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)); }