mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
Add explicit save action for agent creation (#1798)
* Add explicit save action for agent creation * Hide internal save prompts and retry agent reads --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
1694c616ef
commit
3d4f9a88fe
@ -1,8 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BotIcon,
|
||||
CheckCircleIcon,
|
||||
InfoIcon,
|
||||
MoreHorizontalIcon,
|
||||
SaveIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
@ -10,17 +18,20 @@ 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 { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import {
|
||||
AgentNameCheckError,
|
||||
checkAgentName,
|
||||
getAgent,
|
||||
} from "@/core/agents/api";
|
||||
import { checkAgentName, getAgent } from "@/core/agents/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
@ -28,23 +39,46 @@ import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Step = "name" | "chat";
|
||||
type SetupAgentStatus = "idle" | "requested" | "completed";
|
||||
|
||||
const NAME_RE = /^[A-Za-z0-9-]+$/;
|
||||
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen";
|
||||
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getAgentWithRetry(agentName: string) {
|
||||
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
|
||||
if (delay > 0) {
|
||||
await wait(delay);
|
||||
}
|
||||
|
||||
try {
|
||||
return await getAgent(agentName);
|
||||
} catch {
|
||||
// Retry until the write settles or the attempts are exhausted.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function NewAgentPage() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
// ── Step 1: name form ──────────────────────────────────────────────────────
|
||||
const [step, setStep] = useState<Step>("name");
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [nameError, setNameError] = useState("");
|
||||
const [isCheckingName, setIsCheckingName] = useState(false);
|
||||
const [agentName, setAgentName] = useState("");
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
// ── Step 2: chat ───────────────────────────────────────────────────────────
|
||||
const [showSaveHint, setShowSaveHint] = useState(false);
|
||||
const [setupAgentStatus, setSetupAgentStatus] =
|
||||
useState<SetupAgentStatus>("idle");
|
||||
|
||||
// Stable thread ID — all turns belong to the same thread
|
||||
const threadId = useMemo(() => uuid(), []);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
@ -53,17 +87,35 @@ export default function NewAgentPage() {
|
||||
mode: "flash",
|
||||
is_bootstrap: true,
|
||||
},
|
||||
onFinish() {
|
||||
if (!agent && setupAgentStatus === "requested") {
|
||||
setSetupAgentStatus("idle");
|
||||
}
|
||||
},
|
||||
onToolEnd({ name }) {
|
||||
if (name !== "setup_agent" || !agentName) return;
|
||||
getAgent(agentName)
|
||||
.then((fetched) => setAgent(fetched))
|
||||
.catch(() => {
|
||||
// agent write may not be flushed yet — ignore silently
|
||||
});
|
||||
setSetupAgentStatus("completed");
|
||||
void getAgentWithRetry(agentName).then((fetched) => {
|
||||
if (fetched) {
|
||||
setAgent(fetched);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t.agents.agentCreatedPendingRefresh);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
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();
|
||||
@ -72,6 +124,7 @@ export default function NewAgentPage() {
|
||||
setNameError(t.agents.nameStepInvalidError);
|
||||
return;
|
||||
}
|
||||
|
||||
setNameError("");
|
||||
setIsCheckingName(true);
|
||||
try {
|
||||
@ -90,6 +143,7 @@ export default function NewAgentPage() {
|
||||
} finally {
|
||||
setIsCheckingName(false);
|
||||
}
|
||||
|
||||
setAgentName(trimmed);
|
||||
setStep("chat");
|
||||
await sendMessage(threadId, {
|
||||
@ -99,12 +153,12 @@ export default function NewAgentPage() {
|
||||
}, [
|
||||
nameInput,
|
||||
sendMessage,
|
||||
threadId,
|
||||
t.agents.nameStepBootstrapMessage,
|
||||
t.agents.nameStepInvalidError,
|
||||
t.agents.nameStepAlreadyExistsError,
|
||||
t.agents.nameStepNetworkError,
|
||||
t.agents.nameStepBootstrapMessage,
|
||||
t.agents.nameStepCheckError,
|
||||
t.agents.nameStepInvalidError,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
@ -124,26 +178,82 @@ export default function NewAgentPage() {
|
||||
{ agent_name: agentName },
|
||||
);
|
||||
},
|
||||
[thread.isLoading, sendMessage, threadId, agentName],
|
||||
[agentName, sendMessage, thread.isLoading, threadId],
|
||||
);
|
||||
|
||||
// ── Shared header ──────────────────────────────────────────────────────────
|
||||
const handleSaveAgent = useCallback(async () => {
|
||||
if (
|
||||
!agentName ||
|
||||
agent ||
|
||||
thread.isLoading ||
|
||||
setupAgentStatus !== "idle"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSetupAgentStatus("requested");
|
||||
setShowSaveHint(false);
|
||||
try {
|
||||
await sendMessage(
|
||||
threadId,
|
||||
{ text: t.agents.saveCommandMessage, files: [] },
|
||||
{ agent_name: agentName },
|
||||
{ additionalKwargs: { hide_from_ui: true } },
|
||||
);
|
||||
toast.success(t.agents.saveRequested);
|
||||
} catch (error) {
|
||||
setSetupAgentStatus("idle");
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}, [
|
||||
agent,
|
||||
agentName,
|
||||
sendMessage,
|
||||
setupAgentStatus,
|
||||
t.agents.saveCommandMessage,
|
||||
t.agents.saveRequested,
|
||||
thread.isLoading,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
const header = (
|
||||
<header className="flex shrink-0 items-center gap-3 border-b px-4 py-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>
|
||||
<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>
|
||||
);
|
||||
|
||||
// ── Step 1: name form ──────────────────────────────────────────────────────
|
||||
|
||||
if (step === "name") {
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
@ -176,9 +286,9 @@ export default function NewAgentPage() {
|
||||
onKeyDown={handleNameKeyDown}
|
||||
className={cn(nameError && "border-destructive")}
|
||||
/>
|
||||
{nameError && (
|
||||
{nameError ? (
|
||||
<p className="text-destructive text-sm">{nameError}</p>
|
||||
)}
|
||||
) : null}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleConfirmName()}
|
||||
@ -193,8 +303,6 @@ export default function NewAgentPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2: chat ───────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
<ArtifactsProvider>
|
||||
@ -202,20 +310,28 @@ export default function NewAgentPage() {
|
||||
{header}
|
||||
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
{/* ── Message area ── */}
|
||||
{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="size-full pt-10"
|
||||
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Bottom action area ── */}
|
||||
<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 ? (
|
||||
// ✅ Success card
|
||||
<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>
|
||||
@ -238,7 +354,6 @@ export default function NewAgentPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 📝 Normal input
|
||||
<PromptInput
|
||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||
>
|
||||
|
||||
@ -205,6 +205,17 @@ export const enUS: Translations = {
|
||||
nameStepCheckError: "Could not verify name availability — please try again",
|
||||
nameStepBootstrapMessage:
|
||||
"The new custom agent name is {name}. Let's bootstrap it's **SOUL**.",
|
||||
save: "Save agent",
|
||||
saving: "Saving agent...",
|
||||
saveRequested:
|
||||
"Save requested. DeerFlow is generating and saving an initial version now.",
|
||||
saveHint:
|
||||
"You can save this agent at any time from the top-right menu, even if this is only a first draft.",
|
||||
saveCommandMessage:
|
||||
"Please save this custom agent now based on everything we have discussed so far. Treat this as my explicit confirmation to save. If some details are still missing, make reasonable assumptions, generate a concise first SOUL.md in English, and call setup_agent immediately without asking me for more confirmation.",
|
||||
agentCreatedPendingRefresh:
|
||||
"The agent was created, but DeerFlow could not load it yet. Please refresh this page in a moment.",
|
||||
more: "More actions",
|
||||
agentCreated: "Agent created!",
|
||||
startChatting: "Start chatting",
|
||||
backToGallery: "Back to Gallery",
|
||||
|
||||
@ -141,6 +141,13 @@ export interface Translations {
|
||||
nameStepNetworkError: string;
|
||||
nameStepCheckError: string;
|
||||
nameStepBootstrapMessage: string;
|
||||
save: string;
|
||||
saving: string;
|
||||
saveRequested: string;
|
||||
saveHint: string;
|
||||
saveCommandMessage: string;
|
||||
agentCreatedPendingRefresh: string;
|
||||
more: string;
|
||||
agentCreated: string;
|
||||
startChatting: string;
|
||||
backToGallery: string;
|
||||
|
||||
@ -193,6 +193,17 @@ export const zhCN: Translations = {
|
||||
nameStepCheckError: "无法验证名称可用性,请稍后重试",
|
||||
nameStepBootstrapMessage:
|
||||
"新智能体的名称是 {name},现在开始为它生成 **SOUL**。",
|
||||
save: "保存智能体",
|
||||
saving: "正在保存智能体...",
|
||||
saveRequested:
|
||||
"已提交保存请求,DeerFlow 正在根据当前对话生成并保存初版智能体。",
|
||||
saveHint:
|
||||
"你可以在右上角的菜单里随时保存这个智能体,就算目前还只是初稿也可以。",
|
||||
saveCommandMessage:
|
||||
"请现在根据我们目前已经讨论的全部内容保存这个自定义智能体。这就是我明确的保存确认。如果仍有少量细节缺失,请根据上下文做出合理假设,生成一份简洁的英文初始 SOUL.md,并直接调用 setup_agent,不要再向我索要额外确认。",
|
||||
agentCreatedPendingRefresh:
|
||||
"智能体已创建,但 DeerFlow 暂时还无法读取到它。请稍后刷新当前页面。",
|
||||
more: "更多操作",
|
||||
agentCreated: "智能体已创建!",
|
||||
startChatting: "开始对话",
|
||||
backToGallery: "返回 Gallery",
|
||||
|
||||
@ -52,6 +52,10 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (isHiddenFromUIMessage(message)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.name === "todo_reminder") {
|
||||
continue;
|
||||
}
|
||||
@ -323,6 +327,10 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isHiddenFromUIMessage(message: Message) {
|
||||
return message.additional_kwargs?.hide_from_ui === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a file stored in message additional_kwargs.files.
|
||||
* Used for optimistic UI (uploading state) and structured file metadata.
|
||||
|
||||
@ -32,6 +32,10 @@ export type ThreadStreamOptions = {
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
@ -218,6 +222,7 @@ export function useThreadStream({
|
||||
threadId: string,
|
||||
message: PromptInputMessage,
|
||||
extraContext?: Record<string, unknown>,
|
||||
options?: SendMessageOptions,
|
||||
) => {
|
||||
if (sendInFlightRef.current) {
|
||||
return;
|
||||
@ -238,17 +243,23 @@ export function useThreadStream({
|
||||
}),
|
||||
);
|
||||
|
||||
// Create optimistic human message (shown immediately)
|
||||
const optimisticHumanMsg: Message = {
|
||||
type: "human",
|
||||
id: `opt-human-${Date.now()}`,
|
||||
content: text ? [{ type: "text", text }] : "",
|
||||
additional_kwargs:
|
||||
optimisticFiles.length > 0 ? { files: optimisticFiles } : {},
|
||||
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
|
||||
const optimisticAdditionalKwargs = {
|
||||
...options?.additionalKwargs,
|
||||
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
|
||||
};
|
||||
|
||||
const newOptimistic: Message[] = [optimisticHumanMsg];
|
||||
if (optimisticFiles.length > 0) {
|
||||
const newOptimistic: Message[] = [];
|
||||
if (!hideFromUI) {
|
||||
newOptimistic.push({
|
||||
type: "human",
|
||||
id: `opt-human-${Date.now()}`,
|
||||
content: text ? [{ type: "text", text }] : "",
|
||||
additional_kwargs: optimisticAdditionalKwargs,
|
||||
});
|
||||
}
|
||||
|
||||
if (optimisticFiles.length > 0 && !hideFromUI) {
|
||||
// Mock AI message while files are being uploaded
|
||||
newOptimistic.push({
|
||||
type: "ai",
|
||||
@ -369,8 +380,12 @@ export function useThreadStream({
|
||||
text,
|
||||
},
|
||||
],
|
||||
additional_kwargs:
|
||||
filesForSubmit.length > 0 ? { files: filesForSubmit } : {},
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user