'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(null); const [docs, setDocs] = useState(null); const [error, setError] = useState(null); // page_name → "thinking" / null:哪些 doc 在等 AI 回覆 const [aiThinking, setAiThinking] = useState>(new Set()); const meRef = useRef(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 (
{me && ( { setAiThinking(prev => new Set(prev).add(pageName)); // 90 秒後 hard timeout(Mira 思考 + 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 && (
{error}
)} {!error && docs === null &&
載入中…
} {!error && docs !== null && docs.length === 0 && me && ( )} {!error && docs && docs.length > 0 && me && ( { setAiThinking(prev => { if (!prev.has(pageName)) return prev; const n = new Set(prev); n.delete(pageName); return n; }); }} /> )}
); } // ─── AI 回覆觸發器(fire-and-forget)────────────────────── 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; onAiTriggered: (pageName: string) => void; }) { const [text, setText] = useState(''); const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(null); const taRef = useRef(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, }); // 7B.3h:wiki_synthesis 由 arcrun cron-triggered workflow `mira_feed_watcher` // 自動處理(每分鐘掃未處理 raw block),不需前端觸發。 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 (