1153 lines
43 KiB
TypeScript
1153 lines
43 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
BotIcon,
|
||
BriefcaseBusinessIcon,
|
||
CheckIcon,
|
||
DatabaseIcon,
|
||
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 { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
|
||
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 { useLiveAccount } from "@/core/auth/use-live-account";
|
||
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;
|
||
/** 会商席位「技能识别强化」开关。 */
|
||
seatSkillDirective: boolean;
|
||
knowledgeBaseIds: 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 ?? "",
|
||
seatSkillDirective: agent.seat_skill_directive ?? false,
|
||
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
|
||
};
|
||
}
|
||
|
||
function Section({
|
||
icon,
|
||
title,
|
||
extra,
|
||
className,
|
||
children,
|
||
}: {
|
||
icon?: ReactNode;
|
||
title: string;
|
||
extra?: ReactNode;
|
||
className?: string;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<section className={cn("bg-card/60 rounded-xl border p-4", className)}>
|
||
<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,
|
||
defaultCollapsed,
|
||
collapsed: collapsedProp,
|
||
onCollapsedChange,
|
||
showRecentChats,
|
||
compactAppearanceActions,
|
||
avatarSrc,
|
||
positionName,
|
||
skillsIcon,
|
||
skillsTitle,
|
||
belowSkillsSlot,
|
||
feedbackScoreSlot,
|
||
showCommonQuestions = false,
|
||
jobResponsibilities,
|
||
panelTitle,
|
||
embedded,
|
||
hideStatusBadges = false,
|
||
editIcon,
|
||
}: {
|
||
agent: Agent | null | undefined;
|
||
agentName: string;
|
||
onAskQuestion: (text: string) => void;
|
||
disabled?: boolean;
|
||
/** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */
|
||
defaultCollapsed?: boolean;
|
||
/**
|
||
* 受控折叠态:传入时由父级持有状态(智能体问答页把展开/收起按钮放在顶部
|
||
* 「新对话」右侧)。此时收起 = 整列宽度动画归零(不保留细条),面板内置的
|
||
* 收起按钮不再渲染。不传则沿用面板内置状态 + 细条收起交互(会商等工作区)。
|
||
*/
|
||
collapsed?: boolean;
|
||
/** 受控折叠态变化回调(配合 `collapsed` 使用)。 */
|
||
onCollapsedChange?: (collapsed: boolean) => void;
|
||
/** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */
|
||
showRecentChats?: boolean;
|
||
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
||
compactAppearanceActions?: ReactNode;
|
||
avatarSrc?: string;
|
||
positionName?: string;
|
||
skillsIcon?: ReactNode;
|
||
skillsTitle?: string;
|
||
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
|
||
belowSkillsSlot?: ReactNode;
|
||
/** Optional answer-feedback summary rendered near the agent identity. */
|
||
feedbackScoreSlot?: ReactNode;
|
||
showCommonQuestions?: boolean;
|
||
/**
|
||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||
* 不传则隐藏该区块。此字段来自业务链,不随智能体本身编辑。
|
||
*/
|
||
jobResponsibilities?: string | null;
|
||
/** Overrides the standard panel heading for an embedded consumer. */
|
||
panelTitle?: string;
|
||
/** Renders one fixed panel instead of the responsive collapsible rail. */
|
||
embedded?: boolean;
|
||
/** Keeps deployment/status badges out of an embedded product surface. */
|
||
hideStatusBadges?: boolean;
|
||
/** Optional icon used by the compact embedded edit action. */
|
||
editIcon?: ReactNode;
|
||
}) {
|
||
const router = useRouter();
|
||
// 折叠态:受控(collapsedProp 传入,智能体问答页)优先;否则面板内置(会商等工作区)。
|
||
const [internalCollapsed, setInternalCollapsed] = useState(
|
||
defaultCollapsed ?? false,
|
||
);
|
||
const collapsed = collapsedProp ?? internalCollapsed;
|
||
const setCollapsed = (next: boolean) => {
|
||
if (collapsedProp === undefined) setInternalCollapsed(next);
|
||
onCollapsedChange?.(next);
|
||
};
|
||
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);
|
||
// 发布以实时 token 执行 —— 用 live 账号提示发布者并检测账号是否被切换。
|
||
const live = useLiveAccount();
|
||
|
||
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)并首次展开预览时,自动收起智能体
|
||
// 信息面板给预览让位。目录内切换文件不能再次改写用户的手动选择:否则用户
|
||
// 刚展开智能体信息,点另一个文件时面板又会突然收起,看起来就像收起按钮被
|
||
// 移除了。编辑中也不打断用户。
|
||
//
|
||
// 关键:判定用 open + selectedArtifact,而非 artifacts.length。流式写文件时
|
||
// message-group 一开始就调用 select()+setOpen(true) 打开预览并选中该文件,但
|
||
// 该文件要等生成结束、artifact 入库后才会进 thread.values.artifacts。若按
|
||
// 数量判定就只能等生成结束才收起——正是之前「生成完才收起」的 bug 根因。
|
||
const artifactsCtx = useArtifactsOptional();
|
||
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
|
||
const selectedArtifact = artifactsCtx?.selectedArtifact ?? null;
|
||
// preOpen:流式自动开沙箱的两阶段标记 —— 沙箱真正展开(open 翻转)前就
|
||
// 先收起本面板,让收起布局先落地,避免与沙箱展开同帧叠加造成卡顿。
|
||
const artifactPanelVisible =
|
||
(!!artifactsCtx?.open || !!artifactsCtx?.preOpen) &&
|
||
(!!selectedArtifact || artifactCount > 0);
|
||
const prevArtifactPanelVisible = useRef(false);
|
||
useEffect(() => {
|
||
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
|
||
prevArtifactPanelVisible.current = artifactPanelVisible;
|
||
if (opened && !editing) {
|
||
setCollapsed(true);
|
||
}
|
||
}, [artifactPanelVisible, 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 (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
|
||
request.seat_skill_directive = draft.seatSkillDirective;
|
||
if (
|
||
JSON.stringify(draft.knowledgeBaseIds) !==
|
||
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
|
||
)
|
||
request.llmwiki_knowledge_base_ids = draft.knowledgeBaseIds;
|
||
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;
|
||
}
|
||
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 (!agent) return;
|
||
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 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">
|
||
{embedded ? (
|
||
<Tooltip content="编辑智能体">
|
||
<Button
|
||
type="button"
|
||
size="icon-sm"
|
||
variant="ghost"
|
||
onClick={beginEdit}
|
||
aria-label="编辑智能体"
|
||
>
|
||
{editIcon ?? <SquarePenIcon className="size-3.5" />}
|
||
</Button>
|
||
</Tooltip>
|
||
) : (
|
||
<Button size="sm" variant="outline" onClick={beginEdit}>
|
||
<SquarePenIcon className="size-3.5" />
|
||
编辑
|
||
</Button>
|
||
)}
|
||
{!embedded && 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>
|
||
)}
|
||
{!embedded && (
|
||
<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="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
|
||
{avatarSrc ? (
|
||
<img src={avatarSrc} alt="" aria-hidden="true" className="size-9" />
|
||
) : (
|
||
<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>
|
||
)}
|
||
{!editing && positionName && (
|
||
<div className="position-agent-role text-sm">
|
||
岗位:{desensitize(positionName)}
|
||
</div>
|
||
)}
|
||
{!hideStatusBadges && (
|
||
<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>
|
||
)}
|
||
|
||
{!editing && (agent.llmwiki_knowledge_base_ids?.length ?? 0) > 0 && (
|
||
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
|
||
<DatabaseIcon className="size-3 shrink-0" />
|
||
LLMWiki 知识库:{agent.llmwiki_knowledge_base_ids?.length ?? 0} 个
|
||
</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>
|
||
<LlmWikiKnowledgeSelector
|
||
source="all"
|
||
value={draft.knowledgeBaseIds}
|
||
onChange={(knowledgeBaseIds) =>
|
||
setDraft({ ...draft, knowledgeBaseIds })
|
||
}
|
||
variant="panel"
|
||
/>
|
||
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
||
<div className="pr-3">
|
||
<p className="text-sm font-medium">会商技能识别强化</p>
|
||
<p className="text-muted-foreground text-xs">
|
||
作为会商席位时,把已配置技能(含 SKILL.md 路径与「先 read_file
|
||
再用」硬指令)拼进任务,补偿弱模型不识别技能。也可在业务链条配置中打开(任一即生效)。
|
||
</p>
|
||
</div>
|
||
<Switch
|
||
checked={draft.seatSkillDirective}
|
||
onCheckedChange={(checked) =>
|
||
setDraft({ ...draft, seatSkillDirective: checked })
|
||
}
|
||
/>
|
||
</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 && feedbackScoreSlot}
|
||
|
||
|
||
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
||
{jobResponsibilities !== undefined && (
|
||
<Section
|
||
icon={<BriefcaseBusinessIcon className="size-3.5" />}
|
||
title="岗位职责"
|
||
>
|
||
{jobResponsibilities?.trim() ? (
|
||
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||
{desensitize(jobResponsibilities)}
|
||
</p>
|
||
) : (
|
||
<p className="text-muted-foreground/60 text-sm">暂无岗位职责</p>
|
||
)}
|
||
</Section>
|
||
)}
|
||
|
||
|
||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||
{(showCommonQuestions || 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={skillsIcon ?? <SparklesIcon className="size-3.5" />}
|
||
title={skillsTitle ?? `已配置技能 ${skillNamesShown.length} 个`}
|
||
className="position-roundtable-skills-section"
|
||
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>
|
||
|
||
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
||
{belowSkillsSlot}
|
||
|
||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||
</div>
|
||
);
|
||
|
||
const panelChrome = (collapseButton: ReactNode) => (
|
||
<>
|
||
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
|
||
{/* 标题不参与压缩(此前被简洁模式外观控件挤成单字);空间不足时只
|
||
裁剪 compactAppearanceActions,编辑等主操作与标题始终完整。 */}
|
||
<span className="shrink-0 text-sm font-semibold whitespace-nowrap">
|
||
{panelTitle ?? "智能体信息"}
|
||
</span>
|
||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1">
|
||
<div className="flex shrink-0 items-center gap-1">{headerActions}</div>
|
||
<div className="flex min-w-0 items-center justify-end gap-1 overflow-hidden">
|
||
{compactAppearanceActions}
|
||
</div>
|
||
{collapseButton && <div className="shrink-0">{collapseButton}</div>}
|
||
</div>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{/* ── Desktop (lg+) ────────────────────────────────────────────────── */}
|
||
{embedded ? (
|
||
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
|
||
{panelChrome(null)}
|
||
</aside>
|
||
) : collapsedProp !== undefined ? (
|
||
/* 受控模式(智能体问答页):收起时整列宽度动画归零(含左边框),不再保留细条;
|
||
展开态的「收起」按钮在面板右上角,收起态的「展开」按钮在页面
|
||
header「新对话」按钮右侧。 */
|
||
<div
|
||
className={cn(
|
||
"hidden h-full shrink-0 overflow-hidden transition-[width] duration-300 ease-in-out lg:block",
|
||
collapsed ? "w-0" : "w-[360px]",
|
||
)}
|
||
>
|
||
<aside className="bg-card/40 flex h-full w-[360px] flex-col border-l">
|
||
{panelChrome(
|
||
<Tooltip content="收起">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={() => setCollapsed(true)}
|
||
aria-label="收起智能体信息"
|
||
aria-expanded={true}
|
||
>
|
||
<PanelRightCloseIcon className="size-4" />
|
||
</Button>
|
||
</Tooltip>,
|
||
)}
|
||
</aside>
|
||
</div>
|
||
) : collapsed ? (
|
||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||
<div className="flex items-center gap-1">
|
||
{compactAppearanceActions}
|
||
<Tooltip content="展开智能体信息">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
onClick={() => setCollapsed(false)}
|
||
aria-label="展开智能体信息"
|
||
aria-expanded={false}
|
||
>
|
||
<PanelRightOpenIcon className="size-4" />
|
||
</Button>
|
||
</Tooltip>
|
||
</div>
|
||
</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="收起智能体信息"
|
||
aria-expanded={true}
|
||
>
|
||
<PanelRightCloseIcon className="size-4" />
|
||
</Button>
|
||
</Tooltip>,
|
||
)}
|
||
</aside>
|
||
)}
|
||
|
||
{!embedded && (
|
||
<>
|
||
{/* ── 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="请选择要把该智能体发布到哪个广场。每个智能体只能归属一个广场,不会重复出现。"
|
||
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>
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|