openvibers/openvibe.exchange Commits

The owner console shows the picture-orientation detector and tries a picture (#223)

OP OpenVibers committed 1 days ago · e8d941d3f3 · 5 files, +240 −3

* The owner console shows the picture-orientation detector and tries a picture

* The orientation model picker always offers OpenVibe's own choice

Changes

ddiff --git a/public/js/console-orientation.js b/public/js/console-orientation.js
nnew file mode 100644
iindex 0000000..ca72063
---- /dev/null
++++ b/public/js/console-orientation.js
@@@ -0,0 +1,55 @@
++/* The owner console's "Try a picture" check: one picture at 256 px through the detector, the answer in words.
++ It never turns or stores anything; the run on OpenVibe.AI is the same one the budget counts. */
++(function () {
++ 'use strict';
++ var file = document.querySelector('[data-orientation-file]');
++ var button = document.querySelector('[data-orientation-try]');
++ var out = document.querySelector('[data-orientation-result]');
++ if (!file || !button || !out) return;
++ var words = function (n) {
++ if (n === 0) return 'already upright';
++ if (n == null) return 'no answer';
++ return 'turn ' + n + '° clockwise';
++ };
++ function say(text) { out.textContent = text; }
++ function shrink(source) {
++ return new Promise(function (resolve, reject) {
++ var image = new Image();
++ image.onload = function () {
++ var scale = Math.min(1, 256 / Math.max(image.width, image.height));
++ var canvas = document.createElement('canvas');
++ canvas.width = Math.max(1, Math.round(image.width * scale));
++ canvas.height = Math.max(1, Math.round(image.height * scale));
++ canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
++ URL.revokeObjectURL(image.src);
++ resolve(canvas.toDataURL('image/jpeg', 0.85));
++ };
++ image.onerror = function () { URL.revokeObjectURL(image.src); reject(new Error('That file is not a picture this browser can read.')); };
++ image.src = URL.createObjectURL(source);
++ });
++ }
++ button.addEventListener('click', function () {
++ var picked = file.files && file.files[0];
++ if (!picked) { say('Choose a picture first.'); return; }
++ button.disabled = true;
++ say('Looking at it…');
++ shrink(picked).then(function (dataUrl) {
++ return fetch('/console/orientation/try', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ image: { data_url: dataUrl } }),
++ }).then(function (res) {
++ return res.json().then(function (body) { return { ok: res.ok, body: body }; });
++ });
++ }).then(function (r) {
++ if (!r.ok) { say(r.body && r.body.error ? r.body.error : 'The detector did not answer.'); return; }
++ if (r.body.method === 'off') { say('The detector is off. Nothing was sent.'); return; }
++ var confidence = r.body.confidence == null ? null : Math.round(Number(r.body.confidence) * 100);
++ say('It says ' + words(r.body.rotation) + (confidence == null ? '' : ' (confidence ' + confidence + '%)') +
++ ' · ' + (r.body.model || 'the routed model') + ' · $' + Number(r.body.cost_usd || 0).toFixed(4) +
++ (r.body.receipt_id ? ' · receipt ' + r.body.receipt_id : ''));
++ }).catch(function (err) {
++ say(err && err.message ? err.message : 'The detector did not answer.');
++ }).then(function () { button.disabled = false; });
++ });
++})();
ddiff --git a/server/http/console.js b/server/http/console.js
iindex 33ee393..4794344 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -109,6 +109,73 @@ function createConsoleRoutes({ config, ai }) {
try { return page(req, res, section, await ai.adminRequest(path)); }
catch (err) { return page(req, res, section, {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502); }
});
++ // ── Picture orientation (pictures, 2026-11-11) ──
++ // The detector's owner controls live in AI's `pics.orientation` task defaults; this page shows them, today's
++ // count and spend, the last tries, and a check that sends one picture without turning or storing anything.
++ const ORIENTATION = 'pics.orientation';
++ async function orientationData(req) {
++ const [admin, defaults] = await Promise.all([
++ ai.adminRequest('/api/v1/admin/orientation'),
++ ai.meRequest(req.viewer.token, '/api/v1/admin/task-defaults'),
++ ]);
++ let models = [];
++ try {
++ const view = await ai.meRequest(req.viewer.token, `/api/v1/choices?task=${encodeURIComponent(ORIENTATION)}`);
++ models = ((view.options && view.options.openvibe) || []).filter((m) => !m.adult)
++ .map(({ model, source, name, price_estimate_usd }) => ({ model, source, name, price_estimate_usd }));
++ } catch { /* the saved value still renders */ }
++ // A model task's two dynamic picks come first, as on the defaults page: "let OpenVibe choose" and "the
++ // cheapest good model" are not catalogue rows, and the seeded value is openvibe/auto.
++ for (const pick of [{ model: 'openvibe/auto', source: 'openvibe', name: 'Let OpenVibe choose', price_estimate_usd: null }, { model: 'openvibe/cheapest', source: 'openvibe', name: 'The cheapest good model', price_estimate_usd: null }]) {
++ if (!models.some((m) => m.model === pick.model && m.source === pick.source)) models.unshift(pick);
++ }
++ return { ...admin, models, current: (defaults.defaults || {})[ORIENTATION] || null };
++ }
++ r.get('/console/orientation', async (req, res) => {
++ try {
++ const data = await orientationData(req);
++ return send(res, 200, {
++ viewer: req.viewer, config, path: req.originalUrl, index: false, title: 'Picture orientation',
++ head: raw('<script src="/js/console-orientation.js" defer></script>'),
++ body: raw(consolePage({ section: 'orientation', data: { ...data, saved: req.query.saved === '1' } })),
++ });
++ } catch (err) { return page(req, res, 'orientation', {}, err.message || 'The detector setting could not be read.', err.status || 502); }
++ });
++ r.post('/console/orientation', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ const b = req.body || {};
++ const current = (await ai.meRequest(req.viewer.token, '/api/v1/admin/task-defaults')).defaults[ORIENTATION] || {};
++ const model = b.model ? JSON.parse(b.model) : (current.signed_in || { model: 'openvibe/auto', source: 'openvibe' });
++ const value = {
++ ...current,
++ signed_out: model, signed_in: model,
++ method: b.method === 'vision' ? 'vision' : 'off',
++ confidence: b.confidence === '' || b.confidence == null ? 0.8 : Number(b.confidence),
++ daily_cap: b.daily_cap === '' || b.daily_cap == null ? null : Number(b.daily_cap),
++ budget_usd_per_day: b.budget_usd_per_day === '' || b.budget_usd_per_day == null ? null : Number(b.budget_usd_per_day),
++ };
++ await ai.meRequest(req.viewer.token, '/api/v1/admin/task-defaults', { method: 'PUT', body: { defaults: [{ task: ORIENTATION, value }] } });
++ return res.redirect(303, '/console/orientation?saved=1');
++ } catch (err) {
++ try {
++ const data = await orientationData(req);
++ return send(res, err.status || 422, {
++ viewer: req.viewer, config, path: req.originalUrl, index: false, title: 'Picture orientation',
++ head: raw('<script src="/js/console-orientation.js" defer></script>'),
++ body: raw(consolePage({ section: 'orientation', data, error: err.message || 'That setting was not saved.' })),
++ });
++ } catch { return page(req, res, 'orientation', {}, err.message || 'That setting was not saved.', err.status || 502); }
++ }
++ });
++ r.post('/console/orientation/try', express.json({ limit: '3mb' }), async (req, res) => {
++ if (!sameOrigin(req)) return res.status(403).json({ error: 'A check comes from this page only.' });
++ try {
++ const out = await ai.meRequest(req.viewer.token, '/api/v1/vision/orientation', { method: 'POST', body: { image: req.body && req.body.image } });
++ return res.set('Cache-Control', 'no-store').json(out);
++ } catch (err) { return res.status(err.status || 502).json({ error: err.message || 'The detector did not answer.', code: err.code || null }); }
++ });
++
r.get(['/console', '/console/:section'], async (req, res) => {
const section = req.params.section || 'overview';
const endpoint = READS[section];
ddiff --git a/server/views/console-ai.js b/server/views/console-ai.js
iindex 1830a52..a185759 100644
---- a/server/views/console-ai.js
++++ b/server/views/console-ai.js
@@@ -333,5 +333,44 @@ function supplySection(data = {}) {
</div>`;
}
--const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, supply: supplySection, sync: syncSection };
--module.exports = { providersSection, modelsSection, pricebookSection, supplySection, syncSection, SECTIONS, CSS: marks.CSS };
++// ── The picture-orientation detector (pictures, 2026-11-11) ──
++// The owner's method, model, confidence and daily bounds, the detector's spend today, its last tries, and a check
++// that sends one picture to the detector. Plain kit markup; the look is the main session's.
++function orientationSection(data = {}) {
++ const setting = data.setting || {};
++ const today = data.today || {};
++ const recent = Array.isArray(data.recent) ? data.recent : [];
++ const models = Array.isArray(data.models) ? data.models : [];
++ const method = setting.method === 'vision' ? 'vision' : 'off';
++ const chosen = setting.model ? JSON.stringify(setting.model) : '';
++ const option = (m) => `<option value="${esc(JSON.stringify({ model: m.model, source: m.source }))}"${JSON.stringify({ model: m.model, source: m.source }) === chosen ? ' selected' : ''}>${esc(m.name || m.model)}${m.price_estimate_usd == null ? '' : ` · $${Number(m.price_estimate_usd).toFixed(4)} a run`}</option>`;
++ const rotationWord = (r) => (r === 0 ? 'already upright' : r == null ? '—' : `turn ${r}° clockwise`);
++ const money = (v) => (v == null || !Number.isFinite(Number(v)) ? '—' : `$${Number(v).toFixed(4)}`);
++ return `<div class="exc">
++<header class="exc-head"><h2>Picture orientation</h2><p>OpenVibe.Media asks this detector which way is up in a picture whose EXIF did not say. It sends a 256 px copy to the cheapest vision model OpenVibe routes to, gets 0, 90, 180 or 270 degrees with a confidence, and turns the picture only above the confidence you set. Off, nothing is sent.</p></header>
++${data.saved ? `<p class="exc-note" role="status">Saved. The detector reads this within a minute.</p>` : ''}
++<form class="ovk-form" method="post" action="/console/orientation">
++ <fieldset><legend>Method</legend>
++ <label><input type="radio" name="method" value="off"${method === 'off' ? ' checked' : ''}> Off — nothing is sent and no picture changes</label>
++ <label><input type="radio" name="method" value="vision"${method === 'vision' ? ' checked' : ''}> A vision model through OpenVibe's routes (the cheapest that can see)</label>
++ </fieldset>
++ <label>Model <select name="model" required>${models.length ? models.map(option).join('') : `<option value="${esc(chosen || '{"model":"openvibe/auto","source":"openvibe"}')}">OpenVibe's cheapest vision model</option>`}</select></label>
++ <p class="exc-note">OpenVibe's router picks the cheapest eligible model; a named model is used instead and still appears on every receipt.</p>
++ <label>Turn above this confidence <input name="confidence" type="number" min="0" max="1" step="0.01" value="${esc(setting.confidence == null ? 0.8 : setting.confidence)}"></label>
++ <label>Most pictures a day <input name="daily_cap" type="number" min="0" max="1000" step="1" value="${esc(setting.daily_cap == null ? '' : setting.daily_cap)}" placeholder="No cap"></label>
++ <label>Most spent a day (USD) <input name="budget_usd_per_day" type="number" min="0" max="1000" step="0.0001" value="${esc(setting.budget_usd_per_day == null ? '' : setting.budget_usd_per_day)}" placeholder="No budget"></label>
++ <p class="exc-actions"><button class="ovk-btn ovk-btn--primary" type="submit">Save the detector</button></p>
++</form>
++<section class="exc-sec"><h3>Today</h3>
++<p>${esc(String(today.runs || 0))} picture${Number(today.runs) === 1 ? '' : 's'} looked at · ${esc(money(today.cost_usd))} spent${setting.daily_cap != null ? ` · cap ${esc(String(setting.daily_cap))}` : ''}${setting.budget_usd_per_day != null ? ` · budget ${esc(money(setting.budget_usd_per_day))}` : ''}</p>
++${recent.length ? `<table class="ovk-table"><thead><tr><th scope="col">When</th><th scope="col">Answer</th><th scope="col">Confidence</th><th scope="col">Model</th><th scope="col">Cost</th><th scope="col">Receipt</th></tr></thead><tbody>${recent.map((r) => `<tr><td>${esc(String(r.created_at || '').replace('T', ' ').slice(0, 19))}</td><td>${esc(rotationWord(r.rotation))}${r.status && r.status !== 'succeeded' ? ` (${esc(r.status)})` : ''}</td><td>${r.confidence == null ? '—' : esc(String(Math.round(Number(r.confidence) * 100)) + '%')}</td><td>${esc(r.model || '—')}</td><td>${esc(money(r.cost_usd))}</td><td>${r.receipt_id ? `<code>${esc(r.receipt_id)}</code>` : '—'}</td></tr>`).join('')}</tbody></table>` : '<p class="exc-quiet">No picture has been looked at yet.</p>'}</section>
++<section class="exc-sec"><h3>Try a picture</h3>
++<p>Send one picture through the detector as it is set now. It is a real run on the same budget, and it never turns or stores anything: the answer and its receipt come back here.</p>
++<p><input type="file" accept="image/jpeg,image/png,image/webp" data-orientation-file> <button class="ovk-btn ovk-btn--quiet" type="button" data-orientation-try>Try it</button></p>
++<p class="exc-note" data-orientation-result role="status"></p>
++</section>
++</div>`;
++}
++
++const SECTIONS = { providers: providersSection, models: modelsSection, pricebook: pricebookSection, supply: supplySection, sync: syncSection, orientation: orientationSection };
++module.exports = { providersSection, modelsSection, pricebookSection, supplySection, syncSection, orientationSection, SECTIONS, CSS: marks.CSS };
ddiff --git a/server/views/console.js b/server/views/console.js
iindex 8fa765d..989e1ff 100644
---- a/server/views/console.js
++++ b/server/views/console.js
@@@ -4,7 +4,7 @@ const { esc } = require('../render/html');
const designed = require('./console-ai');
const SECTIONS = [
-- ['Overview', '/console'], ['Defaults', '/console/defaults'], ['GPU supply', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
++ ['Overview', '/console'], ['Defaults', '/console/defaults'], ['Orientation', '/console/orientation'], ['GPU supply', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
ddiff --git a/test/console-orientation.test.js b/test/console-orientation.test.js
nnew file mode 100644
iindex 0000000..feb930a
---- /dev/null
++++ b/test/console-orientation.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * The picture-orientation console: owner only, the detector's setting in plain kit markup, the save that reaches
++ * AI's `pics.orientation` task defaults, and the "try a picture" check that is one real run with its receipt.
++ */
++const assert = require('assert');
++const express = require('express');
++const { createServer } = require('http');
++const { createConsoleRoutes } = require('../server/http/console');
++
++const calls = [];
++const setting = { method: 'off', confidence: 0.8, daily_cap: 50, budget_usd_per_day: 0.25, model: { model: 'openvibe/auto', source: 'openvibe' }, max_cost_usd: 0.05 };
++const app = express();
++app.use((req, res, next) => {
++ if (req.get('x-role')) req.viewer = { kind: 'user', subject: 'usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', role: req.get('x-role'), token: 'test-session' };
++ next();
++});
++app.use(createConsoleRoutes({
++ config: { baseUrl: 'https://openvibe.exchange' },
++ ai: {
++ adminRequest: async (path, options) => {
++ calls.push({ kind: 'admin', path, options });
++ if (path === '/api/v1/admin/orientation') {
++ return { setting, today: { runs: 3, cost_usd: 0.0123 }, recent: [{ run_id: 'run_1', status: 'succeeded', created_at: '2026-11-11T20:00:00.000Z', rotation: 90, confidence: 0.93, provider: 'seam', model: 'see', cost_usd: 0.0014, receipt_id: 'rcpt_1' }] };
++ }
++ return {};
++ },
++ meRequest: async (token, path, options) => {
++ calls.push({ kind: 'me', token, path, options });
++ if (path === '/api/v1/admin/task-defaults') return { defaults: { 'pics.orientation': setting } };
++ if (path.startsWith('/api/v1/choices?task=')) return { options: { openvibe: [{ model: 'openvibe/auto', source: 'openvibe', name: 'Let OpenVibe choose', price_estimate_usd: null }, { model: 'openai/gpt-5-nano', source: 'openvibe', name: 'GPT-5 nano', price_estimate_usd: 0.0002, adult: false }] } };
++ if (path === '/api/v1/vision/orientation') return { method: 'vision', answered: true, rotation: 90, confidence: 0.93, model: 'gpt-5-nano', cost_usd: 0.0002, receipt_id: 'rcpt_test' };
++ return {};
++ },
++ },
++}));
++
++(async () => {
++ const server = createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = 'http://127.0.0.1:' + server.address().port;
++ const owner = { 'x-role': 'owner' };
++ try {
++ for (const role of ['', 'user', 'admin']) {
++ assert.strictEqual((await fetch(base + '/console/orientation', { headers: role ? { 'x-role': role } : {} })).status, role ? 403 : 404, `${role || 'signed out'} is not the owner`);
++ }
++ const page = await fetch(base + '/console/orientation', { headers: owner });
++ const html = await page.text();
++ assert.strictEqual(page.status, 200);
++ assert.ok(html.includes('<h2>Picture orientation</h2>'), 'the section names itself');
++ assert.ok(html.includes('name="method"') && html.includes('value="vision"'), 'the method is a plain radio choice');
++ assert.ok(html.includes('name="model"') && html.includes('GPT-5 nano'), 'the model choice is listed');
++ assert.ok(html.includes('name="confidence"') && html.includes('name="daily_cap"') && html.includes('name="budget_usd_per_day"'), 'confidence and both daily bounds are set here');
++ assert.ok(html.includes('3 pictures looked at') && html.includes('$0.0123'), "today's count and spend are shown");
++ assert.ok(html.includes('rcpt_1') && html.includes('turn 90° clockwise'), 'the last tries show the answer and receipt');
++ assert.ok(html.includes('data-orientation-file') && html.includes('Try a picture'), 'the check is here');
++ assert.ok(html.includes('action="/console/orientation"') && html.includes('class="ovk-btn ovk-btn--primary"'), 'plain kit markup');
++
++ const body = new URLSearchParams({ method: 'vision', model: JSON.stringify({ model: 'openai/gpt-5-nano', source: 'openvibe' }), confidence: '0.9', daily_cap: '100', budget_usd_per_day: '0.5' });
++ assert.strictEqual((await fetch(base + '/console/orientation', { method: 'POST', headers: { ...owner, 'Content-Type': 'application/x-www-form-urlencoded' }, body })).status, 403, 'cross-site is refused');
++ const saved = await fetch(base + '/console/orientation', { method: 'POST', redirect: 'manual', headers: { ...owner, Origin: 'https://openvibe.exchange', 'Content-Type': 'application/x-www-form-urlencoded' }, body });
++ assert.strictEqual(saved.status, 303);
++ const value = calls.at(-1).options.body.defaults[0].value;
++ assert.deepStrictEqual([value.method, value.signed_in.model, value.confidence, value.daily_cap, value.budget_usd_per_day], ['vision', 'openai/gpt-5-nano', 0.9, 100, 0.5]);
++ assert.strictEqual(value.signed_out.model, 'openai/gpt-5-nano', 'the model choice is the signed-out and signed-in default');
++
++ const tried = await fetch(base + '/console/orientation/try', { method: 'POST', headers: { ...owner, Origin: 'https://openvibe.exchange', 'Content-Type': 'application/json' }, body: JSON.stringify({ image: { data_url: 'data:image/jpeg;base64,aGk=' } }) });
++ assert.strictEqual(tried.status, 200);
++ const answer = await tried.json();
++ assert.deepStrictEqual([answer.rotation, answer.receipt_id], [90, 'rcpt_test']);
++ assert.ok(calls.some((c) => c.kind === 'me' && c.path === '/api/v1/vision/orientation' && c.options.body.image.data_url.startsWith('data:image/jpeg')), 'the picture reaches AI as a data URL');
++ console.log('Orientation console: owner-only, setting saved into AI defaults, try is a real run');
++ } finally {
++ await new Promise((r) => server.close(r));
++ }
++})().catch((e) => { console.error(e); process.exitCode = 1; });