5d00e71275
頂層 D22 決策(leo 2026-07-03 拍板):推什麼由開發環境歸屬決定, Gitea private=除機敏值/build 產物/.github 外全 push。 解 T1.5 卡點:雲端工人 clone 拿得到 credential-store-migration.md,可就地改寫 SDD。 機敏掃描兩輪通過(新增 189 檔約 2.1MB,node_modules/dist/wasm 照舊排除)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
200 lines
8.1 KiB
TypeScript
200 lines
8.1 KiB
TypeScript
'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<string>();
|
||
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<string | null>(null);
|
||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||
const [input, setInput] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
const logRef = useRef<HTMLDivElement>(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 (
|
||
<div className={`mira-chat${compact ? ' is-compact' : ''}`}>
|
||
<div className="mira-chat-log" ref={logRef}>
|
||
{msgs.length === 0 && (
|
||
<div className="empty-state" style={{ marginTop: 28 }}>
|
||
<div style={{ fontSize: 40, marginBottom: 8 }}>💬</div>
|
||
<p style={{ color: 'var(--mira-text-2)', fontSize: 13 }}>問 Mira 任何事 — 它會先翻你的知識庫。</p>
|
||
</div>
|
||
)}
|
||
{msgs.map((m, i) => (
|
||
<div key={i} className={`mira-chat-msg ${m.role === 'user' ? 'is-user' : 'is-mira'}`}>
|
||
<div className="mira-chat-bubble">
|
||
{m.pending ? (
|
||
<span className="mira-thinking-dots">Mira 思考中</span>
|
||
) : m.role === 'mira' ? (
|
||
<MarkdownView text={m.text} />
|
||
) : (
|
||
m.text
|
||
)}
|
||
{m.sources && m.sources.length > 0 && (
|
||
<div className="mira-chat-sources">
|
||
來源:
|
||
{m.sources.map((s, j) => (
|
||
<span key={j}>
|
||
{j > 0 && ' · '}
|
||
<Link href={s.href}>{s.label}</Link>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mira-chat-input-row">
|
||
<textarea
|
||
className="mira-chat-input"
|
||
value={input}
|
||
onChange={e => setInput(e.target.value)}
|
||
onKeyDown={e => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); void send(); }
|
||
}}
|
||
placeholder="問 Mira…(⌘+Enter 送出)"
|
||
rows={2}
|
||
disabled={sending || !apiKey}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="mira-btn-primary"
|
||
onClick={() => void send()}
|
||
disabled={sending || !apiKey || !input.trim()}
|
||
>
|
||
{sending ? '⋯' : '送出'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|