Files
deerflow-code/frontend-web/src/pages/NewAgentPage.tsx
T
2026-05-13 14:36:44 +08:00

518 lines
16 KiB
TypeScript

"use client";
import {
ArrowLeftIcon,
BotIcon,
CheckCircleIcon,
InfoIcon,
MoreHorizontalIcon,
SaveIcon,
} 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 { 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 { ArtifactsProvider } from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents";
import {
AgentNameCheckError,
checkAgentName,
createAgent,
getAgent,
} from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills";
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;
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function getAgentWithRetry(agentId: string) {
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
if (delay > 0) {
await wait(delay);
}
try {
return await getAgent(agentId);
} catch {
// Retry until the write settles or the attempts are exhausted.
}
}
return null;
}
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 [step, setStep] = useState<Step>("name");
const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState("");
const [isCheckingName, setIsCheckingName] = useState(false);
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
const [showSaveHint, setShowSaveHint] = useState(false);
const [setupAgentStatus, setSetupAgentStatus] =
useState<SetupAgentStatus>("idle");
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);
}, [enabledSkills, isSkillSelectionTouched]);
const [thread, sendMessage] = useThreadStream({
threadId: step === "chat" ? threadId : undefined,
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");
void getAgentWithRetry(agentId).then((fetched) => {
if (fetched) {
setAgent(fetched);
return;
}
toast.error(t.agents.agentCreatedPendingRefresh);
});
},
});
const ensureThreadExists = useCallback(async () => {
if (threadPrepared) {
return;
}
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;
}
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
setNameError("请至少选择一个技能");
return;
}
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);
}
return;
} finally {
setIsCheckingName(false);
}
let createdId: string;
let createdName: string;
setIsCreatingAgent(true);
try {
const created = await createAgent({
name: trimmed,
description: "",
skills: selectedSkills,
soul: "",
});
createdId = created.id;
createdName = created.name;
} catch (err) {
setNameError(
getCreateAgentErrorMessage(
err,
t.agents.nameStepNetworkError,
t.agents.nameStepCheckError,
),
);
return;
} 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,
selectedSkills,
ensureThreadExists,
]);
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 handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
await ensureThreadExists();
await sendMessage(
threadId,
{ text: trimmed, files: [] },
{ agent_id: agentId },
);
},
[agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists],
);
const handleSaveAgent = useCallback(async () => {
if (
!agentId ||
agent ||
thread.isLoading ||
setupAgentStatus !== "idle"
) {
return;
}
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,
]);
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("/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>
);
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>
<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>
);
}
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>
) : null}
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
/>
</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(
`/workspace/agents/${agentId}/chats/new`,
)
}
>
{t.agents.startChatting}
</Button>
<Button
variant="outline"
onClick={() => router.push("/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>
)}
</div>
</div>
</main>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}