openvibers/openvibe.exchange Commits
The owner's AI defaults: one row per task, the model inline, the fallback order (#155)
Changes
nnew file mode 100644@@@ -0,0 +1,60 @@++.ex-task-defaults{max-width:1100px;margin:auto;padding:24px 16px}++.ex-task-defaults h1{font-size:clamp(2rem,5vw,3.2rem);margin:.4em 0}++.ex-task-defaults p{color:var(--text-secondary);line-height:1.5}++.ex-default-tasks{display:grid;gap:20px;margin-top:28px}++.ex-default-task{min-width:0;padding:24px;border:1px solid var(--border);border-radius:16px;background:var(--bg-secondary)}++.ex-default-task>header{display:flex;justify-content:space-between;gap:16px}++.ex-default-task h2{font-size:1.1rem;margin:0;overflow-wrap:anywhere}++.ex-default-task select,.ex-default-task input,.ex-default-task textarea{box-sizing:border-box;max-width:100%;min-height:44px}++.ex-default-task select{width:100%}++.ex-default-picks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}++.ex-default-pick{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:12px 0;min-width:0}++.ex-default-pick>span{display:block;flex-basis:100%;font-size:.85rem}++.ex-default-pick select{flex:1;min-width:0}++.ex-default-task details{margin:16px 0}++.ex-default-task summary{min-height:44px;cursor:pointer;align-content:center}++.ex-default-task ol{padding:0;list-style:none}++.ex-default-task li{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px;border-bottom:1px solid var(--border)}++.ex-default-task li>span{flex:1;min-width:90px;overflow-wrap:anywhere}++.ex-default-task li select{flex:2;min-width:140px;width:auto}++.ex-default-task button{min-height:44px}++.ex-default-cost{display:flex;flex-direction:column;gap:8px;margin:20px 0;max-width:300px}++.ex-default-task fieldset{min-width:0}++.ex-default-task textarea{width:100%}++@media(max-width:600px){.ex-default-task{padding:16px}.ex-default-picks{grid-template-columns:minmax(0,1fr)}}++++/* One row per task (Opus, 11-09): task | the two models | Save, the details in a counted fold under the row; one list++ with lines between rows instead of a card per task. */++.ex-dt-list{gap:0;border:1px solid var(--border);border-radius:16px;background:var(--bg-secondary);overflow:hidden}++.ex-dt-row{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,2fr) auto;gap:8px 20px;align-items:center;padding:16px 18px}++.ex-dt-row+.ex-dt-row{border-top:1px solid var(--border)}++.ex-dt-task{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;align-items:start}++.ex-dt-task h2{grid-column:1;margin:0;font-size:1rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere}++.ex-dt-task p{grid-column:1;margin:0;font-size:.85rem}++.ex-dt-task .ovk-badge{grid-column:2;grid-row:1}++.ex-dt-picks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;min-width:0}++.ex-dt-picks .ex-default-pick{margin:0}++.ex-dt-picks .ex-default-pick>span{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted,var(--text-secondary))}++.ex-dt-go .ovk-btn{min-height:44px}++.ex-dt-more{grid-column:1/-1;margin:0}++.ex-dt-more summary{display:inline-flex;align-items:center;gap:8px;min-height:36px;cursor:pointer;color:var(--text-secondary);font-size:.88rem;font-weight:600}++.ex-dt-more summary span{font-weight:400;color:var(--text-muted,var(--text-secondary))}++.ex-dt-more-body{display:grid;gap:14px;padding:12px 0 4px}++.ex-dt-fallbacks h3{margin:0;font-size:.95rem}++.ex-dt-fallbacks p{margin:2px 0 8px;font-size:.85rem}++.ex-dt-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}++.ex-dt-add{margin:8px 0 0}++@media(max-width:860px){.ex-dt-row{grid-template-columns:minmax(0,1fr) auto}.ex-dt-picks{grid-column:1/-1;grid-row:2}.ex-dt-go{grid-column:2;grid-row:1}}++@media(max-width:520px){.ex-dt-picks{grid-template-columns:minmax(0,1fr)}.ex-dt-row{padding:14px}}++.ex-dt-row ol{counter-reset:ex-fb;list-style:none;margin:0;padding:0;display:grid;gap:6px}++.ex-dt-row ol li{counter-increment:ex-fb;display:grid;grid-template-columns:1.6em minmax(0,1fr) auto auto auto;gap:6px;align-items:center;padding:0;border:0}++.ex-dt-row ol li::before{content:counter(ex-fb) ".";color:var(--text-muted,var(--text-secondary));font-variant-numeric:tabular-nums;text-align:right}++.ex-dt-row ol li>[data-fallback-model]{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}++.ex-dt-row ol li select{width:100%;min-width:0}++.ex-dt-row ol li .ovk-btn{min-height:40px;min-width:40px;padding:0 10px}++.ex-dt-row ol:empty::after{content:"No fallbacks: when the chosen model is unavailable the request says so.";color:var(--text-muted,var(--text-secondary));font-size:.85rem}++.ex-dt-add{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;max-width:560px}++.ex-dt-row select,.ex-dt-row input,.ex-dt-row textarea{box-sizing:border-box;max-width:100%;min-height:42px}++.ex-dt-row textarea{width:100%;max-width:560px}++.ex-dt-row .ex-default-cost{margin:0}++@media(max-width:520px){.ex-dt-row ol li{grid-template-columns:1.4em minmax(0,1fr) auto auto;}.ex-dt-row ol li .ovk-btn:last-child{grid-column:2/-1}}nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++/**++ * The owner's AI defaults: the fallback order moves by drag or by its arrow buttons, "Add fallback" appends the model++ * chosen beside it, and changing a fallback's picker rewrites the value that form posts. One task posts at a time;++ * the server reads the order as the list stands.++ */++document.querySelectorAll('[data-task-default]').forEach((form) => {++ const list = form.querySelector('[data-fallback-list]');++ const models = JSON.parse(list.dataset.models || '[]');++ const byValue = new Map(models.map((m) => [JSON.stringify({ model: m.model, source: m.source }), m.model]));++ let dragged = null;++++ const buttonsFor = (li) => {++ for (const [move, text, label] of [['-1', '↑', 'Move fallback up'], ['1', '↓', 'Move fallback down']]) {++ const button = document.createElement('button');++ button.type = 'button';++ button.className = 'ovk-btn ovk-btn--quiet';++ button.textContent = text;++ button.setAttribute('aria-label', label);++ button.dataset.move = move;++ li.append(button);++ }++ const remove = document.createElement('button');++ remove.type = 'button';++ remove.className = 'ovk-btn ovk-btn--quiet';++ remove.textContent = 'Remove';++ remove.setAttribute('aria-label', 'Remove fallback');++ remove.dataset.remove = '';++ li.append(remove);++ };++++ list.addEventListener('change', (event) => {++ const select = event.target.closest('[data-fallback-edit]');++ if (!select) return;++ const li = select.closest('li');++ li.querySelector('[name=fallback]').value = select.value;++ const label = li.querySelector('[data-fallback-model]');++ if (label) label.textContent = byValue.get(select.value) || JSON.parse(select.value).model;++ });++ list.addEventListener('click', (event) => {++ const button = event.target.closest('button');++ if (!button) return;++ const li = button.closest('li');++ if (button.hasAttribute('data-remove')) li.remove();++ else if (button.dataset.move === '-1' && li.previousElementSibling) list.insertBefore(li, li.previousElementSibling);++ else if (button.dataset.move === '1' && li.nextElementSibling) list.insertBefore(li.nextElementSibling, li);++ });++ list.addEventListener('dragstart', (event) => {++ dragged = event.target.closest('li');++ event.dataTransfer.effectAllowed = 'move';++ });++ list.addEventListener('dragover', (event) => { if (dragged) event.preventDefault(); });++ list.addEventListener('drop', (event) => {++ event.preventDefault();++ const target = event.target.closest('li');++ if (dragged && target && target !== dragged) list.insertBefore(dragged, target);++ dragged = null;++ });++++ form.querySelector('[data-add-fallback]').addEventListener('click', () => {++ const value = form.querySelector('[name=fallback_new]').value;++ if (!value || list.children.length >= 10) return;++ const choice = JSON.parse(value);++ const li = document.createElement('li');++ li.draggable = true;++ const label = document.createElement('span');++ label.dataset.fallbackModel = '';++ label.textContent = byValue.get(value) || choice.model;++ const input = document.createElement('input');++ input.type = 'hidden';++ input.name = 'fallback';++ input.value = value;++ li.append(label, input);++ buttonsFor(li);++ list.append(li);++ });++});@@@ -312,8 +312,8 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (/** The person's own AI credentials: pass only their signed Network session, never the Exchange service token. */async function meRequest(token, path, { method = 'GET', body = undefined } = {}) {-- if (!token) throw Object.assign(new Error('Sign in is required'), { status: 401 });-- const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, Origin: config.baseUrl };++ if (!token && path !== '/api/v1/tasks' && !path.startsWith('/api/v1/choices?')) throw Object.assign(new Error('Sign in is required'), { status: 401 });++ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), Origin: config.baseUrl };if (body !== undefined) headers['Content-Type'] = 'application/json';const res = await fetchImpl(`${config.ai.url}${path}`, {method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),@@@ -4,6 +4,7 @@ const express = require('express');const { raw } = require('../render/html');const { send } = require('../render/layout');const { consolePage } = require('../views/console');++const { defaultsPage } = require('../views/task-defaults');const READS = Object.freeze({supply: '/api/v1/admin/console/supply',@@@ -23,6 +24,51 @@ function createConsoleRoutes({ config, ai }) {body: raw(consolePage({ section, data, error })),});r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(404));++ // The owner's AI defaults: one row per task, the model inline, and the fallback order. Posting one task's form++ // saves only that task, and AI resolves it within a minute.++ const DEFAULTS_HEAD = raw('<link rel="stylesheet" href="/css/task-defaults.css"><script src="/js/task-defaults.js" defer></script>');++ // Owner defaults are OpenVibe supply only, so each picker lists the network's offers for the task's kind: the++ // chat (or code, vision) models, and for an image task its image models too. A failed read leaves that task's++ // pickers empty rather than the page; the saved values still render from the defaults.++ const networkModels = async (task) => {++ try {++ const view = await ai.meRequest(req.viewer.token, `/api/v1/choices?task=${encodeURIComponent(task.name)}`);++ const list = ((view.options && view.options.openvibe) || []).filter((m) => !m.adult);++ const models = list.map(({ model, source, name, price_estimate_usd }) => ({ model, source, name, price_estimate_usd }));++ // A model task's two dynamic picks come first: "let OpenVibe choose" is the built-in every task starts++ // with, and "the cheapest good model" is what Design does signed out. Neither is a single catalogue row,++ // and a search task has neither: it names an engine.++ if (task.kind !== 'search') models.unshift({ 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 });++ return { models, imageModels: task.kind === 'image' ? models : [] };++ } catch { return { models: [], imageModels: [] }; }++ };++ r.get('/console/defaults', async (req, res) => {++ try {++ const data = await ai.meRequest(req.viewer.token, '/api/v1/admin/task-defaults');++ const optionsByTask = {};++ for (const task of data.tasks || []) optionsByTask[task.name] = await networkModels(task);++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, index: false, title: 'AI defaults', head: DEFAULTS_HEAD, body: raw(defaultsPage({ ...data, optionsByTask })) });++ } catch (err) { return page(req, res, 'defaults', {}, err.message, err.status || 502); }++ });++ r.post('/console/defaults', async (req, res) => {++ if (!sameOrigin(req)) return res.sendStatus(403);++ try {++ const b = req.body;++ const fallbacks = b.fallback == null ? [] : [].concat(b.fallback);++ const value = {++ signed_out: JSON.parse(b.signed_out),++ signed_in: JSON.parse(b.signed_in),++ fallbacks: fallbacks.map((v) => JSON.parse(v)),++ image_model: b.image_model ? JSON.parse(b.image_model) : null,++ // One engine a line; a leading - or − turns it off.++ search_engines: String(b.search_engines || '').split(/\r?\n/).map((x) => x.trim()).filter(Boolean)++ .map((x) => ({ engine: x.replace(/^[-−]/, ''), enabled: !/^[-−]/.test(x) })),++ max_cost_usd: b.max_cost_usd === '' ? null : Number(b.max_cost_usd),++ };++ await ai.meRequest(req.viewer.token, '/api/v1/admin/task-defaults', { method: 'PUT', body: { defaults: [{ task: b.task, value }] } });++ return res.redirect(303, '/console/defaults');++ } catch (err) { return page(req, res, 'defaults', {}, err.message, err.status || 422); }++ });r.get('/console/runs/:id', async (req, res) => {try { return page(req, res, 'runs', await ai.adminRequest(`/api/v1/admin/console/run?id=${encodeURIComponent(req.params.id)}`)); }catch (err) { return page(req, res, 'runs', {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502); }@@@ -437,6 +437,16 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''return page(req, res, { title: 'The API', index: false, body: raw(views.apiPage({ catalog, usage, viewer: req.viewer, keyPrivacy })) });});++ r.get('/api/ai/choices', async (req,res) => {++ try { res.set('Cache-Control','private, no-store').json(await ai.meRequest(req.viewer?.token, '/api/v1/choices?task='+encodeURIComponent(String(req.query.task || '')))); }++ catch(err) { res.status(err.status || 502).json({detail:err.message}); }++ });++ r.put('/api/ai/choices/:task', express.json({limit:'4kb'}), async (req,res) => {++ if (req.get('origin') !== new URL(config.baseUrl).origin) return res.sendStatus(403);++ try { res.json(await ai.meRequest(req.viewer?.token, '/api/v1/me/choices/'+encodeURIComponent(req.params.task), {method:'PUT',body:req.body})); }++ catch(err) { res.status(err.status || 502).json({detail:err.message}); }++ });++// A person's AI credentials live on Exchange. The user's signed Network token travels server-to-server to AI;// the page and its responses contain metadata only, never a saved API key or cookie value.r.get('/privacy/keys', async (req, res) => {@@@ -3,7 +3,7 @@const { esc } = require('../render/html');const SECTIONS = [-- ['Overview', '/console'], ['GPU supply (dry run)', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],++ ['Overview', '/console'], ['Defaults', '/console/defaults'], ['GPU supply (dry run)', '/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,77 @@++'use strict';++/**++ * The owner's AI defaults (plan §1, owner rank): one row per task with the model inline and the fallback order.++ * Owner defaults name OpenVibe supply only (AI enforces that), so each picker is a plain model list rather than the++ * person-facing chip: the chip carries "your keys" and "your machines", which an owner default may never use.++ * Design and layout come from the Kit; Opus restyles on review.++ */++const { esc } = require('../render/html');++++const AUTO = { model: 'openvibe/auto', source: 'openvibe' };++++const LABEL = { 'openvibe/auto': 'Let OpenVibe choose' };++++const option = (value, label, selected) => `<option value="${esc(JSON.stringify(value))}"${selected ? ' selected' : ''}>${esc(label)}</option>`;++++/** One model picker: the current choice, and the models the task may use. */++function picker(name, label, choice, models) {++ const current = choice ? JSON.stringify(choice) : '';++ const known = models.some((m) => JSON.stringify({ model: m.model, source: m.source }) === current);++ return `<label class="ex-default-pick"><span>${esc(label)}</span><select name="${esc(name)}" required>++ ${known || !choice ? '' : option(choice, `${LABEL[choice.model] || choice.model} (not listed)`, true)}++ ${models.map((m) => option({ model: m.model, source: m.source }, `${LABEL[m.model] || m.name || m.model}${m.price_estimate_usd == null ? '' : ` · $${m.price_estimate_usd.toFixed(4)} a run`}`, !known && JSON.stringify({ model: m.model, source: m.source }) === current)).join('')}++ </select></label>`;++}++++function fallbackRow(choice, models) {++ return `<li draggable="true"><span data-fallback-model>${esc(choice.model)}</span><input type="hidden" name="fallback" value="${esc(JSON.stringify(choice))}"><select data-fallback-edit aria-label="Change this fallback">++ ${models.map((m) => option({ model: m.model, source: m.source }, m.name || m.model, m.model === choice.model)).join('')}++ </select><button type="button" class="ovk-btn ovk-btn--quiet" data-move="-1" aria-label="Move fallback up">↑</button><button type="button" class="ovk-btn ovk-btn--quiet" data-move="1" aria-label="Move fallback down">↓</button><button type="button" class="ovk-btn ovk-btn--quiet" data-remove>Remove</button></li>`;++}++++/**++ * One task as one row (Opus's design, 11-09: "one row per task, the model inline"): the task and what it is for, the++ * signed-out and signed-in models side by side, Save at the end; the fallback order, the image model, the search++ * engines and the cost ceiling in a counted fold under the row.++ */++function taskForm(task, options) {++ const d = options.current || { signed_out: task.default, signed_in: task.default, fallbacks: [], search_engines: [], image_model: null, max_cost_usd: null };++ const models = options.models || [];++ const more = [`${d.fallbacks.length} fallback${d.fallbacks.length === 1 ? '' : 's'}`, task.kind === 'image' ? 'image model' : '', task.kind === 'search' ? 'search engines' : '', d.max_cost_usd != null ? `ceiling $${Number(d.max_cost_usd).toFixed(4)}` : 'no ceiling'].filter(Boolean).join(' · ');++ return `<form class="ex-dt-row ovk-form" method="post" action="/console/defaults" data-task-default>++ <input type="hidden" name="task" value="${esc(task.name)}">++ <div class="ex-dt-task"><h2>${esc(task.name)}</h2><p>${esc(task.sentence)}</p><span class="ovk-badge">${esc(task.kind)}</span></div>++ <div class="ex-dt-picks">++ ${picker('signed_out', 'Signed out', d.signed_out, models)}++ ${picker('signed_in', 'Signed in', d.signed_in, models)}++ </div>++ <div class="ex-dt-go"><button class="ovk-btn ovk-btn--primary" type="submit" aria-label="Save ${esc(task.name)}">Save</button></div>++ <details class="ex-dt-more">++ <summary>More <span>${esc(more)}</span></summary>++ <div class="ex-dt-more-body">++ ${task.kind === 'image' ? picker('image_model', 'Image model', d.image_model || d.signed_in, options.imageModels || []) : ''}++ <section class="ex-dt-fallbacks"><h3>Fallback order</h3><p>The first available model answers. Drag to reorder, or use the arrows.</p>++ <ol data-fallback-list data-models="${esc(JSON.stringify(models))}">${d.fallbacks.map((c) => fallbackRow(c, models)).join('')}</ol>++ <div class="ex-default-pick ex-dt-add"><span class="ex-dt-sr">Add a fallback</span><select name="fallback_new" aria-label="A model to add as a fallback">++ ${models.map((m) => option({ model: m.model, source: m.source }, m.name || m.model, false)).join('')}++ </select><button type="button" class="ovk-btn ovk-btn--quiet" data-add-fallback>Add fallback</button></div></section>++ ${task.kind === 'search' ? `<fieldset class="ex-dt-engines"><legend>Search engines</legend><p>Enabled engines are tried in this order.</p><textarea name="search_engines" rows="4" aria-label="Search engines in order; prefix a disabled engine with a minus">${esc(d.search_engines.map((e) => (e.enabled ? '' : '-') + e.engine).join('\n'))}</textarea></fieldset>` : ''}++ <label class="ex-default-cost">Per-run cost ceiling (USD)<input name="max_cost_usd" type="number" min="0" max="1000" step="0.0001" value="${esc(d.max_cost_usd ?? '')}" placeholder="No task ceiling"></label>++ </div>++ </details>++ </form>`;++}++++/**++ * optionsByTask: { [task]: { current, models, imageModels } } — AI's resolved options for each task. A task whose++ * options could not be read still renders, with the options it has, so one failing read never hides the page.++ */++function defaultsPage({ tasks = [], defaults = {}, optionsByTask = {}, error = null } = {}) {++ return `<div class="ex-task-defaults">++ <header><p class="ovk-eyebrow">Owner console</p><h1>AI defaults</h1><p>Choose what answers across OpenVibe. A person's own choice takes priority, and every change is audited and reaches services within a minute.</p></header>++ ${error ? `<p role="alert">${esc(error)}</p>` : ''}++ <div class="ex-default-tasks ex-dt-list">${tasks.map((task) => taskForm(task, { current: defaults[task.name] || null, ...(optionsByTask[task.name] || {}) })).join('')}</div>++</div>`;++}++++module.exports = { defaultsPage };nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * The owner's AI defaults page and its post: owner-only, CSRF-guarded, and the payload it sends AI carries the++ * signed-out and signed-in picks, the fallback order as it stands, the search order and the cost ceiling.++ */++const assert = require('assert');++const express = require('express');++const { createServer } = require('http');++const { createConsoleRoutes } = require('../server/http/console');++++const task = { name: 'design.icon', kind: 'code', sentence: 'Draw an original SVG icon.', default: { model: 'openvibe/auto', source: 'openvibe' } };++const searchTask = { name: 'search.web', kind: 'search', sentence: 'Find sources on the web.', default: { model: 'search/wikipedia', source: 'openvibe' } };++const pick = { model: 'anthropic/claude-opus-5-5', source: 'openvibe' };++const defaults = { 'design.icon': { signed_out: pick, signed_in: pick, fallbacks: [pick], image_model: null, search_engines: [], max_cost_usd: 0.5 } };++const choicesFor = (name) => ({ options: { openvibe: [{ model: name === 'search.web' ? 'search/wikipedia' : 'anthropic/claude-opus-5-5', source: 'openvibe', name: 'Claude Opus 5.5', price_estimate_usd: 0.02, adult: false }], keys: [], machines: [] } });++++const calls = [];++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: {++ meRequest: async (token, path, options) => {++ calls.push({ token, path, options });++ if (path === '/api/v1/admin/task-defaults') return { tasks: [task, searchTask], defaults };++ if (path.startsWith('/api/v1/choices?task=')) return choicesFor(decodeURIComponent(path.split('=')[1]));++ return { defaults };++ },++ },++}));++++(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;++ try {++ // Owner rank only: no viewer, a person and an admin all get 404.++ for (const role of ['', 'user', 'admin']) {++ assert.strictEqual((await fetch(base + '/console/defaults', { headers: role ? { 'x-role': role } : {} })).status, 404, `${role || 'signed out'} is not the owner`);++ }++ const page = await fetch(base + '/console/defaults', { headers: { 'x-role': 'owner' } });++ const html = await page.text();++ assert.strictEqual(page.status, 200);++ assert.ok(html.includes('<h1>AI defaults</h1>'), 'the page names itself');++ assert.ok(html.includes('data-fallback-list'), 'the fallback order is on the page');++ assert.ok(html.includes('data-task-default'), 'each task has its own form');++ assert.ok(html.includes('name="signed_out"') && html.includes('name="signed_in"'), 'both picks are on the page');++ assert.ok(html.includes('name="search_engines"'), 'the search task carries its engines');++ assert.ok(html.includes('data-models='), 'the list carries the models the page may offer');++ assert.ok(!html.includes('data-ai-choice'), 'an owner default never offers the person-facing chip (keys, machines)');++ assert.strictEqual(calls[0].token, 'test-session', 'the owner session reaches AI');++++ const body = new URLSearchParams({ task: task.name, signed_out: JSON.stringify(pick), signed_in: JSON.stringify(pick), fallback: JSON.stringify(pick), max_cost_usd: '0.1' });++ // A cross-site post is refused even with the owner's session.++ assert.strictEqual((await fetch(base + '/console/defaults', { method: 'POST', headers: { 'x-role': 'owner', 'Content-Type': 'application/x-www-form-urlencoded' }, body })).status, 403);++ const saved = await fetch(base + '/console/defaults', { method: 'POST', redirect: 'manual', headers: { 'x-role': 'owner', Origin: 'https://openvibe.exchange', 'Content-Type': 'application/x-www-form-urlencoded' }, body });++ assert.strictEqual(saved.status, 303, 'a save redirects back to the page');++ const sent = calls.at(-1).options.body.defaults[0];++ assert.deepStrictEqual([sent.task, sent.value.signed_in.model, sent.value.fallbacks.length, sent.value.max_cost_usd], [task.name, pick.model, 1, 0.1]);++++ // The search order is one engine a line, and a leading minus turns one off.++ const searchBody = new URLSearchParams({ task: searchTask.name, signed_out: JSON.stringify({ model: 'search/wikipedia', source: 'openvibe' }), signed_in: JSON.stringify({ model: 'search/wikipedia', source: 'openvibe' }), search_engines: 'wikipedia\n-commons\nopenverse', max_cost_usd: '' });++ const searchSaved = await fetch(base + '/console/defaults', { method: 'POST', redirect: 'manual', headers: { 'x-role': 'owner', Origin: 'https://openvibe.exchange', 'Content-Type': 'application/x-www-form-urlencoded' }, body: searchBody });++ assert.strictEqual(searchSaved.status, 303);++ const searchValue = calls.at(-1).options.body.defaults[0].value;++ assert.deepStrictEqual(searchValue.search_engines, [{ engine: 'wikipedia', enabled: true }, { engine: 'commons', enabled: false }, { engine: 'openverse', enabled: true }]);++ assert.strictEqual(searchValue.max_cost_usd, null, 'an empty ceiling is no ceiling');++ console.log('Defaults: owner-only page, CSRF protection, and the payload AI receives');++ } 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,60 @@
@@ -0,0 +1,60 @@
+.ex-task-defaults{max-width:1100px;margin:auto;padding:24px 16px}
+.ex-task-defaults h1{font-size:clamp(2rem,5vw,3.2rem);margin:.4em 0}
+.ex-task-defaults p{color:var(--text-secondary);line-height:1.5}
+.ex-default-tasks{display:grid;gap:20px;margin-top:28px}
+.ex-default-task{min-width:0;padding:24px;border:1px solid var(--border);border-radius:16px;background:var(--bg-secondary)}
+.ex-default-task>header{display:flex;justify-content:space-between;gap:16px}
+.ex-default-task h2{font-size:1.1rem;margin:0;overflow-wrap:anywhere}
+.ex-default-task select,.ex-default-task input,.ex-default-task textarea{box-sizing:border-box;max-width:100%;min-height:44px}
+.ex-default-task select{width:100%}
+.ex-default-picks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
+.ex-default-pick{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:12px 0;min-width:0}
+.ex-default-pick>span{display:block;flex-basis:100%;font-size:.85rem}
+.ex-default-pick select{flex:1;min-width:0}
+.ex-default-task details{margin:16px 0}
+.ex-default-task summary{min-height:44px;cursor:pointer;align-content:center}
+.ex-default-task ol{padding:0;list-style:none}
+.ex-default-task li{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px;border-bottom:1px solid var(--border)}
+.ex-default-task li>span{flex:1;min-width:90px;overflow-wrap:anywhere}
+.ex-default-task li select{flex:2;min-width:140px;width:auto}
+.ex-default-task button{min-height:44px}
+.ex-default-cost{display:flex;flex-direction:column;gap:8px;margin:20px 0;max-width:300px}
+.ex-default-task fieldset{min-width:0}
+.ex-default-task textarea{width:100%}
+@media(max-width:600px){.ex-default-task{padding:16px}.ex-default-picks{grid-template-columns:minmax(0,1fr)}}
+
+/* One row per task (Opus, 11-09): task | the two models | Save, the details in a counted fold under the row; one list
+ with lines between rows instead of a card per task. */
+.ex-dt-list{gap:0;border:1px solid var(--border);border-radius:16px;background:var(--bg-secondary);overflow:hidden}
+.ex-dt-row{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,2fr) auto;gap:8px 20px;align-items:center;padding:16px 18px}
+.ex-dt-row+.ex-dt-row{border-top:1px solid var(--border)}
+.ex-dt-task{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;align-items:start}
+.ex-dt-task h2{grid-column:1;margin:0;font-size:1rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere}
+.ex-dt-task p{grid-column:1;margin:0;font-size:.85rem}
+.ex-dt-task .ovk-badge{grid-column:2;grid-row:1}
+.ex-dt-picks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;min-width:0}
+.ex-dt-picks .ex-default-pick{margin:0}
+.ex-dt-picks .ex-default-pick>span{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted,var(--text-secondary))}
+.ex-dt-go .ovk-btn{min-height:44px}
+.ex-dt-more{grid-column:1/-1;margin:0}
+.ex-dt-more summary{display:inline-flex;align-items:center;gap:8px;min-height:36px;cursor:pointer;color:var(--text-secondary);font-size:.88rem;font-weight:600}
+.ex-dt-more summary span{font-weight:400;color:var(--text-muted,var(--text-secondary))}
+.ex-dt-more-body{display:grid;gap:14px;padding:12px 0 4px}
+.ex-dt-fallbacks h3{margin:0;font-size:.95rem}
+.ex-dt-fallbacks p{margin:2px 0 8px;font-size:.85rem}
+.ex-dt-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+.ex-dt-add{margin:8px 0 0}
+@media(max-width:860px){.ex-dt-row{grid-template-columns:minmax(0,1fr) auto}.ex-dt-picks{grid-column:1/-1;grid-row:2}.ex-dt-go{grid-column:2;grid-row:1}}
+@media(max-width:520px){.ex-dt-picks{grid-template-columns:minmax(0,1fr)}.ex-dt-row{padding:14px}}
+.ex-dt-row ol{counter-reset:ex-fb;list-style:none;margin:0;padding:0;display:grid;gap:6px}
+.ex-dt-row ol li{counter-increment:ex-fb;display:grid;grid-template-columns:1.6em minmax(0,1fr) auto auto auto;gap:6px;align-items:center;padding:0;border:0}
+.ex-dt-row ol li::before{content:counter(ex-fb) ".";color:var(--text-muted,var(--text-secondary));font-variant-numeric:tabular-nums;text-align:right}
+.ex-dt-row ol li>[data-fallback-model]{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+.ex-dt-row ol li select{width:100%;min-width:0}
+.ex-dt-row ol li .ovk-btn{min-height:40px;min-width:40px;padding:0 10px}
+.ex-dt-row ol:empty::after{content:"No fallbacks: when the chosen model is unavailable the request says so.";color:var(--text-muted,var(--text-secondary));font-size:.85rem}
+.ex-dt-add{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;max-width:560px}
+.ex-dt-row select,.ex-dt-row input,.ex-dt-row textarea{box-sizing:border-box;max-width:100%;min-height:42px}
+.ex-dt-row textarea{width:100%;max-width:560px}
+.ex-dt-row .ex-default-cost{margin:0}
+@media(max-width:520px){.ex-dt-row ol li{grid-template-columns:1.4em minmax(0,1fr) auto auto;}.ex-dt-row ol li .ovk-btn:last-child{grid-column:2/-1}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+/**
+ * The owner's AI defaults: the fallback order moves by drag or by its arrow buttons, "Add fallback" appends the model
+ * chosen beside it, and changing a fallback's picker rewrites the value that form posts. One task posts at a time;
+ * the server reads the order as the list stands.
+ */
+document.querySelectorAll('[data-task-default]').forEach((form) => {
+ const list = form.querySelector('[data-fallback-list]');
+ const models = JSON.parse(list.dataset.models || '[]');
+ const byValue = new Map(models.map((m) => [JSON.stringify({ model: m.model, source: m.source }), m.model]));
+ let dragged = null;
+
+ const buttonsFor = (li) => {
+ for (const [move, text, label] of [['-1', '↑', 'Move fallback up'], ['1', '↓', 'Move fallback down']]) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'ovk-btn ovk-btn--quiet';
+ button.textContent = text;
+ button.setAttribute('aria-label', label);
+ button.dataset.move = move;
+ li.append(button);
+ }
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'ovk-btn ovk-btn--quiet';
+ remove.textContent = 'Remove';
+ remove.setAttribute('aria-label', 'Remove fallback');
+ remove.dataset.remove = '';
+ li.append(remove);
+ };
+
+ list.addEventListener('change', (event) => {
+ const select = event.target.closest('[data-fallback-edit]');
+ if (!select) return;
+ const li = select.closest('li');
+ li.querySelector('[name=fallback]').value = select.value;
+ const label = li.querySelector('[data-fallback-model]');
+ if (label) label.textContent = byValue.get(select.value) || JSON.parse(select.value).model;
+ });
+ list.addEventListener('click', (event) => {
+ const button = event.target.closest('button');
+ if (!button) return;
+ const li = button.closest('li');
+ if (button.hasAttribute('data-remove')) li.remove();
+ else if (button.dataset.move === '-1' && li.previousElementSibling) list.insertBefore(li, li.previousElementSibling);
+ else if (button.dataset.move === '1' && li.nextElementSibling) list.insertBefore(li.nextElementSibling, li);
+ });
+ list.addEventListener('dragstart', (event) => {
+ dragged = event.target.closest('li');
+ event.dataTransfer.effectAllowed = 'move';
+ });
+ list.addEventListener('dragover', (event) => { if (dragged) event.preventDefault(); });
+ list.addEventListener('drop', (event) => {
+ event.preventDefault();
+ const target = event.target.closest('li');
+ if (dragged && target && target !== dragged) list.insertBefore(dragged, target);
+ dragged = null;
+ });
+
+ form.querySelector('[data-add-fallback]').addEventListener('click', () => {
+ const value = form.querySelector('[name=fallback_new]').value;
+ if (!value || list.children.length >= 10) return;
+ const choice = JSON.parse(value);
+ const li = document.createElement('li');
+ li.draggable = true;
+ const label = document.createElement('span');
+ label.dataset.fallbackModel = '';
+ label.textContent = byValue.get(value) || choice.model;
+ const input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = 'fallback';
+ input.value = value;
+ li.append(label, input);
+ buttonsFor(li);
+ list.append(li);
+ });
+});
@@ -312,8 +312,8 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -312,8 +312,8 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
/** The person's own AI credentials: pass only their signed Network session, never the Exchange service token. */
/** The person's own AI credentials: pass only their signed Network session, never the Exchange service token. */
async function meRequest(token, path, { method = 'GET', body = undefined } = {}) {
async function meRequest(token, path, { method = 'GET', body = undefined } = {}) {
- if (!token) throw Object.assign(new Error('Sign in is required'), { status: 401 });
- const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, Origin: config.baseUrl };
+ if (!token && path !== '/api/v1/tasks' && !path.startsWith('/api/v1/choices?')) throw Object.assign(new Error('Sign in is required'), { status: 401 });
+ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), Origin: config.baseUrl };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetchImpl(`${config.ai.url}${path}`, {
const res = await fetchImpl(`${config.ai.url}${path}`, {
method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
@@ -4,6 +4,7 @@ const express = require('express');
@@ -4,6 +4,7 @@ const express = require('express');
const { raw } = require('../render/html');
const { raw } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { consolePage } = require('../views/console');
const { consolePage } = require('../views/console');
+const { defaultsPage } = require('../views/task-defaults');
const READS = Object.freeze({
const READS = Object.freeze({
supply: '/api/v1/admin/console/supply',
supply: '/api/v1/admin/console/supply',
@@ -23,6 +24,51 @@ function createConsoleRoutes({ config, ai }) {
@@ -23,6 +24,51 @@ function createConsoleRoutes({ config, ai }) {
body: raw(consolePage({ section, data, error })),
body: raw(consolePage({ section, data, error })),
});
});
r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(404));
r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(404));
+ // The owner's AI defaults: one row per task, the model inline, and the fallback order. Posting one task's form
+ // saves only that task, and AI resolves it within a minute.
+ const DEFAULTS_HEAD = raw('<link rel="stylesheet" href="/css/task-defaults.css"><script src="/js/task-defaults.js" defer></script>');
+ // Owner defaults are OpenVibe supply only, so each picker lists the network's offers for the task's kind: the
+ // chat (or code, vision) models, and for an image task its image models too. A failed read leaves that task's
+ // pickers empty rather than the page; the saved values still render from the defaults.
+ const networkModels = async (task) => {
+ try {
+ const view = await ai.meRequest(req.viewer.token, `/api/v1/choices?task=${encodeURIComponent(task.name)}`);
+ const list = ((view.options && view.options.openvibe) || []).filter((m) => !m.adult);
+ const models = list.map(({ model, source, name, price_estimate_usd }) => ({ model, source, name, price_estimate_usd }));
+ // A model task's two dynamic picks come first: "let OpenVibe choose" is the built-in every task starts
+ // with, and "the cheapest good model" is what Design does signed out. Neither is a single catalogue row,
+ // and a search task has neither: it names an engine.
+ if (task.kind !== 'search') models.unshift({ 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 });
+ return { models, imageModels: task.kind === 'image' ? models : [] };
+ } catch { return { models: [], imageModels: [] }; }
+ };
+ r.get('/console/defaults', async (req, res) => {
+ try {
+ const data = await ai.meRequest(req.viewer.token, '/api/v1/admin/task-defaults');
+ const optionsByTask = {};
+ for (const task of data.tasks || []) optionsByTask[task.name] = await networkModels(task);
+ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, index: false, title: 'AI defaults', head: DEFAULTS_HEAD, body: raw(defaultsPage({ ...data, optionsByTask })) });
+ } catch (err) { return page(req, res, 'defaults', {}, err.message, err.status || 502); }
+ });
+ r.post('/console/defaults', async (req, res) => {
+ if (!sameOrigin(req)) return res.sendStatus(403);
+ try {
+ const b = req.body;
+ const fallbacks = b.fallback == null ? [] : [].concat(b.fallback);
+ const value = {
+ signed_out: JSON.parse(b.signed_out),
+ signed_in: JSON.parse(b.signed_in),
+ fallbacks: fallbacks.map((v) => JSON.parse(v)),
+ image_model: b.image_model ? JSON.parse(b.image_model) : null,
+ // One engine a line; a leading - or − turns it off.
+ search_engines: String(b.search_engines || '').split(/\r?\n/).map((x) => x.trim()).filter(Boolean)
+ .map((x) => ({ engine: x.replace(/^[-−]/, ''), enabled: !/^[-−]/.test(x) })),
+ max_cost_usd: b.max_cost_usd === '' ? null : Number(b.max_cost_usd),
+ };
+ await ai.meRequest(req.viewer.token, '/api/v1/admin/task-defaults', { method: 'PUT', body: { defaults: [{ task: b.task, value }] } });
+ return res.redirect(303, '/console/defaults');
+ } catch (err) { return page(req, res, 'defaults', {}, err.message, err.status || 422); }
+ });
r.get('/console/runs/:id', async (req, res) => {
r.get('/console/runs/:id', async (req, res) => {
try { return page(req, res, 'runs', await ai.adminRequest(`/api/v1/admin/console/run?id=${encodeURIComponent(req.params.id)}`)); }
try { return page(req, res, 'runs', await ai.adminRequest(`/api/v1/admin/console/run?id=${encodeURIComponent(req.params.id)}`)); }
catch (err) { return page(req, res, 'runs', {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502); }
catch (err) { return page(req, res, 'runs', {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502); }
@@ -437,6 +437,16 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
@@ -437,6 +437,16 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
return page(req, res, { title: 'The API', index: false, body: raw(views.apiPage({ catalog, usage, viewer: req.viewer, keyPrivacy })) });
return page(req, res, { title: 'The API', index: false, body: raw(views.apiPage({ catalog, usage, viewer: req.viewer, keyPrivacy })) });
});
});
+ r.get('/api/ai/choices', async (req,res) => {
+ try { res.set('Cache-Control','private, no-store').json(await ai.meRequest(req.viewer?.token, '/api/v1/choices?task='+encodeURIComponent(String(req.query.task || '')))); }
+ catch(err) { res.status(err.status || 502).json({detail:err.message}); }
+ });
+ r.put('/api/ai/choices/:task', express.json({limit:'4kb'}), async (req,res) => {
+ if (req.get('origin') !== new URL(config.baseUrl).origin) return res.sendStatus(403);
+ try { res.json(await ai.meRequest(req.viewer?.token, '/api/v1/me/choices/'+encodeURIComponent(req.params.task), {method:'PUT',body:req.body})); }
+ catch(err) { res.status(err.status || 502).json({detail:err.message}); }
+ });
+
// A person's AI credentials live on Exchange. The user's signed Network token travels server-to-server to AI;
// A person's AI credentials live on Exchange. The user's signed Network token travels server-to-server to AI;
// the page and its responses contain metadata only, never a saved API key or cookie value.
// the page and its responses contain metadata only, never a saved API key or cookie value.
r.get('/privacy/keys', async (req, res) => {
r.get('/privacy/keys', async (req, res) => {
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
const { esc } = require('../render/html');
const { esc } = require('../render/html');
const SECTIONS = [
const SECTIONS = [
- ['Overview', '/console'], ['GPU supply (dry run)', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
+ ['Overview', '/console'], ['Defaults', '/console/defaults'], ['GPU supply (dry run)', '/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,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+/**
+ * The owner's AI defaults (plan §1, owner rank): one row per task with the model inline and the fallback order.
+ * Owner defaults name OpenVibe supply only (AI enforces that), so each picker is a plain model list rather than the
+ * person-facing chip: the chip carries "your keys" and "your machines", which an owner default may never use.
+ * Design and layout come from the Kit; Opus restyles on review.
+ */
+const { esc } = require('../render/html');
+
+const AUTO = { model: 'openvibe/auto', source: 'openvibe' };
+
+const LABEL = { 'openvibe/auto': 'Let OpenVibe choose' };
+
+const option = (value, label, selected) => `<option value="${esc(JSON.stringify(value))}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
+
+/** One model picker: the current choice, and the models the task may use. */
+function picker(name, label, choice, models) {
+ const current = choice ? JSON.stringify(choice) : '';
+ const known = models.some((m) => JSON.stringify({ model: m.model, source: m.source }) === current);
+ return `<label class="ex-default-pick"><span>${esc(label)}</span><select name="${esc(name)}" required>
+ ${known || !choice ? '' : option(choice, `${LABEL[choice.model] || choice.model} (not listed)`, true)}
+ ${models.map((m) => option({ model: m.model, source: m.source }, `${LABEL[m.model] || m.name || m.model}${m.price_estimate_usd == null ? '' : ` · $${m.price_estimate_usd.toFixed(4)} a run`}`, !known && JSON.stringify({ model: m.model, source: m.source }) === current)).join('')}
+ </select></label>`;
+}
+
+function fallbackRow(choice, models) {
+ return `<li draggable="true"><span data-fallback-model>${esc(choice.model)}</span><input type="hidden" name="fallback" value="${esc(JSON.stringify(choice))}"><select data-fallback-edit aria-label="Change this fallback">
+ ${models.map((m) => option({ model: m.model, source: m.source }, m.name || m.model, m.model === choice.model)).join('')}
+ </select><button type="button" class="ovk-btn ovk-btn--quiet" data-move="-1" aria-label="Move fallback up">↑</button><button type="button" class="ovk-btn ovk-btn--quiet" data-move="1" aria-label="Move fallback down">↓</button><button type="button" class="ovk-btn ovk-btn--quiet" data-remove>Remove</button></li>`;
+}
+
+/**
+ * One task as one row (Opus's design, 11-09: "one row per task, the model inline"): the task and what it is for, the
+ * signed-out and signed-in models side by side, Save at the end; the fallback order, the image model, the search
+ * engines and the cost ceiling in a counted fold under the row.
+ */
+function taskForm(task, options) {
+ const d = options.current || { signed_out: task.default, signed_in: task.default, fallbacks: [], search_engines: [], image_model: null, max_cost_usd: null };
+ const models = options.models || [];
+ const more = [`${d.fallbacks.length} fallback${d.fallbacks.length === 1 ? '' : 's'}`, task.kind === 'image' ? 'image model' : '', task.kind === 'search' ? 'search engines' : '', d.max_cost_usd != null ? `ceiling $${Number(d.max_cost_usd).toFixed(4)}` : 'no ceiling'].filter(Boolean).join(' · ');
+ return `<form class="ex-dt-row ovk-form" method="post" action="/console/defaults" data-task-default>
+ <input type="hidden" name="task" value="${esc(task.name)}">
+ <div class="ex-dt-task"><h2>${esc(task.name)}</h2><p>${esc(task.sentence)}</p><span class="ovk-badge">${esc(task.kind)}</span></div>
+ <div class="ex-dt-picks">
+ ${picker('signed_out', 'Signed out', d.signed_out, models)}
+ ${picker('signed_in', 'Signed in', d.signed_in, models)}
+ </div>
+ <div class="ex-dt-go"><button class="ovk-btn ovk-btn--primary" type="submit" aria-label="Save ${esc(task.name)}">Save</button></div>
+ <details class="ex-dt-more">
+ <summary>More <span>${esc(more)}</span></summary>
+ <div class="ex-dt-more-body">
+ ${task.kind === 'image' ? picker('image_model', 'Image model', d.image_model || d.signed_in, options.imageModels || []) : ''}
+ <section class="ex-dt-fallbacks"><h3>Fallback order</h3><p>The first available model answers. Drag to reorder, or use the arrows.</p>
+ <ol data-fallback-list data-models="${esc(JSON.stringify(models))}">${d.fallbacks.map((c) => fallbackRow(c, models)).join('')}</ol>
+ <div class="ex-default-pick ex-dt-add"><span class="ex-dt-sr">Add a fallback</span><select name="fallback_new" aria-label="A model to add as a fallback">
+ ${models.map((m) => option({ model: m.model, source: m.source }, m.name || m.model, false)).join('')}
+ </select><button type="button" class="ovk-btn ovk-btn--quiet" data-add-fallback>Add fallback</button></div></section>
+ ${task.kind === 'search' ? `<fieldset class="ex-dt-engines"><legend>Search engines</legend><p>Enabled engines are tried in this order.</p><textarea name="search_engines" rows="4" aria-label="Search engines in order; prefix a disabled engine with a minus">${esc(d.search_engines.map((e) => (e.enabled ? '' : '-') + e.engine).join('\n'))}</textarea></fieldset>` : ''}
+ <label class="ex-default-cost">Per-run cost ceiling (USD)<input name="max_cost_usd" type="number" min="0" max="1000" step="0.0001" value="${esc(d.max_cost_usd ?? '')}" placeholder="No task ceiling"></label>
+ </div>
+ </details>
+ </form>`;
+}
+
+/**
+ * optionsByTask: { [task]: { current, models, imageModels } } — AI's resolved options for each task. A task whose
+ * options could not be read still renders, with the options it has, so one failing read never hides the page.
+ */
+function defaultsPage({ tasks = [], defaults = {}, optionsByTask = {}, error = null } = {}) {
+ return `<div class="ex-task-defaults">
+ <header><p class="ovk-eyebrow">Owner console</p><h1>AI defaults</h1><p>Choose what answers across OpenVibe. A person's own choice takes priority, and every change is audited and reaches services within a minute.</p></header>
+ ${error ? `<p role="alert">${esc(error)}</p>` : ''}
+ <div class="ex-default-tasks ex-dt-list">${tasks.map((task) => taskForm(task, { current: defaults[task.name] || null, ...(optionsByTask[task.name] || {}) })).join('')}</div>
+</div>`;
+}
+
+module.exports = { defaultsPage };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * The owner's AI defaults page and its post: owner-only, CSRF-guarded, and the payload it sends AI carries the
+ * signed-out and signed-in picks, the fallback order as it stands, the search order and the cost ceiling.
+ */
+const assert = require('assert');
+const express = require('express');
+const { createServer } = require('http');
+const { createConsoleRoutes } = require('../server/http/console');
+
+const task = { name: 'design.icon', kind: 'code', sentence: 'Draw an original SVG icon.', default: { model: 'openvibe/auto', source: 'openvibe' } };
+const searchTask = { name: 'search.web', kind: 'search', sentence: 'Find sources on the web.', default: { model: 'search/wikipedia', source: 'openvibe' } };
+const pick = { model: 'anthropic/claude-opus-5-5', source: 'openvibe' };
+const defaults = { 'design.icon': { signed_out: pick, signed_in: pick, fallbacks: [pick], image_model: null, search_engines: [], max_cost_usd: 0.5 } };
+const choicesFor = (name) => ({ options: { openvibe: [{ model: name === 'search.web' ? 'search/wikipedia' : 'anthropic/claude-opus-5-5', source: 'openvibe', name: 'Claude Opus 5.5', price_estimate_usd: 0.02, adult: false }], keys: [], machines: [] } });
+
+const calls = [];
+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: {
+ meRequest: async (token, path, options) => {
+ calls.push({ token, path, options });
+ if (path === '/api/v1/admin/task-defaults') return { tasks: [task, searchTask], defaults };
+ if (path.startsWith('/api/v1/choices?task=')) return choicesFor(decodeURIComponent(path.split('=')[1]));
+ return { defaults };
+ },
+ },
+}));
+
+(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;
+ try {
+ // Owner rank only: no viewer, a person and an admin all get 404.
+ for (const role of ['', 'user', 'admin']) {
+ assert.strictEqual((await fetch(base + '/console/defaults', { headers: role ? { 'x-role': role } : {} })).status, 404, `${role || 'signed out'} is not the owner`);
+ }
+ const page = await fetch(base + '/console/defaults', { headers: { 'x-role': 'owner' } });
+ const html = await page.text();
+ assert.strictEqual(page.status, 200);
+ assert.ok(html.includes('<h1>AI defaults</h1>'), 'the page names itself');
+ assert.ok(html.includes('data-fallback-list'), 'the fallback order is on the page');
+ assert.ok(html.includes('data-task-default'), 'each task has its own form');
+ assert.ok(html.includes('name="signed_out"') && html.includes('name="signed_in"'), 'both picks are on the page');
+ assert.ok(html.includes('name="search_engines"'), 'the search task carries its engines');
+ assert.ok(html.includes('data-models='), 'the list carries the models the page may offer');
+ assert.ok(!html.includes('data-ai-choice'), 'an owner default never offers the person-facing chip (keys, machines)');
+ assert.strictEqual(calls[0].token, 'test-session', 'the owner session reaches AI');
+
+ const body = new URLSearchParams({ task: task.name, signed_out: JSON.stringify(pick), signed_in: JSON.stringify(pick), fallback: JSON.stringify(pick), max_cost_usd: '0.1' });
+ // A cross-site post is refused even with the owner's session.
+ assert.strictEqual((await fetch(base + '/console/defaults', { method: 'POST', headers: { 'x-role': 'owner', 'Content-Type': 'application/x-www-form-urlencoded' }, body })).status, 403);
+ const saved = await fetch(base + '/console/defaults', { method: 'POST', redirect: 'manual', headers: { 'x-role': 'owner', Origin: 'https://openvibe.exchange', 'Content-Type': 'application/x-www-form-urlencoded' }, body });
+ assert.strictEqual(saved.status, 303, 'a save redirects back to the page');
+ const sent = calls.at(-1).options.body.defaults[0];
+ assert.deepStrictEqual([sent.task, sent.value.signed_in.model, sent.value.fallbacks.length, sent.value.max_cost_usd], [task.name, pick.model, 1, 0.1]);
+
+ // The search order is one engine a line, and a leading minus turns one off.
+ const searchBody = new URLSearchParams({ task: searchTask.name, signed_out: JSON.stringify({ model: 'search/wikipedia', source: 'openvibe' }), signed_in: JSON.stringify({ model: 'search/wikipedia', source: 'openvibe' }), search_engines: 'wikipedia\n-commons\nopenverse', max_cost_usd: '' });
+ const searchSaved = await fetch(base + '/console/defaults', { method: 'POST', redirect: 'manual', headers: { 'x-role': 'owner', Origin: 'https://openvibe.exchange', 'Content-Type': 'application/x-www-form-urlencoded' }, body: searchBody });
+ assert.strictEqual(searchSaved.status, 303);
+ const searchValue = calls.at(-1).options.body.defaults[0].value;
+ assert.deepStrictEqual(searchValue.search_engines, [{ engine: 'wikipedia', enabled: true }, { engine: 'commons', enabled: false }, { engine: 'openverse', enabled: true }]);
+ assert.strictEqual(searchValue.max_cost_usd, null, 'an empty ceiling is no ceiling');
+ console.log('Defaults: owner-only page, CSRF protection, and the payload AI receives');
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+})().catch((e) => { console.error(e); process.exitCode = 1; });