From 7da1eb6d656f8138368e7c4ace945e90a6c999ee Mon Sep 17 00:00:00 2001 From: richblack Date: Sat, 16 May 2026 12:08:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(mira):=20[[entity]]=20wikilink=20=E2=80=94?= =?UTF-8?q?=20=E9=A1=AF=E5=BC=8F=E5=BB=BA=E6=AA=94=20+=20autocomplete=20+?= =?UTF-8?q?=20render=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 對應 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: - :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 也建檔 --- landing/app/mira/_shared/markdown.tsx | 38 ++-- landing/app/mira/feed/page.tsx | 292 +++++++++++++++++++++++++- 2 files changed, 308 insertions(+), 22 deletions(-) diff --git a/landing/app/mira/_shared/markdown.tsx b/landing/app/mira/_shared/markdown.tsx index caead1a..b4fbd05 100644 --- a/landing/app/mira/_shared/markdown.tsx +++ b/landing/app/mira/_shared/markdown.tsx @@ -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 metadata;2. [[entity]] 轉成 markdown link + const cleaned = useMemo(() => expandWikilinks(stripLogseqMeta(text)), [text]); return (
( - - {children} - - ), + a: ({ href, children, ...rest }) => { + const isWikiLink = typeof href === 'string' && href.startsWith('/mira/wiki/'); + return ( + + {children} + + ); + }, // 圖片不直接 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})`; + }); +} diff --git a/landing/app/mira/feed/page.tsx b/landing/app/mira/feed/page.tsx index 767cad0..a9f19b5 100644 --- a/landing/app/mira/feed/page.tsx +++ b/landing/app/mira/feed/page.tsx @@ -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> { + 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(); + 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 list(autocomplete 用) +let cachedEntityNames: Set | null = null; +let cachedEntityFetchedAt = 0; +async function getEntityNamesCached(apiKey: string): Promise> { + 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 { + 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.3h:fire-and-forget 觸發 wiki_synthesis(browser → cypher.arcrun.dev,arcrun-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" /> +
@@ -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" /> +
{err && {err}} @@ -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; + value: string; + setValue: (v: string) => void; + apiKey: string; +}) { + const [entities, setEntities] = useState([]); + 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 query(substring + 寬鬆 — 包含中文字片段匹配) + 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 ( +
+ {items.map((it, i) => ( +
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} +
+ ))} +
+ ↑↓ 選擇 · Enter / Tab 確認 · Esc 取消 +
+
+ ); +} + // 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({ (空) )} {mode === 'edit' && ( - + )} {mode === 'reply' && ( - + )}
{mode === 'view' && } @@ -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({
)} {mode === 'edit' && ( - + )} {mode === 'reply' && ( - + )} {/* 巢狀留言 */} {depth < 3 && ( @@ -1484,13 +1753,13 @@ function PageReplyComposer({ return (
- +
); } 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(null); const popupTaRef = useRef(null); @@ -1522,6 +1792,7 @@ function EditingArea({ placeholder={placeholder} className="mira-edit-textarea" /> + {apiKey && }