智能体配置
This commit is contained in:
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+90
@@ -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
|
||||||
+10
@@ -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)
|
||||||
|
|||||||
+5
-1
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user