feat(kb): Obsidian 式文库 —— 笔记浏览 + [[双链]] + 反向链接(Tab 化)
把知识库做出 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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(<strong key={key} className="font-semibold text-slate-100">{m[2]}</strong>);
|
||||
else if (m[3] !== undefined) out.push(<code key={key} className="rounded bg-ink-800 px-1 py-0.5 font-mono text-[0.85em] text-accent-400">{m[3]}</code>);
|
||||
else if (m[4] !== undefined) out.push(<em key={key} className="italic text-slate-300">{m[4]}</em>);
|
||||
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(
|
||||
<button key={key} onClick={() => onLink?.(name)} className="rounded bg-brand/10 px-1 text-brand-400 hover:bg-brand/20">
|
||||
{label}
|
||||
</button>,
|
||||
);
|
||||
} else if (m[3] !== undefined) out.push(<strong key={key} className="font-semibold text-slate-100">{m[3]}</strong>);
|
||||
else if (m[4] !== undefined) out.push(<code key={key} className="rounded bg-ink-800 px-1 py-0.5 font-mono text-[0.85em] text-accent-400">{m[4]}</code>);
|
||||
else if (m[5] !== undefined) out.push(<em key={key} className="italic text-slate-300">{m[5]}</em>);
|
||||
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(
|
||||
<p key={`p${k++}`} className="my-2 leading-relaxed text-slate-300">
|
||||
{inline(para.join(" "), `p${k}`)}
|
||||
{il(para.join(" "), `p${k}`)}
|
||||
</p>,
|
||||
);
|
||||
para = [];
|
||||
@@ -44,7 +53,7 @@ export function Markdown({ text, className }: { text: string; className?: string
|
||||
if (list) {
|
||||
const items = list.items.map((it, idx) => (
|
||||
<li key={idx} className="leading-relaxed text-slate-300">
|
||||
{inline(it, `li${k}-${idx}`)}
|
||||
{il(it, `li${k}-${idx}`)}
|
||||
</li>
|
||||
));
|
||||
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 ? <h1 key={`h${k++}`} className={cls}>{content}</h1> : lvl === 2 ? <h2 key={`h${k++}`} className={cls}>{content}</h2> : <h3 key={`h${k++}`} className={cls}>{content}</h3>);
|
||||
} else if (line.startsWith(">")) {
|
||||
flushPara();
|
||||
flushList();
|
||||
blocks.push(
|
||||
<blockquote key={`q${k++}`} className="my-2 border-l-2 border-brand/50 pl-3 text-sm text-slate-400">
|
||||
{inline(line.replace(/^>\s?/, ""), `q${k}`)}
|
||||
{il(line.replace(/^>\s?/, ""), `q${k}`)}
|
||||
</blockquote>,
|
||||
);
|
||||
} else if (ol) {
|
||||
|
||||
Reference in New Issue
Block a user