feat(kb): 笔记可编辑(按 doc 替换重索引)+ 笔记关系图([[双链]])

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 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-13 15:22:03 +08:00
parent 55c85302b6
commit 10ac5a5277
9 changed files with 242 additions and 29 deletions
@@ -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<string | null>(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" }}
>
<circle cx={n.x} cy={n.y} r={r} fill={c.fill} stroke={hover === n.id ? "#fff" : "#0b0d12"} strokeWidth={hover === n.id ? 2 : 1.5} />
+11
View File
@@ -134,6 +134,17 @@ export async function listVault(id: Identity, kb: string): Promise<VaultDoc[]> {
return data.docs ?? [];
}
// saveNote: POST /api/v1/kb/note —— 新建/编辑笔记(落库 + 按 doc 重入库替换旧块)。
export async function saveNote(id: Identity, kb: string, name: string, content: string): Promise<void> {
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<string> {
const res = await fetch(`${GATEWAY}/api/v1/kb/ingest`, {
+124 -9
View File
@@ -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, "\\$&");
}
// VaultPanelObsidian 式文库 —— 左文档列表 / 右 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;
}
// VaultPanelObsidian 式文库 —— 文档列表 / Markdown 阅读+编辑([[双链]]可点)/ 反向链接 / 笔记关系图。
function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
const toast = useToast();
const [docs, setDocs] = useState<VaultDoc[]>([]);
const [sel, setSel] = useState<string | null>(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 <EmptyState icon={BookOpen} title="文库为空" desc={`${kb}」还没有文档。到「入库」拖入文件或写笔记(支持 [[双链]])。`} />;
// 笔记关系图:文档间 [[双链]] → 三元组(仅保留指向已存在笔记的边)。
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 (
<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">
<div className="mb-1 flex items-center gap-1 px-1 text-[11px] text-slate-500">
<span> {docs.length}</span>
<button onClick={startNew} className="ml-auto text-slate-500 hover:text-brand-400" title="新建笔记">
<Plus className="h-3.5 w-3.5" />
</button>
<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>
@@ -478,7 +550,7 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
{docs.map((d) => (
<li key={d.name}>
<button
onClick={() => setSel(d.name)}
onClick={() => open(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" />
@@ -488,8 +560,51 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
))}
</ul>
</aside>
<div className="min-h-0 flex-1 overflow-y-auto p-5">
{current ? (
{/* 工具条:阅读/关系图 + 编辑 */}
<div className="mb-3 flex items-center gap-2">
<div className="flex rounded-md border border-line p-0.5 text-[11px]">
<button onClick={() => setMode("read")} className={cn("flex items-center gap-1 rounded px-2 py-1", mode === "read" ? "bg-brand/15 text-brand-400" : "text-slate-400")}>
<BookOpen className="h-3 w-3" />
</button>
<button onClick={() => setMode("graph")} className={cn("flex items-center gap-1 rounded px-2 py-1", mode === "graph" ? "bg-brand/15 text-brand-400" : "text-slate-400")}>
<Waypoints className="h-3 w-3" />
</button>
</div>
{mode === "read" && !editing && current && (
<Button size="sm" variant="ghost" icon={Pencil} onClick={startEdit}>
</Button>
)}
{editing && (
<>
<Button size="sm" variant="primary" icon={Save} onClick={onSave} disabled={saving}>
{saving ? "保存中…" : "保存"}
</Button>
<Button size="sm" variant="ghost" icon={X} onClick={() => setEditing(false)}>
</Button>
</>
)}
<span className="ml-auto text-[10px] text-slate-600"> [[]]</span>
</div>
{mode === "graph" ? (
noteTriples.length > 0 ? (
<GraphView triples={noteTriples} height={440} onNode={open} />
) : (
<EmptyState icon={Waypoints} title="暂无笔记关系" desc="在笔记里用 [[其它笔记名]] 互相引用,这里会连成关系图(点节点跳转)。" />
)
) : editing ? (
<div className="flex flex-col gap-2">
{creatingNew && <Input value={draftName} onChange={(e) => setDraftName(e.target.value)} placeholder="笔记名,如 项目A概述" autoFocus />}
{!creatingNew && <div className="text-sm font-semibold text-slate-100">{draftName}</div>}
<Textarea className="min-h-[360px] w-full font-mono" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={"# 标题\n正文支持 Markdown 与 [[双链]]…"} />
</div>
) : empty ? (
<EmptyState icon={BookOpen} title="文库为空" desc={`${kb}」还没有文档。点左上 + 新建笔记,或到「入库」拖入文件。`} action={<Button size="sm" icon={Plus} onClick={startNew}></Button>} />
) : 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" />
@@ -506,7 +621,7 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
<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">
<button onClick={() => open(b.name)} className="text-xs text-brand-400 hover:underline">
{b.name}
</button>
</li>
@@ -516,7 +631,7 @@ function VaultPanel({ identity, kb }: { identity: Identity; kb: string }) {
</div>
</>
) : (
<div className="text-sm text-slate-600"></div>
<div className="text-sm text-slate-600"></div>
)}
</div>
</div>