Files
deerflow-code/frontend-web/src/pages/NewAgentPage.tsx
T
董宏凯andClaude Opus 4.8 b2132dd668 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
解决 artifact 预览组件冲突:mermaid 渲染(dhk-1) 与 markdown 锚点跳转(master) 取并集;package-lock.json 按 master 删除(项目用 pnpm)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:43:02 +08:00

642 lines
24 KiB
TypeScript

"use client";
import {
ArrowLeftIcon,
CheckCircleIcon,
InfoIcon,
SaveIcon,
SearchIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import {
MessageList,
type SetupAgentApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents";
import {
AgentNameCheckError,
checkAgentName,
createAgent,
updateAgent,
} from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/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 { cn } from "@/lib/utils";
const DEFAULT_SELECTED_SKILL_COUNT = 3;
function getCreateAgentErrorMessage(
error: unknown,
networkErrorMessage: string,
fallbackMessage: string,
) {
if (error instanceof TypeError && error.message === "Failed to fetch") {
return networkErrorMessage;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
const threadId = useMemo(() => uuid(), []);
const [nameInput, setNameInput] = useState("");
const [descriptionInput, setDescriptionInput] = useState("");
const [soulInput, setSoulInput] = useState("");
const [modelInput, setModelInput] = useState<string>("");
const [chatModelInput, setChatModelInput] = useState<string>("");
const [formError, setFormError] = useState("");
const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [isSavingDraft, setIsSavingDraft] = useState(false);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [skillSearch, setSkillSearch] = useState("");
const [threadPrepared, setThreadPrepared] = useState(false);
const { skills, isLoading: isSkillsLoading } = useSkills();
const { models, isLoading: isModelsLoading } = useModels();
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
const filteredSkills = useMemo(() => {
const q = skillSearch.trim().toLowerCase();
if (!q) return enabledSkills;
return enabledSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q),
);
}, [enabledSkills, skillSearch]);
useEffect(() => {
if (isSkillSelectionTouched) {
return;
}
setSelectedSkills(
enabledSkills
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
.map((skill) => skill.name),
);
}, [enabledSkills, isSkillSelectionTouched]);
// Seed both model selectors with the first available model the first time
// models load. The user can still change either independently afterwards.
useEffect(() => {
if (models.length === 0) {
return;
}
const first = models[0]!.name;
setModelInput((prev) =>
prev && models.some((m) => m.name === prev) ? prev : first,
);
setChatModelInput((prev) =>
prev && models.some((m) => m.name === prev) ? prev : first,
);
}, [models]);
const resolvedModelInput = useMemo(() => {
if (models.length === 0) {
return "";
}
if (modelInput && models.some((m) => m.name === modelInput)) {
return modelInput;
}
return models[0]!.name;
}, [modelInput, models]);
const resolvedChatModelInput = useMemo(() => {
if (models.length === 0) {
return "";
}
if (chatModelInput && models.some((m) => m.name === chatModelInput)) {
return chatModelInput;
}
return models[0]!.name;
}, [chatModelInput, models]);
const [thread, sendMessage] = useThreadStream({
threadId,
assistantId: agentId || undefined,
context: {
mode: "flash",
is_bootstrap: true,
model_name: chatModelInput || undefined,
},
onToolEnd() {
// setup_agent never actually executes in this flow 鈥?it gets
// intercepted by SetupAgentApprovalMiddleware and the form is filled
// from the approval ToolMessage payload (handleApproveSetupAgent).
// No agent record is fetched/refreshed during chat.
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) {
return;
}
await getAPIClient().threads.create({ threadId, metadata: { thread_type: "bootstrap" } });
setThreadPrepared(true);
}, [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) {
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 to send to the backend just to satisfy the required-field
// validation. Intentionally NOT written back into the visible input 鈥?
// the user wants the name box to stay blank while they chat, and we
// refill it later (with the model's proposed name) on setup_agent
// approval.
name = `未命名智能体-${Date.now().toString(36)}`;
}
const modelOverride = modelInput.trim() || null;
if (agentId) {
setIsSavingDraft(true);
try {
const updated = await updateAgent(agentId, {
name,
description: descriptionInput,
soul: soulInput,
skills: selectedSkills,
model: modelOverride,
});
setAgent(updated);
return updated;
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
return null;
} finally {
setIsSavingDraft(false);
}
}
setIsCreatingAgent(true);
try {
await checkAgentName(name);
const created = await createAgent({
name,
description: descriptionInput,
soul: soulInput,
skills: selectedSkills,
model: modelOverride,
});
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);
}
}, [
agentId,
descriptionInput,
modelInput,
selectedSkills,
soulInput,
t.agents.nameStepCheckError,
t.agents.nameStepNetworkError,
validateForm,
]);
const handleSaveAgent = useCallback(async () => {
const saved = await ensureAgent();
if (!saved) {
return;
}
toast.success(`智能体「${saved.name}」已保存`);
router.push("/page/workspace/agents");
}, [ensureAgent, router]);
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) {
return;
}
// Chat is purely a discussion channel. The agent record is only
// created when the user explicitly clicks Save (top-right). If the
// model proposes a config via setup_agent, our approval handler
// prefills the form locally 鈥?it does NOT persist anything.
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
);
const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => {
// Approving the model's setup_agent proposal **does not** persist
// anything 鈥?it only mirrors the proposal into the form so the user
// can review it. Creation/update happens later when the user clicks
// the top-right Save button.
const proposedName = payload.name?.trim() ?? "";
const newDescription = payload.description ?? "";
const newSoul = payload.soul ?? "";
const newSkills = payload.skills ?? [];
if (proposedName) {
setNameInput(proposedName);
}
setDescriptionInput(newDescription);
setSoulInput(newSoul);
setSelectedSkills(newSkills);
setIsSkillSelectionTouched(true);
toast.success("已套用模型建议,可继续编辑或点保存");
},
[],
);
const handleRejectSetupAgent = useCallback(
async () => {
await handleChatSubmit("我暂时不批准创建这个智能体配置,请继续帮我调整。");
},
[handleChatSubmit],
);
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 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">创建智能体</h1>
</div>
{agent ? (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/page/workspace/agents/${agent.id}/chats/new`)}
>
<CheckCircleIcon className="h-4 w-4" />
开始对话
</Button>
) : null}
</header>
<ResizablePanelGroup
id="new-agent-config-panels"
orientation="horizontal"
defaultLayout={{ chat: 62, config: 38 }}
className="min-h-0 flex-1"
>
<ResizablePanel
id="chat"
defaultSize={62}
minSize={40}
className="min-h-0"
>
<section className="flex size-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onSetupAgentApprove={handleApproveSetupAgent}
onSetupAgentReject={handleRejectSetupAgent}
/>
</div>
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
当前对话模型
</span>
{isModelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{isModelsLoading ? "加载中…" : "暂无模型"}
</div>
) : (
<Select
value={resolvedChatModelInput}
onValueChange={setChatModelInput}
disabled={thread.isLoading}
>
<SelectTrigger
className={cn(
"border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
"data-[placeholder]:text-muted-foreground",
"[&_svg]:dark:text-[#AACEE2]",
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]"
>
{models.map((model) => (
<SelectItem
key={model.name}
value={model.name}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
placeholder="直接告诉模型你想要什么样的智能体,或回答模型的问题"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
</div>
</section>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
id="config"
defaultSize={38}
minSize={24}
maxSize={60}
className="min-h-0"
>
<aside className="bg-background size-full 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">模型</label>
{isModelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-9 w-full items-center rounded-md border px-3 text-sm shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{isModelsLoading ? "加载模型…" : "暂无可用模型"}
</div>
) : (
<Select
value={resolvedModelInput}
onValueChange={setModelInput}
>
<SelectTrigger
className={cn(
"border-input bg-background h-9 w-full shadow-xs outline-none",
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
"data-[placeholder]:text-muted-foreground",
"[&_svg]:dark:text-[#AACEE2]",
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]"
>
{models.map((model) => (
<SelectItem
key={model.name}
value={model.name}
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="text-muted-foreground text-xs">
该智能体被使用时调用的模型。
</p>
</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 max-h-[400px] resize-y overflow-y-auto 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>
{enabledSkills.length > 0 && (
<div className="relative">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能名称或描述…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.currentTarget.value)}
className="h-8 pl-7 text-xs"
/>
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2 dark:border-[#1B477E] dark:bg-[#022248]">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能。
</p>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
没有匹配的技能。
</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
key={skill.name}
className="flex w-full min-w-0 items-center"
>
<Button
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 min-w-0 flex-1 rounded-r-none",
!checked && "text-muted-foreground",
)}
>
<span className="truncate">{skill.name}</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
{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>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}