'use client'; // Mira 對話核心元件(河道右側 dock 與 /mira/chat 單頁共用) // SDD: polaris/mira/.agents/specs/mira-app/design.md §3.6.5 // RAG:提問先語義搜尋 KBDB(wiki + 河道)取 context → claude-api daemon。 // 重要:context 空時 prompt 明確要求「只說沒有相關筆記」,避免 daemon 自由發揮(曾幻想 OpenWebUI) import { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { MarkdownView } from './markdown'; const KBDB_BASE = 'https://kbdb.finally.click'; const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev'; const CLAUDE_API = 'https://claude-api.arcrun.dev'; type Source = { label: string; href: string }; type Msg = { role: 'user' | 'mira'; text: string; sources?: Source[]; pending?: boolean }; type SearchMatch = { score: number; type: 'block' | 'triplet'; block: { id: string; page_name: string | null; content: string | null; type: string } | null; triplet: { subject?: string; predicate?: string; object?: string } | null; }; // 繁體異體字正規化(臺→台 等),讓 query 跟 KB 內容(多用「台」)對得上 function normalizeQuery(q: string): string { return q.replace(/臺/g, '台'); } async function fetchContext(apiKey: string, query: string): Promise<{ context: string; sources: Source[] }> { try { const res = await fetch(`${KBDB_BASE}/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, body: JSON.stringify({ query: normalizeQuery(query), type: 'semantic', topK: 8 }), }); if (!res.ok) return { context: '', sources: [] }; const data = (await res.json()) as { matches?: SearchMatch[] }; const parts: string[] = []; const sources: Source[] = []; for (const m of data.matches ?? []) { if (m.block?.content) { const b = m.block; parts.push(`### ${b.type}:${b.page_name ?? b.id}\n${b.content}`); if (b.type === 'wiki-page' && b.page_name) { sources.push({ label: `📚 ${(b.content || b.page_name).slice(0, 20)}`, href: `/mira/wiki/${encodeURIComponent(b.page_name)}` }); } else if (b.page_name) { sources.push({ label: `🌊 ${(b.content || '').slice(0, 20) || b.page_name}`, href: `/mira/feed#page=${encodeURIComponent(b.page_name)}` }); } } else if (m.triplet) { const t = m.triplet; parts.push(`關係:${t.subject} >> ${t.predicate} >> ${t.object}`); } } const seen = new Set(); const uniq = sources.filter(s => (seen.has(s.href) ? false : (seen.add(s.href), true))); return { context: parts.join('\n\n'), sources: uniq.slice(0, 5) }; } catch { return { context: '', sources: [] }; } } function buildPrompt(history: Msg[], context: string, question: string): string { const convo = history .filter(m => !m.pending) .map(m => `${m.role === 'user' ? 'leo' : 'Mira'}:${m.text}`) .join('\n'); const persona = `你是 Mira,leo 的個人知識庫副駕 AI。你只能根據下方「知識庫」與「對話脈絡」回答,` + `沒有任何外部系統存取權(沒有 OpenWebUI、沒有檔案系統、沒有別的工具)。\n\n`; const kb = context ? `## 知識庫(跟本次提問相關的 wiki / 河道內容)\n\n${context}\n\n` : `## 知識庫\n(這次在 leo 的筆記裡找不到相關內容。)\n\n`; const rules = context ? `規則:繁體中文(台灣用語)、務實不客套、優先引用上方知識庫並說「你之前寫過⋯」、簡短切題。` : `規則:繁體中文。**明確告訴 leo「你的筆記裡目前沒有關於這個的內容」**,` + `可以再用常識補一兩句(要標明那不是來自他的筆記),不要假裝有資料、不要編造系統或工具。`; return ( persona + kb + (convo ? `## 對話脈絡\n${convo}\n\n` : '') + `---\n\nleo 現在問:「${question}」\n\n${rules}` ); } export default function MiraChat({ compact = false }: { compact?: boolean }) { const [apiKey, setApiKey] = useState(null); const [msgs, setMsgs] = useState([]); const [input, setInput] = useState(''); const [sending, setSending] = useState(false); const logRef = useRef(null); useEffect(() => { fetch(`${API_BASE}/me`, { credentials: 'include' }) .then(r => (r.ok ? r.json() : null)) .then((me: { api_key: string } | null) => { if (me?.api_key) setApiKey(me.api_key); }) .catch(() => {}); }, []); useEffect(() => { logRef.current?.scrollTo({ top: logRef.current.scrollHeight, behavior: 'smooth' }); }, [msgs]); const send = async () => { const question = input.trim(); if (!question || sending || !apiKey) return; setInput(''); setSending(true); const history = msgs; setMsgs(m => [...m, { role: 'user', text: question }, { role: 'mira', text: '', pending: true }]); try { const { context, sources } = await fetchContext(apiKey, question); const prompt = buildPrompt(history, context, question); const res = await fetch(CLAUDE_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, timeout_ms: 60000 }), }); const data = (await res.json()) as { success?: boolean; pending?: boolean; data?: { text?: string }; error?: string }; let text: string; if (!res.ok || !data.success) text = `(回答失敗:${data.error ?? res.status})`; else if (data.pending) text = '(Mira 還在想,daemon 切到背景模式,請稍後再問一次)'; else text = data.data?.text ?? '(沒有內容)'; setMsgs(m => { const next = [...m]; next[next.length - 1] = { role: 'mira', text, sources: sources.length ? sources : undefined }; return next; }); } catch (e) { setMsgs(m => { const next = [...m]; next[next.length - 1] = { role: 'mira', text: `(錯誤:${e instanceof Error ? e.message : String(e)})` }; return next; }); } finally { setSending(false); } }; return (
{msgs.length === 0 && (
💬

問 Mira 任何事 — 它會先翻你的知識庫。

)} {msgs.map((m, i) => (
{m.pending ? ( Mira 思考中 ) : m.role === 'mira' ? ( ) : ( m.text )} {m.sources && m.sources.length > 0 && (
來源: {m.sources.map((s, j) => ( {j > 0 && ' · '} {s.label} ))}
)}
))}