智能体配置

This commit is contained in:
吉鑫升 committed 2026-05-13 20:04:06 +08:00
1 parent 83d7fa26fe
commit 31abad86d5
12 files changed
+1127 -752

No files matched your search

@@ -59,11 +59,17 @@ export function AgentCard({ agent }: AgentCardProps) {
agent.skills ?? [], agent.skills ?? [],
); );
const currentUserId = useMemo(() => getStoredAuth()?.user_id ?? null, []); const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
const isBuiltin = agent.builtin; const isBuiltin = agent.builtin;
const isOwned = const isOwned =
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId; !isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
const isFromOthers = !isBuiltin && !isOwned; const isFromOthers = !isBuiltin && !isOwned;
// System admins are allowed to edit built-in agents in place. Delete and
// publish-toggle stay owner-only — built-ins shouldn't be removable, and
// they are inherently visible to everyone so toggling published is moot.
const canEdit = isOwned || (isBuiltin && isAdmin);
useEffect(() => { useEffect(() => {
if (!editOpen) { if (!editOpen) {
@@ -235,40 +241,46 @@ export function AgentCard({ agent }: AgentCardProps) {
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" /> <MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
{t.agents.chat} {t.agents.chat}
</Button> </Button>
{isOwned && ( {(canEdit || isOwned) && (
<div className="flex gap-1"> <div className="flex gap-1">
<Button {isOwned && (
size="icon" <Button
variant="ghost" size="icon"
className="h-8 w-8 shrink-0" variant="ghost"
onClick={() => void handleTogglePublished()} className="h-8 w-8 shrink-0"
disabled={updateAgent.isPending} onClick={() => void handleTogglePublished()}
title={agent.published ? "取消发布" : "发布"} disabled={updateAgent.isPending}
> title={agent.published ? "取消发布" : "发布"}
{agent.published ? ( >
<EyeOffIcon className="h-3.5 w-3.5" /> {agent.published ? (
) : ( <EyeOffIcon className="h-3.5 w-3.5" />
<EyeIcon className="h-3.5 w-3.5" /> ) : (
)} <EyeIcon className="h-3.5 w-3.5" />
</Button> )}
<Button </Button>
size="icon" )}
variant="ghost" {canEdit && (
className="h-8 w-8 shrink-0" <Button
onClick={() => setEditOpen(true)} size="icon"
title="编辑智能体" variant="ghost"
> className="h-8 w-8 shrink-0"
<SquarePenIcon className="h-3.5 w-3.5" /> onClick={() => setEditOpen(true)}
</Button> title="编辑智能体"
<Button >
size="icon" <SquarePenIcon className="h-3.5 w-3.5" />
variant="ghost" </Button>
className="text-destructive hover:text-destructive h-8 w-8 shrink-0" )}
onClick={() => setDeleteOpen(true)} {isOwned && (
title={t.agents.delete} <Button
> size="icon"
<Trash2Icon className="h-3.5 w-3.5" /> variant="ghost"
</Button> className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
</div> </div>
)} )}
</CardFooter> </CardFooter>
@@ -1,14 +1,14 @@
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { SendIcon } from "lucide-react"; import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractContentFromMessage, extractContentFromMessage,
extractPresentFilesFromMessage, extractPresentFilesFromMessage,
@@ -42,17 +42,21 @@ export function MessageList({
className, className,
threadId, threadId,
thread, thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false, tokenUsageEnabled = false,
onClarificationSubmit, onClarificationSubmit,
}: { onSetupAgentApprove,
className?: string; onSetupAgentReject,
threadId: string; }: {
thread: BaseStream<AgentThreadState>; className?: string;
paddingBottom?: number; threadId: string;
tokenUsageEnabled?: boolean; thread: BaseStream<AgentThreadState>;
onClarificationSubmit?: (answer: string) => void | Promise<void>; paddingBottom?: number;
}) { tokenUsageEnabled?: boolean;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) {
const { t } = useI18n(); const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
@@ -83,13 +87,13 @@ export function MessageList({
if (message && hasContent(message)) { if (message && hasContent(message)) {
return ( return (
<div key={group.id} className="w-full"> <div key={group.id} className="w-full">
<ClarificationCard <ClarificationCard
fallbackContent={extractContentFromMessage(message)} fallbackContent={extractContentFromMessage(message)}
isLoading={thread.isLoading} isLoading={thread.isLoading}
message={message} message={message}
onSubmit={onClarificationSubmit} onSubmit={onClarificationSubmit}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
/> />
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
isLoading={thread.isLoading} isLoading={thread.isLoading}
@@ -99,6 +103,23 @@ export function MessageList({
); );
} }
return null; return null;
} else if (group.type === "assistant:setup-agent-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupAgentApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupAgentApprove}
onReject={onSetupAgentReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : null;
} else if (group.type === "assistant:present-files") { } else if (group.type === "assistant:present-files") {
const files: string[] = []; const files: string[] = [];
for (const message of group.messages) { for (const message of group.messages) {
@@ -246,150 +267,289 @@ export function MessageList({
<div style={{ height: `${paddingBottom}px` }} /> <div style={{ height: `${paddingBottom}px` }} />
</ConversationContent> </ConversationContent>
</Conversation> </Conversation>
); );
} }
type ClarificationOption = { type ClarificationOption = {
id: string; id: string;
label: string; label: string;
description?: string; description?: string;
}; };
type ClarificationPayload = { type ClarificationPayload = {
question?: string; question?: string;
context?: string | null; context?: string | null;
clarification_type?: string; clarification_type?: string;
options?: ClarificationOption[]; options?: ClarificationOption[];
allow_custom?: boolean; allow_custom?: boolean;
}; };
function getClarificationPayload(message: unknown): ClarificationPayload | null { export type SetupAgentApprovalPayload = {
if (typeof message !== "object" || message === null) { name?: string;
return null; description?: string;
} soul?: string;
const additionalKwargs = Reflect.get(message, "additional_kwargs"); skills?: string[];
if (typeof additionalKwargs !== "object" || additionalKwargs === null) { };
return null;
} function getSetupAgentApprovalPayload(
const clarification = Reflect.get(additionalKwargs, "clarification"); message: unknown,
if (typeof clarification !== "object" || clarification === null) { ): SetupAgentApprovalPayload | null {
return null; if (typeof message !== "object" || message === null) {
} return null;
return clarification as ClarificationPayload; }
} const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
function ClarificationCard({ return null;
fallbackContent, }
isLoading, const approval = Reflect.get(additionalKwargs, "setup_agent_approval");
message, if (typeof approval !== "object" || approval === null) {
onSubmit, return null;
rehypePlugins, }
}: { return approval as SetupAgentApprovalPayload;
fallbackContent: string; }
isLoading: boolean;
message: unknown; function SetupAgentApprovalCard({
onSubmit?: (answer: string) => void | Promise<void>; disabled,
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>; message,
}) { onApprove,
const payload = getClarificationPayload(message); onReject,
const [customAnswer, setCustomAnswer] = useState(""); }: {
const [submitting, setSubmitting] = useState(false); disabled: boolean;
message: unknown;
const options = useMemo( onApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
() => onReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
(payload?.options ?? []).filter( }) {
(option) => typeof option.label === "string" && option.label.trim(), const payload = getSetupAgentApprovalPayload(message);
), const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
[payload?.options], null,
); );
if (!payload) { if (!payload) {
return ( return null;
<MarkdownContent }
content={fallbackContent}
isLoading={isLoading} const approvalPayload = payload;
rehypePlugins={rehypePlugins} const busy = disabled || submitting !== null;
/> const skills = approvalPayload.skills ?? [];
);
} async function run(action: "approve" | "reject") {
if (busy) {
const disabled = isLoading || submitting || !onSubmit; return;
}
async function submitAnswer(answer: string) { setSubmitting(action);
const trimmed = answer.trim(); try {
if (!trimmed || disabled) { if (action === "approve") {
return; await onApprove?.(approvalPayload);
} } else {
setSubmitting(true); await onReject?.(approvalPayload);
try { }
await onSubmit?.(trimmed); } finally {
} finally { setSubmitting(null);
setSubmitting(false); }
} }
}
return (
return ( <div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm"> <div className="space-y-3">
<div className="space-y-2"> <div>
<div className="text-muted-foreground text-xs font-medium"> <div className="text-muted-foreground text-xs font-medium">
需要你的确认 创建专家审批
</div> </div>
{payload.context ? ( <div className="text-sm font-medium">请确认是否写入这个专家配置。</div>
<p className="text-muted-foreground text-sm leading-6"> </div>
{payload.context}
</p> {approvalPayload.name ? (
) : null} <div className="space-y-1">
<div className="text-sm leading-6 font-medium"> <div className="text-muted-foreground text-xs">名称</div>
{payload.question || fallbackContent} <p className="text-sm font-medium">{approvalPayload.name}</p>
</div> </div>
</div> ) : null}
{options.length > 0 ? ( {approvalPayload.description ? (
<div className="mt-4 grid gap-2"> <div className="space-y-1">
{options.map((option) => ( <div className="text-muted-foreground text-xs">描述</div>
<Button <p className="text-sm leading-6">{approvalPayload.description}</p>
key={option.id || option.label} </div>
type="button" ) : null}
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left" {skills.length > 0 ? (
disabled={disabled} <div className="space-y-1">
onClick={() => void submitAnswer(`我选择:${option.label}`)} <div className="text-muted-foreground text-xs">技能</div>
> <div className="flex flex-wrap gap-1.5">
<span className="flex min-w-0 flex-col items-start gap-1"> {skills.map((skill) => (
<span>{option.label}</span> <span
{option.description ? ( key={skill}
<span className="text-muted-foreground text-xs font-normal leading-5"> className="bg-secondary text-secondary-foreground rounded-md px-2 py-1 text-xs"
{option.description} >
</span> {skill}
) : null} </span>
</span> ))}
</Button> </div>
))} </div>
</div> ) : null}
) : null}
{approvalPayload.soul ? (
{payload.allow_custom !== false ? ( <div className="space-y-1">
<div className="mt-4 flex flex-col gap-2"> <div className="text-muted-foreground text-xs">SOUL.md</div>
<Textarea <pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
value={customAnswer} {approvalPayload.soul}
onChange={(event) => setCustomAnswer(event.currentTarget.value)} </pre>
placeholder="如果以上选项都不合适,可以在这里输入你的回答" </div>
disabled={disabled} ) : null}
className="min-h-20 resize-none" </div>
/>
<div className="flex justify-end"> <div className="mt-4 flex justify-end gap-2">
<Button <Button
type="button" type="button"
size="sm" variant="outline"
disabled={disabled || !customAnswer.trim()} size="sm"
onClick={() => void submitAnswer(customAnswer)} disabled={busy || !onReject}
> onClick={() => void run("reject")}
<SendIcon className="h-4 w-4" /> >
发送回答 <XIcon className="h-4 w-4" />
</Button> 拒绝
</div> </Button>
</div> <Button
) : null} type="button"
</div> size="sm"
); disabled={busy || !onApprove}
} onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
批准创建
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
}
const additionalKwargs = Reflect.get(message, "additional_kwargs");
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
return null;
}
const clarification = Reflect.get(additionalKwargs, "clarification");
if (typeof clarification !== "object" || clarification === null) {
return null;
}
return clarification as ClarificationPayload;
}
function ClarificationCard({
fallbackContent,
isLoading,
message,
onSubmit,
rehypePlugins,
}: {
fallbackContent: string;
isLoading: boolean;
message: unknown;
onSubmit?: (answer: string) => void | Promise<void>;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false);
const options = useMemo(
() =>
(payload?.options ?? []).filter(
(option) => typeof option.label === "string" && option.label.trim(),
),
[payload?.options],
);
if (!payload) {
return (
<MarkdownContent
content={fallbackContent}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
);
}
const disabled = isLoading || submitting || !onSubmit;
async function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed || disabled) {
return;
}
setSubmitting(true);
try {
await onSubmit?.(trimmed);
} finally {
setSubmitting(false);
}
}
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">
需要你的确认
</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">
{payload.context}
</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => void submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span>{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
) : null}
</span>
</Button>
))}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(event) => setCustomAnswer(event.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={disabled}
className="min-h-20 resize-none"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={disabled || !customAnswer.trim()}
onClick={() => void submitAnswer(customAnswer)}
>
<SendIcon className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
+38 -18
View File
@@ -14,17 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {} interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {} interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup = type MessageGroup =
| HumanMessageGroup | HumanMessageGroup
| AssistantProcessingGroup | AssistantProcessingGroup
| AssistantMessageGroup | AssistantMessageGroup
| AssistantPresentFilesGroup | AssistantPresentFilesGroup
| AssistantClarificationGroup | AssistantClarificationGroup
| AssistantSubagentGroup; | AssistantSetupAgentApprovalGroup
| AssistantSubagentGroup;
export function groupMessages<T>( export function groupMessages<T>(
messages: Message[], messages: Message[],
@@ -41,10 +44,11 @@ export function groupMessages<T>(
function lastOpenGroup() { function lastOpenGroup() {
const last = groups[groups.length - 1]; const last = groups[groups.length - 1];
if ( if (
last && last &&
last.type !== "human" && last.type !== "human" &&
last.type !== "assistant" && last.type !== "assistant" &&
last.type !== "assistant:clarification" last.type !== "assistant:clarification" &&
last.type !== "assistant:setup-agent-approval"
) { ) {
return last; return last;
} }
@@ -66,16 +70,23 @@ export function groupMessages<T>(
} }
if (message.type === "tool") { if (message.type === "tool") {
if (isClarificationToolMessage(message)) { if (isClarificationToolMessage(message)) {
// Add to the preceding processing group to preserve tool-call association, // Add to the preceding processing group to preserve tool-call association,
// then also open a standalone clarification group for prominent display. // then also open a standalone clarification group for prominent display.
lastOpenGroup()?.messages.push(message); lastOpenGroup()?.messages.push(message);
groups.push({ groups.push({
id: message.id, id: message.id,
type: "assistant:clarification", type: "assistant:clarification",
messages: [message], messages: [message],
}); });
} else { } else if (isSetupAgentApprovalToolMessage(message)) {
lastOpenGroup()?.messages.push(message);
groups.push({
id: message.id,
type: "assistant:setup-agent-approval",
messages: [message],
});
} else {
const open = lastOpenGroup(); const open = lastOpenGroup();
if (open) { if (open) {
open.messages.push(message); open.messages.push(message);
@@ -286,10 +297,19 @@ export function hasPresentFiles(message: Message) {
); );
} }
export function isClarificationToolMessage(message: Message) { export function isClarificationToolMessage(message: Message) {
return message.type === "tool" && message.name === "ask_clarification"; return message.type === "tool" && message.name === "ask_clarification";
} }
export function isSetupAgentApprovalToolMessage(message: Message) {
return (
message.type === "tool" &&
message.name === "setup_agent" &&
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
message.additional_kwargs.setup_agent_approval !== null
);
}
export function extractPresentFilesFromMessage(message: Message) { export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) { if (message.type !== "ai" || !hasPresentFiles(message)) {
return []; return [];
+477 -517
View File
@@ -1,517 +1,477 @@
"use client"; "use client";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
BotIcon, CheckCircleIcon,
CheckCircleIcon, SaveIcon,
InfoIcon, } from "lucide-react";
MoreHorizontalIcon, import { useRouter } from "next/navigation";
SaveIcon, import { useCallback, useEffect, useMemo, useState } from "react";
} from "lucide-react"; import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react"; import {
import { toast } from "sonner"; PromptInput,
PromptInputFooter,
import { PromptInputSubmit,
PromptInput, PromptInputTextarea,
PromptInputFooter, } from "@/components/ai-elements/prompt-input";
PromptInputSubmit, import { Button } from "@/components/ui/button";
PromptInputTextarea, import { Input } from "@/components/ui/input";
} from "@/components/ai-elements/prompt-input"; import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { Button } from "@/components/ui/button"; import {
import { MessageList,
DropdownMenu, type SetupAgentApprovalPayload,
DropdownMenuContent, } from "@/components/workspace/messages";
DropdownMenuItem, import { ThreadContext } from "@/components/workspace/messages/context";
DropdownMenuTrigger, import { getAPIClient } from "@/core/api";
} from "@/components/ui/dropdown-menu"; import type { Agent } from "@/core/agents";
import { Input } from "@/components/ui/input"; import {
import { ArtifactsProvider } from "@/components/workspace/artifacts"; AgentNameCheckError,
import { MessageList } from "@/components/workspace/messages"; checkAgentName,
import { ThreadContext } from "@/components/workspace/messages/context"; createAgent,
import { getAPIClient } from "@/core/api"; getAgent,
import type { Agent } from "@/core/agents"; updateAgent,
import { } from "@/core/agents/api";
AgentNameCheckError, import { useI18n } from "@/core/i18n/hooks";
checkAgentName, import type { Skill } from "@/core/skills";
createAgent, import { useSkills } from "@/core/skills/hooks";
getAgent, import { useThreadStream } from "@/core/threads/hooks";
} from "@/core/agents/api"; import { uuid } from "@/core/utils/uuid";
import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils";
import { useSkills } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills"; const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
import { useThreadStream } from "@/core/threads/hooks"; const DEFAULT_SELECTED_SKILL_COUNT = 3;
import { uuid } from "@/core/utils/uuid";
import { isIMEComposing } from "@/lib/ime"; function wait(ms: number) {
import { cn } from "@/lib/utils"; return new Promise((resolve) => window.setTimeout(resolve, ms));
}
type Step = "name" | "chat";
type SetupAgentStatus = "idle" | "requested" | "completed"; async function getAgentWithRetry(agentId: string) {
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen"; if (delay > 0) {
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000]; await wait(delay);
const DEFAULT_SELECTED_SKILL_COUNT = 3; }
try {
function wait(ms: number) { return await getAgent(agentId);
return new Promise((resolve) => window.setTimeout(resolve, ms)); } catch {
} // Retry until the file/database write settles.
}
async function getAgentWithRetry(agentId: string) { }
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) { return null;
if (delay > 0) { }
await wait(delay);
} function getCreateAgentErrorMessage(
error: unknown,
try { networkErrorMessage: string,
return await getAgent(agentId); fallbackMessage: string,
} catch { ) {
// Retry until the write settles or the attempts are exhausted. if (error instanceof TypeError && error.message === "Failed to fetch") {
} return networkErrorMessage;
} }
if (error instanceof Error && error.message) {
return null; return error.message;
} }
return fallbackMessage;
function getCreateAgentErrorMessage( }
error: unknown,
networkErrorMessage: string, export default function NewAgentPage() {
fallbackMessage: string, const { t } = useI18n();
) { const router = useRouter();
if (error instanceof TypeError && error.message === "Failed to fetch") { const threadId = useMemo(() => uuid(), []);
return networkErrorMessage;
} const [nameInput, setNameInput] = useState("");
if (error instanceof Error && error.message) { const [descriptionInput, setDescriptionInput] = useState("");
return error.message; const [soulInput, setSoulInput] = useState("");
} const [formError, setFormError] = useState("");
return fallbackMessage; const [agentId, setAgentId] = useState("");
} const [agent, setAgent] = useState<Agent | null>(null);
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
export default function NewAgentPage() { const [isSavingDraft, setIsSavingDraft] = useState(false);
const { t } = useI18n(); const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const router = useRouter(); const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [threadPrepared, setThreadPrepared] = useState(false);
const [step, setStep] = useState<Step>("name"); const { skills, isLoading: isSkillsLoading } = useSkills();
const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState(""); const enabledSkills = useMemo(
const [isCheckingName, setIsCheckingName] = useState(false); () => skills.filter((skill) => skill.enabled),
const [isCreatingAgent, setIsCreatingAgent] = useState(false); [skills],
const [agentId, setAgentId] = useState(""); );
const [agent, setAgent] = useState<Agent | null>(null);
const [showSaveHint, setShowSaveHint] = useState(false); useEffect(() => {
const [setupAgentStatus, setSetupAgentStatus] = if (isSkillSelectionTouched) {
useState<SetupAgentStatus>("idle"); return;
const [selectedSkills, setSelectedSkills] = useState<string[]>([]); }
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false); setSelectedSkills(
const [threadPrepared, setThreadPrepared] = useState(false); enabledSkills
const { skills, isLoading: isSkillsLoading } = useSkills(); .slice(0, DEFAULT_SELECTED_SKILL_COUNT)
const enabledSkills = useMemo( .map((skill) => skill.name),
() => skills.filter((skill) => skill.enabled), );
[skills], }, [enabledSkills, isSkillSelectionTouched]);
);
const [thread, sendMessage] = useThreadStream({
const threadId = useMemo(() => uuid(), []); threadId,
assistantId: agentId || undefined,
useEffect(() => { context: {
if (isSkillSelectionTouched) { mode: "flash",
return; is_bootstrap: true,
} },
const defaults = enabledSkills onToolEnd({ name }) {
.slice(0, DEFAULT_SELECTED_SKILL_COUNT) if (name !== "setup_agent" || !agentId) {
.map((skill) => skill.name); return;
setSelectedSkills(defaults); }
}, [enabledSkills, isSkillSelectionTouched]); void getAgentWithRetry(agentId).then((fetched) => {
if (fetched) {
const [thread, sendMessage] = useThreadStream({ setAgent(fetched);
threadId: step === "chat" ? threadId : undefined, setDescriptionInput(fetched.description ?? "");
assistantId: agentId || undefined, setSoulInput(fetched.soul ?? "");
context: { setSelectedSkills(fetched.skills ?? []);
mode: "flash", toast.success("专家配置已创建");
is_bootstrap: true, }
}, });
onFinish() { },
if (!agent && setupAgentStatus === "requested") { });
setSetupAgentStatus("idle");
} const ensureThreadExists = useCallback(async () => {
}, if (threadPrepared) {
onToolEnd({ name }) { return;
if (name !== "setup_agent" || !agentId) return; }
setSetupAgentStatus("completed"); await getAPIClient().threads.create({ threadId });
void getAgentWithRetry(agentId).then((fetched) => { setThreadPrepared(true);
if (fetched) { }, [threadId, threadPrepared]);
setAgent(fetched);
return; 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
toast.error(t.agents.agentCreatedPendingRefresh); // and writes it back into the input so they can rename it later.
}); if (enabledSkills.length > 0 && selectedSkills.length === 0) {
}, setFormError("请至少选择一个技能");
}); return null;
}
const ensureThreadExists = useCallback(async () => { setFormError("");
if (threadPrepared) { return nameInput.trim();
return; }, [enabledSkills.length, nameInput, selectedSkills.length]);
}
await getAPIClient().threads.create({ const ensureAgent = useCallback(async () => {
threadId, const provided = validateForm();
}); if (provided === null) {
setThreadPrepared(true); return null;
}, [threadPrepared, threadId]); }
let name = provided;
useEffect(() => { if (!name) {
if (typeof window === "undefined" || step !== "chat") { // Names are display-only and may collide, so a timestamped placeholder is
return; // safe. Reflect it in the form so the user sees what got created.
} name = `未命名专家-${Date.now().toString(36)}`;
if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") { setNameInput(name);
return; }
}
setShowSaveHint(true); if (agentId) {
window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1"); setIsSavingDraft(true);
}, [step]); try {
const updated = await updateAgent(agentId, {
const handleConfirmName = useCallback(async () => { name,
const trimmed = nameInput.trim(); description: descriptionInput,
if (!trimmed) { soul: soulInput,
setNameError(t.agents.nameStepInvalidError); skills: selectedSkills,
return; });
} setAgent(updated);
return updated;
if (enabledSkills.length > 0 && selectedSkills.length === 0) { } catch (error) {
setNameError("请至少选择一个技能"); toast.error(error instanceof Error ? error.message : String(error));
return; return null;
} } finally {
setIsSavingDraft(false);
setNameError(""); }
setIsCheckingName(true); }
try {
await checkAgentName(trimmed); setIsCreatingAgent(true);
} catch (err) { try {
if ( await checkAgentName(name);
err instanceof AgentNameCheckError && const created = await createAgent({
err.reason === "backend_unreachable" name,
) { description: descriptionInput,
setNameError(t.agents.nameStepNetworkError); soul: soulInput,
} else { skills: selectedSkills,
setNameError(t.agents.nameStepCheckError); });
} setAgentId(created.id);
return; setAgent(created);
} finally { return created;
setIsCheckingName(false); } catch (error) {
} if (
error instanceof AgentNameCheckError &&
let createdId: string; error.reason === "backend_unreachable"
let createdName: string; ) {
setIsCreatingAgent(true); setFormError(t.agents.nameStepNetworkError);
try { } else {
const created = await createAgent({ setFormError(
name: trimmed, getCreateAgentErrorMessage(
description: "", error,
skills: selectedSkills, t.agents.nameStepNetworkError,
soul: "", t.agents.nameStepCheckError,
}); ),
createdId = created.id; );
createdName = created.name; }
} catch (err) { return null;
setNameError( } finally {
getCreateAgentErrorMessage( setIsCreatingAgent(false);
err, }
t.agents.nameStepNetworkError, }, [
t.agents.nameStepCheckError, agentId,
), descriptionInput,
); selectedSkills,
return; soulInput,
} finally { t.agents.nameStepCheckError,
setIsCreatingAgent(false); t.agents.nameStepNetworkError,
} validateForm,
]);
setAgentId(createdId);
setStep("chat"); const handleSaveAgent = useCallback(async () => {
await ensureThreadExists(); const saved = await ensureAgent();
await sendMessage(threadId, { if (!saved) {
text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName), return;
files: [], }
}); toast.success(`专家「${saved.name}」已保存`);
}, [ }, [ensureAgent]);
nameInput,
sendMessage, const handleChatSubmit = useCallback(
t.agents.nameStepNetworkError, async (text: string) => {
t.agents.nameStepBootstrapMessage, const trimmed = text.trim();
t.agents.nameStepCheckError, if (!trimmed || thread.isLoading) {
t.agents.nameStepInvalidError, return;
threadId, }
enabledSkills.length, // Lazily create the agent on first message so the user can just start
selectedSkills, // typing — ensureAgent generates a placeholder name when none is provided.
ensureThreadExists, let activeAgentId = agentId;
]); if (!activeAgentId) {
const created = await ensureAgent();
const toggleSkill = useCallback((skill: Skill) => { if (!created) {
setIsSkillSelectionTouched(true); return;
setNameError(""); }
setSelectedSkills((prev) => { activeAgentId = created.id;
if (prev.includes(skill.name)) { }
return prev.filter((name) => name !== skill.name); await ensureThreadExists();
} await sendMessage(
return [...prev, skill.name]; threadId,
}); { text: trimmed, files: [] },
}, []); { agent_id: activeAgentId },
);
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { },
if (e.key === "Enter" && !isIMEComposing(e)) { [agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId],
e.preventDefault(); );
void handleConfirmName();
} const handleApproveSetupAgent = useCallback(
}; async (payload: SetupAgentApprovalPayload) => {
if (!agentId) {
const handleChatSubmit = useCallback( return;
async (text: string) => { }
const trimmed = text.trim(); // Approval persists directly to the backend — no LLM round-trip. The
if (!trimmed || thread.isLoading) return; // backend's PUT /api/agents/{id} writes both the DB record and SOUL.md /
await ensureThreadExists(); // config.yaml on disk, which is exactly what setup_agent_tool would have
await sendMessage( // done if we'd let the model re-invoke it.
threadId, const proposedName = payload.name?.trim() ?? "";
{ text: trimmed, files: [] }, const newDescription = payload.description ?? "";
{ agent_id: agentId }, const newSoul = payload.soul ?? "";
); const newSkills = payload.skills ?? [];
},
[agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists], // 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;
const handleSaveAgent = useCallback(async () => { setNameInput(resolvedName);
if ( setDescriptionInput(newDescription);
!agentId || setSoulInput(newSoul);
agent || setSelectedSkills(newSkills);
thread.isLoading || setIsSkillSelectionTouched(true);
setupAgentStatus !== "idle"
) { try {
return; const updated = await updateAgent(agentId, {
} name: resolvedName,
description: newDescription,
setSetupAgentStatus("requested"); soul: newSoul,
setShowSaveHint(false); skills: newSkills,
try { });
await sendMessage( setAgent(updated);
threadId, toast.success("专家配置已保存");
{ text: t.agents.saveCommandMessage, files: [] }, } catch (error) {
{ agent_id: agentId }, toast.error(error instanceof Error ? error.message : "保存失败");
{ additionalKwargs: { hide_from_ui: true } }, }
); },
toast.success(t.agents.saveRequested); [agent, agentId, nameInput],
} catch (error) { );
setSetupAgentStatus("idle");
toast.error(error instanceof Error ? error.message : String(error)); const handleRejectSetupAgent = useCallback(
} async () => {
}, [ await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
agent, },
agentId, [handleChatSubmit],
sendMessage, );
setupAgentStatus,
t.agents.saveCommandMessage, const toggleSkill = useCallback((skill: Skill) => {
t.agents.saveRequested, setIsSkillSelectionTouched(true);
thread.isLoading, setFormError("");
threadId, setSelectedSkills((prev) =>
]); prev.includes(skill.name)
? prev.filter((name) => name !== skill.name)
const header = ( : [...prev, skill.name],
<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" const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
size="icon-sm"
onClick={() => router.push("/workspace/agents")} return (
> <ThreadContext.Provider value={{ thread }}>
<ArrowLeftIcon className="h-4 w-4" /> <ArtifactsProvider>
</Button> <div className="flex size-full flex-col">
<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> <div className="flex items-center gap-3">
<Button
{step === "chat" ? ( variant="ghost"
<DropdownMenu> size="icon-sm"
<DropdownMenuTrigger asChild> onClick={() => router.push("/workspace/agents")}
<Button variant="ghost" size="icon-sm" aria-label={t.agents.more}> >
<MoreHorizontalIcon className="h-4 w-4" /> <ArrowLeftIcon className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> <h1 className="text-sm font-semibold">创建专家</h1>
<DropdownMenuContent align="end"> </div>
<DropdownMenuItem {agent ? (
onSelect={() => void handleSaveAgent()} <Button
disabled={ variant="outline"
!!agent || thread.isLoading || setupAgentStatus !== "idle" size="sm"
} onClick={() => router.push(`/workspace/agents/${agent.id}/chats/new`)}
> >
<SaveIcon className="h-4 w-4" /> <CheckCircleIcon className="h-4 w-4" />
{setupAgentStatus === "requested" 开始对话
? t.agents.saving </Button>
: t.agents.save} ) : null}
</DropdownMenuItem> </header>
</DropdownMenuContent>
</DropdownMenu> <main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]">
) : null} <section className="flex min-h-0 flex-col border-r">
</header> <div className="min-h-0 flex-1">
); <MessageList
className="size-full"
if (step === "name") { threadId={threadId}
return ( thread={thread}
<div className="flex size-full flex-col"> onClarificationSubmit={handleChatSubmit}
{header} onSetupAgentApprove={handleApproveSetupAgent}
<main className="flex flex-1 flex-col items-center justify-center px-4"> onSetupAgentReject={handleRejectSetupAgent}
<div className="w-full max-w-sm space-y-8"> />
<div className="space-y-3 text-center"> </div>
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full"> <div className="bg-background shrink-0 border-t px-4 py-4">
<BotIcon className="text-primary h-7 w-7" /> <PromptInput
</div> onSubmit={({ text }) => void handleChatSubmit(text)}
<div className="space-y-1"> >
<h2 className="text-xl font-semibold"> <PromptInputTextarea
{t.agents.nameStepTitle} placeholder="直接告诉模型你想要什么样的专家,或回答模型的问题"
</h2> disabled={thread.isLoading}
<p className="text-muted-foreground text-sm"> />
{t.agents.nameStepHint} <PromptInputFooter className="justify-end">
</p> <PromptInputSubmit disabled={thread.isLoading} />
</div> </PromptInputFooter>
</div> </PromptInput>
</div>
<div className="space-y-3"> </section>
<Input
autoFocus <aside className="bg-background min-h-0 overflow-y-auto">
placeholder={t.agents.nameStepPlaceholder} <div className="space-y-5 p-4">
value={nameInput} <div>
onChange={(e) => { <h2 className="text-sm font-semibold">专家配置</h2>
setNameInput(e.target.value); <p className="text-muted-foreground mt-1 text-xs">
setNameError(""); 这些内容会作为创建专家时的初始配置。
}} </p>
onKeyDown={handleNameKeyDown} </div>
className={cn(nameError && "border-destructive")}
/> <div className="space-y-2">
{nameError ? ( <label className="text-sm font-medium">名称</label>
<p className="text-destructive text-sm">{nameError}</p> <Input
) : null} placeholder="例如:合同审查专家"
<div className="space-y-2"> value={nameInput}
<div className="flex items-center justify-between"> onChange={(event) => {
<p className="text-sm font-medium">默认技能(可自定义)</p> setNameInput(event.currentTarget.value);
<p className="text-muted-foreground text-xs"> setFormError("");
已选 {selectedSkills.length} 个 }}
</p> className={cn(formError && "border-destructive")}
</div> />
<div className="rounded-md border p-2"> </div>
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p> <div className="space-y-2">
) : enabledSkills.length === 0 ? ( <label className="text-sm font-medium">描述</label>
<p className="text-muted-foreground text-sm"> <Textarea
当前没有可用技能,将创建为无技能智能体。 placeholder="简要说明这个专家擅长什么"
</p> value={descriptionInput}
) : ( onChange={(event) =>
<div className="flex flex-wrap gap-2"> setDescriptionInput(event.currentTarget.value)
{enabledSkills.map((skill) => { }
const checked = selectedSkills.includes(skill.name); className="min-h-24 resize-none"
return ( />
<Button </div>
key={skill.name}
type="button" <div className="space-y-2">
size="sm" <label className="text-sm font-medium">SOUL.md</label>
variant={checked ? "default" : "outline"} <Textarea
onClick={() => toggleSkill(skill)} placeholder="写下专家的人设、边界、工作方式和输出要求"
className={cn( value={soulInput}
"h-8", onChange={(event) => setSoulInput(event.currentTarget.value)}
checked ? "" : "text-muted-foreground", className="min-h-48 resize-y font-mono text-xs"
)} />
title={skill.description} </div>
>
{skill.name} <div className="space-y-2">
</Button> <div className="flex items-center justify-between">
); <label className="text-sm font-medium">技能</label>
})} <span className="text-muted-foreground text-xs">
</div> 已选 {selectedSkills.length} 个
)} </span>
</div> </div>
</div> <div className="rounded-md border p-2">
<Button {isSkillsLoading ? (
className="w-full" <p className="text-muted-foreground text-sm">技能加载中...</p>
onClick={() => void handleConfirmName()} ) : enabledSkills.length === 0 ? (
disabled={ <p className="text-muted-foreground text-sm">
!nameInput.trim() || 当前没有可用技能。
isCheckingName || </p>
isCreatingAgent || ) : (
isSkillsLoading <div className="flex flex-wrap gap-2">
} {enabledSkills.map((skill) => {
> const checked = selectedSkills.includes(skill.name);
{t.agents.nameStepContinue} return (
</Button> <Button
</div> key={skill.name}
</div> type="button"
</main> size="sm"
</div> variant={checked ? "default" : "outline"}
); onClick={() => toggleSkill(skill)}
} title={skill.description}
className={cn(
return ( "h-8 max-w-full",
<ThreadContext.Provider value={{ thread }}> !checked && "text-muted-foreground",
<ArtifactsProvider> )}
<div className="flex size-full flex-col"> >
{header} <span className="truncate">{skill.name}</span>
</Button>
<main className="flex min-h-0 flex-1 flex-col"> );
{showSaveHint ? ( })}
<div className="px-4 pt-4"> </div>
<div className="mx-auto w-full max-w-(--container-width-md)"> )}
<Alert> </div>
<InfoIcon className="h-4 w-4" /> </div>
<AlertDescription>{t.agents.saveHint}</AlertDescription>
</Alert> {formError ? (
</div> <p className="text-destructive text-sm">{formError}</p>
</div> ) : null}
) : null}
<Button
<div className="flex min-h-0 flex-1 justify-center"> className="w-full"
<MessageList onClick={() => void handleSaveAgent()}
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")} disabled={busy || isSkillsLoading}
threadId={threadId} >
thread={thread} <SaveIcon className="h-4 w-4" />
onClarificationSubmit={handleChatSubmit} {agentId ? "保存专家" : "创建专家"}
/> </Button>
</div> </div>
</aside>
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4"> </main>
<div className="w-full max-w-(--container-width-md)"> </div>
{agent ? ( </ArtifactsProvider>
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center"> </ThreadContext.Provider>
<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>
);
}
@@ -89,6 +89,12 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id() return get_effective_user_id()
def _is_admin_user(request: Request) -> bool:
"""Return True when the request is authenticated as a system admin."""
user = getattr(request.state, "user", None)
return getattr(user, "system_role", None) == "admin"
def _validate_display_name(name: str) -> str: def _validate_display_name(name: str) -> str:
normalized = name.strip() normalized = name.strip()
if not normalized: if not normalized:
@@ -203,6 +209,29 @@ async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) ->
raise HTTPException(status_code=403, detail="You can only modify agents that you created") raise HTTPException(status_code=403, detail="You can only modify agents that you created")
async def _get_editable_or_raise(
store: AgentStore,
agent_id: str,
user_id: str,
*,
is_admin: bool,
) -> tuple[dict[str, Any], str]:
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
'admin' is granted only when the agent is built-in (user_id IS NULL) and the
caller is a system admin. Custom agents owned by other users still 403.
"""
owned = await store.get_owned(agent_id, user_id)
if owned is not None:
return owned, "owner"
visible = await store.get_visible(agent_id, user_id)
if visible is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
if is_admin and visible.get("user_id") is None:
return visible, "admin"
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
@router.get( @router.get(
"/agents", "/agents",
response_model=AgentsListResponse, response_model=AgentsListResponse,
@@ -322,7 +351,10 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
try: try:
store = get_agent_store(http_request) store = get_agent_store(http_request)
user_id = _current_user_id(http_request) user_id = _current_user_id(http_request)
current = await _get_owned_or_raise(store, agent_id, user_id) is_admin = _is_admin_user(http_request)
current, edit_mode = await _get_editable_or_raise(
store, agent_id, user_id, is_admin=is_admin
)
fields_set = request.model_fields_set fields_set = request.model_fields_set
update_data: dict[str, Any] = {} update_data: dict[str, Any] = {}
@@ -354,7 +386,12 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
if request.soul is not None: if request.soul is not None:
_write_agent_soul(agent_id, request.soul) _write_agent_soul(agent_id, request.soul)
record = await store.update(agent_id, user_id, update_data) if update_data else current if not update_data:
record = current
elif edit_mode == "admin":
record = await store.update_builtin(agent_id, update_data)
else:
record = await store.update(agent_id, user_id, update_data)
if record is None: if record is None:
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
@@ -8,8 +8,9 @@ from deerflow.agents.lead_agent.prompt import apply_prompt_template
from deerflow.agents.memory.summarization_hook import memory_flush_hook from deerflow.agents.memory.summarization_hook import memory_flush_hook
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware from deerflow.agents.middlewares.title_middleware import TitleMiddleware
from deerflow.agents.middlewares.todo_middleware import TodoMiddleware from deerflow.agents.middlewares.todo_middleware import TodoMiddleware
@@ -303,8 +304,10 @@ def _build_middlewares(
if custom_middlewares: if custom_middlewares:
middlewares.extend(custom_middlewares) middlewares.extend(custom_middlewares)
# ClarificationMiddleware should always be last middlewares.append(SetupAgentApprovalMiddleware())
middlewares.append(ClarificationMiddleware())
# ClarificationMiddleware should always be last
middlewares.append(ClarificationMiddleware())
return middlewares return middlewares
@@ -0,0 +1,90 @@
"""Approval middleware for the custom-agent setup tool."""
from __future__ import annotations
import logging
from collections.abc import Callable
from hashlib import sha256
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
from langgraph.graph import END
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
logger = logging.getLogger(__name__)
class SetupAgentApprovalMiddlewareState(AgentState):
"""Compatible with the DeerFlow thread state schema."""
pass
class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareState]):
"""Stop before ``setup_agent`` writes files and ask the user to approve.
The first ``setup_agent`` call is converted into a structured tool message
and the run ends. A subsequent request can set ``setup_agent_approved`` in
runtime context to allow the tool to execute.
"""
state_schema = SetupAgentApprovalMiddlewareState
def _is_approved(self, request: ToolCallRequest) -> bool:
context = getattr(request.runtime, "context", None) or {}
if not isinstance(context, dict):
return False
return context.get("setup_agent_approved") is True
def _stable_message_id(self, tool_call_id: str, args: dict[str, Any]) -> str:
if tool_call_id:
return f"setup-agent-approval:{tool_call_id}"
digest = sha256(repr(sorted(args.items())).encode("utf-8")).hexdigest()[:16]
return f"setup-agent-approval:{digest}"
def _build_payload(self, args: dict[str, Any]) -> dict[str, Any]:
skills = args.get("skills")
return {
"name": str(args.get("name") or "").strip(),
"description": str(args.get("description") or "").strip(),
"soul": str(args.get("soul") or "").strip(),
"skills": skills if isinstance(skills, list) else [],
}
def _handle_setup_agent(self, request: ToolCallRequest) -> Command:
args = request.tool_call.get("args", {}) or {}
tool_call_id = request.tool_call.get("id", "")
payload = self._build_payload(args)
logger.info("Setup agent approval requested before writing agent files")
message = ToolMessage(
id=self._stable_message_id(tool_call_id, args),
content="请确认是否创建/更新这个专家配置。",
tool_call_id=tool_call_id,
name="setup_agent",
additional_kwargs={"setup_agent_approval": payload},
)
return Command(update={"messages": [message]}, goto=END)
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request):
return handler(request)
return self._handle_setup_agent(request)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request):
return await handler(request)
return self._handle_setup_agent(request)
@@ -35,6 +35,15 @@ class AgentStore(ABC):
async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None: async def update(self, agent_id: str, user_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def update_builtin(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update a built-in agent (user_id IS NULL).
Caller is responsible for authorization — this method does NOT check
any user identity and returns ``None`` if the target is not a built-in.
"""
raise NotImplementedError
@abstractmethod @abstractmethod
async def delete(self, agent_id: str, user_id: str) -> bool: async def delete(self, agent_id: str, user_id: str) -> bool:
raise NotImplementedError raise NotImplementedError
@@ -91,6 +91,16 @@ class MemoryAgentStore(AgentStore):
row["updated_at"] = _now() row["updated_at"] = _now()
return _copy(row) return _copy(row)
async def update_builtin(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
row = self._agents.get(agent_id)
if row is None or row.get("user_id") is not None:
return None
for key in ("name", "description", "published"):
if key in data:
row[key] = data[key]
row["updated_at"] = _now()
return _copy(row)
async def delete(self, agent_id: str, user_id: str) -> bool: async def delete(self, agent_id: str, user_id: str) -> bool:
row = self._agents.get(agent_id) row = self._agents.get(agent_id)
if row is None or row.get("user_id") != user_id: if row is None or row.get("user_id") != user_id:
@@ -107,6 +107,19 @@ class AgentRepository(AgentStore):
await session.refresh(row) await session.refresh(row)
return _row_to_dict(row) return _row_to_dict(row)
async def update_builtin(self, agent_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(AgentRow, agent_id)
if row is None or row.user_id is not None:
return None
for key in ("name", "description", "published"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def delete(self, agent_id: str, user_id: str) -> bool: async def delete(self, agent_id: str, user_id: str) -> bool:
async with self._sf() as session: async with self._sf() as session:
row = await session.get(AgentRow, agent_id) row = await session.get(AgentRow, agent_id)
@@ -17,6 +17,7 @@ def setup_agent(
soul: str, soul: str,
description: str, description: str,
runtime: ToolRuntime, runtime: ToolRuntime,
name: str | None = None,
skills: list[str] | None = None, skills: list[str] | None = None,
) -> Command: ) -> Command:
"""Setup the custom DeerFlow agent. """Setup the custom DeerFlow agent.
@@ -24,6 +25,8 @@ def setup_agent(
Args: Args:
soul: Full SOUL.md content defining the agent's personality and behavior. soul: Full SOUL.md content defining the agent's personality and behavior.
description: One-line description of what the agent does. description: One-line description of what the agent does.
name: Optional human-readable display name for the agent. If omitted,
the agent's stable id is used as the display name.
skills: Optional list of skill names this agent should use. None or empty means no skills. skills: Optional list of skill names this agent should use. None or empty means no skills.
""" """
@@ -40,7 +43,8 @@ def setup_agent(
if agent_name: if agent_name:
# If agent_name is provided, we are creating a custom agent in the agents/ directory # If agent_name is provided, we are creating a custom agent in the agents/ directory
config_data: dict = {"id": agent_name, "name": agent_name} display_name = (name or "").strip() or agent_name
config_data: dict = {"id": agent_name, "name": display_name}
if description: if description:
config_data["description"] = description config_data["description"] = description
if skills is not None: if skills is not None:
@@ -52,6 +52,63 @@ async def test_agent_store_only_owner_can_update_or_delete() -> None:
assert await store.delete("private-a", "user-a") is True assert await store.delete("private-a", "user-a") is True
@pytest.mark.asyncio
async def test_update_builtin_only_targets_builtins() -> None:
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# update_builtin succeeds on a built-in agent
updated = await store.update_builtin("builtin-agent", {"name": "Renamed by admin"})
assert updated is not None
assert updated["name"] == "Renamed by admin"
# update_builtin refuses to touch a user-owned agent (returns None)
assert await store.update_builtin("owned-agent", {"name": "Hijacked"}) is None
# And on a missing agent
assert await store.update_builtin("does-not-exist", {"name": "Ghost"}) is None
@pytest.mark.asyncio
async def test_get_editable_or_raise_admin_can_edit_builtin() -> None:
"""Route helper: admin gets edit access to built-ins; non-admin still 403."""
from fastapi import HTTPException
from app.gateway.routers.agents import _get_editable_or_raise
store = MemoryAgentStore()
await store.ensure_builtin({"id": "builtin-agent", "name": "Built in"})
await store.create({"id": "owned-agent", "name": "Owned", "user_id": "user-a"})
# Owner edits their own agent → returns ('owner', record)
record, mode = await _get_editable_or_raise(
store, "owned-agent", "user-a", is_admin=False
)
assert mode == "owner" and record["id"] == "owned-agent"
# Admin edits a built-in agent → returns ('admin', record)
record, mode = await _get_editable_or_raise(
store, "builtin-agent", "user-a", is_admin=True
)
assert mode == "admin" and record["id"] == "builtin-agent"
# Non-admin tries to edit a built-in → 403
with pytest.raises(HTTPException) as exc:
await _get_editable_or_raise(store, "builtin-agent", "user-a", is_admin=False)
assert exc.value.status_code == 403
# Admin tries to edit someone else's PUBLISHED custom agent → still 403
# (admin override only applies to built-ins, not to others' custom agents).
await store.create(
{"id": "published-other", "name": "Pub", "user_id": "user-x", "published": True}
)
with pytest.raises(HTTPException) as exc:
await _get_editable_or_raise(store, "published-other", "user-a", is_admin=True)
assert exc.value.status_code == 403
def test_build_run_config_injects_custom_assistant_as_agent_id() -> None: def test_build_run_config_injects_custom_assistant_as_agent_id() -> None:
config = build_run_config("thread-1", None, None, assistant_id="agent-123") config = build_run_config("thread-1", None, None, assistant_id="agent-123")