Files
Arcrun/landing/app/mira/feed/page.tsx
T
Leo 660b32eafd feat(mira): 河道 → wiki 自動化(fire-and-forget 觸發 wiki_synthesis)
對應 polaris/mira/.agents/specs/mira-app/tasks.md 7B.3h(簡化版)。

原計畫用 arcrun cron 零件 → cypher-executor scheduled() handler,但發現
cron 零件只是 validator,cypher-executor 還沒實作 scheduled()。為了不擋
「河道書寫 → 自動產 wiki」這條 UX,先做 fire-and-forget 版本:

- 新 cypher-executor route POST /mira/wiki-from-raw
  - body: { raw_block_id }
  - server 端從 MIRA_CONFIG secret 補 partner key / mira_token / 三個 block IDs
  - waitUntil 背景跑 executeWebhookGraph,立刻回 202
- landing 河道 post composer 成功寫 raw 後 fire-and-forget triggerWikiSynthesis()
  跟既有 triggerAiReply() 同範式
- types.ts 加 MIRA_CONFIG?: string

部署後需手動:
  echo '{"service_api_key":"ak_...","data_api_key":"ak_...","schema_block_id":"...","skill_block_id":"...","entities_block_id":"...","mira_token":"..."}' \
    | wrangler secret put MIRA_CONFIG

UX:河道貼一則 → AI reply 30s 內 → wiki 60-90s 內出現在 /mira/wiki。

arcrun.md 記 P1 #3:cypher-executor 加 scheduled() handler,那是真正的
cron 路線,封測前不擋。
2026-05-14 13:50:13 +08:00

1487 lines
49 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)──────────────────────
async function triggerWikiSynthesis(opts: { rawBlockId: string }) {
// 對應 cypher-executor routes/mira.ts POST /mira/wiki-from-raw
// server 端從 MIRA_CONFIG secret 補齊所有 partner key / token / block IDs
// workflow 跑 60-90s,這裡 fire-and-forget 不等結果(拿 202 立刻回)
try {
const res = await fetch(`${API_BASE}/mira/wiki-from-raw`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ raw_block_id: opts.rawBlockId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.warn('[mira wiki-from-raw] not triggered:', res.status, data);
return;
}
const data = await res.json().catch(() => ({}));
console.log('[mira wiki-from-raw] accepted:', data);
} catch (e) {
console.warn('[mira wiki-from-raw] error:', e);
}
}
async function triggerAiReply(opts: {
apiKey: string;
postContent: string;
parentBlockId: string;
pageName: string;
}) {
const prompt =
`用戶 leo 在 mira 河道發了這則貼文:\n\n` +
`「${opts.postContent}\n\n` +
`請以 Mira 副駕 AI 的身份留言回應。\n` +
`規則:\n` +
`- 繁體中文(台灣用語)\n` +
`- 簡短 1-3 段,務實,不客套\n` +
`- 可以發問、補充、提建議、或反問釐清\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 taRef = 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('');
// fire-and-forget 觸發 Mira AI 回覆(不擋用戶)
void triggerAiReply({
apiKey: me.api_key,
postContent: trimmed,
parentBlockId: postBlockId,
pageName,
});
// fire-and-forget 觸發 wiki_synthesis7B.3h 簡化版:從 frontend 直接觸發,不走 cron
// 對應 routes/mira.tsserver 端從 MIRA_CONFIG secret 補齊 token / block IDs
void triggerWikiSynthesis({ rawBlockId: postBlockId });
onAiTriggered(pageName);
// 給 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="現在想分享什麼?"
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>
);
}
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>
);
}
// ─── ⋮ 選單 ──────────────────────────────────────────────
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);
}, []);
return (
<article className="mira-card mira-post" ref={cardRef}>
{/* 頭部:作者區塊 */}
<header className="mira-post-header">
<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-time">
<SourceBadge source={inferSource(blocks, doc.page_name)} />
<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);
} 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="編輯內容⋯"
/>
);
}
// 完整內容(每個 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);
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),
});
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);
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),
});
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);
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">
{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>
</>
);
}
// 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;
}