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:
+189
-49
@@ -158,6 +158,12 @@ export default function MiraPage() {
|
||||
|
||||
// ─── AI 回覆觸發器(fire-and-forget)──────────────────────
|
||||
|
||||
// P0 #5b:偵測文字含 @mira(mention) — 大小寫不限,前後可有標點
|
||||
// 用法:leo 在貼文 / 留言寫 `@mira <主題>` → 觸發 mira reply
|
||||
function hasMiraMention(text: string): boolean {
|
||||
return /(^|[^\w])@mira\b/i.test(text);
|
||||
}
|
||||
|
||||
async function triggerWikiSynthesis(opts: { apiKey: string; rawBlockId: string }) {
|
||||
// 觸發 arcrun wiki_synthesis workflow(arcrun-native public trigger endpoint)
|
||||
// 不等結果(workflow 60-90s 含 2 次 claude_api pause/resume)
|
||||
@@ -187,10 +193,18 @@ async function triggerAiReply(opts: {
|
||||
parentBlockId: 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 =
|
||||
`用戶 leo 在 mira 河道發了這則貼文:\n\n` +
|
||||
`用戶 leo 在 mira 河道發了這則訊息:\n\n` +
|
||||
`「${opts.postContent}」\n\n` +
|
||||
`請以 Mira 副駕 AI 的身份留言回應。\n` +
|
||||
topicHint +
|
||||
`\n請以 Mira 副駕 AI 的身份留言回應。\n` +
|
||||
`規則:\n` +
|
||||
`- 繁體中文(台灣用語)\n` +
|
||||
`- 簡短 1-3 段,務實,不客套\n` +
|
||||
@@ -257,7 +271,9 @@ function PostComposer({
|
||||
const [text, setText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const popupTaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
const trimmed = text.trim();
|
||||
@@ -288,16 +304,18 @@ function PostComposer({
|
||||
const postBlockId = data.data.id;
|
||||
setText('');
|
||||
|
||||
// fire-and-forget 觸發 Mira AI 回覆(不擋用戶)
|
||||
void triggerAiReply({
|
||||
apiKey: me.api_key,
|
||||
postContent: trimmed,
|
||||
parentBlockId: postBlockId,
|
||||
pageName,
|
||||
});
|
||||
// P0 #5b:只有 @mira 時才觸發 Mira AI 回覆(撤回每篇 auto-reply)
|
||||
// 對應 design.md §3.6.5「河道是 process 場 + Mira 是 KB 同步介面」
|
||||
if (hasMiraMention(trimmed)) {
|
||||
void triggerAiReply({
|
||||
apiKey: me.api_key,
|
||||
postContent: trimmed,
|
||||
parentBlockId: postBlockId,
|
||||
pageName,
|
||||
});
|
||||
}
|
||||
// 7B.3h:fire-and-forget 觸發 wiki_synthesis(browser → cypher.arcrun.dev,arcrun-native)
|
||||
// 不走 watcher 是因為 cypher-executor 自己 fetch 自己 workers.dev URL 被 CF 1042 擋
|
||||
// watcher 仍作為 cron-driven backup(漏掉的 raws 5 分鐘後補跑),但需先解 self-fetch 問題
|
||||
// 跟 @mira 無關,每篇都跑 — 河道書寫永遠進 wiki KB 副本
|
||||
void triggerWikiSynthesis({ apiKey: me.api_key, rawBlockId: postBlockId });
|
||||
onAiTriggered(pageName);
|
||||
|
||||
@@ -312,42 +330,120 @@ function PostComposer({
|
||||
}, [text, submitting, me, onPosted, onAiTriggered]);
|
||||
|
||||
return (
|
||||
<div className="mira-card mira-composer-card">
|
||||
<div className="mira-composer-row">
|
||||
<Avatar me={me} />
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
return;
|
||||
}
|
||||
handleTabIndent(e, text, setText);
|
||||
<>
|
||||
<div className="mira-card mira-composer-card">
|
||||
<div className="mira-composer-row">
|
||||
<Avatar me={me} />
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
return;
|
||||
}
|
||||
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 ? '送出中⋯' : '貼文'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mira-composer-popup">
|
||||
<header className="mira-composer-popup-header">
|
||||
<Avatar me={me} size={32} />
|
||||
<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 };
|
||||
@@ -646,15 +771,18 @@ function DocCard({
|
||||
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 (
|
||||
<article className="mira-card mira-post" ref={cardRef}>
|
||||
{/* 頭部:作者區塊 */}
|
||||
{/* 頭部:作者區塊(依 source 判斷頭像 + 名字) */}
|
||||
<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-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">
|
||||
<SourceBadge source={inferSource(blocks, doc.page_name)} />
|
||||
<SourceBadge source={postSource} />
|
||||
<span>·</span>
|
||||
<RelTime when={doc.updated_at} />
|
||||
</div>
|
||||
@@ -979,6 +1107,12 @@ function BlockLine({
|
||||
tags_json: '[]', refs_json: '[]', sort_order: 0,
|
||||
created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
// P0 #5b:reply 內含 @mira → 觸發 mira 接龍(parent = 此 reply 形成 thread)
|
||||
if (hasMiraMention(trimmed)) {
|
||||
void triggerAiReply({
|
||||
apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
|
||||
});
|
||||
}
|
||||
setMode('view'); setDraft('');
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
|
||||
finally { setSaving(false); }
|
||||
@@ -1094,6 +1228,12 @@ function ReplyLine({
|
||||
tags_json: '[]', refs_json: '[]', sort_order: 0,
|
||||
created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
// P0 #5b:reply 內含 @mira → 觸發 mira 接龍(parent = 此 reply 形成 thread)
|
||||
if (hasMiraMention(trimmed)) {
|
||||
void triggerAiReply({
|
||||
apiKey, postContent: trimmed, parentBlockId: data.data.id, pageName: docPageName,
|
||||
});
|
||||
}
|
||||
setMode('view'); setDraft('');
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
|
||||
finally { setSaving(false); }
|
||||
|
||||
@@ -139,6 +139,18 @@
|
||||
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 貼文 ── */
|
||||
.mira-app .mira-post {
|
||||
display: flex;
|
||||
@@ -775,3 +787,75 @@
|
||||
border-bottom: 1px solid var(--mira-line-soft);
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user