5d00e71275
頂層 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>
308 lines
13 KiB
TypeScript
308 lines
13 KiB
TypeScript
'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>
|
||
);
|
||
}
|