fix(mira): reply @mira indicator + RAG match 寬鬆化
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 更多上下文
This commit is contained in:
@@ -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 (
|
||||
<MiraTriggerContext.Provider value={triggerThinking}>
|
||||
<div className="mira-app">
|
||||
<div className="mira-content">
|
||||
{me && (
|
||||
<PostComposer
|
||||
me={me}
|
||||
onPosted={reload}
|
||||
onAiTriggered={(pageName) => {
|
||||
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() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MiraTriggerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, string>(); // 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<string | null>(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<string | null>(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<string | null>(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)); }
|
||||
|
||||
Reference in New Issue
Block a user