'use client'; // Mira 河道 — 社群貼文式設計(FB / X 風格) // SDD: polaris/mira/.agents/specs/mira-app/design.md §5.3 import { useEffect, useState, useCallback, useRef, useMemo, createContext, useContext } from 'react'; import { MarkdownView } from '../_shared/markdown'; import '../mira.css'; // Context:把「mira 已觸發要顯示 thinking + 啟動 polling」設定 callback 傳到深層 reply / edit 元件 // 不用 prop drill。對應 P0 #5b bug fix(leo 反饋:reply 中 @mira 沒看到 indicator → 以為失敗) const MiraTriggerContext = createContext<((pageName: string) => void) | null>(null); function useMiraTrigger() { return useContext(MiraTriggerContext); } const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev'; const KBDB_BASE = 'https://kbdb.finally.click'; const KBDB_INGEST = 'https://kbdb-ingest.arcrun.dev'; const KBDB_PATCH = 'https://kbdb-patch-block.arcrun.dev'; const KBDB_CREATE = 'https://kbdb-create-block.arcrun.dev'; const CLAUDE_API = 'https://claude-api.arcrun.dev'; const PREVIEW_LINES = 3; // 卡片預覽顯示行數 const PREVIEW_CHARS = 200; // 卡片預覽顯示字數上限 type Document = { page_name: string; user_id: string; block_count: number; created_at: number | string; updated_at: number | string; }; type KBDBBlock = { id: string; content: string | null; type: string; source: string | null; user_id: string | null; parent_id: string | null; page_name: string | null; tags_json: string; refs_json: string; sort_order?: number; created_at: number | string; updated_at: number | string; }; type Me = { email: string; api_key: string; display_name: string; avatar_url?: string }; const MIRA_POST_USER_ID = 'inkstone_mira_post'; export default function MiraPage() { const [me, setMe] = useState(null); const [docs, setDocs] = useState(null); const [error, setError] = useState(null); // page_name → "thinking" / null:哪些 doc 在等 AI 回覆 const [aiThinking, setAiThinking] = useState>(new Set()); const meRef = useRef(null); meRef.current = me; const reload = useCallback(async () => { const m = meRef.current; if (!m) return; try { const docsRes = await fetch(`${KBDB_BASE}/blocks/documents?limit=50&_t=${Date.now()}`, { headers: { Authorization: `Bearer ${m.api_key}` }, cache: 'no-store', }); if (docsRes.ok) { const data = (await docsRes.json()) as { documents?: Document[] }; setDocs(data.documents ?? []); } } catch (e) { console.error('[mira reload]', e); } }, []); useEffect(() => { let cancelled = false; (async () => { try { const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' }); if (!meRes.ok) { window.location.href = '/login?redirect=/mira/feed'; return; } const meData = (await meRes.json()) as Me; if (cancelled) return; setMe(meData); const docsRes = await fetch(`${KBDB_BASE}/blocks/documents?limit=50`, { headers: { Authorization: `Bearer ${meData.api_key}` }, }); if (!docsRes.ok) { setError(`KBDB 讀取失敗:${docsRes.status}`); return; } const data = (await docsRes.json()) as { documents?: Document[] }; if (cancelled) return; setDocs(data.documents ?? []); } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); } })(); return () => { cancelled = true; }; }, []); // 把 thinking trigger 抽成共用 callback(PostComposer 跟所有 reply / edit 子元件用同一份) const triggerThinking = useCallback((pageName: string) => { setAiThinking(prev => new Set(prev).add(pageName)); // 90 秒後 hard timeout(Mira 思考 + KBDB write 通常 10-45 秒,給寬限) // 卡片內 useEffect 偵測 AI reply 真的進來會主動呼叫 onAiResponded 清掉 setTimeout(() => { setAiThinking(prev => { if (!prev.has(pageName)) return prev; const n = new Set(prev); n.delete(pageName); return n; }); }, 90000); }, []); return (
{me && ( )} {error && (
{error}
)} {!error && docs === null &&
載入中…
} {!error && docs !== null && docs.length === 0 && me && ( )} {!error && docs && docs.length > 0 && me && ( { setAiThinking(prev => { if (!prev.has(pageName)) return prev; const n = new Set(prev); n.delete(pageName); return n; }); }} /> )}
); } // ─── AI 回覆觸發器(fire-and-forget)────────────────────── // P0 #5b:偵測文字含 @mira(mention) — 大小寫不限,前後可有標點 // 用法:leo 在貼文 / 留言寫 `@mira <主題>` → 觸發 mira reply 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:debounce 短 TTL(5s),同時 autocomplete 開啟時主動 refetch // 30s 太久:wiki_synthesis 後台跑出新 entity(如 Claude 自己命名的「李飛飛的視界之旅」)autocomplete 撈不到 let cachedEntityNames: Set | null = null; let cachedEntityFetchedAt = 0; async function getEntityNamesCached(apiKey: string): Promise> { const now = Date.now(); if (cachedEntityNames && now - cachedEntityFetchedAt < 5_000) return cachedEntityNames; cachedEntityNames = await fetchAllEntityNames(apiKey); cachedEntityFetchedAt = now; return cachedEntityNames; } function invalidateEntityCache() { cachedEntityNames = null; cachedEntityFetchedAt = 0; } // 簡單 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 async function fetchRelevantWikiContext(apiKey: string, postContent: string): Promise { try { const headers = { Authorization: `Bearer ${apiKey}` }; // 平行撈 index-entry(含 markdown 摘要)跟 wiki-page(content 是 entity 名稱) 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 = { id: string; 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: [] }; // 收集 unique entity 名稱(從 index-entry page_name 跟 wiki-page content)+ 對應 markdown const entityMap = new Map(); // entity name -> 顯示用 markdown context for (const b of (idxData.blocks ?? [])) { const entity = (b.page_name ?? '').replace(/^index-/, '').trim(); if (entity && !entityMap.has(entity)) entityMap.set(entity, b.content); } for (const b of (pageData.blocks ?? [])) { const entity = (b.content ?? '').trim(); if (entity && !entityMap.has(entity)) { // wiki-page content 只有 entity 名稱,沒摘要 — 給最簡 placeholder entityMap.set(entity, `(已建 wiki-page,但尚無 index-entry 摘要)`); } } if (entityMap.size === 0) return ''; // 雙向 substring match: // - entity 名稱 substring of postContent,或 // - postContent 中任 4+ 字 sliding window substring of entity(中文場景容忍) const lower = postContent.toLowerCase(); const scored: Array<{ entity: string; content: string; score: number }> = []; for (const [entity, content] of entityMap) { const e = entity.toLowerCase(); let score = 0; // 直接 substring(嚴格) if (lower.includes(e)) score = e.length * 2; // 較寬鬆:entity 內 ≥ 3 字片段在 postContent 出現 else if (e.length >= 3) { for (let i = 0; i + 3 <= e.length; i++) { const frag = e.slice(i, i + Math.min(4, e.length - i)); if (frag.length >= 3 && lower.includes(frag)) { score = Math.max(score, frag.length); break; } } } if (score > 0) scored.push({ entity, content, score }); } scored.sort((a, b) => b.score - a.score); const top = scored.slice(0, 5); if (top.length === 0) return ''; return top.map(s => `### entity: ${s.entity}\n${s.content}`).join('\n\n'); } catch (e) { console.warn('[fetchRelevantWikiContext] failed', e); return ''; } } async function triggerWikiSynthesis(opts: { apiKey: string; rawBlockId: string }) { // 觸發 arcrun wiki_synthesis workflow(arcrun-native public trigger endpoint) // 不等結果(workflow 60-90s 含 2 次 claude_api pause/resume) try { const res = await fetch(`${API_BASE}/webhooks/named/wiki_synthesis/trigger`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Arcrun-API-Key': opts.apiKey, }, body: JSON.stringify({ api_key: opts.apiKey, raw_block_id: opts.rawBlockId }), }); if (!res.ok) { console.warn('[wiki_synthesis trigger] non-ok:', res.status); return; } const data = await res.json().catch(() => ({})); console.log('[wiki_synthesis trigger] response:', data); } catch (e) { console.warn('[wiki_synthesis trigger] error:', e); } } async function triggerAiReply(opts: { apiKey: string; postContent: string; parentBlockId: string; pageName: string; }) { // 抽 @mira 後面的 topic(同行第一段,到換行 / 句號 / 標點停) const mentionMatch = opts.postContent.match(/@mira\s*([^\n。.!!??,,]*)/i); const topic = mentionMatch?.[1]?.trim() || ''; const topicHint = topic ? `\nleo 用 \`@mira ${topic}\` 呼叫了你,所以這則對話的主題鎖定在「${topic}」。\n` : '\nleo 用 \`@mira\` 呼叫了你,請針對訊息回覆。\n'; // RAG:撈既有 wiki index-entries,挑跟貼文 keyword match 的塞進 system context // 對應 leo bug #3「Mira 應該以擁有知識庫的專人來回覆,不是無狀態」 const knowledgeContext = await fetchRelevantWikiContext(opts.apiKey, opts.postContent); const prompt = (knowledgeContext ? `## 你目前的 wiki 知識庫(跟本次訊息相關的 entity 摘要)\n\n${knowledgeContext}\n\n---\n\n` : '') + `用戶 leo 在 mira 河道發了這則訊息:\n\n` + `「${opts.postContent}」\n\n` + topicHint + `\n請以 Mira 副駕 AI 的身份留言回應。\n` + `規則:\n` + `- 繁體中文(台灣用語)\n` + `- 簡短 1-3 段,務實,不客套\n` + `- 可以發問、補充、提建議、或反問釐清\n` + `- 不替老闆下決策,但可以給判斷依據\n` + (knowledgeContext ? `- **如果 leo 提到的 entity 你已有 wiki 知識,請主動引用 / 連結(用「你之前寫過...」或「在《X》wiki 中你提到...」)**\n` : ''); try { const aiRes = await fetch(CLAUDE_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, timeout_ms: 25000 }), }); const aiData = (await aiRes.json()) as { success?: boolean; pending?: boolean; data?: { text?: string }; error?: string; }; if (!aiRes.ok || !aiData.success) { console.error('[mira ai reply] claude_api failed:', aiData.error); return; } if (aiData.pending) { // daemon 切到 async,目前簡化版不處理 polling console.warn('[mira ai reply] daemon went async, skipping'); return; } const text = aiData.data?.text; if (!text) { console.warn('[mira ai reply] no text in response'); return; } // 寫成 chat block (parent_id = 原貼文 block, source=ai-mira) await fetch(KBDB_CREATE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: opts.apiKey, content: text, type: 'chat', parent_id: opts.parentBlockId, user_id: MIRA_POST_USER_ID, source: 'ai-mira', page_name: opts.pageName, }), }); } catch (e) { console.error('[mira ai reply] exception:', e); } } // ─── 寫貼文 composer ────────────────────────────────────── function PostComposer({ me, onPosted, onAiTriggered, }: { me: Me; onPosted: () => Promise | void; onAiTriggered: (pageName: string) => void; }) { const [text, setText] = useState(''); const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(null); const [expanded, setExpanded] = useState(false); const taRef = useRef(null); const popupTaRef = useRef(null); const submit = useCallback(async () => { const trimmed = text.trim(); if (!trimmed || submitting) return; setSubmitting(true); setErr(null); try { const stamp = new Date().toISOString(); const pageName = `post-${stamp}-${Math.random().toString(36).slice(2, 8)}`; const res = await fetch(KBDB_CREATE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ api_key: me.api_key, content: trimmed, type: 'note', user_id: MIRA_POST_USER_ID, source: 'km-writer-direct', page_name: pageName, }), }); const data = (await res.json()) as { success?: boolean; error?: string; data?: { id?: string } }; if (!res.ok || !data.success || !data.data?.id) { setErr(data.error || `寫入失敗:${res.status}`); return; } const postBlockId = data.data.id; setText(''); // P0 #5b:只有 @mira 時才觸發 Mira AI 回覆(撤回每篇 auto-reply) // 對應 design.md §3.6.5「河道是 process 場 + Mira 是 KB 同步介面」 // 「Mira 思考中」indicator 也只在 @mira 時 show(避免每篇都顯示讓 leo 誤會) if (hasMiraMention(trimmed)) { void triggerAiReply({ apiKey: me.api_key, postContent: trimmed, parentBlockId: postBlockId, pageName, }); 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 }); // 給 D1 GROUP BY 查詢看到新資料的時間 await new Promise(r => setTimeout(r, 1500)); await onPosted(); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { setSubmitting(false); } }, [text, submitting, me, onPosted, onAiTriggered]); return ( <>