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:
+122
-222
@@ -2,192 +2,136 @@
|
|||||||
|
|
||||||
export const runtime = 'edge';
|
export const runtime = 'edge';
|
||||||
|
|
||||||
// Mira 搜尋頁 — Karpathy index pattern 為 primary(leo 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 worker(cypher-proxy /kbdb/entries 尚未補,見 mira §1.7)。
|
||||||
// 3. 向量兜底(折疊):KBDB /search semantic,SDD 明文「不是 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';
|
// 新 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 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 內容
|
// 繁體異體字正規化(臺→台),讓 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_name(index-{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 卻回 keyword(Vectorize 未開)
|
||||||
.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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user