feat(knowledge): 支持文件导入沉淀知识,知识图谱可视化随主题切换
- 后端新增 /api/knowledge/ingest/file:上传 md/txt/pdf/word/ppt/excel, 转 Markdown 后走 wiki-capture LLM 蒸馏为陈述式知识笔记,失败回退逐字草稿 - service.capture_file + 文档专用蒸馏提示词,按主题拆分 1~4 页 - 知识图谱导出 HTML 支持 ?theme=light|dark,布局稳定后冻结, 按度数缩放节点,悬停/点击聚焦邻居 - 前端知识库页新增「导入文件」对话框与 ingestKnowledgeFile API - 智能体问答页与简介侧边栏 UI 优化;批量人物蒸馏脚本提取正文逻辑修正 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
17bb3715d1
commit
3845d5090b
14 files changed
+1040
-352
No files matched your search
@@ -2,35 +2,31 @@
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
InfoIcon,
|
||||
MessageCircleQuestionIcon,
|
||||
PanelRightCloseIcon,
|
||||
PanelRightOpenIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { type Agent } from "@/core/agents";
|
||||
import { useUpdateAgent } from "@/core/agents/hooks";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Collapsible right-hand panel on the agent chat page that surfaces the agent's
|
||||
* profile (name / description / skills) plus a list of frequently-asked
|
||||
* questions ("常用问题"). Clicking a question sends it as a message. The agent's
|
||||
* owner (or an admin) can edit the question list inline.
|
||||
* Collapsible right-hand "智能体简介" panel on the agent chat page. It is a
|
||||
* read-only window onto the agent's profile — avatar, name, slogan/description,
|
||||
* the admin-authored SOUL.md as a long-form blurb, the skill whitelist, and the
|
||||
* admin-configured common questions surfaced as quick-ask entries. Clicking a
|
||||
* question sends it as a message.
|
||||
*
|
||||
* Editing the common questions lives in the "编辑智能体" dialog (agent-card), NOT
|
||||
* here — this panel never offers an edit affordance or an admin hint.
|
||||
*/
|
||||
export function AgentInfoSidebar({
|
||||
agent,
|
||||
@@ -49,20 +45,12 @@ export function AgentInfoSidebar({
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
const { skills } = useSkills();
|
||||
const updateAgent = useUpdateAgent();
|
||||
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const description = agent?.description
|
||||
? desensitize(agent.description)
|
||||
: undefined;
|
||||
|
||||
// Owner / admin gate for inline editing of the common-questions list.
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const canEdit = useMemo(() => {
|
||||
if (!agent) return false;
|
||||
if (storedAuth?.system_role === "admin") return true;
|
||||
return agent.user_id != null && agent.user_id === storedAuth?.user_id;
|
||||
}, [agent, storedAuth]);
|
||||
const soul = agent?.soul?.trim() ? desensitize(agent.soul) : undefined;
|
||||
|
||||
// Map a stored skill name to its Chinese display label (falls back to name).
|
||||
const skillLabel = useMemo(() => {
|
||||
@@ -78,45 +66,9 @@ export function AgentInfoSidebar({
|
||||
[agent?.common_questions],
|
||||
);
|
||||
|
||||
// --- Inline editing state ---
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [drafts, setDrafts] = useState<string[]>([]);
|
||||
|
||||
const startEditing = () => {
|
||||
setDrafts(questions.length > 0 ? [...questions] : [""]);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditing(false);
|
||||
setDrafts([]);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!agent) return;
|
||||
const cleaned = drafts.map((q) => q.trim()).filter((q) => q.length > 0);
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { common_questions: cleaned },
|
||||
});
|
||||
toast.success("常用问题已保存");
|
||||
setEditing(false);
|
||||
setDrafts([]);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
// Leaving edit mode whenever the panel collapses keeps the state tidy.
|
||||
useEffect(() => {
|
||||
if (collapsed && editing) cancelEditing();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [collapsed]);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-14">
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-3">
|
||||
<Tooltip content="智能体简介">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -132,8 +84,9 @@ export function AgentInfoSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="bg-card/40 flex w-72 shrink-0 flex-col border-l pt-12">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<aside className="bg-card/40 hidden h-full w-[340px] shrink-0 flex-col border-l lg:flex">
|
||||
{/* Header — same height as the chat header so the two columns line up. */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<span className="text-sm font-semibold">智能体简介</span>
|
||||
<Tooltip content="收起">
|
||||
<Button
|
||||
@@ -147,125 +100,66 @@ export function AgentInfoSidebar({
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto p-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{/* Identity */}
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-primary h-6 w-6" />
|
||||
<div className="flex flex-col items-center gap-2 px-6 pb-6 pt-7 text-center">
|
||||
<div className="bg-primary/10 ring-primary/15 flex h-16 w-16 items-center justify-center rounded-2xl ring-1">
|
||||
<BotIcon className="text-primary h-8 w-8" />
|
||||
</div>
|
||||
<div className="text-base font-bold" title={displayName}>
|
||||
<div className="text-lg font-bold" title={displayName}>
|
||||
{displayName}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Soul — the agent's long-form personality blurb. */}
|
||||
{soul && (
|
||||
<section className="space-y-3 border-t px-6 py-5">
|
||||
<h4 className="text-foreground text-sm font-semibold">Soul</h4>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{soul}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{agent?.skills && agent.skills.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
<section className="space-y-3 border-t px-6 py-5">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
技能
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agent.skills.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setDetailSkillName(name)}
|
||||
title="查看技能详情"
|
||||
className="border-border bg-background hover:bg-accent inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors"
|
||||
className="border-border bg-background hover:bg-accent inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors"
|
||||
>
|
||||
<span className="max-w-[150px] truncate">
|
||||
<span className="max-w-[180px] truncate">
|
||||
{skillLabel(name)}
|
||||
</span>
|
||||
<InfoIcon className="text-muted-foreground h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Common questions */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
|
||||
{/* Common questions — read-only quick-ask entries. */}
|
||||
{questions.length > 0 && (
|
||||
<section className="space-y-3 border-t px-6 py-5">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
<MessageCircleQuestionIcon className="h-3.5 w-3.5" />
|
||||
常用问题
|
||||
</div>
|
||||
{canEdit && !editing && (
|
||||
<Tooltip content="编辑常用问题">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={startEditing}
|
||||
aria-label="编辑常用问题"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
{drafts.map((value, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={`问题 ${index + 1}`}
|
||||
onChange={(e) => {
|
||||
const next = [...drafts];
|
||||
next[index] = e.currentTarget.value;
|
||||
setDrafts(next);
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
setDrafts(drafts.filter((_, i) => i !== index))
|
||||
}
|
||||
aria-label="删除"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setDrafts([...drafts, ""])}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" /> 添加问题
|
||||
</Button>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={updateAgent.isPending}
|
||||
>
|
||||
<CheckIcon className="h-3.5 w-3.5" /> 保存
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={cancelEditing}
|
||||
disabled={updateAgent.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : questions.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
推荐问题
|
||||
</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{questions.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
@@ -274,20 +168,16 @@ export function AgentInfoSidebar({
|
||||
onClick={() => onAskQuestion(question)}
|
||||
title={question}
|
||||
className={cn(
|
||||
"border-border bg-background hover:bg-accent w-full rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
"border-border/60 bg-background/60 hover:bg-accent hover:border-border w-full rounded-xl border px-3 py-2.5 text-left text-sm leading-relaxed transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
{desensitize(question)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{canEdit ? "还没有常用问题,点击上方编辑添加。" : "暂无常用问题。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SkillDetailDialog
|
||||
|
||||
@@ -41,14 +41,14 @@ export function AgentWelcome({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 ring-primary/15 flex h-16 w-16 items-center justify-center rounded-2xl ring-1">
|
||||
<BotIcon className="text-primary h-8 w-8" />
|
||||
<div className="bg-primary/10 ring-primary/15 mb-2 flex h-[72px] w-[72px] items-center justify-center rounded-3xl ring-1">
|
||||
<BotIcon className="text-primary h-9 w-9" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold tracking-tight">
|
||||
<div className="text-3xl font-bold tracking-tight">
|
||||
Hi,我是{displayName}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-muted-foreground max-w-md text-sm leading-relaxed">
|
||||
<p className="text-muted-foreground max-w-xl text-base leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
PinIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -207,6 +209,12 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
const [editName, setEditName] = useState(agent.name);
|
||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
// Common questions (常用问题) are authored here in the edit dialog and read
|
||||
// back read-only on the agent chat page. Keep one trailing blank row so there
|
||||
// is always an empty input to type the next question into.
|
||||
const [editCommonQuestions, setEditCommonQuestions] = useState<string[]>(
|
||||
agent.common_questions ?? [],
|
||||
);
|
||||
const [editPublished, setEditPublished] = useState(agent.published);
|
||||
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||
@@ -250,8 +258,10 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
setEditPublished(agent.published);
|
||||
setEditIsPrivate(agent.is_private);
|
||||
setSelectedSkills(agent.skills ?? []);
|
||||
setEditCommonQuestions(agent.common_questions ?? []);
|
||||
setSkillSearch("");
|
||||
}, [
|
||||
agent.common_questions,
|
||||
agent.description,
|
||||
agent.is_private,
|
||||
agent.name,
|
||||
@@ -269,6 +279,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
// include `soul`) keep working.
|
||||
if (agentDetail) {
|
||||
setEditSoul(agentDetail.soul ?? "");
|
||||
setEditCommonQuestions(agentDetail.common_questions ?? []);
|
||||
} else {
|
||||
setEditSoul(agent.soul ?? "");
|
||||
}
|
||||
@@ -345,6 +356,9 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
published: editIsPrivate ? false : editPublished,
|
||||
is_private: editIsPrivate,
|
||||
skills: selectedSkills,
|
||||
common_questions: editCommonQuestions
|
||||
.map((q) => q.trim())
|
||||
.filter((q) => q.length > 0),
|
||||
},
|
||||
});
|
||||
toast.success("智能体已更新");
|
||||
@@ -831,7 +845,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
|
||||
可以修改名称、描述、技能白名单、SOUL 内容、常用问题与发布状态。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
@@ -935,6 +949,52 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
className="min-h-40 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">常用问题</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
这些问题会在该智能体的首页与简介栏作为推荐问题展示,点击即可一键提问。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{editCommonQuestions.map((value, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={`问题 ${index + 1}`}
|
||||
onChange={(e) => {
|
||||
const next = [...editCommonQuestions];
|
||||
next[index] = e.currentTarget.value;
|
||||
setEditCommonQuestions(next);
|
||||
}}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
setEditCommonQuestions(
|
||||
editCommonQuestions.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
aria-label="删除问题"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
setEditCommonQuestions([...editCommonQuestions, ""])
|
||||
}
|
||||
>
|
||||
<PlusIcon className="size-3.5" /> 添加问题
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">公开发布</p>
|
||||
|
||||
Reference in new issue
Block a user