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,307 @@
|
||||
'use client';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
// Mira 搜尋頁 — Karpathy index pattern 為 primary(leo 2026-05-23)
|
||||
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4「Karpathy index pattern(不用 vector embedding)」
|
||||
// 三層(C 混合):
|
||||
// 1. Index 即時文字比對:掃 index-entry(entity 名 + 摘要)子字串命中 → 列 entity(零 token)
|
||||
// 2. LLM 路由(選用):整個 index 餵 Claude,問「leo 想找哪些 entity」→ 最貼 Karpathy 本意
|
||||
// 3. 向量兜底(折疊):KBDB /search semantic,SDD 明文「不是 primary,當保險」
|
||||
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { MarkdownView } from '../_shared/markdown';
|
||||
import '../mira.css';
|
||||
|
||||
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';
|
||||
|
||||
// 繁體異體字正規化(臺→台),讓 query 對得上多用「台」的 KB 內容
|
||||
function normalizeQuery(q: string): string {
|
||||
return q.replace(/臺/g, '台');
|
||||
}
|
||||
|
||||
type IndexEntry = {
|
||||
entity: string; // H1 / page_name 去 index- 前綴
|
||||
pageName: string; // index-entry 自己的 page_name(index-{entity})
|
||||
oneLiner: string; // 「一句話定義」
|
||||
outline: string; // facet outline 全文(拿來比對 + 餵 LLM)
|
||||
raw: string; // 完整 content(餵 LLM 用,截斷)
|
||||
};
|
||||
|
||||
// 解析 index-entry markdown → 結構
|
||||
function parseIndexEntry(content: string, pageName: string): IndexEntry {
|
||||
const entity = (content.match(/^#\s+(.+)$/m)?.[1] ?? pageName.replace(/^index-/, '')).trim();
|
||||
const oneLiner = (content.match(/##\s*一句話定義\s*\n+([^\n#]+)/)?.[1] ?? '').trim();
|
||||
const outlineMatch = content.match(/##\s*段落 outline[^\n]*\n([\s\S]*?)(?=\n##|$)/);
|
||||
const outline = (outlineMatch?.[1] ?? '').trim();
|
||||
return { entity, pageName, oneLiner, outline, raw: content.slice(0, 700) };
|
||||
}
|
||||
|
||||
// entity 名 → wiki page 路由(wiki-{entity})
|
||||
function wikiHref(entity: string): string {
|
||||
return `/mira/wiki/${encodeURIComponent('wiki-' + entity)}`;
|
||||
}
|
||||
|
||||
// ── 向量兜底型別 ──
|
||||
type SearchMatch = {
|
||||
score: number;
|
||||
type: 'block' | 'triplet';
|
||||
metadata?: { entity?: string;[k: string]: unknown };
|
||||
block: { id: string; page_name: string | null; content: string | null; type: string; source: string | null } | null;
|
||||
triplet: { id: string; subject?: string; predicate?: string; object?: string } | null;
|
||||
};
|
||||
|
||||
function SearchInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const initialQ = params.get('q') ?? '';
|
||||
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [input, setInput] = useState(initialQ);
|
||||
const [query, setQuery] = useState(initialQ.trim());
|
||||
const [index, setIndex] = useState<IndexEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// LLM 路由結果
|
||||
const [llmEntities, setLlmEntities] = useState<{ entity: string; reason: string }[] | null>(null);
|
||||
const [llmLoading, setLlmLoading] = useState(false);
|
||||
|
||||
// 向量兜底
|
||||
const [vecMatches, setVecMatches] = useState<SearchMatch[] | null>(null);
|
||||
const [vecLoading, setVecLoading] = useState(false);
|
||||
const [vecOpen, setVecOpen] = useState(false);
|
||||
|
||||
// 載入:me + 全部 index-entry
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
|
||||
if (!meRes.ok) { window.location.href = '/login?redirect=/mira/search'; return; }
|
||||
const me = (await meRes.json()) as { api_key: string };
|
||||
setApiKey(me.api_key);
|
||||
const r = await fetch(`${KBDB_BASE}/blocks?type=index-entry&limit=300`, {
|
||||
headers: { Authorization: `Bearer ${me.api_key}` },
|
||||
});
|
||||
if (!r.ok) { setError(`index 讀取失敗:${r.status}`); return; }
|
||||
const data = (await r.json()) as { blocks?: { content: string; page_name: string }[] };
|
||||
setIndex((data.blocks ?? []).map(b => parseIndexEntry(b.content || '', b.page_name || '')));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 即時 index 文字比對(純 client,零 token)
|
||||
const indexHits = useMemo(() => {
|
||||
if (!index || !query) return [];
|
||||
const q = normalizeQuery(query).toLowerCase();
|
||||
const terms = q.split(/\s+/).filter(Boolean);
|
||||
const scored = index
|
||||
.map(e => {
|
||||
const hay = normalizeQuery(`${e.entity}\n${e.oneLiner}\n${e.outline}`).toLowerCase();
|
||||
let score = 0;
|
||||
for (const t of terms) {
|
||||
if (e.entity.toLowerCase().includes(t)) score += 10; // entity 名命中權重高
|
||||
else if (hay.includes(t)) score += 3;
|
||||
}
|
||||
return { e, score };
|
||||
})
|
||||
.filter(x => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 12);
|
||||
return scored.map(x => x.e);
|
||||
}, [index, query]);
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = input.trim();
|
||||
setQuery(q);
|
||||
setLlmEntities(null);
|
||||
setVecMatches(null);
|
||||
setVecOpen(false);
|
||||
router.replace(`/mira/search?q=${encodeURIComponent(q)}`);
|
||||
};
|
||||
|
||||
// LLM 路由:整個 index 餵 Claude
|
||||
const runLlmRoute = useCallback(async () => {
|
||||
if (!index || !query || llmLoading) return;
|
||||
setLlmLoading(true);
|
||||
setLlmEntities(null);
|
||||
try {
|
||||
const indexDigest = index
|
||||
.map(e => `- ${e.entity}:${e.oneLiner || '(無摘要)'}`)
|
||||
.join('\n');
|
||||
const prompt =
|
||||
`你是 leo 知識庫的索引導航員。以下是所有 wiki entity 的索引(entity:一句話定義):\n\n` +
|
||||
`${indexDigest}\n\n---\n\n` +
|
||||
`leo 想找:「${query}」\n\n` +
|
||||
`請從上面索引挑出最相關的 entity(最多 6 個,可能 0 個)。` +
|
||||
`只輸出 JSON 陣列,格式 [{"entity":"<完全照抄索引裡的名稱>","reason":"<為何相關,20字內>"}],不要其他文字。`;
|
||||
const res = await fetch(CLAUDE_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, timeout_ms: 45000 }),
|
||||
});
|
||||
const data = (await res.json()) as { success?: boolean; data?: { text?: string } };
|
||||
const text = data.data?.text ?? '';
|
||||
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
||||
const parsed = jsonMatch ? JSON.parse(jsonMatch[0]) as { entity: string; reason: string }[] : [];
|
||||
// 只留實際存在於 index 的 entity
|
||||
const known = new Set(index.map(e => e.entity));
|
||||
setLlmEntities(parsed.filter(p => known.has(p.entity)));
|
||||
} catch {
|
||||
setLlmEntities([]);
|
||||
} finally {
|
||||
setLlmLoading(false);
|
||||
}
|
||||
}, [index, query, llmLoading]);
|
||||
|
||||
// 向量兜底
|
||||
const runVecSearch = useCallback(async () => {
|
||||
if (!apiKey || !query || vecLoading) return;
|
||||
setVecOpen(true);
|
||||
setVecLoading(true);
|
||||
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: 12 }),
|
||||
});
|
||||
const data = (await res.json()) as { matches?: SearchMatch[] };
|
||||
setVecMatches(data.matches ?? []);
|
||||
} catch {
|
||||
setVecMatches([]);
|
||||
} finally {
|
||||
setVecLoading(false);
|
||||
}
|
||||
}, [apiKey, query, vecLoading]);
|
||||
|
||||
return (
|
||||
<main className="mira-page">
|
||||
<div className="mira-content">
|
||||
<header style={{ padding: '24px 0 8px' }}>
|
||||
<Link href="/mira/feed" style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}>← 河道</Link>
|
||||
<h1 style={{ fontSize: 26, fontWeight: 700, color: '#fff', margin: '8px 0 0' }}>🔍 Wiki 搜尋</h1>
|
||||
<p style={{ color: '#888', fontSize: 12, marginTop: 4 }}>
|
||||
從 {index?.length ?? '…'} 個 wiki 主題的索引找(Karpathy index)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="mira-search-form" onSubmit={submit}>
|
||||
<input
|
||||
className="mira-search-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
placeholder="找主題(例:特化、台北大學、本地模型)"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="mira-btn-primary" disabled={!index}>搜尋</button>
|
||||
</form>
|
||||
|
||||
{error && <div className="mira-error" style={{ marginBottom: 10 }}>{error}</div>}
|
||||
{!index && !error && <div className="empty-state">載入索引中…</div>}
|
||||
|
||||
{index && query && (
|
||||
<>
|
||||
{/* 第 1 層:index 即時命中 */}
|
||||
<section style={{ marginTop: 8 }}>
|
||||
<div className="mira-search-section-head">📇 索引命中({indexHits.length})</div>
|
||||
{indexHits.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '16px 0' }}>索引裡沒有直接命中的主題。</div>
|
||||
) : (
|
||||
indexHits.map(e => (
|
||||
<Link key={e.pageName} href={wikiHref(e.entity)} className="mira-search-result">
|
||||
<div className="mira-search-result-meta"><span>📚 {e.entity}</span></div>
|
||||
{e.oneLiner && <div className="mira-search-snippet">{e.oneLiner}</div>}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 第 2 層:LLM 路由(選用) */}
|
||||
<section style={{ marginTop: 18 }}>
|
||||
{!llmEntities && (
|
||||
<button type="button" className="mira-search-llm-btn" onClick={runLlmRoute} disabled={llmLoading}>
|
||||
{llmLoading ? '🧠 Mira 翻索引中…' : '🧠 找不到?讓 Mira 讀整個索引幫你找'}
|
||||
</button>
|
||||
)}
|
||||
{llmEntities && (
|
||||
<>
|
||||
<div className="mira-search-section-head">🧠 Mira 從索引挑的({llmEntities.length})</div>
|
||||
{llmEntities.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '12px 0' }}>Mira 也覺得索引裡沒有相關主題。</div>
|
||||
) : (
|
||||
llmEntities.map(p => (
|
||||
<Link key={p.entity} href={wikiHref(p.entity)} className="mira-search-result">
|
||||
<div className="mira-search-result-meta"><span>📚 {p.entity}</span></div>
|
||||
<div className="mira-search-snippet" style={{ color: 'var(--mira-text-3)' }}>{p.reason}</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 第 3 層:向量兜底(折疊) */}
|
||||
<section style={{ marginTop: 18 }}>
|
||||
{!vecOpen ? (
|
||||
<button type="button" className="mira-search-vec-toggle" onClick={runVecSearch}>
|
||||
▸ 也試試全文 / 語義搜尋(兜底)
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="mira-search-section-head">🧬 語義兜底</div>
|
||||
{vecLoading && <div className="empty-state" style={{ padding: '12px 0' }}>搜尋中…</div>}
|
||||
{vecMatches && vecMatches.length === 0 && !vecLoading && (
|
||||
<div className="empty-state" style={{ padding: '12px 0' }}>沒有更多結果。</div>
|
||||
)}
|
||||
{vecMatches && vecMatches.map((m, i) => <VecResult key={i} match={m} />)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function VecResult({ match }: { match: SearchMatch }) {
|
||||
const pct = Math.round((match.score ?? 0) * 100);
|
||||
if (match.triplet) {
|
||||
const { subject, predicate, object } = match.triplet;
|
||||
return (
|
||||
<div className="mira-search-result">
|
||||
<div className="mira-search-result-meta"><span>關係</span>{pct > 0 && <span className="mira-search-score">{pct}%</span>}</div>
|
||||
<div className="mira-search-snippet" style={{ fontFamily: 'monospace' }}>{subject} ﹥﹥ {predicate} ﹥﹥ {object}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const b = match.block;
|
||||
if (!b) return null;
|
||||
const snippet = (b.content ?? '').replace(/\n+/g, ' ').slice(0, 200);
|
||||
const href = b.type === 'wiki-page' && b.page_name
|
||||
? `/mira/wiki/${encodeURIComponent(b.page_name)}`
|
||||
: b.page_name ? `/mira/feed#page=${encodeURIComponent(b.page_name)}` : `/mira/feed#raw=${encodeURIComponent(b.id)}`;
|
||||
return (
|
||||
<Link href={href} className="mira-search-result">
|
||||
<div className="mira-search-result-meta">
|
||||
<span>{b.type === 'wiki-page' ? '📚 Wiki' : '🌊 河道'}</span>
|
||||
{pct > 0 && <span className="mira-search-score">{pct}%</span>}
|
||||
</div>
|
||||
<div className="mira-search-snippet"><MarkdownView text={snippet + ((b.content ?? '').length > 200 ? '…' : '')} /></div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MiraSearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="empty-state">載入中…</div>}>
|
||||
<SearchInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user