feat(mira): P0 河道完善 3 task — Mira 頭像 / @mira 選擇性回覆 / 編輯器 popup

對應 tasks.md backlog #5a / #5b / #5c(leo 2026-05-16 P0),design.md §3.6.5。

#5a Mira 發文獨立頭像
- 新 MiraAvatar 元件(紫色漸層圓 + 🤖 emoji)
- isMiraSource() 判斷 post 來源是 leo(km-writer-direct/logseq/mobile/web/tg/rss)
  還是 mira(ai-* / mira-* / 其他 test sources)
- PostCard header:showMira ? Mira 頭像 + 名「Mira」 : leo 頭像 + leo 名

#5b @mira 選擇性回覆(撤回每篇 auto-reply)
- 新 hasMiraMention() regex:偵測文字含 @mira(前後可有標點)
- PostComposer / BlockEditor / ReplyLine 三處 submit:只有 @mira 時 triggerAiReply
- triggerAiReply prompt 加 topic 抽取(@mira 後第一段到標點)+ scope hint
- wiki_synthesis trigger 跟 @mira 無關,每篇都跑(KB 副本同步)
- 不擋手動筆記(leo 隨手寫不需要 mira 每篇都回)

#5c 編輯器 popup 放大
- composer 加 ⇱ 放大按鈕 → 切到 fullscreen overlay popup
- popup 含大 textarea + 同 markdown toolbar + 同 submit 邏輯
- ⌘+Enter 發布、Esc 收起、外點 backdrop 收起
- 對應 leo「寫長文 textarea 太小」需求

