fix: restore reliable authenticated live recognition
This commit is contained in:
parent
0e23425ef0
commit
934615b593
40 changed files with 2556 additions and 820 deletions
|
|
@ -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 = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue