'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(null); const [input, setInput] = useState(initialQ); const [query, setQuery] = useState(initialQ.trim()); const [index, setIndex] = useState(null); const [error, setError] = useState(null); // LLM 路由結果 const [llmEntities, setLlmEntities] = useState<{ entity: string; reason: string }[] | null>(null); const [llmLoading, setLlmLoading] = useState(false); // 向量兜底 const [vecMatches, setVecMatches] = useState(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 (
← 河道

🔍 Wiki 搜尋

從 {index?.length ?? '…'} 個 wiki 主題的索引找(Karpathy index)

setInput(e.target.value)} placeholder="找主題(例:特化、台北大學、本地模型)" autoFocus />
{error &&
{error}
} {!index && !error &&
載入索引中…
} {index && query && ( <> {/* 第 1 層:index 即時命中 */}
📇 索引命中({indexHits.length})
{indexHits.length === 0 ? (
索引裡沒有直接命中的主題。
) : ( indexHits.map(e => (
📚 {e.entity}
{e.oneLiner &&
{e.oneLiner}
} )) )}
{/* 第 2 層:LLM 路由(選用) */}
{!llmEntities && ( )} {llmEntities && ( <>
🧠 Mira 從索引挑的({llmEntities.length})
{llmEntities.length === 0 ? (
Mira 也覺得索引裡沒有相關主題。
) : ( llmEntities.map(p => (
📚 {p.entity}
{p.reason}
)) )} )}
{/* 第 3 層:向量兜底(折疊) */}
{!vecOpen ? ( ) : ( <>
🧬 語義兜底
{vecLoading &&
搜尋中…
} {vecMatches && vecMatches.length === 0 && !vecLoading && (
沒有更多結果。
)} {vecMatches && vecMatches.map((m, i) => )} )}
)}
); } function VecResult({ match }: { match: SearchMatch }) { const pct = Math.round((match.score ?? 0) * 100); if (match.triplet) { const { subject, predicate, object } = match.triplet; return (
關係{pct > 0 && {pct}%}
{subject} ﹥﹥ {predicate} ﹥﹥ {object}
); } 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 (
{b.type === 'wiki-page' ? '📚 Wiki' : '🌊 河道'} {pct > 0 && {pct}%}
200 ? '…' : '')} />
); } export default function MiraSearchPage() { return ( 載入中…}> ); }