openvibers/openvibe.ai Commits
ui.layout.suggest: words into a checked layout, one retry, honest couldn't (#485)
Changes
@@@ -76,7 +76,10 @@ Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`;`ai.quota.attribution.manage` (per-streamer quotas), `ai.memory.read` and `ai.memory.write` (Memory M2a,`/api/v1/memory/*`), `ai.me.read` and `ai.me.manage` ("Your AI", `/api/v1/me/*`: a person's own keys androuting preferences; only the person's own token or AI-site session) and `ai.app.run` (developer apps, ADR-014; public,--registered since openvibe-contracts 0.109.0). A service token's `ns`++registered since openvibe-contracts 0.109.0), and `ai.layout.suggest` (`POST /api/v1/ui/layout/suggest`: a surface catalog,++the layout shown now and the person's words in, a validated `ui.layout@1` with a one-sentence summary or an honest++refusal out; one retry with the validator's reasons, on the cheap structured-output route, as the person named in++`X-OV-Subject`). A service token's `ns`claim limits which workflow namespaces it may run; an app token is fixed to its project's.A first-party service may also open a batch of the network's own with the `ai.batches.create` grant@@@ -7,8 +7,8 @@"runtime": "node 22, express, postgresql","port": 4700,"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",-- "contracts": "openvibe-contracts v0.408.0 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)",-- "sdk": "openvibe-sdk v0.150.0",++ "contracts": "openvibe-contracts v0.410.0 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)",++ "sdk": "openvibe-sdk v0.152.0","shared": "openvibe-shared v4.206.0","capabilities": ["ai.run.create",@@@ -23,7 +23,8 @@"ai.memory.write","ai.me.read","ai.me.manage",-- "ai.config.manage"++ "ai.config.manage",++ "ai.layout.suggest"],"capabilitiesRegistered": true,"providers": [@@@ -13,8 +13,8 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0","pg": "^8.23.0"},@@@ -1624,9 +1624,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.408.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",-- "integrity": "sha512-Q2XjfuCDyMFd88CIKVwdZKR9IwZsxZB41cFKxaIWVIrmCt8YO5P2zj8beqvBanqpNgOjPB74HTU/0JCMTbyA+Q==",++ "version": "0.410.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "integrity": "sha512-rmmq2mebXim2fv+sip2YQ0PrrZLxGUhEfe1w+1EFfGdoVvLI5tp0/eavY2sW6/VU+rzs7cQMC/A0NZD5th5wrA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1640,9 +1640,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.150.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",++ "version": "0.152.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -29,8 +29,9 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0","pg": "^8.23.0"},nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++/**++ * POST /api/v1/ui/layout/suggest (ai.layout.suggest; make your own controls design, 2026-11-11,++ * piece 6). A first-party service sends the surface catalog, the layout shown now, the person's words++ * and the device; a validated ui.layout@1 and a one-sentence summary come back, or an honest++ * "couldn't" with the validator's reasons.++ *++ * The run is the workflow ui.layout.suggest on the cheap structured-output route. If its answer++ * breaks a rule, this route makes ONE more run with the validator's reasons in the input (the same++ * catalog and words, no other change). It never applies a layout: the caller previews it for the++ * person. The person the service names in X-OV-Subject is the run's owner, so their own AI limits,++ * preferences and cache scope apply.++ */++const express = require('express');++const { validate, http } = require('openvibe-contracts');++const { AiError, sendError } = require('../util');++++const CAP = 'ai.layout.suggest';++const REQUEST = 'ui.layout-request@1';++const SUGGESTION = 'ui.layout-suggestion@1';++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const WAIT_MS = 20000;++const LIMIT = 10;++const WINDOW_MS = 60_000;++++function uiLayoutRouter({ runs, auth, log = console, now = () => Date.now() }) {++ const r = express.Router();++ r.use(http.middleware());++ const guard = auth.requireCap(CAP);++ const seen = new Map();++ const allow = (key, at) => {++ const kept = (seen.get(key) || []).filter((t) => at - t < WINDOW_MS);++ if (kept.length >= LIMIT) { seen.set(key, kept); return false; }++ kept.push(at); seen.set(key, kept);++ if (seen.size > 5000) seen.delete(seen.keys().next().value);++ return true;++ };++++ const receiptOf = (run) => ({++ model: String((run.provenance && run.provenance.model) || run.model || 'unknown').slice(0, 200),++ ...(run.provenance && run.provenance.provider ? { provider: String(run.provenance.provider).slice(0, 100) } : {}),++ ...(run.provenance && run.provenance.route ? { route: String(run.provenance.route).slice(0, 200) } : {}),++ run_id: String(run.id),++ cost_usd: Number(run.usage && run.usage.cost_usd) || 0,++ });++++ function refused(reasons, run) {++ const list = (reasons || []).map((x) => String(x).slice(0, 300)).filter(Boolean).slice(0, 20);++ const why = list[0] || 'the model could not make that layout';++ return { ok: false, layout: null, summary: `Couldn't do that: ${why}`.slice(0, 300), reasons: list.length ? list : [why], receipt: receiptOf(run) };++ }++++ async function oneRun(input, req) {++ const created = await runs.create({ workflow: 'ui.layout.suggest', task: 'ui.layout.suggest', input }, req.principal, {++ trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, callerWaitMs: WAIT_MS,++ });++ return await runs.explained(await runs.wait(created, WAIT_MS));++ }++++ const unavailable = (run) => run && run.error && ['provider.unavailable', 'route.unavailable', 'ai.resting', 'workflow.not_found', 'task.not_found'].includes(run.error.code);++++ r.post('/api/v1/ui/layout/suggest', guard, async (req, res) => {++ try {++ const at = now();++ const named = String(req.get('x-ov-subject') || '').trim();++ if (named && !SUBJECT_RE.test(named)) throw new AiError(422, 'input.invalid', 'X-OV-Subject names a person with usr_…');++ if (named) req.principal = { ...req.principal, onBehalfOf: named };++ if (!allow(named || (req.principal && req.principal.sub) || 'anonymous', at)) {++ throw new AiError(429, 'layout.rate_limited', 'too many layout suggestions; wait a minute');++ }++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ const v = validate(REQUEST, body);++ if (!v.valid) throw new AiError(422, 'input.invalid', `the request does not match ${REQUEST}`, { errors: v.errors });++ let run = await oneRun(body, req);++ if (run.status !== 'succeeded' || !run.output || run.output.ok !== true) {++ const first = run.status !== 'succeeded'++ ? [run.error && run.error.detail ? String(run.error.detail).slice(0, 300) : 'the model did not answer with a layout']++ : (Array.isArray(run.output.reasons) && run.output.reasons.length ? run.output.reasons : ['the answer broke a rule']);++ if (run.status !== 'succeeded' && unavailable(run)) throw new AiError(503, 'layout.suggest_unavailable', first[0]);++ // One retry with the validator's reasons. Same catalog, same words, same device.++ run = await oneRun({ ...body, retry: { reasons: first.slice(0, 20) } }, req);++ }++ if (run.status !== 'succeeded') {++ if (unavailable(run)) throw new AiError(503, 'layout.suggest_unavailable', (run.error && run.error.detail) || 'the model is unavailable');++ return res.json(refused([run.error && run.error.detail || 'the model could not make that layout'], run));++ }++ const answer = run.output && typeof run.output === 'object' ? { ...run.output, receipt: receiptOf(run) } : null;++ if (!answer || !validate(SUGGESTION, answer).valid) {++ throw new AiError(502, 'layout.suggest_invalid', 'the model answered something that is not a layout suggestion');++ }++ if (!answer.ok) return res.json({ ...answer, receipt: receiptOf(run) });++ // The layout is already checked with openvibe-sdk/ui-layout by the workflow's postprocess; the++ // route only confirms the whole answer is ui.layout-suggestion@1.++ return res.json(answer);++ } catch (err) {++ if (err && err.status) return sendError(res, err, req.ov, log);++ log.error && log.error('[ui-layout]', err && err.stack || err);++ return http.sendProblem(res, 500, 'layout.suggest_error', { detail: 'could not suggest a layout', ctx: req.ov });++ }++ });++ return r;++}++++module.exports = { uiLayoutRouter, CAP };@@@ -262,6 +262,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, pconst ops = createOps({ db, registry, pool, quotas, cache, runs });app.use(runsRouter({ choices, runs, registry, auth, config, log }));app.use(actionsRouter({ runs, registry, auth, log }));++ // People's own controls (2026-11-11): one task that turns words into a checked ui.layout@1.++ app.use(require('./api/ui-layout').uiLayoutRouter({ runs, auth, log }));app.use(require('./api/compat-extra').compatExtra({ runs, auth, config, log }));// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));@@@ -32,6 +32,7 @@ const SEEDS = [['search.web', 'search', 'Find sources on the web.', 'research.gather'],['search.images', 'search', 'Find licensed images.', 'research.gather'],['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'blog.changelog.tag'],++ ['ui.layout.suggest', 'text', 'Turn a person’s words into a layout for one surface.', 'ui.layout.suggest'],];const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;@@@ -942,6 +942,52 @@ route('get', '/actions', {responses: { 200: json('common.action-list-result@1', { type: 'object', required: ['items'], properties: { items: arrayOf(ref('ActionItem')) } }), ...errors([400, 401, 403, 429]) },});++// ── People's own controls (2026-11-11) ───────────────────────────────────────────────────────++const layoutPart = {++ type: 'object', additionalProperties: false, required: ['id', 'visible', 'place', 'style', 'bindings'],++ properties: {++ id: str({ maxLength: 40 }), visible: { type: 'boolean' },++ place: { type: 'object', description: 'x, y, w, h in grid cells, or dock + order + w + h for an overlay' },++ style: { type: 'object', properties: { tint: str({ description: 'a theme token, never a colour' }), shape: str({ enum: ['round', 'pill', 'square'] }), opacity: { type: 'number', minimum: 0.4, maximum: 1 }, label: { type: 'boolean' } } },++ bindings: { type: 'object', properties: { keys: arrayOf(str()), gamepad: arrayOf(str()) } },++ },++};++const uiLayout = {++ type: 'object', additionalProperties: false,++ required: ['surface', 'surface_version', 'device', 'name', 'author', 'sharing', 'parts'],++ properties: {++ id: str({ pattern: '^lay_[0-9A-HJKMNP-TV-Z]{26}$' }), revision: integer({ minimum: 1 }), active: { type: 'boolean' },++ surface: str({ maxLength: 80 }), surface_version: integer({ minimum: 1 }), device: str({ enum: ['phone', 'tablet', 'computer'] }),++ name: str({ maxLength: 80 }), author: { type: 'object', properties: { name: str({ maxLength: 80 }), subject: ref('SubjectRef') } },++ sharing: str({ enum: ['private', 'link', 'gallery'] }), parts: arrayOf(layoutPart), updated_at: str({ format: 'date-time' }),++ },++};++const uiLayoutSuggestion = {++ type: 'object', additionalProperties: false, required: ['ok', 'layout', 'summary', 'reasons'],++ properties: {++ ok: { type: 'boolean' },++ layout: { oneOf: [uiLayout, { type: 'null' }] },++ summary: str({ maxLength: 300 }),++ reasons: arrayOf(str({ maxLength: 300 })),++ receipt: { type: 'object', required: ['model'], properties: { model: str(), provider: str(), route: str(), run_id: str(), cost_usd: { type: 'number', minimum: 0 } } },++ },++};++route('post', '/ui/layout/suggest', {++ operationId: 'suggestUiLayout', summary: 'Turn words into a checked arrangement of one surface', tags: ['ui'], security: SERVICE,++ description: 'ai.layout.suggest: a first-party service sends a ui.surface@1 catalog, the ui.layout@1 shown now (or null), the person\'s words (1-500 characters) and the device class. Answers ui.layout-suggestion@1: a layout openvibe-sdk/ui-layout accepted, with a one-sentence summary and reasons, or ok false with the validator\'s plain reasons. If the first answer is refused, the task makes ONE more run with those reasons, then says honestly that it could not. The layout is never applied; the caller previews it. X-OV-Subject names the person the run belongs to, so their own AI limits, preferences and cache scope apply.',++ parameters: [{ name: 'X-OV-Subject', in: 'header', required: false, schema: str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), description: 'The person the arrangement is for' }],++ requestBody: body({++ type: 'object', additionalProperties: false, required: ['catalog', 'layout', 'words', 'device'],++ properties: {++ catalog: { type: 'object', description: 'ui.surface@1: id, version, devices, grid, theme_tokens and parts' },++ layout: { oneOf: [uiLayout, { type: 'null' }] },++ words: str({ minLength: 1, maxLength: 500 }),++ device: str({ enum: ['phone', 'tablet', 'computer'] }),++ },++ }),++ responses: { 200: json('ui.layout-suggestion@1', uiLayoutSuggestion), ...errors([400, 401, 403, 422, 429, 503]) },++});++// ── Routing presets and receipts (the Exchange, 2026-11-06) ───────────────────────────────────route('get', '/presets', {operationId: 'listPresets', summary: 'The routing presets this caller may name', tags: ['presets'], security: SERVICE_OR_APP,@@@ -626,6 +626,8 @@ const POSTPROCESS = {// Space's builder keeps its hooks beside its templates (server/workflows/space.js).{ const space = require('./space'); Object.assign(PREPARE, space.PREPARE); Object.assign(POSTPROCESS, space.POSTPROCESS); }++// People's own controls (2026-11-11): the layout suggestion's prepare and postprocess live with its template.++{ const ui = require('./ui-layout'); PREPARE['ui.layout.suggest'] = ui.prepare; POSTPROCESS['ui.layout.suggest'] = ui.postprocess; }/*** What the run cache keys on, for a workflow whose input carries parts that do not change the answer (item 151, C12:@@@ -25,6 +25,7 @@ const research = require('./research');const meme = require('./meme');const changelog = require('./changelog');const pictures = require('./pictures');++const uiLayout = require('./ui-layout');const poolSeed = require('../providers/pool-seed');const { resolveSecret } = require('../util');@@@ -255,6 +256,9 @@ async function seed({ registry, quotas, config, env = process.env, db }) {await registry.seedVersioned('route', 'default.json', pool(60000, { options: { temperature: 0.3 }, max_output_tokens: 2400, response_format: 'json' }));// Meme captions need fluency, not factual precision; choose the cheapest eligible JSON route by default.await registry.seedVersioned('route', 'meme.caption', pool(30000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0.85 }, max_output_tokens: 700, response_format: 'json' }));++ // A layout suggestion is a small JSON arrangement, not a conversation: the cheapest structured-output++ // offer that is healthy, with room for every part of a surface (make your own controls, 2026-11-11).++ await registry.seedVersioned('route', 'ui.layout.suggest', pool(60000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0.2 }, max_output_tokens: 4000, response_format: 'json' }));await registry.seedVersioned('route', 'default.embedding', pool(30000, { 'capability': 'embed', constraints: { objective: 'balanced' } }));// Speech (the Exchange speech route): the voice pins the exact offer, so the pool is only the fallback// order when no voice is pinned; every provider that declares `speak` may answer it.@@@ -310,11 +314,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });// Templates, then workflows (workflows reference templates)-- for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates]) {++ for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates, ...uiLayout.templates]) {const { key, ...def } = t;await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });}-- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows]) {++ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows]) {const { key, ...def } = w;await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });}nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * ui.layout.suggest (make your own controls design, 2026-11-11, piece 6).++ *++ * The person's words, the surface catalog and the layout they see now go in; a ui.layout@1 that++ * only arranges parts the catalog has comes out, with a one-sentence summary. The postprocess is the++ * authority: it builds the document from the model's answer, fills in what the model never chooses++ * (surface, version, device, author, sharing) and runs openvibe-sdk/ui-layout, the same function the++ * editor and OpenVibe.Network use. An answer that breaks a rule becomes ok:false with the validator's++ * reasons, never a silently repaired layout; the route asks once more with those reasons.++ *++ * Model output shape (enforced as structured output): { ok, layout, summary, reasons }. The++ * postprocess never trusts the model's `ok`.++ */++const { validate: validateLayout } = require('openvibe-sdk/ui-layout');++++const MAX_REASONS = 20;++const oneLine = (v, max = 300) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '');++const listOf = (v) => (Array.isArray(v) ? v.map((x) => oneLine(x, 300)).filter(Boolean).slice(0, MAX_REASONS) : []);++++function refused(reasons, summary) {++ const list = reasons.length ? reasons : ['that arrangement cannot be made'];++ return { ok: false, layout: null, summary: oneLine(summary) || `Couldn't do that: ${list[0]}`, reasons: list };++}++++/** The model's answer -> ui.layout-suggestion@1, with the layout built server-side and checked. */++function postprocess(r, input) {++ const answer = r && r.json;++ if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return refused(['the model did not answer with a layout'], "Couldn't do that: the model did not answer with a layout.");++ if (answer.ok === false) return refused(listOf(answer.reasons), answer.summary);++ const raw = answer.layout && typeof answer.layout === 'object' && !Array.isArray(answer.layout) ? answer.layout : null;++ if (!raw || !Array.isArray(raw.parts)) return refused(['the model did not send a layout'], "Couldn't do that: the model did not send a layout.");++ const current = input.layout && typeof input.layout === 'object' ? input.layout : null;++ const layout = {++ surface: input.catalog.id,++ surface_version: input.catalog.version,++ device: input.device,++ name: oneLine(raw.name, 80) || oneLine(current && current.name, 80) || 'Suggested',++ author: { name: oneLine(current && current.author && current.author.name, 80) || 'OpenVibe' },++ sharing: 'private',++ parts: raw.parts,++ };++ const checked = validateLayout(input.catalog, layout);++ if (!checked.ok) return refused(checked.reasons, `Couldn't do that: ${checked.reasons[0]}`);++ return {++ ok: true,++ layout,++ summary: oneLine(answer.summary) || 'Updated the arrangement.',++ reasons: listOf(answer.reasons),++ };++}++++const templates = [{++ key: 'ui.layout.suggest', name: 'A layout from a person’s words',++ description: 'Turn words into a ui.layout@1 for one surface, using only the catalog’s parts and rules.',++ input_schema: { type: 'object' },++ output_schema: {++ type: 'object', additionalProperties: false, required: ['ok', 'layout', 'summary', 'reasons'],++ properties: {++ ok: { type: 'boolean' },++ layout: { type: ['object', 'null'] },++ summary: { type: 'string', minLength: 1, maxLength: 300 },++ reasons: { type: 'array', maxItems: MAX_REASONS, items: { type: 'string', minLength: 1, maxLength: 300 } },++ },++ },++ system_prompt: 'You arrange the controls of one OpenVibe surface for one person. The catalog is the only power you have: use only the parts it lists, with their kinds, minimum sizes, allowed styles (tints are theme tokens, never colours), shapes, opacity and bindings. Never add a part, a binding or a style the catalog does not allow, and never invent a command. A required part (for example Stop and the e-stop) must stay visible and at least its minimum size; anything pressed must be at least 44 px, and a part with a larger minimum keeps it. Nothing may cover a required part. Keep the answer data: no URL, markup or code. The person’s words are a wish; safety rules win. Choose a sensible arrangement on the grid named for the device: x, y, w and h are cells, with the catalog’s column count. Give every part you place a visible flag, a place, a style and bindings from the catalog. If the person asks for something the catalog cannot do, answer ok false with a one-sentence summary and the plain reasons instead of breaking a rule. Output only JSON: {"ok":true,"layout":{"name":"…","parts":[…] },"summary":"one sentence","reasons":["…"]}, or {"ok":false,"layout":null,"summary":"Couldn\'t do that: …","reasons":["…"]}.',++ user_prompt: '{{json payload}}',++ default_route: 'ui.layout.suggest', owner: 'network', metadata: { json_name: 'layout_suggestion' },++}];++++const workflows = [{++ key: 'ui.layout.suggest', name: 'Suggest a layout from words', namespace: 'network',++ description: 'OpenVibe.Network’s Ask OpenVibe: the catalog, the current layout, the words and the device in, a validated ui.layout@1 out. One retry with the validator’s reasons is made by the route, never silently.',++ input_schema: {++ type: 'object', additionalProperties: false, required: ['catalog', 'layout', 'words', 'device'],++ properties: {++ catalog: { type: 'object' },++ layout: { type: ['object', 'null'] },++ words: { type: 'string', minLength: 1, maxLength: 500 },++ device: { enum: ['phone', 'tablet', 'computer'] },++ retry: {++ type: 'object', additionalProperties: false, required: ['reasons'],++ properties: { reasons: { type: 'array', maxItems: MAX_REASONS, items: { type: 'string', minLength: 1, maxLength: 300 } } },++ },++ },++ },++ output_schema: templates[0].output_schema,++ steps: [{ kind: 'llm', template: 'ui.layout.suggest', operation: 'generate', output: 'json', prepare: 'ui.layout.suggest', postprocess: 'ui.layout.suggest', params: { max_tokens: 4000, temperature: 0.2, timeout_ms: 60000 } }],++ cache_mode: 'none',++}];++++/** The template variables: the request itself, plus the reasons the last answer was refused. */++function prepare(input) {++ const payload = { catalog: input.catalog, layout: input.layout, words: input.words, device: input.device };++ if (input.retry && Array.isArray(input.retry.reasons) && input.retry.reasons.length) payload.previous_refusal = { reasons: input.retry.reasons.slice(0, MAX_REASONS) };++ return { vars: { payload } };++}++++module.exports = { templates, workflows, prepare, postprocess };nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++// ui.layout.suggest (make your own controls design, 2026-11-11, piece 6): a fake model through the++// HTTP seam answers twice — once with a layout that hides Stop, once with a valid one — and the task++// retries exactly once with the validator's reasons. The second test makes the model fail both times++// and checks the honest "couldn't".++const assert = require('assert');++const { boot, request, seamServer, suite, token } = require('./helpers');++const { validate: validateLayout } = require('openvibe-sdk/ui-layout');++const { validate } = require('openvibe-contracts');++++const t = suite('ui-layout');++const CAP = token('network', ['ai.layout.suggest'], { sub: 'svc:network' });++const NOCAP = token('network', ['ai.run.create'], { sub: 'svc:network' });++const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++++const catalog = () => ({++ id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone', 'tablet', 'computer'],++ grid: { columns: { phone: 4, tablet: 8, computer: 12 }, cell_px: { phone: 44, tablet: 44, computer: 44 } },++ theme_tokens: ['danger', 'surface', 'text'],++ parts: [++ { id: 'stop', name: 'Stop', kind: 'button', required: true, pressed: true, min: { w: 2, h: 2, px: 64 },++ styles: { tints: ['danger'], shapes: ['round'], opacity: { min: 0.7, max: 1 }, label: true },++ bindings: { keys: ['Space'], gamepad: ['south'] }, never_covers: [] },++ { id: 'stick', name: 'Drive stick', kind: 'stick', required: false, pressed: true, min: { w: 2, h: 2 },++ styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },++ bindings: { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] }, never_covers: ['stop'] },++ ],++});++const part = (id, place, visible = true) => ({++ id, visible, place,++ style: { tint: id === 'stop' ? 'danger' : 'surface', shape: 'round', opacity: 1, label: id === 'stop' },++ bindings: id === 'stop' ? { keys: ['Space'], gamepad: ['south'] } : { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] },++});++const validLayout = () => ({++ surface: 'bot.controls', surface_version: 1, device: 'phone', name: 'One thumb',++ author: { name: 'Ana' }, sharing: 'private',++ parts: [part('stop', { x: 0, y: 0, w: 2, h: 2 }), part('stick', { x: 0, y: 2, w: 2, h: 2 })],++});++const hiddenStop = () => {++ const l = validLayout(); l.parts[0].visible = false; return l;++};++const answer = (layout, ok = true, summary = 'Moved Stop to the top.', reasons = ['Stop stayed visible.']) => ({ json: { ok, layout, summary, reasons } });++const requestBody = () => ({ catalog: catalog(), layout: validLayout(), words: 'put Stop on the left and make it huge', device: 'phone' });++++let seam = null;++let answers = [];++let h = null;++++t.test('a layout that hides Stop is refused, retried once with the reasons, and the valid second answer comes back', async () => {++ seam = await seamServer(async () => {++ const index = Math.min(seam.calls - 1, answers.length - 1);++ return answers[index];++ });++ answers = [answer(hiddenStop()), answer(validLayout())];++ h = await boot({ env: { AI_HTTP_SEAM_URL: seam.url, AI_USER_MODULES: 'off', AI_FREE_PROVIDERS: 'http-seam', AI_FREE_DAILY_TOKENS: '100000' } });++ // The seam becomes the only chat/json offer: a fake model with a model record the router can choose.++ await h.registry.upsertProvider({ key: 'http-seam', capabilities: ['chat', 'generate', 'json'], default_model: 'layout-fake', billing_profile: 'free', provider_access: 'OWN_DEPLOYMENT' }, { actor: 'test' });++ await h.registry.upsertModel({ provider_key: 'http-seam', model_key: 'layout-fake', type: 'chat', status: 'active', supports: { json: true }, cost: { in_per_mtok: 0, out_per_mtok: 0, cached_per_mtok: 0 } }, { actor: 'test' });++ await h.registry.upsertProvider({ key: 'stub', status: 'disabled' }, { actor: 'test' });++++ const r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: requestBody() });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.ok, true, r.text);++ assert.strictEqual(seam.calls, 2, 'one retry, no more');++ assert.strictEqual(r.body.receipt.model, 'layout-fake');++ assert.ok(r.body.receipt.run_id, 'the receipt names the run');++ assert.ok(r.body.receipt.cost_usd >= 0);++ assert.ok(validate('ui.layout-suggestion@1', r.body).valid, JSON.stringify(validate('ui.layout-suggestion@1', r.body).errors));++ assert.deepStrictEqual(validateLayout(catalog(), r.body.layout), { ok: true, reasons: [] });++ assert.strictEqual(r.body.layout.author.subject, undefined, 'the run does not invent an account author');++ const row = await h.db.prepare("SELECT on_behalf_of, requester_type FROM runs WHERE workflow_key = 'ui.layout.suggest' ORDER BY id DESC LIMIT 1").get();++ assert.deepStrictEqual(JSON.parse(row.on_behalf_of), { type: 'user', id: SUBJECT }, 'the run is the person’s, for their own limits');++ assert.strictEqual(row.requester_type, 'service', 'the service is still the recorded requester');++ // The second prompt carries the validator's reason, and the same words and catalog.++ const second = JSON.parse(seam.last.messages[seam.last.messages.length - 1].content);++ assert.deepStrictEqual(second.previous_refusal.reasons, ['Stop has to stay visible.']);++ assert.strictEqual(second.words, 'put Stop on the left and make it huge');++ assert.strictEqual(second.device, 'phone');++});++++t.test('two invalid answers end in an honest could-not, not a third call', async () => {++ answers = [answer(hiddenStop()), answer(hiddenStop())];++ const r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: requestBody() });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.ok, false);++ assert.strictEqual(r.body.layout, null);++ assert.match(r.body.summary, /^Couldn't do that: Stop has to stay visible\./);++ assert.ok(r.body.reasons.includes('Stop has to stay visible.'));++ assert.ok(validate('ui.layout-suggestion@1', r.body).valid);++ assert.strictEqual(seam.calls, 4, 'one retry for each of the two requests');++});++++t.test('the model may say it cannot, and the answer stays a refusal', async () => {++ answers = [answer(null, false, "Couldn't do that: the catalog has no vertical head slider.", ['the catalog has no vertical head slider'])];++ const r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: requestBody() });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.ok, false);++ assert.strictEqual(r.body.layout, null);++ assert.ok(r.body.reasons.includes('the catalog has no vertical head slider'));++ assert.ok(r.body.receipt.model, 'a refusal carries its receipt too');++});++++t.test('refusals before any model call: capability, subject, body and words', async () => {++ const before = seam.calls;++ let r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: NOCAP, body: requestBody() });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.body.code, 'capability.denied');++ r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': 'not-a-subject' }, body: requestBody() });++ assert.strictEqual(r.status, 422);++ r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: { ...requestBody(), words: '' } });++ assert.strictEqual(r.status, 422);++ assert.match(r.text, /words/);++ r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: { ...requestBody(), device: 'watch' } });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(seam.calls, before, 'no model was called');++});++++t.test('the generic run API can run the task, and the task is registered under its own name', async () => {++ answers = [answer(validLayout())];++ const route = await h.registry.resolveRoute('ui.layout.suggest');++ assert.strictEqual(route.response_format, 'json', 'the task asks for structured output');++ assert.strictEqual(route.constraints.objective, 'cheapest', 'the task runs on the cheap tier');++ const task = await h.choices.task('ui.layout.suggest');++ assert.ok(task, 'the task is seeded');++ assert.strictEqual(task.workflow, 'ui.layout.suggest');++ const run = await h.runs.create({ task: 'ui.layout.suggest', input: requestBody() }, { kind: 'service', sub: 'svc:network', subject: { type: 'service', id: 'network' }, ns: ['*'], cap: ['ai.layout.suggest'] }, { callerWaitMs: 20000 });++ const done = await h.runs.explained(await h.runs.wait(run, 20000));++ assert.strictEqual(done.status, 'succeeded', JSON.stringify(done.error));++ assert.strictEqual(done.output.ok, true);++});++++t.test('cleanup', async () => {++ await seam.close();++ await h.stop();++});++++t.run();@@@ -98,6 +98,22 @@ EXAMPLES['live.easter_egg'] = { vibe: 'croutons' };EXAMPLES['space.app.build'] = { prompt: 'A guestbook where people sign in and leave a one-line note', name: 'Guestbook', current: [{ path: 'index.html', content: '<!doctype html><title>Guestbook</title><script src="/_openvibe/sdk.js"></script>' }] };EXAMPLES['space.app.ask'] = { space: 'Pirate helper', instructions: 'Talk like a pirate.', transcript: 'visitor: hi\nai: Ahoy!', message: 'What is a good name for a parrot?' };EXAMPLES['live.home.star'] = { recent_stars: ['bob'], previous_star: 'bob', candidates: [{ username: 'ann', name: 'Ann', language: 'en', last_14d: { sessions: 5, hours: 12 } }] };++// People's own controls (2026-11-11): the fake surface is deliberately small; the stub's answer is++// refused by the layout validator, which is still a schema-valid suggestion and exercises that path.++EXAMPLES['ui.layout.suggest'] = {++ catalog: {++ id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone'],++ grid: { columns: { phone: 4, tablet: 8, computer: 12 }, cell_px: { phone: 44, tablet: 44, computer: 44 } },++ theme_tokens: ['danger', 'surface'],++ parts: [++ { id: 'stop', name: 'Stop', kind: 'button', required: true, pressed: true, min: { w: 2, h: 2, px: 64 }, styles: { tints: ['danger'], shapes: ['round'] }, bindings: { keys: ['Space'], gamepad: ['south'] } },++ { id: 'stick', name: 'Drive stick', kind: 'stick', required: false, pressed: true, min: { w: 2, h: 2 }, styles: { tints: ['surface'], shapes: ['round'] }, bindings: { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] }, never_covers: ['stop'] },++ ],++ },++ layout: null,++ words: 'put Stop on the left and make it huge',++ device: 'phone',++};EXAMPLES['live.moments.verify'] = { title: 'Rust night', clip_title: 'It Compiles At Last', clip_desc: 'The streamer jumps up cheering when the build passes.', t: 95, timeline: [{ t: 95, text: 'streamer jumps up cheering' }], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 96, label: 'Cheering' }], chat: { peak: 22, avg: 3, people: 7 } };EXAMPLES['live.moments.pick'] = { title: 'Rust night', flavor: 'clip', timeline: [{ t: 12, text: 'the build fails' }, { t: 95, text: 'streamer jumps up cheering' }], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 96, label: 'Cheering' }], clipped: [95], spikes: [96], avoid: [300] };
@@ -76,7 +76,10 @@ Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`;
@@ -76,7 +76,10 @@ Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`;
`ai.quota.attribution.manage` (per-streamer quotas), `ai.memory.read` and `ai.memory.write` (Memory M2a,
`ai.quota.attribution.manage` (per-streamer quotas), `ai.memory.read` and `ai.memory.write` (Memory M2a,
`/api/v1/memory/*`), `ai.me.read` and `ai.me.manage` ("Your AI", `/api/v1/me/*`: a person's own keys and
`/api/v1/memory/*`), `ai.me.read` and `ai.me.manage` ("Your AI", `/api/v1/me/*`: a person's own keys and
routing preferences; only the person's own token or AI-site session) and `ai.app.run` (developer apps, ADR-014; public,
routing preferences; only the person's own token or AI-site session) and `ai.app.run` (developer apps, ADR-014; public,
-registered since openvibe-contracts 0.109.0). A service token's `ns`
+registered since openvibe-contracts 0.109.0), and `ai.layout.suggest` (`POST /api/v1/ui/layout/suggest`: a surface catalog,
+the layout shown now and the person's words in, a validated `ui.layout@1` with a one-sentence summary or an honest
+refusal out; one retry with the validator's reasons, on the cheap structured-output route, as the person named in
+`X-OV-Subject`). A service token's `ns`
claim limits which workflow namespaces it may run; an app token is fixed to its project's.
claim limits which workflow namespaces it may run; an app token is fixed to its project's.
A first-party service may also open a batch of the network's own with the `ai.batches.create` grant
A first-party service may also open a batch of the network's own with the `ai.batches.create` grant
@@ -7,8 +7,8 @@
@@ -7,8 +7,8 @@
"runtime": "node 22, express, postgresql",
"runtime": "node 22, express, postgresql",
"port": 4700,
"port": 4700,
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
- "contracts": "openvibe-contracts v0.408.0 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)",
- "sdk": "openvibe-sdk v0.150.0",
+ "contracts": "openvibe-contracts v0.410.0 (ai.config.manage and the common.config-* contracts carry D11; billing.credit-hold@1 takes a nullable project_id for personal holds)",
+ "sdk": "openvibe-sdk v0.152.0",
"shared": "openvibe-shared v4.206.0",
"shared": "openvibe-shared v4.206.0",
"capabilities": [
"capabilities": [
"ai.run.create",
"ai.run.create",
@@ -23,7 +23,8 @@
@@ -23,7 +23,8 @@
"ai.memory.write",
"ai.memory.write",
"ai.me.read",
"ai.me.read",
"ai.me.manage",
"ai.me.manage",
- "ai.config.manage"
+ "ai.config.manage",
+ "ai.layout.suggest"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"providers": [
"providers": [
@@ -13,8 +13,8 @@
@@ -13,8 +13,8 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1624,9 +1624,9 @@
@@ -1624,9 +1624,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.408.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
- "integrity": "sha512-Q2XjfuCDyMFd88CIKVwdZKR9IwZsxZB41cFKxaIWVIrmCt8YO5P2zj8beqvBanqpNgOjPB74HTU/0JCMTbyA+Q==",
+ "version": "0.410.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "integrity": "sha512-rmmq2mebXim2fv+sip2YQ0PrrZLxGUhEfe1w+1EFfGdoVvLI5tp0/eavY2sW6/VU+rzs7cQMC/A0NZD5th5wrA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1640,9 +1640,9 @@
@@ -1640,9 +1640,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.150.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",
+ "version": "0.152.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "integrity": "sha512-/nZbjF9KTJPa3582v60tx238BBDa47tJAmxVkbgSWRReXgMHHGAhV8FGKqFmxPHOxLzvMgVp0Ei4mz4A4jsUvQ==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -29,8 +29,9 @@
@@ -29,8 +29,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.410.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.152.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.206.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+/**
+ * POST /api/v1/ui/layout/suggest (ai.layout.suggest; make your own controls design, 2026-11-11,
+ * piece 6). A first-party service sends the surface catalog, the layout shown now, the person's words
+ * and the device; a validated ui.layout@1 and a one-sentence summary come back, or an honest
+ * "couldn't" with the validator's reasons.
+ *
+ * The run is the workflow ui.layout.suggest on the cheap structured-output route. If its answer
+ * breaks a rule, this route makes ONE more run with the validator's reasons in the input (the same
+ * catalog and words, no other change). It never applies a layout: the caller previews it for the
+ * person. The person the service names in X-OV-Subject is the run's owner, so their own AI limits,
+ * preferences and cache scope apply.
+ */
+const express = require('express');
+const { validate, http } = require('openvibe-contracts');
+const { AiError, sendError } = require('../util');
+
+const CAP = 'ai.layout.suggest';
+const REQUEST = 'ui.layout-request@1';
+const SUGGESTION = 'ui.layout-suggestion@1';
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const WAIT_MS = 20000;
+const LIMIT = 10;
+const WINDOW_MS = 60_000;
+
+function uiLayoutRouter({ runs, auth, log = console, now = () => Date.now() }) {
+ const r = express.Router();
+ r.use(http.middleware());
+ const guard = auth.requireCap(CAP);
+ const seen = new Map();
+ const allow = (key, at) => {
+ const kept = (seen.get(key) || []).filter((t) => at - t < WINDOW_MS);
+ if (kept.length >= LIMIT) { seen.set(key, kept); return false; }
+ kept.push(at); seen.set(key, kept);
+ if (seen.size > 5000) seen.delete(seen.keys().next().value);
+ return true;
+ };
+
+ const receiptOf = (run) => ({
+ model: String((run.provenance && run.provenance.model) || run.model || 'unknown').slice(0, 200),
+ ...(run.provenance && run.provenance.provider ? { provider: String(run.provenance.provider).slice(0, 100) } : {}),
+ ...(run.provenance && run.provenance.route ? { route: String(run.provenance.route).slice(0, 200) } : {}),
+ run_id: String(run.id),
+ cost_usd: Number(run.usage && run.usage.cost_usd) || 0,
+ });
+
+ function refused(reasons, run) {
+ const list = (reasons || []).map((x) => String(x).slice(0, 300)).filter(Boolean).slice(0, 20);
+ const why = list[0] || 'the model could not make that layout';
+ return { ok: false, layout: null, summary: `Couldn't do that: ${why}`.slice(0, 300), reasons: list.length ? list : [why], receipt: receiptOf(run) };
+ }
+
+ async function oneRun(input, req) {
+ const created = await runs.create({ workflow: 'ui.layout.suggest', task: 'ui.layout.suggest', input }, req.principal, {
+ trace: req.ov && req.ov.traceId, requestId: req.ov && req.ov.requestId, callerWaitMs: WAIT_MS,
+ });
+ return await runs.explained(await runs.wait(created, WAIT_MS));
+ }
+
+ const unavailable = (run) => run && run.error && ['provider.unavailable', 'route.unavailable', 'ai.resting', 'workflow.not_found', 'task.not_found'].includes(run.error.code);
+
+ r.post('/api/v1/ui/layout/suggest', guard, async (req, res) => {
+ try {
+ const at = now();
+ const named = String(req.get('x-ov-subject') || '').trim();
+ if (named && !SUBJECT_RE.test(named)) throw new AiError(422, 'input.invalid', 'X-OV-Subject names a person with usr_…');
+ if (named) req.principal = { ...req.principal, onBehalfOf: named };
+ if (!allow(named || (req.principal && req.principal.sub) || 'anonymous', at)) {
+ throw new AiError(429, 'layout.rate_limited', 'too many layout suggestions; wait a minute');
+ }
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const v = validate(REQUEST, body);
+ if (!v.valid) throw new AiError(422, 'input.invalid', `the request does not match ${REQUEST}`, { errors: v.errors });
+ let run = await oneRun(body, req);
+ if (run.status !== 'succeeded' || !run.output || run.output.ok !== true) {
+ const first = run.status !== 'succeeded'
+ ? [run.error && run.error.detail ? String(run.error.detail).slice(0, 300) : 'the model did not answer with a layout']
+ : (Array.isArray(run.output.reasons) && run.output.reasons.length ? run.output.reasons : ['the answer broke a rule']);
+ if (run.status !== 'succeeded' && unavailable(run)) throw new AiError(503, 'layout.suggest_unavailable', first[0]);
+ // One retry with the validator's reasons. Same catalog, same words, same device.
+ run = await oneRun({ ...body, retry: { reasons: first.slice(0, 20) } }, req);
+ }
+ if (run.status !== 'succeeded') {
+ if (unavailable(run)) throw new AiError(503, 'layout.suggest_unavailable', (run.error && run.error.detail) || 'the model is unavailable');
+ return res.json(refused([run.error && run.error.detail || 'the model could not make that layout'], run));
+ }
+ const answer = run.output && typeof run.output === 'object' ? { ...run.output, receipt: receiptOf(run) } : null;
+ if (!answer || !validate(SUGGESTION, answer).valid) {
+ throw new AiError(502, 'layout.suggest_invalid', 'the model answered something that is not a layout suggestion');
+ }
+ if (!answer.ok) return res.json({ ...answer, receipt: receiptOf(run) });
+ // The layout is already checked with openvibe-sdk/ui-layout by the workflow's postprocess; the
+ // route only confirms the whole answer is ui.layout-suggestion@1.
+ return res.json(answer);
+ } catch (err) {
+ if (err && err.status) return sendError(res, err, req.ov, log);
+ log.error && log.error('[ui-layout]', err && err.stack || err);
+ return http.sendProblem(res, 500, 'layout.suggest_error', { detail: 'could not suggest a layout', ctx: req.ov });
+ }
+ });
+ return r;
+}
+
+module.exports = { uiLayoutRouter, CAP };
@@ -262,6 +262,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
@@ -262,6 +262,8 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
const ops = createOps({ db, registry, pool, quotas, cache, runs });
const ops = createOps({ db, registry, pool, quotas, cache, runs });
app.use(runsRouter({ choices, runs, registry, auth, config, log }));
app.use(runsRouter({ choices, runs, registry, auth, config, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
+ // People's own controls (2026-11-11): one task that turns words into a checked ui.layout@1.
+ app.use(require('./api/ui-layout').uiLayoutRouter({ runs, auth, log }));
app.use(require('./api/compat-extra').compatExtra({ runs, auth, config, log }));
app.use(require('./api/compat-extra').compatExtra({ runs, auth, config, log }));
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
// The OpenAI-compatible endpoint (POST /v1/chat/completions, GET /v1/models): runs of ai.complete.
app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));
app.use(require('./api/routing-preview').routingPreview({ auth, registry, log }));
@@ -32,6 +32,7 @@ const SEEDS = [
@@ -32,6 +32,7 @@ const SEEDS = [
['search.web', 'search', 'Find sources on the web.', 'research.gather'],
['search.web', 'search', 'Find sources on the web.', 'research.gather'],
['search.images', 'search', 'Find licensed images.', 'research.gather'],
['search.images', 'search', 'Find licensed images.', 'research.gather'],
['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'blog.changelog.tag'],
['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'blog.changelog.tag'],
+ ['ui.layout.suggest', 'text', 'Turn a person’s words into a layout for one surface.', 'ui.layout.suggest'],
];
];
const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;
const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;
@@ -942,6 +942,52 @@ route('get', '/actions', {
@@ -942,6 +942,52 @@ route('get', '/actions', {
responses: { 200: json('common.action-list-result@1', { type: 'object', required: ['items'], properties: { items: arrayOf(ref('ActionItem')) } }), ...errors([400, 401, 403, 429]) },
responses: { 200: json('common.action-list-result@1', { type: 'object', required: ['items'], properties: { items: arrayOf(ref('ActionItem')) } }), ...errors([400, 401, 403, 429]) },
});
});
+// ── People's own controls (2026-11-11) ───────────────────────────────────────────────────────
+const layoutPart = {
+ type: 'object', additionalProperties: false, required: ['id', 'visible', 'place', 'style', 'bindings'],
+ properties: {
+ id: str({ maxLength: 40 }), visible: { type: 'boolean' },
+ place: { type: 'object', description: 'x, y, w, h in grid cells, or dock + order + w + h for an overlay' },
+ style: { type: 'object', properties: { tint: str({ description: 'a theme token, never a colour' }), shape: str({ enum: ['round', 'pill', 'square'] }), opacity: { type: 'number', minimum: 0.4, maximum: 1 }, label: { type: 'boolean' } } },
+ bindings: { type: 'object', properties: { keys: arrayOf(str()), gamepad: arrayOf(str()) } },
+ },
+};
+const uiLayout = {
+ type: 'object', additionalProperties: false,
+ required: ['surface', 'surface_version', 'device', 'name', 'author', 'sharing', 'parts'],
+ properties: {
+ id: str({ pattern: '^lay_[0-9A-HJKMNP-TV-Z]{26}$' }), revision: integer({ minimum: 1 }), active: { type: 'boolean' },
+ surface: str({ maxLength: 80 }), surface_version: integer({ minimum: 1 }), device: str({ enum: ['phone', 'tablet', 'computer'] }),
+ name: str({ maxLength: 80 }), author: { type: 'object', properties: { name: str({ maxLength: 80 }), subject: ref('SubjectRef') } },
+ sharing: str({ enum: ['private', 'link', 'gallery'] }), parts: arrayOf(layoutPart), updated_at: str({ format: 'date-time' }),
+ },
+};
+const uiLayoutSuggestion = {
+ type: 'object', additionalProperties: false, required: ['ok', 'layout', 'summary', 'reasons'],
+ properties: {
+ ok: { type: 'boolean' },
+ layout: { oneOf: [uiLayout, { type: 'null' }] },
+ summary: str({ maxLength: 300 }),
+ reasons: arrayOf(str({ maxLength: 300 })),
+ receipt: { type: 'object', required: ['model'], properties: { model: str(), provider: str(), route: str(), run_id: str(), cost_usd: { type: 'number', minimum: 0 } } },
+ },
+};
+route('post', '/ui/layout/suggest', {
+ operationId: 'suggestUiLayout', summary: 'Turn words into a checked arrangement of one surface', tags: ['ui'], security: SERVICE,
+ description: 'ai.layout.suggest: a first-party service sends a ui.surface@1 catalog, the ui.layout@1 shown now (or null), the person\'s words (1-500 characters) and the device class. Answers ui.layout-suggestion@1: a layout openvibe-sdk/ui-layout accepted, with a one-sentence summary and reasons, or ok false with the validator\'s plain reasons. If the first answer is refused, the task makes ONE more run with those reasons, then says honestly that it could not. The layout is never applied; the caller previews it. X-OV-Subject names the person the run belongs to, so their own AI limits, preferences and cache scope apply.',
+ parameters: [{ name: 'X-OV-Subject', in: 'header', required: false, schema: str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), description: 'The person the arrangement is for' }],
+ requestBody: body({
+ type: 'object', additionalProperties: false, required: ['catalog', 'layout', 'words', 'device'],
+ properties: {
+ catalog: { type: 'object', description: 'ui.surface@1: id, version, devices, grid, theme_tokens and parts' },
+ layout: { oneOf: [uiLayout, { type: 'null' }] },
+ words: str({ minLength: 1, maxLength: 500 }),
+ device: str({ enum: ['phone', 'tablet', 'computer'] }),
+ },
+ }),
+ responses: { 200: json('ui.layout-suggestion@1', uiLayoutSuggestion), ...errors([400, 401, 403, 422, 429, 503]) },
+});
+
// ── Routing presets and receipts (the Exchange, 2026-11-06) ───────────────────────────────────
// ── Routing presets and receipts (the Exchange, 2026-11-06) ───────────────────────────────────
route('get', '/presets', {
route('get', '/presets', {
operationId: 'listPresets', summary: 'The routing presets this caller may name', tags: ['presets'], security: SERVICE_OR_APP,
operationId: 'listPresets', summary: 'The routing presets this caller may name', tags: ['presets'], security: SERVICE_OR_APP,
@@ -626,6 +626,8 @@ const POSTPROCESS = {
@@ -626,6 +626,8 @@ const POSTPROCESS = {
// Space's builder keeps its hooks beside its templates (server/workflows/space.js).
// Space's builder keeps its hooks beside its templates (server/workflows/space.js).
{ const space = require('./space'); Object.assign(PREPARE, space.PREPARE); Object.assign(POSTPROCESS, space.POSTPROCESS); }
{ const space = require('./space'); Object.assign(PREPARE, space.PREPARE); Object.assign(POSTPROCESS, space.POSTPROCESS); }
+// People's own controls (2026-11-11): the layout suggestion's prepare and postprocess live with its template.
+{ const ui = require('./ui-layout'); PREPARE['ui.layout.suggest'] = ui.prepare; POSTPROCESS['ui.layout.suggest'] = ui.postprocess; }
/**
/**
* What the run cache keys on, for a workflow whose input carries parts that do not change the answer (item 151, C12:
* What the run cache keys on, for a workflow whose input carries parts that do not change the answer (item 151, C12:
@@ -25,6 +25,7 @@ const research = require('./research');
@@ -25,6 +25,7 @@ const research = require('./research');
const meme = require('./meme');
const meme = require('./meme');
const changelog = require('./changelog');
const changelog = require('./changelog');
const pictures = require('./pictures');
const pictures = require('./pictures');
+const uiLayout = require('./ui-layout');
const poolSeed = require('../providers/pool-seed');
const poolSeed = require('../providers/pool-seed');
const { resolveSecret } = require('../util');
const { resolveSecret } = require('../util');
@@ -255,6 +256,9 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -255,6 +256,9 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
await registry.seedVersioned('route', 'default.json', pool(60000, { options: { temperature: 0.3 }, max_output_tokens: 2400, response_format: 'json' }));
await registry.seedVersioned('route', 'default.json', pool(60000, { options: { temperature: 0.3 }, max_output_tokens: 2400, response_format: 'json' }));
// Meme captions need fluency, not factual precision; choose the cheapest eligible JSON route by default.
// Meme captions need fluency, not factual precision; choose the cheapest eligible JSON route by default.
await registry.seedVersioned('route', 'meme.caption', pool(30000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0.85 }, max_output_tokens: 700, response_format: 'json' }));
await registry.seedVersioned('route', 'meme.caption', pool(30000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0.85 }, max_output_tokens: 700, response_format: 'json' }));
+ // A layout suggestion is a small JSON arrangement, not a conversation: the cheapest structured-output
+ // offer that is healthy, with room for every part of a surface (make your own controls, 2026-11-11).
+ await registry.seedVersioned('route', 'ui.layout.suggest', pool(60000, { constraints: { objective: 'cheapest', latency_class: 'interactive' }, options: { temperature: 0.2 }, max_output_tokens: 4000, response_format: 'json' }));
await registry.seedVersioned('route', 'default.embedding', pool(30000, { 'capability': 'embed', constraints: { objective: 'balanced' } }));
await registry.seedVersioned('route', 'default.embedding', pool(30000, { 'capability': 'embed', constraints: { objective: 'balanced' } }));
// Speech (the Exchange speech route): the voice pins the exact offer, so the pool is only the fallback
// Speech (the Exchange speech route): the voice pins the exact offer, so the pool is only the fallback
// order when no voice is pinned; every provider that declares `speak` may answer it.
// order when no voice is pinned; every provider that declares `speak` may answer it.
@@ -310,11 +314,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -310,11 +314,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
// Templates, then workflows (workflows reference templates)
// Templates, then workflows (workflows reference templates)
- for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates]) {
+ for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates, ...uiLayout.templates]) {
const { key, ...def } = t;
const { key, ...def } = t;
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
}
}
- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows]) {
+ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows, ...uiLayout.workflows]) {
const { key, ...def } = w;
const { key, ...def } = w;
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * ui.layout.suggest (make your own controls design, 2026-11-11, piece 6).
+ *
+ * The person's words, the surface catalog and the layout they see now go in; a ui.layout@1 that
+ * only arranges parts the catalog has comes out, with a one-sentence summary. The postprocess is the
+ * authority: it builds the document from the model's answer, fills in what the model never chooses
+ * (surface, version, device, author, sharing) and runs openvibe-sdk/ui-layout, the same function the
+ * editor and OpenVibe.Network use. An answer that breaks a rule becomes ok:false with the validator's
+ * reasons, never a silently repaired layout; the route asks once more with those reasons.
+ *
+ * Model output shape (enforced as structured output): { ok, layout, summary, reasons }. The
+ * postprocess never trusts the model's `ok`.
+ */
+const { validate: validateLayout } = require('openvibe-sdk/ui-layout');
+
+const MAX_REASONS = 20;
+const oneLine = (v, max = 300) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '');
+const listOf = (v) => (Array.isArray(v) ? v.map((x) => oneLine(x, 300)).filter(Boolean).slice(0, MAX_REASONS) : []);
+
+function refused(reasons, summary) {
+ const list = reasons.length ? reasons : ['that arrangement cannot be made'];
+ return { ok: false, layout: null, summary: oneLine(summary) || `Couldn't do that: ${list[0]}`, reasons: list };
+}
+
+/** The model's answer -> ui.layout-suggestion@1, with the layout built server-side and checked. */
+function postprocess(r, input) {
+ const answer = r && r.json;
+ if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return refused(['the model did not answer with a layout'], "Couldn't do that: the model did not answer with a layout.");
+ if (answer.ok === false) return refused(listOf(answer.reasons), answer.summary);
+ const raw = answer.layout && typeof answer.layout === 'object' && !Array.isArray(answer.layout) ? answer.layout : null;
+ if (!raw || !Array.isArray(raw.parts)) return refused(['the model did not send a layout'], "Couldn't do that: the model did not send a layout.");
+ const current = input.layout && typeof input.layout === 'object' ? input.layout : null;
+ const layout = {
+ surface: input.catalog.id,
+ surface_version: input.catalog.version,
+ device: input.device,
+ name: oneLine(raw.name, 80) || oneLine(current && current.name, 80) || 'Suggested',
+ author: { name: oneLine(current && current.author && current.author.name, 80) || 'OpenVibe' },
+ sharing: 'private',
+ parts: raw.parts,
+ };
+ const checked = validateLayout(input.catalog, layout);
+ if (!checked.ok) return refused(checked.reasons, `Couldn't do that: ${checked.reasons[0]}`);
+ return {
+ ok: true,
+ layout,
+ summary: oneLine(answer.summary) || 'Updated the arrangement.',
+ reasons: listOf(answer.reasons),
+ };
+}
+
+const templates = [{
+ key: 'ui.layout.suggest', name: 'A layout from a person’s words',
+ description: 'Turn words into a ui.layout@1 for one surface, using only the catalog’s parts and rules.',
+ input_schema: { type: 'object' },
+ output_schema: {
+ type: 'object', additionalProperties: false, required: ['ok', 'layout', 'summary', 'reasons'],
+ properties: {
+ ok: { type: 'boolean' },
+ layout: { type: ['object', 'null'] },
+ summary: { type: 'string', minLength: 1, maxLength: 300 },
+ reasons: { type: 'array', maxItems: MAX_REASONS, items: { type: 'string', minLength: 1, maxLength: 300 } },
+ },
+ },
+ system_prompt: 'You arrange the controls of one OpenVibe surface for one person. The catalog is the only power you have: use only the parts it lists, with their kinds, minimum sizes, allowed styles (tints are theme tokens, never colours), shapes, opacity and bindings. Never add a part, a binding or a style the catalog does not allow, and never invent a command. A required part (for example Stop and the e-stop) must stay visible and at least its minimum size; anything pressed must be at least 44 px, and a part with a larger minimum keeps it. Nothing may cover a required part. Keep the answer data: no URL, markup or code. The person’s words are a wish; safety rules win. Choose a sensible arrangement on the grid named for the device: x, y, w and h are cells, with the catalog’s column count. Give every part you place a visible flag, a place, a style and bindings from the catalog. If the person asks for something the catalog cannot do, answer ok false with a one-sentence summary and the plain reasons instead of breaking a rule. Output only JSON: {"ok":true,"layout":{"name":"…","parts":[…] },"summary":"one sentence","reasons":["…"]}, or {"ok":false,"layout":null,"summary":"Couldn\'t do that: …","reasons":["…"]}.',
+ user_prompt: '{{json payload}}',
+ default_route: 'ui.layout.suggest', owner: 'network', metadata: { json_name: 'layout_suggestion' },
+}];
+
+const workflows = [{
+ key: 'ui.layout.suggest', name: 'Suggest a layout from words', namespace: 'network',
+ description: 'OpenVibe.Network’s Ask OpenVibe: the catalog, the current layout, the words and the device in, a validated ui.layout@1 out. One retry with the validator’s reasons is made by the route, never silently.',
+ input_schema: {
+ type: 'object', additionalProperties: false, required: ['catalog', 'layout', 'words', 'device'],
+ properties: {
+ catalog: { type: 'object' },
+ layout: { type: ['object', 'null'] },
+ words: { type: 'string', minLength: 1, maxLength: 500 },
+ device: { enum: ['phone', 'tablet', 'computer'] },
+ retry: {
+ type: 'object', additionalProperties: false, required: ['reasons'],
+ properties: { reasons: { type: 'array', maxItems: MAX_REASONS, items: { type: 'string', minLength: 1, maxLength: 300 } } },
+ },
+ },
+ },
+ output_schema: templates[0].output_schema,
+ steps: [{ kind: 'llm', template: 'ui.layout.suggest', operation: 'generate', output: 'json', prepare: 'ui.layout.suggest', postprocess: 'ui.layout.suggest', params: { max_tokens: 4000, temperature: 0.2, timeout_ms: 60000 } }],
+ cache_mode: 'none',
+}];
+
+/** The template variables: the request itself, plus the reasons the last answer was refused. */
+function prepare(input) {
+ const payload = { catalog: input.catalog, layout: input.layout, words: input.words, device: input.device };
+ if (input.retry && Array.isArray(input.retry.reasons) && input.retry.reasons.length) payload.previous_refusal = { reasons: input.retry.reasons.slice(0, MAX_REASONS) };
+ return { vars: { payload } };
+}
+
+module.exports = { templates, workflows, prepare, postprocess };
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+// ui.layout.suggest (make your own controls design, 2026-11-11, piece 6): a fake model through the
+// HTTP seam answers twice — once with a layout that hides Stop, once with a valid one — and the task
+// retries exactly once with the validator's reasons. The second test makes the model fail both times
+// and checks the honest "couldn't".
+const assert = require('assert');
+const { boot, request, seamServer, suite, token } = require('./helpers');
+const { validate: validateLayout } = require('openvibe-sdk/ui-layout');
+const { validate } = require('openvibe-contracts');
+
+const t = suite('ui-layout');
+const CAP = token('network', ['ai.layout.suggest'], { sub: 'svc:network' });
+const NOCAP = token('network', ['ai.run.create'], { sub: 'svc:network' });
+const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+
+const catalog = () => ({
+ id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone', 'tablet', 'computer'],
+ grid: { columns: { phone: 4, tablet: 8, computer: 12 }, cell_px: { phone: 44, tablet: 44, computer: 44 } },
+ theme_tokens: ['danger', 'surface', 'text'],
+ parts: [
+ { id: 'stop', name: 'Stop', kind: 'button', required: true, pressed: true, min: { w: 2, h: 2, px: 64 },
+ styles: { tints: ['danger'], shapes: ['round'], opacity: { min: 0.7, max: 1 }, label: true },
+ bindings: { keys: ['Space'], gamepad: ['south'] }, never_covers: [] },
+ { id: 'stick', name: 'Drive stick', kind: 'stick', required: false, pressed: true, min: { w: 2, h: 2 },
+ styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },
+ bindings: { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] }, never_covers: ['stop'] },
+ ],
+});
+const part = (id, place, visible = true) => ({
+ id, visible, place,
+ style: { tint: id === 'stop' ? 'danger' : 'surface', shape: 'round', opacity: 1, label: id === 'stop' },
+ bindings: id === 'stop' ? { keys: ['Space'], gamepad: ['south'] } : { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] },
+});
+const validLayout = () => ({
+ surface: 'bot.controls', surface_version: 1, device: 'phone', name: 'One thumb',
+ author: { name: 'Ana' }, sharing: 'private',
+ parts: [part('stop', { x: 0, y: 0, w: 2, h: 2 }), part('stick', { x: 0, y: 2, w: 2, h: 2 })],
+});
+const hiddenStop = () => {
+ const l = validLayout(); l.parts[0].visible = false; return l;
+};
+const answer = (layout, ok = true, summary = 'Moved Stop to the top.', reasons = ['Stop stayed visible.']) => ({ json: { ok, layout, summary, reasons } });
+const requestBody = () => ({ catalog: catalog(), layout: validLayout(), words: 'put Stop on the left and make it huge', device: 'phone' });
+
+let seam = null;
+let answers = [];
+let h = null;
+
+t.test('a layout that hides Stop is refused, retried once with the reasons, and the valid second answer comes back', async () => {
+ seam = await seamServer(async () => {
+ const index = Math.min(seam.calls - 1, answers.length - 1);
+ return answers[index];
+ });
+ answers = [answer(hiddenStop()), answer(validLayout())];
+ h = await boot({ env: { AI_HTTP_SEAM_URL: seam.url, AI_USER_MODULES: 'off', AI_FREE_PROVIDERS: 'http-seam', AI_FREE_DAILY_TOKENS: '100000' } });
+ // The seam becomes the only chat/json offer: a fake model with a model record the router can choose.
+ await h.registry.upsertProvider({ key: 'http-seam', capabilities: ['chat', 'generate', 'json'], default_model: 'layout-fake', billing_profile: 'free', provider_access: 'OWN_DEPLOYMENT' }, { actor: 'test' });
+ await h.registry.upsertModel({ provider_key: 'http-seam', model_key: 'layout-fake', type: 'chat', status: 'active', supports: { json: true }, cost: { in_per_mtok: 0, out_per_mtok: 0, cached_per_mtok: 0 } }, { actor: 'test' });
+ await h.registry.upsertProvider({ key: 'stub', status: 'disabled' }, { actor: 'test' });
+
+ const r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: requestBody() });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.ok, true, r.text);
+ assert.strictEqual(seam.calls, 2, 'one retry, no more');
+ assert.strictEqual(r.body.receipt.model, 'layout-fake');
+ assert.ok(r.body.receipt.run_id, 'the receipt names the run');
+ assert.ok(r.body.receipt.cost_usd >= 0);
+ assert.ok(validate('ui.layout-suggestion@1', r.body).valid, JSON.stringify(validate('ui.layout-suggestion@1', r.body).errors));
+ assert.deepStrictEqual(validateLayout(catalog(), r.body.layout), { ok: true, reasons: [] });
+ assert.strictEqual(r.body.layout.author.subject, undefined, 'the run does not invent an account author');
+ const row = await h.db.prepare("SELECT on_behalf_of, requester_type FROM runs WHERE workflow_key = 'ui.layout.suggest' ORDER BY id DESC LIMIT 1").get();
+ assert.deepStrictEqual(JSON.parse(row.on_behalf_of), { type: 'user', id: SUBJECT }, 'the run is the person’s, for their own limits');
+ assert.strictEqual(row.requester_type, 'service', 'the service is still the recorded requester');
+ // The second prompt carries the validator's reason, and the same words and catalog.
+ const second = JSON.parse(seam.last.messages[seam.last.messages.length - 1].content);
+ assert.deepStrictEqual(second.previous_refusal.reasons, ['Stop has to stay visible.']);
+ assert.strictEqual(second.words, 'put Stop on the left and make it huge');
+ assert.strictEqual(second.device, 'phone');
+});
+
+t.test('two invalid answers end in an honest could-not, not a third call', async () => {
+ answers = [answer(hiddenStop()), answer(hiddenStop())];
+ const r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: requestBody() });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.ok, false);
+ assert.strictEqual(r.body.layout, null);
+ assert.match(r.body.summary, /^Couldn't do that: Stop has to stay visible\./);
+ assert.ok(r.body.reasons.includes('Stop has to stay visible.'));
+ assert.ok(validate('ui.layout-suggestion@1', r.body).valid);
+ assert.strictEqual(seam.calls, 4, 'one retry for each of the two requests');
+});
+
+t.test('the model may say it cannot, and the answer stays a refusal', async () => {
+ answers = [answer(null, false, "Couldn't do that: the catalog has no vertical head slider.", ['the catalog has no vertical head slider'])];
+ const r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: requestBody() });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.ok, false);
+ assert.strictEqual(r.body.layout, null);
+ assert.ok(r.body.reasons.includes('the catalog has no vertical head slider'));
+ assert.ok(r.body.receipt.model, 'a refusal carries its receipt too');
+});
+
+t.test('refusals before any model call: capability, subject, body and words', async () => {
+ const before = seam.calls;
+ let r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: NOCAP, body: requestBody() });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.body.code, 'capability.denied');
+ r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': 'not-a-subject' }, body: requestBody() });
+ assert.strictEqual(r.status, 422);
+ r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: { ...requestBody(), words: '' } });
+ assert.strictEqual(r.status, 422);
+ assert.match(r.text, /words/);
+ r = await request(h.base, 'POST', '/api/v1/ui/layout/suggest', { tok: CAP, headers: { 'X-OV-Subject': SUBJECT }, body: { ...requestBody(), device: 'watch' } });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(seam.calls, before, 'no model was called');
+});
+
+t.test('the generic run API can run the task, and the task is registered under its own name', async () => {
+ answers = [answer(validLayout())];
+ const route = await h.registry.resolveRoute('ui.layout.suggest');
+ assert.strictEqual(route.response_format, 'json', 'the task asks for structured output');
+ assert.strictEqual(route.constraints.objective, 'cheapest', 'the task runs on the cheap tier');
+ const task = await h.choices.task('ui.layout.suggest');
+ assert.ok(task, 'the task is seeded');
+ assert.strictEqual(task.workflow, 'ui.layout.suggest');
+ const run = await h.runs.create({ task: 'ui.layout.suggest', input: requestBody() }, { kind: 'service', sub: 'svc:network', subject: { type: 'service', id: 'network' }, ns: ['*'], cap: ['ai.layout.suggest'] }, { callerWaitMs: 20000 });
+ const done = await h.runs.explained(await h.runs.wait(run, 20000));
+ assert.strictEqual(done.status, 'succeeded', JSON.stringify(done.error));
+ assert.strictEqual(done.output.ok, true);
+});
+
+t.test('cleanup', async () => {
+ await seam.close();
+ await h.stop();
+});
+
+t.run();
@@ -98,6 +98,22 @@ EXAMPLES['live.easter_egg'] = { vibe: 'croutons' };
@@ -98,6 +98,22 @@ EXAMPLES['live.easter_egg'] = { vibe: 'croutons' };
EXAMPLES['space.app.build'] = { prompt: 'A guestbook where people sign in and leave a one-line note', name: 'Guestbook', current: [{ path: 'index.html', content: '<!doctype html><title>Guestbook</title><script src="/_openvibe/sdk.js"></script>' }] };
EXAMPLES['space.app.build'] = { prompt: 'A guestbook where people sign in and leave a one-line note', name: 'Guestbook', current: [{ path: 'index.html', content: '<!doctype html><title>Guestbook</title><script src="/_openvibe/sdk.js"></script>' }] };
EXAMPLES['space.app.ask'] = { space: 'Pirate helper', instructions: 'Talk like a pirate.', transcript: 'visitor: hi\nai: Ahoy!', message: 'What is a good name for a parrot?' };
EXAMPLES['space.app.ask'] = { space: 'Pirate helper', instructions: 'Talk like a pirate.', transcript: 'visitor: hi\nai: Ahoy!', message: 'What is a good name for a parrot?' };
EXAMPLES['live.home.star'] = { recent_stars: ['bob'], previous_star: 'bob', candidates: [{ username: 'ann', name: 'Ann', language: 'en', last_14d: { sessions: 5, hours: 12 } }] };
EXAMPLES['live.home.star'] = { recent_stars: ['bob'], previous_star: 'bob', candidates: [{ username: 'ann', name: 'Ann', language: 'en', last_14d: { sessions: 5, hours: 12 } }] };
+// People's own controls (2026-11-11): the fake surface is deliberately small; the stub's answer is
+// refused by the layout validator, which is still a schema-valid suggestion and exercises that path.
+EXAMPLES['ui.layout.suggest'] = {
+ catalog: {
+ id: 'bot.controls', version: 1, name: 'Robot controls', devices: ['phone'],
+ grid: { columns: { phone: 4, tablet: 8, computer: 12 }, cell_px: { phone: 44, tablet: 44, computer: 44 } },
+ theme_tokens: ['danger', 'surface'],
+ parts: [
+ { id: 'stop', name: 'Stop', kind: 'button', required: true, pressed: true, min: { w: 2, h: 2, px: 64 }, styles: { tints: ['danger'], shapes: ['round'] }, bindings: { keys: ['Space'], gamepad: ['south'] } },
+ { id: 'stick', name: 'Drive stick', kind: 'stick', required: false, pressed: true, min: { w: 2, h: 2 }, styles: { tints: ['surface'], shapes: ['round'] }, bindings: { keys: ['W', 'A', 'S', 'D'], gamepad: ['left-stick'] }, never_covers: ['stop'] },
+ ],
+ },
+ layout: null,
+ words: 'put Stop on the left and make it huge',
+ device: 'phone',
+};
EXAMPLES['live.moments.verify'] = { title: 'Rust night', clip_title: 'It Compiles At Last', clip_desc: 'The streamer jumps up cheering when the build passes.', t: 95, timeline: [{ t: 95, text: 'streamer jumps up cheering' }], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 96, label: 'Cheering' }], chat: { peak: 22, avg: 3, people: 7 } };
EXAMPLES['live.moments.verify'] = { title: 'Rust night', clip_title: 'It Compiles At Last', clip_desc: 'The streamer jumps up cheering when the build passes.', t: 95, timeline: [{ t: 95, text: 'streamer jumps up cheering' }], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 96, label: 'Cheering' }], chat: { peak: 22, avg: 3, people: 7 } };
EXAMPLES['live.moments.pick'] = { title: 'Rust night', flavor: 'clip', timeline: [{ t: 12, text: 'the build fails' }, { t: 95, text: 'streamer jumps up cheering' }], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 96, label: 'Cheering' }], clipped: [95], spikes: [96], avoid: [300] };
EXAMPLES['live.moments.pick'] = { title: 'Rust night', flavor: 'clip', timeline: [{ t: 12, text: 'the build fails' }, { t: 95, text: 'streamer jumps up cheering' }], transcript: [{ t: 94, text: 'IT COMPILES' }], sounds: [{ t: 96, label: 'Cheering' }], clipped: [95], spikes: [96], avoid: [300] };