This commit is contained in:
fengxiangdebaozi committed 2026-05-13 21:25:40 +08:00
commit 6cc28c7edd
15 files changed
+1373 -998

No files matched your search

+314 -353
View File
@@ -2,10 +2,7 @@
import {
ArrowLeftIcon,
BotIcon,
CheckCircleIcon,
InfoIcon,
MoreHorizontalIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -18,17 +15,14 @@ import {
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages";
import {
MessageList,
type SetupAgentApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents";
@@ -37,19 +31,15 @@ import {
checkAgentName,
createAgent,
getAgent,
updateAgent,
} from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
type Step = "name" | "chat";
type SetupAgentStatus = "idle" | "requested" | "completed";
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen";
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
const DEFAULT_SELECTED_SKILL_COUNT = 3;
@@ -62,14 +52,12 @@ async function getAgentWithRetry(agentId: string) {
if (delay > 0) {
await wait(delay);
}
try {
return await getAgent(agentId);
} catch {
// Retry until the write settles or the attempts are exhausted.
// Retry until the file/database write settles.
}
}
return null;
}
@@ -90,60 +78,56 @@ function getCreateAgentErrorMessage(
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
const threadId = useMemo(() => uuid(), []);
const [step, setStep] = useState<Step>("name");
const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState("");
const [isCheckingName, setIsCheckingName] = useState(false);
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [descriptionInput, setDescriptionInput] = useState("");
const [soulInput, setSoulInput] = useState("");
const [formError, setFormError] = useState("");
const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
const [showSaveHint, setShowSaveHint] = useState(false);
const [setupAgentStatus, setSetupAgentStatus] =
useState<SetupAgentStatus>("idle");
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [isSavingDraft, setIsSavingDraft] = useState(false);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [threadPrepared, setThreadPrepared] = useState(false);
const { skills, isLoading: isSkillsLoading } = useSkills();
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
const threadId = useMemo(() => uuid(), []);
useEffect(() => {
if (isSkillSelectionTouched) {
return;
}
const defaults = enabledSkills
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
.map((skill) => skill.name);
setSelectedSkills(defaults);
setSelectedSkills(
enabledSkills
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
.map((skill) => skill.name),
);
}, [enabledSkills, isSkillSelectionTouched]);
const [thread, sendMessage] = useThreadStream({
threadId: step === "chat" ? threadId : undefined,
threadId,
assistantId: agentId || undefined,
context: {
mode: "flash",
is_bootstrap: true,
},
onFinish() {
if (!agent && setupAgentStatus === "requested") {
setSetupAgentStatus("idle");
}
},
onToolEnd({ name }) {
if (name !== "setup_agent" || !agentId) return;
setSetupAgentStatus("completed");
if (name !== "setup_agent" || !agentId) {
return;
}
void getAgentWithRetry(agentId).then((fetched) => {
if (fetched) {
setAgent(fetched);
return;
setDescriptionInput(fetched.description ?? "");
setSoulInput(fetched.soul ?? "");
setSelectedSkills(fetched.skills ?? []);
toast.success("专家配置已创建");
}
toast.error(t.agents.agentCreatedPendingRefresh);
});
},
});
@@ -152,362 +136,339 @@ export default function NewAgentPage() {
if (threadPrepared) {
return;
}
await getAPIClient().threads.create({
threadId,
});
await getAPIClient().threads.create({ threadId });
setThreadPrepared(true);
}, [threadPrepared, threadId]);
useEffect(() => {
if (typeof window === "undefined" || step !== "chat") {
return;
}
if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") {
return;
}
setShowSaveHint(true);
window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1");
}, [step]);
const handleConfirmName = useCallback(async () => {
const trimmed = nameInput.trim();
if (!trimmed) {
setNameError(t.agents.nameStepInvalidError);
return;
}
}, [threadId, threadPrepared]);
const validateForm = useCallback(() => {
// Name is intentionally NOT required here so the user can start chatting
// immediately. If they leave it blank, ensureAgent() generates a placeholder
// and writes it back into the input so they can rename it later.
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
setNameError("请至少选择一个技能");
return;
setFormError("请至少选择一个技能");
return null;
}
setFormError("");
return nameInput.trim();
}, [enabledSkills.length, nameInput, selectedSkills.length]);
const ensureAgent = useCallback(async () => {
const provided = validateForm();
if (provided === null) {
return null;
}
let name = provided;
if (!name) {
// Names are display-only and may collide, so a timestamped placeholder is
// safe. Reflect it in the form so the user sees what got created.
name = `未命名专家-${Date.now().toString(36)}`;
setNameInput(name);
}
setNameError("");
setIsCheckingName(true);
try {
await checkAgentName(trimmed);
} catch (err) {
if (
err instanceof AgentNameCheckError &&
err.reason === "backend_unreachable"
) {
setNameError(t.agents.nameStepNetworkError);
} else {
setNameError(t.agents.nameStepCheckError);
if (agentId) {
setIsSavingDraft(true);
try {
const updated = await updateAgent(agentId, {
name,
description: descriptionInput,
soul: soulInput,
skills: selectedSkills,
});
setAgent(updated);
return updated;
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
return null;
} finally {
setIsSavingDraft(false);
}
return;
} finally {
setIsCheckingName(false);
}
let createdId: string;
let createdName: string;
setIsCreatingAgent(true);
try {
await checkAgentName(name);
const created = await createAgent({
name: trimmed,
description: "",
name,
description: descriptionInput,
soul: soulInput,
skills: selectedSkills,
soul: "",
});
createdId = created.id;
createdName = created.name;
} catch (err) {
setNameError(
getCreateAgentErrorMessage(
err,
t.agents.nameStepNetworkError,
t.agents.nameStepCheckError,
),
);
return;
setAgentId(created.id);
setAgent(created);
return created;
} catch (error) {
if (
error instanceof AgentNameCheckError &&
error.reason === "backend_unreachable"
) {
setFormError(t.agents.nameStepNetworkError);
} else {
setFormError(
getCreateAgentErrorMessage(
error,
t.agents.nameStepNetworkError,
t.agents.nameStepCheckError,
),
);
}
return null;
} finally {
setIsCreatingAgent(false);
}
setAgentId(createdId);
setStep("chat");
await ensureThreadExists();
await sendMessage(threadId, {
text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName),
files: [],
});
}, [
nameInput,
sendMessage,
t.agents.nameStepNetworkError,
t.agents.nameStepBootstrapMessage,
t.agents.nameStepCheckError,
t.agents.nameStepInvalidError,
threadId,
enabledSkills.length,
agentId,
descriptionInput,
selectedSkills,
ensureThreadExists,
soulInput,
t.agents.nameStepCheckError,
t.agents.nameStepNetworkError,
validateForm,
]);
const toggleSkill = useCallback((skill: Skill) => {
setIsSkillSelectionTouched(true);
setNameError("");
setSelectedSkills((prev) => {
if (prev.includes(skill.name)) {
return prev.filter((name) => name !== skill.name);
}
return [...prev, skill.name];
});
}, []);
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleConfirmName();
const handleSaveAgent = useCallback(async () => {
const saved = await ensureAgent();
if (!saved) {
return;
}
};
toast.success(`专家「${saved.name}」已保存`);
}, [ensureAgent]);
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
if (!trimmed || thread.isLoading) {
return;
}
// Lazily create the agent on first message so the user can just start
// typing — ensureAgent generates a placeholder name when none is provided.
let activeAgentId = agentId;
if (!activeAgentId) {
const created = await ensureAgent();
if (!created) {
return;
}
activeAgentId = created.id;
}
await ensureThreadExists();
await sendMessage(
threadId,
{ text: trimmed, files: [] },
{ agent_id: agentId },
{ agent_id: activeAgentId },
);
},
[agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists],
[agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId],
);
const handleSaveAgent = useCallback(async () => {
if (
!agentId ||
agent ||
thread.isLoading ||
setupAgentStatus !== "idle"
) {
return;
}
const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => {
if (!agentId) {
return;
}
// Approval persists directly to the backend — no LLM round-trip. The
// backend's PUT /api/agents/{id} writes both the DB record and SOUL.md /
// config.yaml on disk, which is exactly what setup_agent_tool would have
// done if we'd let the model re-invoke it.
const proposedName = payload.name?.trim() ?? "";
const newDescription = payload.description ?? "";
const newSoul = payload.soul ?? "";
const newSkills = payload.skills ?? [];
setSetupAgentStatus("requested");
setShowSaveHint(false);
try {
await sendMessage(
threadId,
{ text: t.agents.saveCommandMessage, files: [] },
{ agent_id: agentId },
{ additionalKwargs: { hide_from_ui: true } },
);
toast.success(t.agents.saveRequested);
} catch (error) {
setSetupAgentStatus("idle");
toast.error(error instanceof Error ? error.message : String(error));
}
}, [
agent,
agentId,
sendMessage,
setupAgentStatus,
t.agents.saveCommandMessage,
t.agents.saveRequested,
thread.isLoading,
threadId,
]);
// Prefer the model's proposed name when present; fall back to whatever
// the user already typed, or the existing agent record.
const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId;
setNameInput(resolvedName);
setDescriptionInput(newDescription);
setSoulInput(newSoul);
setSelectedSkills(newSkills);
setIsSkillSelectionTouched(true);
const header = (
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/page/workspace/agents")}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
</div>
{step === "chat" ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={t.agents.more}>
<MoreHorizontalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => void handleSaveAgent()}
disabled={
!!agent || thread.isLoading || setupAgentStatus !== "idle"
}
>
<SaveIcon className="h-4 w-4" />
{setupAgentStatus === "requested"
? t.agents.saving
: t.agents.save}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</header>
try {
const updated = await updateAgent(agentId, {
name: resolvedName,
description: newDescription,
soul: newSoul,
skills: newSkills,
});
setAgent(updated);
toast.success("专家配置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
},
[agent, agentId, nameInput],
);
if (step === "name") {
return (
<div className="flex size-full flex-col">
{header}
<main className="flex flex-1 flex-col items-center justify-center px-4">
<div className="w-full max-w-sm space-y-8">
<div className="space-y-3 text-center">
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-primary h-7 w-7" />
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold">
{t.agents.nameStepTitle}
</h2>
<p className="text-muted-foreground text-sm">
{t.agents.nameStepHint}
</p>
</div>
</div>
const handleRejectSetupAgent = useCallback(
async () => {
await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
},
[handleChatSubmit],
);
<div className="space-y-3">
<Input
autoFocus
placeholder={t.agents.nameStepPlaceholder}
value={nameInput}
onChange={(e) => {
setNameInput(e.target.value);
setNameError("");
}}
onKeyDown={handleNameKeyDown}
className={cn(nameError && "border-destructive")}
/>
{nameError ? (
<p className="text-destructive text-sm">{nameError}</p>
) : null}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">默认技能(可自定义)</p>
<p className="text-muted-foreground text-xs">
已选 {selectedSkills.length} 个
</p>
</div>
<div className="rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能,将创建为无技能智能体。
</p>
) : (
<div className="flex flex-wrap gap-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<Button
key={skill.name}
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
className={cn(
"h-8",
checked ? "" : "text-muted-foreground",
)}
title={skill.description}
>
{skill.name}
</Button>
);
})}
</div>
)}
</div>
</div>
<Button
className="w-full"
onClick={() => void handleConfirmName()}
disabled={
!nameInput.trim() ||
isCheckingName ||
isCreatingAgent ||
isSkillsLoading
}
>
{t.agents.nameStepContinue}
</Button>
</div>
</div>
</main>
</div>
const toggleSkill = useCallback((skill: Skill) => {
setIsSkillSelectionTouched(true);
setFormError("");
setSelectedSkills((prev) =>
prev.includes(skill.name)
? prev.filter((name) => name !== skill.name)
: [...prev, skill.name],
);
}
}, []);
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col">
{header}
<main className="flex min-h-0 flex-1 flex-col">
{showSaveHint ? (
<div className="px-4 pt-4">
<div className="mx-auto w-full max-w-(--container-width-md)">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>{t.agents.saveHint}</AlertDescription>
</Alert>
</div>
</div>
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/workspace/agents")}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">创建专家</h1>
</div>
{agent ? (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/workspace/agents/${agent.id}/chats/new`)}
>
<CheckCircleIcon className="h-4 w-4" />
开始对话
</Button>
) : null}
</header>
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
threadId={threadId}
thread={thread}
/>
</div>
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4">
<div className="w-full max-w-(--container-width-md)">
{agent ? (
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
<CheckCircleIcon className="text-primary h-10 w-10" />
<p className="font-semibold">{t.agents.agentCreated}</p>
<div className="flex gap-2">
<Button
onClick={() =>
router.push(
`/page/workspace/agents/${agentId}/chats/new`,
)
}
>
{t.agents.startChatting}
</Button>
<Button
variant="outline"
onClick={() => router.push("/page/workspace/agents")}
>
{t.agents.backToGallery}
</Button>
</div>
</div>
) : (
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
autoFocus
placeholder={t.agents.createPageSubtitle}
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
)}
<main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]">
<section className="flex min-h-0 flex-col border-r">
<div className="min-h-0 flex-1">
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onSetupAgentApprove={handleApproveSetupAgent}
onSetupAgentReject={handleRejectSetupAgent}
/>
</div>
</div>
<div className="bg-background shrink-0 border-t px-4 py-4">
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
placeholder="直接告诉模型你想要什么样的专家,或回答模型的问题"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
</div>
</section>
<aside className="bg-background min-h-0 overflow-y-auto">
<div className="space-y-5 p-4">
<div>
<h2 className="text-sm font-semibold">专家配置</h2>
<p className="text-muted-foreground mt-1 text-xs">
这些内容会作为创建专家时的初始配置。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">名称</label>
<Input
placeholder="例如:合同审查专家"
value={nameInput}
onChange={(event) => {
setNameInput(event.currentTarget.value);
setFormError("");
}}
className={cn(formError && "border-destructive")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">描述</label>
<Textarea
placeholder="简要说明这个专家擅长什么"
value={descriptionInput}
onChange={(event) =>
setDescriptionInput(event.currentTarget.value)
}
className="min-h-24 resize-none"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label>
<Textarea
placeholder="写下专家的人设、边界、工作方式和输出要求"
value={soulInput}
onChange={(event) => setSoulInput(event.currentTarget.value)}
className="min-h-48 resize-y font-mono text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">技能</label>
<span className="text-muted-foreground text-xs">
已选 {selectedSkills.length} 个
</span>
</div>
<div className="rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能。
</p>
) : (
<div className="flex flex-wrap gap-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<Button
key={skill.name}
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 max-w-full",
!checked && "text-muted-foreground",
)}
>
<span className="truncate">{skill.name}</span>
</Button>
);
})}
</div>
)}
</div>
</div>
{formError ? (
<p className="text-destructive text-sm">{formError}</p>
) : null}
<Button
className="w-full"
onClick={() => void handleSaveAgent()}
disabled={busy || isSkillsLoading}
>
<SaveIcon className="h-4 w-4" />
{agentId ? "保存专家" : "创建专家"}
</Button>
</div>
</aside>
</main>
</div>
</ArtifactsProvider>