Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server
This commit is contained in:
commit
6cc28c7edd
15 files changed
+1373
-998
No files matched your search
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(grep \"\\\\.tsx$\")",
|
|
||||||
"Bash(awk '{print $NF}')",
|
|
||||||
"Bash(xargs -I {} basename {})"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+23
-20
@@ -1,17 +1,20 @@
|
|||||||
# Workspace/editor
|
# Workspace/editor
|
||||||
.vscode/
|
.vscode/
|
||||||
frontend-web/.vscode/
|
frontend-web/.vscode/
|
||||||
|
|
||||||
# Local runtime logs and pid files
|
# Claude Code local-only settings (per-user permissions, not shared)
|
||||||
.runtime/
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Local runtime logs and pid files
|
||||||
|
.runtime/
|
||||||
|
|
||||||
# Frontend local/runtime artifacts
|
# Frontend local/runtime artifacts
|
||||||
frontend-web/.env
|
frontend-web/.env
|
||||||
frontend-web/node_modules/
|
frontend-web/node_modules/
|
||||||
frontend-web/.pnpm-store/
|
frontend-web/.pnpm-store/
|
||||||
frontend-web/tsconfig.tsbuildinfo
|
frontend-web/tsconfig.tsbuildinfo
|
||||||
frontend-web/vite-dev.log
|
frontend-web/vite-dev.log
|
||||||
frontend-web/vite-dev.err.log
|
frontend-web/vite-dev.err.log
|
||||||
|
|
||||||
# Frontend local packages/archives/docs (non-source)
|
# Frontend local packages/archives/docs (non-source)
|
||||||
frontend-web/pnpm-lock.yaml
|
frontend-web/pnpm-lock.yaml
|
||||||
@@ -52,11 +55,11 @@ offline-backend-20260512/config.yaml
|
|||||||
offline-backend-20260512/config.yaml.example
|
offline-backend-20260512/config.yaml.example
|
||||||
offline-backend-20260512/data/
|
offline-backend-20260512/data/
|
||||||
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
||||||
offline-backend-20260512/extensions_config.json
|
offline-backend-20260512/extensions_config.json
|
||||||
offline-backend-20260512/skills/
|
offline-backend-20260512/skills/
|
||||||
offline-backend-20260512/wheelhouse/
|
offline-backend-20260512/wheelhouse/
|
||||||
offline-backend-20260512/backend/.deer-flow/data/*.db
|
offline-backend-20260512/backend/.deer-flow/data/*.db
|
||||||
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
offline-backend-20260512/backend/.deer-flow/data/*.db-*
|
||||||
offline-backend-20260512/start_backend.sh
|
offline-backend-20260512/start_backend.sh
|
||||||
offline-backend-20260512/stop_backend.sh
|
offline-backend-20260512/stop_backend.sh
|
||||||
offline-backend-20260512/check_backend.sh
|
offline-backend-20260512/check_backend.sh
|
||||||
@@ -1,406 +1,418 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BotIcon,
|
BotIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
SquarePenIcon,
|
SquarePenIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
|
|
||||||
interface AgentCardProps {
|
interface AgentCardProps {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentCard({ agent }: AgentCardProps) {
|
export function AgentCard({ agent }: AgentCardProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const deleteAgent = useDeleteAgent();
|
const deleteAgent = useDeleteAgent();
|
||||||
const updateAgent = useUpdateAgent();
|
const updateAgent = useUpdateAgent();
|
||||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [editName, setEditName] = useState(agent.name);
|
const [editName, setEditName] = useState(agent.name);
|
||||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||||
const [editPublished, setEditPublished] = useState(agent.published);
|
const [editPublished, setEditPublished] = useState(agent.published);
|
||||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||||
agent.skills ?? [],
|
agent.skills ?? [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentUserId = useMemo(() => getStoredAuth()?.user_id ?? null, []);
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
const isBuiltin = agent.builtin;
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
const isOwned =
|
const isAdmin = storedAuth?.system_role === "admin";
|
||||||
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
const isBuiltin = agent.builtin;
|
||||||
const isFromOthers = !isBuiltin && !isOwned;
|
const isOwned =
|
||||||
|
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
||||||
useEffect(() => {
|
const isFromOthers = !isBuiltin && !isOwned;
|
||||||
if (!editOpen) {
|
// System admins are allowed to edit built-in agents in place. Delete and
|
||||||
return;
|
// publish-toggle stay owner-only — built-ins shouldn't be removable, and
|
||||||
}
|
// they are inherently visible to everyone so toggling published is moot.
|
||||||
setEditName(agent.name);
|
const canEdit = isOwned || (isBuiltin && isAdmin);
|
||||||
setEditDescription(agent.description ?? "");
|
|
||||||
setEditSoul(agent.soul ?? "");
|
useEffect(() => {
|
||||||
setEditPublished(agent.published);
|
if (!editOpen) {
|
||||||
setSelectedSkills(agent.skills ?? []);
|
return;
|
||||||
}, [
|
}
|
||||||
agent.description,
|
setEditName(agent.name);
|
||||||
agent.name,
|
setEditDescription(agent.description ?? "");
|
||||||
agent.published,
|
setEditSoul(agent.soul ?? "");
|
||||||
agent.skills,
|
setEditPublished(agent.published);
|
||||||
agent.soul,
|
setSelectedSkills(agent.skills ?? []);
|
||||||
editOpen,
|
}, [
|
||||||
]);
|
agent.description,
|
||||||
|
agent.name,
|
||||||
function handleChat() {
|
agent.published,
|
||||||
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
|
agent.skills,
|
||||||
}
|
agent.soul,
|
||||||
|
editOpen,
|
||||||
async function handleDelete() {
|
]);
|
||||||
try {
|
|
||||||
await deleteAgent.mutateAsync(agent.id);
|
function handleChat() {
|
||||||
toast.success(t.agents.deleteSuccess);
|
router.push(`/workspace/agents/${agent.id}/chats/new`);
|
||||||
setDeleteOpen(false);
|
}
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
async function handleDelete() {
|
||||||
}
|
try {
|
||||||
}
|
await deleteAgent.mutateAsync(agent.id);
|
||||||
|
toast.success(t.agents.deleteSuccess);
|
||||||
async function handleSaveEdit() {
|
setDeleteOpen(false);
|
||||||
const trimmedName = editName.trim();
|
} catch (err) {
|
||||||
if (!trimmedName) {
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
|
}
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
try {
|
async function handleSaveEdit() {
|
||||||
await updateAgent.mutateAsync({
|
const trimmedName = editName.trim();
|
||||||
id: agent.id,
|
if (!trimmedName) {
|
||||||
request: {
|
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
|
||||||
name: trimmedName,
|
return;
|
||||||
description: editDescription,
|
}
|
||||||
soul: editSoul,
|
try {
|
||||||
published: editPublished,
|
await updateAgent.mutateAsync({
|
||||||
skills: selectedSkills,
|
id: agent.id,
|
||||||
},
|
request: {
|
||||||
});
|
name: trimmedName,
|
||||||
toast.success("智能体已更新");
|
description: editDescription,
|
||||||
setEditOpen(false);
|
soul: editSoul,
|
||||||
} catch (err) {
|
published: editPublished,
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
skills: selectedSkills,
|
||||||
}
|
},
|
||||||
}
|
});
|
||||||
|
toast.success("智能体已更新");
|
||||||
async function handleTogglePublished() {
|
setEditOpen(false);
|
||||||
try {
|
} catch (err) {
|
||||||
await updateAgent.mutateAsync({
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
id: agent.id,
|
}
|
||||||
request: { published: !agent.published },
|
}
|
||||||
});
|
|
||||||
toast.success(agent.published ? "已取消发布" : "已发布");
|
async function handleTogglePublished() {
|
||||||
} catch (err) {
|
try {
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
await updateAgent.mutateAsync({
|
||||||
}
|
id: agent.id,
|
||||||
}
|
request: { published: !agent.published },
|
||||||
|
});
|
||||||
function toggleSkill(skillName: string) {
|
toast.success(agent.published ? "已取消发布" : "已发布");
|
||||||
setSelectedSkills((prev) => {
|
} catch (err) {
|
||||||
if (prev.includes(skillName)) {
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
return prev.filter((name) => name !== skillName);
|
}
|
||||||
}
|
}
|
||||||
return [...prev, skillName];
|
|
||||||
});
|
function toggleSkill(skillName: string) {
|
||||||
}
|
setSelectedSkills((prev) => {
|
||||||
|
if (prev.includes(skillName)) {
|
||||||
const enabledSkills = skills.filter((skill) => skill.enabled);
|
return prev.filter((name) => name !== skillName);
|
||||||
|
}
|
||||||
return (
|
return [...prev, skillName];
|
||||||
<>
|
});
|
||||||
<Card className="group flex flex-col transition-shadow hover:shadow-md">
|
}
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
const enabledSkills = skills.filter((skill) => skill.enabled);
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
return (
|
||||||
<BotIcon className="h-5 w-5" />
|
<>
|
||||||
</div>
|
<Card className="group flex flex-col transition-shadow hover:shadow-md">
|
||||||
<div className="min-w-0">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="truncate text-base">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{agent.name}
|
<div className="flex items-center gap-2">
|
||||||
</CardTitle>
|
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||||
{agent.model && (
|
<BotIcon className="h-5 w-5" />
|
||||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
</div>
|
||||||
{agent.model}
|
<div className="min-w-0">
|
||||||
</Badge>
|
<CardTitle className="truncate text-base">
|
||||||
)}
|
{agent.name}
|
||||||
</div>
|
</CardTitle>
|
||||||
</div>
|
{agent.model && (
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||||
{isBuiltin && (
|
{agent.model}
|
||||||
<Badge variant="secondary" className="text-xs">
|
</Badge>
|
||||||
内置
|
)}
|
||||||
</Badge>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{isOwned && (
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Badge
|
{isBuiltin && (
|
||||||
variant="default"
|
<Badge variant="secondary" className="text-xs">
|
||||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
内置
|
||||||
>
|
</Badge>
|
||||||
我的
|
)}
|
||||||
</Badge>
|
{isOwned && (
|
||||||
)}
|
<Badge
|
||||||
{isFromOthers && (
|
variant="default"
|
||||||
<Badge
|
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||||
variant="outline"
|
>
|
||||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
我的
|
||||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
</Badge>
|
||||||
>
|
)}
|
||||||
来自他人
|
{isFromOthers && (
|
||||||
</Badge>
|
<Badge
|
||||||
)}
|
variant="outline"
|
||||||
{isOwned && agent.published && (
|
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||||
<Badge
|
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
||||||
variant="outline"
|
>
|
||||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
来自他人
|
||||||
title="该智能体已对所有用户可见"
|
</Badge>
|
||||||
>
|
)}
|
||||||
已发布
|
{isOwned && agent.published && (
|
||||||
</Badge>
|
<Badge
|
||||||
)}
|
variant="outline"
|
||||||
</div>
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||||
</div>
|
title="该智能体已对所有用户可见"
|
||||||
{agent.description && (
|
>
|
||||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
已发布
|
||||||
{agent.description}
|
</Badge>
|
||||||
</CardDescription>
|
)}
|
||||||
)}
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
|
{agent.description && (
|
||||||
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
|
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||||
<CardContent className="pt-0 pb-3">
|
{agent.description}
|
||||||
<div className="flex flex-wrap gap-1">
|
</CardDescription>
|
||||||
{agent.tool_groups?.map((group) => (
|
)}
|
||||||
<Badge
|
</CardHeader>
|
||||||
key={`tg:${group}`}
|
|
||||||
variant="outline"
|
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
|
||||||
className="text-xs"
|
<CardContent className="pt-0 pb-3">
|
||||||
>
|
<div className="flex flex-wrap gap-1">
|
||||||
{group}
|
{agent.tool_groups?.map((group) => (
|
||||||
</Badge>
|
<Badge
|
||||||
))}
|
key={`tg:${group}`}
|
||||||
{agent.skills?.map((skill) => (
|
variant="outline"
|
||||||
<Badge
|
className="text-xs"
|
||||||
key={`sk:${skill}`}
|
>
|
||||||
variant="secondary"
|
{group}
|
||||||
className="text-xs"
|
</Badge>
|
||||||
>
|
))}
|
||||||
{skill}
|
{agent.skills?.map((skill) => (
|
||||||
</Badge>
|
<Badge
|
||||||
))}
|
key={`sk:${skill}`}
|
||||||
</div>
|
variant="secondary"
|
||||||
</CardContent>
|
className="text-xs"
|
||||||
)}
|
>
|
||||||
|
{skill}
|
||||||
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
|
</Badge>
|
||||||
<Button size="sm" className="flex-1" onClick={handleChat}>
|
))}
|
||||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
</div>
|
||||||
{t.agents.chat}
|
</CardContent>
|
||||||
</Button>
|
)}
|
||||||
{isOwned && (
|
|
||||||
<div className="flex gap-1">
|
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
|
||||||
<Button
|
<Button size="sm" className="flex-1" onClick={handleChat}>
|
||||||
size="icon"
|
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||||
variant="ghost"
|
{t.agents.chat}
|
||||||
className="h-8 w-8 shrink-0"
|
</Button>
|
||||||
onClick={() => void handleTogglePublished()}
|
{(canEdit || isOwned) && (
|
||||||
disabled={updateAgent.isPending}
|
<div className="flex gap-1">
|
||||||
title={agent.published ? "取消发布" : "发布"}
|
{isOwned && (
|
||||||
>
|
<Button
|
||||||
{agent.published ? (
|
size="icon"
|
||||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
variant="ghost"
|
||||||
) : (
|
className="h-8 w-8 shrink-0"
|
||||||
<EyeIcon className="h-3.5 w-3.5" />
|
onClick={() => void handleTogglePublished()}
|
||||||
)}
|
disabled={updateAgent.isPending}
|
||||||
</Button>
|
title={agent.published ? "取消发布" : "发布"}
|
||||||
<Button
|
>
|
||||||
size="icon"
|
{agent.published ? (
|
||||||
variant="ghost"
|
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||||
className="h-8 w-8 shrink-0"
|
) : (
|
||||||
onClick={() => setEditOpen(true)}
|
<EyeIcon className="h-3.5 w-3.5" />
|
||||||
title="编辑智能体"
|
)}
|
||||||
>
|
</Button>
|
||||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
)}
|
||||||
</Button>
|
{canEdit && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
className="h-8 w-8 shrink-0"
|
||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setEditOpen(true)}
|
||||||
title={t.agents.delete}
|
title="编辑智能体"
|
||||||
>
|
>
|
||||||
<Trash2Icon className="h-3.5 w-3.5" />
|
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
{isOwned && (
|
||||||
</CardFooter>
|
<Button
|
||||||
</Card>
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
onClick={() => setDeleteOpen(true)}
|
||||||
<DialogHeader>
|
title={t.agents.delete}
|
||||||
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
|
>
|
||||||
<DialogDescription>
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
|
</Button>
|
||||||
</DialogDescription>
|
)}
|
||||||
</DialogHeader>
|
</div>
|
||||||
<div className="space-y-4 py-2">
|
)}
|
||||||
<div className="space-y-2">
|
</CardFooter>
|
||||||
<label className="text-sm font-medium">名称</label>
|
</Card>
|
||||||
<Input
|
|
||||||
value={editName}
|
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||||
placeholder="输入智能体名称(允许中文)"
|
<DialogHeader>
|
||||||
/>
|
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
|
||||||
</div>
|
<DialogDescription>
|
||||||
<div className="space-y-2">
|
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
|
||||||
<label className="text-sm font-medium">描述</label>
|
</DialogDescription>
|
||||||
<Textarea
|
</DialogHeader>
|
||||||
value={editDescription}
|
<div className="space-y-4 py-2">
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
<div className="space-y-2">
|
||||||
placeholder="输入智能体描述"
|
<label className="text-sm font-medium">名称</label>
|
||||||
className="min-h-20"
|
<Input
|
||||||
/>
|
value={editName}
|
||||||
</div>
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
<div className="space-y-2">
|
placeholder="输入智能体名称(允许中文)"
|
||||||
<div className="flex items-center justify-between">
|
/>
|
||||||
<label className="text-sm font-medium">技能白名单</label>
|
</div>
|
||||||
<span className="text-muted-foreground text-xs">
|
<div className="space-y-2">
|
||||||
已选 {selectedSkills.length} 个
|
<label className="text-sm font-medium">描述</label>
|
||||||
</span>
|
<Textarea
|
||||||
</div>
|
value={editDescription}
|
||||||
<div className="rounded-md border p-2">
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
{isSkillsLoading ? (
|
placeholder="输入智能体描述"
|
||||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
className="min-h-20"
|
||||||
) : enabledSkills.length === 0 ? (
|
/>
|
||||||
<p className="text-muted-foreground text-sm">
|
</div>
|
||||||
当前没有可用技能
|
<div className="space-y-2">
|
||||||
</p>
|
<div className="flex items-center justify-between">
|
||||||
) : (
|
<label className="text-sm font-medium">技能白名单</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<span className="text-muted-foreground text-xs">
|
||||||
{enabledSkills.map((skill) => {
|
已选 {selectedSkills.length} 个
|
||||||
const checked = selectedSkills.includes(skill.name);
|
</span>
|
||||||
return (
|
</div>
|
||||||
<Button
|
<div className="rounded-md border p-2">
|
||||||
key={skill.name}
|
{isSkillsLoading ? (
|
||||||
type="button"
|
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||||
size="sm"
|
) : enabledSkills.length === 0 ? (
|
||||||
variant={checked ? "default" : "outline"}
|
<p className="text-muted-foreground text-sm">
|
||||||
onClick={() => toggleSkill(skill.name)}
|
当前没有可用技能
|
||||||
title={skill.description}
|
</p>
|
||||||
className="h-8"
|
) : (
|
||||||
>
|
<div className="flex flex-wrap gap-2">
|
||||||
{skill.name}
|
{enabledSkills.map((skill) => {
|
||||||
</Button>
|
const checked = selectedSkills.includes(skill.name);
|
||||||
);
|
return (
|
||||||
})}
|
<Button
|
||||||
</div>
|
key={skill.name}
|
||||||
)}
|
type="button"
|
||||||
</div>
|
size="sm"
|
||||||
<p className="text-muted-foreground text-xs">
|
variant={checked ? "default" : "outline"}
|
||||||
不选任何技能会保存为无技能(skills = [])。
|
onClick={() => toggleSkill(skill.name)}
|
||||||
</p>
|
title={skill.description}
|
||||||
</div>
|
className="h-8"
|
||||||
<div className="space-y-2">
|
>
|
||||||
<label className="text-sm font-medium">SOUL</label>
|
{skill.name}
|
||||||
<Textarea
|
</Button>
|
||||||
value={editSoul ?? ""}
|
);
|
||||||
onChange={(e) => setEditSoul(e.target.value)}
|
})}
|
||||||
placeholder="输入 SOUL.md 内容"
|
</div>
|
||||||
className="min-h-40 font-mono text-xs"
|
)}
|
||||||
/>
|
</div>
|
||||||
</div>
|
<p className="text-muted-foreground text-xs">
|
||||||
<div className="flex items-center justify-between rounded-md border p-3">
|
不选任何技能会保存为无技能(skills = [])。
|
||||||
<div>
|
</p>
|
||||||
<p className="text-sm font-medium">公开发布</p>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<div className="space-y-2">
|
||||||
打开后,该智能体对所有用户可见。
|
<label className="text-sm font-medium">SOUL</label>
|
||||||
</p>
|
<Textarea
|
||||||
</div>
|
value={editSoul ?? ""}
|
||||||
<Switch
|
onChange={(e) => setEditSoul(e.target.value)}
|
||||||
checked={editPublished}
|
placeholder="输入 SOUL.md 内容"
|
||||||
onCheckedChange={setEditPublished}
|
className="min-h-40 font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center justify-between rounded-md border p-3">
|
||||||
<DialogFooter>
|
<div>
|
||||||
<Button
|
<p className="text-sm font-medium">公开发布</p>
|
||||||
variant="outline"
|
<p className="text-muted-foreground text-xs">
|
||||||
onClick={() => setEditOpen(false)}
|
打开后,该智能体对所有用户可见。
|
||||||
disabled={updateAgent.isPending}
|
</p>
|
||||||
>
|
</div>
|
||||||
{t.common.cancel}
|
<Switch
|
||||||
</Button>
|
checked={editPublished}
|
||||||
<Button onClick={handleSaveEdit} disabled={updateAgent.isPending}>
|
onCheckedChange={setEditPublished}
|
||||||
{updateAgent.isPending ? t.common.loading : "保存修改"}
|
/>
|
||||||
</Button>
|
</div>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
<DialogFooter>
|
||||||
</Dialog>
|
<Button
|
||||||
|
variant="outline"
|
||||||
{/* Delete Confirm */}
|
onClick={() => setEditOpen(false)}
|
||||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
disabled={updateAgent.isPending}
|
||||||
<DialogContent>
|
>
|
||||||
<DialogHeader>
|
{t.common.cancel}
|
||||||
<DialogTitle>{t.agents.delete}</DialogTitle>
|
</Button>
|
||||||
<DialogDescription>{t.agents.deleteConfirm}</DialogDescription>
|
<Button onClick={handleSaveEdit} disabled={updateAgent.isPending}>
|
||||||
</DialogHeader>
|
{updateAgent.isPending ? t.common.loading : "保存修改"}
|
||||||
<DialogFooter>
|
</Button>
|
||||||
<Button
|
</DialogFooter>
|
||||||
variant="outline"
|
</DialogContent>
|
||||||
onClick={() => setDeleteOpen(false)}
|
</Dialog>
|
||||||
disabled={deleteAgent.isPending}
|
|
||||||
>
|
{/* Delete Confirm */}
|
||||||
{t.common.cancel}
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
</Button>
|
<DialogContent>
|
||||||
<Button
|
<DialogHeader>
|
||||||
variant="destructive"
|
<DialogTitle>{t.agents.delete}</DialogTitle>
|
||||||
onClick={handleDelete}
|
<DialogDescription>{t.agents.deleteConfirm}</DialogDescription>
|
||||||
disabled={deleteAgent.isPending}
|
</DialogHeader>
|
||||||
>
|
<DialogFooter>
|
||||||
{deleteAgent.isPending ? t.common.loading : t.common.delete}
|
<Button
|
||||||
</Button>
|
variant="outline"
|
||||||
</DialogFooter>
|
onClick={() => setDeleteOpen(false)}
|
||||||
</DialogContent>
|
disabled={deleteAgent.isPending}
|
||||||
</Dialog>
|
>
|
||||||
</>
|
{t.common.cancel}
|
||||||
);
|
</Button>
|
||||||
}
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteAgent.isPending}
|
||||||
|
>
|
||||||
|
{deleteAgent.isPending ? t.common.loading : t.common.delete}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
BotIcon,
|
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
InfoIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -18,17 +15,14 @@ import {
|
|||||||
PromptInputSubmit,
|
PromptInputSubmit,
|
||||||
PromptInputTextarea,
|
PromptInputTextarea,
|
||||||
} from "@/components/ai-elements/prompt-input";
|
} from "@/components/ai-elements/prompt-input";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||||
import { MessageList } from "@/components/workspace/messages";
|
import {
|
||||||
|
MessageList,
|
||||||
|
type SetupAgentApprovalPayload,
|
||||||
|
} from "@/components/workspace/messages";
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import type { Agent } from "@/core/agents";
|
import type { Agent } from "@/core/agents";
|
||||||
@@ -37,19 +31,15 @@ import {
|
|||||||
checkAgentName,
|
checkAgentName,
|
||||||
createAgent,
|
createAgent,
|
||||||
getAgent,
|
getAgent,
|
||||||
|
updateAgent,
|
||||||
} from "@/core/agents/api";
|
} from "@/core/agents/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { useSkills } from "@/core/skills/hooks";
|
|
||||||
import type { Skill } from "@/core/skills";
|
import type { Skill } from "@/core/skills";
|
||||||
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
import { useThreadStream } from "@/core/threads/hooks";
|
import { useThreadStream } from "@/core/threads/hooks";
|
||||||
import { uuid } from "@/core/utils/uuid";
|
import { uuid } from "@/core/utils/uuid";
|
||||||
import { isIMEComposing } from "@/lib/ime";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Step = "name" | "chat";
|
|
||||||
type SetupAgentStatus = "idle" | "requested" | "completed";
|
|
||||||
|
|
||||||
const SAVE_HINT_STORAGE_KEY = "deerflow.agent-create.save-hint-seen";
|
|
||||||
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
|
||||||
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
const DEFAULT_SELECTED_SKILL_COUNT = 3;
|
||||||
|
|
||||||
@@ -62,14 +52,12 @@ async function getAgentWithRetry(agentId: string) {
|
|||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
await wait(delay);
|
await wait(delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await getAgent(agentId);
|
return await getAgent(agentId);
|
||||||
} catch {
|
} catch {
|
||||||
// Retry until the write settles or the attempts are exhausted.
|
// Retry until the file/database write settles.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,60 +78,56 @@ function getCreateAgentErrorMessage(
|
|||||||
export default function NewAgentPage() {
|
export default function NewAgentPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const threadId = useMemo(() => uuid(), []);
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>("name");
|
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [nameError, setNameError] = useState("");
|
const [descriptionInput, setDescriptionInput] = useState("");
|
||||||
const [isCheckingName, setIsCheckingName] = useState(false);
|
const [soulInput, setSoulInput] = useState("");
|
||||||
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
const [formError, setFormError] = useState("");
|
||||||
const [agentId, setAgentId] = useState("");
|
const [agentId, setAgentId] = useState("");
|
||||||
const [agent, setAgent] = useState<Agent | null>(null);
|
const [agent, setAgent] = useState<Agent | null>(null);
|
||||||
const [showSaveHint, setShowSaveHint] = useState(false);
|
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||||
const [setupAgentStatus, setSetupAgentStatus] =
|
const [isSavingDraft, setIsSavingDraft] = useState(false);
|
||||||
useState<SetupAgentStatus>("idle");
|
|
||||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||||
const [threadPrepared, setThreadPrepared] = useState(false);
|
const [threadPrepared, setThreadPrepared] = useState(false);
|
||||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||||
|
|
||||||
const enabledSkills = useMemo(
|
const enabledSkills = useMemo(
|
||||||
() => skills.filter((skill) => skill.enabled),
|
() => skills.filter((skill) => skill.enabled),
|
||||||
[skills],
|
[skills],
|
||||||
);
|
);
|
||||||
|
|
||||||
const threadId = useMemo(() => uuid(), []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSkillSelectionTouched) {
|
if (isSkillSelectionTouched) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const defaults = enabledSkills
|
setSelectedSkills(
|
||||||
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
|
enabledSkills
|
||||||
.map((skill) => skill.name);
|
.slice(0, DEFAULT_SELECTED_SKILL_COUNT)
|
||||||
setSelectedSkills(defaults);
|
.map((skill) => skill.name),
|
||||||
|
);
|
||||||
}, [enabledSkills, isSkillSelectionTouched]);
|
}, [enabledSkills, isSkillSelectionTouched]);
|
||||||
|
|
||||||
const [thread, sendMessage] = useThreadStream({
|
const [thread, sendMessage] = useThreadStream({
|
||||||
threadId: step === "chat" ? threadId : undefined,
|
threadId,
|
||||||
assistantId: agentId || undefined,
|
assistantId: agentId || undefined,
|
||||||
context: {
|
context: {
|
||||||
mode: "flash",
|
mode: "flash",
|
||||||
is_bootstrap: true,
|
is_bootstrap: true,
|
||||||
},
|
},
|
||||||
onFinish() {
|
|
||||||
if (!agent && setupAgentStatus === "requested") {
|
|
||||||
setSetupAgentStatus("idle");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onToolEnd({ name }) {
|
onToolEnd({ name }) {
|
||||||
if (name !== "setup_agent" || !agentId) return;
|
if (name !== "setup_agent" || !agentId) {
|
||||||
setSetupAgentStatus("completed");
|
return;
|
||||||
|
}
|
||||||
void getAgentWithRetry(agentId).then((fetched) => {
|
void getAgentWithRetry(agentId).then((fetched) => {
|
||||||
if (fetched) {
|
if (fetched) {
|
||||||
setAgent(fetched);
|
setAgent(fetched);
|
||||||
return;
|
setDescriptionInput(fetched.description ?? "");
|
||||||
|
setSoulInput(fetched.soul ?? "");
|
||||||
|
setSelectedSkills(fetched.skills ?? []);
|
||||||
|
toast.success("专家配置已创建");
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(t.agents.agentCreatedPendingRefresh);
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -152,362 +136,339 @@ export default function NewAgentPage() {
|
|||||||
if (threadPrepared) {
|
if (threadPrepared) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await getAPIClient().threads.create({
|
await getAPIClient().threads.create({ threadId });
|
||||||
threadId,
|
|
||||||
});
|
|
||||||
setThreadPrepared(true);
|
setThreadPrepared(true);
|
||||||
}, [threadPrepared, threadId]);
|
}, [threadId, threadPrepared]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined" || step !== "chat") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window.localStorage.getItem(SAVE_HINT_STORAGE_KEY) === "1") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowSaveHint(true);
|
|
||||||
window.localStorage.setItem(SAVE_HINT_STORAGE_KEY, "1");
|
|
||||||
}, [step]);
|
|
||||||
|
|
||||||
const handleConfirmName = useCallback(async () => {
|
|
||||||
const trimmed = nameInput.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
setNameError(t.agents.nameStepInvalidError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const validateForm = useCallback(() => {
|
||||||
|
// Name is intentionally NOT required here so the user can start chatting
|
||||||
|
// immediately. If they leave it blank, ensureAgent() generates a placeholder
|
||||||
|
// and writes it back into the input so they can rename it later.
|
||||||
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
|
if (enabledSkills.length > 0 && selectedSkills.length === 0) {
|
||||||
setNameError("请至少选择一个技能");
|
setFormError("请至少选择一个技能");
|
||||||
return;
|
return null;
|
||||||
|
}
|
||||||
|
setFormError("");
|
||||||
|
return nameInput.trim();
|
||||||
|
}, [enabledSkills.length, nameInput, selectedSkills.length]);
|
||||||
|
|
||||||
|
const ensureAgent = useCallback(async () => {
|
||||||
|
const provided = validateForm();
|
||||||
|
if (provided === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let name = provided;
|
||||||
|
if (!name) {
|
||||||
|
// Names are display-only and may collide, so a timestamped placeholder is
|
||||||
|
// safe. Reflect it in the form so the user sees what got created.
|
||||||
|
name = `未命名专家-${Date.now().toString(36)}`;
|
||||||
|
setNameInput(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
setNameError("");
|
if (agentId) {
|
||||||
setIsCheckingName(true);
|
setIsSavingDraft(true);
|
||||||
try {
|
try {
|
||||||
await checkAgentName(trimmed);
|
const updated = await updateAgent(agentId, {
|
||||||
} catch (err) {
|
name,
|
||||||
if (
|
description: descriptionInput,
|
||||||
err instanceof AgentNameCheckError &&
|
soul: soulInput,
|
||||||
err.reason === "backend_unreachable"
|
skills: selectedSkills,
|
||||||
) {
|
});
|
||||||
setNameError(t.agents.nameStepNetworkError);
|
setAgent(updated);
|
||||||
} else {
|
return updated;
|
||||||
setNameError(t.agents.nameStepCheckError);
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsSavingDraft(false);
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
setIsCheckingName(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let createdId: string;
|
|
||||||
let createdName: string;
|
|
||||||
setIsCreatingAgent(true);
|
setIsCreatingAgent(true);
|
||||||
try {
|
try {
|
||||||
|
await checkAgentName(name);
|
||||||
const created = await createAgent({
|
const created = await createAgent({
|
||||||
name: trimmed,
|
name,
|
||||||
description: "",
|
description: descriptionInput,
|
||||||
|
soul: soulInput,
|
||||||
skills: selectedSkills,
|
skills: selectedSkills,
|
||||||
soul: "",
|
|
||||||
});
|
});
|
||||||
createdId = created.id;
|
setAgentId(created.id);
|
||||||
createdName = created.name;
|
setAgent(created);
|
||||||
} catch (err) {
|
return created;
|
||||||
setNameError(
|
} catch (error) {
|
||||||
getCreateAgentErrorMessage(
|
if (
|
||||||
err,
|
error instanceof AgentNameCheckError &&
|
||||||
t.agents.nameStepNetworkError,
|
error.reason === "backend_unreachable"
|
||||||
t.agents.nameStepCheckError,
|
) {
|
||||||
),
|
setFormError(t.agents.nameStepNetworkError);
|
||||||
);
|
} else {
|
||||||
return;
|
setFormError(
|
||||||
|
getCreateAgentErrorMessage(
|
||||||
|
error,
|
||||||
|
t.agents.nameStepNetworkError,
|
||||||
|
t.agents.nameStepCheckError,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingAgent(false);
|
setIsCreatingAgent(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAgentId(createdId);
|
|
||||||
setStep("chat");
|
|
||||||
await ensureThreadExists();
|
|
||||||
await sendMessage(threadId, {
|
|
||||||
text: t.agents.nameStepBootstrapMessage.replace("{name}", createdName),
|
|
||||||
files: [],
|
|
||||||
});
|
|
||||||
}, [
|
}, [
|
||||||
nameInput,
|
agentId,
|
||||||
sendMessage,
|
descriptionInput,
|
||||||
t.agents.nameStepNetworkError,
|
|
||||||
t.agents.nameStepBootstrapMessage,
|
|
||||||
t.agents.nameStepCheckError,
|
|
||||||
t.agents.nameStepInvalidError,
|
|
||||||
threadId,
|
|
||||||
enabledSkills.length,
|
|
||||||
selectedSkills,
|
selectedSkills,
|
||||||
ensureThreadExists,
|
soulInput,
|
||||||
|
t.agents.nameStepCheckError,
|
||||||
|
t.agents.nameStepNetworkError,
|
||||||
|
validateForm,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toggleSkill = useCallback((skill: Skill) => {
|
const handleSaveAgent = useCallback(async () => {
|
||||||
setIsSkillSelectionTouched(true);
|
const saved = await ensureAgent();
|
||||||
setNameError("");
|
if (!saved) {
|
||||||
setSelectedSkills((prev) => {
|
return;
|
||||||
if (prev.includes(skill.name)) {
|
|
||||||
return prev.filter((name) => name !== skill.name);
|
|
||||||
}
|
|
||||||
return [...prev, skill.name];
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key === "Enter" && !isIMEComposing(e)) {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleConfirmName();
|
|
||||||
}
|
}
|
||||||
};
|
toast.success(`专家「${saved.name}」已保存`);
|
||||||
|
}, [ensureAgent]);
|
||||||
|
|
||||||
const handleChatSubmit = useCallback(
|
const handleChatSubmit = useCallback(
|
||||||
async (text: string) => {
|
async (text: string) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || thread.isLoading) return;
|
if (!trimmed || thread.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Lazily create the agent on first message so the user can just start
|
||||||
|
// typing — ensureAgent generates a placeholder name when none is provided.
|
||||||
|
let activeAgentId = agentId;
|
||||||
|
if (!activeAgentId) {
|
||||||
|
const created = await ensureAgent();
|
||||||
|
if (!created) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeAgentId = created.id;
|
||||||
|
}
|
||||||
await ensureThreadExists();
|
await ensureThreadExists();
|
||||||
await sendMessage(
|
await sendMessage(
|
||||||
threadId,
|
threadId,
|
||||||
{ text: trimmed, files: [] },
|
{ text: trimmed, files: [] },
|
||||||
{ agent_id: agentId },
|
{ agent_id: activeAgentId },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[agentId, sendMessage, thread.isLoading, threadId, ensureThreadExists],
|
[agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveAgent = useCallback(async () => {
|
const handleApproveSetupAgent = useCallback(
|
||||||
if (
|
async (payload: SetupAgentApprovalPayload) => {
|
||||||
!agentId ||
|
if (!agentId) {
|
||||||
agent ||
|
return;
|
||||||
thread.isLoading ||
|
}
|
||||||
setupAgentStatus !== "idle"
|
// Approval persists directly to the backend — no LLM round-trip. The
|
||||||
) {
|
// backend's PUT /api/agents/{id} writes both the DB record and SOUL.md /
|
||||||
return;
|
// config.yaml on disk, which is exactly what setup_agent_tool would have
|
||||||
}
|
// done if we'd let the model re-invoke it.
|
||||||
|
const proposedName = payload.name?.trim() ?? "";
|
||||||
|
const newDescription = payload.description ?? "";
|
||||||
|
const newSoul = payload.soul ?? "";
|
||||||
|
const newSkills = payload.skills ?? [];
|
||||||
|
|
||||||
setSetupAgentStatus("requested");
|
// Prefer the model's proposed name when present; fall back to whatever
|
||||||
setShowSaveHint(false);
|
// the user already typed, or the existing agent record.
|
||||||
try {
|
const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId;
|
||||||
await sendMessage(
|
setNameInput(resolvedName);
|
||||||
threadId,
|
setDescriptionInput(newDescription);
|
||||||
{ text: t.agents.saveCommandMessage, files: [] },
|
setSoulInput(newSoul);
|
||||||
{ agent_id: agentId },
|
setSelectedSkills(newSkills);
|
||||||
{ additionalKwargs: { hide_from_ui: true } },
|
setIsSkillSelectionTouched(true);
|
||||||
);
|
|
||||||
toast.success(t.agents.saveRequested);
|
|
||||||
} catch (error) {
|
|
||||||
setSetupAgentStatus("idle");
|
|
||||||
toast.error(error instanceof Error ? error.message : String(error));
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
agent,
|
|
||||||
agentId,
|
|
||||||
sendMessage,
|
|
||||||
setupAgentStatus,
|
|
||||||
t.agents.saveCommandMessage,
|
|
||||||
t.agents.saveRequested,
|
|
||||||
thread.isLoading,
|
|
||||||
threadId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const header = (
|
try {
|
||||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
const updated = await updateAgent(agentId, {
|
||||||
<div className="flex items-center gap-3">
|
name: resolvedName,
|
||||||
<Button
|
description: newDescription,
|
||||||
variant="ghost"
|
soul: newSoul,
|
||||||
size="icon-sm"
|
skills: newSkills,
|
||||||
onClick={() => router.push("/page/workspace/agents")}
|
});
|
||||||
>
|
setAgent(updated);
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
toast.success("专家配置已保存");
|
||||||
</Button>
|
} catch (error) {
|
||||||
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
|
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||||
</div>
|
}
|
||||||
|
},
|
||||||
{step === "chat" ? (
|
[agent, agentId, nameInput],
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={t.agents.more}>
|
|
||||||
<MoreHorizontalIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => void handleSaveAgent()}
|
|
||||||
disabled={
|
|
||||||
!!agent || thread.isLoading || setupAgentStatus !== "idle"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SaveIcon className="h-4 w-4" />
|
|
||||||
{setupAgentStatus === "requested"
|
|
||||||
? t.agents.saving
|
|
||||||
: t.agents.save}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (step === "name") {
|
const handleRejectSetupAgent = useCallback(
|
||||||
return (
|
async () => {
|
||||||
<div className="flex size-full flex-col">
|
await handleChatSubmit("我暂时不批准创建这个专家配置,请继续帮我调整。");
|
||||||
{header}
|
},
|
||||||
<main className="flex flex-1 flex-col items-center justify-center px-4">
|
[handleChatSubmit],
|
||||||
<div className="w-full max-w-sm space-y-8">
|
);
|
||||||
<div className="space-y-3 text-center">
|
|
||||||
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
|
|
||||||
<BotIcon className="text-primary h-7 w-7" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h2 className="text-xl font-semibold">
|
|
||||||
{t.agents.nameStepTitle}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
{t.agents.nameStepHint}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
const toggleSkill = useCallback((skill: Skill) => {
|
||||||
<Input
|
setIsSkillSelectionTouched(true);
|
||||||
autoFocus
|
setFormError("");
|
||||||
placeholder={t.agents.nameStepPlaceholder}
|
setSelectedSkills((prev) =>
|
||||||
value={nameInput}
|
prev.includes(skill.name)
|
||||||
onChange={(e) => {
|
? prev.filter((name) => name !== skill.name)
|
||||||
setNameInput(e.target.value);
|
: [...prev, skill.name],
|
||||||
setNameError("");
|
|
||||||
}}
|
|
||||||
onKeyDown={handleNameKeyDown}
|
|
||||||
className={cn(nameError && "border-destructive")}
|
|
||||||
/>
|
|
||||||
{nameError ? (
|
|
||||||
<p className="text-destructive text-sm">{nameError}</p>
|
|
||||||
) : null}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm font-medium">默认技能(可自定义)</p>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
已选 {selectedSkills.length} 个
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border p-2">
|
|
||||||
{isSkillsLoading ? (
|
|
||||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
|
||||||
) : enabledSkills.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
当前没有可用技能,将创建为无技能智能体。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{enabledSkills.map((skill) => {
|
|
||||||
const checked = selectedSkills.includes(skill.name);
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={skill.name}
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={checked ? "default" : "outline"}
|
|
||||||
onClick={() => toggleSkill(skill)}
|
|
||||||
className={cn(
|
|
||||||
"h-8",
|
|
||||||
checked ? "" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
title={skill.description}
|
|
||||||
>
|
|
||||||
{skill.name}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => void handleConfirmName()}
|
|
||||||
disabled={
|
|
||||||
!nameInput.trim() ||
|
|
||||||
isCheckingName ||
|
|
||||||
isCreatingAgent ||
|
|
||||||
isSkillsLoading
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t.agents.nameStepContinue}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
|
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThreadContext.Provider value={{ thread }}>
|
<ThreadContext.Provider value={{ thread }}>
|
||||||
<ArtifactsProvider>
|
<ArtifactsProvider>
|
||||||
<div className="flex size-full flex-col">
|
<div className="flex size-full flex-col">
|
||||||
{header}
|
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<main className="flex min-h-0 flex-1 flex-col">
|
<Button
|
||||||
{showSaveHint ? (
|
variant="ghost"
|
||||||
<div className="px-4 pt-4">
|
size="icon-sm"
|
||||||
<div className="mx-auto w-full max-w-(--container-width-md)">
|
onClick={() => router.push("/workspace/agents")}
|
||||||
<Alert>
|
>
|
||||||
<InfoIcon className="h-4 w-4" />
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
<AlertDescription>{t.agents.saveHint}</AlertDescription>
|
</Button>
|
||||||
</Alert>
|
<h1 className="text-sm font-semibold">创建专家</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{agent ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push(`/workspace/agents/${agent.id}/chats/new`)}
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="h-4 w-4" />
|
||||||
|
开始对话
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 justify-center">
|
<main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]">
|
||||||
<MessageList
|
<section className="flex min-h-0 flex-col border-r">
|
||||||
className={cn("size-full", showSaveHint ? "pt-4" : "pt-10")}
|
<div className="min-h-0 flex-1">
|
||||||
threadId={threadId}
|
<MessageList
|
||||||
thread={thread}
|
className="size-full"
|
||||||
/>
|
threadId={threadId}
|
||||||
</div>
|
thread={thread}
|
||||||
|
onClarificationSubmit={handleChatSubmit}
|
||||||
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4">
|
onSetupAgentApprove={handleApproveSetupAgent}
|
||||||
<div className="w-full max-w-(--container-width-md)">
|
onSetupAgentReject={handleRejectSetupAgent}
|
||||||
{agent ? (
|
/>
|
||||||
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
|
|
||||||
<CheckCircleIcon className="text-primary h-10 w-10" />
|
|
||||||
<p className="font-semibold">{t.agents.agentCreated}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
router.push(
|
|
||||||
`/page/workspace/agents/${agentId}/chats/new`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t.agents.startChatting}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => router.push("/page/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>
|
||||||
</div>
|
<div className="bg-background shrink-0 border-t px-4 py-4">
|
||||||
|
<PromptInput
|
||||||
|
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||||
|
>
|
||||||
|
<PromptInputTextarea
|
||||||
|
placeholder="直接告诉模型你想要什么样的专家,或回答模型的问题"
|
||||||
|
disabled={thread.isLoading}
|
||||||
|
/>
|
||||||
|
<PromptInputFooter className="justify-end">
|
||||||
|
<PromptInputSubmit disabled={thread.isLoading} />
|
||||||
|
</PromptInputFooter>
|
||||||
|
</PromptInput>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="bg-background min-h-0 overflow-y-auto">
|
||||||
|
<div className="space-y-5 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold">专家配置</h2>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
这些内容会作为创建专家时的初始配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">名称</label>
|
||||||
|
<Input
|
||||||
|
placeholder="例如:合同审查专家"
|
||||||
|
value={nameInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
setNameInput(event.currentTarget.value);
|
||||||
|
setFormError("");
|
||||||
|
}}
|
||||||
|
className={cn(formError && "border-destructive")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">描述</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="简要说明这个专家擅长什么"
|
||||||
|
value={descriptionInput}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDescriptionInput(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
className="min-h-24 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">SOUL.md</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="写下专家的人设、边界、工作方式和输出要求"
|
||||||
|
value={soulInput}
|
||||||
|
onChange={(event) => setSoulInput(event.currentTarget.value)}
|
||||||
|
className="min-h-48 resize-y font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">技能</label>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
已选 {selectedSkills.length} 个
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border p-2">
|
||||||
|
{isSkillsLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||||
|
) : enabledSkills.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
当前没有可用技能。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{enabledSkills.map((skill) => {
|
||||||
|
const checked = selectedSkills.includes(skill.name);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={skill.name}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={checked ? "default" : "outline"}
|
||||||
|
onClick={() => toggleSkill(skill)}
|
||||||
|
title={skill.description}
|
||||||
|
className={cn(
|
||||||
|
"h-8 max-w-full",
|
||||||
|
!checked && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{skill.name}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError ? (
|
||||||
|
<p className="text-destructive text-sm">{formError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => void handleSaveAgent()}
|
||||||
|
disabled={busy || isSkillsLoading}
|
||||||
|
>
|
||||||
|
<SaveIcon className="h-4 w-4" />
|
||||||
|
{agentId ? "保存专家" : "创建专家"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</ArtifactsProvider>
|
</ArtifactsProvider>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+14
-9
@@ -459,12 +459,17 @@ class ScheduledTaskService:
|
|||||||
self._running_task_ids.discard(task["task_id"])
|
self._running_task_ids.discard(task["task_id"])
|
||||||
|
|
||||||
async def _execute_task(self, task: dict[str, Any], *, scheduled_for: datetime, manual: bool) -> None:
|
async def _execute_task(self, task: dict[str, Any], *, scheduled_for: datetime, manual: bool) -> None:
|
||||||
|
# Each run uses its own throwaway thread so message history cannot accumulate
|
||||||
|
# across runs and blow past the model's input window. The per-user
|
||||||
|
# task["scheduler_thread_id"] is no longer used to drive the LLM call —
|
||||||
|
# we keep it on the task row only for backwards compatibility.
|
||||||
|
runtime_thread_id = str(uuid.uuid4())
|
||||||
run_row = await self.store.create_run_once(
|
run_row = await self.store.create_run_once(
|
||||||
{
|
{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"task_id": task["task_id"],
|
"task_id": task["task_id"],
|
||||||
"user_id": task["user_id"],
|
"user_id": task["user_id"],
|
||||||
"scheduler_thread_id": task["scheduler_thread_id"],
|
"scheduler_thread_id": runtime_thread_id,
|
||||||
"scheduled_for": scheduled_for,
|
"scheduled_for": scheduled_for,
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"started_at": datetime.now(UTC),
|
"started_at": datetime.now(UTC),
|
||||||
@@ -478,16 +483,16 @@ class ScheduledTaskService:
|
|||||||
await self.store.update_task(task["task_id"], task["user_id"], {"next_run_at": next_run_at, "last_status": "running"})
|
await self.store.update_task(task["task_id"], task["user_id"], {"next_run_at": next_run_at, "last_status": "running"})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._ensure_thread_meta(task["user_id"], task["scheduler_thread_id"])
|
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
|
||||||
async with self._agent_run_lock:
|
async with self._agent_run_lock:
|
||||||
output_snapshot = self._snapshot_result_files(task["scheduler_thread_id"], task["user_id"])
|
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"])
|
||||||
record = await self._start_agent_run(task, scheduled_for=scheduled_for)
|
record = await self._start_agent_run(task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
|
||||||
await self.store.update_run(run_row["id"], {"agent_run_id": record.run_id})
|
await self.store.update_run(run_row["id"], {"agent_run_id": record.run_id})
|
||||||
if record.task is not None:
|
if record.task is not None:
|
||||||
await record.task
|
await record.task
|
||||||
result_text = await self._extract_latest_ai_text(task["scheduler_thread_id"])
|
result_text = await self._extract_latest_ai_text(runtime_thread_id)
|
||||||
result_files = await self._extract_result_files(
|
result_files = await self._extract_result_files(
|
||||||
task["scheduler_thread_id"],
|
runtime_thread_id,
|
||||||
task["user_id"],
|
task["user_id"],
|
||||||
run_id=run_row["id"],
|
run_id=run_row["id"],
|
||||||
output_snapshot=output_snapshot,
|
output_snapshot=output_snapshot,
|
||||||
@@ -542,7 +547,7 @@ class ScheduledTaskService:
|
|||||||
finally:
|
finally:
|
||||||
reset_current_user(token)
|
reset_current_user(token)
|
||||||
|
|
||||||
async def _start_agent_run(self, task: dict[str, Any], *, scheduled_for: datetime):
|
async def _start_agent_run(self, task: dict[str, Any], *, scheduled_for: datetime, runtime_thread_id: str):
|
||||||
from app.gateway.routers.thread_runs import RunCreateRequest
|
from app.gateway.routers.thread_runs import RunCreateRequest
|
||||||
from app.gateway.services import start_run
|
from app.gateway.services import start_run
|
||||||
|
|
||||||
@@ -555,7 +560,7 @@ class ScheduledTaskService:
|
|||||||
execution_context.update(
|
execution_context.update(
|
||||||
{
|
{
|
||||||
"is_scheduled_run": True,
|
"is_scheduled_run": True,
|
||||||
"thread_id": task["scheduler_thread_id"],
|
"thread_id": runtime_thread_id,
|
||||||
"thinking_enabled": execution_context.get("thinking_enabled", True),
|
"thinking_enabled": execution_context.get("thinking_enabled", True),
|
||||||
"is_plan_mode": execution_context.get("is_plan_mode", True),
|
"is_plan_mode": execution_context.get("is_plan_mode", True),
|
||||||
}
|
}
|
||||||
@@ -572,7 +577,7 @@ class ScheduledTaskService:
|
|||||||
context=execution_context,
|
context=execution_context,
|
||||||
multitask_strategy="enqueue" if False else "reject",
|
multitask_strategy="enqueue" if False else "reject",
|
||||||
)
|
)
|
||||||
return await start_run(body, task["scheduler_thread_id"], request) # type: ignore[arg-type]
|
return await start_run(body, runtime_thread_id, request) # type: ignore[arg-type]
|
||||||
finally:
|
finally:
|
||||||
reset_current_user(token)
|
reset_current_user(token)
|
||||||
|
|
||||||
|
|||||||
+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