fix(mira): 4 bug — popup 在 edit / 思考 indicator 條件 / RAG / edit @mira

leo 試用 P0 後反饋的 4 個問題:

bug #1: popup 編輯時沒有,更擠
- EditingArea 元件加 ⇱ 放大 button + popup overlay(共用 PostComposer 的 popup pattern)
- 影響:所有 edit / reply 模式現在都有放大編輯

bug #2: Mira 思考中很久(即使沒 @mira)
- root cause: onAiTriggered(pageName) 在 PostComposer 永遠 fire,會在每篇貼文觸發
  thinking indicator UI
- 修:移到 if (hasMiraMention) 分支內。沒 @mira → 不顯示 thinking
- wiki_synthesis 仍每篇都跑(背景靜默)但不顯示 thinking,避免 leo 誤會

bug #3: Mira reply 不知道有 wiki,無狀態
- 加 fetchRelevantWikiContext: 撈所有 type=index-entry blocks,用 entity 名稱
  keyword match against post content,取 top 3 整篇 markdown 餵進 prompt
- prompt 加規則「如果 leo 提到的 entity 你已有 wiki 知識,請主動引用」
- 對應 leo「應該以擁有知識庫的專人來回覆」

bug #4: edit 時 @mira 沒反應
- PostEditor.save / BlockEditor.saveEdit / ReplyLine.save 三處都加 hasMiraMention 偵測
- 條件:新內容含 @mira 且舊版沒有 → 觸發(避免重複編輯重複 trigger)

