898 lines
32 KiB
TypeScript
898 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
BotIcon,
|
|
CheckIcon,
|
|
GlobeIcon,
|
|
InfoIcon,
|
|
Loader2Icon,
|
|
LockIcon,
|
|
MessageCircleQuestionIcon,
|
|
PanelRightCloseIcon,
|
|
PanelRightOpenIcon,
|
|
PlusIcon,
|
|
ShieldIcon,
|
|
SparklesIcon,
|
|
SquarePenIcon,
|
|
Trash2Icon,
|
|
UploadCloudIcon,
|
|
UserRoundIcon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
|
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
|
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
|
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
|
|
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
|
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
|
import { Tooltip } from "@/components/workspace/tooltip";
|
|
import {
|
|
normalizeAgentQuestions,
|
|
useDeleteAgent,
|
|
useSetAgentFeatured,
|
|
useUpdateAgent,
|
|
type Agent,
|
|
type AgentQuestion,
|
|
type UpdateAgentRequest,
|
|
} from "@/core/agents";
|
|
import { getStoredAuth } from "@/core/auth";
|
|
import { isAgentSkillCandidate } from "@/core/skills";
|
|
import { useSkills } from "@/core/skills/hooks";
|
|
import { usePublishSquares } from "@/core/system-settings/hooks";
|
|
import { desensitize } from "@/lib/desensitize";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
/** Local edit-mode draft — committed in one `updateAgent` call on 保存. */
|
|
interface AgentDraft {
|
|
name: string;
|
|
description: string;
|
|
soul: string;
|
|
questions: AgentQuestion[];
|
|
skills: string[];
|
|
published: boolean;
|
|
featured: boolean;
|
|
templateName: string;
|
|
}
|
|
|
|
function draftFromAgent(agent: Agent): AgentDraft {
|
|
return {
|
|
name: agent.name,
|
|
description: agent.description ?? "",
|
|
soul: agent.soul ?? "",
|
|
questions: normalizeAgentQuestions(agent.common_questions),
|
|
skills: agent.skills ?? [],
|
|
published: agent.published,
|
|
featured: agent.featured_order != null,
|
|
templateName: agent.template_name ?? "",
|
|
};
|
|
}
|
|
|
|
function Section({
|
|
icon,
|
|
title,
|
|
extra,
|
|
children,
|
|
}: {
|
|
icon?: ReactNode;
|
|
title: string;
|
|
extra?: ReactNode;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section className="bg-card/60 rounded-xl border p-4">
|
|
<div className="mb-3 flex items-center justify-between gap-2">
|
|
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
|
{icon}
|
|
{title}
|
|
</h4>
|
|
{extra}
|
|
</div>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 智能体详情面板 — the right-hand agent configuration center on the agent chat
|
|
* page. View mode shows the full profile (avatar, name, description, SOUL,
|
|
* 常用问题, enabled skills, publish/featured state); 管理员或发布者 can flip
|
|
* into an inline edit mode (no dialog) that edits everything locally and
|
|
* commits in a single 保存. 添加技能 opens a secondary drawer (SkillPickerSheet).
|
|
* Everyone else gets a read-only profile. On narrow screens (<lg) the panel
|
|
* collapses into a rail whose button opens the same content in a Sheet, so it
|
|
* never overlaps the chat input.
|
|
*/
|
|
export function AgentDetailPanel({
|
|
agent,
|
|
agentName,
|
|
onAskQuestion,
|
|
disabled,
|
|
}: {
|
|
agent: Agent | null | undefined;
|
|
agentName: string;
|
|
onAskQuestion: (text: string) => void;
|
|
disabled?: boolean;
|
|
}) {
|
|
const router = useRouter();
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState<AgentDraft | null>(null);
|
|
const [editingQuestionIndex, setEditingQuestionIndex] = useState<
|
|
number | null
|
|
>(null);
|
|
const [questionDraft, setQuestionDraft] = useState<AgentQuestion>({
|
|
title: "",
|
|
prompt: "",
|
|
});
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
|
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
|
|
|
const updateAgent = useUpdateAgent();
|
|
const deleteAgent = useDeleteAgent();
|
|
const setFeatured = useSetAgentFeatured();
|
|
const { data: publishSquares } = usePublishSquares();
|
|
const { skills, isLoading: isSkillsLoading } = useSkills();
|
|
|
|
// Permission model mirrors agent-card: admins manage the full catalog,
|
|
// owners manage their own agents, everyone else is read-only.
|
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
|
const currentUserId = storedAuth?.user_id ?? null;
|
|
const isAdmin = storedAuth?.system_role === "admin";
|
|
|
|
// 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」,
|
|
// 其他用户未发布的自定义技能不可选(管理员例外)。
|
|
const enabledPool = useMemo(
|
|
() =>
|
|
skills.filter((skill) =>
|
|
isAgentSkillCandidate(skill, currentUserId, isAdmin),
|
|
),
|
|
[skills, currentUserId, isAdmin],
|
|
);
|
|
const isOwned =
|
|
!!agent &&
|
|
!agent.builtin &&
|
|
agent.user_id !== null &&
|
|
agent.user_id === currentUserId;
|
|
const canEdit = !!agent && (isOwned || isAdmin);
|
|
// Built-ins are inherently visible to everyone, so publish toggle is moot.
|
|
const canTogglePublish = !!agent && (isOwned || (isAdmin && !agent.builtin));
|
|
|
|
const isSaving = updateAgent.isPending || setFeatured.isPending;
|
|
|
|
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
|
|
// 信息面板给预览让位;新文件落地时同样收起。编辑中不打断用户,用户随时
|
|
// 可手动重新展开。
|
|
const artifactsCtx = useArtifactsOptional();
|
|
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
|
|
const artifactPanelVisible = !!artifactsCtx?.open && artifactCount > 0;
|
|
const prevArtifactPanelVisible = useRef(false);
|
|
const prevArtifactCount = useRef(0);
|
|
useEffect(() => {
|
|
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
|
|
const grew =
|
|
artifactPanelVisible && artifactCount > prevArtifactCount.current;
|
|
prevArtifactPanelVisible.current = artifactPanelVisible;
|
|
prevArtifactCount.current = artifactCount;
|
|
if ((opened || grew) && !editing) {
|
|
setCollapsed(true);
|
|
}
|
|
}, [artifactPanelVisible, artifactCount, editing]);
|
|
|
|
const displayName = desensitize(agent?.name ?? agentName);
|
|
const questions = useMemo(
|
|
() => normalizeAgentQuestions(agent?.common_questions),
|
|
[agent?.common_questions],
|
|
);
|
|
|
|
function beginEdit() {
|
|
if (!agent || !canEdit) return;
|
|
cancelQuestionEdit();
|
|
setDraft(draftFromAgent(agent));
|
|
setEditing(true);
|
|
}
|
|
|
|
function cancelEdit() {
|
|
setEditing(false);
|
|
setDraft(null);
|
|
setPickerOpen(false);
|
|
}
|
|
|
|
function beginQuestionEdit(index: number, question: AgentQuestion) {
|
|
if (!canEdit) return;
|
|
setEditingQuestionIndex(index);
|
|
setQuestionDraft(question);
|
|
}
|
|
|
|
function cancelQuestionEdit() {
|
|
setEditingQuestionIndex(null);
|
|
setQuestionDraft({ title: "", prompt: "" });
|
|
}
|
|
|
|
async function saveQuestionEdit(index: number) {
|
|
if (!agent) return;
|
|
const nextQuestions = questions.map((question, questionIndex) =>
|
|
questionIndex === index
|
|
? {
|
|
title: questionDraft.title.trim() || questionDraft.prompt.trim(),
|
|
prompt: questionDraft.prompt.trim() || questionDraft.title.trim(),
|
|
}
|
|
: question,
|
|
);
|
|
const cleanedQuestions = nextQuestions.filter(
|
|
(question) => question.prompt.trim().length > 0,
|
|
);
|
|
try {
|
|
await updateAgent.mutateAsync({
|
|
id: agent.id,
|
|
request: { common_questions: cleanedQuestions },
|
|
});
|
|
toast.success("Updated");
|
|
cancelQuestionEdit();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function saveEdit() {
|
|
if (!agent || !draft) return;
|
|
const trimmedName = draft.name.trim();
|
|
if (!trimmedName) {
|
|
toast.error("名称不能为空");
|
|
return;
|
|
}
|
|
// 私密广场与公开发布互斥 — force what the backend will accept.
|
|
const nextPublished = agent.is_private ? false : draft.published;
|
|
const nextQuestions = draft.questions
|
|
.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. editing 常用问题 alone no longer
|
|
// re-saves skills/SOUL/发布状态 and the save returns quickly.
|
|
const request: UpdateAgentRequest = {};
|
|
if (trimmedName !== agent.name) request.name = trimmedName;
|
|
if (draft.description !== (agent.description ?? ""))
|
|
request.description = draft.description;
|
|
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
|
|
if (draft.templateName.trim() !== (agent.template_name ?? ""))
|
|
request.template_name = draft.templateName.trim();
|
|
if (nextPublished !== agent.published) request.published = nextPublished;
|
|
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
|
|
request.skills = draft.skills;
|
|
if (
|
|
JSON.stringify(nextQuestions) !==
|
|
JSON.stringify(normalizeAgentQuestions(agent.common_questions))
|
|
)
|
|
request.common_questions = nextQuestions;
|
|
try {
|
|
if (Object.keys(request).length > 0) {
|
|
await updateAgent.mutateAsync({ id: agent.id, request });
|
|
}
|
|
// 首页推荐 rides a dedicated endpoint — only call it when toggled.
|
|
const wasFeatured = agent.featured_order != null;
|
|
if (isAdmin && draft.featured !== wasFeatured) {
|
|
if (draft.featured && !nextPublished) {
|
|
toast.warning("只有已发布的智能体可以加入首页推荐,已跳过该项");
|
|
} else {
|
|
await setFeatured.mutateAsync({
|
|
id: agent.id,
|
|
order: draft.featured ? Date.now() : null,
|
|
});
|
|
}
|
|
}
|
|
toast.success("智能体已更新");
|
|
setEditing(false);
|
|
setDraft(null);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function handleTogglePublished() {
|
|
if (!agent) return;
|
|
if (!agent.published && agent.is_private) {
|
|
toast.warning("该智能体已加入私密广场,不可发布");
|
|
return;
|
|
}
|
|
// Publishing: pick a target square when any are configured.
|
|
if (!agent.published && (publishSquares?.squares.length ?? 0) > 0) {
|
|
setPublishPickerOpen(true);
|
|
return;
|
|
}
|
|
try {
|
|
await updateAgent.mutateAsync({
|
|
id: agent.id,
|
|
request: { published: !agent.published },
|
|
});
|
|
toast.success(agent.published ? "已下架" : "已发布");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function handlePublishToSquare(squareId: string) {
|
|
if (!agent) 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}」`);
|
|
setPublishPickerOpen(false);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!agent) return;
|
|
try {
|
|
await deleteAgent.mutateAsync(agent.id);
|
|
toast.success("智能体已删除");
|
|
setDeleteOpen(false);
|
|
router.push("/page/workspace/agents");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
const statusBadges = agent && (
|
|
<>
|
|
{agent.builtin && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
内置
|
|
</Badge>
|
|
)}
|
|
{agent.published && !agent.is_private && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
|
>
|
|
<GlobeIcon className="size-3" />
|
|
已发布
|
|
</Badge>
|
|
)}
|
|
{!agent.published && !agent.builtin && (
|
|
<Badge variant="outline" className="text-muted-foreground text-xs">
|
|
未发布
|
|
</Badge>
|
|
)}
|
|
{agent.is_private && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
|
>
|
|
<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"
|
|
>
|
|
<SparklesIcon className="size-3" />
|
|
首页推荐
|
|
</Badge>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
const headerActions = canEdit ? (
|
|
editing ? (
|
|
<div className="flex items-center gap-1.5">
|
|
<Button size="sm" onClick={() => void saveEdit()} disabled={isSaving}>
|
|
{isSaving && <Loader2Icon className="size-3.5 animate-spin" />}
|
|
保存
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={cancelEdit}
|
|
disabled={isSaving}
|
|
>
|
|
取消
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1">
|
|
<Button size="sm" variant="outline" onClick={beginEdit}>
|
|
<SquarePenIcon className="size-3.5" />
|
|
编辑
|
|
</Button>
|
|
{canTogglePublish && (
|
|
<Tooltip content={agent?.published ? "下架" : "发布"}>
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
onClick={() => void handleTogglePublished()}
|
|
disabled={updateAgent.isPending}
|
|
aria-label={agent?.published ? "下架" : "发布"}
|
|
>
|
|
{agent?.published ? (
|
|
<LockIcon className="size-3.5" />
|
|
) : (
|
|
<GlobeIcon className="size-3.5" />
|
|
)}
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip content="删除智能体">
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => setDeleteOpen(true)}
|
|
aria-label="删除智能体"
|
|
>
|
|
<Trash2Icon className="size-3.5" />
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
)
|
|
) : null;
|
|
|
|
const skillNamesShown = editing && draft ? draft.skills : (agent?.skills ?? []);
|
|
|
|
const body = !agent ? (
|
|
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
|
|
<Loader2Icon className="size-4 animate-spin" />
|
|
智能体信息加载中…
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4 px-4 py-4">
|
|
{/* ── Identity / 基础信息 ─────────────────────────────────────────── */}
|
|
<section className="bg-card/60 rounded-xl border p-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
|
|
<BotIcon className="text-primary size-6" />
|
|
</div>
|
|
<div className="min-w-0 flex-1 space-y-1.5">
|
|
{editing && draft ? (
|
|
<Input
|
|
value={draft.name}
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, name: e.currentTarget.value })
|
|
}
|
|
placeholder="智能体名称"
|
|
className="h-8 text-sm font-semibold"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="truncate text-base font-bold"
|
|
title={displayName}
|
|
>
|
|
{displayName}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
{statusBadges}
|
|
</div>
|
|
{!editing && agent.owner_username && !agent.builtin && (
|
|
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
|
<UserRoundIcon className="size-3 shrink-0" />
|
|
发布者:{desensitize(agent.owner_username)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
{editing && draft ? (
|
|
<Textarea
|
|
value={draft.description}
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, description: e.currentTarget.value })
|
|
}
|
|
placeholder="智能体描述"
|
|
className="min-h-16 text-sm"
|
|
/>
|
|
) : agent.description ? (
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{desensitize(agent.description)}
|
|
</p>
|
|
) : (
|
|
<p className="text-muted-foreground/60 text-sm">暂无描述</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 入库模板名 — view mode shows a hint row when configured. */}
|
|
{!editing && agent.template_name?.trim() && (
|
|
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
|
|
<UploadCloudIcon className="size-3 shrink-0" />
|
|
入库模板:{desensitize(agent.template_name)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Publish / featured toggles — only meaningful in edit mode. */}
|
|
{editing && draft && (
|
|
<div className="mt-3 space-y-2">
|
|
<div className="bg-background/60 space-y-1 rounded-lg border px-3 py-2">
|
|
<p className="text-sm font-medium">入库模板名</p>
|
|
<Input
|
|
value={draft.templateName}
|
|
onChange={(e) =>
|
|
setDraft({ ...draft, templateName: e.currentTarget.value })
|
|
}
|
|
placeholder="例如:临时策略库(留空表示不支持入库)"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<p className="text-muted-foreground text-xs">
|
|
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
|
|
</p>
|
|
</div>
|
|
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
|
<div>
|
|
<p className="text-sm font-medium">公开发布</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{agent.is_private
|
|
? "已加入私密广场,不可发布"
|
|
: "打开后对所有用户可见"}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={draft.published && !agent.is_private}
|
|
disabled={agent.is_private}
|
|
onCheckedChange={(v) => setDraft({ ...draft, published: v })}
|
|
/>
|
|
</div>
|
|
{isAdmin && (
|
|
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
|
<div>
|
|
<p className="text-sm font-medium">首页推荐</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
加入首页选择器(需已发布)
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={draft.featured}
|
|
onCheckedChange={(v) => setDraft({ ...draft, featured: v })}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
|
|
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
|
{(editing || questions.length > 0 || canEdit) && (
|
|
<Section
|
|
icon={<MessageCircleQuestionIcon className="size-3.5" />}
|
|
title="常用问题"
|
|
extra={
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-muted-foreground text-xs tabular-nums">
|
|
{(editing && draft ? draft.questions : questions).length} 个
|
|
</span>
|
|
{!editing && canEdit && (
|
|
<Tooltip content="管理常用问题(新增 / 删除 / 排序)">
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
onClick={beginEdit}
|
|
aria-label="管理常用问题"
|
|
>
|
|
<SquarePenIcon className="size-3.5" />
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
}
|
|
>
|
|
{editing && draft ? (
|
|
<AgentQuestionEditor
|
|
value={draft.questions}
|
|
onChange={(next) => setDraft({ ...draft, questions: next })}
|
|
disabled={isSaving}
|
|
/>
|
|
) : questions.length === 0 ? (
|
|
<button
|
|
type="button"
|
|
onClick={beginEdit}
|
|
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
|
|
>
|
|
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
|
|
添加常用问题
|
|
</button>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{questions.map((question, index) => {
|
|
const isEditingThis = editingQuestionIndex === index;
|
|
return (
|
|
<div
|
|
key={index}
|
|
className="border-border/60 bg-background/60 rounded-xl border p-2"
|
|
>
|
|
{isEditingThis ? (
|
|
<div className="space-y-2">
|
|
<Input
|
|
value={questionDraft.title}
|
|
disabled={isSaving}
|
|
placeholder="Title"
|
|
onChange={(e) =>
|
|
setQuestionDraft({
|
|
...questionDraft,
|
|
title: e.currentTarget.value,
|
|
})
|
|
}
|
|
className="h-8 text-sm font-medium"
|
|
/>
|
|
<Textarea
|
|
value={questionDraft.prompt}
|
|
disabled={isSaving}
|
|
placeholder="Prompt"
|
|
onChange={(e) =>
|
|
setQuestionDraft({
|
|
...questionDraft,
|
|
prompt: e.currentTarget.value,
|
|
})
|
|
}
|
|
className="min-h-16 resize-none text-sm"
|
|
/>
|
|
<div className="flex justify-end gap-1.5">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={cancelQuestionEdit}
|
|
disabled={isSaving}
|
|
>
|
|
<XIcon className="size-3.5" />
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => void saveQuestionEdit(index)}
|
|
disabled={isSaving}
|
|
>
|
|
{isSaving ? (
|
|
<Loader2Icon className="size-3.5 animate-spin" />
|
|
) : (
|
|
<CheckIcon className="size-3.5" />
|
|
)}
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => onAskQuestion(question.prompt)}
|
|
title={desensitize(question.prompt)}
|
|
className={cn(
|
|
"hover:bg-accent min-w-0 flex-1 rounded-lg px-2 py-1.5 text-left text-sm leading-relaxed transition-colors",
|
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
>
|
|
<span className="block truncate">
|
|
{desensitize(question.title)}
|
|
</span>
|
|
</button>
|
|
{canEdit && (
|
|
<Button
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
onClick={() => beginQuestionEdit(index, question)}
|
|
disabled={isSaving}
|
|
aria-label="Edit question"
|
|
>
|
|
<SquarePenIcon className="size-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
)}
|
|
|
|
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
|
<Section
|
|
icon={<SparklesIcon className="size-3.5" />}
|
|
title={`已启用技能 ${skillNamesShown.length} 个`}
|
|
extra={
|
|
editing ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-xs"
|
|
onClick={() => setPickerOpen(true)}
|
|
>
|
|
<PlusIcon className="size-3.5" />
|
|
添加技能
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
>
|
|
<EnabledSkillList
|
|
skillNames={skillNamesShown}
|
|
skills={skills}
|
|
isLoading={isSkillsLoading}
|
|
editing={editing}
|
|
onRemove={
|
|
editing && draft
|
|
? (name) =>
|
|
setDraft({
|
|
...draft,
|
|
skills: draft.skills.filter((n) => n !== name),
|
|
})
|
|
: undefined
|
|
}
|
|
onViewDetail={setDetailSkillName}
|
|
/>
|
|
</Section>
|
|
</div>
|
|
);
|
|
|
|
const panelChrome = (collapseButton: ReactNode) => (
|
|
<>
|
|
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
|
<span className="text-sm font-semibold">智能体信息</span>
|
|
<div className="flex items-center gap-1">
|
|
{headerActions}
|
|
{collapseButton}
|
|
</div>
|
|
</div>
|
|
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
|
{collapsed ? (
|
|
<div className="hidden shrink-0 flex-col items-center border-l pt-3 lg:flex">
|
|
<Tooltip content="智能体信息">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setCollapsed(false)}
|
|
aria-label="展开智能体信息"
|
|
>
|
|
<PanelRightOpenIcon className="size-4" />
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
) : (
|
|
<aside className="bg-card/40 hidden h-full w-[360px] shrink-0 flex-col border-l lg:flex">
|
|
{panelChrome(
|
|
<Tooltip content="收起">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setCollapsed(true)}
|
|
aria-label="收起智能体信息"
|
|
>
|
|
<PanelRightCloseIcon className="size-4" />
|
|
</Button>
|
|
</Tooltip>,
|
|
)}
|
|
</aside>
|
|
)}
|
|
|
|
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
|
|
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
|
|
<Tooltip content="智能体信息">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setMobileOpen(true)}
|
|
aria-label="打开智能体信息"
|
|
>
|
|
<InfoIcon className="size-4" />
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
|
<SheetHeader className="border-b px-4 pb-3 pt-4">
|
|
<SheetTitle className="flex items-center justify-between text-sm">
|
|
智能体信息
|
|
<span className="mr-6">{headerActions}</span>
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
|
|
<SkillPickerSheet
|
|
open={pickerOpen}
|
|
onOpenChange={setPickerOpen}
|
|
skills={enabledPool}
|
|
isLoading={isSkillsLoading}
|
|
selected={editing && draft ? draft.skills : []}
|
|
onChange={(next) => {
|
|
if (editing && draft) setDraft({ ...draft, skills: next });
|
|
}}
|
|
onViewDetail={setDetailSkillName}
|
|
/>
|
|
|
|
<SkillDetailDialog
|
|
skillName={detailSkillName}
|
|
open={detailSkillName !== null}
|
|
onOpenChange={(o) => {
|
|
if (!o) setDetailSkillName(null);
|
|
}}
|
|
/>
|
|
|
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>删除智能体</DialogTitle>
|
|
<DialogDescription>
|
|
确定要删除「{displayName}」吗?该操作不可恢复。
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setDeleteOpen(false)}
|
|
disabled={deleteAgent.isPending}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => void handleDelete()}
|
|
disabled={deleteAgent.isPending}
|
|
>
|
|
{deleteAgent.isPending ? "删除中…" : "删除"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<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="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
|
/>
|
|
</>
|
|
);
|
|
}
|