From 10ac5a527721699df822f49e1365bdcef1a76c27 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Sat, 13 Jun 2026 15:22:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(kb):=20=E7=AC=94=E8=AE=B0=E5=8F=AF?= =?UTF-8?q?=E7=BC=96=E8=BE=91=EF=BC=88=E6=8C=89=20doc=20=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E9=87=8D=E7=B4=A2=E5=BC=95=EF=BC=89+=20=E7=AC=94=E8=AE=B0?= =?UTF-8?q?=E5=85=B3=E7=B3=BB=E5=9B=BE=EF=BC=88[[=E5=8F=8C=E9=93=BE]]?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obsidian 化继续:笔记能编辑/新建,文档间 [[双链]] 连成可点关系图。 按 doc 重索引(编辑不重复累积): - Milvus 加 doc 字段(旧 schema 自动重建);insert 带 doc;deleteDoc(kb,doc) 重入库前清旧块。 - Bleve 索引 id 含 doc + deleteDoc 按 kb+doc 清旧块。 - rag.Ingest(kb, doc, text):写入前按 doc 删旧块再写(Neo4j MERGE 仍幂等,附加式)。 - kb_ingest 工具加 doc 参数;gateway runIngest 把 doc 透传,forceDoc 支持编辑保持笔记名稳定。 编辑/新建: - gateway POST /kb/note {kb,name,content}:落库 + 以 name 为 doc 重入库(替换旧块,搜索/图谱同步)。 - 前端 VaultPanel:阅读/编辑切换(textarea 预填原文,保存调 saveNote)、新建笔记、乐观更新。 笔记关系图: - GraphView 加 onNode(节点可点);VaultPanel 阅读/关系图切换,关系图 = 文档间 [[双链]] 三元组 力导向(点节点跳转该笔记)。 验证:curl 编辑 笔记B → 检索只返编辑后内容(旧块已清,不重复)。Preview:关系图渲染 笔记B—链接→项目A概述/模块X 且节点可点;编辑器预填原文可改可存。tsc+vite+后端 build 通过;重建 .app。 注:Milvus 加 doc 字段会触发集合重建(旧向量丢,文库原文在 PG 可重灌);Neo4j 图谱按附加式合并, 编辑删除的实体不会自动消失(图谱倾向增长)。 Co-Authored-By: Claude Opus 4.8 --- .../frontend/src/components/GraphView.tsx | 4 +- sundynix-desktop/frontend/src/lib/api.ts | 11 ++ .../frontend/src/views/KbView.tsx | 133 ++++++++++++++++-- sundynix-gateway/internal/handler/kb.go | 36 ++++- sundynix-gateway/internal/router/router.go | 1 + sundynix-mcp-go/internal/mcp/gateway.go | 3 +- sundynix-mcp-go/internal/rag/bleve.go | 30 +++- sundynix-mcp-go/internal/rag/milvus.go | 42 +++++- sundynix-mcp-go/internal/rag/rag.go | 11 +- 9 files changed, 242 insertions(+), 29 deletions(-) diff --git a/sundynix-desktop/frontend/src/components/GraphView.tsx b/sundynix-desktop/frontend/src/components/GraphView.tsx index 0f7740b..58caac7 100644 --- a/sundynix-desktop/frontend/src/components/GraphView.tsx +++ b/sundynix-desktop/frontend/src/components/GraphView.tsx @@ -94,7 +94,8 @@ function nodeColor(deg: number): { fill: string; text: string } { } // GraphView 把知识三元组渲染为力导向图(实体=节点,关系=带标签的边),hover 高亮邻域。 -export function GraphView({ triples, height = 360 }: { triples: Triple[]; height?: number }) { +// onNode 非空时节点可点(用于笔记关系图点节点开笔记)。 +export function GraphView({ triples, height = 360, onNode }: { triples: Triple[]; height?: number; onNode?: (id: string) => void }) { const W = 560; const H = height; const [hover, setHover] = useState(null); @@ -148,6 +149,7 @@ export function GraphView({ triples, height = 360 }: { triples: Triple[]; height opacity={on ? 1 : 0.2} onMouseEnter={() => setHover(n.id)} onMouseLeave={() => setHover(null)} + onClick={() => onNode?.(n.id)} style={{ cursor: "pointer" }} > diff --git a/sundynix-desktop/frontend/src/lib/api.ts b/sundynix-desktop/frontend/src/lib/api.ts index c410174..909ffbe 100644 --- a/sundynix-desktop/frontend/src/lib/api.ts +++ b/sundynix-desktop/frontend/src/lib/api.ts @@ -134,6 +134,17 @@ export async function listVault(id: Identity, kb: string): Promise { return data.docs ?? []; } +// saveNote: POST /api/v1/kb/note —— 新建/编辑笔记(落库 + 按 doc 重入库替换旧块)。 +export async function saveNote(id: Identity, kb: string, name: string, content: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/kb/note`, { + method: "POST", + headers: { "Content-Type": "application/json", ...idHeaders(id) }, + body: JSON.stringify({ kb, name, content }), + }); + const data = (await res.json()) as { name?: string; error?: string }; + if (!res.ok || !data.name) throw new Error(data.error ?? `save failed: ${res.status}`); +} + // 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 5e8888d..f1a4b5f 100644 --- a/sundynix-desktop/frontend/src/views/KbView.tsx +++ b/sundynix-desktop/frontend/src/views/KbView.tsx @@ -19,6 +19,10 @@ import { BookOpen, Link2, RefreshCw, + Pencil, + Save, + X, + Waypoints, type LucideIcon, } from "lucide-react"; import { @@ -30,6 +34,7 @@ import { listKb, createKb, listVault, + saveNote, type IngestEvent, type KbHit, type Triple, @@ -430,11 +435,27 @@ function escapeReg(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -// VaultPanel:Obsidian 式文库 —— 左文档列表 / 右 Markdown 笔记([[双链]]可点)+ 反向链接。 +// wikiLinks 从内容中抽出所有 [[名称]](忽略别名部分)。 +function wikiLinks(content: string): string[] { + const out: string[] = []; + const re = /\[\[([^\]|]+)(\|[^\]]*)?\]\]/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) out.push(m[1].trim()); + return out; +} + +// VaultPanel:Obsidian 式文库 —— 文档列表 / Markdown 阅读+编辑([[双链]]可点)/ 反向链接 / 笔记关系图。 function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { + const toast = useToast(); const [docs, setDocs] = useState([]); const [sel, setSel] = useState(null); const [loading, setLoading] = useState(false); + const [mode, setMode] = useState<"read" | "graph">("read"); + const [editing, setEditing] = useState(false); + const [creatingNew, setCreatingNew] = useState(false); + const [draft, setDraft] = useState(""); + const [draftName, setDraftName] = useState(""); + const [saving, setSaving] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -450,26 +471,77 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) { }, [identity, kb]); useEffect(() => { void load(); + setEditing(false); + setMode("read"); }, [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); + if (names.has(name)) { + setSel(name); + setMode("read"); + setEditing(false); + } }; 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 ; + // 笔记关系图:文档间 [[双链]] → 三元组(仅保留指向已存在笔记的边)。 + const noteTriples: Triple[] = []; + for (const d of docs) { + for (const link of wikiLinks(d.content)) { + if (names.has(link) && link !== d.name) noteTriples.push({ s: d.name, p: "链接", o: link }); + } } + const startNew = () => { + setCreatingNew(true); + setEditing(true); + setDraftName(""); + setDraft(""); + }; + const startEdit = () => { + if (!current) return; + setCreatingNew(false); + setEditing(true); + setDraftName(current.name); + setDraft(current.content); + }; + const onSave = async () => { + const name = (creatingNew ? draftName : current?.name ?? "").trim(); + if (!name || !draft.trim()) { + toast.push("error", "笔记名与内容不能为空"); + return; + } + setSaving(true); + try { + await saveNote(identity, kb, name, draft); + toast.push("success", `已保存「${name}」(正在重建索引)`); + setEditing(false); + setCreatingNew(false); + // 乐观更新本地 + 选中,再后台刷新。 + setDocs((ds) => [...ds.filter((x) => x.name !== name), { name, content: draft }]); + setSel(name); + setTimeout(() => void load(), 300); + } catch (e) { + toast.push("error", (e as Error).message); + } finally { + setSaving(false); + } + }; + + const empty = !loading && docs.length === 0; + return (
+
- {current ? ( + {/* 工具条:阅读/关系图 + 编辑 */} +
+
+ + +
+ {mode === "read" && !editing && current && ( + + )} + {editing && ( + <> + + + + )} + 笔记支持 [[双链]];保存后重建索引与图谱 +
+ + {mode === "graph" ? ( + noteTriples.length > 0 ? ( + + ) : ( + + ) + ) : editing ? ( +
+ {creatingNew && setDraftName(e.target.value)} placeholder="笔记名,如 项目A概述" autoFocus />} + {!creatingNew &&
{draftName}
} +