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