feat: add company builder wizard with AI-powered org generation (#73)
* feat: add company builder wizard with AI-powered org generation Adds a new "Build Your Company" step to the onboarding wizard that lets users describe their business and generates a full agent org structure using OpenClaw's AI. Includes company plan generation, role deduplication, agent bootstrap with main-agent reuse, org chart preview, confetti on success, CSS voxel running-avatar loader, amber theme unification, and best-effort SSH workspace cleanup. Made-with: Cursor * fix: resolve lint errors in CompanyBuilderModal Replace setState-in-effect pattern with a direct callback, escape apostrophes in JSX text, and derive org chart hover state without side effects. Made-with: Cursor --------- Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
@@ -0,0 +1,958 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GitBranch, Plus, Sparkles, Trash2, Wand2, X } from "lucide-react";
|
||||
import { AgentAvatarPreview3D } from "@/features/agents/components/AgentAvatarPreview3D";
|
||||
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
|
||||
import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile";
|
||||
import type {
|
||||
CompanyBuilderInput,
|
||||
CompanyBuilderPlan,
|
||||
CompanyBuilderRole,
|
||||
} from "@/features/company-builder/types";
|
||||
|
||||
type CompanyBuilderModalProps = {
|
||||
open: boolean;
|
||||
connected: boolean;
|
||||
agentCount: number;
|
||||
plannerAgentName: string | null;
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
statusLine?: string | null;
|
||||
initialInput?: CompanyBuilderInput;
|
||||
initialPlan?: CompanyBuilderPlan | null;
|
||||
onClose: () => void;
|
||||
onClear: () => void;
|
||||
onImproveBrief: (brief: string) => Promise<string>;
|
||||
onGeneratePlan: (brief: string) => Promise<CompanyBuilderPlan>;
|
||||
onCreateCompany: (params: {
|
||||
input: CompanyBuilderInput;
|
||||
plan: CompanyBuilderPlan;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
const inputClassName =
|
||||
"w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none placeholder:text-white/30";
|
||||
const textareaClassName =
|
||||
"min-h-[120px] w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none placeholder:text-white/30";
|
||||
|
||||
const createEmptyRole = (index: number): CompanyBuilderRole => ({
|
||||
id: `custom-role-${index + 1}`,
|
||||
title: "",
|
||||
purpose: "",
|
||||
soul: "",
|
||||
responsibilities: [],
|
||||
collaborators: [],
|
||||
tools: [],
|
||||
heartbeat: [],
|
||||
emoji: "🤖",
|
||||
creature: "specialist",
|
||||
vibe: "helpful and focused",
|
||||
userContext: "",
|
||||
commandMode: "ask",
|
||||
});
|
||||
|
||||
const parseCommaList = (value: string) =>
|
||||
value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
const joinCommaList = (values: string[]) => values.join(", ");
|
||||
|
||||
const buildRoleAvatarProfile = (role: CompanyBuilderRole) =>
|
||||
createDefaultAgentAvatarProfile(
|
||||
[
|
||||
role.id,
|
||||
role.title,
|
||||
role.emoji,
|
||||
role.creature,
|
||||
role.vibe,
|
||||
role.commandMode,
|
||||
]
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
.join(":") || "company-role"
|
||||
);
|
||||
|
||||
const renderRoleFacts = (label: string, values: string[]) => {
|
||||
if (values.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
{label}
|
||||
</p>
|
||||
<div className="text-xs leading-5 text-white/75">{values.join(", ")}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function CompanyBuilderModal({
|
||||
open,
|
||||
connected,
|
||||
agentCount,
|
||||
plannerAgentName,
|
||||
busy = false,
|
||||
error = null,
|
||||
statusLine = null,
|
||||
initialInput,
|
||||
initialPlan,
|
||||
onClose,
|
||||
onClear,
|
||||
onImproveBrief,
|
||||
onGeneratePlan,
|
||||
onCreateCompany,
|
||||
}: CompanyBuilderModalProps) {
|
||||
const [input, setInput] = useState<CompanyBuilderInput>({
|
||||
businessDescription: initialInput?.businessDescription ?? "",
|
||||
improvedBrief: initialInput?.improvedBrief ?? "",
|
||||
});
|
||||
const [plan, setPlan] = useState<CompanyBuilderPlan | null>(initialPlan ?? null);
|
||||
const [promptModalOpen, setPromptModalOpen] = useState(
|
||||
() =>
|
||||
!(
|
||||
(initialInput?.businessDescription ?? "").trim() ||
|
||||
(initialInput?.improvedBrief ?? "").trim()
|
||||
)
|
||||
);
|
||||
const [promptDraft, setPromptDraft] = useState(initialInput?.businessDescription ?? "");
|
||||
const [replaceConfirmOpen, setReplaceConfirmOpen] = useState(false);
|
||||
const [orgChartOpen, setOrgChartOpen] = useState(false);
|
||||
const [hoveredOrgRoleId, setHoveredOrgRoleId] = useState<string | null>(null);
|
||||
const roleListContainerRef = useRef<HTMLElement | null>(null);
|
||||
const pendingRoleScrollRef = useRef(false);
|
||||
|
||||
const effectiveBrief = useMemo(
|
||||
() => input.improvedBrief.trim() || input.businessDescription.trim(),
|
||||
[input.businessDescription, input.improvedBrief]
|
||||
);
|
||||
const canUseAi = connected && agentCount > 0;
|
||||
const canGenerate = canUseAi && effectiveBrief.length > 0 && !busy;
|
||||
const canPreviewChart = Boolean(plan && plan.roles.length > 0);
|
||||
const canCreate = Boolean(connected && plan && plan.roles.length > 0 && !busy);
|
||||
const canClear = Boolean(
|
||||
!busy &&
|
||||
(input.businessDescription.trim() ||
|
||||
input.improvedBrief.trim() ||
|
||||
promptDraft.trim() ||
|
||||
plan?.roles.length)
|
||||
);
|
||||
const replacesExistingAgents = agentCount > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!plan || !pendingRoleScrollRef.current) return;
|
||||
pendingRoleScrollRef.current = false;
|
||||
requestAnimationFrame(() => {
|
||||
roleListContainerRef.current?.scrollTo({
|
||||
top: roleListContainerRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
}, [plan]);
|
||||
|
||||
const fireAutoGenerate = useCallback(
|
||||
(brief: string) => {
|
||||
void onGeneratePlan(brief)
|
||||
.then((nextPlan) => {
|
||||
setPlan(nextPlan);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to auto-generate company plan.", error);
|
||||
});
|
||||
},
|
||||
[onGeneratePlan],
|
||||
);
|
||||
|
||||
const orgChartDefaultRoleId = orgChartOpen && plan?.roles.length
|
||||
? plan.roles[0]?.id ?? null
|
||||
: null;
|
||||
const resolvedHoveredOrgRoleId =
|
||||
hoveredOrgRoleId && plan?.roles.some((role) => role.id === hoveredOrgRoleId)
|
||||
? hoveredOrgRoleId
|
||||
: orgChartDefaultRoleId;
|
||||
|
||||
const triggerCreateCompany = () => {
|
||||
if (!plan) return;
|
||||
void onCreateCompany({ input, plan }).catch((error) => {
|
||||
console.error("Failed to create company.", error);
|
||||
});
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const hoveredOrgRole =
|
||||
plan?.roles.find((role) => role.id === resolvedHoveredOrgRoleId) ?? plan?.roles[0] ?? null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100100] flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm">
|
||||
<div className="flex h-[min(92vh,920px)] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#090d13] text-white shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-cyan-200/70">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Company Builder
|
||||
</div>
|
||||
<h2 className="mt-1 text-lg font-semibold">Design an AI company from one prompt</h2>
|
||||
<p className="mt-1 text-sm text-white/55">
|
||||
Uses your connected OpenClaw runtime
|
||||
{plannerAgentName ? ` via ${plannerAgentName}.` : "."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs font-semibold text-red-100 transition hover:bg-red-500/20 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
setInput({ businessDescription: "", improvedBrief: "" });
|
||||
setPromptDraft("");
|
||||
setPlan(null);
|
||||
setPromptModalOpen(true);
|
||||
setReplaceConfirmOpen(false);
|
||||
onClear();
|
||||
}}
|
||||
disabled={!canClear}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-amber-500 px-3 py-2 text-xs font-semibold text-[#1a1206] transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
void onGeneratePlan(effectiveBrief)
|
||||
.then((nextPlan) => {
|
||||
setPlan(nextPlan);
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
disabled={!canGenerate}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Generate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/25 bg-cyan-500/10 px-3 py-2 text-xs font-semibold text-cyan-100 transition hover:bg-cyan-500/20 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
setOrgChartOpen(true);
|
||||
}}
|
||||
disabled={!canPreviewChart}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
Org Chart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-md bg-emerald-600 px-3 py-2 text-xs font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
if (!plan) return;
|
||||
if (replacesExistingAgents) {
|
||||
setReplaceConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
triggerCreateCompany();
|
||||
}}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
<Wand2 className="h-3.5 w-3.5" />
|
||||
Create Company
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-white/10 p-2 text-white/60 transition hover:bg-white/5 hover:text-white"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label="Close company builder"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<section className="overflow-y-auto border-b border-white/10 px-6 py-5 lg:border-b-0 lg:border-r">
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||
Source prompt
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-2.5 py-1.5 text-[11px] font-semibold text-cyan-100 transition hover:bg-cyan-500/20 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
setPromptDraft(input.businessDescription);
|
||||
setPromptModalOpen(true);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<Wand2 className="h-3 w-3" />
|
||||
{input.businessDescription.trim() ? "Edit prompt" : "Describe company"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-white/70">
|
||||
{input.businessDescription.trim()
|
||||
? input.businessDescription
|
||||
: "Describe what the company should do and Claw3D will immediately turn it into an improved brief."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||
Improved Brief
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-white/45">
|
||||
This is the text used for company generation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className={`${textareaClassName} mt-3 min-h-[340px]`}
|
||||
placeholder="AI will rewrite the brief here."
|
||||
value={input.improvedBrief}
|
||||
onChange={(event) =>
|
||||
setInput((current) => ({
|
||||
...current,
|
||||
improvedBrief: event.target.value,
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-white/60">
|
||||
Company Actions
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-white/45">
|
||||
Generate the org, then create it in OpenClaw.
|
||||
</p>
|
||||
</div>
|
||||
{replacesExistingAgents ? (
|
||||
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs leading-5 text-amber-100/85">
|
||||
Your current {agentCount === 1 ? "agent will" : `${agentCount} agents will`} be
|
||||
deleted and replaced by this company when you create it. This action is
|
||||
irreversible and will delete the old agents' workspaces.
|
||||
</div>
|
||||
) : null}
|
||||
{!canUseAi ? (
|
||||
<p className="text-xs text-amber-200/80">
|
||||
Connect to OpenClaw and keep at least one available planning agent in the fleet
|
||||
to use AI suggestions.
|
||||
</p>
|
||||
) : null}
|
||||
{statusLine ? <p className="text-xs text-cyan-100/75">{statusLine}</p> : null}
|
||||
{error ? <p className="text-xs text-red-200">{error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref={roleListContainerRef} className="min-h-0 overflow-y-auto px-6 py-5">
|
||||
{plan ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Company name
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={plan.companyName}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
companyName: event.target.value,
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Shared rules
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={joinCommaList(plan.sharedRules)}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
sharedRules: parseCommaList(event.target.value),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Company summary
|
||||
<textarea
|
||||
className={`${textareaClassName} min-h-[110px]`}
|
||||
value={plan.summary}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
summary: event.target.value,
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Org structure</p>
|
||||
<p className="text-xs text-white/55">
|
||||
Edit the team before creating agents in OpenClaw.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold text-white transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
pendingRoleScrollRef.current = true;
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: [...current.roles, createEmptyRole(current.roles.length)],
|
||||
}
|
||||
: current
|
||||
);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add role
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{plan.roles.map((role, index) => (
|
||||
<div
|
||||
key={role.id || `role-${index}`}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.03] p-4"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<div className="h-28 w-24 overflow-hidden rounded-xl border border-white/10 bg-[#070b16]">
|
||||
<AgentAvatarPreview3D
|
||||
profile={buildRoleAvatarProfile(role)}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.14em] text-white/45">
|
||||
Role {index + 1}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
{role.title || "Untitled role"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-white/45">
|
||||
3D avatar preview generated for this role.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-red-500/20 bg-red-500/10 p-2 text-red-100 transition hover:bg-red-500/20 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.filter((_, roleIndex) => roleIndex !== index),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy || plan.roles.length <= 1}
|
||||
aria-label={`Remove role ${index + 1}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Name
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={role.title}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((entry, roleIndex) =>
|
||||
roleIndex === index
|
||||
? { ...entry, title: event.target.value }
|
||||
: entry
|
||||
),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Emoji
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={role.emoji}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((entry, roleIndex) =>
|
||||
roleIndex === index
|
||||
? { ...entry, emoji: event.target.value }
|
||||
: entry
|
||||
),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Purpose
|
||||
<textarea
|
||||
className={textareaClassName}
|
||||
value={role.purpose}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((entry, roleIndex) =>
|
||||
roleIndex === index
|
||||
? { ...entry, purpose: event.target.value }
|
||||
: entry
|
||||
),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Soul
|
||||
<textarea
|
||||
className={textareaClassName}
|
||||
value={role.soul}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((entry, roleIndex) =>
|
||||
roleIndex === index
|
||||
? { ...entry, soul: event.target.value }
|
||||
: entry
|
||||
),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Responsibilities
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={joinCommaList(role.responsibilities)}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((entry, roleIndex) =>
|
||||
roleIndex === index
|
||||
? {
|
||||
...entry,
|
||||
responsibilities: parseCommaList(event.target.value),
|
||||
}
|
||||
: entry
|
||||
),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Collaborators
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={joinCommaList(role.collaborators)}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((entry, roleIndex) =>
|
||||
roleIndex === index
|
||||
? {
|
||||
...entry,
|
||||
collaborators: parseCommaList(event.target.value),
|
||||
}
|
||||
: entry
|
||||
),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Tool notes
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={joinCommaList(role.tools)}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((entry, roleIndex) =>
|
||||
roleIndex === index
|
||||
? { ...entry, tools: parseCommaList(event.target.value) }
|
||||
: entry
|
||||
),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-white/60">
|
||||
Heartbeat checklist
|
||||
<input
|
||||
className={inputClassName}
|
||||
value={joinCommaList(role.heartbeat)}
|
||||
onChange={(event) =>
|
||||
setPlan((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
roles: current.roles.map((entry, roleIndex) =>
|
||||
roleIndex === index
|
||||
? { ...entry, heartbeat: parseCommaList(event.target.value) }
|
||||
: entry
|
||||
),
|
||||
}
|
||||
: current
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-8 text-center">
|
||||
<div className="max-w-md space-y-3">
|
||||
<Sparkles className="mx-auto h-8 w-8 text-cyan-300/70" />
|
||||
<p className="text-lg font-semibold text-white">No company generated yet</p>
|
||||
<p className="text-sm text-white/55">
|
||||
Start by describing the company. Claw3D will create the improved brief
|
||||
automatically, then you can generate and edit the org structure before anything
|
||||
is created.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{busy ? (
|
||||
<div className="fixed inset-0 z-[100120] flex items-center justify-center bg-black/80 backdrop-blur-md">
|
||||
<div className="w-full max-w-md rounded-2xl border border-cyan-500/20 bg-[#08111a] px-6 py-6 text-center shadow-2xl">
|
||||
<RunningAvatarLoader size={40} trackWidth={104} />
|
||||
<p className="mt-4 text-sm font-semibold text-white">
|
||||
{statusLine?.trim() || "Working on your company."}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-white/55">
|
||||
Claw3D is using your OpenClaw runtime right now. Please wait until this finishes.
|
||||
</p>
|
||||
<div className="mt-5 flex gap-2">
|
||||
{Array.from({ length: 4 }, (_, index) => (
|
||||
<span
|
||||
key={`company-loading-${index}`}
|
||||
className="h-1.5 flex-1 rounded-full bg-cyan-400/30 animate-pulse"
|
||||
style={{ animationDelay: `${index * 120}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{replaceConfirmOpen ? (
|
||||
<div className="fixed inset-0 z-[100115] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg rounded-2xl border border-white/10 bg-[#0b1119] p-6 shadow-2xl">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-white">Replace current agents?</p>
|
||||
<p className="text-sm leading-6 text-white/65">
|
||||
Your current {agentCount === 1 ? "agent will" : `${agentCount} agents will`} be
|
||||
deleted and replaced by this new company. This action is irreversible and will
|
||||
delete the old agents' workspaces. Are you sure you want to continue?
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/10"
|
||||
onClick={() => {
|
||||
setReplaceConfirmOpen(false);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-[#1a1206] transition hover:bg-amber-400"
|
||||
onClick={() => {
|
||||
setReplaceConfirmOpen(false);
|
||||
triggerCreateCompany();
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
Create Company
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{orgChartOpen && plan ? (
|
||||
<div className="fixed inset-0 z-[100112] flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm">
|
||||
<div className="flex h-[min(88vh,860px)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#0b1119] shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-6 py-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200/70">
|
||||
Org Chart Preview
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-white/60">
|
||||
Hover any avatar to inspect the role brief, responsibilities, and collaborators.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-white/10 p-2 text-white/60 transition hover:bg-white/5 hover:text-white"
|
||||
onClick={() => {
|
||||
setOrgChartOpen(false);
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label="Close org chart preview"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="mx-auto grid max-w-5xl gap-6 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="flex min-h-0 flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full max-w-xs flex-col items-center rounded-2xl border px-5 py-5 text-center transition ${
|
||||
resolvedHoveredOrgRoleId === plan.roles[0]?.id
|
||||
? "border-cyan-400/40 bg-cyan-500/12"
|
||||
: "border-cyan-500/20 bg-cyan-500/10 hover:border-cyan-300/35"
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
setHoveredOrgRoleId(plan.roles[0]?.id ?? null);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setHoveredOrgRoleId(plan.roles[0]?.id ?? null);
|
||||
}}
|
||||
>
|
||||
<div className="h-28 w-24 overflow-hidden rounded-xl border border-white/10 bg-[#070b16]">
|
||||
<AgentAvatarPreview3D
|
||||
profile={buildRoleAvatarProfile(plan.roles[0])}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-xs uppercase tracking-[0.14em] text-cyan-100/65">Role 1</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">
|
||||
{plan.roles[0].title || "Untitled role"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-white/60">
|
||||
{plan.roles[0].purpose || "No purpose yet."}
|
||||
</p>
|
||||
</button>
|
||||
{plan.roles.length > 1 ? (
|
||||
<>
|
||||
<div className="h-10 w-px bg-white/10" />
|
||||
<div className="mb-8 h-px w-[min(100%,720px)] bg-white/10" />
|
||||
<div className="grid w-full max-w-4xl gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{plan.roles.slice(1).map((role, index) => (
|
||||
<button
|
||||
key={role.id || `org-chart-role-${index + 2}`}
|
||||
type="button"
|
||||
className={`flex flex-col items-center rounded-2xl border px-4 py-5 text-center transition ${
|
||||
resolvedHoveredOrgRoleId === role.id
|
||||
? "border-cyan-400/40 bg-cyan-500/10"
|
||||
: "border-white/10 bg-white/[0.03] hover:border-cyan-300/25"
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
setHoveredOrgRoleId(role.id);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setHoveredOrgRoleId(role.id);
|
||||
}}
|
||||
>
|
||||
<div className="h-24 w-20 overflow-hidden rounded-xl border border-white/10 bg-[#070b16]">
|
||||
<AgentAvatarPreview3D
|
||||
profile={buildRoleAvatarProfile(role)}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-xs uppercase tracking-[0.14em] text-white/45">
|
||||
Role {index + 2}
|
||||
</p>
|
||||
<p className="mt-1 text-base font-semibold text-white">
|
||||
{role.title || "Untitled role"}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-white/60">
|
||||
{role.purpose || "No purpose yet."}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<aside className="rounded-2xl border border-white/10 bg-[#08111a] p-5 lg:sticky lg:top-0 lg:h-fit">
|
||||
{hoveredOrgRole ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Active Role
|
||||
</p>
|
||||
<p className="mt-2 text-xl font-semibold text-white">
|
||||
{hoveredOrgRole.title || "Untitled role"}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-white/70">
|
||||
{hoveredOrgRole.soul || "No soul notes yet."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-cyan-100/65">
|
||||
Purpose
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-white/75">
|
||||
{hoveredOrgRole.purpose || "No purpose yet."}
|
||||
</p>
|
||||
</div>
|
||||
{renderRoleFacts("Responsibilities", hoveredOrgRole.responsibilities)}
|
||||
{renderRoleFacts("Collaborators", hoveredOrgRole.collaborators)}
|
||||
{renderRoleFacts("Tools", hoveredOrgRole.tools)}
|
||||
{renderRoleFacts("Heartbeat", hoveredOrgRole.heartbeat)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-white/55">Hover a role to inspect it.</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{promptModalOpen ? (
|
||||
<div className="fixed inset-0 z-[100110] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl rounded-2xl border border-white/10 bg-[#0b1119] p-6 shadow-2xl">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200/70">
|
||||
What should the company do?
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-white/55">
|
||||
As soon as you submit this, OpenClaw will improve the brief automatically.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-white/10 p-2 text-white/60 transition hover:bg-white/5 hover:text-white"
|
||||
onClick={() => {
|
||||
if (busy) return;
|
||||
setPromptModalOpen(false);
|
||||
}}
|
||||
disabled={busy}
|
||||
aria-label="Close prompt modal"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className={`${textareaClassName} mt-5 min-h-[220px]`}
|
||||
placeholder="I run a web design company that builds websites, web apps, mobile apps, SEO campaigns, and social media services..."
|
||||
value={promptDraft}
|
||||
onChange={(event) => {
|
||||
setPromptDraft(event.target.value);
|
||||
}}
|
||||
disabled={busy}
|
||||
/>
|
||||
<div className="mt-5 flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-white/45">
|
||||
The improved brief becomes the main editable input for generation.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-[#1a1206] transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
const trimmedPrompt = promptDraft.trim();
|
||||
if (!trimmedPrompt) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const improvedBrief = await onImproveBrief(trimmedPrompt);
|
||||
setInput({
|
||||
businessDescription: trimmedPrompt,
|
||||
improvedBrief,
|
||||
});
|
||||
setPromptModalOpen(false);
|
||||
fireAutoGenerate(improvedBrief);
|
||||
} catch {}
|
||||
})();
|
||||
}}
|
||||
disabled={!canUseAi || promptDraft.trim().length === 0 || busy}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Generate Company
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { buildCompanyAgentBlueprints } from "@/features/company-builder/planning";
|
||||
import {
|
||||
buildCompanyRolePermissionsDraft,
|
||||
} from "@/features/company-builder/operations/companyBuilderGateway";
|
||||
import type {
|
||||
CompanyBuilderInput,
|
||||
CompanyBuilderPlan,
|
||||
} from "@/features/company-builder/types";
|
||||
import type { AgentFileName } from "@/lib/agents/agentFiles";
|
||||
import type { CommandModeId } from "@/features/agents/operations/agentPermissionsOperation";
|
||||
|
||||
type CreatedAgentRecord = {
|
||||
agentId: string;
|
||||
commandMode: CommandModeId;
|
||||
};
|
||||
|
||||
export async function runCompanyBootstrapOperation(params: {
|
||||
input: CompanyBuilderInput;
|
||||
plan: CompanyBuilderPlan;
|
||||
existingAgentIds?: string[];
|
||||
deleteExistingAgent?: (agentId: string) => Promise<void>;
|
||||
clearReusedAgentState?: (agentId: string) => Promise<void>;
|
||||
renameAgent?: (agentId: string, name: string) => Promise<void>;
|
||||
onExistingAgentDeleted?: (agentId: string) => void;
|
||||
createAgent: (name: string) => Promise<{ id: string }>;
|
||||
writeAgentFiles: (
|
||||
agentId: string,
|
||||
files: Record<AgentFileName, string>,
|
||||
) => Promise<void>;
|
||||
saveAvatar: (agentId: string) => void;
|
||||
loadAgents: () => Promise<void>;
|
||||
findAgentById: (agentId: string) => { agentId: string; sessionKey: string } | null;
|
||||
resetAgentSession?: (agentId: string, sessionKey: string) => Promise<void>;
|
||||
applyPermissions: (
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
commandMode: CommandModeId,
|
||||
) => Promise<void>;
|
||||
persistSnapshot: (input: CompanyBuilderInput, plan: CompanyBuilderPlan) => void;
|
||||
setOfficeTitle: (title: string) => void;
|
||||
selectAgent: (agentId: string) => void;
|
||||
setStatusLine: (value: string | null) => void;
|
||||
}): Promise<string[]> {
|
||||
const blueprints = buildCompanyAgentBlueprints(params.plan);
|
||||
const existingAgentIds = params.existingAgentIds ?? [];
|
||||
const reusableAgentId = existingAgentIds.includes("main") && blueprints[0] ? "main" : null;
|
||||
const deletableAgentIds = existingAgentIds.filter((agentId) => agentId !== reusableAgentId);
|
||||
|
||||
if (deletableAgentIds.length > 0 && params.deleteExistingAgent) {
|
||||
params.setStatusLine(
|
||||
deletableAgentIds.length === 1
|
||||
? "Replacing your current agent."
|
||||
: `Replacing your current ${deletableAgentIds.length} agents.`,
|
||||
);
|
||||
for (const agentId of deletableAgentIds) {
|
||||
await params.deleteExistingAgent(agentId);
|
||||
params.onExistingAgentDeleted?.(agentId);
|
||||
}
|
||||
}
|
||||
const createdAgents: CreatedAgentRecord[] = [];
|
||||
const remainingBlueprints = [...blueprints];
|
||||
|
||||
if (reusableAgentId && remainingBlueprints[0]) {
|
||||
const firstBlueprint = remainingBlueprints.shift();
|
||||
if (firstBlueprint) {
|
||||
if (params.clearReusedAgentState) {
|
||||
params.setStatusLine("Clearing the previous main agent state.");
|
||||
await params.clearReusedAgentState(reusableAgentId);
|
||||
}
|
||||
params.setStatusLine(`Reconfiguring main as ${firstBlueprint.agentName}.`);
|
||||
await params.renameAgent?.(reusableAgentId, firstBlueprint.agentName);
|
||||
await params.writeAgentFiles(reusableAgentId, firstBlueprint.files);
|
||||
params.saveAvatar(reusableAgentId);
|
||||
createdAgents.push({
|
||||
agentId: reusableAgentId,
|
||||
commandMode: firstBlueprint.role.commandMode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const blueprint of remainingBlueprints) {
|
||||
params.setStatusLine(`Creating ${blueprint.agentName}.`);
|
||||
const created = await params.createAgent(blueprint.agentName);
|
||||
createdAgents.push({
|
||||
agentId: created.id,
|
||||
commandMode: blueprint.role.commandMode,
|
||||
});
|
||||
await params.writeAgentFiles(created.id, blueprint.files);
|
||||
params.saveAvatar(created.id);
|
||||
}
|
||||
|
||||
params.setStatusLine("Syncing the new company into the office.");
|
||||
await params.loadAgents();
|
||||
|
||||
for (const createdAgent of createdAgents) {
|
||||
const liveAgent = params.findAgentById(createdAgent.agentId);
|
||||
if (!liveAgent?.sessionKey) continue;
|
||||
await params.applyPermissions(
|
||||
liveAgent.agentId,
|
||||
liveAgent.sessionKey,
|
||||
createdAgent.commandMode,
|
||||
);
|
||||
}
|
||||
|
||||
if (reusableAgentId && params.resetAgentSession) {
|
||||
const reusableAgent = params.findAgentById(reusableAgentId);
|
||||
if (reusableAgent?.sessionKey) {
|
||||
params.setStatusLine("Refreshing the first role session.");
|
||||
await params.resetAgentSession(reusableAgentId, reusableAgent.sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
params.persistSnapshot(params.input, params.plan);
|
||||
params.setOfficeTitle(`${params.plan.companyName} HQ`);
|
||||
if (createdAgents[0]?.agentId) {
|
||||
params.selectAgent(createdAgents[0].agentId);
|
||||
}
|
||||
params.setStatusLine(null);
|
||||
return createdAgents.map((entry) => entry.agentId);
|
||||
}
|
||||
|
||||
export { buildCompanyRolePermissionsDraft };
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AgentPermissionsDraft } from "@/features/agents/operations/agentPermissionsOperation";
|
||||
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
|
||||
import { buildHistoryLines, type ChatHistoryMessage } from "@/features/agents/state/runtimeEventBridge";
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import type { CommandModeId } from "@/features/agents/operations/agentPermissionsOperation";
|
||||
import type { TranscriptAppendMeta } from "@/features/agents/state/transcript";
|
||||
|
||||
type GatewayClientLike = {
|
||||
call: <T = unknown>(method: string, params: unknown) => Promise<T>;
|
||||
};
|
||||
|
||||
type DispatchAction =
|
||||
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
|
||||
| { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta };
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
export const resolveCompanyPlanningAgent = (params: {
|
||||
agents: AgentState[];
|
||||
preferredAgentId?: string | null;
|
||||
}) => {
|
||||
const preferred = params.preferredAgentId?.trim() ?? "";
|
||||
if (preferred) {
|
||||
const preferredAgent = params.agents.find((entry) => entry.agentId === preferred) ?? null;
|
||||
if (preferredAgent) return preferredAgent;
|
||||
}
|
||||
return params.agents[0] ?? null;
|
||||
};
|
||||
|
||||
export const buildCompanyRolePermissionsDraft = (
|
||||
commandMode: CommandModeId
|
||||
): AgentPermissionsDraft => ({
|
||||
commandMode,
|
||||
webAccess: commandMode !== "off",
|
||||
fileTools: commandMode !== "off",
|
||||
});
|
||||
|
||||
export async function runOpenClawPlanningPrompt(params: {
|
||||
client: GatewayClientLike;
|
||||
dispatch: (action: DispatchAction) => void;
|
||||
agent: AgentState;
|
||||
getAgent: (agentId: string) => AgentState | null;
|
||||
clearRunTracking?: (runId: string) => void;
|
||||
prompt: string;
|
||||
historyLimit?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<string> {
|
||||
const trimmedPrompt = params.prompt.trim();
|
||||
if (!trimmedPrompt) {
|
||||
throw new Error("Planning prompt is required.");
|
||||
}
|
||||
if (params.agent.status === "running") {
|
||||
throw new Error(`Wait for ${params.agent.name} to finish the current run first.`);
|
||||
}
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: params.client,
|
||||
dispatch: params.dispatch,
|
||||
getAgent: params.getAgent,
|
||||
agentId: params.agent.agentId,
|
||||
sessionKey: params.agent.sessionKey,
|
||||
message: trimmedPrompt,
|
||||
clearRunTracking: params.clearRunTracking,
|
||||
echoUserMessage: false,
|
||||
});
|
||||
|
||||
const timeoutAt = Date.now() + (params.timeoutMs ?? 90_000);
|
||||
while (Date.now() < timeoutAt) {
|
||||
const liveAgent = params.getAgent(params.agent.agentId);
|
||||
if (liveAgent && liveAgent.status !== "running") {
|
||||
const history = await params.client.call<{
|
||||
sessionKey: string;
|
||||
messages: ChatHistoryMessage[];
|
||||
}>("chat.history", {
|
||||
sessionKey: params.agent.sessionKey,
|
||||
limit: params.historyLimit ?? 80,
|
||||
});
|
||||
const derived = buildHistoryLines(history.messages ?? []);
|
||||
if (derived.lastAssistant?.trim()) {
|
||||
return derived.lastAssistant.trim();
|
||||
}
|
||||
throw new Error("The planning agent finished, but no assistant response was available.");
|
||||
}
|
||||
await sleep(800);
|
||||
}
|
||||
|
||||
throw new Error("Timed out while waiting for the planning agent response.");
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import {
|
||||
type CommandModeId,
|
||||
} from "@/features/agents/operations/agentPermissionsOperation";
|
||||
import {
|
||||
createEmptyPersonalityDraft,
|
||||
serializePersonalityFiles,
|
||||
type PersonalityBuilderDraft,
|
||||
} from "@/lib/agents/personalityBuilder";
|
||||
import type { AgentFileName } from "@/lib/agents/agentFiles";
|
||||
import type {
|
||||
CompanyAgentBlueprint,
|
||||
CompanyBuilderPlan,
|
||||
CompanyBuilderRole,
|
||||
CompanyBuilderStoredSnapshot,
|
||||
} from "@/features/company-builder/types";
|
||||
|
||||
type ParsedCompanyPlan = {
|
||||
companyName?: unknown;
|
||||
summary?: unknown;
|
||||
sharedRules?: unknown;
|
||||
plannerNotes?: unknown;
|
||||
roles?: unknown;
|
||||
};
|
||||
|
||||
type ParsedCompanyRole = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
title?: unknown;
|
||||
purpose?: unknown;
|
||||
soul?: unknown;
|
||||
responsibilities?: unknown;
|
||||
collaborators?: unknown;
|
||||
tools?: unknown;
|
||||
heartbeat?: unknown;
|
||||
emoji?: unknown;
|
||||
creature?: unknown;
|
||||
vibe?: unknown;
|
||||
userContext?: unknown;
|
||||
commandMode?: unknown;
|
||||
};
|
||||
|
||||
const COMPANY_FENCE_RE = /^```(?:json)?\s*|\s*```$/gim;
|
||||
const MAX_ROLE_COUNT = 8;
|
||||
|
||||
const normalizeLine = (value: string) => value.replace(/\r\n/g, "\n").trim();
|
||||
|
||||
const coerceString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
const coerceStringArray = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
};
|
||||
|
||||
const uniqueStrings = (values: string[]) => Array.from(new Set(values));
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
const toPascalCaseWord = (value: string) =>
|
||||
value
|
||||
.replace(/[^a-zA-Z0-9]+/g, " ")
|
||||
.split(/\s+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
.map((entry) => `${entry.charAt(0).toUpperCase()}${entry.slice(1).toLowerCase()}`)
|
||||
.join("");
|
||||
|
||||
const normalizeRoleName = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
const singleWord = trimmed.replace(/[^a-zA-Z0-9]/g, "");
|
||||
if (singleWord.length > 0 && !/\s/.test(trimmed)) {
|
||||
return singleWord.slice(0, 18);
|
||||
}
|
||||
const compact = toPascalCaseWord(trimmed);
|
||||
return compact.slice(0, 18);
|
||||
};
|
||||
|
||||
const dedupeCompactNames = (values: string[], fallbackPrefix: string) => {
|
||||
const used = new Set<string>();
|
||||
return values.map((value, index) => {
|
||||
const fallback = `${fallbackPrefix}${index + 1}`;
|
||||
const baseName = normalizeRoleName(value) || normalizeRoleName(fallback) || fallback;
|
||||
let nextName = baseName;
|
||||
let suffix = 2;
|
||||
while (used.has(nextName.toLowerCase())) {
|
||||
const suffixText = String(suffix);
|
||||
const trimmedBase = baseName.slice(0, Math.max(1, 18 - suffixText.length));
|
||||
nextName = `${trimmedBase}${suffixText}`;
|
||||
suffix += 1;
|
||||
}
|
||||
used.add(nextName.toLowerCase());
|
||||
return nextName;
|
||||
});
|
||||
};
|
||||
|
||||
const toSentenceList = (values: string[]) =>
|
||||
values
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
.map((entry) => (/[.!?]$/.test(entry) ? entry : `${entry}.`));
|
||||
|
||||
const resolveCommandMode = (value: unknown, roleText: string): CommandModeId => {
|
||||
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
if (normalized === "off" || normalized === "ask" || normalized === "auto") {
|
||||
return normalized;
|
||||
}
|
||||
const lowered = roleText.toLowerCase();
|
||||
if (/\b(developer|engineer|automation|devops|ops)\b/.test(lowered)) {
|
||||
return "auto";
|
||||
}
|
||||
if (/\b(manager|lead|qa|support|analyst|marketing|social)\b/.test(lowered)) {
|
||||
return "ask";
|
||||
}
|
||||
return "ask";
|
||||
};
|
||||
|
||||
const buildRoleIdentity = (role: CompanyBuilderRole) => ({
|
||||
emoji: role.emoji || "🤖",
|
||||
creature: role.creature || "specialist",
|
||||
vibe: role.vibe || "helpful and focused",
|
||||
});
|
||||
|
||||
const buildRoleAgentsMarkdown = (params: {
|
||||
plan: CompanyBuilderPlan;
|
||||
role: CompanyBuilderRole;
|
||||
}) => {
|
||||
const collaborators =
|
||||
params.role.collaborators.length > 0
|
||||
? params.role.collaborators
|
||||
: params.plan.roles
|
||||
.filter((entry) => entry.id !== params.role.id)
|
||||
.slice(0, 3)
|
||||
.map((entry) => entry.title);
|
||||
const responsibilityLines = toSentenceList(params.role.responsibilities);
|
||||
const sharedRules = toSentenceList(params.plan.sharedRules);
|
||||
const plannerNotes = toSentenceList(params.plan.plannerNotes);
|
||||
return [
|
||||
`# ${params.plan.companyName} Team Operating Guide`,
|
||||
"",
|
||||
`You are the ${params.role.title} inside ${params.plan.companyName}.`,
|
||||
"",
|
||||
"## Mission",
|
||||
"",
|
||||
params.role.purpose || `Own the ${params.role.title.toLowerCase()} function for the company.`,
|
||||
"",
|
||||
"## Responsibilities",
|
||||
"",
|
||||
...(responsibilityLines.length > 0
|
||||
? responsibilityLines.map((entry) => `- ${entry}`)
|
||||
: ["- Keep your area moving and surface blockers quickly."]),
|
||||
"",
|
||||
"## Collaborators",
|
||||
"",
|
||||
...(collaborators.length > 0
|
||||
? collaborators.map((entry) => `- Work closely with ${entry}.`)
|
||||
: ["- Coordinate with the rest of the company when work crosses team boundaries."]),
|
||||
"",
|
||||
"## Shared Rules",
|
||||
"",
|
||||
...(sharedRules.length > 0
|
||||
? sharedRules.map((entry) => `- ${entry}`)
|
||||
: [
|
||||
"- Keep updates concise, practical, and action-oriented.",
|
||||
"- Hand off work clearly when another role should take over.",
|
||||
]),
|
||||
"",
|
||||
"## Planning Notes",
|
||||
"",
|
||||
...(plannerNotes.length > 0
|
||||
? plannerNotes.map((entry) => `- ${entry}`)
|
||||
: ["- Treat the user's company brief as the source of truth."]),
|
||||
"",
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const buildRoleToolsMarkdown = (role: CompanyBuilderRole) =>
|
||||
[
|
||||
"# TOOLS.md",
|
||||
"",
|
||||
`Preferred operating mode: ${role.commandMode}.`,
|
||||
"",
|
||||
"## Tool Preferences",
|
||||
"",
|
||||
...(role.tools.length > 0
|
||||
? role.tools.map((entry) => `- ${entry}.`)
|
||||
: [
|
||||
"- Use the tools that best match your role.",
|
||||
"- Ask for help when another teammate has better context.",
|
||||
]),
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const buildRoleHeartbeatMarkdown = (role: CompanyBuilderRole) =>
|
||||
[
|
||||
"# HEARTBEAT.md",
|
||||
"",
|
||||
"When your heartbeat runs:",
|
||||
"",
|
||||
...(role.heartbeat.length > 0
|
||||
? role.heartbeat.map((entry) => `- ${entry}.`)
|
||||
: [
|
||||
"- Check your most important queue or active work.",
|
||||
"- Report blockers before they become expensive.",
|
||||
"- Coordinate with collaborators if handoffs are waiting.",
|
||||
]),
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const buildRoleMemoryMarkdown = (params: {
|
||||
plan: CompanyBuilderPlan;
|
||||
role: CompanyBuilderRole;
|
||||
}) =>
|
||||
[
|
||||
"# MEMORY.md",
|
||||
"",
|
||||
`Company: ${params.plan.companyName}.`,
|
||||
`Role: ${params.role.title}.`,
|
||||
"",
|
||||
"Remember:",
|
||||
"",
|
||||
`- ${params.plan.summary || "The company plan should guide your decisions."}`,
|
||||
`- ${params.role.purpose || "Protect the quality of your function."}`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const buildRoleSoulDraft = (params: {
|
||||
plan: CompanyBuilderPlan;
|
||||
role: CompanyBuilderRole;
|
||||
}): PersonalityBuilderDraft => {
|
||||
const draft = createEmptyPersonalityDraft();
|
||||
const identity = buildRoleIdentity(params.role);
|
||||
draft.identity.name = params.role.title;
|
||||
draft.identity.emoji = identity.emoji;
|
||||
draft.identity.creature = identity.creature;
|
||||
draft.identity.vibe = identity.vibe;
|
||||
draft.user.context = normalizeLine(
|
||||
[
|
||||
`Company brief: ${params.plan.summary}`,
|
||||
params.role.userContext,
|
||||
]
|
||||
.filter((entry) => entry.trim().length > 0)
|
||||
.join("\n\n")
|
||||
);
|
||||
draft.soul.coreTruths = normalizeLine(
|
||||
[
|
||||
params.role.soul,
|
||||
`Your job is to help ${params.plan.companyName} succeed as the ${params.role.title}.`,
|
||||
]
|
||||
.filter((entry) => entry.trim().length > 0)
|
||||
.join("\n\n")
|
||||
);
|
||||
draft.soul.boundaries = normalizeLine(
|
||||
[
|
||||
"Do not invent decisions that should be handed to another specialist.",
|
||||
"Escalate blockers early and keep handoffs explicit.",
|
||||
].join("\n")
|
||||
);
|
||||
draft.soul.vibe = normalizeLine(
|
||||
params.role.vibe || `${params.role.title} energy: practical, collaborative, and sharp.`
|
||||
);
|
||||
draft.soul.continuity = normalizeLine(
|
||||
`Keep continuity around ${params.plan.companyName}'s goals, teammates, and operating rules.`
|
||||
);
|
||||
draft.agents = buildRoleAgentsMarkdown(params);
|
||||
draft.tools = buildRoleToolsMarkdown(params.role);
|
||||
draft.heartbeat = buildRoleHeartbeatMarkdown(params.role);
|
||||
draft.memory = buildRoleMemoryMarkdown(params);
|
||||
return draft;
|
||||
};
|
||||
|
||||
const normalizeRole = (value: ParsedCompanyRole, index: number): CompanyBuilderRole | null => {
|
||||
const title = normalizeRoleName(coerceString(value.name) || coerceString(value.title));
|
||||
if (!title) return null;
|
||||
const purpose = coerceString(value.purpose);
|
||||
const soul = coerceString(value.soul);
|
||||
const responsibilities = uniqueStrings(coerceStringArray(value.responsibilities)).slice(0, 8);
|
||||
const collaborators = uniqueStrings(coerceStringArray(value.collaborators)).slice(0, 8);
|
||||
const tools = uniqueStrings(coerceStringArray(value.tools)).slice(0, 8);
|
||||
const heartbeat = uniqueStrings(coerceStringArray(value.heartbeat)).slice(0, 8);
|
||||
const emoji = coerceString(value.emoji);
|
||||
const creature = coerceString(value.creature);
|
||||
const vibe = coerceString(value.vibe);
|
||||
const userContext = coerceString(value.userContext);
|
||||
const id = coerceString(value.id) || slugify(title) || `role-${index + 1}`;
|
||||
const roleText = [title, purpose, soul, ...responsibilities, ...tools].join(" ");
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
purpose,
|
||||
soul,
|
||||
responsibilities,
|
||||
collaborators,
|
||||
tools,
|
||||
heartbeat,
|
||||
emoji,
|
||||
creature,
|
||||
vibe,
|
||||
userContext,
|
||||
commandMode: resolveCommandMode(value.commandMode, roleText),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildImproveCompanyBriefPrompt = (businessDescription: string) =>
|
||||
[
|
||||
"You are helping a user describe the company they want to build inside Claw3D.",
|
||||
"Rewrite their brief so another OpenClaw agent can generate a clean org structure from it.",
|
||||
"Keep the answer short, concrete, and useful.",
|
||||
"Return markdown with these sections only:",
|
||||
"## Company",
|
||||
"## Goals",
|
||||
"## Constraints",
|
||||
"## Suggested Roles",
|
||||
"",
|
||||
"User brief:",
|
||||
businessDescription.trim(),
|
||||
].join("\n");
|
||||
|
||||
export const buildGenerateCompanyPlanPrompt = (brief: string) =>
|
||||
[
|
||||
"You are designing an AI company org structure for Claw3D.",
|
||||
"Return only valid JSON with no markdown fence.",
|
||||
"Each role name must be one concise word only with no spaces.",
|
||||
"Schema:",
|
||||
"{",
|
||||
' "companyName": "string",',
|
||||
' "summary": "string",',
|
||||
' "sharedRules": ["string"],',
|
||||
' "plannerNotes": ["string"],',
|
||||
' "roles": [',
|
||||
" {",
|
||||
' "id": "string",',
|
||||
' "name": "string",',
|
||||
' "purpose": "string",',
|
||||
' "soul": "string",',
|
||||
' "responsibilities": ["string"],',
|
||||
' "collaborators": ["string"],',
|
||||
' "tools": ["string"],',
|
||||
' "heartbeat": ["string"],',
|
||||
' "emoji": "string",',
|
||||
' "creature": "string",',
|
||||
' "vibe": "string",',
|
||||
' "userContext": "string",',
|
||||
' "commandMode": "off|ask|auto"',
|
||||
" }",
|
||||
" ]",
|
||||
"}",
|
||||
"Create between 2 and 6 roles unless the brief clearly needs more or less.",
|
||||
"Prefer silly but useful role titles when it helps the brand, but keep the org practical.",
|
||||
"Role names should be short single words like Builder, Analyst, Closer, Captain, Scout, or Designer.",
|
||||
"All role names must be unique.",
|
||||
"Make collaborators reference role names.",
|
||||
"",
|
||||
"Company brief:",
|
||||
brief.trim(),
|
||||
].join("\n");
|
||||
|
||||
export const extractJsonFromAssistantText = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("The planning agent returned an empty response.");
|
||||
}
|
||||
const unfenced = trimmed.replace(COMPANY_FENCE_RE, "").trim();
|
||||
const firstBrace = unfenced.indexOf("{");
|
||||
const lastBrace = unfenced.lastIndexOf("}");
|
||||
if (firstBrace < 0 || lastBrace < firstBrace) {
|
||||
throw new Error("The planning agent did not return valid JSON.");
|
||||
}
|
||||
return unfenced.slice(firstBrace, lastBrace + 1);
|
||||
};
|
||||
|
||||
export const parseCompanyPlanFromAssistantText = (value: string): CompanyBuilderPlan => {
|
||||
let parsed: ParsedCompanyPlan;
|
||||
try {
|
||||
parsed = JSON.parse(extractJsonFromAssistantText(value)) as ParsedCompanyPlan;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Failed to parse the planning agent response.");
|
||||
}
|
||||
|
||||
const rolesRaw = Array.isArray(parsed.roles) ? parsed.roles : [];
|
||||
const normalizedRoles = rolesRaw
|
||||
.map((entry, index) => normalizeRole((entry ?? {}) as ParsedCompanyRole, index))
|
||||
.filter((entry): entry is CompanyBuilderRole => Boolean(entry))
|
||||
.slice(0, MAX_ROLE_COUNT);
|
||||
if (normalizedRoles.length === 0) {
|
||||
throw new Error("The planning agent did not return any company roles.");
|
||||
}
|
||||
const uniqueTitles = dedupeCompactNames(
|
||||
normalizedRoles.map((entry) => entry.title),
|
||||
"Agent",
|
||||
);
|
||||
const usedIds = new Set<string>();
|
||||
const roles = normalizedRoles.map((role, index) => {
|
||||
const title = uniqueTitles[index] ?? role.title;
|
||||
const baseId = role.id.trim() || slugify(title) || `role-${index + 1}`;
|
||||
let nextId = baseId;
|
||||
let suffix = 2;
|
||||
while (usedIds.has(nextId)) {
|
||||
nextId = `${baseId}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedIds.add(nextId);
|
||||
return {
|
||||
...role,
|
||||
id: nextId,
|
||||
title,
|
||||
};
|
||||
});
|
||||
return {
|
||||
companyName: coerceString(parsed.companyName) || "New Company",
|
||||
summary: coerceString(parsed.summary) || "A company plan generated from the user's brief.",
|
||||
sharedRules: uniqueStrings(coerceStringArray(parsed.sharedRules)).slice(0, 12),
|
||||
plannerNotes: uniqueStrings(coerceStringArray(parsed.plannerNotes)).slice(0, 12),
|
||||
roles,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCompanyAgentBlueprints = (plan: CompanyBuilderPlan): CompanyAgentBlueprint[] => {
|
||||
const usedNames = new Set<string>();
|
||||
return plan.roles.map((role, index) => {
|
||||
const baseName = role.title.trim() || `Agent ${index + 1}`;
|
||||
let nextName = baseName;
|
||||
let dedupe = 2;
|
||||
while (usedNames.has(nextName.toLowerCase())) {
|
||||
nextName = `${baseName} ${dedupe}`;
|
||||
dedupe += 1;
|
||||
}
|
||||
usedNames.add(nextName.toLowerCase());
|
||||
const roleWithName = { ...role, title: nextName };
|
||||
const draft = buildRoleSoulDraft({ plan, role: roleWithName });
|
||||
const files = serializePersonalityFiles(draft) as Record<AgentFileName, string>;
|
||||
return {
|
||||
agentName: nextName,
|
||||
role: roleWithName,
|
||||
draft,
|
||||
files,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const buildStoredCompanySnapshot = (params: {
|
||||
prompt: string;
|
||||
improvedBrief: string;
|
||||
plan: CompanyBuilderPlan;
|
||||
now?: () => string;
|
||||
}): CompanyBuilderStoredSnapshot => ({
|
||||
companyName: params.plan.companyName,
|
||||
prompt: params.prompt.trim(),
|
||||
improvedBrief: params.improvedBrief.trim(),
|
||||
summary: params.plan.summary,
|
||||
generatedAt: (params.now ?? (() => new Date().toISOString()))(),
|
||||
roleTitles: params.plan.roles.map((entry) => entry.title),
|
||||
planJson: JSON.stringify(params.plan),
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PersonalityBuilderDraft } from "@/lib/agents/personalityBuilder";
|
||||
import type { AgentFileName } from "@/lib/agents/agentFiles";
|
||||
import type { CommandModeId } from "@/features/agents/operations/agentPermissionsOperation";
|
||||
|
||||
export type CompanyBuilderInput = {
|
||||
businessDescription: string;
|
||||
improvedBrief: string;
|
||||
};
|
||||
|
||||
export type CompanyBuilderRole = {
|
||||
id: string;
|
||||
title: string;
|
||||
purpose: string;
|
||||
soul: string;
|
||||
responsibilities: string[];
|
||||
collaborators: string[];
|
||||
tools: string[];
|
||||
heartbeat: string[];
|
||||
emoji: string;
|
||||
creature: string;
|
||||
vibe: string;
|
||||
userContext: string;
|
||||
commandMode: CommandModeId;
|
||||
};
|
||||
|
||||
export type CompanyBuilderPlan = {
|
||||
companyName: string;
|
||||
summary: string;
|
||||
sharedRules: string[];
|
||||
plannerNotes: string[];
|
||||
roles: CompanyBuilderRole[];
|
||||
};
|
||||
|
||||
export type CompanyBuilderStoredSnapshot = {
|
||||
companyName: string;
|
||||
prompt: string;
|
||||
improvedBrief: string;
|
||||
summary: string;
|
||||
generatedAt: string;
|
||||
roleTitles: string[];
|
||||
planJson: string;
|
||||
};
|
||||
|
||||
export type CompanyAgentBlueprint = {
|
||||
agentName: string;
|
||||
role: CompanyBuilderRole;
|
||||
draft: PersonalityBuilderDraft;
|
||||
files: Record<AgentFileName, string>;
|
||||
};
|
||||
Reference in New Issue
Block a user