Files
Arcrun/landing/app/mira/feed/page.tsx
T
Leo 94368ec981 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 回覆」。
2026-05-16 11:07:34 +08:00

1739 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
// Mira 河道 — 社群貼文式設計(FB / X 風格)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.3
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { MarkdownView } from '../_shared/markdown';
import '../mira.css';
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';
const KBDB_PATCH = 'https://kbdb-patch-block.arcrun.dev';
const KBDB_CREATE = 'https://kbdb-create-block.arcrun.dev';
const CLAUDE_API = 'https://claude-api.arcrun.dev';
const PREVIEW_LINES = 3; // 卡片預覽顯示行數
const PREVIEW_CHARS = 200; // 卡片預覽顯示字數上限
type Document = {
page_name: string;
user_id: string;
block_count: number;
created_at: number | string;
updated_at: number | string;
};
type KBDBBlock = {
id: string;
content: string | null;
type: string;
source: string | null;
user_id: string | null;
parent_id: string | null;
page_name: string | null;
tags_json: string;
refs_json: string;
sort_order?: number;
created_at: number | string;
updated_at: number | string;
};
type Me = { email: string; api_key: string; display_name: string; avatar_url?: string };
const MIRA_POST_USER_ID = 'inkstone_mira_post';
export default function MiraPage() {
const [me, setMe] = useState<Me | null>(null);
const [docs, setDocs] = useState<Document[] | null>(null);
const [error, setError] = useState<string | null>(null);
// page_name → "thinking" / null:哪些 doc 在等 AI 回覆
const [aiThinking, setAiThinking] = useState<Set<string>>(new Set());
const meRef = useRef<Me | null>(null);
meRef.current = me;
const reload = useCallback(async () => {
const m = meRef.current;
if (!m) return;
try {
const docsRes = await fetch(`${KBDB_BASE}/blocks/documents?limit=50&_t=${Date.now()}`, {
headers: { Authorization: `Bearer ${m.api_key}` },
cache: 'no-store',
});
if (docsRes.ok) {
const data = (await docsRes.json()) as { documents?: Document[] };
setDocs(data.documents ?? []);
}
} catch (e) {
console.error('[mira reload]', e);
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
if (!meRes.ok) {
window.location.href = '/login?redirect=/mira/feed';
return;
}
const meData = (await meRes.json()) as Me;
if (cancelled) return;
setMe(meData);
const docsRes = await fetch(`${KBDB_BASE}/blocks/documents?limit=50`, {
headers: { Authorization: `Bearer ${meData.api_key}` },
});
if (!docsRes.ok) {
setError(`KBDB 讀取失敗:${docsRes.status}`);
return;
}
const data = (await docsRes.json()) as { documents?: Document[] };
if (cancelled) return;
setDocs(data.documents ?? []);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
}
})();
return () => { cancelled = true; };
}, []);
return (
<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 timeoutMira 思考 + 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);
}}
/>
)}
{error && (
<div className="mira-card">
<div className="mira-error">{error}</div>
</div>
)}
{!error && docs === null && <div className="empty-state">載入中…</div>}
{!error && docs !== null && docs.length === 0 && me && (
<EmptyRiver email={me.email} />
)}
{!error && docs && docs.length > 0 && me && (
<DocsList
docs={docs}
me={me}
aiThinking={aiThinking}
onAiResponded={(pageName) => {
setAiThinking(prev => {
if (!prev.has(pageName)) return prev;
const n = new Set(prev);
n.delete(pageName);
return n;
});
}}
/>
)}
</div>
</div>
);
}
// ─── AI 回覆觸發器(fire-and-forget)──────────────────────
// P0 #5b:偵測文字含 @miramention) — 大小寫不限,前後可有標點
// 用法:leo 在貼文 / 留言寫 `@mira <主題>` → 觸發 mira reply
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
try {
const res = await fetch(`${API_BASE}/webhooks/named/wiki_synthesis/trigger`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Arcrun-API-Key': opts.apiKey,
},
body: JSON.stringify({ api_key: opts.apiKey, raw_block_id: opts.rawBlockId }),
});
if (!res.ok) {
console.warn('[wiki_synthesis trigger] non-ok:', res.status);
return;
}
const data = await res.json().catch(() => ({}));
console.log('[wiki_synthesis trigger] response:', data);
} catch (e) {
console.warn('[wiki_synthesis trigger] error:', e);
}
}
async function triggerAiReply(opts: {
apiKey: string;
postContent: string;
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';
// 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 +
`\n請以 Mira 副駕 AI 的身份留言回應。\n` +
`規則:\n` +
`- 繁體中文(台灣用語)\n` +
`- 簡短 1-3 段,務實,不客套\n` +
`- 可以發問、補充、提建議、或反問釐清\n` +
`- 不替老闆下決策,但可以給判斷依據\n` +
(knowledgeContext ? `- **如果 leo 提到的 entity 你已有 wiki 知識,請主動引用 / 連結(用「你之前寫過...」或「在《X》wiki 中你提到...」)**\n` : '');
try {
const aiRes = await fetch(CLAUDE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, timeout_ms: 25000 }),
});
const aiData = (await aiRes.json()) as {
success?: boolean;
pending?: boolean;
data?: { text?: string };
error?: string;
};
if (!aiRes.ok || !aiData.success) {
console.error('[mira ai reply] claude_api failed:', aiData.error);
return;
}
if (aiData.pending) {
// daemon 切到 async,目前簡化版不處理 polling
console.warn('[mira ai reply] daemon went async, skipping');
return;
}
const text = aiData.data?.text;
if (!text) {
console.warn('[mira ai reply] no text in response');
return;
}
// 寫成 chat block (parent_id = 原貼文 block, source=ai-mira)
await fetch(KBDB_CREATE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: opts.apiKey,
content: text,
type: 'chat',
parent_id: opts.parentBlockId,
user_id: MIRA_POST_USER_ID,
source: 'ai-mira',
page_name: opts.pageName,
}),
});
} catch (e) {
console.error('[mira ai reply] exception:', e);
}
}
// ─── 寫貼文 composer ──────────────────────────────────────
function PostComposer({
me,
onPosted,
onAiTriggered,
}: {
me: Me;
onPosted: () => Promise<void> | void;
onAiTriggered: (pageName: string) => void;
}) {
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();
if (!trimmed || submitting) return;
setSubmitting(true);
setErr(null);
try {
const stamp = new Date().toISOString();
const pageName = `post-${stamp}-${Math.random().toString(36).slice(2, 8)}`;
const res = await fetch(KBDB_CREATE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: me.api_key,
content: trimmed,
type: 'note',
user_id: MIRA_POST_USER_ID,
source: 'km-writer-direct',
page_name: pageName,
}),
});
const data = (await res.json()) as { success?: boolean; error?: string; data?: { id?: string } };
if (!res.ok || !data.success || !data.data?.id) {
setErr(data.error || `寫入失敗:${res.status}`);
return;
}
const postBlockId = data.data.id;
setText('');
// 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,
postContent: trimmed,
parentBlockId: postBlockId,
pageName,
});
onAiTriggered(pageName);
}
// 7B.3hfire-and-forget 觸發 wiki_synthesisbrowser → cypher.arcrun.devarcrun-native
// 跟 @mira 無關,每篇都跑 — 河道書寫永遠進 wiki KB 副本(不顯示 thinking indicator,背景靜默跑)
void triggerWikiSynthesis({ apiKey: me.api_key, rawBlockId: postBlockId });
// 給 D1 GROUP BY 查詢看到新資料的時間
await new Promise(r => setTimeout(r, 1500));
await onPosted();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
}, [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);
}}
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);
}}
>
<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>
)}
</>
);
}
function Avatar({ me, size = 40 }: { me: Me; size?: number }) {
if (me.avatar_url) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={me.avatar_url}
alt={me.display_name}
width={size}
height={size}
className="mira-avatar"
style={{ width: size, height: size }}
/>
);
}
const initial = (me.display_name || me.email || '?').slice(0, 1).toUpperCase();
return (
<div className="mira-avatar mira-avatar-fallback" style={{ width: size, height: size, fontSize: size * 0.4 }}>
{initial}
</div>
);
}
// 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 };
function MoreMenu({ items }: { items: MenuItem[] }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClickOutside = (e: MouseEvent | TouchEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onClickOutside);
document.addEventListener('touchstart', onClickOutside);
return () => {
document.removeEventListener('mousedown', onClickOutside);
document.removeEventListener('touchstart', onClickOutside);
};
}, [open]);
return (
<div ref={ref} className="mira-menu-wrap">
<button
type="button"
className="mira-icon-btn mira-icon-btn-vertical"
aria-label="更多"
onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
>
</button>
{open && (
<div className="mira-menu" onClick={(e) => e.stopPropagation()} role="menu">
{items.map((item, i) => (
<button
key={i}
type="button"
className={`mira-menu-item${item.danger ? ' danger' : ''}`}
onClick={() => { item.onClick(); setOpen(false); }}
role="menuitem"
>
{item.label}
</button>
))}
</div>
)}
</div>
);
}
// ─── 列表 ──────────────────────────────────────────────────
function EmptyRiver({ email }: { email: string }) {
return (
<div className="empty-state">
<div style={{ fontSize: 56, marginBottom: 14 }}>🌊</div>
<p style={{ color: 'var(--mira-text-2)', fontSize: 14, marginBottom: 6 }}>
河裡還沒有東西。
</p>
<p style={{ fontSize: 12, color: 'var(--mira-text-3)' }}>
登入身份:<span className="mira-en">{email}</span>
<br />
在上方寫第一則貼文。
</p>
</div>
);
}
function DocsList({
docs,
me,
aiThinking,
onAiResponded,
}: {
docs: Document[];
me: Me;
aiThinking: Set<string>;
onAiResponded: (pageName: string) => void;
}) {
return (
<>
{docs.map(d => (
<DocCard
key={`${d.page_name}::${d.user_id}`}
doc={d}
me={me}
aiThinking={aiThinking.has(d.page_name)}
onAiResponded={() => onAiResponded(d.page_name)}
/>
))}
</>
);
}
// ─── 卡片(社群貼文風格)────────────────────────────────────
function DocCard({
doc,
me,
aiThinking,
onAiResponded,
}: {
doc: Document;
me: Me;
aiThinking: boolean;
onAiResponded: () => void;
}) {
const [blocks, setBlocks] = useState<KBDBBlock[] | null>(null);
const [loading, setLoading] = useState(false);
const [expandedFull, setExpandedFull] = useState(false);
const [showReply, setShowReply] = useState(false);
const [showAllReplies, setShowAllReplies] = useState(false);
const [editingPost, setEditingPost] = useState(false);
const cardRef = useRef<HTMLElement>(null);
const fetchedRef = useRef(false);
// Lazy fetch:進入視窗才抓 sub-blocksIntersectionObserver
useEffect(() => {
if (fetchedRef.current) return;
const node = cardRef.current;
if (!node) return;
const trigger = async () => {
if (fetchedRef.current) return;
fetchedRef.current = true;
setLoading(true);
try {
const res = await fetch(
`${KBDB_BASE}/blocks?page_name=${encodeURIComponent(doc.page_name)}&limit=200`,
{ headers: { Authorization: `Bearer ${me.api_key}` } },
);
if (res.ok) {
const data = (await res.json()) as { blocks?: KBDBBlock[] };
setBlocks(data.blocks ?? []);
} else {
setBlocks([]);
}
} catch {
setBlocks([]);
} finally {
setLoading(false);
}
};
if (typeof IntersectionObserver === 'undefined') {
// 不支援 IntersectionObserver 的環境(極舊瀏覽器):直接 fetch
void trigger();
return;
}
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
void trigger();
io.disconnect();
break;
}
}
}, {
rootMargin: '200px 0px', // 提前 200px 觸發,不等卡片完全進入視窗
threshold: 0.01,
});
io.observe(node);
return () => io.disconnect();
}, [doc.page_name, me.api_key]);
// 強制重抓(外部呼叫 / aiThinking polling
const refetch = useCallback(async (): Promise<KBDBBlock[] | null> => {
try {
const res = await fetch(
`${KBDB_BASE}/blocks?page_name=${encodeURIComponent(doc.page_name)}&limit=200&_t=${Date.now()}`,
{ headers: { Authorization: `Bearer ${me.api_key}` }, cache: 'no-store' },
);
if (res.ok) {
const data = (await res.json()) as { blocks?: KBDBBlock[] };
const fresh = data.blocks ?? [];
setBlocks(fresh);
return fresh;
}
} catch {}
return null;
}, [doc.page_name, me.api_key]);
// aiThinking 期間每 5 秒 refetch;偵測 AI reply 出現就清 thinking
useEffect(() => {
if (!aiThinking) return;
const id = setInterval(async () => {
const fresh = await refetch();
if (fresh && fresh.some(b => b.source?.startsWith('ai-'))) {
onAiResponded();
}
}, 5000);
return () => clearInterval(id);
}, [aiThinking, refetch, onAiResponded]);
// 主內容(type != chat 的 sorted blocks),合成單一文字流
// 留言(type=chat)拍平按時間排,不論 parent_id 都集中到卡片底部
const { mainContent, mainBlocksList, allReplies } = useMemo(() => {
if (!blocks) return {
mainContent: '',
mainBlocksList: [] as KBDBBlock[],
allReplies: [] as KBDBBlock[],
};
const sorted = [...blocks].sort((a, b) => {
const sa = a.sort_order ?? 0;
const sb = b.sort_order ?? 0;
if (sa !== sb) return sa - sb;
return toDate(a.created_at).getTime() - toDate(b.created_at).getTime();
});
const main = sorted.filter(b => b.type !== 'chat');
const chats = sorted.filter(b => b.type === 'chat');
const repliesSorted = [...chats].sort(
(a, b) => toDate(a.created_at).getTime() - toDate(b.created_at).getTime(),
);
const text = main.map(b => b.content || '').filter(t => t.trim()).join('\n\n');
return { mainContent: text, mainBlocksList: main, allReplies: repliesSorted };
}, [blocks]);
// 預覽截斷
const { previewText, isLong } = useMemo(() => {
const text = mainContent;
const lines = text.split('\n');
const tooManyLines = lines.length > PREVIEW_LINES;
const tooLong = text.length > PREVIEW_CHARS;
if (!tooManyLines && !tooLong) return { previewText: text, isLong: false };
let preview = lines.slice(0, PREVIEW_LINES).join('\n');
if (preview.length > PREVIEW_CHARS) preview = preview.slice(0, PREVIEW_CHARS);
return { previewText: preview, isLong: true };
}, [mainContent]);
const deletePost = useCallback(async () => {
if (!blocks || blocks.length === 0) return;
const ok = window.confirm(`確定刪除這篇貼文?(共 ${blocks.length} 個 block + 留言將一併刪除,無法復原)`);
if (!ok) return;
await Promise.all(
blocks.map(b =>
fetch(`${KBDB_BASE}/blocks/${b.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${me.api_key}` },
}).catch(() => null),
),
);
window.location.reload();
}, [blocks, me.api_key]);
const cardMenu: MenuItem[] = [
...(isLong || expandedFull ? [{
label: expandedFull ? '收起' : '展開全文',
onClick: () => setExpandedFull(v => !v),
}] : []),
{ label: '編輯', onClick: () => setEditingPost(true) },
{ label: '留言', onClick: () => setShowReply(true) },
{ label: '複製連結', onClick: () => {
const url = `${window.location.origin}/mira/feed#page=${encodeURIComponent(doc.page_name)}`;
void navigator.clipboard?.writeText(url);
}},
{ label: '刪除', onClick: deletePost, danger: true },
];
const totalReplies = allReplies.length;
// 樂觀添加新 reply
const addReply = useCallback((b: KBDBBlock) => {
setBlocks(curr => (curr ? [...curr, b] : [b]));
}, []);
const updateBlock = useCallback((id: string, newContent: string) => {
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">
{showMira ? <MiraAvatar size={40} /> : <Avatar me={me} size={40} />}
<div className="mira-post-author">
<div className="mira-post-name">{showMira ? 'Mira' : (me.display_name || me.email)}</div>
<div className="mira-post-time">
<SourceBadge source={postSource} />
<span>·</span>
<RelTime when={doc.updated_at} />
</div>
</div>
<MoreMenu items={cardMenu} />
</header>
{/* 內容主體 */}
<div className="mira-post-body">
{loading && <div className="mira-skel" />}
{!loading && !mainContent && !editingPost && (
<em style={{ color: 'var(--mira-text-3)' }}>(無內容)</em>
)}
{!loading && editingPost && (
<PostEditor
initial={mainContent}
mainBlocks={mainBlocksList}
apiKey={me.api_key}
onCancel={() => setEditingPost(false)}
onSaved={(newContent) => {
setEditingPost(false);
// 樂觀更新:第一個 block 改新內容、其餘清空
setBlocks(curr => {
if (!curr) return curr;
const main = curr.filter(b => b.type !== 'chat');
const chats = curr.filter(b => b.type === 'chat');
if (main.length === 0) return curr;
const updated: KBDBBlock[] = [
{ ...main[0], content: newContent },
...chats,
];
return updated;
});
}}
/>
)}
{!loading && !editingPost && mainContent && (
expandedFull ? (
<>
<FullContent blocks={mainBlocksList}
apiKey={me.api_key} docPageName={doc.page_name}
addReply={addReply} updateBlock={updateBlock} />
<button
type="button"
className="mira-show-more"
onClick={() => setExpandedFull(false)}
>
收起
</button>
</>
) : (
<>
<PreviewContent text={previewText} />
{isLong && (
<button
type="button"
className="mira-show-more"
onClick={() => setExpandedFull(true)}
>
⋯查看更多
</button>
)}
</>
)
)}
</div>
{/* 互動列 */}
<footer className="mira-post-footer">
<button type="button" className="mira-action-btn" onClick={() => setShowReply(s => !s)}>
💬 留言{totalReplies > 0 && <span className="mira-action-count">{totalReplies}</span>}
</button>
</footer>
{/* 留言區(所有 chat 拍平按時間排,預設顯示後 2 則,點開看全部)*/}
{(allReplies.length > 0 || aiThinking) && (
<div className="mira-post-replies">
{!showAllReplies && allReplies.length > 2 && (
<button
type="button"
className="mira-show-replies"
onClick={() => setShowAllReplies(true)}
>
查看全部 {allReplies.length} 則留言
</button>
)}
{(showAllReplies ? allReplies : allReplies.slice(-2)).map(r => (
<ReplyLine
key={r.id}
reply={r}
apiKey={me.api_key}
docPageName={doc.page_name}
onUpdated={updateBlock}
onReplyAdded={addReply}
repliesByParent={new Map()}
depth={0}
/>
))}
{aiThinking && (
<div className="mira-reply-line is-ai mira-thinking">
<span className="mira-reply-icon">🤖</span>
<div className="mira-reply-content">
<span className="mira-thinking-dots">Mira 思考中</span>
</div>
</div>
)}
{showAllReplies && allReplies.length > 2 && (
<button
type="button"
className="mira-show-replies"
onClick={() => setShowAllReplies(false)}
>
收起留言
</button>
)}
</div>
)}
{/* 留言 composer */}
{showReply && (
<div className="mira-reply-composer">
<Avatar me={me} size={32} />
<PageReplyComposer
apiKey={me.api_key}
docPageName={doc.page_name}
onSubmitted={(b) => { addReply(b); setShowReply(false); }}
onCancel={() => setShowReply(false)}
/>
</div>
)}
</article>
);
}
// 預覽內容(純文字,不顯示 sub-block 結構)
function PreviewContent({ text }: { text: string }) {
return <div className="mira-post-content"><MarkdownView text={text} /></div>;
}
// 整篇貼文編輯器:把所有 main blocks 合併成一段編輯
// 存回時:第一個 block 改成新內容,其餘 main blocks 刪除
function PostEditor({
initial,
mainBlocks,
apiKey,
onCancel,
onSaved,
}: {
initial: string;
mainBlocks: KBDBBlock[];
apiKey: string;
onCancel: () => void;
onSaved: (newContent: string) => void;
}) {
const [draft, setDraft] = useState(initial);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const save = async () => {
if (saving) return;
const trimmed = draft.trim();
if (!trimmed) {
setErr('內容不能空');
return;
}
if (trimmed === initial.trim()) {
onCancel();
return;
}
if (mainBlocks.length === 0) {
setErr('找不到可編輯的 block');
return;
}
setSaving(true);
setErr(null);
try {
// 1. 改第一個 block 內容
const firstId = mainBlocks[0].id;
const patchRes = await fetch(KBDB_PATCH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKey, block_id: firstId, content: trimmed }),
});
const patchData = (await patchRes.json()) as { success?: boolean; error?: string };
if (!patchRes.ok || !patchData.success) {
setErr(patchData.error || `儲存失敗:${patchRes.status}`);
return;
}
// 2. 刪其餘 main blocks(如有)
if (mainBlocks.length > 1) {
await Promise.all(
mainBlocks.slice(1).map(b =>
fetch(`${KBDB_BASE}/blocks/${b.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` },
}).catch(() => null),
),
);
}
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 {
setSaving(false);
}
};
return (
<EditingArea
draft={draft}
setDraft={setDraft}
onSubmit={save}
onCancel={onCancel}
saving={saving}
err={err}
submitLabel="儲存"
placeholder="編輯內容⋯(@mira 呼叫 Mira 回覆)"
/>
);
}
// 完整內容(每個 sub-block 一行,可編輯,可留言)
function FullContent({
blocks,
apiKey,
docPageName,
addReply,
updateBlock,
}: {
blocks: KBDBBlock[];
apiKey: string;
docPageName: string;
addReply: (b: KBDBBlock) => void;
updateBlock: (id: string, c: string) => void;
}) {
// 只顯示 main blockstype != chat),所有 chat 全部集中到卡片底部 replies 區
return (
<div className="mira-post-content mira-post-content-full">
{blocks.slice(0, 100).map(b => (
<BlockLine
key={b.id}
block={b}
apiKey={apiKey}
docPageName={docPageName}
onUpdated={updateBlock}
onReplyAdded={addReply}
/>
))}
{blocks.length > 100 && (
<div style={{ color: 'var(--mira-text-2)', fontSize: 12, marginTop: 12 }}>
…還有 {blocks.length - 100} block 未顯示
</div>
)}
</div>
);
}
function BlockLine({
block,
apiKey,
docPageName,
onUpdated,
onReplyAdded,
}: {
block: KBDBBlock;
apiKey: string;
docPageName: string;
onUpdated: (id: string, c: string) => void;
onReplyAdded: (b: KBDBBlock) => void;
}) {
const [mode, setMode] = useState<'view' | 'edit' | 'reply'>('view');
const [draft, setDraft] = useState(block.content ?? '');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const startEdit = () => { setDraft(block.content ?? ''); setMode('edit'); setErr(null); };
const startReply = () => { setDraft(''); setMode('reply'); setErr(null); };
const cancel = () => { setMode('view'); setDraft(block.content ?? ''); setErr(null); };
const saveEdit = async () => {
if (saving) return;
if (draft === (block.content ?? '')) { setMode('view'); return; }
setSaving(true); setErr(null);
try {
const res = await fetch(KBDB_PATCH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKey, block_id: block.id, content: draft }),
});
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); }
};
const submitReply = async () => {
if (saving) return;
const trimmed = draft.trim();
if (!trimmed) { cancel(); return; }
setSaving(true); setErr(null);
try {
const res = await fetch(KBDB_CREATE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
content: trimmed, type: 'chat', parent_id: block.id,
user_id: MIRA_POST_USER_ID, source: 'mira-reply', page_name: docPageName,
}),
});
const data = (await res.json()) as { success?: boolean; data?: { id?: string }; error?: string };
if (!res.ok || !data.success || !data.data?.id) {
setErr(data.error || `留言失敗:${res.status}`); return;
}
onReplyAdded({
id: data.data.id, content: trimmed, type: 'chat', source: 'mira-reply',
user_id: MIRA_POST_USER_ID, parent_id: block.id, page_name: docPageName,
tags_json: '[]', refs_json: '[]', sort_order: 0,
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('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); }
};
const indent = block.parent_id ? 16 : 0;
const borderLeft = block.parent_id ? '2px solid var(--mira-line-soft)' : 'none';
const deleteBlock = async () => {
const ok = window.confirm('確定刪除這個 block?(不可復原)');
if (!ok) return;
await fetch(`${KBDB_BASE}/blocks/${block.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` },
}).catch(() => null);
// 設為空內容讓父層感知(樂觀更新;refetch 才會真正消失)
onUpdated(block.id, '【已刪除】');
};
const menu: MenuItem[] = [
{ label: '編輯', onClick: startEdit },
{ label: '回覆', onClick: startReply },
{ label: '刪除', onClick: deleteBlock, danger: true },
];
return (
<div className="mira-block-line"
style={{ marginBottom: 8, paddingLeft: indent, borderLeft, position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8 }}
>
<div style={{ flex: 1, minWidth: 0 }}>
{mode === 'view' && (
block.content ? <MarkdownView text={block.content} /> :
<em style={{ color: 'var(--mira-text-3)' }}>(空)</em>
)}
{mode === 'edit' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={saveEdit} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" />
)}
{mode === 'reply' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆⋯⋯" />
)}
</div>
{mode === 'view' && <MoreMenu items={menu} />}
</div>
);
}
function ReplyLine({
reply,
apiKey,
docPageName,
onUpdated,
onReplyAdded,
repliesByParent,
depth,
}: {
reply: KBDBBlock;
apiKey: string;
docPageName: string;
onUpdated: (id: string, c: string) => void;
onReplyAdded: (b: KBDBBlock) => void;
repliesByParent: Map<string, KBDBBlock[]>;
depth: number;
}) {
const [mode, setMode] = useState<'view' | 'edit' | 'reply'>('view');
const [draft, setDraft] = useState(reply.content ?? '');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const isAI = reply.source?.startsWith('ai-') ?? false;
const startEdit = () => { setDraft(reply.content ?? ''); setMode('edit'); setErr(null); };
const startReply = () => { setDraft(''); setMode('reply'); setErr(null); };
const cancel = () => { setMode('view'); setDraft(reply.content ?? ''); setErr(null); };
const save = async () => {
if (saving || draft === (reply.content ?? '')) { setMode('view'); return; }
setSaving(true); setErr(null);
try {
const res = await fetch(KBDB_PATCH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKey, block_id: reply.id, content: draft }),
});
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); }
};
const submitReply = async () => {
if (saving) return;
const trimmed = draft.trim();
if (!trimmed) { cancel(); return; }
setSaving(true); setErr(null);
try {
const res = await fetch(KBDB_CREATE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
content: trimmed, type: 'chat', parent_id: reply.id,
user_id: MIRA_POST_USER_ID, source: 'mira-reply', page_name: docPageName,
}),
});
const data = (await res.json()) as { success?: boolean; data?: { id?: string }; error?: string };
if (!res.ok || !data.success || !data.data?.id) {
setErr(data.error || `留言失敗:${res.status}`); return;
}
onReplyAdded({
id: data.data.id, content: trimmed, type: 'chat', source: 'mira-reply',
user_id: MIRA_POST_USER_ID, parent_id: reply.id, page_name: docPageName,
tags_json: '[]', refs_json: '[]', sort_order: 0,
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('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); }
};
const deleteReply = async () => {
const ok = window.confirm('確定刪除這則留言?(不可復原)');
if (!ok) return;
await fetch(`${KBDB_BASE}/blocks/${reply.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` },
}).catch(() => null);
onUpdated(reply.id, '【已刪除】');
};
const menu: MenuItem[] = [
{ label: '回覆', onClick: startReply },
{ label: '編輯', onClick: startEdit },
{ label: '刪除', onClick: deleteReply, danger: true },
];
return (
<div className={`mira-reply-line${isAI ? ' is-ai' : ''}`}>
<span className="mira-reply-icon">{isAI ? '🤖' : '💬'}</span>
<div style={{ flex: 1, minWidth: 0 }}>
{mode === 'view' && (
<div className="mira-reply-content">
{reply.content ? <MarkdownView text={reply.content} /> :
<em style={{ color: 'var(--mira-text-3)' }}>(空)</em>}
</div>
)}
{mode === 'edit' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={save} onCancel={cancel} saving={saving} err={err} submitLabel="儲存" />
)}
{mode === 'reply' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆這則留言⋯⋯" />
)}
{/* 巢狀留言 */}
{depth < 3 && (
<ReplyTree
replies={repliesByParent.get(reply.id) ?? []}
allReplies={repliesByParent}
apiKey={apiKey}
docPageName={docPageName}
onUpdated={onUpdated}
onReplyAdded={onReplyAdded}
depth={depth + 1}
/>
)}
</div>
{mode === 'view' && <MoreMenu items={menu} />}
</div>
);
}
function ReplyTree({
replies,
allReplies,
apiKey,
docPageName,
onUpdated,
onReplyAdded,
depth,
}: {
replies: KBDBBlock[];
allReplies: Map<string, KBDBBlock[]>;
apiKey: string;
docPageName: string;
onUpdated: (id: string, c: string) => void;
onReplyAdded: (b: KBDBBlock) => void;
depth: number;
}) {
if (replies.length === 0) return null;
const sorted = [...replies].sort((a, b) => toDate(a.created_at).getTime() - toDate(b.created_at).getTime());
return (
<div className="mira-reply-nested">
{sorted.map(r => (
<ReplyLine
key={r.id}
reply={r}
apiKey={apiKey}
docPageName={docPageName}
onUpdated={onUpdated}
onReplyAdded={onReplyAdded}
repliesByParent={allReplies}
depth={depth}
/>
))}
</div>
);
}
function PageReplyComposer({
apiKey,
docPageName,
onSubmitted,
onCancel,
}: {
apiKey: string;
docPageName: string;
onSubmitted: (b: KBDBBlock) => void;
onCancel: () => void;
}) {
const [draft, setDraft] = useState('');
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const submit = async () => {
if (saving) return;
const trimmed = draft.trim();
if (!trimmed) { onCancel(); return; }
setSaving(true); setErr(null);
try {
const res = await fetch(KBDB_CREATE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
content: trimmed, type: 'chat',
user_id: MIRA_POST_USER_ID, source: 'mira-reply', page_name: docPageName,
}),
});
const data = (await res.json()) as { success?: boolean; data?: { id?: string }; error?: string };
if (!res.ok || !data.success || !data.data?.id) {
setErr(data.error || `留言失敗:${res.status}`); return;
}
onSubmitted({
id: data.data.id, content: trimmed, type: 'chat', source: 'mira-reply',
user_id: MIRA_POST_USER_ID, parent_id: null, page_name: docPageName,
tags_json: '[]', refs_json: '[]', sort_order: 0,
created_at: Math.floor(Date.now() / 1000), updated_at: Math.floor(Date.now() / 1000),
});
setDraft('');
} catch (e) { setErr(e instanceof Error ? e.message : String(e)); }
finally { setSaving(false); }
};
return (
<div style={{ flex: 1, minWidth: 0 }}>
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submit} onCancel={onCancel} saving={saving} err={err} submitLabel="留言" placeholder="寫留言⋯⋯" />
</div>
);
}
function EditingArea({
draft, setDraft, onSubmit, onCancel, saving, err, submitLabel, placeholder,
}: {
draft: string;
setDraft: (s: string) => void;
onSubmit: () => void;
onCancel: () => void;
saving: boolean;
err: string | null;
submitLabel: string;
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} />
<textarea
ref={taRef}
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); return; }
if (e.key === 'Escape') { e.preventDefault(); onCancel(); return; }
handleTabIndent(e, draft, setDraft);
}}
autoFocus
rows={Math.max(2, draft.split('\n').length)}
disabled={saving}
placeholder={placeholder}
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>
<button type="button" onClick={onCancel} disabled={saving} className="mira-btn-ghost">取消</button>
<button type="button" onClick={onSubmit} disabled={saving} className="mira-btn-primary">
{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>
)}
</>
);
}
// Tab / Shift-Tab 處理:textarea 內按 Tab 縮排(不離開 textarea
// - 無選取:游標位置插 2 空格(行內)/ 行首插 2 空格(行首)
// - 有選取:選取範圍內每行行首加/減 2 空格
// - Shift-Tab:減縮排
function handleTabIndent(
e: React.KeyboardEvent<HTMLTextAreaElement>,
value: string,
setValue: (v: string) => void,
): boolean {
if (e.key !== 'Tab') return false;
e.preventDefault();
const ta = e.currentTarget;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const indent = ' '; // 2 空格
const isShift = e.shiftKey;
// 找該 selection 涉及的所有行
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const hasMultiLine = value.slice(start, end).includes('\n');
if (start === end && !isShift) {
// 無選取 + Tab:游標位置插 indent
const next = value.slice(0, start) + indent + value.slice(start);
setValue(next);
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + indent.length;
});
return true;
}
// 有選取 或 Shift-Tab:對涉及的所有行操作
const beforeBlock = value.slice(0, lineStart);
const block = value.slice(lineStart, end);
const afterBlock = value.slice(end);
const lines = block.split('\n');
let delta = 0;
const newLines = lines.map((line, i) => {
if (isShift) {
// 減縮排:若行首是 indent 或 1 個 tab/space 開頭,去掉
if (line.startsWith(indent)) {
if (i === 0) delta -= indent.length;
else delta -= indent.length;
return line.slice(indent.length);
}
if (line.startsWith('\t')) {
if (i === 0) delta -= 1;
else delta -= 1;
return line.slice(1);
}
if (line.startsWith(' ')) {
if (i === 0) delta -= 1;
else delta -= 1;
return line.slice(1);
}
return line;
} else {
// 加縮排:行首加 indent
if (i === 0) delta += indent.length;
else delta += indent.length;
return indent + line;
}
});
const next = beforeBlock + newLines.join('\n') + afterBlock;
setValue(next);
requestAnimationFrame(() => {
if (hasMultiLine) {
ta.selectionStart = lineStart;
ta.selectionEnd = end + delta;
} else {
ta.selectionStart = ta.selectionEnd = end + delta;
}
});
return true;
}
// ─── Markdown toolbar(極簡,4 個按鈕)──────────────────────
function MarkdownToolbar({
textareaRef,
value,
setValue,
compact,
}: {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
value: string;
setValue: (v: string) => void;
compact?: boolean;
}) {
const wrap = (before: string, after: string = before, placeholder = '') => {
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = value.slice(start, end);
const insert = selected || placeholder;
const next = value.slice(0, start) + before + insert + after + value.slice(end);
setValue(next);
requestAnimationFrame(() => {
ta.focus();
const pos = start + before.length;
ta.setSelectionRange(pos, pos + insert.length);
});
};
const linePrefix = (prefix: string) => {
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart;
// 找該行行首
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
const next = value.slice(0, lineStart) + prefix + value.slice(lineStart);
setValue(next);
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(start + prefix.length, start + prefix.length);
});
};
return (
<div className={`mira-md-toolbar${compact ? ' compact' : ''}`}>
<button type="button" className="mira-md-btn" title="標題(# 開頭)" onClick={() => linePrefix('## ')}>
H
</button>
<button type="button" className="mira-md-btn" title="粗體(⌘+B" onClick={() => wrap('**', '**', '粗體')}>
<strong>B</strong>
</button>
<button type="button" className="mira-md-btn" title="斜體(⌘+I" onClick={() => wrap('*', '*', '斜體')}>
<em>I</em>
</button>
<button type="button" className="mira-md-btn" title="條列" onClick={() => linePrefix('- ')}>
•—
</button>
<button type="button" className="mira-md-btn" title="連結" onClick={() => wrap('[', '](url)', '文字')}>
🔗
</button>
</div>
);
}
// ─── 視覺輔助:source / time / markdown ────────────────────
function inferSource(blocks: KBDBBlock[] | null, pageName?: string): string {
if (blocks && blocks.length > 0) {
const main = blocks.find(b => b.type !== 'chat' && b.source);
if (main?.source) return main.source;
}
// fallback:用 page_name 推測
if (pageName) {
if (pageName.startsWith('post-')) return 'km-writer-direct';
// Logseq daily journal 格式:YYYYMMDD8 位數字)
if (/^\d{8}$/.test(pageName)) return 'logseq';
if (pageName.startsWith('km-writer')) return 'km-writer-direct';
if (pageName === 'MyDay' || pageName.includes('Mini me')) return 'logseq';
}
return 'logseq'; // 既有大部分資料來自 logseq sync
}
function SourceBadge({ source }: { source: string }) {
const s = source.toLowerCase();
let label = source;
let cls = 'logseq';
if (s.includes('logseq')) { label = 'Logseq'; cls = 'logseq'; }
else if (s.includes('km-writer') || s.includes('mobile') || s.includes('web')) { label = 'Web'; cls = 'mobile'; }
else if (s.includes('telegram') || s.includes('tg')) { label = 'Telegram'; cls = 'tg'; }
else if (s.includes('rss')) { label = 'RSS'; cls = 'rss'; }
else if (s.startsWith('ai-')) { label = 'AI'; cls = 'ai'; }
else if (s === 'unknown') { label = '?'; cls = ''; }
return (
<span className={`src-tag ${cls}`}>
<span className="src-dot" />
{label}
</span>
);
}
function RelTime({ when }: { when: number | string }) {
const d = toDate(when);
const now = Date.now();
const diff = now - d.getTime();
let text: string;
if (diff < 60_000) text = '剛才';
else if (diff < 3600_000) text = `${Math.floor(diff / 60_000)} 分鐘前`;
else if (diff < 86400_000) text = `${Math.floor(diff / 3600_000)} 小時前`;
else if (diff < 7 * 86400_000) text = `${Math.floor(diff / 86400_000)} 天前`;
else text = d.toLocaleDateString('zh-TW', { year: 'numeric', month: 'numeric', day: 'numeric' });
return <span className="mira-rel-time" title={d.toLocaleString('zh-TW')}>{text}</span>;
}
// 用 react-markdown 渲染(GFM 支援:表格、刪除線、task list、autolink
// + 預先 strip Logseq 專屬語法(屬性行、collapsed、id::、logseq.* 等)
// MarkdownView 與 stripLogseqMeta 已抽出至 _shared/markdown.tsx(給 wiki 頁面共用)
// ─── helpers ───────────────────────────────────────────────
function toDate(x: number | string): Date {
if (typeof x === 'number') return new Date(x * 1000);
const d = new Date(x);
return Number.isNaN(d.getTime()) ? new Date(0) : d;
}