From 55c85302b6095ebb563feb8936e0f6e8354b3adb Mon Sep 17 00:00:00 2001 From: Blizzard Date: Sat, 13 Jun 2026 15:06:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(kb):=20Obsidian=20=E5=BC=8F=E6=96=87?= =?UTF-8?q?=E5=BA=93=20=E2=80=94=E2=80=94=20=E7=AC=94=E8=AE=B0=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=20+=20[[=E5=8F=8C=E9=93=BE]]=20+=20=E5=8F=8D=E5=90=91?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=EF=BC=88Tab=20=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把知识库做出 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 --- .../frontend/src/components/Markdown.tsx | 35 +- sundynix-desktop/frontend/src/lib/api.ts | 13 + .../frontend/src/views/KbView.tsx | 361 ++++++++++++------ sundynix-gateway/internal/handler/kb.go | 47 ++- sundynix-gateway/internal/router/router.go | 1 + sundynix-gateway/internal/store/model.go | 34 ++ sundynix-gateway/internal/store/pgsql.go | 2 +- 7 files changed, 355 insertions(+), 138 deletions(-) diff --git a/sundynix-desktop/frontend/src/components/Markdown.tsx b/sundynix-desktop/frontend/src/components/Markdown.tsx index ef842c7..51d0833 100644 --- a/sundynix-desktop/frontend/src/components/Markdown.tsx +++ b/sundynix-desktop/frontend/src/components/Markdown.tsx @@ -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({m[2]}); - else if (m[3] !== undefined) out.push({m[3]}); - else if (m[4] !== undefined) out.push({m[4]}); + 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( + , + ); + } else if (m[3] !== undefined) out.push({m[3]}); + else if (m[4] !== undefined) out.push({m[4]}); + else if (m[5] !== undefined) out.push({m[5]}); 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(

- {inline(para.join(" "), `p${k}`)} + {il(para.join(" "), `p${k}`)}

, ); para = []; @@ -44,7 +53,7 @@ export function Markdown({ text, className }: { text: string; className?: string if (list) { const items = list.items.map((it, idx) => (
  • - {inline(it, `li${k}-${idx}`)} + {il(it, `li${k}-${idx}`)}
  • )); 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 ?

    {content}

    : lvl === 2 ?

    {content}

    :

    {content}

    ); } else if (line.startsWith(">")) { flushPara(); flushList(); blocks.push(
    - {inline(line.replace(/^>\s?/, ""), `q${k}`)} + {il(line.replace(/^>\s?/, ""), `q${k}`)}
    , ); } else if (ol) { diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index c1f5082..c410174 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -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 { + 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 { const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, { diff --git a/sundynix-desktop/frontend/src/views/KbView.tsx b/sundynix-desktop/frontend/src/views/KbView.tsx index 4a18f6a..5e8888d 100644 --- a/sundynix-desktop/frontend/src/views/KbView.tsx +++ b/sundynix-desktop/frontend/src/views/KbView.tsx @@ -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 = { 解析: { 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(); 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([]); const [kb, setKb] = useState("default"); + const [tab, setTab] = useState("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(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[] = [ + { key: "ingest", label: "入库" }, + { key: "vault", label: "文库" }, + { key: "search", label: "检索" }, + { key: "graph", label: "图谱" }, + ]; + return (
    - {/* 知识库选择 / 新建 */} + {/* 知识库选择 / 新建 + 隔离徽标 */}
    知识库