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:
吉鑫升andClaude Opus 4.8 committed 2026-06-11 10:25:27 +08:00
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>
+24
View File
@@ -127,6 +127,30 @@ export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) {
);
}
export interface IngestFileResult {
note: KnowledgeNote;
created: boolean;
notes: KnowledgeNote[];
llm_fallback: boolean;
}
/** Import a file (md/txt/pdf/word/ppt/excel) into the KB as wiki note(s). */
export async function ingestKnowledgeFile(
file: File,
opts: { title?: string; tags?: string; status?: string; model_name?: string | null } = {},
) {
const form = new FormData();
form.append("file", file);
if (opts.title) form.append("title", opts.title);
if (opts.tags) form.append("tags", opts.tags);
if (opts.status) form.append("status", opts.status);
if (opts.model_name) form.append("model_name", opts.model_name);
// No Content-Type header — the browser sets the multipart boundary itself.
return parseResponse<IngestFileResult>(
await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }),
);
}
/** Resolve `[[wikilink]]` targets to note ids / vault pages. */
export async function resolveWikilinks(targets: string[]) {
const res = await parseResponse<{ resolved: WikilinkResolutionMap }>(
+11
View File
@@ -11,6 +11,7 @@ import {
exportKnowledgeGraph,
fetchVaultPage,
getKnowledgeNote,
ingestKnowledgeFile,
ingestThread,
ingestThreadsBatch,
listDuplicates,
@@ -106,6 +107,16 @@ export function useKnowledgeActions() {
mutationFn: ingestThreadsBatch,
onSuccess: invalidate,
}),
ingestFile: useMutation({
mutationFn: ({
file,
opts,
}: {
file: File;
opts?: Parameters<typeof ingestKnowledgeFile>[1];
}) => ingestKnowledgeFile(file, opts),
onSuccess: invalidate,
}),
exportGraph: useMutation({ mutationFn: exportKnowledgeGraph }),
merge: useMutation({
mutationFn: ({ primaryId, duplicateIds }: { primaryId: string; duplicateIds: string[] }) =>
+125 -120
View File
@@ -13,11 +13,7 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box";
import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
@@ -32,7 +28,6 @@ import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const routeBase = useRouteBase();
@@ -163,146 +158,156 @@ export default function AgentChatPage() {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation;
const isDemoMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const status = thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready";
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 border-b px-4",
isLandingState
? "border-transparent bg-transparent"
: "bg-card/90 border-border/70 shadow-sm backdrop-blur-md",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span
className="max-w-[200px] truncate text-xs font-medium"
title={agentDisplayName}
>
{agentDisplayName}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="mr-4 flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(`/page/workspace/agents/${agent_id}/chats/new`);
}}
{/* Root: fixed-height horizontal split. The left main column owns the
chat (home / conversation) and the input; the right sidebar is a
separate flex sibling, so nothing in the main column can ever bleed
over it. */}
<div className="flex size-full min-h-0 overflow-hidden">
{/* ── Left: main conversation column ───────────────────────────── */}
<main className="flex min-h-0 min-w-0 flex-1 flex-col">
{/* Header — in normal flow (not absolute), fixed height. */}
<header className="bg-card/90 border-border/70 flex h-12 shrink-0 items-center gap-2 border-b px-4 backdrop-blur-md">
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span
className="max-w-[200px] truncate text-xs font-medium"
title={agentDisplayName}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
{agentDisplayName}
</span>
</div>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isLandingState && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
/>
</div>
<div className="flex min-w-0 flex-1 items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState
? "max-w-(--container-width-md)"
: "max-w-(--container-width-lg)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
<div className="flex items-center">
<Tooltip content={t.agents.newChat}>
<Button
size="sm"
variant="secondary"
onClick={() => {
router.push(
`/page/workspace/agents/${agent_id}/chats/new`,
);
}}
>
<PlusSquare /> {t.agents.newChat}
</Button>
</Tooltip>
<TokenUsageIndicator
enabled={tokenUsageEnabled}
messages={thread.messages}
/>
<ExportTrigger threadId={threadId} />
<ArtifactTrigger />
</div>
</header>
<div className="w-full -translate-y-4">
{isLandingState ? (
/* ── home: centered hero + suggested questions + input ──────
Everything lives inside the scrollable main column. No fixed,
no absolute, no viewport centering — so the avatar can't touch
the header and the input can't cover the sidebar. */
<div className="flex min-h-0 flex-1 flex-col items-center overflow-y-auto px-6 pb-10 pt-16 sm:pt-24">
<AgentWelcome
agent={agent}
agentName={agentDisplayName}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
disabled={isDemoMode}
className="mb-7"
/>
<div className="w-full max-w-(--container-width-md)">
<InputBox
className="w-full"
isNewThread={isLandingState}
isNewThread
threadId={threadId}
assistantId={agent_id}
autoFocus={isLandingState}
status={
thread.error
? "error"
: thread.isLoading
? "streaming"
: "ready"
}
autoFocus
status={status}
context={settings.context}
extraHeader={
isLandingState && (
<AgentWelcome
agent={agent}
agentName={agentDisplayName}
onAskQuestion={(text) =>
handleSubmit({ text, files: [] })
}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
/>
)
disabled={isDemoMode}
onContextChange={(context) =>
setSettings("context", context)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{isDemoMode && (
<div className="text-muted-foreground/67 mt-3 w-full text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
) : (
/* ── conversation (active / history): messages scroll, input
docked at the bottom of the main column only. */
<div className="flex min-h-0 flex-1 flex-col">
<MessageList
className="min-h-0 flex-1"
threadId={threadId}
thread={thread}
paddingBottom={showFollowups ? 96 : 24}
tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
onEditResubmit={(text) => handleSubmit({ text, files: [] })}
/>
<div className="from-background pointer-events-none shrink-0 bg-gradient-to-t to-transparent px-4 pb-4 pt-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--container-width-lg)">
<TodoList
className="bg-background/5 mb-2"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos ||
thread.values.todos.length === 0
}
/>
<InputBox
className="w-full"
isNewThread={false}
threadId={threadId}
assistantId={agent_id}
status={status}
context={settings.context}
disabled={isDemoMode}
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{isDemoMode && (
<div className="text-muted-foreground/67 mt-2 w-full text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</div>
)}
</main>
{/* ── Right: read-only agent profile sidebar ───────────────────── */}
<AgentInfoSidebar
agent={agent}
agentName={agentDisplayName}
disabled={
thread.isLoading ||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
}
disabled={thread.isLoading || isDemoMode}
onAskQuestion={(text) => handleSubmit({ text, files: [] })}
/>
</div>
@@ -11,6 +11,7 @@ import {
Search,
Sparkles,
Trash2,
Upload,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -323,6 +324,119 @@ function BatchImportDialog({
);
}
function ImportFileDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { ingestFile } = useKnowledgeActions();
const navigate = useNavigate();
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [tags, setTags] = useState("");
const [model, setModel] = useState<string | null>(null);
const reset = () => {
setFile(null);
setTitle("");
setTags("");
setModel(null);
};
const handleImport = async () => {
if (!file) {
toast.error("请选择文件");
return;
}
try {
const result = await ingestFile.mutateAsync({
file,
opts: {
title: title.trim() || undefined,
tags: tags.trim() || undefined,
status: "approved",
model_name: model,
},
});
const count = result.notes?.length ?? 1;
if (result.llm_fallback) {
// The model produced nothing usable — the file was stored verbatim as a
// draft instead of distilled into a wiki note.
toast.warning(
`已导入 ${count} 条(大模型异常,改用规则抽取并存为草稿)`,
);
} else {
toast.success(`已从文件生成 ${count} 条知识`);
}
onOpenChange(false);
reset();
if (result.note?.id) {
navigate(`/page/workspace/knowledge/${result.note.id}`);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "文件导入失败");
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>导入文件生成 Wiki</DialogTitle>
<DialogDescription>
上传文档(Markdown / TXT / PDF / Word / PPT / Excel),自动提炼为知识库 wiki 词条并写入 vault。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5 text-sm">
选择文件
<Input
type="file"
accept=".md,.markdown,.txt,.pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="cursor-pointer"
/>
{file ? (
<span className="text-muted-foreground truncate text-xs">
{file.name} · {(file.size / 1024).toFixed(0)} KB
</span>
) : null}
</label>
<Input
placeholder="标题(可选,留空则由模型生成)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
placeholder="标签,逗号分隔(可选)"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
<label className="flex items-center justify-between gap-2 text-sm">
提炼大模型
<ModelNameSelect
value={model}
onChange={setModel}
disabled={ingestFile.isPending}
triggerClassName="h-9 w-[11rem]"
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={() => void handleImport()} disabled={ingestFile.isPending || !file}>
{ingestFile.isPending ? "导入中..." : "导入并生成"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function DedupDialog({
open,
onOpenChange,
@@ -698,6 +812,7 @@ export default function KnowledgeBasePage() {
const [status, setStatus] = useState("all");
const [createOpen, setCreateOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [dedupOpen, setDedupOpen] = useState(false);
const params = useMemo(
@@ -752,6 +867,9 @@ export default function KnowledgeBasePage() {
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="size-4" /> 导入文件
</Button>
<Button variant="outline" size="sm" onClick={() => setBatchOpen(true)}>
<RefreshCw className="size-4" /> 批量导入
</Button>
@@ -814,6 +932,7 @@ export default function KnowledgeBasePage() {
</div>
<CreateNoteDialog open={createOpen} onOpenChange={setCreateOpen} />
<ImportFileDialog open={importOpen} onOpenChange={setImportOpen} />
<BatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} />
<DedupDialog open={dedupOpen} onOpenChange={setDedupOpen} />
</div>
+10 -2
View File
@@ -4,13 +4,17 @@ import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/contexts/ThemeContext";
import { knowledgeExportFileUrl } from "@/core/knowledge/api";
import { useKnowledgeActions } from "@/core/knowledge/hooks";
export default function KnowledgeGraphPage() {
const navigate = useNavigate();
const { theme } = useTheme();
const { exportGraph } = useKnowledgeActions();
const [src, setSrc] = useState<string | null>(null);
// Keep the served base URL (without the theme) so a theme toggle can re-skin
// the iframe without regenerating the graph on the backend.
const [baseSrc, setBaseSrc] = useState<string | null>(null);
const [stats, setStats] = useState<{ nodes: number; edges: number } | null>(null);
const regenerate = useCallback(async () => {
@@ -18,7 +22,7 @@ export default function KnowledgeGraphPage() {
const result = await exportGraph.mutateAsync();
setStats(result.stats);
// Cache-bust the iframe so it reflects the freshly generated graph.
setSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
setBaseSrc(`${knowledgeExportFileUrl("graph.html")}?t=${Date.now()}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成图谱失败");
}
@@ -29,6 +33,10 @@ export default function KnowledgeGraphPage() {
void regenerate();
}, [regenerate]);
// The graph HTML lives in an iframe and can't read the app's CSS theme, so we
// pass the active day/night mode via a query param; toggling re-renders it.
const src = baseSrc ? `${baseSrc}&theme=${theme}` : null;
return (
<div className="flex size-full min-h-0 flex-col">
<header className="flex items-center gap-2 border-b p-3">