feat(kb): Obsidian 式文库 —— 笔记浏览 + [[双链]] + 反向链接(Tab 化)

把知识库做出 Obsidian 感:入库的每份文件/笔记留原文,可浏览、可读、可互链。

- store: sundynix_doc(owner+kb+name 唯一,存原文),SaveDoc(OnConflict 覆盖)/ListVault。
- gateway: runIngest 留存原文(文件用文件名、文本用首行作笔记名);GET /kb/vault?kb= 取文库(owner 隔离)。
- Markdown 组件:解析 [[名称]] / [[名称|别名]] → onLink 可点(Obsidian 双链)。
- KbView 改 Tab(入库 / 文库 / 检索 / 图谱):
  - 文库 = 左文档列表 + 右 Markdown 笔记([[双链]]点击跳转)+ 反向链接面板(扫全库 [[本笔记]])。
  - 检索、图谱各占整页;图谱放大到 460。

验证(Preview):入两条带 [[双链]] 的笔记 → 文库列出 2 篇 → 打开「项目A概述」渲染出可点的
[[模块X]][[模块Y]] + 反向链接显示「模块X」→ 点 [[模块X]] 跳转到该笔记、其 [[项目A概述]] 亦可点。
curl 证隔离:alice 取 wt 的 vault → 空。tsc+vite+gateway build 通过;重建 .app 重启窗口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-13 15:06:31 +08:00
parent 3a175e46f3
commit 55c85302b6
7 changed files with 355 additions and 138 deletions
@@ -1,29 +1,38 @@
import { type ReactNode } from "react";
// 轻量 Markdown 渲染 —— 零依赖、行级解析,覆盖报告正文用到的子集:
// # / ## / ### 标题、**粗** *斜* `码`、- 与 1. 列表、> 引用、--- 分隔、段落。
// 流式安全:每个 token 重渲染,残缺语法也能容忍。
// # / ## / ### 标题、**粗** *斜* `码`、[[双链]]、- 与 1. 列表、> 引用、--- 分隔、段落。
// 流式安全:每个 token 重渲染,残缺语法也能容忍。onLink 非空时 [[名称]] 可点(Obsidian 式)。
// 行内:把一段文本切成 **粗** / *斜* / `码` / 纯文本节点。
function inline(text: string, keyPrefix: string): ReactNode[] {
// 行内:把一段文本切成 [[双链]] / **粗** / *斜* / `码` / 纯文本节点。
function inline(text: string, keyPrefix: string, onLink?: (name: string) => void): ReactNode[] {
const out: ReactNode[] = [];
const re = /(\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g;
const re = /(\[\[([^\]]+)\]\]|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push(text.slice(last, m.index));
const key = `${keyPrefix}-${i++}`;
if (m[2] !== undefined) out.push(<strong key={key} className="font-semibold text-slate-100">{m[2]}</strong>);
else if (m[3] !== undefined) out.push(<code key={key} className="rounded bg-ink-800 px-1 py-0.5 font-mono text-[0.85em] text-accent-400">{m[3]}</code>);
else if (m[4] !== undefined) out.push(<em key={key} className="italic text-slate-300">{m[4]}</em>);
if (m[2] !== undefined) {
const name = m[2].split("|")[0].trim(); // 支持 [[名称|别名]]
const label = m[2].includes("|") ? m[2].split("|")[1].trim() : m[2];
out.push(
<button key={key} onClick={() => onLink?.(name)} className="rounded bg-brand/10 px-1 text-brand-400 hover:bg-brand/20">
{label}
</button>,
);
} else if (m[3] !== undefined) out.push(<strong key={key} className="font-semibold text-slate-100">{m[3]}</strong>);
else if (m[4] !== undefined) out.push(<code key={key} className="rounded bg-ink-800 px-1 py-0.5 font-mono text-[0.85em] text-accent-400">{m[4]}</code>);
else if (m[5] !== undefined) out.push(<em key={key} className="italic text-slate-300">{m[5]}</em>);
last = re.lastIndex;
}
if (last < text.length) out.push(text.slice(last));
return out;
}
export function Markdown({ text, className }: { text: string; className?: string }) {
export function Markdown({ text, className, onLink }: { text: string; className?: string; onLink?: (name: string) => void }) {
const il = (t: string, k: string) => inline(t, k, onLink);
const lines = text.replace(/\r\n/g, "\n").split("\n");
const blocks: ReactNode[] = [];
let para: string[] = [];
@@ -34,7 +43,7 @@ export function Markdown({ text, className }: { text: string; className?: string
if (para.length) {
blocks.push(
<p key={`p${k++}`} className="my-2 leading-relaxed text-slate-300">
{inline(para.join(" "), `p${k}`)}
{il(para.join(" "), `p${k}`)}
</p>,
);
para = [];
@@ -44,7 +53,7 @@ export function Markdown({ text, className }: { text: string; className?: string
if (list) {
const items = list.items.map((it, idx) => (
<li key={idx} className="leading-relaxed text-slate-300">
{inline(it, `li${k}-${idx}`)}
{il(it, `li${k}-${idx}`)}
</li>
));
blocks.push(
@@ -76,14 +85,14 @@ export function Markdown({ text, className }: { text: string; className?: string
flushList();
const lvl = h[1].length;
const cls = lvl === 1 ? "mt-1 mb-3 text-xl font-bold text-slate-100" : lvl === 2 ? "mt-4 mb-2 text-base font-semibold text-slate-100" : "mt-3 mb-1 text-sm font-semibold text-slate-200";
const content = inline(h[2], `h${k}`);
const content = il(h[2], `h${k}`);
blocks.push(lvl === 1 ? <h1 key={`h${k++}`} className={cls}>{content}</h1> : lvl === 2 ? <h2 key={`h${k++}`} className={cls}>{content}</h2> : <h3 key={`h${k++}`} className={cls}>{content}</h3>);
} else if (line.startsWith(">")) {
flushPara();
flushList();
blocks.push(
<blockquote key={`q${k++}`} className="my-2 border-l-2 border-brand/50 pl-3 text-sm text-slate-400">
{inline(line.replace(/^>\s?/, ""), `q${k}`)}
{il(line.replace(/^>\s?/, ""), `q${k}`)}
</blockquote>,
);
} else if (ol) {
+13
View File
@@ -121,6 +121,19 @@ export async function createKb(id: Identity, name: string, kind: string): Promis
return { name: data.name, kind: data.kind ?? kind };
}
export interface VaultDoc {
name: string;
content: string;
}
// listVault: GET /api/v1/kb/vault —— 某知识库的原始文档(Obsidian 式文库浏览)。
export async function listVault(id: Identity, kb: string): Promise<VaultDoc[]> {
const res = await fetch(`${GATEWAY}/api/v1/kb/vault?kb=${encodeURIComponent(kb)}`, { headers: idHeaders(id) });
const data = (await res.json()) as { docs?: VaultDoc[]; error?: string };
if (!res.ok) throw new Error(data.error ?? `vault failed: ${res.status}`);
return data.docs ?? [];
}
// ingestKb: POST /api/v1/kb/ingest —— 文本入库(异步,返回 job_id)。
export async function ingestKb(id: Identity, kb: string, text: string): Promise<string> {
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, {
+241 -120
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Upload,
FileUp,
@@ -16,6 +16,9 @@ import {
Loader2,
Trash2,
Lock,
BookOpen,
Link2,
RefreshCw,
type LucideIcon,
} from "lucide-react";
import {
@@ -26,14 +29,17 @@ import {
graphKb,
listKb,
createKb,
listVault,
type IngestEvent,
type KbHit,
type Triple,
type KbInfo,
type VaultDoc,
type Identity,
} from "../lib/api";
import { GraphView } from "../components/GraphView";
import { Button, Input, Textarea, Select, Badge, cn, useToast } from "../ui";
import { Markdown } from "../components/Markdown";
import { Button, Input, Textarea, Select, Badge, Tabs, EmptyState, cn, useToast, type TabDef } from "../ui";
interface IngestLog {
t: string;
@@ -58,9 +64,10 @@ interface Progress {
interface FileJob {
id: string;
name: string;
status: string; // 排队/解析/向量化/写入/抽取/完成/失败
status: string;
error?: string;
}
type KbTab = "ingest" | "vault" | "search" | "graph";
const STAGE: Record<string, { icon: LucideIcon; label: string }> = {
: { icon: Upload, label: "解析文件" },
@@ -85,7 +92,6 @@ function stageToStatus(stage: string): string {
if (stage === "失败" || stage === "连接中断") return "失败";
return "排队";
}
function dedupTriples(ts: Triple[]): Triple[] {
const seen = new Set<string>();
const out: Triple[] = [];
@@ -99,11 +105,12 @@ function dedupTriples(ts: Triple[]): Triple[] {
return out;
}
// 知识库管理:按 owner 隔离 + 项目/案件/文件夹组织;批量文件入库(文件列表) + 实时时间线 + 力导向图谱 + 混合检索
// 知识库owner 隔离 + 项目/案件/文件夹组织;Tab 分(入库 / 文库(Obsidian 式) / 检索 / 图谱)
export function KbView({ identity }: { identity: Identity }) {
const toast = useToast();
const [kbs, setKbs] = useState<KbInfo[]>([]);
const [kb, setKb] = useState("default");
const [tab, setTab] = useState<KbTab>("ingest");
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newKind, setNewKind] = useState("project");
@@ -122,24 +129,22 @@ export function KbView({ identity }: { identity: Identity }) {
const [graph, setGraph] = useState<Triple[] | null>(null);
const [dragOver, setDragOver] = useState(false);
// 让文件夹输入框可选整个目录(标准类型无 webkitdirectory,挂在 DOM 上)。
useEffect(() => {
if (folderRef.current) folderRef.current.setAttribute("webkitdirectory", "");
}, []);
const refreshKbs = async () => {
const refreshKbs = useCallback(async () => {
try {
const list = await listKb(identity);
setKbs(list);
if (list.length && !list.some((k) => k.name === kb)) setKb(list[0].name);
setKb((cur) => (list.length && !list.some((k) => k.name === cur) ? list[0].name : cur));
} catch {
/* 降级用默认库 */
/* 降级用默认库 */
}
};
}, [identity]);
useEffect(() => {
void refreshKbs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity.userId]);
}, [refreshKbs]);
const onCreate = async () => {
const name = newName.trim();
@@ -209,7 +214,6 @@ export function KbView({ identity }: { identity: Identity }) {
}
};
// 批量入库:每个文件起一个 job,行内显示各自状态。
const ingestFiles = (list: FileList | File[] | null | undefined) => {
const arr = Array.from(list ?? []);
arr.forEach((file, idx) => {
@@ -234,13 +238,14 @@ export function KbView({ identity }: { identity: Identity }) {
}
};
const onGraph = async () => {
const onGraph = useCallback(async () => {
try {
setGraph(await graphKb(identity, kb));
} catch (e) {
toast.push("error", (e as Error).message);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity, kb]);
const vecPct = prog?.vecTotal ? Math.round(((prog.vecDone ?? 0) / prog.vecTotal) * 100) : 0;
const graphData = graph ?? prog?.triples ?? null;
@@ -248,9 +253,16 @@ export function KbView({ identity }: { identity: Identity }) {
const doneCount = files.filter((f) => f.status === "完成").length;
const failCount = files.filter((f) => f.status === "失败").length;
const tabs: TabDef<KbTab>[] = [
{ key: "ingest", label: "入库" },
{ key: "vault", label: "文库" },
{ key: "search", label: "检索" },
{ key: "graph", label: "图谱" },
];
return (
<div className="flex h-full flex-col">
{/* 知识库选择 / 新建 */}
{/* 知识库选择 / 新建 + 隔离徽标 */}
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-ink-900 px-4 py-2">
<span className="text-sm font-semibold text-slate-300"></span>
<Select className="h-8 w-48" value={kb} onChange={(e) => setKb(e.target.value)}>
@@ -286,117 +298,226 @@ export function KbView({ identity }: { identity: Identity }) {
</span>
</div>
<div className="flex min-h-0 flex-1">
{/* 左:入库 + 文件列表 + 时间线 */}
<section className="flex w-1/2 flex-col overflow-y-auto border-r border-line p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400">{kb}</h3>
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
ingestFiles(e.dataTransfer.files);
}}
className={cn("relative rounded-md", dragOver && "ring-2 ring-brand")}
>
<Textarea className="h-20 w-full resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或把一批文件 / 整个文件夹拖到这里" />
{dragOver && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-ink-950/85 text-xs font-medium text-brand-400"></div>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={busy || !text.trim()}>
</Button>
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={busy}>
</Button>
<Button size="sm" icon={FolderUp} onClick={() => folderRef.current?.click()} disabled={busy}>
</Button>
<input ref={fileRef} type="file" multiple accept=".txt,.md,.csv,.docx,.xlsx,.pdf" onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
<input ref={folderRef} type="file" multiple onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
</div>
<span className="mt-1 text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span>
{/* Tab 条 */}
<div className="flex items-center border-b border-line px-3">
<Tabs tabs={tabs} value={tab} onChange={setTab} />
{busy && <span className="ml-2 flex items-center gap-1 text-[11px] text-accent-400"><Loader2 className="h-3 w-3 animate-spin" /> </span>}
</div>
{/* 批量文件列表 */}
{files.length > 0 && (
<div className="mt-3 rounded-lg border border-line bg-ink-850 p-2.5">
<div className="mb-1.5 flex items-center text-[11px] text-slate-400">
<span> {files.length} </span>
<span className="ml-auto text-slate-500">
{doneCount} · {failCount}
</span>
<button className="ml-2 text-slate-600 hover:text-slate-400" onClick={() => setFiles([])} title="清空列表">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<ul className="max-h-40 space-y-1 overflow-auto">
{files.map((f) => (
<li key={f.id} className="flex items-center gap-2 rounded bg-ink-900 px-2 py-1 text-[11px]">
<FileText className="h-3.5 w-3.5 shrink-0 text-slate-500" />
<span className="truncate text-slate-300" title={f.name}>{f.name}</span>
<span className="ml-auto">
<FileStatus status={f.status} />
</span>
<div className="min-h-0 flex-1 overflow-hidden">
{tab === "ingest" && (
<div className="h-full overflow-y-auto p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400">{kb}</h3>
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
ingestFiles(e.dataTransfer.files);
}}
className={cn("relative max-w-2xl rounded-md", dragOver && "ring-2 ring-brand")}
>
<Textarea className="h-20 w-full resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或把一批文件 / 整个文件夹拖到这里(笔记支持 [[双链]])" />
{dragOver && <div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-ink-950/85 text-xs font-medium text-brand-400"></div>}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={busy || !text.trim()}>
</Button>
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={busy}>
</Button>
<Button size="sm" icon={FolderUp} onClick={() => folderRef.current?.click()} disabled={busy}>
</Button>
<input ref={fileRef} type="file" multiple accept=".txt,.md,.csv,.docx,.xlsx,.pdf" onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
<input ref={folderRef} type="file" multiple onChange={(e) => ingestFiles(e.target.files)} className="hidden" />
</div>
<span className="mt-1 block text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span>
<div className="mt-3 grid max-w-3xl gap-3 lg:grid-cols-2">
{files.length > 0 && (
<div className="rounded-lg border border-line bg-ink-850 p-2.5">
<div className="mb-1.5 flex items-center text-[11px] text-slate-400">
<span> {files.length} </span>
<span className="ml-auto text-slate-500"> {doneCount} · {failCount}</span>
<button className="ml-2 text-slate-600 hover:text-slate-400" onClick={() => setFiles([])} title="清空列表">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<ul className="max-h-48 space-y-1 overflow-auto">
{files.map((f) => (
<li key={f.id} className="flex items-center gap-2 rounded bg-ink-900 px-2 py-1 text-[11px]">
<FileText className="h-3.5 w-3.5 shrink-0 text-slate-500" />
<span className="truncate text-slate-300" title={f.name}>{f.name}</span>
<span className="ml-auto"><FileStatus status={f.status} /></span>
</li>
))}
</ul>
</div>
)}
{prog && (
<div className={files.length > 0 ? "" : "lg:col-span-2"}>
<Timeline prog={prog} vecPct={vecPct} />
</div>
)}
</div>
{logs.length > 0 && (
<ul className="mt-3 max-w-2xl space-y-1">
{logs.map((l, i) => (
<li key={i} className={cn("text-xs", l.ok ? "text-success" : "text-danger")}>
<span className="text-slate-600">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
</li>
))}
</ul>
)}
</div>
)}
{tab === "vault" && <VaultPanel identity={identity} kb={kb} />}
{tab === "search" && (
<div className="h-full overflow-y-auto p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"> · {kb} + rerank</h3>
<div className="flex max-w-3xl gap-2">
<Input className="flex-1" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" />
<Input type="number" className="w-16" value={topK} min={1} max={20} onChange={(e) => setTopK(Number(e.target.value))} title="TopK" />
<Button variant="primary" size="md" icon={Search} onClick={onSearch} disabled={searching || !q.trim()}>
{searching ? "检索中…" : "检索"}
</Button>
</div>
)}
{prog && <Timeline prog={prog} vecPct={vecPct} />}
<h3 className="mb-1 mt-4 text-xs font-semibold text-slate-400"></h3>
<ul className="space-y-1">
{logs.length === 0 && <li className="text-xs text-slate-600"></li>}
{logs.map((l, i) => (
<li key={i} className={cn("text-xs", l.ok ? "text-success" : "text-danger")}>
<span className="text-slate-600">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
</li>
))}
</ul>
</section>
{/* 右:检索台 + 知识图谱 */}
<section className="flex w-1/2 flex-col overflow-y-auto p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"> · {kb} + rerank</h3>
<div className="flex gap-2">
<Input className="flex-1" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" />
<Input type="number" className="w-16" value={topK} min={1} max={20} onChange={(e) => setTopK(Number(e.target.value))} title="TopK" />
<Button variant="primary" size="md" icon={Search} onClick={onSearch} disabled={searching || !q.trim()}>
{searching ? "检索中…" : "检索"}
</Button>
<ul className="mt-3 max-w-3xl space-y-2">
{hits === null && <li className="text-xs text-slate-600"></li>}
{hits !== null && hits.length === 0 && <li className="text-xs text-slate-600"> RAG </li>}
{hits?.map((h, i) => (
<li key={i} className="rounded-lg border border-line bg-ink-850 p-2">
<div className="mb-1 flex items-center gap-2 text-[10px]">
<Badge tone="accent"></Badge>
<span className="text-slate-600">#{i + 1}</span>
<span className="ml-auto font-mono text-brand-400">{h.score.toFixed(3)}</span>
</div>
<div className="text-xs text-slate-300">{h.text}</div>
</li>
))}
</ul>
</div>
<ul className="mt-3 space-y-2">
{hits === null && <li className="text-xs text-slate-600"></li>}
{hits !== null && hits.length === 0 && <li className="text-xs text-slate-600"> RAG </li>}
{hits?.map((h, i) => (
<li key={i} className="rounded-lg border border-line bg-ink-850 p-2">
<div className="mb-1 flex items-center gap-2 text-[10px]">
<Badge tone="accent"></Badge>
<span className="text-slate-600">#{i + 1}</span>
<span className="ml-auto font-mono text-brand-400">{h.score.toFixed(3)}</span>
</div>
<div className="text-xs text-slate-300">{h.text}</div>
</li>
))}
</ul>
)}
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
<h3 className="text-xs font-semibold text-slate-400">Neo4j · </h3>
<Button size="sm" icon={Network} onClick={onGraph}>
</Button>
{tab === "graph" && (
<div className="h-full overflow-y-auto p-4">
<div className="mb-2 flex max-w-3xl items-center justify-between">
<h3 className="text-xs font-semibold text-slate-400"> · {kb}Neo4j · </h3>
<Button size="sm" icon={Network} onClick={onGraph}>
</Button>
</div>
<div className="max-w-3xl">
<GraphView triples={graphData ?? []} height={460} />
</div>
</div>
<div className="mt-2">
<GraphView triples={graphData ?? []} />
</div>
</section>
)}
</div>
</div>
);
}
function escapeReg(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// VaultPanelObsidian 式文库 —— 左文档列表 / 右 Markdown 笔记([[双链]]可点)+ 反向链接。
function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
const [docs, setDocs] = useState<VaultDoc[]>([]);
const [sel, setSel] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const d = await listVault(identity, kb);
setDocs(d);
setSel((s) => (d.some((x) => x.name === s) ? s : d[0]?.name ?? null));
} catch {
setDocs([]);
} finally {
setLoading(false);
}
}, [identity, kb]);
useEffect(() => {
void load();
}, [load]);
const names = new Set(docs.map((d) => d.name));
const current = docs.find((d) => d.name === sel);
const open = (name: string) => {
if (names.has(name)) setSel(name);
};
const backlinks = current
? docs.filter((d) => d.name !== current.name && new RegExp(`\\[\\[\\s*${escapeReg(current.name)}(\\|[^\\]]*)?\\s*\\]\\]`).test(d.content))
: [];
if (!loading && docs.length === 0) {
return <EmptyState icon={BookOpen} title="文库为空" desc={`${kb}」还没有文档。到「入库」拖入文件或写笔记(支持 [[双链]])。`} />;
}
return (
<div className="flex h-full min-h-0">
<aside className="flex w-56 shrink-0 flex-col overflow-y-auto border-r border-line p-2">
<div className="mb-1 flex items-center justify-between px-1 text-[11px] text-slate-500">
<span> {docs.length}</span>
<button onClick={load} className="text-slate-600 hover:text-slate-300" title="刷新">
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</button>
</div>
<ul className="space-y-0.5">
{docs.map((d) => (
<li key={d.name}>
<button
onClick={() => setSel(d.name)}
className={cn("flex w-full items-center gap-1.5 rounded px-2 py-1.5 text-left text-xs", d.name === sel ? "bg-brand/15 text-brand-400" : "text-slate-300 hover:bg-ink-800")}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate" title={d.name}>{d.name}</span>
</button>
</li>
))}
</ul>
</aside>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{current ? (
<>
<h2 className="mb-3 flex items-center gap-2 text-base font-semibold text-slate-100">
<FileText className="h-4 w-4 text-brand-400" />
{current.name}
</h2>
<Markdown text={current.content} className="text-sm" onLink={open} />
<div className="mt-6 border-t border-line pt-3">
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-slate-400">
<Link2 className="h-3.5 w-3.5" /> {backlinks.length}
</div>
{backlinks.length === 0 ? (
<p className="text-[11px] text-slate-600"> [[{current.name}]] </p>
) : (
<ul className="space-y-1">
{backlinks.map((b) => (
<li key={b.name}>
<button onClick={() => setSel(b.name)} className="text-xs text-brand-400 hover:underline">
{b.name}
</button>
</li>
))}
</ul>
)}
</div>
</>
) : (
<div className="text-sm text-slate-600"></div>
)}
</div>
</div>
);
@@ -417,7 +538,7 @@ function FileStatus({ status }: { status: string }) {
// Timeline 渲染入库各阶段:状态灯 + 标签 + 详情;解析预览、切块块、抽取的知识三元组逐步呈现。
function Timeline({ prog, vecPct }: { prog: Progress; vecPct: number }) {
return (
<div className="mt-3 rounded-lg border border-line bg-ink-850 p-3">
<div className="rounded-lg border border-line bg-ink-850 p-3">
<ol className="relative ml-1 space-y-2 border-l border-line pl-4">
{prog.steps.map((s, i) => {
const meta = STAGE[s.stage] ?? { icon: Loader2, label: s.stage };
+43 -4
View File
@@ -77,10 +77,39 @@ func (h *Handler) KbIngest(c *gin.Context) {
}
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(body.KB), "general")
job := newJobID()
go h.runIngest(job, scopedKB(c, body.KB), "", nil, body.Text)
go h.runIngest(job, userID(c), rawKB(body.KB), scopedKB(c, body.KB), "", nil, body.Text)
c.JSON(http.StatusAccepted, gin.H{"job_id": job})
}
// KbVault: GET /api/v1/kb/vault?kb= —— 某知识库的全部原始文档(名+内容),供 Obsidian 式文库浏览。
func (h *Handler) KbVault(c *gin.Context) {
rows, err := h.db.ListVault(c.Request.Context(), userID(c), rawKB(c.Query("kb")))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
docs := make([]gin.H, 0, len(rows))
for _, r := range rows {
docs = append(docs, gin.H{"name": r.Name, "content": r.Content})
}
c.JSON(http.StatusOK, gin.H{"docs": docs})
}
// noteName 取文本首个非空行作笔记名(截断 40 字),用于文本入库的文库留存。
func noteName(text string) string {
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line != "" {
r := []rune(line)
if len(r) > 40 {
return string(r[:40])
}
return line
}
}
return "笔记"
}
// KbIngestFile: POST /api/v1/kb/ingest_filemultipart)—— 文件入库(异步,返回 job_id)。
// 流水线(解析→切块→向量化→写入)的进度经 sundynix.streams.<job_id> 回流,UI 用 SSE 看。
func (h *Handler) KbIngestFile(c *gin.Context) {
@@ -103,13 +132,14 @@ func (h *Handler) KbIngestFile(c *gin.Context) {
}
_ = h.db.EnsureKB(c.Request.Context(), userID(c), rawKB(kb), "general")
job := newJobID()
go h.runIngest(job, scopedKB(c, kb), fh.Filename, data, "")
go h.runIngest(job, userID(c), rawKB(kb), scopedKB(c, kb), fh.Filename, data, "")
c.JSON(http.StatusAccepted, gin.H{"job_id": job, "file": fh.Filename})
}
// runIngest 后台跑入库流水线,逐阶段把进度发到 sundynix.streams.<job>。
// owner+kbName 用于"文库"原文留存;scoped 是 owner/kb 作向量/全文/图谱分区键。
// filename 非空表示文件入库(先经 mcp-py 解析);否则用 rawText。
func (h *Handler) runIngest(job, kb, filename string, data []byte, rawText string) {
func (h *Handler) runIngest(job, owner, kbName, scoped, filename string, data []byte, rawText string) {
ctx := context.Background()
emit := func(ev contract.IngestEvent) { _ = h.bus.PublishIngest(job, &ev) }
time.Sleep(400 * time.Millisecond) // 给 SSE 客户端订阅时间(core NATS 无缓冲)
@@ -131,9 +161,18 @@ func (h *Handler) runIngest(job, kb, filename string, data []byte, rawText strin
text = parsed
}
// 文库留存原文:文件用文件名,文本用首行作笔记名(best-effort,不阻断入库)。
docName := filename
if docName == "" {
docName = noteName(text)
}
if text != "" {
_ = h.db.SaveDoc(ctx, owner, kbName, docName, text)
}
// 调 mcp-go kb_ingest(带 job_id):它会发 切块/向量化/写入/完成 事件 + CompleteStream。
res, err := h.bus.CallTool(ctx, contract.ToolSubjectGo("kb_ingest"),
&contract.ToolCall{Tool: "kb_ingest", Args: map[string]any{"kb": kb, "text": text, "job_id": job}})
&contract.ToolCall{Tool: "kb_ingest", Args: map[string]any{"kb": scoped, "text": text, "job_id": job}})
if err != nil || res == nil || !res.OK {
msg := "kb_ingest 失败"
if err != nil {
@@ -30,6 +30,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
api.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库(docx/xlsx/pdf… 异步)
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(实时监控)
api.POST("/kb/search", h.KbSearch) // 知识库检索台(→ mcp-go kb_search
api.GET("/kb/vault", h.KbVault) // 文库:原始文档浏览(Obsidian 式)
api.GET("/kb/graph", h.KbGraph) // 知识图谱三元组(→ mcp-go kb_graphNeo4j
api.POST("/reports", h.GenerateReport) // 报告生成(intent=report 任务 → Dispatcher 专用编排)
+34
View File
@@ -45,6 +45,40 @@ func (p *Postgres) EnsureKB(ctx context.Context, owner, name, kind string) error
}).Create(&KB{Owner: owner, Name: name, Kind: kind}).Error
}
// Doc 是入库的一份原始文档/笔记(供 Obsidian 式"文库"浏览:列表 + Markdown 阅读 + 双链)。
// 表名 sundynix_doc。(owner,kb,name) 唯一;按 owner 隔离。
type Doc struct {
ID uint `gorm:"primaryKey"`
Owner string `gorm:"size:64;uniqueIndex:idx_doc_okn"`
KB string `gorm:"size:64;uniqueIndex:idx_doc_okn"`
Name string `gorm:"size:160;uniqueIndex:idx_doc_okn"`
Content string `gorm:"type:text"`
CreatedAt time.Time
}
func (Doc) TableName() string { return "sundynix_doc" }
// SaveDoc 写入/更新一份文档(owner+kb+name 唯一,重名覆盖内容)。
func (p *Postgres) SaveDoc(ctx context.Context, owner, kb, name, content string) error {
if p.db == nil {
return nil
}
return p.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "owner"}, {Name: "kb"}, {Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"content"}),
}).Create(&Doc{Owner: owner, KB: kb, Name: name, Content: content}).Error
}
// ListVault 返回某 owner 某 kb 的全部文档(名+内容),供文库浏览/双链/反链。
func (p *Postgres) ListVault(ctx context.Context, owner, kb string) ([]Doc, error) {
if p.db == nil {
return nil, nil
}
var rows []Doc
err := p.db.WithContext(ctx).Where("owner = ? AND kb = ?", owner, kb).Order("id").Find(&rows).Error
return rows, err
}
// LLMModel 是一个模型后端配置(控制面:管理员在此登记可用模型)。
// 表名 sundynix_model(遵守前缀约定)。每个 kind 同一时刻仅一条 Active=true。
type LLMModel struct {
+1 -1
View File
@@ -34,7 +34,7 @@ func OpenPostgres(dsn string) *Postgres {
log.Printf("[store] postgres 不可用,降级运行(不持久化): %v", err)
return &Postgres{}
}
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}); err != nil {
if err := db.AutoMigrate(&User{}, &Task{}, &LLMModel{}, &KB{}, &Doc{}); err != nil {
log.Printf("[store] postgres AutoMigrate 失败,降级运行: %v", err)
return &Postgres{}
}