feat(web): 新增 Web 观测台(形态 A)+ 抽出 render/targets 共用层

- tcaptcha/web/:FastAPI + SSE + 零构建单页。把同步阻塞的 solve() 包成可观测
  后台任务(工作线程 + 全局串行,因 onnxruntime session 非线程安全),用
  logging.Handler 零侵入把 client 日志变成事件流;按靶子实测 min_interval 强制节流。
- tcaptcha/render.py:标注图(点击顺序编号 + 质心偏差)与点位量尺,CLI 与 web 共用。
- tcaptcha/targets.py:靶子表提为单一来源(含实测频控间隔 1ms=60s / mliev=3s)。
- fastapi/uvicorn 走 optional extra `web`,核心包零 web 依赖(带回归断言)。
- 修两个真 bug:hook 求解器时 args[0] 是实例自身(真入参从 args[1] 起);
  client 默认每次新建 solver,预热实例未被复用(每发重载 56MB ONNX)。
- 复验:icon/text/slider 三题型各拿到 errorCode=0;selftest 全绿;依赖隔离通过。

用法:uv run --extra web python -m tcaptcha.web.app
This commit is contained in:
yml2213
2026-10-14 19:10:54 +08:00
parent 74a863c8b3
commit 99565f65f0
15 changed files with 1827 additions and 1189 deletions
+294
View File
@@ -0,0 +1,294 @@
/* tcaptcha 观测台 —— 零构建单页。只用原生 API,不引框架/打包器。
*
* 数据来源只有两个:
* POST /api/jobs 建任务,拿 job_id
* GET /api/jobs/{id}/events SSE 事件流(服务端推 event.to_dict())
* 事件类型由后端 tcaptcha/web/events.py 的 EVT_* 定义,这里按 data.type 分派。
*/
'use strict';
const $ = (id) => document.getElementById(id);
const EC_MEANING = {
0: { text: '通过', cls: 'ec0' },
50: { text: '答案被否', cls: 'ec50' },
9: { text: '答案被否', cls: 'ec50' },
12: { text: '包级拒答', cls: 'ec12' },
};
function ecInfo(ec) {
if (ec === null || ec === undefined || ec === '') return { text: '—', cls: 'ecother' };
return EC_MEANING[Number(ec)] || { text: '未知', cls: 'ecother' };
}
function hhmmss(ts) {
const d = new Date((ts || Date.now() / 1000) * 1000);
return d.toTimeString().slice(0, 8);
}
let state = {
targets: [], kinds: [], currentJob: null, es: null,
cooldownUntil: 0, cards: {}, lastResult: null,
};
/* ------------------------------------------------------------------ 启动 */
async function boot() {
await loadTargets();
await loadHistory();
setInterval(tickCooldown, 1000);
$('target').onchange = () => renderNote();
$('start').onclick = startJob;
$('cancel').onclick = cancelJob;
}
async function loadTargets() {
const r = await fetch('/api/targets').then((x) => x.json());
state.targets = r.targets;
state.kinds = r.kinds;
$('target').innerHTML = r.targets
.map((t) => `<option value="${t.key}">${t.key} aid=${t.aid}(最小间隔 ${t.min_interval}s)</option>`)
.join('');
renderNote();
applyCooldown(r.cooldown);
}
function renderNote() {
const t = state.targets.find((x) => x.key === $('target').value);
$('targetNote').textContent = t ? `【${t.key}】${t.note}` : '';
}
function applyCooldown(cd) {
const left = cd && cd[$('target').value];
if (typeof left === 'number' && left > 0) state.cooldownUntil = Date.now() + left * 1000;
}
function tickCooldown() {
const left = Math.ceil((state.cooldownUntil - Date.now()) / 1000);
const el = $('cooldown');
if (left > 0) {
el.textContent = `频控冷却 ${left}s`;
el.className = 'cooldown';
$('start').disabled = true;
} else {
el.textContent = '可发';
el.className = 'cooldown ready';
$('start').disabled = false;
}
}
/* ------------------------------------------------------------------ 任务 */
async function startJob() {
const body = {
target: $('target').value,
kind: $('kind').value,
max_retries: 2,
max_refreshes: 20,
};
resetView();
const job = await fetch('/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then((r) => r.json());
subscribe(job.id);
}
function subscribe(jobId) {
if (state.es) { state.es.close(); state.es = null; }
state.currentJob = jobId;
$('jobId').textContent = jobId;
setState('pending');
$('cancel').disabled = false;
const es = new EventSource(`/api/jobs/${jobId}/events?since=0`);
es.onmessage = (e) => {
let evt;
try { evt = JSON.parse(e.data); } catch (err) { return; }
onEvent(evt);
if (evt.type === 'job.finished' || evt.type === 'job.error') {
es.close(); // 关键:必须主动关闭,否则 EventSource 会自动重连
if (state.es === es) state.es = null;
}
};
es.onerror = () => { /* 断线时 EventSource 自动重连并带 Last-Event-ID 续传 */ };
state.es = es;
}
async function cancelJob() {
if (!state.currentJob) return;
await fetch(`/api/jobs/${state.currentJob}/cancel`, { method: 'POST' });
$('cancel').disabled = true;
}
/* ------------------------------------------------------------ 事件分派 */
function onEvent(evt) {
const { type, data } = evt;
switch (type) {
case 'job.started':
setState('running');
logRow('meta', `任务启动:靶子=${data.target} aid=${data.aid} 题型=${data.kind} 模式=${data.mode}`);
return;
case 'log':
logRow(data.level, data.message);
return;
case 'challenge':
addChallengeCard(data);
logRow('meta', `抽到第 ${data.index} 题:${data.kind} ${data.instruction}`);
return;
case 'solve':
fillSolve(data);
return;
case 'telemetry':
logRow('meta', `collect:来源=${data.source} 长度=${data.collect_len} ` +
`段几何=${data.segments ? JSON.stringify(data.segments) : '—'}` +
(data.traj_points ? ` 轨迹点=${data.traj_points}` : '') +
(data.segments_err ? ` ⚠ ${data.segments_err}` : ''));
return;
case 'verify':
renderVerify(data);
return;
case 'job.finished':
finishJob(data);
return;
case 'job.error':
setState('error');
logRow('ERROR', data.message || '任务异常');
$('cancel').disabled = true;
return;
default:
return;
}
}
function setState(s) {
const map = {
pending: ['排队中', ''], running: ['执行中', 'running'], throttled: ['频控等待', 'warn'],
done: ['已完成', 'ok'], error: ['失败', 'err'], cancelled: ['已取消', 'warn'],
};
const [text, cls] = map[s] || [s, ''];
const el = $('state');
el.textContent = text;
el.className = 'badge ' + cls;
}
function logRow(level, message) {
const box = $('log');
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 30;
const row = document.createElement('div');
row.className = 'row ' + level;
row.innerHTML = `<span class="t">${hhmmss()}</span><span class="m"></span>`;
row.querySelector('.m').textContent = message;
box.appendChild(row);
if (atBottom) box.scrollTop = box.scrollHeight;
}
/* ------------------------------------------------------------ 图片卡片 */
function addChallengeCard(d) {
const wrap = $('images');
const card = document.createElement('div');
card.className = 'shot';
const imgs = [];
if (d.bg_url) imgs.push(`<img src="${d.bg_url}" alt="底图">`);
if (d.prompt_url) imgs.push(`<img src="${d.prompt_url}" alt="提示图">`);
card.innerHTML =
`<div class="cap"><span>第 ${d.index} 题 · ${d.kind}</span>
<span class="muted">${d.instruction || ''}</span></div>
<div class="pair">${imgs.join('')}</div>
<div class="pts" data-pts>求解中…</div>`;
wrap.prepend(card); // 最新的题放最上面
state.cards[d.index] = card;
}
function fillSolve(d) {
const card = state.cards[d.index];
if (!card) return;
const offs = (d.off_px || []).map((v) => (v === null ? '—' : v + 'px')).join(' / ');
card.querySelector('[data-pts]').textContent =
`点位(按点击顺序): ${JSON.stringify(d.points)} 离图标中心: ${offs}`;
if (d.annotated_url) {
const box = card.querySelector('.pair');
const img = document.createElement('img');
img.src = d.annotated_url;
img.alt = '标注图';
box.appendChild(img);
}
if (d.note) logRow('meta', `求解结论: ${d.note}`);
}
/* ------------------------------------------------------------ 结果渲染 */
function renderVerify(d) {
const info = ecInfo(d.errorCode);
state.lastResult = d;
$('result').innerHTML = `
<div class="card">
<div class="kv"><div class="k">errorCode</div>
<div class="v ${info.cls}">${d.errorCode ?? '—'} <span style="font-size:12px">${info.text}</span></div></div>
<div class="kv"><div class="k">耗时</div><div class="v">${fmtMs(d.elapsed_ms)}</div></div>
<div class="kv"><div class="k">randstr</div><div class="v">${d.randstr || '—'}</div></div>
<div class="kv"><div class="k">ticket</div><div class="v">${d.ticket || '—'}</div></div>
</div>`;
}
function finishJob(d) {
const info = ecInfo(d.errorCode);
setState(d.success ? 'done' : (info.cls === 'ec12' ? 'error' : 'warn'));
$('cancel').disabled = true;
const st = d.stats || {};
$('result').insertAdjacentHTML('beforeend', `
<div class="card">
<div class="kv"><div class="k">结果</div><div class="v ${info.cls}">${d.success ? '成功' : (d.errorCode ?? '失败')}</div></div>
<div class="kv"><div class="k">抽题次数</div><div class="v">${st.challenges ?? '—'}</div></div>
<div class="kv"><div class="k">点位总数</div><div class="v">${st.points ?? '—'}</div></div>
<div class="kv"><div class="k">平均偏离中心</div><div class="v">${st.mean_offset_px ?? '—'}${st.mean_offset_px ? 'px' : ''}</div></div>
</div>`);
if (d.error) logRow('WARNING', d.error);
// 本地起冷却(服务端也会节流,这里只是让按钮状态立刻正确)
const t = state.targets.find((x) => x.key === $('target').value);
if (t) state.cooldownUntil = Date.now() + t.min_interval * 1000;
tickCooldown();
loadHistory();
}
function fmtMs(ms) {
if (ms === null || ms === undefined) return '—';
return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms';
}
/* ------------------------------------------------------------ 历史任务 */
async function loadHistory() {
const r = await fetch('/api/jobs').then((x) => x.json());
const tb = $('history').querySelector('tbody');
tb.innerHTML = '';
for (const j of r.jobs) {
const ec = j.result ? j.result.errorCode : null;
const info = ecInfo(ec);
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="mono muted">${hhmmss(j.created_at)}</td>
<td>${j.target}</td>
<td class="mono">${j.params.kind}</td>
<td class="mono ${info.cls}">${ec ?? '—'} <span class="muted" style="font-size:11px">${info.text}</span></td>
<td class="mono">${fmtMs(j.result ? j.result.elapsed_ms : null)}</td>
<td class="mono">${(j.result && j.result.success) ? '✓' : '—'}</td>
<td class="mono muted">${(j.result && j.result.ticket) ? j.result.ticket.slice(0, 14) + '…' : '—'}</td>`;
tr.onclick = () => replay(j.id);
tb.appendChild(tr);
}
}
/** 点历史行 → 重新订阅该任务的事件流,把整个过程重放一遍。 */
function replay(jobId) {
resetView();
logRow('meta', `重放任务 ${jobId} …`);
subscribe(jobId);
$('cancel').disabled = true;
}
function resetView() {
$('log').innerHTML = '';
$('images').innerHTML = '';
$('result').innerHTML = '<p class="muted">等待事件…</p>';
state.cards = {};
}
boot();