diff --git a/landing/app/mira/search/page.tsx b/landing/app/mira/search/page.tsx index 6a272b0..7154f3b 100644 --- a/landing/app/mira/search/page.tsx +++ b/landing/app/mira/search/page.tsx @@ -2,192 +2,136 @@ 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,當保險」 +// Mira 搜尋頁 — 大眾化語義入口(leo 2026-07-05 拍板) +// default = 語義搜尋(普通用戶記大概意思;語義 normalize:黃仁勳=皮衣男=Jensen Huang 歸一) +// 進階 = 關鍵字(懂的人用) +// 後端:self-hosted leo21c KBDB(新 ingest 產 triplet/gloss entry,非舊 index-entry blocks)。 +// 過渡期直連 kbdb raw worker(cypher-proxy /kbdb/entries 尚未補,見 mira §1.7)。 +// 舊後端 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 Link from 'next/link'; -import { MarkdownView } from '../_shared/markdown'; import '../mira.css'; -const KBDB_BASE = 'https://kbdb.finally.click'; +// 新 KBDB(self-hosted leo21c)。過渡期直連 raw worker(無 auth、單租戶), +// 不帶 owner_id(Vectorize owner metadata index 未建,帶了會回 0;總管另修)。 +const KBDB_BASE = 'https://arcrun-kbdb.leo21c.workers.dev'; +// 登入 gate 仍走官方 cypher(session cookie 綁 arcrun.dev;leo21c 對應 URL 未定 → 保留原樣,見回報)。 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 回傳的 entry(gloss:content = "實體:描述") +type SearchEntry = { + id: string; + content: string; + entry_type?: string; + owner_id?: string; + score?: number; +}; + +type SearchResponse = { + entries?: SearchEntry[]; + mode?: string; // 實際採用的 mode(semantic 未開會誠實降級 keyword) + capability_hint?: string; // 降級時的提示 +}; // 繁體異體字正規化(臺→台),讓 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) }; +// gloss content「實體:描述」→ { title, desc }(無冒號則整串當標題) +function splitGloss(content: string): { title: string; desc: string } { + const m = content.match(/^\s*([^::]+)[::]\s*([\s\S]*)$/); + if (m) return { title: m[1].trim(), desc: m[2].trim() }; + return { title: content.trim(), desc: '' }; } -// 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 [ready, setReady] = useState(false); // 登入檢查完成 const [input, setInput] = useState(initialQ); - const [query, setQuery] = useState(initialQ.trim()); - const [index, setIndex] = useState(null); + const [mode, setMode] = useState('semantic'); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [hint, setHint] = 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 + // 登入 gate(沿用官方 cypher /me;只確認登入,不需 api_key——raw worker 無 auth) 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 || ''))); + setReady(true); } 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]); + // 語義/關鍵字搜尋:GET {KBDB_BASE}/entries/search?q=&mode=&limit=12(不帶 owner_id) + const runSearch = useCallback(async (rawQuery: string, m: SearchMode) => { + const q = normalizeQuery(rawQuery.trim()); + if (!q) { setResults(null); setHint(null); return; } + setLoading(true); + setError(null); + setHint(null); + try { + const url = `${KBDB_BASE}/entries/search?q=${encodeURIComponent(q)}&mode=${m}&limit=12`; + const res = await fetch(url); + if (!res.ok) { setError(`搜尋失敗:${res.status}`); setResults([]); return; } + const data = (await res.json()) as SearchResponse; + const entries = (data.entries ?? []) + .slice() + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); // 語義無門檻,照 score 排序顯示前 N + setResults(entries); + // 誠實降級提示:要了 semantic 卻回 keyword(Vectorize 未開) + if (m === 'semantic' && data.mode && data.mode !== 'semantic') { + setHint(data.capability_hint ?? '語義索引尚未啟用,暫以關鍵字比對代替。'); + } + } 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) => { e.preventDefault(); const q = input.trim(); - setQuery(q); - setLlmEntities(null); - setVecMatches(null); - setVecOpen(false); router.replace(`/mira/search?q=${encodeURIComponent(q)}`); + runSearch(q, mode); }; - // 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]); + // 切換 default 語義/進階 keyword;已有 query 就重查 + const switchMode = (m: SearchMode) => { + if (m === mode) return; + setMode(m); + if (input.trim()) runSearch(input, m); + }; return (
← 河道 -

🔍 Wiki 搜尋

+

🔍 搜尋

- 從 {index?.length ?? '…'} 個 wiki 主題的索引找(Karpathy index) + 會打字就能查——{mode === 'semantic' ? '語義搜尋(記大概意思即可)' : '關鍵字精確比對'}

@@ -196,105 +140,61 @@ function SearchInner() { className="mira-search-input" value={input} onChange={e => setInput(e.target.value)} - placeholder="找主題(例:特化、台北大學、本地模型)" + placeholder={mode === 'semantic' ? '想找什麼?(例:那個穿皮衣的 AI 老闆、本地模型)' : '關鍵字(精確比對)'} autoFocus /> - + - {error &&
{error}
} - {!index && !error &&
載入索引中…
} + {/* default 語義/進階 keyword 切換 */} +
+ + +
- {index && query && ( - <> - {/* 第 1 層:index 即時命中 */} -
-
📇 索引命中({indexHits.length})
- {indexHits.length === 0 ? ( -
索引裡沒有直接命中的主題。
- ) : ( - indexHits.map(e => ( - -
📚 {e.entity}
- {e.oneLiner &&
{e.oneLiner}
} - - )) - )} -
+ {error &&
{error}
} + {hint &&
ℹ️ {hint}
} - {/* 第 2 層:LLM 路由(選用) */} -
- {!llmEntities && ( - - )} - {llmEntities && ( - <> -
🧠 Mira 從索引挑的({llmEntities.length})
- {llmEntities.length === 0 ? ( -
Mira 也覺得索引裡沒有相關主題。
- ) : ( - llmEntities.map(p => ( - -
📚 {p.entity}
-
{p.reason}
- - )) - )} - - )} -
+ {loading &&
搜尋中…
} - {/* 第 3 層:向量兜底(折疊) */} -
- {!vecOpen ? ( - - ) : ( - <> -
🧬 語義兜底
- {vecLoading &&
搜尋中…
} - {vecMatches && vecMatches.length === 0 && !vecLoading && ( -
沒有更多結果。
- )} - {vecMatches && vecMatches.map((m, i) => )} - - )} -
- + {!loading && results && ( +
+
+ {mode === 'semantic' ? '🧬 語義結果' : '📇 關鍵字結果'}({results.length}) +
+ {results.length === 0 ? ( +
沒有找到相關結果,換個說法試試。
+ ) : ( + results.map(e => ) + )} +
)}
); } -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)}`; +function ResultRow({ entry }: { entry: SearchEntry }) { + const { title, desc } = splitGloss(entry.content || ''); + const pct = entry.score != null ? Math.round(entry.score * 100) : null; return ( - +
- {b.type === 'wiki-page' ? '📚 Wiki' : '🌊 河道'} - {pct > 0 && {pct}%} + 📚 {title} + {pct != null && pct > 0 && {pct}%}
-
200 ? '…' : '')} />
- + {desc &&
{desc}
} +
); }