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

+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">