312dd0f65d
- 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
208 lines
8.3 KiB
TypeScript
208 lines
8.3 KiB
TypeScript
'use client';
|
||
|
||
export const runtime = 'edge';
|
||
|
||
// 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, useState } from 'react';
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import Link from 'next/link';
|
||
import '../mira.css';
|
||
|
||
// 新 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';
|
||
|
||
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, '台');
|
||
}
|
||
|
||
// 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: '' };
|
||
}
|
||
|
||
function SearchInner() {
|
||
const router = useRouter();
|
||
const params = useSearchParams();
|
||
const initialQ = params.get('q') ?? '';
|
||
|
||
const [ready, setReady] = useState(false); // 登入檢查完成
|
||
const [input, setInput] = useState(initialQ);
|
||
const [mode, setMode] = useState<SearchMode>('semantic');
|
||
const [results, setResults] = useState<SearchEntry[] | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [hint, setHint] = useState<string | null>(null);
|
||
|
||
// 登入 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; }
|
||
setReady(true);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e));
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
// 語義/關鍵字搜尋: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();
|
||
router.replace(`/mira/search?q=${encodeURIComponent(q)}`);
|
||
runSearch(q, mode);
|
||
};
|
||
|
||
// 切換 default 語義/進階 keyword;已有 query 就重查
|
||
const switchMode = (m: SearchMode) => {
|
||
if (m === mode) return;
|
||
setMode(m);
|
||
if (input.trim()) runSearch(input, m);
|
||
};
|
||
|
||
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' }}>🔍 搜尋</h1>
|
||
<p style={{ color: '#888', fontSize: 12, marginTop: 4 }}>
|
||
會打字就能查——{mode === 'semantic' ? '語義搜尋(記大概意思即可)' : '關鍵字精確比對'}
|
||
</p>
|
||
</header>
|
||
|
||
<form className="mira-search-form" onSubmit={submit}>
|
||
<input
|
||
className="mira-search-input"
|
||
value={input}
|
||
onChange={e => setInput(e.target.value)}
|
||
placeholder={mode === 'semantic' ? '想找什麼?(例:那個穿皮衣的 AI 老闆、本地模型)' : '關鍵字(精確比對)'}
|
||
autoFocus
|
||
/>
|
||
<button type="submit" className="mira-btn-primary" disabled={!ready}>搜尋</button>
|
||
</form>
|
||
|
||
{/* default 語義/進階 keyword 切換 */}
|
||
<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>
|
||
|
||
{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>}
|
||
|
||
{loading && <div className="empty-state" style={{ padding: '16px 0' }}>搜尋中…</div>}
|
||
|
||
{!loading && results && (
|
||
<section style={{ marginTop: 14 }}>
|
||
<div className="mira-search-section-head">
|
||
{mode === 'semantic' ? '🧬 語義結果' : '📇 關鍵字結果'}({results.length})
|
||
</div>
|
||
{results.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '16px 0' }}>沒有找到相關結果,換個說法試試。</div>
|
||
) : (
|
||
results.map(e => <ResultRow key={e.id} entry={e} />)
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function ResultRow({ entry }: { entry: SearchEntry }) {
|
||
const { title, desc } = splitGloss(entry.content || '');
|
||
const pct = entry.score != null ? Math.round(entry.score * 100) : null;
|
||
return (
|
||
<div className="mira-search-result">
|
||
<div className="mira-search-result-meta">
|
||
<span>📚 {title}</span>
|
||
{pct != null && pct > 0 && <span className="mira-search-score">{pct}%</span>}
|
||
</div>
|
||
{desc && <div className="mira-search-snippet">{desc}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function MiraSearchPage() {
|
||
return (
|
||
<Suspense fallback={<div className="empty-state">載入中…</div>}>
|
||
<SearchInner />
|
||
</Suspense>
|
||
);
|
||
}
|