Files
dish-image-toolkit/retrieval/static/app.js
T
陈世睿 917613c5ce 初始化 dish-image-toolkit:菜品图爬虫 + 图片名称检索平台
- crawler/:豆果(主)/下厨房/Bing 爬虫 + dishes.jsonl(3056 菜→9167 图映射)+ verify_repair 按 URL 重下
- retrieval/:三路检索(BM25 + 本地 BGE-M3 向量 + RRF 融合),FastAPI + 前端;
  写死图片目录(默认 crawler/images,可 IMAGE_DIR 覆盖)、绑 0.0.0.0 局域网访问、
  启动自动建索引、服务器 serve 图片
- 图片(1.7G)与向量模型(2.3G)不进 git

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:27:51 +08:00

121 lines
4.3 KiB
JavaScript

'use strict';
const $ = (id) => document.getElementById(id);
let pollTimer = null;
// ---------- 启动:轮询索引状态(服务器开机自动建索引) ----------
window.addEventListener('DOMContentLoaded', pollStatus);
function pollStatus() {
clearInterval(pollTimer);
tick();
pollTimer = setInterval(tick, 800);
}
async function tick() {
let s;
try { s = await (await fetch('/api/status')).json(); }
catch { $('status').textContent = '连接服务失败,确认服务器已启动。'; return; }
// BM25 就绪即可搜(向量还在编码也能先看关键词路)
if (s.bm25_ready) { $('q').disabled = false; $('search').disabled = false; }
const bar = $('buildBar');
if (s.state === 'building') {
bar.classList.remove('hidden');
if (s.phase === 'loadmodel') {
$('status').textContent = `图片 ${s.n_images} 张 / 名称 ${s.total} 个 · 正在加载向量模型…`;
setBar('加载本地向量模型 BGE-M3(首次需下载约 2.3G)…', 8);
} else if (s.phase === 'embedding') {
const pct = s.emb_total ? Math.round((s.emb_done / s.emb_total) * 100) : 0;
const dev = s.device ? ` · ${s.device}` : '';
$('status').textContent = `图片 ${s.n_images} 张 / 名称 ${s.total} 个 · 向量编码中(BM25 已就绪,可先搜)`;
setBar(`向量编码中${dev} ${s.emb_done}/${s.emb_total}`, pct);
} else {
$('status').textContent = 'BM25 分词建索引中…';
setBar('BM25 分词建索引中…', 4);
}
} else if (s.state === 'ready') {
clearInterval(pollTimer);
const dev = s.device ? ` (${s.device})` : '';
const note = s.failed ? `,${s.failed} 条失败` : '';
$('status').innerHTML = `✓ 就绪:<b>${s.total}</b> 个名称 / <b>${s.n_images}</b> 张图${dev}${note} —— 直接搜索吧。`;
bar.classList.add('hidden');
} else if (s.state === 'error') {
clearInterval(pollTimer);
$('status').textContent = '❌ ' + (s.message || '建索引失败');
bar.classList.add('hidden');
}
}
function setBar(t, pct) {
$('buildText').textContent = t;
$('barFill').style.width = (pct || 0) + '%';
}
// ---------- 搜索 ----------
$('search').addEventListener('click', doSearch);
$('q').addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
async function doSearch() {
const q = $('q').value.trim();
if (!q) return;
const params = new URLSearchParams({
q, topk: $('topk').value || 20, rrf_k: $('rrfk').value || 60,
w_bm25: $('wbm').value || 1, w_vec: $('wvec').value || 1,
});
let d;
try { d = await (await fetch('/api/search?' + params)).json(); }
catch (err) { alert('搜索失败:' + err.message); return; }
renderCol('bm25', d.bm25);
renderCol('vector', d.vector);
renderCol('fusion', d.fusion);
}
function renderCol(kind, items) {
$('cnt-' + kind).textContent = items.length ? `${items.length}` : '';
const box = $('col-' + kind);
box.innerHTML = '';
if (!items.length) { box.innerHTML = '<div class="empty">无结果</div>'; return; }
items.forEach((it, i) => box.appendChild(card(kind, it, i + 1)));
}
function card(kind, it, rank) {
const el = document.createElement('div');
el.className = 'card';
const primary = kind === 'bm25' ? `BM25 ${fmt(it.bm25)}`
: kind === 'vector' ? `余弦 ${fmt(it.vec)}`
: `RRF ${fmt(it.score)}`;
const badges = [];
if (kind !== 'bm25') badges.push(rankBadge('BM25', it.bm25_rank));
if (kind !== 'vector') badges.push(rankBadge('向量', it.vec_rank));
const src = imgURL(it.files && it.files[0]);
el.innerHTML = `
<div class="rk">${rank}</div>
${src ? `<img loading="lazy" src="${src}" title="${esc(it.name)}">` : '<div class="card-img"></div>'}
<div class="meta">
<div class="nm" title="${esc(it.name)}">${esc(it.name)}</div>
<div class="sc">${primary}</div>
<div class="badges">${badges.join('')}</div>
</div>`;
return el;
}
function rankBadge(label, rank) {
return rank
? `<span class="badge">${label}#${rank}</span>`
: `<span class="badge miss">${label}未命中</span>`;
}
function imgURL(file) {
return file ? '/images/' + encodeURIComponent(file) : '';
}
const fmt = (v) => (v === null || v === undefined ? '—' : v);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));