fix: restore reliable authenticated live recognition

This commit is contained in:
kiyo9w 2026-09-28 12:38:15 +09:00
parent 0e23425ef0
commit 934615b593
40 changed files with 2556 additions and 820 deletions

View file

@ -3,12 +3,24 @@
*
* A client of the recognition server, nothing else: live frames go out over
* /v1/stream, stills go out over /v1/recognize. Point it at another server with
* ?server=https://host:port and pass a token with ?token=...
* ?server=https://host:port. A token, when the server requires one, comes from
* sessionStorage (key dorayaki.token) or the fragment #token=..., never the query.
*/
const params = new URLSearchParams(location.search);
const SERVER = (params.get('server') || '').replace(/\/$/, '');
const TOKEN = params.get('token') || '';
const TOKEN = readToken();
function readToken() {
const stored = sessionStorage.getItem('dorayaki.token') || '';
const fragment = new URLSearchParams((location.hash || '').replace(/^#/, ''));
const fromHash = fragment.get('token') || '';
if (fromHash) {
sessionStorage.setItem('dorayaki.token', fromHash);
history.replaceState(null, '', location.pathname + location.search);
}
return fromHash || stored;
}
const I18N = {
en: {
@ -363,7 +375,7 @@ function thumbUrl(skuId) {
async function listCameras() {
const select = el('sourceSelect');
select.innerHTML = '';
select.replaceChildren();
if (!navigator.mediaDevices || !window.isSecureContext) {
const option = document.createElement('option');
option.value = 'file';
@ -390,13 +402,16 @@ function connectStream() {
if (state.ws && (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING)) return;
setConnection('connecting');
const base = SERVER || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;
const url = `${base.replace(/^http/, 'ws')}/v1/stream${TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : ''}`;
const url = `${base.replace(/^http/, 'ws')}/v1/stream`;
const socket = new WebSocket(url);
socket.binaryType = 'arraybuffer';
state.ws = socket;
socket.onopen = () => {
state.reconnectDelay = 1000;
// Browsers cannot set Authorization on a WebSocket. The server hello
// arrives first; this frame is the token, and it is not a URL.
if (TOKEN) socket.send(JSON.stringify({ type: 'auth', token: TOKEN }));
setConnection('live');
socket.send(JSON.stringify({ type: 'config', method: state.method, empty_slots: state.showEmpty }));
startSending();
@ -464,25 +479,25 @@ function sendFrame() {
function renderCompliance(compliance) {
const strip = el('complianceStrip');
strip.replaceChildren();
if (!compliance) { strip.hidden = true; return; }
const rows = compliance.rows || [];
const items = rows.slice(0, 3).map(
(row) => `<span class="compliance-item">⚠ ${t('rule_' + row.rule)} · ${t('row')} ${row.row_index} (${row.missing})</span>`
);
if (rows.length > 3) {
items.push(`<span class="compliance-item">+${rows.length - 3} ${t('more')}</span>`);
}
const hints = compliance.hints || [];
if (!items.length && hints.length) {
strip.hidden = false;
strip.dataset.kind = 'hint';
strip.innerHTML = hints.map((hint) => `<span class="compliance-item">${hint.detail}</span>`).join('');
return;
const chips = [];
for (const row of rows.slice(0, 3)) {
chips.push(`⚠ ${t('rule_' + row.rule)} · ${t('row')} ${row.row_index} (${row.missing})`);
}
if (!items.length) { strip.hidden = true; return; }
if (rows.length > 3) chips.push(`+${rows.length - 3} ${t('more')}`);
const texts = chips.length ? chips : hints.map((hint) => hint.detail);
if (!texts.length) { strip.hidden = true; return; }
strip.hidden = false;
strip.dataset.kind = 'violation';
strip.innerHTML = items.join('');
strip.dataset.kind = chips.length ? 'violation' : 'hint';
for (const text of texts) {
const item = document.createElement('span');
item.className = 'compliance-item';
item.textContent = text;
strip.appendChild(item);
}
}
function handleResult(message) {
@ -536,7 +551,7 @@ async function loadSamples() {
const response = await fetch(api('/v1/samples'), { headers: headers() });
const data = await response.json();
const select = el('sampleSelect');
select.innerHTML = '';
select.replaceChildren();
for (const sample of data.samples) {
const option = document.createElement('option');
option.value = sample.id;
@ -634,24 +649,42 @@ function renderInView() {
}
}
}
list.replaceChildren();
if (!counts.size) {
list.innerHTML = `<div class="placeholder small">${t('inviewPlaceholder')}</div>`;
const empty = document.createElement('div');
empty.className = 'placeholder small';
empty.textContent = t('inviewPlaceholder');
list.appendChild(empty);
return;
}
const rows = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([skuId, facings]) => {
const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]);
for (const [skuId, facings] of ranked) {
const product = productFor(skuId);
const name = product ? product.product_name : skuId;
const price = product ? `¥${Math.round(product.price)}` : '';
return `<div class="inview-row" data-sku="${skuId}">
<img class="inview-thumb" src="${thumbUrl(skuId)}" alt="">
<div class="inview-text"><div class="inview-name">${name}</div>
<div class="inview-meta">${price} · ${t('facings')} ${facings}</div></div>
<div class="inview-facings">${facings}</div></div>`;
}).join('');
list.innerHTML = rows;
list.querySelectorAll('.inview-row').forEach((row) => {
row.addEventListener('click', () => selectBySku(row.dataset.sku));
});
const row = document.createElement('div');
row.className = 'inview-row';
row.dataset.sku = skuId;
const thumb = document.createElement('img');
thumb.className = 'inview-thumb';
thumb.alt = '';
thumb.src = thumbUrl(skuId);
const text = document.createElement('div');
text.className = 'inview-text';
const nameEl = document.createElement('div');
nameEl.className = 'inview-name';
nameEl.textContent = name;
const meta = document.createElement('div');
meta.className = 'inview-meta';
meta.textContent = `${price} · ${t('facings')} ${facings}`;
text.append(nameEl, meta);
const count = document.createElement('div');
count.className = 'inview-facings';
count.textContent = String(facings);
row.append(thumb, text, count);
row.addEventListener('click', () => selectBySku(skuId));
list.appendChild(row);
}
}
function selectBySku(skuId) {
@ -715,9 +748,19 @@ function renderOcrSection(tag, reconciliation) {
if (tag.state === 'absent') lines.push([t('ocrState'), t('ocrAbsent'), false]);
}
if (reconciliation && reconciliation.message) lines.push([t('ocrNote'), reconciliation.message, state === 'mismatch']);
rows.innerHTML = lines
.map(([key, value, bad]) => `<div class="ocr-row"><span class="ocr-key">${key}</span><span class="ocr-val${bad ? ' bad' : ''}">${value}</span></div>`)
.join('');
rows.replaceChildren();
for (const [key, value, bad] of lines) {
const row = document.createElement('div');
row.className = 'ocr-row';
const keyEl = document.createElement('span');
keyEl.className = 'ocr-key';
keyEl.textContent = key;
const valueEl = document.createElement('span');
valueEl.className = bad ? 'ocr-val bad' : 'ocr-val';
valueEl.textContent = value;
row.append(keyEl, valueEl);
rows.appendChild(row);
}
}
function showProductCard(product, identity) {
@ -755,12 +798,22 @@ function showCandidatesCard(identity) {
el('metricReceipt').textContent = '—';
el('metricExpiry').textContent = '—';
const candidates = el('candidates');
const rows = (identity.candidates || []).map((candidate) => {
candidates.replaceChildren();
const title = document.createElement('div');
title.className = 'candidates-title';
title.textContent = t('candidatesTitle');
candidates.appendChild(title);
for (const candidate of identity.candidates || []) {
const product = productFor(candidate.sku_id);
const name = product ? product.product_name : candidate.sku_id;
return `<div class="candidate-row"><span>${name}</span><span>${candidate.score}</span></div>`;
}).join('');
candidates.innerHTML = `<div class="candidates-title">${t('candidatesTitle')}</div>${rows}`;
const row = document.createElement('div');
row.className = 'candidate-row';
const name = document.createElement('span');
name.textContent = product ? product.product_name : candidate.sku_id;
const score = document.createElement('span');
score.textContent = String(candidate.score);
row.append(name, score);
candidates.appendChild(row);
}
candidates.hidden = false;
el('showMatchBtn').disabled = false;
renderOcrSection(state.currentTag, state.currentReconciliation);
@ -889,10 +942,18 @@ function handleInspect(result) {
if (result.crop_jpeg_b64) {
el('productThumb').src = result.crop_jpeg_b64;
}
const box = el('matchBox');
box.replaceChildren();
if (result.match_visualization_jpeg_b64) {
el('matchBox').innerHTML = `<img src="${result.match_visualization_jpeg_b64}" alt="feature matches">`;
const image = document.createElement('img');
image.alt = 'feature matches';
image.src = result.match_visualization_jpeg_b64;
box.appendChild(image);
} else {
el('matchBox').innerHTML = `<span class="match-placeholder">${t('matchPlaceholder')}</span>`;
const placeholder = document.createElement('span');
placeholder.className = 'match-placeholder';
placeholder.textContent = t('matchPlaceholder');
box.appendChild(placeholder);
}
const identity = result.identity || {};
const parts = [];