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:
@@ -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} />
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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<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>
|
||||
|
||||
Reference in New Issue
Block a user