CSS 加 .mira-avatar-mira / .mira-composer-popup-* 系列。
TS check pass。
This commit is contained in:
2026-05-16 10:35:32 +08:00
parent 0f2a00e0d5
commit 8d4c3a3464
2 changed files with 273 additions and 49 deletions
+189 -49
View File
@@ -158,6 +158,12 @@ export default function MiraPage() {
// ─── AI 回覆觸發器(fire-and-forget)────────────────────── // ─── AI 回覆觸發器(fire-and-forget)──────────────────────
// P0 #5b:偵測文字含 @miramention) — 大小寫不限,前後可有標點
// 用法:leo 在貼文 / 留言寫 `@mira <主題>` → 觸發 mira reply
function hasMiraMention(text: string): boolean {
return /(^|[^\w])@mira\b/i.test(text);
}
async function triggerWikiSynthesis(opts: { apiKey: string; rawBlockId: string }) { async function triggerWikiSynthesis(opts: { apiKey: string; rawBlockId: string }) {
// 觸發 arcrun wiki_synthesis workflowarcrun-native public trigger endpoint // 觸發 arcrun wiki_synthesis workflowarcrun-native public trigger endpoint
// 不等結果(workflow 60-90s 含 2 次 claude_api pause/resume // 不等結果(workflow 60-90s 含 2 次 claude_api pause/resume
@@ -187,10 +193,18 @@ async function triggerAiReply(opts: {
parentBlockId: string; parentBlockId: string;
pageName: 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';
const prompt = const prompt =
`用戶 leo 在 mira 河道發了這則貼文\n\n` + `用戶 leo 在 mira 河道發了這則訊息\n\n` +
`${opts.postContent}\n\n` + `${opts.postContent}\n\n` +
`請以 Mira 副駕 AI 的身份留言回應。\n` + topicHint +
`\n請以 Mira 副駕 AI 的身份留言回應。\n` +
`規則:\n` + `規則:\n` +
`- 繁體中文(台灣用語)\n` + `- 繁體中文(台灣用語)\n` +
`- 簡短 1-3 段,務實,不客套\n` + `- 簡短 1-3 段,務實,不客套\n` +
@@ -257,7 +271,9 @@ function PostComposer({
const [text, setText] = useState(''); const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
const popupTaRef = useRef<HTMLTextAreaElement>(null);
const submit = useCallback(async () => { const submit = useCallback(async () => {
const trimmed = text.trim(); const trimmed = text.trim();
@@ -288,16 +304,18 @@ function PostComposer({
const postBlockId = data.data.id; const postBlockId = data.data.id;
setText(''); setText('');
// fire-and-forget 觸發 Mira AI 回覆(不擋用戶 // P0 #5b:只有 @mira 時才觸發 Mira AI 回覆(撤回每篇 auto-reply
void triggerAiReply({ // 對應 design.md §3.6.5「河道是 process 場 + Mira 是 KB 同步介面」
apiKey: me.api_key, if (hasMiraMention(trimmed)) {
postContent: trimmed, void triggerAiReply({
parentBlockId: postBlockId, apiKey: me.api_key,
pageName, postContent: trimmed,
}); parentBlockId: postBlockId,
pageName,
});
}
// 7B.3hfire-and-forget 觸發 wiki_synthesisbrowser → cypher.arcrun.devarcrun-native // 7B.3hfire-and-forget 觸發 wiki_synthesisbrowser → cypher.arcrun.devarcrun-native
// 不走 watcher 是因為 cypher-executor 自己 fetch 自己 workers.dev URL 被 CF 1042 擋 // 跟 @mira 無關,每篇都跑 — 河道書寫永遠進 wiki KB 副本
// watcher 仍作為 cron-driven backup(漏掉的 raws 5 分鐘後補跑),但需先解 self-fetch 問題
void triggerWikiSynthesis({ apiKey: me.api_key, rawBlockId: postBlockId }); void triggerWikiSynthesis({ apiKey: me.api_key, rawBlockId: postBlockId });
onAiTriggered(pageName); onAiTriggered(pageName);
@@ -312,42 +330,120 @@ function PostComposer({
}, [text, submitting, me, onPosted, onAiTriggered]); }, [text, submitting, me, onPosted, onAiTriggered]);
return ( return (
<div className="mira-card mira-composer-card"> <>
<div className="mira-composer-row"> <div className="mira-card mira-composer-card">
<Avatar me={me} /> <div className="mira-composer-row">
<textarea <Avatar me={me} />
ref={taRef} <textarea
value={text} ref={taRef}
onChange={e => setText(e.target.value)} value={text}
onKeyDown={e => { onChange={e => setText(e.target.value)}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { onKeyDown={e => {
e.preventDefault(); if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
submit(); e.preventDefault();
return; submit();
} return;
handleTabIndent(e, text, setText); }
handleTabIndent(e, text, setText);
}}
placeholder="現在想分享什麼?(@mira 呼叫 Mira 回覆)"
rows={2}
disabled={submitting}
className="mira-composer-textarea"
/>
</div>
<div className="mira-composer-actions">
<MarkdownToolbar textareaRef={taRef} value={text} setValue={setText} compact />
<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</span>
<button
type="button"
disabled={submitting || !text.trim()}
onClick={submit}
className={`mira-btn-primary${!text.trim() ? ' disabled' : ''}`}
>
{submitting ? '送出中⋯' : '貼文'}
</button>
</div>
</div>
{/* Popup 放大編輯(FB 覆蓋版面風格)— P0 #5c */}
{expanded && (
<div
className="mira-composer-popup-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) setExpanded(false);
}} }}
placeholder="現在想分享什麼?"
rows={2}
disabled={submitting}
className="mira-composer-textarea"
/>
</div>
<div className="mira-composer-actions">
<MarkdownToolbar textareaRef={taRef} value={text} setValue={setText} compact />
{err && <span className="mira-msg-error">{err}</span>}
<span style={{ flex: 1 }} />
<span className="mira-kbd">+Enter</span>
<button
type="button"
disabled={submitting || !text.trim()}
onClick={submit}
className={`mira-btn-primary${!text.trim() ? ' disabled' : ''}`}
> >
{submitting ? '送出中⋯' : '貼文'} <div className="mira-composer-popup">
</button> <header className="mira-composer-popup-header">
</div> <Avatar me={me} size={32} />
</div> <span className="mira-composer-popup-title"></span>
<span style={{ flex: 1 }} />
<button
type="button"
onClick={() => setExpanded(false)}
className="mira-btn-ghost"
title="收起"
aria-label="關閉"
>
</button>
</header>
<textarea
ref={popupTaRef}
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
submit().then(() => setExpanded(false));
return;
}
if (e.key === 'Escape') {
setExpanded(false);
return;
}
handleTabIndent(e, text, setText);
}}
placeholder="現在想分享什麼?(Esc 收起、⌘+Enter 發布)"
disabled={submitting}
className="mira-composer-popup-textarea"
/>
<div className="mira-composer-popup-actions">
<MarkdownToolbar textareaRef={popupTaRef} value={text} setValue={setText} />
{err && <span className="mira-msg-error">{err}</span>}
<span style={{ flex: 1 }} />
<span className="mira-kbd">+Enter</span>
<button
type="button"
disabled={submitting || !text.trim()}
onClick={async () => {
await submit();
setExpanded(false);
}}
className={`mira-btn-primary${!text.trim() ? ' disabled' : ''}`}
>
{submitting ? '送出中⋯' : '貼文'}
</button>
</div>
</div>
</div>
)}
</>
); );
} }
@@ -373,6 +469,35 @@ function Avatar({ me, size = 40 }: { me: Me; size?: number }) {
); );
} }
// Mira 自有頭像(區別於 leo),用機器人 emoji + 紫色圓
// 對應 design.md §3.6.5 + tasks.md backlog #5a (P0)
function MiraAvatar({ size = 40 }: { size?: number }) {
return (
<div
className="mira-avatar mira-avatar-mira"
style={{ width: size, height: size, fontSize: size * 0.55 }}
title="Mira"
aria-label="Mira"
>
🤖
</div>
);
}
// 用 source 判斷該 post 是 leo 寫的還是 mira 生成的
// 已知 leo 來源 → leo 頭像;其他(ai-* / mira-* / test sources)→ mira 頭像
function isMiraSource(source: string | null | undefined): boolean {
if (!source) return false;
const s = source.toLowerCase();
// leo 寫入的所有 channels
if (s.startsWith('km-writer-direct')) return false;
if (s.startsWith('logseq')) return false;
if (s === 'mobile' || s === 'web' || s.startsWith('tg') || s === 'telegram') return false;
if (s === 'rss') return false;
// 其他(ai-* / mira* / test sources)視為 mira
return true;
}
// ─── ⋮ 選單 ────────────────────────────────────────────── // ─── ⋮ 選單 ──────────────────────────────────────────────
type MenuItem = { label: string; onClick: () => void; danger?: boolean }; type MenuItem = { label: string; onClick: () => void; danger?: boolean };
@@ -646,15 +771,18 @@ function DocCard({
setBlocks(curr => curr ? curr.map(b => b.id === id ? { ...b, content: newContent } : b) : null); setBlocks(curr => curr ? curr.map(b => b.id === id ? { ...b, content: newContent } : b) : null);
}, []); }, []);
const postSource = inferSource(blocks, doc.page_name);
const showMira = isMiraSource(postSource);
return ( return (
<article className="mira-card mira-post" ref={cardRef}> <article className="mira-card mira-post" ref={cardRef}>
{/* 頭部:作者區塊 */} {/* 頭部:作者區塊(依 source 判斷頭像 + 名字) */}
<header className="mira-post-header"> <header className="mira-post-header">
<Avatar me={me} size={40} /> {showMira ? <MiraAvatar size={40} /> : <Avatar me={me} size={40} />}
<div className="mira-post-author"> <div className="mira-post-author">
<div className="mira-post-name">{me.display_name || me.email}</div> <div className="mira-post-name">{showMira ? 'Mira' : (me.display_name || me.email)}</div>
<div className="mira-post-time"> <div className="mira-post-time">
<SourceBadge source={inferSource(blocks, doc.page_name)} /> <SourceBadge source={postSource} />
<span>·</span> <span>·</span>
<RelTime when={doc.updated_at} /> <RelTime when={doc.updated_at} />
</div> </div>
@@ -979,6 +1107,12 @@ function BlockLine({
tags_json: '[]', refs_json: '[]', sort_order: 0, tags_json: '[]', refs_json: '[]', sort_order: 0,
created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
}); });
// P0 #5breply 內含 @mira → 觸發 mira 接龍(parent = 此 reply 形成 thread
if (hasMiraMention(trimmed)) {
void triggerAiReply({
apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
});
}
setMode('view'); setDraft(''); setMode('view'); setDraft('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); } } catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); } finally { setSaving(false); }
@@ -1094,6 +1228,12 @@ function ReplyLine({
tags_json: '[]', refs_json: '[]', sort_order: 0, tags_json: '[]', refs_json: '[]', sort_order: 0,
created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
}); });
// P0 #5breply 內含 @mira → 觸發 mira 接龍(parent = 此 reply 形成 thread
if (hasMiraMention(trimmed)) {
void triggerAiReply({
apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
});
}
setMode('view'); setDraft(''); setMode('view'); setDraft('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); } } catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); } finally { setSaving(false); }
+84
View File
@@ -139,6 +139,18 @@
font-family: var(--mira-font-en); font-family: var(--mira-font-en);
} }
/* Mira 自有頭像 — 跟 leo 區分(紫色漸層 + 機器人 emoji) */
.mira-app .mira-avatar-mira {
display: grid;
place-items: center;
background: linear-gradient(135deg, oklch(0.45 0.15 280), oklch(0.55 0.18 300));
color: #fff;
border-radius: 50%;
flex-shrink: 0;
/* emoji 本身有色彩,背景僅當邊框襯托 */
line-height: 1;
}
/* ── post 貼文 ── */ /* ── post 貼文 ── */
.mira-app .mira-post { .mira-app .mira-post {
display: flex; display: flex;
@@ -775,3 +787,75 @@
border-bottom: 1px solid var(--mira-line-soft); border-bottom: 1px solid var(--mira-line-soft);
padding-bottom: 4px; padding-bottom: 4px;
} }
/* ── Composer popup (P0 #5c 編輯器放大) ── */
.mira-app .mira-composer-expand {
font-size: 18px;
padding: 0 8px;
cursor: pointer;
color: var(--mira-text-3);
}
.mira-app .mira-composer-expand:hover {
color: var(--mira-text-1);
}
/* backdrop 蓋全頁 + 居中 */
.mira-composer-popup-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1000;
display: grid;
place-items: center;
padding: 16px;
}
/* popup card 大編輯區 */
.mira-composer-popup {
background: var(--mira-bg-1);
border: 1px solid var(--mira-line);
border-radius: var(--mira-radius-lg);
width: min(800px, 100%);
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: var(--mira-font-zh);
color: var(--mira-text-1);
}
.mira-composer-popup-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--mira-line);
}
.mira-composer-popup-title {
font-weight: 600;
font-size: 15px;
}
.mira-composer-popup-textarea {
flex: 1;
min-height: 320px;
padding: 16px;
background: transparent;
color: var(--mira-text-1);
border: none;
outline: none;
resize: none;
font-family: inherit;
font-size: 15px;
line-height: 1.7;
}
.mira-composer-popup-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--mira-line);
background: var(--mira-bg-0);
}