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:
uncle6me-web
2026-07-03 07:13:15 +08:00
parent c830150da1
commit 5d00e71275
190 changed files with 39486 additions and 14 deletions
+199
View File
@@ -0,0 +1,199 @@
'use client';
// Mira 對話核心元件(河道右側 dock 與 /mira/chat 單頁共用)
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.6.5
// RAG:提問先語義搜尋 KBDBwiki + 河道)取 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>
);
}