feat(mira): 搜尋 repoint leo21c + 語義升 default(大眾化) + 進階 keyword

- KBDB_BASE: kbdb.finally.click(舊禁) → arcrun-kbdb.leo21c.workers.dev
- default mode=semantic(一個框會打字就查,含 normalize);進階切 keyword
- 移除舊 index-entry primary/LLM路由/舊向量兜底(依賴已不存在的 index-entry blocks)
- 登入 gate 維持舊 cypher(cookie 網域,v1 不動);next build 0/tsc 0
This commit is contained in:
Claude
2026-07-05 14:24:16 +00:00
parent ed2e42e007
commit 312dd0f65d
+122 -222
View File
@@ -2,192 +2,136 @@
export const runtime = 'edge'; export const runtime = 'edge';
// Mira 搜尋頁 — Karpathy index pattern 為 primaryleo 2026-05-23 // Mira 搜尋頁 — 大眾化語義入口leo 2026-07-05 拍板
// SDD: polaris/mira/.agents/specs/mira-app/design.md §3.5.12.4「Karpathy index pattern(不用 vector embedding)」 // default = 語義搜尋(普通用戶記大概意思;語義 normalize:黃仁勳=皮衣男=Jensen Huang 歸一)
// 三層(C 混合): // 進階 = 關鍵字(懂的人用)
// 1. Index 即時文字比對:掃 index-entry(entity 名 + 摘要)子字串命中 → 列 entity(零 token // 後端:self-hosted leo21c KBDB(新 ingest 產 triplet/gloss entry,非舊 index-entry blocks)。
// 2. LLM 路由(選用):整個 index 餵 Claude,問「leo 想找哪些 entity」→ 最貼 Karpathy 本意 // 過渡期直連 kbdb raw workercypher-proxy /kbdb/entries 尚未補,見 mira §1.7)。
// 3. 向量兜底(折疊):KBDB /search semanticSDD 明文「不是 primary,當保險」 // 舊後端 kbdb.finally.click 已移除(mira 鐵律:禁直打舊 SaaS KBDB 當主儲存)。
// graph 遍歷之後再補(本次不做)。
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { MarkdownView } from '../_shared/markdown';
import '../mira.css'; import '../mira.css';
const KBDB_BASE = 'https://kbdb.finally.click'; // 新 KBDBself-hosted leo21c)。過渡期直連 raw worker(無 auth、單租戶),
// 不帶 owner_idVectorize owner metadata index 未建,帶了會回 0;總管另修)。
const KBDB_BASE = 'https://arcrun-kbdb.leo21c.workers.dev';
// 登入 gate 仍走官方 cyphersession cookie 綁 arcrun.devleo21c 對應 URL 未定 → 保留原樣,見回報)。
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev'; const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
const CLAUDE_API = 'https://claude-api.arcrun.dev';
type SearchMode = 'semantic' | 'keyword';
// KBDB /entries/search 回傳的 entryglosscontent = "實體:描述"
type SearchEntry = {
id: string;
content: string;
entry_type?: string;
owner_id?: string;
score?: number;
};
type SearchResponse = {
entries?: SearchEntry[];
mode?: string; // 實際採用的 modesemantic 未開會誠實降級 keyword
capability_hint?: string; // 降級時的提示
};
// 繁體異體字正規化(臺→台),讓 query 對得上多用「台」的 KB 內容 // 繁體異體字正規化(臺→台),讓 query 對得上多用「台」的 KB 內容
function normalizeQuery(q: string): string { function normalizeQuery(q: string): string {
return q.replace(/臺/g, '台'); return q.replace(/臺/g, '台');
} }
type IndexEntry = { // gloss content「實體:描述」→ { title, desc }(無冒號則整串當標題)
entity: string; // H1 / page_name 去 index- 前綴 function splitGloss(content: string): { title: string; desc: string } {
pageName: string; // index-entry 自己的 page_nameindex-{entity} const m = content.match(/^\s*([^:]+)[:]\s*([\s\S]*)$/);
oneLiner: string; // 「一句話定義」 if (m) return { title: m[1].trim(), desc: m[2].trim() };
outline: string; // facet outline 全文(拿來比對 + 餵 LLM) return { title: content.trim(), desc: '' };
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() { function SearchInner() {
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const initialQ = params.get('q') ?? ''; const initialQ = params.get('q') ?? '';
const [apiKey, setApiKey] = useState<string | null>(null); const [ready, setReady] = useState(false); // 登入檢查完成
const [input, setInput] = useState(initialQ); const [input, setInput] = useState(initialQ);
const [query, setQuery] = useState(initialQ.trim()); const [mode, setMode] = useState<SearchMode>('semantic');
const [index, setIndex] = useState<IndexEntry[] | null>(null); const [results, setResults] = useState<SearchEntry[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hint, setHint] = useState<string | null>(null);
// LLM 路由結果 // 登入 gate(沿用官方 cypher /me;只確認登入,不需 api_key——raw worker 無 auth
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(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' }); const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' });
if (!meRes.ok) { window.location.href = '/login?redirect=/mira/search'; return; } if (!meRes.ok) { window.location.href = '/login?redirect=/mira/search'; return; }
const me = (await meRes.json()) as { api_key: string }; setReady(true);
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) { } catch (e) {
setError(e instanceof Error ? e.message : String(e)); setError(e instanceof Error ? e.message : String(e));
} }
})(); })();
}, []); }, []);
// 即時 index 文字比對(純 client,零 token // 語義/關鍵字搜尋:GET {KBDB_BASE}/entries/search?q=&mode=&limit=12(不帶 owner_id
const indexHits = useMemo(() => { const runSearch = useCallback(async (rawQuery: string, m: SearchMode) => {
if (!index || !query) return []; const q = normalizeQuery(rawQuery.trim());
const q = normalizeQuery(query).toLowerCase(); if (!q) { setResults(null); setHint(null); return; }
const terms = q.split(/\s+/).filter(Boolean); setLoading(true);
const scored = index setError(null);
.map(e => { setHint(null);
const hay = normalizeQuery(`${e.entity}\n${e.oneLiner}\n${e.outline}`).toLowerCase(); try {
let score = 0; const url = `${KBDB_BASE}/entries/search?q=${encodeURIComponent(q)}&mode=${m}&limit=12`;
for (const t of terms) { const res = await fetch(url);
if (e.entity.toLowerCase().includes(t)) score += 10; // entity 名命中權重高 if (!res.ok) { setError(`搜尋失敗:${res.status}`); setResults([]); return; }
else if (hay.includes(t)) score += 3; const data = (await res.json()) as SearchResponse;
} const entries = (data.entries ?? [])
return { e, score }; .slice()
}) .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); // 語義無門檻,照 score 排序顯示前 N
.filter(x => x.score > 0) setResults(entries);
.sort((a, b) => b.score - a.score) // 誠實降級提示:要了 semantic 卻回 keywordVectorize 未開)
.slice(0, 12); if (m === 'semantic' && data.mode && data.mode !== 'semantic') {
return scored.map(x => x.e); setHint(data.capability_hint ?? '語義索引尚未啟用,暫以關鍵字比對代替。');
}, [index, query]); }
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setResults([]);
} finally {
setLoading(false);
}
}, []);
// 進站帶 ?q= 且登入完成 → 自動查一次
useEffect(() => {
if (ready && initialQ.trim()) runSearch(initialQ, mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready]);
const submit = (e: React.FormEvent) => { const submit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const q = input.trim(); const q = input.trim();
setQuery(q);
setLlmEntities(null);
setVecMatches(null);
setVecOpen(false);
router.replace(`/mira/search?q=${encodeURIComponent(q)}`); router.replace(`/mira/search?q=${encodeURIComponent(q)}`);
runSearch(q, mode);
}; };
// LLM 路由:整個 index 餵 Claude // 切換 default 語義/進階 keyword;已有 query 就重查
const runLlmRoute = useCallback(async () => { const switchMode = (m: SearchMode) => {
if (!index || !query || llmLoading) return; if (m === mode) return;
setLlmLoading(true); setMode(m);
setLlmEntities(null); if (input.trim()) runSearch(input, m);
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 ( return (
<main className="mira-page"> <main className="mira-page">
<div className="mira-content"> <div className="mira-content">
<header style={{ padding: '24px 0 8px' }}> <header style={{ padding: '24px 0 8px' }}>
<Link href="/mira/feed" style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}> </Link> <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> <h1 style={{ fontSize: 26, fontWeight: 700, color: '#fff', margin: '8px 0 0' }}>🔍 </h1>
<p style={{ color: '#888', fontSize: 12, marginTop: 4 }}> <p style={{ color: '#888', fontSize: 12, marginTop: 4 }}>
{index?.length ?? '…'} wiki Karpathy index {mode === 'semantic' ? '語義搜尋(記大概意思即可)' : '關鍵字精確比對'}
</p> </p>
</header> </header>
@@ -196,105 +140,61 @@ function SearchInner() {
className="mira-search-input" className="mira-search-input"
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
placeholder="找主題(例:特化、台北大學、本地模型)" placeholder={mode === 'semantic' ? '想找什麼?(例:那個穿皮衣的 AI 老闆、本地模型)' : '關鍵字(精確比對)'}
autoFocus autoFocus
/> />
<button type="submit" className="mira-btn-primary" disabled={!index}></button> <button type="submit" className="mira-btn-primary" disabled={!ready}></button>
</form> </form>
{error && <div className="mira-error" style={{ marginBottom: 10 }}>{error}</div>} {/* default 語義/進階 keyword 切換 */}
{!index && !error && <div className="empty-state"></div>} <div className="mira-search-mode" style={{ display: 'flex', gap: 8, marginTop: 10, fontSize: 13 }}>
<button
type="button"
onClick={() => switchMode('semantic')}
className={mode === 'semantic' ? 'mira-btn-primary' : 'mira-search-vec-toggle'}
style={{ padding: '4px 12px' }}
></button>
<button
type="button"
onClick={() => switchMode('keyword')}
className={mode === 'keyword' ? 'mira-btn-primary' : 'mira-search-vec-toggle'}
style={{ padding: '4px 12px' }}
></button>
</div>
{index && query && ( {error && <div className="mira-error" style={{ marginTop: 12 }}>{error}</div>}
<> {hint && <div className="empty-state" style={{ padding: '10px 0', color: 'var(--mira-text-3)' }}> {hint}</div>}
{/* 第 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 路由(選用) */} {loading && <div className="empty-state" style={{ padding: '16px 0' }}></div>}
<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 層:向量兜底(折疊) */} {!loading && results && (
<section style={{ marginTop: 18 }}> <section style={{ marginTop: 14 }}>
{!vecOpen ? ( <div className="mira-search-section-head">
<button type="button" className="mira-search-vec-toggle" onClick={runVecSearch}> {mode === 'semantic' ? '🧬 語義結果' : '📇 關鍵字結果'}{results.length}
/ </div>
</button> {results.length === 0 ? (
) : ( <div className="empty-state" style={{ padding: '16px 0' }}></div>
<> ) : (
<div className="mira-search-section-head">🧬 </div> results.map(e => <ResultRow key={e.id} entry={e} />)
{vecLoading && <div className="empty-state" style={{ padding: '12px 0' }}></div>} )}
{vecMatches && vecMatches.length === 0 && !vecLoading && ( </section>
<div className="empty-state" style={{ padding: '12px 0' }}></div>
)}
{vecMatches && vecMatches.map((m, i) => <VecResult key={i} match={m} />)}
</>
)}
</section>
</>
)} )}
</div> </div>
</main> </main>
); );
} }
function VecResult({ match }: { match: SearchMatch }) { function ResultRow({ entry }: { entry: SearchEntry }) {
const pct = Math.round((match.score ?? 0) * 100); const { title, desc } = splitGloss(entry.content || '');
if (match.triplet) { const pct = entry.score != null ? Math.round(entry.score * 100) : null;
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 ( return (
<Link href={href} className="mira-search-result"> <div className="mira-search-result">
<div className="mira-search-result-meta"> <div className="mira-search-result-meta">
<span>{b.type === 'wiki-page' ? '📚 Wiki' : '🌊 河道'}</span> <span>📚 {title}</span>
{pct > 0 && <span className="mira-search-score">{pct}%</span>} {pct != null && pct > 0 && <span className="mira-search-score">{pct}%</span>}
</div> </div>
<div className="mira-search-snippet"><MarkdownView text={snippet + ((b.content ?? '').length > 200 ? '…' : '')} /></div> {desc && <div className="mira-search-snippet">{desc}</div>}
</Link> </div>
); );
} }