openvibers/openvibe.exchange Commits
The owner console shows the picture-orientation detector and tries a picture (#223)
Changes
nnew file mode 100644@@@ -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; });++ });++})();@@@ -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];@@@ -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 };@@@ -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'],nnew file mode 100644@@@ -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; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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; });
+ });
+})();
@@ -109,6 +109,73 @@ function createConsoleRoutes({ config, ai }) {
@@ -109,6 +109,73 @@ function createConsoleRoutes({ config, ai }) {
try { return page(req, res, section, await ai.adminRequest(path)); }
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); }
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) => {
r.get(['/console', '/console/:section'], async (req, res) => {
const section = req.params.section || 'overview';
const section = req.params.section || 'overview';
const endpoint = READS[section];
const endpoint = READS[section];
@@ -333,5 +333,44 @@ function supplySection(data = {}) {
@@ -333,5 +333,44 @@ function supplySection(data = {}) {
</div>`;
</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 };
@@ -4,7 +4,7 @@ const { esc } = require('../render/html');
@@ -4,7 +4,7 @@ const { esc } = require('../render/html');
const designed = require('./console-ai');
const designed = require('./console-ai');
const SECTIONS = [
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'],
['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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; });