From d6bff9d551953982a0a4f00e691e428dcb95e5f1 Mon Sep 17 00:00:00 2001 From: richblack Date: Sat, 16 May 2026 11:43:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(mira):=20reply=20@mira=20indicator=20+=20RA?= =?UTF-8?q?G=20match=20=E5=AF=AC=E9=AC=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 試用反饋: bug:reply 中 @mira 「看起來無效」 - 真正 root cause: trigger 確實 fire,但 thinking indicator 沒亮起 → leo 沒看到回應 以為失敗(其實 mira 後台跑 30s+ 才回) - 修:MiraTriggerContext 把 thinking trigger 抽成 context callback PostComposer / PostEditor / BlockEditor / ReplyLine 五個 @mira 觸發點 都呼叫 triggerThinking?.(docPageName) 啟動 5s polling bug:RAG 抓不到 entity(leo 寫《李飛飛的視界之旅》沒被識別) - 之前只看 type=index-entry,且嚴格 substring。新建 wiki 還沒 index-entry 時就漏;entity 名稱跟貼文文字稍微不同也漏 - 修 fetchRelevantWikiContext: - 平行撈 index-entry + wiki-page 兩種,合併 entity set - 雙向匹配:entity 完整 substring postContent(嚴格) OR entity 內 3+ 字片段在 postContent 出現(寬鬆,中文友善) - top 5(從 3 增加),給 mira 更多上下文 --- landing/app/mira/feed/page.tsx | 121 +++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 35 deletions(-) diff --git a/landing/app/mira/feed/page.tsx b/landing/app/mira/feed/page.tsx index 05eefc1..767cad0 100644 --- a/landing/app/mira/feed/page.tsx +++ b/landing/app/mira/feed/page.tsx @@ -3,10 +3,17 @@ // Mira 河道 — 社群貼文式設計(FB / X 風格) // SDD: polaris/mira/.agents/specs/mira-app/design.md §5.3 -import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; +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'; @@ -101,26 +108,30 @@ export default function MiraPage() { 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 && ( { - 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); - }} + onAiTriggered={triggerThinking} /> )} @@ -153,6 +164,7 @@ export default function MiraPage() { )}
+
); } @@ -165,30 +177,61 @@ function hasMiraMention(text: string): boolean { } // 撈相關 wiki context 給 mira reply 用(簡單 RAG) -// 策略:拿全部 index-entry blocks → 用 entity 名稱 keyword match against post content -// → 取 top 3 把整篇 markdown 餵進 prompt(每篇通常 < 1KB,3 篇還在 token 預算內) +// 策略:拿全部 index-entry + wiki-page,雙向 substring match against post content +// → 取 top 5 餵進 prompt async function fetchRelevantWikiContext(apiKey: string, postContent: string): Promise { try { - const res = await fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); - if (!res.ok) return ''; - const data = await res.json() as { blocks?: Array<{ id: string; page_name: string | null; content: string }> }; - const all = data.blocks ?? []; - if (all.length === 0) return ''; + 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(); - // 評分:page_name 剝 `index-` prefix = entity 名稱,看 postContent 是否含 - const scored = all - .map(b => { - const entity = (b.page_name ?? '').replace(/^index-/, '').trim(); - const score = entity && lower.includes(entity.toLowerCase()) ? entity.length : 0; - return { entity, content: b.content, score }; - }) - .filter(x => x.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, 3); - if (scored.length === 0) return ''; - return scored.map(s => `### entity: ${s.entity}\n${s.content}`).join('\n\n'); + 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 ''; @@ -978,6 +1021,7 @@ function PostEditor({ const [draft, setDraft] = useState(initial); const [saving, setSaving] = useState(false); const [err, setErr] = useState(null); + const triggerThinking = useMiraTrigger(); const save = async () => { if (saving) return; @@ -1031,6 +1075,7 @@ function PostEditor({ void triggerAiReply({ apiKey, postContent: trimmed, parentBlockId: firstId, pageName, }); + triggerThinking?.(pageName); } } } catch (e) { @@ -1107,6 +1152,7 @@ function BlockLine({ const [draft, setDraft] = useState(block.content ?? ''); const [saving, setSaving] = useState(false); const [err, setErr] = useState(null); + const triggerThinking = useMiraTrigger(); const startEdit = () => { setDraft(block.content ?? ''); setMode('edit'); setErr(null); }; const startReply = () => { setDraft(''); setMode('reply'); setErr(null); }; @@ -1130,6 +1176,7 @@ function BlockLine({ void triggerAiReply({ apiKey, postContent: draft, parentBlockId: block.id, pageName: docPageName, }); + triggerThinking?.(docPageName); } setMode('view'); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } @@ -1166,6 +1213,7 @@ function BlockLine({ void triggerAiReply({ apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName, }); + triggerThinking?.(docPageName); } setMode('view'); setDraft(''); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } @@ -1235,6 +1283,7 @@ function ReplyLine({ const [saving, setSaving] = useState(false); const [err, setErr] = useState(null); const isAI = reply.source?.startsWith('ai-') ?? false; + const triggerThinking = useMiraTrigger(); const startEdit = () => { setDraft(reply.content ?? ''); setMode('edit'); setErr(null); }; const startReply = () => { setDraft(''); setMode('reply'); setErr(null); }; @@ -1257,6 +1306,7 @@ function ReplyLine({ void triggerAiReply({ apiKey, postContent: draft, parentBlockId: reply.id, pageName: docPageName, }); + triggerThinking?.(docPageName); } setMode('view'); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } @@ -1293,6 +1343,7 @@ function ReplyLine({ void triggerAiReply({ apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName, }); + triggerThinking?.(docPageName); } setMode('view'); setDraft(''); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); }