placeholder 加 hint「@mira 呼叫 Mira 回覆」。
This commit is contained in:
2026-05-16 11:07:34 +08:00
parent 8d4c3a3464
commit 94368ec981
+116 -4
View File
@@ -164,6 +164,37 @@ function hasMiraMention(text: string): boolean {
return /(^|[^\w])@mira\b/i.test(text);
}
// 撈相關 wiki context 給 mira reply 用(簡單 RAG
// 策略:拿全部 index-entry blocks → 用 entity 名稱 keyword match against post content
// → 取 top 3 把整篇 markdown 餵進 prompt(每篇通常 < 1KB3 篇還在 token 預算內)
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 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');
} catch (e) {
console.warn('[fetchRelevantWikiContext] failed', e);
return '';
}
}
async function triggerWikiSynthesis(opts: { apiKey: string; rawBlockId: string }) {
// 觸發 arcrun wiki_synthesis workflowarcrun-native public trigger endpoint
// 不等結果(workflow 60-90s 含 2 次 claude_api pause/resume
@@ -200,7 +231,12 @@ async function triggerAiReply(opts: {
? `\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 +
@@ -209,7 +245,8 @@ async function triggerAiReply(opts: {
`- 繁體中文(台灣用語)\n` +
`- 簡短 1-3 段,務實,不客套\n` +
`- 可以發問、補充、提建議、或反問釐清\n` +
`- 不替老闆下決策,但可以給判斷依據`;
`- 不替老闆下決策,但可以給判斷依據\n` +
(knowledgeContext ? `- **如果 leo 提到的 entity 你已有 wiki 知識,請主動引用 / 連結(用「你之前寫過...」或「在《X》wiki 中你提到...」)**\n` : '');
try {
const aiRes = await fetch(CLAUDE_API, {
@@ -306,6 +343,7 @@ function PostComposer({
// 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,
@@ -313,11 +351,11 @@ function PostComposer({
parentBlockId: postBlockId,
pageName,
});
onAiTriggered(pageName);
}
// 7B.3hfire-and-forget 觸發 wiki_synthesisbrowser → cypher.arcrun.devarcrun-native
// 跟 @mira 無關,每篇都跑 — 河道書寫永遠進 wiki KB 副本
// 跟 @mira 無關,每篇都跑 — 河道書寫永遠進 wiki KB 副本(不顯示 thinking indicator,背景靜默跑)
void triggerWikiSynthesis({ apiKey: me.api_key, rawBlockId: postBlockId });
onAiTriggered(pageName);
// 給 D1 GROUP BY 查詢看到新資料的時間
await new Promise(r => setTimeout(r, 1500));
@@ -985,6 +1023,16 @@ function PostEditor({
}
onSaved(trimmed);
// P0 #5b bug #4: edit 後若加了 @mira(舊版沒)→ 觸發 mira reply
if (hasMiraMention(trimmed) && !hasMiraMention(initial)) {
const firstId = mainBlocks[0]?.id;
const pageName = mainBlocks[0]?.page_name ?? '';
if (firstId && pageName) {
void triggerAiReply({
apiKey, postContent: trimmed, parentBlockId: firstId, pageName,
});
}
}
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
@@ -1001,7 +1049,7 @@ function PostEditor({
saving={saving}
err={err}
submitLabel="儲存"
placeholder="編輯內容⋯"
placeholder="編輯內容⋯@mira 呼叫 Mira 回覆)"
/>
);
}
@@ -1077,6 +1125,12 @@ function BlockLine({
const data = (await res.json()) as { success?: boolean; error?: string };
if (!res.ok || !data.success) { setErr(data.error || `儲存失敗:${res.status}`); return; }
onUpdated(block.id, draft);
// P0 #5b bug #4edit 後若加了 @mira(之前沒)→ 觸發 mira reply
if (hasMiraMention(draft) && !hasMiraMention(block.content ?? '')) {
void triggerAiReply({
apiKey, postContent: draft, parentBlockId: block.id, pageName: docPageName,
});
}
setMode('view');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); }
@@ -1198,6 +1252,12 @@ function ReplyLine({
const data = (await res.json()) as { success?: boolean; error?: string };
if (!res.ok || !data.success) { setErr(data.error || `儲存失敗:${res.status}`); return; }
onUpdated(reply.id, draft);
// P0 #5b bug #4: edit reply 加 @mira 也觸發
if (hasMiraMention(draft) && !hasMiraMention(reply.content ?? '')) {
void triggerAiReply({
apiKey, postContent: draft, parentBlockId: reply.id, pageName: docPageName,
});
}
setMode('view');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); }
@@ -1391,6 +1451,8 @@ function EditingArea({
placeholder?: string;
}) {
const taRef = useRef<HTMLTextAreaElement>(null);
const popupTaRef = useRef<HTMLTextAreaElement>(null);
const [expanded, setExpanded] = useState(false);
return (
<>
<MarkdownToolbar textareaRef={taRef} value={draft} setValue={setDraft} />
@@ -1410,6 +1472,13 @@ function EditingArea({
className="mira-edit-textarea"
/>
<div className="mira-edit-actions">
<button
type="button"
onClick={() => { setExpanded(true); setTimeout(() => popupTaRef.current?.focus(), 50); }}
className="mira-btn-ghost mira-composer-expand"
title="放大編輯"
aria-label="放大編輯"
></button>
{err && <span className="mira-msg-error">{err}</span>}
<span style={{ flex: 1 }} />
<span className="mira-kbd">+Enter · Esc</span>
@@ -1418,6 +1487,49 @@ function EditingArea({
{saving ? '處理中⋯' : submitLabel}
</button>
</div>
{/* P0 #5c: editing 也支援 popup 放大 */}
{expanded && (
<div
className="mira-composer-popup-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) setExpanded(false); }}
>
<div className="mira-composer-popup">
<header className="mira-composer-popup-header">
<span className="mira-composer-popup-title">{submitLabel}</span>
<span style={{ flex: 1 }} />
<button type="button" onClick={() => setExpanded(false)} className="mira-btn-ghost" title="收起" aria-label="關閉"></button>
</header>
<textarea
ref={popupTaRef}
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); setExpanded(false); return; }
if (e.key === 'Escape') { e.preventDefault(); setExpanded(false); return; }
handleTabIndent(e, draft, setDraft);
}}
placeholder={placeholder ?? '寫點什麼…(Esc 收起、⌘+Enter 送出)'}
disabled={saving}
className="mira-composer-popup-textarea"
/>
<div className="mira-composer-popup-actions">
<MarkdownToolbar textareaRef={popupTaRef} value={draft} setValue={setDraft} />
{err && <span className="mira-msg-error">{err}</span>}
<span style={{ flex: 1 }} />
<span className="mira-kbd">+Enter</span>
<button
type="button"
disabled={saving}
onClick={() => { onSubmit(); setExpanded(false); }}
className="mira-btn-primary"
>
{saving ? '處理中⋯' : submitLabel}
</button>
</div>
</div>
</div>
)}
</>
);
}