Files
deerflow-code/frontend-web/src/components/workspace/agents/agent-card.tsx
T
董宏凯 5be21f2c3d Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-zl
# Conflicts:
#	frontend-web/src/components/workspace/agents/agent-card.tsx
#	frontend-web/src/components/workspace/custom-prompt-menu.tsx
2026-08-26 17:45:39 +08:00

1281 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
ClockIcon,
CopyIcon,
EyeOffIcon,
GlobeIcon,
LockIcon,
MessageSquareIcon,
MoreHorizontalIcon,
PinIcon,
ShieldIcon,
SparklesIcon,
SquarePenIcon,
StarIcon,
TagsIcon,
Trash2Icon,
UserIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { usePublishSquares } from "@/core/system-settings/hooks";
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { AgentBrandIcon } from "@/components/workspace/agents/agent-brand-icon";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
normalizeAgentQuestions,
useAgent,
useCreateAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useToggleAgentPin,
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent, AgentQuestion, UpdateAgentRequest } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import { formatDateTime } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
interface AgentCardProps {
agent: Agent;
/** "card" (default) renders the full gallery card; "list" renders a compact
* single-row layout for the list view. All dialogs/actions are shared. */
layout?: "card" | "list";
/** Click the 发布者 line to filter the gallery to that publisher. Receives the
* owner user_id + its desensitized display label. */
onPublisherClick?: (publisher: { id: string; label: string }) => void;
}
interface BadgeItem {
key: string;
node: ReactNode;
}
// Minimum number of skill/tool badges to always render before collapsing the
// remainder into the "+N" chip.
const MIN_VISIBLE_BADGES = 3;
/** Render skill/tool-group badges clamped to at most two rows. Overflow is
* collapsed into a clickable "+N" chip that opens a dialog listing the full
* badge set (the visible rows stay as-is — only the chip is interactive).
*
* Badge widths are variable, so the cutoff is measured rather than assumed: an
* off-screen mirror of the full badge set is laid out at the real container
* width, and we keep only the badges that land within the first two rows
* (reserving one slot for the "+N" chip). A ResizeObserver recomputes when the
* responsive grid changes the card width.
*/
function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
const measureRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(items.length);
const [dialogOpen, setDialogOpen] = useState(false);
const sig = items.map((i) => i.key).join("|");
useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const compute = () => {
const kids = Array.from(el.children) as HTMLElement[];
if (kids.length === 0) {
setVisible(0);
return;
}
const top0 = kids[0].offsetTop;
const rowH = kids[0].offsetHeight;
// +2px fudge so the second row's badges (which may sit a hair lower due
// to line-box rounding) are not falsely treated as a third row.
const maxBottom = top0 + rowH * 2 + 2;
let count = 0;
for (const k of kids) {
if (k.offsetTop + k.offsetHeight <= maxBottom) count += 1;
else break;
}
// Reserve one slot for the "+N" chip when anything overflows so the chip
// itself never spills onto a third row.
if (count < kids.length && count > 0) count -= 1;
// Always surface a few badges before collapsing into "+N", even when the
// card is narrow enough that two rows barely fit anything (otherwise a
// skinny column could show just "+N" with no skills at all). Keeps at
// least one badge hidden so the chip still makes sense.
if (count < kids.length) {
count = Math.max(count, Math.min(MIN_VISIBLE_BADGES, kids.length - 1));
}
setVisible(count);
};
compute();
const ro = new ResizeObserver(compute);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sig]);
const hidden = items.length - visible;
const showChip = hidden > 0;
const shown = items.slice(0, visible);
return (
<div className="relative">
{/* Off-screen full-width mirror used only for measuring row breaks. */}
<div
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute inset-x-0 top-0 flex flex-wrap gap-1"
>
{items.map((i) => i.node)}
</div>
<div className="flex flex-wrap gap-1">
{shown.map((i) => i.node)}
{showChip && (
<Badge
variant="secondary"
className="hover:bg-secondary/70 cursor-pointer text-xs"
title="查看全部"
onClick={(e) => {
e.stopPropagation();
setDialogOpen(true);
}}
>
+{hidden}
</Badge>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>全部技能与工具</DialogTitle>
<DialogDescription>共 {items.length} 项</DialogDescription>
</DialogHeader>
<div className="flex max-h-[60vh] flex-wrap gap-1.5 overflow-y-auto py-1">
{items.map((i) => i.node)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCardProps) {
const isList = layout === "list";
const { t } = useI18n();
const router = useRouter();
const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent();
const createAgent = useCreateAgent();
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const togglePin = useToggleAgentPin();
const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [editName, setEditName] = useState(agent.name);
const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
// Common questions (常用问题) — primary editing now lives in the agent chat
// page's right-hand AgentDetailPanel; this dialog stays as a backup entry.
// Each row has a short title (shown on the chip) and the full prompt sent
// when clicked.
const [editCommonQuestions, setEditCommonQuestions] = useState<AgentQuestion[]>(
() => normalizeAgentQuestions(agent.common_questions),
);
const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
// 会商席位「技能识别强化」开关(弱模型补偿)。默认关。
const [editSeatSkillDirective, setEditSeatSkillDirective] = useState(
agent.seat_skill_directive ?? false,
);
const [editSquareId, setEditSquareId] = useState(agent.square_id ?? "");
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
const { data: publishSquares } = usePublishSquares();
const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [],
);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// The /api/agents list response intentionally omits `soul` (perf optimization
// — SOUL.md is only read on demand). Fetch the detail row when the edit
// dialog opens so the SOUL textarea can be hydrated.
const { agent: agentDetail, isLoading: isAgentDetailLoading } = useAgent(
editOpen ? agent.id : null,
);
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
// 发布请求实际以「实时」token 执行 —— 用 live 账号来提示发布者并检测账号是否被切换。
const live = useLiveAccount();
const isAdmin = storedAuth?.system_role === "admin";
const isBuiltin = agent.builtin;
const isOwned =
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
const isFromOthers = !isBuiltin && !isOwned;
// System admins are allowed to edit and delete any agent (built-in or owned
// by another user) so the admin console can manage the full catalog.
// Publish-toggle stays owner-only — built-ins are inherently visible to
// everyone so toggling published is moot.
const canEdit = isOwned || isAdmin;
const canDelete = isOwned || isAdmin;
// Favoriting collects a square agent into "我的"; pointless for one you
// already own, so only offer it on built-in / others' agents.
const canFavorite = !isOwned;
const isFavorite = !!agent.is_favorite;
const isPinned = !!agent.is_pinned;
useEffect(() => {
if (!editOpen) {
return;
}
setEditName(agent.name);
setEditDescription(agent.description ?? "");
setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setEditSquareId(agent.square_id ?? "");
setSelectedSkills(agent.skills ?? []);
setEditCommonQuestions(normalizeAgentQuestions(agent.common_questions));
setEditSeatSkillDirective(agent.seat_skill_directive ?? false);
}, [
agent.common_questions,
agent.description,
agent.is_private,
agent.name,
agent.published,
agent.square_id,
agent.skills,
agent.seat_skill_directive,
editOpen,
]);
useEffect(() => {
if (!editOpen) {
return;
}
// SOUL.md only ships on the detail endpoint; fall back to whatever the list
// happened to carry so older deployments (or cached objects that still
// include `soul`) keep working.
if (agentDetail) {
setEditSoul(agentDetail.soul ?? "");
setEditCommonQuestions(normalizeAgentQuestions(agentDetail.common_questions));
// 列表响应不带 config 额外字段(seat_skill_directive 为 null),详情才有真值。
setEditSeatSkillDirective(agentDetail.seat_skill_directive ?? false);
} else {
setEditSoul(agent.soul ?? "");
}
}, [agent.soul, agentDetail, editOpen]);
function handleChat() {
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
}
async function handleDelete() {
try {
await deleteAgent.mutateAsync(agent.id);
toast.success(t.agents.deleteSuccess);
setDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDuplicate() {
if (createAgent.isPending) {
return;
}
try {
// SOUL.md is omitted from the list payload (perf), so fetch the detail
// row to carry the source agent's personality into the copy. Fall back
// to whatever the list happened to include if the detail fetch fails.
let soul = agent.soul ?? "";
try {
const detail = await getAgent(agent.id);
soul = detail.soul ?? soul;
} catch {
// keep the list fallback
}
const created = await createAgent.mutateAsync({
name: `${agent.name} 副本`,
description: agent.description ?? "",
soul,
skills: agent.skills ?? [],
model: agent.model ?? null,
tool_groups: agent.tool_groups ?? null,
// A copy starts as a private, unpublished draft the owner can refine
// before sharing — never inherit the source's published/private state.
published: false,
is_private: false,
});
toast.success(
`已复制为「${desensitize(created.name)}」,可在「我的」中编辑`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleSaveEdit() {
const trimmedName = editName.trim();
if (!trimmedName) {
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
return;
}
if (editIsPrivate && editPublished) {
toast.warning("私密广场与公开发布互斥,请只勾选其一");
return;
}
// Private agents cannot also be published — force the value the
// backend will see, regardless of any stale state in editPublished.
const nextPublished = editIsPrivate ? false : editPublished;
const nextQuestions = editCommonQuestions
.map((q) => ({
title: q.title.trim() || q.prompt.trim(),
prompt: q.prompt.trim() || q.title.trim(),
}))
.filter((q) => q.prompt.length > 0);
// Only send the fields that actually changed: the backend merges partial
// updates (model_fields_set), so e.g. a 常用问题 edit no longer rewrites
// skills/SOUL and saving stays fast.
const baselineQuestions = normalizeAgentQuestions(
agentDetail?.common_questions ?? agent.common_questions,
);
const request: UpdateAgentRequest = {};
if (trimmedName !== agent.name) request.name = trimmedName;
if (editDescription !== (agent.description ?? "")) request.description = editDescription;
if (editSoul !== (agentDetail?.soul ?? agent.soul ?? "")) request.soul = editSoul;
if (nextPublished !== agent.published) request.published = nextPublished;
if (editIsPrivate !== agent.is_private) request.is_private = editIsPrivate;
if ((editSquareId || "") !== (agent.square_id ?? "")) request.square_id = editSquareId;
if (JSON.stringify(selectedSkills) !== JSON.stringify(agent.skills ?? []))
request.skills = selectedSkills;
if (JSON.stringify(nextQuestions) !== JSON.stringify(baselineQuestions))
request.common_questions = nextQuestions;
if (editSeatSkillDirective !== ((agentDetail?.seat_skill_directive ?? agent.seat_skill_directive) ?? false))
request.seat_skill_directive = editSeatSkillDirective;
if (Object.keys(request).length === 0) {
setEditOpen(false);
return;
}
try {
await updateAgent.mutateAsync({ id: agent.id, request });
toast.success("智能体已更新");
setEditOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePublished() {
// Private and public squares are mutually exclusive — a private agent
// can't simultaneously be published to everyone. Block the toggle
// instead of silently moving the agent between squares.
if (!agent.published && agent.is_private) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
// Publishing: prompt for which square to publish into (when any squares are
// configured). Unpublishing: just flip it off.
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
setPublishPickerOpen(true);
return;
}
if (!warnIfAccountSwitched()) {
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: !agent.published },
});
toast.success(
agent.published ? "已取消发布" : `已发布(操作账号:${live.displayName})`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
// 发布操作以实时 token 执行。若会话中途账号被切换(常见于另一标签页登录了别的
// 账号),先弹一次确认 —— 避免误以本人之外的账号执行发布。返回 false = 用户取消。
function warnIfAccountSwitched(): boolean {
if (!live.switched) {
return true;
}
return window.confirm(
`当前登录账号已从「${live.mountDisplayName}」切换为「${live.displayName}」。\n` +
`本次发布将以「${live.displayName}」执行。如需以「${live.mountDisplayName}」操作,请取消并刷新页面。\n\n仍要继续吗?`,
);
}
async function handlePublishToSquare(squareId: string) {
if (!warnIfAccountSwitched()) {
return;
}
try {
await updateAgent.mutateAsync({
id: agent.id,
request: { published: true, square_id: squareId },
});
const sqName =
publishSquares?.squares.find((s) => s.id === squareId)?.name ?? "广场";
toast.success(`已发布到「${sqName}」(操作账号:${live.displayName})`);
setPublishPickerOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePrivate() {
const turningOn = !agent.is_private;
try {
if (isOwned) {
// Owners can re-categorize their own agents through the normal update
// endpoint — keeps publish/private edits in one transaction.
await updateAgent.mutateAsync({
id: agent.id,
request: { is_private: turningOn },
});
} else {
// Admins can move any agent (built-in or other users') via the
// dedicated admin endpoint.
await setPrivate.mutateAsync({ id: agent.id, isPrivate: turningOn });
}
toast.success(turningOn ? "已加入私密广场" : "已移出私密广场");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFavorite() {
const next = !isFavorite;
try {
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
toast.success(next ? "已收藏到「我的」" : "已取消收藏");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePin() {
const next = !isPinned;
try {
await togglePin.mutateAsync({ id: agent.id, pinned: next });
toast.success(next ? "已置顶" : "已取消置顶");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
// dialog. Featured agents get appended to the end on add (Date.now() is a
// safe seed; the manage dialog can rewrite to 0..N-1 later).
const turningOn = agent.featured_order == null;
if (turningOn && !agent.published) {
toast.error("只有已发布的智能体可以加入首页选择器");
return;
}
try {
await setFeatured.mutateAsync({
id: agent.id,
order: turningOn ? Date.now() : null,
});
toast.success(turningOn ? "已加入首页选择器" : "已从首页选择器移除");
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled),
[skills],
);
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
// both the card header and the compact list row.
const statusBadges = (
<>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
内置
</Badge>
)}
{isOwned && (
<Badge
variant="default"
className="border border-emerald-200 bg-emerald-50 text-xs text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300"
>
我的
</Badge>
)}
{isFromOthers && (
<Badge
variant="outline"
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
>
来自他人
</Badge>
)}
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-primary/20 bg-primary/5 text-primary text-xs dark:border-primary/40 dark:bg-primary/10 dark:text-primary"
title="该智能体已对所有用户可见"
>
<GlobeIcon className="size-3" />
已发布
</Badge>
)}
{(isOwned || isAdmin) && !isBuiltin && !agent.published && !agent.is_private && (
<Badge
variant="outline"
className="border-zinc-400/40 text-zinc-500 dark:text-zinc-400 text-xs"
title="该智能体尚未发布,仅所有者可见"
>
<EyeOffIcon className="size-3" />
未发布
</Badge>
)}
{agent.is_private && (
<Badge
variant="outline"
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
title="该智能体位于私密广场,需要密码才能查看"
>
<ShieldIcon className="size-3" />
私密
</Badge>
)}
{agent.featured_order != null && (
<Badge
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
title="已加入首页选择器"
>
<SparklesIcon className="size-3" />
首页推荐
</Badge>
)}
</>
);
// 发布者(仅自定义/已发布的智能体显示;内置无 owner 不显示)。点击可按发布者筛选。
const publisherLabel = agent.owner_username
? desensitize(agent.owner_username)
: "";
const canFilterByPublisher =
!isBuiltin && !!agent.owner_username && !!agent.user_id && !!onPublisherClick;
const publisherNode =
!isBuiltin && agent.owner_username ? (
<button
type="button"
disabled={!canFilterByPublisher}
onClick={(e) => {
if (!canFilterByPublisher) return;
e.stopPropagation();
onPublisherClick!({ id: agent.user_id!, label: publisherLabel });
}}
className={cn(
"text-muted-foreground flex max-w-full items-center gap-1 text-sm font-medium",
canFilterByPublisher &&
"hover:text-primary cursor-pointer transition-colors hover:underline",
)}
title={
canFilterByPublisher
? `点击筛选发布者:${publisherLabel}`
: `发布者:${publisherLabel}`
}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">发布者:{publisherLabel}</span>
</button>
) : null;
const createdAtText = formatDateTime(agent.created_at);
const createdAtNode = createdAtText ? (
<div
className="text-muted-foreground flex max-w-full items-center gap-1 text-xs"
title={`创建时间:${createdAtText}`}
>
<ClockIcon className="size-3.5 shrink-0" />
<span className="truncate">创建时间:{createdAtText}</span>
</div>
) : null;
// 技能英文名 → 中文名映射,用于把智能体的技能徽章显示为中文(agent.skills 只存英文名)。
const skillNameZhMap = useMemo(() => {
const map = new Map<string, string>();
for (const s of skills) {
if (s.name_zh?.trim()) map.set(s.name, s.name_zh.trim());
}
return map;
}, [skills]);
const skillBadgeItems = [
...(agent.tool_groups ?? []).map((group) => ({
key: `tg:${group}`,
node: (
<Badge
key={`tg:${group}`}
variant="outline"
className="border-0 bg-primary/5 text-primary hover:bg-primary/10 px-2.5 py-1 text-xs font-normal"
>
{desensitize(group)}
</Badge>
),
})),
...(agent.skills ?? []).map((skill) => ({
key: `sk:${skill}`,
node: (
<Badge
key={`sk:${skill}`}
variant="secondary"
className="bg-primary/5 text-primary hover:bg-primary/10 cursor-pointer border-0 px-2.5 py-1 text-xs font-normal"
title={t.settings.skills.viewDetailAction}
onClick={(e) => {
e.stopPropagation();
setDetailSkillName(skill);
}}
>
{skillNameZhMap.get(skill) ?? desensitize(skill)}
</Badge>
),
})),
];
const hasSkillsOrTools =
(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0;
const showActionRow =
canEdit || canDelete || isOwned || isAdmin || canFavorite;
// The icon action buttons, shared by both layouts.
const actionButtons = (
<>
{canFavorite && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
!isList && "mr-auto",
isFavorite && "text-amber-500 hover:text-amber-600",
)}
onClick={() => void handleToggleFavorite()}
disabled={toggleFavorite.isPending}
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
>
<StarIcon
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
/>
</Button>
)}
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
isPinned && "text-emerald-600 hover:text-emerald-700",
!isList && !canFavorite && "mr-auto",
)}
onClick={() => void handleTogglePin()}
disabled={togglePin.isPending}
title={isPinned ? "取消置顶" : "置顶"}
>
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
</Button>
{isAdmin && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.featured_order != null &&
"text-amber-600 hover:text-amber-700",
)}
onClick={() => void handleToggleFeatured()}
disabled={setFeatured.isPending}
title={
agent.featured_order != null
? "从首页选择器移除"
: "加入首页选择器"
}
>
<SparklesIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setTagPickerOpen(true)}
title={t.adminTags.manageTags}
>
<TagsIcon className="h-3.5 w-3.5" />
</Button>
{isOwned && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleTogglePublished()}
disabled={updateAgent.isPending}
title={agent.published ? "取消发布" : "发布"}
>
{agent.published ? (
<LockIcon className="h-3.5 w-3.5" />
) : (
<GlobeIcon className="h-3.5 w-3.5" />
)}
</Button>
)}
{(isOwned || isAdmin) && (
<Button
size="icon"
variant="ghost"
className={cn(
"h-8 w-8 shrink-0",
agent.is_private && "text-purple-600 hover:text-purple-700",
)}
onClick={() => void handleTogglePrivate()}
disabled={updateAgent.isPending || setPrivate.isPending}
title={agent.is_private ? "移出私密广场" : "放入私密广场"}
>
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleDuplicate()}
disabled={createAgent.isPending}
title="复制为新智能体"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canEdit && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => setEditOpen(true)}
title="编辑智能体"
>
<SquarePenIcon className="h-3.5 w-3.5" />
</Button>
)}
{canDelete && (
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setDeleteOpen(true)}
title={t.agents.delete}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
)}
</>
);
const chatButton = (
<Button
variant={isList ? "default" : "outline"}
size="sm"
className={cn(
isList
? "bg-primary hover:bg-primary/90 shrink-0 rounded-xl px-4 font-medium text-primary-foreground"
: "border-primary/35 text-primary hover:border-primary/55 hover:bg-primary/5 h-11 w-full rounded-lg bg-white font-medium shadow-none dark:bg-slate-900 dark:text-primary",
)}
onClick={handleChat}
>
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
开始对话
</Button>
);
const cardStatusBadge = agent.is_private ? (
<Badge className="border-0 bg-purple-50 px-2.5 py-1 text-xs font-normal text-purple-600 hover:bg-purple-50 dark:bg-purple-500/15 dark:text-purple-300">
<span className="mr-1.5 size-1.5 rounded-full bg-purple-500" />
私密
</Badge>
) : agent.published || isBuiltin ? (
<Badge className="border-0 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-normal dark:bg-primary/15 dark:text-primary">
<span className="bg-primary mr-1.5 size-1.5 rounded-full" />
已发布
</Badge>
) : (
<Badge className="border-0 bg-emerald-50 px-2.5 py-1 text-xs font-normal text-emerald-600 hover:bg-emerald-50 dark:bg-emerald-500/15 dark:text-emerald-300">
<span className="mr-1.5 size-1.5 rounded-full bg-emerald-500" />
运行中
</Badge>
);
const updatedAtText = formatDateTime(agent.updated_at ?? agent.created_at);
const cardActionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-8 rounded-lg text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-white"
title="更多操作"
>
<MoreHorizontalIcon className="size-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{canFavorite && (
<DropdownMenuItem onSelect={() => void handleToggleFavorite()}>
<StarIcon className={cn("size-4", isFavorite && "fill-current text-amber-500")} />
{isFavorite ? "取消收藏" : "收藏到我的"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => void handleTogglePin()}>
<PinIcon className={cn("size-4", isPinned && "fill-current text-emerald-600")} />
{isPinned ? "取消置顶" : "置顶"}
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem onSelect={() => void handleToggleFeatured()}>
<SparklesIcon className="size-4" />
{agent.featured_order != null ? "取消推荐" : "设为推荐"}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => setTagPickerOpen(true)}>
<TagsIcon className="size-4" />
{t.adminTags.manageTags}
</DropdownMenuItem>
{isOwned && (
<DropdownMenuItem onSelect={() => void handleTogglePublished()}>
{agent.published ? <LockIcon className="size-4" /> : <GlobeIcon className="size-4" />}
{agent.published ? "取消发布" : "发布"}
</DropdownMenuItem>
)}
{(isOwned || isAdmin) && (
<DropdownMenuItem onSelect={() => void handleTogglePrivate()}>
<ShieldIcon className="size-4" />
{agent.is_private ? "移出私密广场" : "放入私密广场"}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDuplicate()}>
<CopyIcon className="size-4" />
复制智能体
</DropdownMenuItem>
{canEdit && (
<DropdownMenuItem onSelect={() => setEditOpen(true)}>
<SquarePenIcon className="size-4" />
编辑智能体
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-4" />
{t.agents.delete}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
const cardLayout = (
<Card
className={cn(
"group flex min-h-[334px] min-w-0 flex-col gap-0 overflow-hidden rounded-2xl border-slate-200/90 bg-white py-0 shadow-[0_5px_18px_rgba(15,23,42,0.035)] transition-[background-color,border-color,box-shadow] duration-200 hover:!border-primary/30 hover:!bg-primary/5 hover:!ring-1 hover:!ring-primary/10 hover:shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:border-slate-800 dark:bg-slate-900",
agent.featured_order != null && "border-primary/40 ring-1 ring-primary/15 dark:border-primary/45 dark:ring-primary/20",
)}
data-agent-guide="agent-card"
>
<CardHeader className="min-w-0 gap-0 px-5 pt-5 pb-2">
{agent.featured_order != null ? (
<>
<div className="mb-1 flex h-7 items-center justify-between">
<Badge className="border border-primary/20 bg-primary/5 text-primary hover:bg-primary/5 px-2.5 py-1 text-xs font-medium dark:border-primary/40 dark:bg-primary/15 dark:text-primary">
<SparklesIcon className="mr-1 size-3.5 fill-current" />
推荐
</Badge>
{cardActionsMenu}
</div>
<AgentBrandIcon size="lg" />
</>
) : (
<div className="flex items-start justify-between">
<AgentBrandIcon size="lg" />
{cardActionsMenu}
</div>
)}
<CardTitle
className="mt-3 block w-full min-w-0 overflow-hidden text-[18px] leading-6 font-semibold tracking-[-0.015em] text-ellipsis whitespace-nowrap text-slate-950 dark:text-white"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</CardTitle>
{agent.description && (
<CardDescription className="mt-1.5 min-h-11 line-clamp-2 text-[14px] leading-[22px] text-slate-600 dark:text-slate-400">
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
{(agent.tags?.length ?? 0) > 0 && (
<CardContent className="px-5 pt-0 pb-1.5">
<TagChipList tags={agent.tags!} />
</CardContent>
)}
{hasSkillsOrTools && (
<CardContent className="px-5 pt-0 pb-2">
<ClampedBadgeRow items={skillBadgeItems} />
</CardContent>
)}
<CardFooter className="mt-auto flex flex-col items-stretch gap-4 px-5 pt-2 pb-4">
<div className="flex min-w-0 items-center gap-3">
{cardStatusBadge}
<span
className="min-w-0 truncate text-xs text-slate-400"
title={updatedAtText ? `更新于 ${updatedAtText}` : undefined}
>
{updatedAtText ? `更新于 ${updatedAtText}` : "最近更新"}
{publisherLabel ? ` · 由 ${publisherLabel} 更新` : isBuiltin ? " · 由系统更新" : ""}
</span>
</div>
{chatButton}
</CardFooter>
</Card>
);
const listLayout = (
<div
className="group flex min-w-0 items-center gap-4 rounded-2xl border border-slate-200/90 bg-white p-4 shadow-[0_8px_24px_rgba(15,23,42,0.04)] transition-all hover:border-primary/25 hover:shadow-[0_12px_32px_rgba(15,23,42,0.09)] dark:border-slate-800 dark:bg-slate-900"
data-agent-guide="agent-card"
>
<AgentBrandIcon className="agent-avatar" size="md" />
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex min-w-0 items-center gap-2">
<span
className="min-w-0 truncate text-sm font-medium"
title={desensitize(agent.name)}
>
{desensitize(agent.name)}
</span>
<div className="flex shrink-0 items-center gap-1">{statusBadges}</div>
</div>
{agent.description && (
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{desensitize(agent.description)}
</p>
)}
{publisherNode && <div className="mt-0.5">{publisherNode}</div>}
{createdAtNode && <div className="mt-0.5">{createdAtNode}</div>}
</div>
<div className="flex shrink-0 items-center gap-1">
{showActionRow && actionButtons}
{chatButton}
</div>
</div>
);
return (
<>
{isList ? listLayout : cardLayout}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
<DialogDescription>
可以修改名称、描述、技能白名单、SOUL 内容、常用问题与发布状态。
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">名称</label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="输入智能体名称(允许中文)"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">描述</label>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="输入智能体描述"
className="min-h-20"
/>
</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>
<SkillTransferSelector
skills={enabledSkills}
selected={selectedSkills}
onChange={setSelectedSkills}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
/>
<p className="text-muted-foreground text-xs">
不选任何技能会保存为无技能(skills = [])。
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">SOUL</label>
<Textarea
value={editSoul ?? ""}
onChange={(e) => setEditSoul(e.target.value)}
placeholder={
isAgentDetailLoading && !agentDetail
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="max-h-[45vh] min-h-48 resize-y text-xs"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">常用问题</label>
<p className="text-muted-foreground text-xs">
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
</p>
<AgentQuestionEditor
value={editCommonQuestions}
onChange={setEditCommonQuestions}
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-md border p-3">
<div>
<p className="text-sm font-medium">会商技能识别强化</p>
<p className="text-muted-foreground text-xs">
作为会商席位时,把已配置技能(含 SKILL.md 路径与「先 read_file
再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在业务链条配置里打开(任一即生效)。
</p>
</div>
<Switch
checked={editSeatSkillDirective}
onCheckedChange={setEditSeatSkillDirective}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs">
{editIsPrivate
? "已加入私密广场,不可发布;请先关闭私密广场。"
: "打开后,该智能体对所有用户可见。"}
</p>
</div>
<Switch
checked={editPublished && !editIsPrivate}
disabled={editIsPrivate}
onCheckedChange={(v) => {
if (editIsPrivate) {
toast.warning("该智能体已加入私密广场,不可发布");
return;
}
setEditPublished(v);
}}
/>
</div>
{editPublished && !editIsPrivate && (publishSquares?.squares.length ?? 0) > 0 && (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">发布广场</p>
<p className="text-muted-foreground text-xs">
选择发布到哪个广场;不选则归入默认广场。每个智能体只能归属一个广场。
</p>
</div>
<Select
value={editSquareId || publishSquares?.default_square_id || ""}
onValueChange={setEditSquareId}
>
<SelectTrigger className="h-9 w-44 text-sm">
<SelectValue placeholder="默认广场" />
</SelectTrigger>
<SelectContent position="popper">
{publishSquares?.squares.map((sq) => (
<SelectItem key={sq.id} value={sq.id}>
{sq.name}
{sq.id === publishSquares?.default_square_id ? "(默认)" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">加入私密广场</p>
<p className="text-muted-foreground text-xs">
打开后,该智能体仅在私密广场显示,需输入密码才能查看;与「公开发布」互斥。
</p>
</div>
<Switch
checked={editIsPrivate}
onCheckedChange={(v) => {
setEditIsPrivate(v);
if (v) {
// Mutual-exclusion: switching the agent into the private
// square automatically clears the public-publish flag so
// the user can't end up with a half-and-half state.
setEditPublished(false);
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditOpen(false)}
disabled={updateAgent.isPending}
>
{t.common.cancel}
</Button>
<Button onClick={handleSaveEdit} disabled={updateAgent.isPending}>
{updateAgent.isPending ? t.common.loading : "保存修改"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirm */}
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.agents.delete}</DialogTitle>
<DialogDescription>{t.agents.deleteConfirm}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteOpen(false)}
disabled={deleteAgent.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteAgent.isPending}
>
{deleteAgent.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TagPickerDialog
open={tagPickerOpen}
onOpenChange={setTagPickerOpen}
targetType="agent"
targetId={agent.id}
currentTags={agent.tags ?? []}
/>
<SkillDetailDialog
skillName={detailSkillName}
open={detailSkillName !== null}
onOpenChange={(o) => {
if (!o) setDetailSkillName(null);
}}
/>
<PublishSquarePickerDialog
open={publishPickerOpen}
onOpenChange={setPublishPickerOpen}
squares={publishSquares?.squares ?? []}
defaultSquareId={agent.square_id || publishSquares?.default_square_id}
loading={updateAgent.isPending}
onConfirm={(squareId) => void handlePublishToSquare(squareId)}
description="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
accountNotice={
<div className="text-xs">
<p className="text-muted-foreground">
发布操作账号:
<span className="text-foreground font-medium">
「{live.displayName}」
</span>
</p>
{live.switched && (
<p className="text-destructive mt-1">
⚠ 当前登录账号已从「{live.mountDisplayName}」切换为「
{live.displayName}」。如需以「{live.mountDisplayName}
」操作,请刷新页面确认。
</p>
)}
</div>
}
/>
</>
);
}