Files
Arcrun/landing/app/mira/feed/page.tsx
T
Leo 3de4cff014 fix(mira/feed): soften wiki status badge — 30min 不算漏了
leo 反饋:『現在出現大量的漏了,是不是出錯?』
調查:watcher 一直在跑,但 cron 5min 一輪 × 一輪最多 20 raws,
21 個剛清的假處理 + 14 個歷史舊 raw 排隊處理需要多輪 cron。

舊邏輯 30 min 就標 ⚠️ 漏了 太兇,給用戶『系統壞了』錯覺。
新邏輯:
  - < 6 min:  處理中
  - 6 min ~ 6h: ○ 等待(含 ↻ 立即優先)   ← 新增
  - > 6h: ⚠️ 漏了 ↻ (跟 wiki_giveup_scanner 6h 閾值對齊)
  - wiki-give-up tag: 🚫 放棄 ↻

對齊 scanner 邏輯:6h 前 watcher 仍會自動重試,這時間內顯示等待。
過 6h 才算真卡。
2026-05-17 19:22:21 +08:00

2236 lines
79 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, createContext, useContext } from 'react';
import { MarkdownView } from '../_shared/markdown';
import '../mira.css';
// Context:把「mira 已觸發要顯示 thinking + 啟動 polling」設定 callback 傳到深層 reply / edit 元件
// 不用 prop drill。對應 P0 #5b bug fixleo 反饋:reply 中 @mira 沒看到 indicator → 以為失敗)
const MiraTriggerContext = createContext<((pageName: string) => void) | null>(null);
function useMiraTrigger() {
return useContext(MiraTriggerContext);
}
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; };
}, []);
// 把 thinking trigger 抽成共用 callbackPostComposer 跟所有 reply / edit 子元件用同一份)
const triggerThinking = useCallback((pageName: string) => {
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);
}, []);
return (
<MiraTriggerContext.Provider value={triggerThinking}>
<div className="mira-app">
<div className="mira-content">
{me && (
<PostComposer
me={me}
onPosted={reload}
onAiTriggered={triggerThinking}
/>
)}
{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>
</MiraTriggerContext.Provider>
);
}
// ─── AI 回覆觸發器(fire-and-forget)──────────────────────
// P0 #5b:偵測文字含 @miramention) — 大小寫不限,前後可有標點
// 用法:leo 在貼文 / 留言寫 `@mira <主題>` → 觸發 mira reply
function hasMiraMention(text: string): boolean {
return /(^|[^\w])@mira\b/i.test(text);
}
// ── Backlog #12[[entity]] 統一引用 syntax ──────────────────────────────────
// design.md §3.6.2 ZK 進化版(防雙胞胎)
// 抽 [[X]] 出來
function parseWikilinks(text: string): string[] {
const matches = text.matchAll(/\[\[([^\[\]\n]+?)\]\]/g);
const out: string[] = [];
for (const m of matches) {
const entity = m[1].trim();
if (entity) out.push(entity);
}
return Array.from(new Set(out)); // unique
}
// 撈所有既存 entity 名稱(從 wiki-page + index-entry 合集)
async function fetchAllEntityNames(apiKey: string): Promise<Set<string>> {
try {
const headers = { Authorization: `Bearer ${apiKey}` };
const [idxRes, pageRes] = await Promise.all([
fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, { headers }),
fetch(`${KBDB_BASE}/blocks?type=wiki-page&source=ai-canon-wiki&limit=200`, { headers }),
]);
type B = { page_name: string | null; content: string };
const idxData = idxRes.ok ? await idxRes.json() as { blocks?: B[] } : { blocks: [] };
const pageData = pageRes.ok ? await pageRes.json() as { blocks?: B[] } : { blocks: [] };
const set = new Set<string>();
for (const b of (idxData.blocks ?? [])) {
const e = (b.page_name ?? '').replace(/^index-/, '').trim();
if (e) set.add(e);
}
for (const b of (pageData.blocks ?? [])) {
const e = (b.content ?? '').trim();
if (e) set.add(e);
}
return set;
} catch {
return new Set();
}
}
// Cachedebounce 短 TTL5s),同時 autocomplete 開啟時主動 refetch
// 30s 太久:wiki_synthesis 後台跑出新 entity(如 Claude 自己命名的「李飛飛的視界之旅」)autocomplete 撈不到
let cachedEntityNames: Set<string> | null = null;
let cachedEntityFetchedAt = 0;
async function getEntityNamesCached(apiKey: string): Promise<Set<string>> {
const now = Date.now();
if (cachedEntityNames && now - cachedEntityFetchedAt < 5_000) return cachedEntityNames;
cachedEntityNames = await fetchAllEntityNames(apiKey);
cachedEntityFetchedAt = now;
return cachedEntityNames;
}
function invalidateEntityCache() {
cachedEntityNames = null;
cachedEntityFetchedAt = 0;
}
// 簡單 type 推測:《X》 → 書;http(s)://X → URL;其他 → 概念
function guessEntityType(name: string): 'book' | 'url' | 'concept' {
if (/^《.*》$|《[^》]+》/.test(name)) return 'book';
if (/^https?:\/\//.test(name)) return 'url';
return 'concept';
}
// 確保 [[X]] 命中的 entity 都存在 KBDB(不存在則新建 wiki-page 占位)
// 後續 wiki_synthesis 會把該 entity 的 raw 寫進 paragraphs
// 對應 leo 期望:寫 [[X]] 立刻建檔(Logseq 行為)
async function ensureEntitiesExist(apiKey: string, entities: string[]): Promise<void> {
if (entities.length === 0) return;
const existing = await fetchAllEntityNames(apiKey);
const toCreate = entities.filter(e => !existing.has(e));
if (toCreate.length === 0) return;
for (const entity of toCreate) {
const type = guessEntityType(entity);
const tags = ['mira-wiki', 'leo-explicit', `entity-type:${type}`];
try {
await fetch(KBDB_CREATE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: apiKey,
content: entity,
type: 'wiki-page',
page_name: `wiki-${entity}`,
source: 'leo-explicit',
user_id: 'inkstone_mira_post',
tags_json: JSON.stringify(tags),
}),
});
console.log(`[wikilink] created entity placeholder: ${entity} (type=${type})`);
} catch (e) {
console.warn(`[wikilink] create failed: ${entity}`, e);
}
}
}
// 撈相關 wiki context 給 mira reply 用(簡單 RAG
// 策略:拿全部 index-entry + wiki-page,雙向 substring match against post content
// → 取 top 5 餵進 prompt
async function fetchRelevantWikiContext(apiKey: string, postContent: string): Promise<string> {
try {
const headers = { Authorization: `Bearer ${apiKey}` };
// 平行撈 index-entry(含 markdown 摘要)跟 wiki-pagecontent 是 entity 名稱)
const [idxRes, pageRes] = await Promise.all([
fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=200`, { headers }),
fetch(`${KBDB_BASE}/blocks?type=wiki-page&source=ai-canon-wiki&limit=200`, { headers }),
]);
type B = { id: string; page_name: string | null; content: string };
const idxData = idxRes.ok ? await idxRes.json() as { blocks?: B[] } : { blocks: [] };
const pageData = pageRes.ok ? await pageRes.json() as { blocks?: B[] } : { blocks: [] };
// 收集 unique entity 名稱(從 index-entry page_name 跟 wiki-page content+ 對應 markdown
const entityMap = new Map<string, string>(); // entity name -> 顯示用 markdown context
for (const b of (idxData.blocks ?? [])) {
const entity = (b.page_name ?? '').replace(/^index-/, '').trim();
if (entity && !entityMap.has(entity)) entityMap.set(entity, b.content);
}
for (const b of (pageData.blocks ?? [])) {
const entity = (b.content ?? '').trim();
if (entity && !entityMap.has(entity)) {
// wiki-page content 只有 entity 名稱,沒摘要 — 給最簡 placeholder
entityMap.set(entity, `(已建 wiki-page,但尚無 index-entry 摘要)`);
}
}
if (entityMap.size === 0) return '';
// 雙向 substring match
// - entity 名稱 substring of postContent,或
// - postContent 中任 4+ 字 sliding window substring of entity(中文場景容忍)
const lower = postContent.toLowerCase();
const scored: Array<{ entity: string; content: string; score: number }> = [];
for (const [entity, content] of entityMap) {
const e = entity.toLowerCase();
let score = 0;
// 直接 substring(嚴格)
if (lower.includes(e)) score = e.length * 2;
// 較寬鬆:entity 內 ≥ 3 字片段在 postContent 出現
else if (e.length >= 3) {
for (let i = 0; i + 3 <= e.length; i++) {
const frag = e.slice(i, i + Math.min(4, e.length - i));
if (frag.length >= 3 && lower.includes(frag)) {
score = Math.max(score, frag.length);
break;
}
}
}
if (score > 0) scored.push({ entity, content, score });
}
scored.sort((a, b) => b.score - a.score);
const top = scored.slice(0, 5);
if (top.length === 0) return '';
return top.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);
}
// Backlog #12[[X]] 顯式建檔 — 解析貼文 wikilinks,不存在的 entity 立刻建 wiki-page placeholder
const wikilinks = parseWikilinks(trimmed);
if (wikilinks.length > 0) {
void ensureEntitiesExist(me.api_key, wikilinks);
}
// 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 回覆 · [[X]] 引用 entity"
rows={2}
disabled={submitting}
className="mira-composer-textarea"
/>
<WikilinkAutocomplete textareaRef={taRef} value={text} setValue={setText} apiKey={me.api_key} />
</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 發布、[[X]] 引用 entity"
disabled={submitting}
className="mira-composer-popup-textarea"
/>
<WikilinkAutocomplete textareaRef={popupTaRef} value={text} setValue={setText} apiKey={me.api_key} />
<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>
);
}
// ── WikilinkAutocomplete ─────────────────────────────────────────────────────
// 偵測 textarea 內 cursor 位置左側是否有未閉合的 [[query
// 若是 → 顯示下拉清單(既存 entity + 「⊕ 建立 [[query]]」option
// 對應 tasks.md backlog #12 / design.md §3.6.2
function WikilinkAutocomplete({
textareaRef, value, setValue, apiKey,
}: {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
value: string;
setValue: (v: string) => void;
apiKey: string;
}) {
const [entities, setEntities] = useState<string[]>([]);
const [matchInfo, setMatchInfo] = useState<{ start: number; query: string } | null>(null);
const [selectedIdx, setSelectedIdx] = useState(0);
// 載入 entity 清單(5s cache + 每次 `[[` 開啟時 refetch 保持新鮮)
useEffect(() => {
getEntityNamesCached(apiKey).then(set => setEntities(Array.from(set).sort()));
}, [apiKey]);
// matchInfo 從 null → 有值 = 剛打開 autocomplete → 重抓
useEffect(() => {
if (!matchInfo) return;
invalidateEntityCache();
getEntityNamesCached(apiKey).then(set => setEntities(Array.from(set).sort()));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchInfo !== null, apiKey]);
// 監聽 textarea 變化 / cursor 移動 → 重算 matchInfo
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
const update = () => {
const cursorPos = ta.selectionStart;
// 從 cursor 往前找 `[[`,且該 `[[` 之後到 cursor 之間沒有 `]]` 或換行
const before = value.slice(0, cursorPos);
const lastOpen = before.lastIndexOf('[[');
if (lastOpen === -1) { setMatchInfo(null); return; }
const inside = before.slice(lastOpen + 2);
if (inside.includes(']]') || inside.includes('\n')) { setMatchInfo(null); return; }
setMatchInfo({ start: lastOpen, query: inside });
setSelectedIdx(0);
};
update();
ta.addEventListener('keyup', update);
ta.addEventListener('click', update);
ta.addEventListener('input', update);
return () => {
ta.removeEventListener('keyup', update);
ta.removeEventListener('click', update);
ta.removeEventListener('input', update);
};
}, [textareaRef, value]);
// filter 既存 entity by querysubstring + 寬鬆 — 包含中文字片段匹配)
const filtered = useMemo(() => {
if (!matchInfo) return [];
const q = matchInfo.query.toLowerCase();
if (!q) return entities.slice(0, 8);
return entities
.filter(e => e.toLowerCase().includes(q))
.slice(0, 8);
}, [matchInfo, entities]);
// create-new option(總顯示,當有 query 且 query 不完全等於既存 entity 時)
const showCreate = !!matchInfo && matchInfo.query.trim() !== '' &&
!entities.some(e => e.toLowerCase() === matchInfo.query.toLowerCase());
const items = useMemo(() => {
if (!matchInfo) return [] as Array<{ kind: 'create' | 'existing'; label: string; entity: string }>;
const list: Array<{ kind: 'create' | 'existing'; label: string; entity: string }> = [];
if (showCreate) list.push({ kind: 'create', label: `⊕ 建立 [[${matchInfo.query}]]`, entity: matchInfo.query });
for (const e of filtered) list.push({ kind: 'existing', label: e, entity: e });
return list;
}, [matchInfo, filtered, showCreate]);
// keyboard handler(綁在 textarea 的 onKeyDown,但需要 capture 階段;改用全域 listener while open
useEffect(() => {
if (!matchInfo || items.length === 0) return;
const ta = textareaRef.current;
if (!ta) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIdx(i => (i + 1) % items.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIdx(i => (i - 1 + items.length) % items.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
commitSelection(items[selectedIdx]);
} else if (e.key === 'Escape') {
e.preventDefault();
setMatchInfo(null);
}
};
ta.addEventListener('keydown', handler);
return () => ta.removeEventListener('keydown', handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchInfo, items, selectedIdx, textareaRef]);
function commitSelection(item: { entity: string }) {
if (!matchInfo) return;
const before = value.slice(0, matchInfo.start);
const after = value.slice(matchInfo.start + 2 + matchInfo.query.length);
const inserted = `[[${item.entity}]]`;
const newValue = before + inserted + after;
setValue(newValue);
setMatchInfo(null);
// refresh cache 以納入新建 entity(但其實要等 ensureEntitiesExist 跑完才會在 KBDB;先 invalidate cache 就夠)
cachedEntityNames = null;
// 下個 tick 把 cursor 移到 ]] 之後
setTimeout(() => {
const ta = textareaRef.current;
if (ta) {
const pos = before.length + inserted.length;
ta.selectionStart = ta.selectionEnd = pos;
ta.focus();
}
}, 0);
}
if (!matchInfo || items.length === 0) return null;
// 位置:textarea 下方
const ta = textareaRef.current;
const rect = ta?.getBoundingClientRect();
if (!rect) return null;
return (
<div
className="mira-wikilink-popover"
style={{
position: 'fixed',
top: rect.bottom + 4,
left: rect.left,
minWidth: Math.max(220, rect.width / 2),
maxWidth: 380,
background: 'var(--mira-bg-2)',
border: '1px solid var(--mira-line)',
borderRadius: 6,
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
zIndex: 1100,
maxHeight: 280,
overflowY: 'auto',
}}
>
{items.map((it, i) => (
<div
key={`${it.kind}-${it.entity}`}
onClick={() => commitSelection(it)}
onMouseEnter={() => setSelectedIdx(i)}
style={{
padding: '8px 12px',
cursor: 'pointer',
background: i === selectedIdx ? 'var(--mira-bg-3)' : 'transparent',
color: it.kind === 'create' ? 'var(--mira-accent)' : 'var(--mira-text-1)',
fontSize: 14,
borderBottom: i < items.length - 1 ? '1px solid var(--mira-line-soft)' : 'none',
}}
>
{it.label}
</div>
))}
<div style={{ padding: '6px 12px', fontSize: 11, color: 'var(--mira-text-3)', borderTop: '1px solid var(--mira-line-soft)' }}>
↑↓ 選擇 · Enter / Tab 確認 · Esc 取消
</div>
</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} />
{(() => {
const badge = (
<WikiStatusBadge
mainBlock={mainBlocksList[0]}
createdAt={doc.created_at}
showMira={showMira}
apiKey={me.api_key}
/>
);
return !showMira && mainBlocksList[0]
? (<><span>·</span>{badge}</>)
: null;
})()}
</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 triggerThinking = useMiraTrigger();
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,
});
triggerThinking?.(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 回覆 · [[X]] 引用 entity"
apiKey={apiKey}
/>
);
}
// 完整內容(每個 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 triggerThinking = useMiraTrigger();
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,
});
triggerThinking?.(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,
});
triggerThinking?.(docPageName);
}
// Backlog #12: reply 含 [[X]] 也建檔
const wikilinks = parseWikilinks(trimmed);
if (wikilinks.length > 0) void ensureEntitiesExist(apiKey, wikilinks);
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="儲存" apiKey={apiKey} />
)}
{mode === 'reply' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆⋯⋯(@mira 呼叫 Mira · [[X]] 引用)" apiKey={apiKey} />
)}
</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 triggerThinking = useMiraTrigger();
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,
});
triggerThinking?.(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,
});
triggerThinking?.(docPageName);
}
// Backlog #12: reply 含 [[X]] 也建檔
const wikilinks = parseWikilinks(trimmed);
if (wikilinks.length > 0) void ensureEntitiesExist(apiKey, wikilinks);
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="儲存" apiKey={apiKey} />
)}
{mode === 'reply' && (
<EditingArea draft={draft} setDraft={setDraft} onSubmit={submitReply} onCancel={cancel} saving={saving} err={err} submitLabel="回覆" placeholder="回覆這則留言⋯⋯(@mira 呼叫 Mira · [[X]] 引用)" apiKey={apiKey} />
)}
{/* 巢狀留言 */}
{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="寫留言⋯⋯(@mira 呼叫 Mira · [[X]] 引用)" apiKey={apiKey} />
</div>
);
}
function EditingArea({
draft, setDraft, onSubmit, onCancel, saving, err, submitLabel, placeholder, apiKey,
}: {
draft: string;
setDraft: (s: string) => void;
onSubmit: () => void;
onCancel: () => void;
saving: boolean;
err: string | null;
submitLabel: string;
placeholder?: string;
apiKey?: string; // 給 WikilinkAutocomplete 抓 entity 清單用;不傳則 autocomplete 不生效
}) {
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"
/>
{apiKey && <WikilinkAutocomplete textareaRef={taRef} value={draft} setValue={setDraft} apiKey={apiKey} />}
<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 送出 · [[X]] 引用 entity'}
disabled={saving}
className="mira-composer-popup-textarea"
/>
{apiKey && <WikilinkAutocomplete textareaRef={popupTaRef} value={draft} setValue={setDraft} apiKey={apiKey} />}
<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>
);
}
// WikiStatusBadge:顯示這篇 raw 是否已合成 wiki + 「漏了」手動重試按鈕
// 對應 leo 2026-05-17 反饋:「漏了的要怎麼做?會自己慢慢完成還是要手動下令?」
//
// 自動行為:mira_feed_watcher cron 每 5 min 掃 tags=[] 的 raw,會無限重試
// 手動入口:⚠️ 漏了 變按鈕,點擊 = 清 tag + 立即 trigger wiki_synthesis(不等 cron
function WikiStatusBadge({
mainBlock,
createdAt,
showMira,
apiKey,
}: {
mainBlock: KBDBBlock | undefined;
createdAt: number | string;
showMira: boolean;
apiKey: string;
}) {
const [retrying, setRetrying] = useState(false);
const [retriedAt, setRetriedAt] = useState<number | null>(null);
if (showMira) return null;
if (!mainBlock) return null;
let processed = false;
let givenUp = false;
try {
const tags = JSON.parse(mainBlock.tags_json || '[]') as string[];
if (Array.isArray(tags)) {
processed = tags.includes('wiki-processed');
givenUp = tags.includes('wiki-give-up');
}
} catch {
// ignore
}
const ageMs = Date.now() - toDate(createdAt).getTime();
const minutes = ageMs / 60_000;
const recentlyRetried = retriedAt && Date.now() - retriedAt < 60_000;
// 手動重試:清 wiki-processed tag(保險:watcher 才會重撈)+ 立即 trigger wiki_synthesis
const handleRetry = useCallback(async () => {
if (retrying) return;
setRetrying(true);
try {
await fetch(`${KBDB_BASE}/blocks/${mainBlock.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: [] }),
}).catch(() => null);
await fetch(`${API_BASE}/webhooks/named/wiki_synthesis/trigger`, {
method: 'POST',
headers: { 'X-Arcrun-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKey, raw_block_id: mainBlock.id }),
}).catch(() => null);
setRetriedAt(Date.now());
} finally {
setRetrying(false);
}
}, [retrying, mainBlock?.id, apiKey]);
if (processed) {
return (
<span
title="已合成 wiki — 若實際看不到 entity 可能是 fan-out 假處理,按 ↻ 重試"
style={{ color: '#3a8a3a', fontSize: '0.85em', display: 'inline-flex', gap: 4, alignItems: 'center' }}
>
wiki
<button
onClick={handleRetry}
disabled={retrying || !!recentlyRetried}
title="重跑(適用 tag 已標但實際沒 wiki-page 的情況)"
style={{
background: 'transparent', border: 'none', color: '#888',
fontSize: '0.85em', padding: 0, cursor: 'pointer',
}}
>
{recentlyRetried ? '已重試' : (retrying ? '…' : '↻')}
</button>
</span>
);
}
// wiki_giveup_scanner 標的:已試多輪 cron 仍失敗,停止重試
if (givenUp) {
return (
<button
onClick={handleRetry}
disabled={retrying || !!recentlyRetried}
title="跑了 6h+ 仍沒 wiki,已給棄停止 watcher 重試。點擊強制重試一次(也許 wiki_synthesis 已修復)"
style={{
background: 'transparent', border: '1px solid #944', color: '#a55',
fontSize: '0.85em', padding: '0 6px', borderRadius: 4, cursor: 'pointer',
}}
>
{recentlyRetried ? '⏳ 已重試' : (retrying ? '…' : '🚫 放棄 ↻')}
</button>
);
}
if (recentlyRetried) {
return <span style={{ color: '#888', fontSize: '0.85em' }}> 重試中</span>;
}
if (minutes < 6) {
return (
<span title="處理中(mira_feed_watcher 每 5 分鐘掃一次)" style={{ color: '#888', fontSize: '0.85em' }}>
處理中
</span>
);
}
// 6min ~ 6hwatcher 仍會持續重試(一輪 20 個,多 raws 時要排幾輪)
// 顯示 ○ 等待 + 可選 ↻ 強制立即重試(不等下個 cron tick)
if (minutes < 360) {
const ageText = minutes < 60
? `${Math.floor(minutes)} 分鐘`
: `${Math.floor(minutes / 60)} 小時`;
return (
<span
title={`已等 ${ageText} — watcher cron 5min 一輪,多筆排隊時要多輪。點 ↻ 立即優先處理`}
style={{ color: '#aaa', fontSize: '0.85em', display: 'inline-flex', gap: 4, alignItems: 'center' }}
>
等待
<button
onClick={handleRetry}
disabled={retrying}
style={{
background: 'transparent', border: 'none', color: '#888',
fontSize: '0.85em', padding: 0, cursor: 'pointer',
}}
>
{retrying ? '…' : '↻'}
</button>
</span>
);
}
// > 6hscanner 即將(或已)標 wiki-give-up,可確認真卡了
return (
<button
onClick={handleRetry}
disabled={retrying}
title={`已 ${Math.floor(minutes / 60)} 小時未處理 — cron 試過很多次都失敗。點擊強制重試`}
style={{
background: 'transparent', border: '1px solid #c66', color: '#c66',
fontSize: '0.85em', padding: '0 6px', borderRadius: 4, cursor: 'pointer',
}}
>
{retrying ? '…' : '⚠️ 漏了 ↻'}
</button>
);
}
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;
}