feat(mira): [[entity]] wikilink — 顯式建檔 + autocomplete + render link

對應 tasks.md backlog #12 / design.md §3.6.2。leo 反饋:要像 Logseq 那樣
寫 [[X]] 立刻建檔,下次 [[X 自動補完,render 變連結。

helpers:
- parseWikilinks(text) → 抽 [[X]] entity list
- fetchAllEntityNames(apiKey) → 合 index-entry + wiki-page entity 名稱
- getEntityNamesCached → 30s session cache for autocomplete
- ensureEntitiesExist → 新 entity 立刻建 wiki-page placeholder
  - tags 含 entity-type:book/url/concept(guessEntityType 推測:《》→ book / http → url)
  - source: leo-explicit
- expandWikilinks(text) → render 時把 [[X]] 轉 markdown link to /mira/wiki/wiki-X

UI:
- <WikilinkAutocomplete>:textarea cursor 在 `[[query` 未閉合時,下拉顯示既存
  entity(substring filter)+「⊕ 建立 [[query]]」option,↑↓選 / Enter 確認 /
  Esc 取消。fixed-position below textarea bottom(cursor tracking 留下輪)
- PostComposer compact + popup 兩個 textarea 都掛 autocomplete
- EditingArea(PostEditor / BlockEditor / ReplyLine / PageReplyComposer 共用)
  加 apiKey prop,內部 textarea + popup 都掛 autocomplete

submit hook:
- PostComposer.submit:postBlockId 建好後 ensureEntitiesExist(wikilinks)
- BlockEditor.submitReply / ReplyLine.submitReply 同樣建檔
- 在 wiki_synthesis trigger 前先建,避免 race

render:
- markdown.tsx expandWikilinks 取代 stripLogseqMeta 前處理(兩階段)
- 內部 wiki link(/mira/wiki/...)不開 _target=_blank(不離開頁面)

留下輪:metadata 補完 (作者/出版社) / cursor tracking / PostEditor.save 也建檔
This commit is contained in:
2026-05-16 12:08:28 +08:00
parent d6bff9d551
commit 7da1eb6d65
2 changed files with 308 additions and 22 deletions
+26 -12
View File
@@ -8,23 +8,26 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export function MarkdownView({ text }: { text: string }) {
const cleaned = useMemo(() => stripLogseqMeta(text), [text]);
// 兩階段預處理:1. strip Logseq metadata2. [[entity]] 轉成 markdown link
const cleaned = useMemo(() => expandWikilinks(stripLogseqMeta(text)), [text]);
return (
<div className="mira-md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children, ...rest }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="wiki-link"
{...rest}
>
{children}
</a>
),
a: ({ href, children, ...rest }) => {
const isWikiLink = typeof href === 'string' && href.startsWith('/mira/wiki/');
return (
<a
href={href}
{...(isWikiLink ? {} : { target: '_blank', rel: 'noopener noreferrer' })}
className="wiki-link"
{...rest}
>
{children}
</a>
);
},
// 圖片不直接 inline 顯示(避免大圖打亂 feed),改成連結
img: ({ src, alt }) => {
const href = typeof src === 'string' ? src : '';
@@ -61,3 +64,14 @@ export function stripLogseqMeta(text: string): string {
})
.join('\n');
}
// 把 [[entity]] 轉成 markdown link 指向 /mira/wiki/wiki-{entity}
// 對應 mira-app design.md §3.6.2 + tasks.md backlog #12
export function expandWikilinks(text: string): string {
return text.replace(/\[\[([^\[\]\n]+?)\]\]/g, (_, entity: string) => {
const e = entity.trim();
if (!e) return '[[]]';
const url = `/mira/wiki/${encodeURIComponent('wiki-' + e)}`;
return `[${e}](${url})`;
});
}
+282 -10
View File
@@ -176,6 +176,97 @@ function hasMiraMention(text: string): boolean {
return /(^|[^\w])@mira\b/i.test(text);
}
// ── Backlog #12[[entity]] 統一引用 syntax ──────────────────────────────────
// design.md §3.6.2 ZK 進化版(防雙胞胎)
// 抽 [[X]] 出來
function parseWikilinks(text: string): string[] {
const matches = text.matchAll(/\[\[([^\[\]\n]+?)\]\]/g);
const out: string[] = [];
for (const m of matches) {
const entity = m[1].trim();
if (entity) out.push(entity);
}
return Array.from(new Set(out)); // unique
}
// 撈所有既存 entity 名稱(從 wiki-page + index-entry 合集)
async function fetchAllEntityNames(apiKey: string): Promise<Set<string>> {
try {
const headers = { Authorization: `Bearer ${apiKey}` };
const [idxRes, pageRes] = await Promise.all([
fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, { headers }),
fetch(`${KBDB_BASE}/blocks?type=wiki-page&source=ai-canon-wiki&limit=200`, { headers }),
]);
type B = { page_name: string | null; content: string };
const idxData = idxRes.ok ? await idxRes.json() as { blocks?: B[] } : { blocks: [] };
const pageData = pageRes.ok ? await pageRes.json() as { blocks?: B[] } : { blocks: [] };
const set = new Set<string>();
for (const b of (idxData.blocks ?? [])) {
const e = (b.page_name ?? '').replace(/^index-/, '').trim();
if (e) set.add(e);
}
for (const b of (pageData.blocks ?? [])) {
const e = (b.content ?? '').trim();
if (e) set.add(e);
}
return set;
} catch {
return new Set();
}
}
// Cache:每個 session 只 fetch 一次 entity listautocomplete 用)
let cachedEntityNames: Set<string> | null = null;
let cachedEntityFetchedAt = 0;
async function getEntityNamesCached(apiKey: string): Promise<Set<string>> {
const now = Date.now();
// 30s TTL — leo 持續寫貼文時新建的 entity 也能很快被 autocomplete 看到
if (cachedEntityNames && now - cachedEntityFetchedAt < 30_000) return cachedEntityNames;
cachedEntityNames = await fetchAllEntityNames(apiKey);
cachedEntityFetchedAt = now;
return cachedEntityNames;
}
// 簡單 type 推測:《X》 → 書;http(s)://X → URL;其他 → 概念
function guessEntityType(name: string): 'book' | 'url' | 'concept' {
if (/^《.*》$|《[^》]+》/.test(name)) return 'book';
if (/^https?:\/\//.test(name)) return 'url';
return 'concept';
}
// 確保 [[X]] 命中的 entity 都存在 KBDB(不存在則新建 wiki-page 占位)
// 後續 wiki_synthesis 會把該 entity 的 raw 寫進 paragraphs
// 對應 leo 期望:寫 [[X]] 立刻建檔(Logseq 行為)
async function ensureEntitiesExist(apiKey: string, entities: string[]): Promise<void> {
if (entities.length === 0) return;
const existing = await fetchAllEntityNames(apiKey);
const toCreate = entities.filter(e => !existing.has(e));
if (toCreate.length === 0) return;
for (const entity of toCreate) {
const type = guessEntityType(entity);
const tags = ['mira-wiki', 'leo-explicit', `entity-type:${type}`];
try {
await fetch(KBDB_CREATE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
content: entity,
type: 'wiki-page',
page_name: `wiki-${entity}`,
source: 'leo-explicit',
user_id: 'inkstone_mira_post',
tags_json: JSON.stringify(tags),
}),
});
console.log(`[wikilink] created entity placeholder: ${entity} (type=${type})`);
} catch (e) {
console.warn(`[wikilink] create failed: ${entity}`, e);
}
}
}
// 撈相關 wiki context 給 mira reply 用(簡單 RAG
// 策略:拿全部 index-entry + wiki-page,雙向 substring match against post content
// → 取 top 5 餵進 prompt
@@ -396,6 +487,11 @@ function PostComposer({
});
onAiTriggered(pageName);
}
// Backlog #12[[X]] 顯式建檔 — 解析貼文 wikilinks,不存在的 entity 立刻建 wiki-page placeholder
const wikilinks = parseWikilinks(trimmed);
if (wikilinks.length > 0) {
void ensureEntitiesExist(me.api_key, wikilinks);
}
// 7B.3hfire-and-forget 觸發 wiki_synthesisbrowser → cypher.arcrun.devarcrun-native
// 跟 @mira 無關,每篇都跑 — 河道書寫永遠進 wiki KB 副本(不顯示 thinking indicator,背景靜默跑)
void triggerWikiSynthesis({ apiKey: me.api_key, rawBlockId: postBlockId });
@@ -427,11 +523,12 @@ function PostComposer({
}
handleTabIndent(e, text, setText);
}}
placeholder="現在想分享什麼?(@mira 呼叫 Mira 回覆)"
placeholder="現在想分享什麼?(@mira 呼叫 Mira 回覆 · [[X]] 引用 entity"
rows={2}
disabled={submitting}
className="mira-composer-textarea"
/>
<WikilinkAutocomplete textareaRef={taRef} value={text} setValue={setText} apiKey={me.api_key} />
</div>
<div className="mira-composer-actions">
<MarkdownToolbar textareaRef={taRef} value={text} setValue={setText} compact />
@@ -500,10 +597,11 @@ function PostComposer({
}
handleTabIndent(e, text, setText);
}}
placeholder="現在想分享什麼?(Esc 收起、⌘+Enter 發布)"
placeholder="現在想分享什麼?(Esc 收起、⌘+Enter 發布、[[X]] 引用 entity"
disabled={submitting}
className="mira-composer-popup-textarea"
/>
<WikilinkAutocomplete textareaRef={popupTaRef} value={text} setValue={setText} apiKey={me.api_key} />
<div className="mira-composer-popup-actions">
<MarkdownToolbar textareaRef={popupTaRef} value={text} setValue={setText} />
{err && <span className="mira-msg-error">{err}</span>}
@@ -550,6 +648,170 @@ function Avatar({ me, size = 40 }: { me: Me; size?: number }) {
);
}
// ── WikilinkAutocomplete ─────────────────────────────────────────────────────
// 偵測 textarea 內 cursor 位置左側是否有未閉合的 [[query
// 若是 → 顯示下拉清單(既存 entity + 「⊕ 建立 [[query]]」option
// 對應 tasks.md backlog #12 / design.md §3.6.2
function WikilinkAutocomplete({
textareaRef, value, setValue, apiKey,
}: {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
value: string;
setValue: (v: string) => void;
apiKey: string;
}) {
const [entities, setEntities] = useState<string[]>([]);
const [matchInfo, setMatchInfo] = useState<{ start: number; query: string } | null>(null);
const [selectedIdx, setSelectedIdx] = useState(0);
// 載入 entity 清單(cached 30s
useEffect(() => {
getEntityNamesCached(apiKey).then(set => setEntities(Array.from(set).sort()));
}, [apiKey]);
// 監聽 textarea 變化 / cursor 移動 → 重算 matchInfo
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
const update = () => {
const cursorPos = ta.selectionStart;
// 從 cursor 往前找 `[[`,且該 `[[` 之後到 cursor 之間沒有 `]]` 或換行
const before = value.slice(0, cursorPos);
const lastOpen = before.lastIndexOf('[[');
if (lastOpen === -1) { setMatchInfo(null); return; }
const inside = before.slice(lastOpen + 2);
if (inside.includes(']]') || inside.includes('\n')) { setMatchInfo(null); return; }
setMatchInfo({ start: lastOpen, query: inside });
setSelectedIdx(0);
};
update();
ta.addEventListener('keyup', update);
ta.addEventListener('click', update);
ta.addEventListener('input', update);
return () => {
ta.removeEventListener('keyup', update);
ta.removeEventListener('click', update);
ta.removeEventListener('input', update);
};
}, [textareaRef, value]);
// filter 既存 entity by querysubstring + 寬鬆 — 包含中文字片段匹配)
const filtered = useMemo(() => {
if (!matchInfo) return [];
const q = matchInfo.query.toLowerCase();
if (!q) return entities.slice(0, 8);
return entities
.filter(e => e.toLowerCase().includes(q))
.slice(0, 8);
}, [matchInfo, entities]);
// create-new option(總顯示,當有 query 且 query 不完全等於既存 entity 時)
const showCreate = !!matchInfo && matchInfo.query.trim() !== '' &&
!entities.some(e => e.toLowerCase() === matchInfo.query.toLowerCase());
const items = useMemo(() => {
if (!matchInfo) return [] as Array<{ kind: 'create' | 'existing'; label: string; entity: string }>;
const list: Array<{ kind: 'create' | 'existing'; label: string; entity: string }> = [];
if (showCreate) list.push({ kind: 'create', label: `⊕ 建立 [[${matchInfo.query}]]`, entity: matchInfo.query });
for (const e of filtered) list.push({ kind: 'existing', label: e, entity: e });
return list;
}, [matchInfo, filtered, showCreate]);
// keyboard handler(綁在 textarea 的 onKeyDown,但需要 capture 階段;改用全域 listener while open
useEffect(() => {
if (!matchInfo || items.length === 0) return;
const ta = textareaRef.current;
if (!ta) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIdx(i => (i + 1) % items.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIdx(i => (i - 1 + items.length) % items.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
commitSelection(items[selectedIdx]);
} else if (e.key === 'Escape') {
e.preventDefault();
setMatchInfo(null);
}
};
ta.addEventListener('keydown', handler);
return () => ta.removeEventListener('keydown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchInfo, items, selectedIdx, textareaRef]);
function commitSelection(item: { entity: string }) {
if (!matchInfo) return;
const before = value.slice(0, matchInfo.start);
const after = value.slice(matchInfo.start + 2 + matchInfo.query.length);
const inserted = `[[${item.entity}]]`;
const newValue = before + inserted + after;
setValue(newValue);
setMatchInfo(null);
// refresh cache 以納入新建 entity(但其實要等 ensureEntitiesExist 跑完才會在 KBDB;先 invalidate cache 就夠)
cachedEntityNames = null;
// 下個 tick 把 cursor 移到 ]] 之後
setTimeout(() => {
const ta = textareaRef.current;
if (ta) {
const pos = before.length + inserted.length;
ta.selectionStart = ta.selectionEnd = pos;
ta.focus();
}
}, 0);
}
if (!matchInfo || items.length === 0) return null;
// 位置:textarea 下方
const ta = textareaRef.current;
const rect = ta?.getBoundingClientRect();
if (!rect) return null;
return (
<div
className="mira-wikilink-popover"
style={{
position: 'fixed',
top: rect.bottom + 4,
left: rect.left,
minWidth: Math.max(220, rect.width / 2),
maxWidth: 380,
background: 'var(--mira-bg-2)',
border: '1px solid var(--mira-line)',
borderRadius: 6,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
zIndex: 1100,
maxHeight: 280,
overflowY: 'auto',
}}
>
{items.map((it, i) => (
<div
key={`${it.kind}-${it.entity}`}
onClick={() => commitSelection(it)}
onMouseEnter={() => setSelectedIdx(i)}
style={{
padding: '8px 12px',
cursor: 'pointer',
background: i === selectedIdx ? 'var(--mira-bg-3)' : 'transparent',
color: it.kind === 'create' ? 'var(--mira-accent)' : 'var(--mira-text-1)',
fontSize: 14,
borderBottom: i < items.length - 1 ? '1px solid var(--mira-line-soft)' : 'none',
}}
>
{it.label}
</div>
))}
<div style={{ padding: '6px 12px', fontSize: 11, color: 'var(--mira-text-3)', borderTop: '1px solid var(--mira-line-soft)' }}>
· Enter / Tab · Esc
</div>
</div>
);
}
// Mira 自有頭像(區別於 leo),用機器人 emoji + 紫色圓
// 對應 design.md §3.6.5 + tasks.md backlog #5a (P0)
function MiraAvatar({ size = 40 }: { size?: number }) {
@@ -1094,7 +1356,8 @@ function PostEditor({
saving={saving}
err={err}
submitLabel="儲存"
placeholder="編輯內容⋯(@mira 呼叫 Mira 回覆)"
placeholder="編輯內容⋯(@mira 呼叫 Mira 回覆 · [[X]] 引用 entity"
apiKey={apiKey}
/>
);
}
@@ -1215,6 +1478,9 @@ function BlockLine({
});
triggerThinking?.(docPageName);
}
// Backlog #12: reply 含 [[X]] 也建檔
const wikilinks = parseWikilinks(trimmed);
if (wikilinks.length > 0) void ensureEntitiesExist(apiKey, wikilinks);
setMode('view'); setDraft('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); }
@@ -1250,10 +1516,10 @@ function BlockLine({
<em style={{ color: 'var(--mira-text-3)' }}></em>
)}
{mode === 'edit' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={saveEdit} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" />
<EditingArea draft={draft} setDraft={setDraft} onSubmit={saveEdit} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" apiKey={apiKey} />
)}
{mode === 'reply' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆⋯⋯" />
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆⋯⋯@mira 呼叫 Mira · [[X]] 引用)" apiKey={apiKey} />
)}
</div>
{mode === 'view' && <MoreMenu items={menu} />}
@@ -1345,6 +1611,9 @@ function ReplyLine({
});
triggerThinking?.(docPageName);
}
// Backlog #12: reply 含 [[X]] 也建檔
const wikilinks = parseWikilinks(trimmed);
if (wikilinks.length > 0) void ensureEntitiesExist(apiKey, wikilinks);
setMode('view'); setDraft('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); }
@@ -1377,10 +1646,10 @@ function ReplyLine({
</div>
)}
{mode === 'edit' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={save} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" />
<EditingArea draft={draft} setDraft={setDraft} onSubmit={save} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" apiKey={apiKey} />
)}
{mode === 'reply' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆這則留言⋯⋯" />
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆這則留言⋯⋯@mira 呼叫 Mira · [[X]] 引用)" apiKey={apiKey} />
)}
{/* 巢狀留言 */}
{depth < 3 && (
@@ -1484,13 +1753,13 @@ function PageReplyComposer({
return (
<div style={{ flex: 1, minWidth: 0 }}>
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submit} onCancel={onCancel} saving={saving} err={err} submitLabel="留言" placeholder="寫留言⋯⋯" />
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submit} onCancel={onCancel} saving={saving} err={err} submitLabel="留言" placeholder="寫留言⋯⋯@mira 呼叫 Mira · [[X]] 引用)" apiKey={apiKey} />
</div>
);
}
function EditingArea({
draft, setDraft, onSubmit, onCancel, saving, err, submitLabel, placeholder,
draft, setDraft, onSubmit, onCancel, saving, err, submitLabel, placeholder, apiKey,
}: {
draft: string;
setDraft: (s: string) => void;
@@ -1500,6 +1769,7 @@ function EditingArea({
err: string | null;
submitLabel: string;
placeholder?: string;
apiKey?: string; // 給 WikilinkAutocomplete 抓 entity 清單用;不傳則 autocomplete 不生效
}) {
const taRef = useRef<HTMLTextAreaElement>(null);
const popupTaRef = useRef<HTMLTextAreaElement>(null);
@@ -1522,6 +1792,7 @@ function EditingArea({
placeholder={placeholder}
className="mira-edit-textarea"
/>
{apiKey && <WikilinkAutocomplete textareaRef={taRef} value={draft} setValue={setDraft} apiKey={apiKey} />}
<div className="mira-edit-actions">
<button
type="button"
@@ -1560,10 +1831,11 @@ function EditingArea({
if (e.key === 'Escape') { e.preventDefault(); setExpanded(false); return; }
handleTabIndent(e, draft, setDraft);
}}
placeholder={placeholder ?? '寫點什麼…(Esc 收起、⌘+Enter 送出)'}
placeholder={placeholder ?? '寫點什麼…(Esc 收起、⌘+Enter 送出 · [[X]] 引用 entity'}
disabled={saving}
className="mira-composer-popup-textarea"
/>
{apiKey && <WikilinkAutocomplete textareaRef={popupTaRef} value={draft} setValue={setDraft} apiKey={apiKey} />}
<div className="mira-composer-popup-actions">
<MarkdownToolbar textareaRef={popupTaRef} value={draft} setValue={setDraft} />
{err && <span className="mira-msg-error">{err}</span>}