Files
Arcrun/landing/app/mira/search/page.tsx
T
uncle6me-web 5d00e71275 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>
2026-07-03 07:13:33 +08:00

308 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
export const runtime = 'edge';
// Mira 搜尋頁 — Karpathy index pattern 為 primaryleo 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 semanticSDD 明文「不是 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_nameindex-{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>
);
}