Files
Arcrun/landing/app/mira/search/page.tsx
T
Claude 312dd0f65d 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
2026-07-05 14:24:16 +00:00

208 lines
8.3 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 搜尋頁 — 大眾化語義入口(leo 2026-07-05 拍板)
// default = 語義搜尋(普通用戶記大概意思;語義 normalize:黃仁勳=皮衣男=Jensen Huang 歸一)
// 進階 = 關鍵字(懂的人用)
// 後端:self-hosted leo21c KBDB(新 ingest 產 triplet/gloss entry,非舊 index-entry blocks)。
// 過渡期直連 kbdb raw workercypher-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';
// 新 KBDBself-hosted leo21c)。過渡期直連 raw worker(無 auth、單租戶),
// 不帶 owner_idVectorize owner metadata index 未建,帶了會回 0;總管另修)。
const KBDB_BASE = 'https://arcrun-kbdb.leo21c.workers.dev';
// 登入 gate 仍走官方 cyphersession cookie 綁 arcrun.devleo21c 對應 URL 未定 → 保留原樣,見回報)。
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev';
type SearchMode = 'semantic' | 'keyword';
// KBDB /entries/search 回傳的 entryglosscontent = "實體:描述"
type SearchEntry = {
id: string;
content: string;
entry_type?: string;
owner_id?: string;
score?: number;
};
type SearchResponse = {
entries?: SearchEntry[];
mode?: string; // 實際採用的 modesemantic 未開會誠實降級 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 卻回 keywordVectorize 未開)
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>
);
}