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:
@@ -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) {
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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/pdf;可整文件夹拖入(docx/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/pdf;可整文件夹拖入(docx/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, "\\$&");
|
||||
}
|
||||
|
||||
// VaultPanel:Obsidian 式文库 —— 左文档列表 / 右 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 };
|
||||
|
||||
Reference in New Issue
Block a user