chore: D22 落地——docs/SDD/wiki/CLAUDE.md 進 repo(Gitea private 預設全 push)
頂層 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>
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user