openvibers/openvibe.design Commits

Create asks the server what it can make and what it will cost (#246)

OP OpenVibers committed 3 days ago · 78b9c95a0b · 17 files, +1484 −41

The create request now names a kind, a frame, a look, one or more models with takes, and a starting URL, design or Media picture. GET /api/create/options reads the same Exchange catalogue OpenVibe.AI publishes and the person's own keys and machines; POST /api/create/estimate and POST /api/create share one plan and one price. Each design stores its choices and a generated thumbnail, and GET /api/create/recent pages the person's designs. The present composer keeps working.

Changes

ddiff --git a/.env.example b/.env.example
iindex 5bba0cd..a6cfbb1 100644
---- a/.env.example
++++ b/.env.example
@@@ -41,6 +41,14 @@ OV_AI_INTERNAL_URL=http://127.0.0.1:4700
# Three explicit provider:model slots; unset pins AI's generate-route offers (fewer than three falls back to its pool).
# DESIGN_ARENA_MODELS=shared:gpt-5-mini,local:qwen2.5
++# OpenVibe.Billing: the person's Balance for the create estimate, read with their own token. A missing
++# service leaves the Balance unknown, never zero.
++OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
++# DESIGN_BILLING_TIMEOUT_MS=3000
++
++# OpenVibe.Media: a starting picture is uploaded there and named by its id; this is the public file origin.
++# OV_MEDIA_PUBLIC_ORIGIN=https://openvibe.media
++
# OpenVibe.Bench owns new Arena rounds, entries, votes and boards.
OV_BENCH_INTERNAL_URL=http://127.0.0.1:5230
# OV_BENCH_URL=https://bench.openvibe.services
ddiff --git a/migrations/0021_create_choices.sql b/migrations/0021_create_choices.sql
nnew file mode 100644
iindex 0000000..13b1164
---- /dev/null
++++ b/migrations/0021_create_choices.sql
@@@ -0,0 +1,18 @@
++-- phase: expand
++-- A design records the choices it was made with (the create page's modular request): the frame, the look
++-- (the theme centre's theme or the person's own), the models asked for, the starting point and the estimate.
++-- Every column is nullable and the old page's rows keep working; the kind check is widened, never narrowed.
++ALTER TABLE designs ADD COLUMN IF NOT EXISTS frame text COLLATE "C";
++ALTER TABLE designs ADD COLUMN IF NOT EXISTS look text COLLATE "C";
++ALTER TABLE designs ADD COLUMN IF NOT EXISTS look_variables jsonb;
++ALTER TABLE designs ADD COLUMN IF NOT EXISTS models jsonb;
++ALTER TABLE designs ADD COLUMN IF NOT EXISTS start jsonb;
++ALTER TABLE designs ADD COLUMN IF NOT EXISTS source_design_id text COLLATE "C";
++ALTER TABLE designs ADD COLUMN IF NOT EXISTS estimate jsonb;
++ALTER TABLE designs ADD COLUMN IF NOT EXISTS thumbnail_svg text;
++
++ALTER TABLE designs DROP CONSTRAINT IF EXISTS designs_kind;
++ALTER TABLE designs ADD CONSTRAINT designs_kind
++ CHECK (kind IN ('page', 'screen', 'component', 'slide', 'email', 'social-card', 'mark'));
++
++CREATE INDEX IF NOT EXISTS designs_owner_recent_page ON designs (owner, created_at DESC, id);
ddiff --git a/server/app.js b/server/app.js
iindex 62f4722..26a0fdd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -40,6 +40,9 @@ const { createMcpRoutes } = require('./http/mcp');
const { createArenaApi } = require('./http/arena-api');
const { createDesignApi } = require('./http/design-api');
const { createDesigns } = require('./designs');
++const { createCreateOptions } = require('./create/options');
++const { createCreateApi } = require('./create/api');
++const { createBalanceReader } = require('./create/billing');
const { createComponentRegistry } = require('./components/store');
const { createSharing, describeRef } = require('./sharing');
const { createPageRoutes } = require('./http/pages');
@@@ -100,6 +103,10 @@ async function createApp(opts = {}) {
// check every read path asks, and the uniform API below. Guest designs keep their secret edit key.
const sharing = opts.sharing || createSharing({ db: s.db, config, log, now });
const designs = opts.designs || createDesigns({ s, ai, bench, catalog, config, log, now, sharing });
++ // The create page's options, its estimate and its person's Balance, read from the services that own them
++ // (AI's Exchange catalogue and choices, Network's themes, Billing's Balance), never a second hand-kept list.
++ const balance = opts.balance || createBalanceReader({ config, fetchImpl, log });
++ const createOptions = opts.createOptions || createCreateOptions({ config, ai, catalog, fetchImpl, log, now });
// Publishing a design on openvibe.space (open item D8).
const publisher = opts.publisher || require('./publish').createPublisher({ config, fetchImpl, log });
const accountData = accountDataLib.create({ db: s.db, log });
@@@ -115,7 +122,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Design cannot answer account events'); });
const ctx = {
-- config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker, registry,
++ config, s, store: s, keys, sso, settings, catalog, searchIndex, arena, bench, ai, jobs, designs, sharing, publisher, accountData, accountSend, log, picks, fonts, aiStore, maker, registry, createOptions, balance,
random: opts.random || Math.random,
};
@@@ -286,6 +293,7 @@ async function createApp(opts = {}) {
// ── API: the Arena, the glyphs (with their pages), then the theme proxy ──
app.use('/api/v1/designs', express.json({ limit: '256kb' }), createDesignApi(ctx));
app.use('/api', express.json({ limit: '64kb' }));
++ app.use('/api/create', createCreateApi(ctx));
app.use('/api/v1/arena', createArenaApi(ctx));
app.use('/api/v1/brand', require('./http/brand-api').createBrandApi(ctx));
app.get('/api/v1/prompts', (req, res) => { res.set('Cache-Control', 'public, max-age=300'); res.json({ prompts: require('./views/prompts').list() }); });
ddiff --git a/server/arena/ai.js b/server/arena/ai.js
iindex 20f6a2d..c35c9c9 100644
---- a/server/arena/ai.js
++++ b/server/arena/ai.js
@@@ -31,6 +31,10 @@ const CATEGORY_BRIEF = {
game_ui: 'A game interface: HUD, inventory or lobby that is readable at a glance and feels like a game.',
logo: 'A logo and small brand sheet: the mark, a wordmark, a colour palette and a few lockups.',
slides: 'A short slide deck: a title slide and two or three content slides with a clear visual hierarchy.',
++ component: 'One interface component with its states: its anatomy, the default view and the empty, busy and error states side by side.',
++ email: 'A 600 px email: table-friendly, inline styling, no scripts or remote images, one clear action and readable with images off.',
++ social: 'A share card with one idea: a short headline, a supporting line and the mark, legible at a glance inside the safe margins.',
++ mark: 'A brand mark sheet: the mark, a wordmark, a small palette and two or three lockups, drawn with inline SVG.',
};
/** The system prompt every design generation sends (published at openvibe.design/prompts). */
@@@ -97,6 +101,43 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
return json;
}
++ /**
++ * The Exchange catalogue AI publishes: every model, its offers, price in/out, speed, uptime and the 18+
++ * mark (`uncensored`). The create options read this, not a list kept in Design.
++ */
++ async function exchangeCatalog({ kind = 'chat' } = {}) {
++ const query = kind ? `?kind=${encodeURIComponent(kind)}` : '';
++ return await call('GET', `/api/v1/exchange/catalog${query}`);
++ }
++
++ /**
++ * AI's choice for a task, with the person's own token when there is one: the options grouped by source
++ * (openvibe, keys, machines), and `adult`, which says whether AI lets this person use the 18+ tier. A
++ * request with no token is the signed-out answer. A bad person token falls back to the signed-out answer
++ * rather than failing the whole create page.
++ */
++ async function choices({ token = null, task = 'ai.generate' } = {}) {
++ const path = `/api/v1/choices?task=${encodeURIComponent(task)}`;
++ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
++ let res;
++ try {
++ res = await fetchImpl(`${ai.url.replace(/\/+$/, '')}${path}`, {
++ headers, signal: AbortSignal.timeout(ai.timeoutMs),
++ });
++ } catch (err) {
++ throw new ArenaAiError(502, 'ai.unreachable', err && err.name === 'TimeoutError'
++ ? 'OpenVibe.AI did not answer in time.'
++ : 'OpenVibe.AI could not be reached.');
++ }
++ const json = await res.json().catch(() => null);
++ if (!res.ok) {
++ const code = (json && (json.code || json.error)) || `ai.http_${res.status}`;
++ const detail = (json && (json.detail || json.message)) || `OpenVibe.AI answered ${res.status}.`;
++ throw new ArenaAiError(res.status >= 500 ? 502 : res.status, String(code).slice(0, 80), String(detail).slice(0, 500));
++ }
++ return json;
++ }
++
/**
* The offers the routing lists for `generate` (GET /api/v1/routes?operation=generate). AI's route
* response is the source: an `offers` array of { provider, model }. A route that predates offers
@@@ -403,7 +444,7 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
return { text: textOf(run.output), model: String(prov.model || run.model || 'unknown').slice(0, 120), provider: providerName(prov.provider || run.provider || 'ai').slice(0, 80), run_id: run.id || null, ...paidFrom(run) };
}
-- return { enabled, slots, slotPlan, designPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };
++ return { enabled, slots, slotPlan, designPlan, offeredModels, rankOffers, generate, systemPrompt, text, exchangeCatalog, choices, CATEGORY_BRIEF };
}
module.exports = { createArenaAi, ArenaAiError, CATEGORY_BRIEF, systemPrompt };
ddiff --git a/server/config.js b/server/config.js
iindex 9d9e1be..f1ebd7e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -64,6 +64,19 @@ function load(env = process.env) {
maxHtmlBytes: 200 * 1024,
},
++ // OpenVibe.Media: a starting picture is uploaded there by the page and the create request carries its id;
++ // Design names its public file address in the prompt and never takes raw image bytes.
++ media: {
++ publicOrigin: trim(env.OV_MEDIA_PUBLIC_ORIGIN || 'https://openvibe.media'),
++ },
++
++ // OpenVibe.Billing: the person's Balance, read with their own token for the create estimate. Off (null)
++ // when the service is not on this host; the estimate then says the Balance is unknown, never zero.
++ billing: {
++ internalUrl: trim(env.OV_BILLING_INTERNAL_URL || 'http://127.0.0.1:4600'),
++ timeoutMs: Math.max(500, int(env.DESIGN_BILLING_TIMEOUT_MS, 3000)),
++ },
++
// OpenVibe.Space (open item D8): a design is published as a static site, with Design's service token acting for
// the person (X-OV-Subject); Network grants space.site.manage and space.deploy.create.
space: {
ddiff --git a/server/create/api.js b/server/create/api.js
nnew file mode 100644
iindex 0000000..c121e50
---- /dev/null
++++ b/server/create/api.js
@@@ -0,0 +1,147 @@
++'use strict';
++
++/**
++ * The create API, /api/create:
++ *
++ * GET /options kinds, frames, looks, models and takes (signed in or not)
++ * POST /estimate the same body a create takes → per model, total, Balance, whether it covers it
++ * POST / the create request → the design and its takes
++ * GET /recent the person's designs, newest first, paged
++ * GET /designs/:id/thumb.svg the recent list's card (a generated look card, not a screenshot)
++ *
++ * A guest is a guest (today's two-a-day IP limit and secret edit key); a signed-in person is their own owner
++ * and their Balance is read with their own token. The create and the estimate share server/create/plan.js, so a
++ * price and a run can never be planned from different choices.
++ */
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('../http/router');
++const { requireSameOrigin } = require('../http/same-origin');
++const { resolvePlan, estimatePlan, withBalance } = require('./plan');
++
++const owner = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.subject : null);
++const viewer = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer : null);
++const token = (req) => (req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null);
++const keyOf = (req) => req.get('x-design-key') || req.query.key || (req.body && (req.body.key || (req.body.start && req.body.start.key))) || null;
++
++function draftsOf(req) {
++ try {
++ const links = JSON.parse((req.cookies && req.cookies.design_drafts) || '[]');
++ return Array.isArray(links) ? links : [];
++ } catch { return []; }
++}
++
++function draftKeyFor(req, id) {
++ const found = draftsOf(req).find((item) => item && item.id === id);
++ return found ? found.key : null;
++}
++
++function createCreateApi(ctx) {
++ const routes = asyncRouter();
++ const sameOrigin = requireSameOrigin(ctx.config);
++ const privateJson = (res, status, body) => res.status(status).set('Cache-Control', 'private, no-store').json(body);
++ const safe = (fn) => async (req, res) => {
++ try { return await fn(req, res); } catch (err) {
++ ctx.log.warn('[OpenVibe.Design] create:', (err && err.message) || err);
++ if (res.headersSent) return;
++ return http.sendProblem(res, err.status || 500, err.code || 'create.error', {
++ detail: err.status ? err.detail : 'The request could not be completed.', ctx: req.ov,
++ });
++ }
++ };
++
++ routes.get('/options', safe(async (req, res) => {
++ const signedIn = Boolean(owner(req));
++ const all = await ctx.createOptions.options({ token: token(req) });
++ if (signedIn) res.set('Cache-Control', 'private, no-store');
++ else res.set({ 'Cache-Control': 'public, max-age=300', Vary: 'Cookie' });
++ return res.json(ctx.createOptions.publicOptions(all));
++ }));
++
++ const requestKey = (req) => {
++ const startId = req.body && req.body.start && req.body.start.design_id;
++ return keyOf(req) || (startId ? draftKeyFor(req, String(startId)) : null);
++ };
++
++ routes.post('/estimate', sameOrigin, safe(async (req, res) => {
++ const all = await ctx.createOptions.options({ token: token(req) });
++ const plan = await resolvePlan(req.body || {}, {
++ options: all, designs: ctx.designs, ai: ctx.ai, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,
++ });
++ const estimate = estimatePlan(plan);
++ const signedIn = Boolean(owner(req));
++ const balance = signedIn ? await ctx.balance.read(token(req)) : null;
++ return privateJson(res, 200, {
++ ...withBalance(estimate, balance),
++ kind: plan.kind, frame: plan.frame, look: plan.look,
++ legacy: plan.legacy,
++ ...(signedIn ? {} : { balance_note: 'Sign in to see your Balance.' }),
++ });
++ }));
++
++ routes.post('/', sameOrigin, safe(async (req, res) => {
++ const all = await ctx.createOptions.options({ token: token(req) });
++ const plan = await resolvePlan(req.body || {}, {
++ options: all, designs: ctx.designs, ai: ctx.ai, owner: owner(req), key: requestKey(req), viewer: viewer(req), config: ctx.config,
++ });
++ const estimate = estimatePlan(plan);
++ const out = await ctx.designs.create({
++ prompt: plan.prompt,
++ kind: plan.kind,
++ frame: plan.frame,
++ themeId: plan.themeId,
++ look: plan.look,
++ lookVariables: plan.lookVariables,
++ models: plan.legacy ? null : plan.models,
++ takes: plan.legacy ? null : plan.takes,
++ start: plan.start,
++ sourceDesignId: plan.start.source_design_id || null,
++ estimate,
++ who: (req.body || {}).who,
++ owner: owner(req), ip: req.ip,
++ });
++ if (out.editKey) {
++ let links = draftsOf(req);
++ links = [{ id: out.design.id, key: out.editKey }, ...links.filter((x) => x && x.id !== out.design.id)].slice(0, 3);
++ res.cookie('design_drafts', JSON.stringify(links), { httpOnly: true, sameSite: 'lax',
++ secure: ctx.config.cookies.secure, maxAge: 30 * 86400000, path: '/' });
++ }
++ const signedIn = Boolean(owner(req));
++ const balance = signedIn ? await ctx.balance.read(token(req)) : null;
++ return privateJson(res, 201, {
++ design: out.design,
++ takes: out.takes || [],
++ estimate: withBalance(estimate, balance),
++ ...(out.editKey ? { editKey: out.editKey, editUrl: out.editUrl } : {}),
++ });
++ }));
++
++ routes.get('/recent', safe(async (req, res) => {
++ const limit = Math.max(1, Math.min(48, Number.parseInt(req.query.limit, 10) || 12));
++ const before = typeof req.query.before === 'string' && req.query.before ? req.query.before : null;
++ const links = draftsOf(req);
++ const rows = await ctx.designs.recent(owner(req), links, { limit, before });
++ const last = rows.length ? rows[rows.length - 1].createdAt : null;
++ return privateJson(res, 200, {
++ designs: rows,
++ next: rows.length === limit && last ? last : null,
++ limit,
++ });
++ }));
++
++ routes.get('/designs/:id/thumb.svg', safe(async (req, res) => {
++ const id = String(req.params.id || '');
++ const key = keyOf(req) || draftKeyFor(req, id);
++ const out = await ctx.designs.thumbnail(id, owner(req), key, viewer(req));
++ res.set({
++ 'Content-Type': 'image/svg+xml; charset=utf-8',
++ 'Cache-Control': 'private, max-age=60',
++ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
++ 'X-Content-Type-Options': 'nosniff',
++ });
++ return res.send(out.svg);
++ }));
++
++ return routes;
++}
++
++module.exports = { createCreateApi };
ddiff --git a/server/create/billing.js b/server/create/billing.js
nnew file mode 100644
iindex 0000000..39d6a12
---- /dev/null
++++ b/server/create/billing.js
@@@ -0,0 +1,55 @@
++'use strict';
++
++/**
++ * The person's Balance, for the estimate's "does it cover it" line.
++ *
++ * A run on a managed model is charged in USD against the person's Billing Balance (the same account AI's
++ * credit holds use). Billing answers `/api/me/balance` for the person's own Network token; Design reads only
++ * the personal side (`available_nano_usd`, after holds). A missing or disabled Balance is null, never zero:
++ * the estimate says it does not know rather than that the person is broke.
++ */
++
++const NANO_PER_USD = 1_000_000_000;
++
++function createBalanceReader({ config = {}, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String((config.billing && config.billing.internalUrl) || '').replace(/\/+$/, '');
++ const timeoutMs = Math.max(500, Number(config.billing && config.billing.timeoutMs) || 3000);
++
++ /** → { currency, balance_usd, held_usd, available_usd } or null. Never throws. */
++ async function read(token) {
++ if (!token || !base) return null;
++ let res;
++ try {
++ res = await fetchImpl(`${base}/api/me/balance`, {
++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch {
++ return null;
++ }
++ const body = await res.json().catch(() => null);
++ if (!res.ok) {
++ // 404 billing.not_enabled and 409 billing.not_authoritative mean "no Balance here", not a person with $0.
++ if (res.status !== 404 && res.status !== 409) log.warn('[OpenVibe.Design] Balance read:', res.status);
++ return null;
++ }
++ const personal = body && body.personal;
++ if (!personal || personal.available_nano_usd == null) return null;
++ const nano = (value) => {
++ const n = Number(value);
++ return Number.isFinite(n) && n >= 0 ? Math.round(n) / NANO_PER_USD : null;
++ };
++ const available = nano(personal.available_nano_usd);
++ if (available == null) return null;
++ return {
++ currency: 'USD',
++ balance_usd: nano(personal.balance_nano_usd),
++ held_usd: nano(personal.held_nano_usd),
++ available_usd: available,
++ };
++ }
++
++ return { read, get enabled() { return Boolean(base); } };
++}
++
++module.exports = { createBalanceReader, NANO_PER_USD };
ddiff --git a/server/create/egress.js b/server/create/egress.js
nnew file mode 100644
iindex 0000000..dfdd3ff
---- /dev/null
++++ b/server/create/egress.js
@@@ -0,0 +1,134 @@
++'use strict';
++
++/**
++ * The one path a create request may use to read a page the person named.
++ *
++ * A starting URL is untrusted input. It is https only, no credentials in the URL, every DNS answer must be a
++ * public unicast address (openvibe-shared/egress, checked again at connect time by `safeLookup` so a rebinding
++ * answer cannot point the socket inside the network), redirects are followed at most twice and each hop is
++ * checked the same way, and the answer is capped. Nothing from the person (cookie, token, header) is sent.
++ *
++ * The page is reduced to a title and readable text; the generator gets that excerpt, never the raw HTML.
++ */
++const https = require('https');
++const { assertPublicUrl, safeLookup } = require('openvibe-shared/egress');
++
++const MAX_BYTES = 512 * 1024;
++const MAX_REDIRECTS = 2;
++const MAX_TEXT = 8000;
++
++class StartUrlError extends Error {
++ constructor(code, detail) {
++ super(detail || code);
++ this.name = 'StartUrlError';
++ this.status = 422;
++ this.code = code;
++ this.detail = detail || code;
++ }
++}
++
++const ENTITIES = {
++ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”',
++};
++
++function decode(text) {
++ return String(text || '').replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, name) => {
++ if (name[0] === '#') {
++ const code = name[1] && name[1].toLowerCase() === 'x' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
++ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : ' ';
++ }
++ return Object.hasOwn(ENTITIES, name.toLowerCase()) ? ENTITIES[name.toLowerCase()] : ' ';
++ });
++}
++
++/** The readable text of an HTML page: no script, style, noscript or template, tags as spaces, collapsed. */
++function readableText(html) {
++ const withoutHead = String(html || '')
++ .replace(/<!--[\s\S]*?-->/g, ' ')
++ .replace(/<(script|style|noscript|template|svg)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, ' ')
++ .replace(/<head\b[^>]*>[\s\S]*?<\/head\s*>/gi, ' ');
++ const titleMatch = /<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(html) || /<meta\b[^>]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i.exec(html);
++ const title = decode(titleMatch ? titleMatch[1].replace(/<[^>]+>/g, ' ') : '').replace(/\s+/g, ' ').trim().slice(0, 200);
++ const text = decode(withoutHead.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
++ return { title, text };
++}
++
++function requestOnce(url, { timeoutMs = 6000 } = {}) {
++ return new Promise((resolve, reject) => {
++ const req = https.request(url, {
++ method: 'GET',
++ lookup: safeLookup,
++ headers: {
++ Accept: 'text/html,application/xhtml+xml;q=0.9,text/plain;q=0.8',
++ 'User-Agent': 'OpenVibe.Design starting-point reader (https://openvibe.design)',
++ },
++ signal: AbortSignal.timeout(timeoutMs),
++ }, (res) => {
++ const status = Number(res.statusCode) || 0;
++ if (status >= 300 && status < 400 && res.headers.location) {
++ res.resume();
++ return resolve({ redirect: res.headers.location, status });
++ }
++ if (status < 200 || status >= 300) {
++ res.resume();
++ return reject(new StartUrlError('start_url_refused', `That page answered ${status}.`));
++ }
++ let bytes = 0;
++ const chunks = [];
++ res.on('data', (chunk) => {
++ bytes += chunk.length;
++ if (bytes > MAX_BYTES) {
++ req.destroy();
++ reject(new StartUrlError('start_url_too_large', 'That page is too large to read as a starting point.'));
++ return;
++ }
++ chunks.push(chunk);
++ });
++ res.on('end', () => resolve({ status, body: Buffer.concat(chunks) }));
++ res.on('error', reject);
++ });
++ req.on('error', (err) => {
++ if (err && (err.code === 'EGRESS_DENIED' || err.code === 'EADDRNOTPUBLIC')) {
++ return reject(new StartUrlError('start_url_denied', 'That address is not a public web page.'));
++ }
++ reject(new StartUrlError('start_url_unreachable', 'That page could not be reached.'));
++ });
++ req.end();
++ });
++}
++
++/**
++ * Read a starting page through the guarded path. → { url, title, text }
++ * Throws StartUrlError(422) for a non-public or unreachable address.
++ */
++async function fetchStartingPage(raw, { timeoutMs = 6000 } = {}) {
++ let url;
++ try { url = new URL(String(raw == null ? '' : raw).trim()); } catch { throw new StartUrlError('start_url_invalid', 'That starting address is not a URL.'); }
++ if (url.protocol !== 'https:') throw new StartUrlError('start_url_scheme', 'A starting address must be an https page.');
++ if (url.username || url.password) throw new StartUrlError('start_url_credentials', 'A starting address cannot carry a username or password.');
++ try { await assertPublicUrl(url); } catch {
++ throw new StartUrlError('start_url_denied', 'That address is not a public web page.');
++ }
++ let current = url;
++ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
++ let out;
++ try { out = await requestOnce(current, { timeoutMs }); } catch (err) {
++ if (err instanceof StartUrlError) throw err;
++ throw new StartUrlError('start_url_unreachable', 'That page could not be reached.');
++ }
++ if (out.redirect) {
++ let next;
++ try { next = new URL(out.redirect, current); } catch { throw new StartUrlError('start_url_redirect', 'That page redirected to an address that is not a URL.'); }
++ if (next.protocol !== 'https:') throw new StartUrlError('start_url_redirect', 'That page redirected away from https.');
++ try { await assertPublicUrl(next); } catch { throw new StartUrlError('start_url_denied', 'That page redirected to a private address.'); }
++ current = next;
++ continue;
++ }
++ const read = readableText(out.body.toString('utf8'));
++ if (!read.text) throw new StartUrlError('start_url_empty', 'That page had no readable text.');
++ return { url: current.toString(), title: read.title, text: read.text };
++ }
++ throw new StartUrlError('start_url_redirects', 'That page redirected too many times.');
++}
++
++module.exports = { fetchStartingPage, readableText, StartUrlError, MAX_BYTES, MAX_TEXT };
ddiff --git a/server/create/kinds.js b/server/create/kinds.js
nnew file mode 100644
iindex 0000000..9acba0d
---- /dev/null
++++ b/server/create/kinds.js
@@@ -0,0 +1,103 @@
++'use strict';
++
++/**
++ * What a create request is asking for, in one place.
++ *
++ * A kind is ONE entry here: its id, the plain name the screen shows, a short hint, the instruction the
++ * generator adds for it, and the frames it allows. Adding a kind is adding one object; the options API,
++ * the request validation, the stored design and the generation prompt all read this file.
++ *
++ * A frame is the shape the frame is judged in (the width and height the screen draws and the prompt
++ * names). `instructionFor` stitches the kind and the frame into the text the generator receives.
++ */
++
++const FRAMES = [
++ { id: 'desktop', name: 'Desktop', width: 1280, height: 900 },
++ { id: 'phone', name: 'Phone', width: 390, height: 844 },
++ { id: 'tablet', name: 'Tablet', width: 834, height: 1112 },
++ { id: 'slide', name: 'Slide 16:9', width: 1920, height: 1080 },
++ { id: 'email', name: 'Email 600', width: 600, height: 1200 },
++ { id: 'square', name: 'Social square', width: 1080, height: 1080 },
++ { id: 'story', name: 'Story 9:16', width: 1080, height: 1920 },
++ { id: 'mark', name: 'Mark', width: 512, height: 512 },
++];
++
++const KINDS = [
++ {
++ id: 'page', name: 'Page', hint: 'A landing or content page', frames: ['desktop', 'phone', 'tablet'],
++ instruction: 'Build one complete, responsive page: a clear first screen, real content, a primary action, and a sensible reading order. It must hold together at every allowed frame width.',
++ },
++ {
++ id: 'screen', name: 'App screen', hint: 'A phone-first app screen or flow', frames: ['phone', 'tablet'],
++ instruction: 'Build one complete app screen or a short flow: one clear task, thumb-sized controls, safe areas, and a bottom bar only when it helps the task. Every control has a visible state.',
++ },
++ {
++ id: 'component', name: 'Component', hint: 'One interface component with its states', frames: ['desktop', 'phone'],
++ instruction: 'Build one self-contained interface component and show its states side by side (default, hover or focus where relevant, empty, and one busy or error state). Name the component in the page and keep its anatomy and keyboard behaviour clear.',
++ },
++ {
++ id: 'slide', name: 'Slide', hint: 'One 16:9 presentation slide', frames: ['slide'],
++ instruction: 'Build one presentation slide at 16:9: a title, the few lines that carry the point, and one visual that earns its place. Everything must be readable when the slide is shown, not scrolled.',
++ },
++ {
++ id: 'email', name: 'Email', hint: 'A 600 px email that survives a mail client', frames: ['email'],
++ instruction: 'Build one email at 600 px wide: table-friendly markup, inline styling, no scripts, no external stylesheet, and one clear action. It must read well with images off; use text and colour, not remote images.',
++ },
++ {
++ id: 'social-card', name: 'Social card', hint: 'A square or story card for sharing', frames: ['square', 'story'],
++ instruction: 'Build one share card at the chosen frame: a single idea, a short headline, a supporting line and the OpenVibe mark. It must be legible at a glance and stay inside the safe margins of the frame.',
++ },
++ {
++ id: 'mark', name: 'Mark', hint: 'A logo or small brand mark sheet', frames: ['mark'],
++ instruction: 'Build one brand mark sheet: the mark, a wordmark, a small palette and two or three lockups. The mark must stay recognisable at 16 px and be drawn with inline SVG, not a remote image.',
++ },
++];
++
++const BY_ID = new Map(KINDS.map((kind) => [kind.id, kind]));
++const FRAME_BY_ID = new Map(FRAMES.map((frame) => [frame.id, frame]));
++
++/** The one kind with this id, or null. */
++function kindOf(id) {
++ return BY_ID.get(String(id == null ? '' : id).trim()) || null;
++}
++
++/** The one frame with this id, or null. */
++function frameOf(id) {
++ return FRAME_BY_ID.get(String(id == null ? '' : id).trim()) || null;
++}
++
++/** The frames a kind allows, as full frame objects. */
++function framesFor(kindId) {
++ const kind = kindOf(kindId);
++ if (!kind) return [];
++ return kind.frames.map((id) => FRAME_BY_ID.get(id)).filter(Boolean);
++}
++
++/** The instruction the generator adds for a kind at a frame. */
++function instructionFor(kindId, frameId) {
++ const kind = kindOf(kindId);
++ if (!kind) return '';
++ const frame = frameOf(frameId);
++ const shape = frame
++ ? `The frame is ${frame.name}: ${frame.width} × ${frame.height} px. Draw for that frame exactly; do not add a device mockup around it.`
++ : '';
++ return [kind.instruction, shape].filter(Boolean).join('\n');
++}
++
++/** The category name the Arena's system prompt knows for a kind (server/arena/ai.js CATEGORY_BRIEF). */
++function categoryFor(kindId) {
++ switch (String(kindId || 'page')) {
++ case 'screen': return 'mobile_app';
++ case 'component': return 'component';
++ case 'slide': return 'slides';
++ case 'email': return 'email';
++ case 'social-card': return 'social';
++ case 'mark': return 'mark';
++ default: return 'landing';
++ }
++}
++
++const KIND_IDS = KINDS.map((kind) => kind.id);
++const FRAME_IDS = FRAMES.map((frame) => frame.id);
++
++module.exports = { KINDS, FRAMES, KIND_IDS, FRAME_IDS, kindOf, frameOf, framesFor, instructionFor, categoryFor };
ddiff --git a/server/create/options.js b/server/create/options.js
nnew file mode 100644
iindex 0000000..fefded7
---- /dev/null
++++ b/server/create/options.js
@@@ -0,0 +1,274 @@
++'use strict';
++
++/**
++ * The choices a create request is asked to make, read from the services that own them.
++ *
++ * kinds, frames server/create/kinds.js (one entry per kind)
++ * looks OpenVibe.Network's theme centre (public themes) and, signed in, the person's own themes
++ * models the same catalogue OpenVibe.Exchange shows, through OpenVibe.AI:
++ * GET /api/v1/exchange/catalog names, offers, price in/out, 18+ marks
++ * GET /api/v1/routes the offers AI can actually run (provider and model)
++ * GET /api/v1/choices the person's own keys and machines, and whether AI says
++ * this person may use the 18+ tier
++ *
++ * Nothing here is a second hand-kept model list or price list. The catalogue is cached for five minutes;
++ * the person's own keys and adult answer are read on every signed-in request (private, no-store).
++ */
++
++const { KINDS, FRAMES } = require('./kinds');
++
++const MODEL_TTL_MS = 5 * 60_000;
++const EXCHANGE_ORIGIN = 'https://openvibe.exchange';
++
++const COLOUR = /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\)|hsla?\([0-9., %]+\))$/i;
++
++function swatchOf(variables = {}) {
++ const out = [];
++ for (const name of ['--bg-primary', '--text-primary', '--accent', '--text-link', '--bg-card', '--border']) {
++ const value = variables[name];
++ if (typeof value === 'string' && COLOUR.test(value.trim()) && !out.includes(value.trim())) out.push(value.trim());
++ }
++ for (const value of Object.values(variables)) {
++ if (out.length >= 4) break;
++ if (typeof value === 'string' && COLOUR.test(value.trim()) && !out.includes(value.trim())) out.push(value.trim());
++ }
++ return out.slice(0, 4);
++}
++
++function priceOf(offer) {
++ const price = offer && offer.price;
++ return {
++ price_in: price && Number.isFinite(Number(price.in)) ? Number(price.in) : null,
++ price_out: price && Number.isFinite(Number(price.out)) ? Number(price.out) : null,
++ };
++}
++
++function noteOf(offer, provider) {
++ if (!offer) return 'Managed by OpenVibe.AI';
++ const parts = [offer.provider_name || provider];
++ if (offer.context) parts.push(`${Math.round(Number(offer.context) / 1000)}k context`);
++ if (!offer.price && offer.free) parts.push('prepaid');
++ else if (offer.free_tier) parts.push('free to try');
++ if (offer.status && offer.status !== 'up') parts.push(String(offer.status));
++ return parts.filter(Boolean).join(' · ').slice(0, 160);
++}
++
++/** A model doc's best offer: the cheapest priced one, else its first offer. */
++function bestOfferOf(doc) {
++ const offers = Array.isArray(doc && doc.offers) ? doc.offers : [];
++ const priced = offers.filter((o) => o && o.price && Number.isFinite(Number(o.price.in)) && Number.isFinite(Number(o.price.out)));
++ priced.sort((a, b) => (Number(a.price.in) + Number(a.price.out)) - (Number(b.price.in) + Number(b.price.out)));
++ return priced[0] || offers[0] || null;
++}
++
++function createCreateOptions({ config = {}, ai = null, catalog, fetchImpl = globalThis.fetch, log = console, now = () => Date.now(), ttlMs = MODEL_TTL_MS } = {}) {
++ let cached = { at: 0, value: null };
++
++ const exchangeCatalog = async () => {
++ if (!ai || typeof ai.exchangeCatalog !== 'function') return { models: [] };
++ try { return await ai.exchangeCatalog({ kind: 'chat' }); } catch (err) {
++ log.warn('[OpenVibe.Design] create catalogue unavailable:', (err && err.message) || err);
++ return { models: [] };
++ }
++ };
++ const routeOffers = async () => {
++ if (!ai || typeof ai.offeredModels !== 'function') return [];
++ try { return await ai.offeredModels(); } catch (err) {
++ log.warn('[OpenVibe.Design] create routes unavailable:', (err && err.message) || err);
++ return [];
++ }
++ };
++
++ /**
++ * Join the runnable AI offers to the Exchange catalogue: the offer names the provider and model the run
++ * can pin, the doc gives the name, the price in and out, the 18+ mark and the public page.
++ */
++ function join(routes, docs) {
++ const models = Array.isArray(docs && docs.models) ? docs.models.filter((m) => !m.kind || m.kind === 'chat') : [];
++ const byOffer = new Map();
++ const byModel = new Map();
++ for (const doc of models) {
++ for (const offer of (doc.offers || [])) {
++ byOffer.set(`${offer.provider}|${offer.model || ''}`, { doc, offer });
++ if (offer.model) byModel.set(String(offer.model), { doc, offer });
++ }
++ }
++ const out = [];
++ const seen = new Set();
++ const add = (entry) => {
++ if (!entry || !entry.id || seen.has(entry.id)) return;
++ seen.add(entry.id);
++ out.push(entry);
++ };
++ const list = Array.isArray(routes) ? routes : [];
++ if (list.length) {
++ for (const route of list) {
++ const provider = String((route && route.provider) || '').trim();
++ const model = route && route.model != null ? String(route.model).trim() : null;
++ if (!provider) continue;
++ const match = byOffer.get(`${provider}|${model || ''}`) || (model ? byModel.get(model) : null) || null;
++ const doc = match && match.doc;
++ const offer = (match && match.offer) || (doc && bestOfferOf(doc)) || null;
++ const prices = priceOf(offer);
++ add({
++ id: model ? `${provider}:${model}` : provider,
++ name: (doc && doc.name) || model || provider,
++ provider,
++ model,
++ price_in: prices.price_in,
++ price_out: prices.price_out,
++ note: noteOf(offer, provider),
++ exchange_url: doc ? `${EXCHANGE_ORIGIN}/models/${String(doc.slug).split('/').map(encodeURIComponent).join('/')}` : null,
++ adult: Boolean(doc && doc.uncensored),
++ source: 'exchange',
++ pin: true,
++ });
++ }
++ }
++ if (!out.length) {
++ // AI's route list is the authority; when it cannot be read, the catalogue still gives the page
++ // something truthful to show (the run may re-route, and the take records what actually answered).
++ for (const doc of models) {
++ const offer = bestOfferOf(doc);
++ if (!offer) continue;
++ const prices = priceOf(offer);
++ add({
++ id: `${offer.provider}:${offer.model || ''}`,
++ name: doc.name || offer.model,
++ provider: offer.provider,
++ model: offer.model || null,
++ price_in: prices.price_in,
++ price_out: prices.price_out,
++ note: noteOf(offer, offer.provider),
++ exchange_url: `${EXCHANGE_ORIGIN}/models/${String(doc.slug).split('/').map(encodeURIComponent).join('/')}`,
++ adult: Boolean(doc.uncensored),
++ source: 'exchange',
++ pin: true,
++ });
++ }
++ }
++ return out;
++ }
++
++ async function modelCatalogue() {
++ if (cached.value && now() - cached.at < ttlMs) return cached.value;
++ const [routes, docs] = await Promise.all([routeOffers(), exchangeCatalog()]);
++ const value = join(routes, docs);
++ cached = { at: now(), value };
++ return value;
++ }
++
++ /** The person's own keys and machines, and whether AI says this person may use the 18+ tier. */
++ async function personal(token) {
++ if (!token || !ai || typeof ai.choices !== 'function') return { adult: false, models: [] };
++ let body;
++ try { body = await ai.choices({ token, task: 'ai.generate' }); } catch (err) {
++ // A read of their own settings must never stop the page; it simply means the list is the public one.
++ log.warn('[OpenVibe.Design] create personal models unavailable:', (err && err.message) || err);
++ return { adult: false, models: [] };
++ }
++ const options = (body && body.options) || {};
++ const models = [];
++ for (const key of Array.isArray(options.keys) ? options.keys : []) {
++ if (!key || !key.model) continue;
++ models.push({
++ id: `keys:${key.model}`, name: key.name || key.model, provider: 'your key', model: key.model,
++ price_in: 0, price_out: 0, note: 'Your own provider key · no charge', exchange_url: null,
++ adult: false, source: 'keys', pin: false,
++ });
++ }
++ for (const machine of Array.isArray(options.machines) ? options.machines : []) {
++ if (!machine || !machine.model) continue;
++ models.push({
++ id: `machines:${machine.machine || 'any'}:${machine.model}`,
++ name: machine.name || machine.model,
++ provider: machine.machine_name || 'your machine',
++ model: machine.model,
++ machine: machine.machine || null,
++ price_in: 0, price_out: 0, note: 'Your own machine · no charge', exchange_url: null,
++ adult: false, source: 'machines', pin: false,
++ });
++ }
++ return { adult: body && body.adult === true, models };
++ }
++
++ /** The theme centre, a system look, and the person's saved themes. */
++ async function fullLooks(token) {
++ const out = [];
++ let themes = [];
++ try { themes = ((await catalog.list()).themes || []); } catch (err) {
++ log.warn('[OpenVibe.Design] create looks unavailable:', (err && err.message) || err);
++ }
++ const defaultTheme = themes.find((t) => t.id === 'vibe') || themes[0] || null;
++ out.push({ id: 'system', name: 'System', source: 'system', themeId: (defaultTheme && defaultTheme.id) || null,
++ swatch: swatchOf(defaultTheme ? defaultTheme.variables : {}), variables: (defaultTheme && defaultTheme.variables) || {} });
++ for (const t of themes) {
++ if (!t || !t.id) continue;
++ out.push({ id: t.id, name: t.name || t.id, source: 'theme', themeId: t.id, swatch: swatchOf(t.variables || {}), variables: t.variables || {} });
++ }
++ if (token && catalog) {
++ try {
++ const subs = await catalog.request('/me/submissions', { token });
++ if (subs && subs.ok && subs.data && Array.isArray(subs.data.themes)) {
++ for (const t of subs.data.themes) {
++ if (!t || !t.id) continue;
++ out.push({ id: t.id, name: t.name || 'My theme', source: 'mine', themeId: t.id, swatch: swatchOf(t.variables || {}), variables: t.variables || {} });
++ }
++ }
++ } catch { /* their saved themes are optional */ }
++ try {
++ const active = await catalog.request('/me/active', { token });
++ const custom = active && active.ok && active.data ? active.data.custom_variables : null;
++ if (custom && typeof custom === 'object' && Object.keys(custom).length) {
++ out.push({ id: 'mine:active', name: 'My current theme', source: 'mine',
++ themeId: (active.data && active.data.theme_id) || null, swatch: swatchOf(custom), variables: custom });
++ }
++ } catch { /* optional */ }
++ }
++ const seen = new Set();
++ return out.filter((look) => (seen.has(look.id) ? false : (seen.add(look.id), true)));
++ }
++
++ /** Everything the create page asks for. `token` is the person's Network token, or null. */
++ async function options({ token = null } = {}) {
++ const [catalogue, mine] = await Promise.all([modelCatalogue(), personal(token)]);
++ const models = catalogue
++ .filter((model) => !model.adult || mine.adult)
++ .map((model) => ({ ...model }))
++ .concat(mine.models);
++ const looks = await fullLooks(token);
++ return {
++ kinds: KINDS.map((kind) => ({ id: kind.id, name: kind.name, hint: kind.hint, frames: kind.frames.slice() })),
++ frames: FRAMES.map((frame) => ({ ...frame })),
++ looks,
++ models,
++ takes: { min: 1, max: 4, default: 3 },
++ limits: { prompt: 4000, models: 4, takes_per_model: 4, takes_total: 8 },
++ currency: 'USD',
++ adult: mine.adult,
++ models_unavailable: models.length === 0,
++ };
++ }
++
++ /** The public shape the page sees: no internal pin, machine id or theme variables. */
++ function publicOptions(all) {
++ return {
++ kinds: all.kinds,
++ frames: all.frames,
++ looks: all.looks.map(({ id, name, source, swatch }) => ({ id, name, source, swatch })),
++ models: all.models.map(({ id, name, provider, price_in, price_out, note, exchange_url, adult, source }) =>
++ ({ id, name, provider, price_in, price_out, note, exchange_url, adult, source })),
++ takes: all.takes,
++ limits: all.limits,
++ currency: all.currency,
++ adult: all.adult,
++ models_unavailable: all.models_unavailable,
++ };
++ }
++
++ function clear() { cached = { at: 0, value: null }; }
++
++ return { options, publicOptions, fullLooks, modelCatalogue, personal, clear, get cached() { return cached; } };
++}
++
++module.exports = { createCreateOptions, MODEL_TTL_MS, EXCHANGE_ORIGIN };
ddiff --git a/server/create/plan.js b/server/create/plan.js
nnew file mode 100644
iindex 0000000..a67ff8c
---- /dev/null
++++ b/server/create/plan.js
@@@ -0,0 +1,210 @@
++'use strict';
++
++/**
++ * One plan for both doors: POST /api/create/estimate and POST /api/create.
++ *
++ * resolvePlan() validates a request against the options the page was given, reads a starting URL through the
++ * guarded egress path (server/create/egress.js), loads a starting design through the ordinary access check, and
++ * turns the chosen models and takes into the slots the run will use. estimatePlan() prices exactly those slots.
++ * The create route calls both; the estimate route calls both and stops. Neither can drift from the other.
++ *
++ * Every field is optional. No models means today's behaviour (AI's design plan, Claude first, three takes); no
++ * look means the first theme in the centre, as the present page does; no frame means the kind's first frame.
++ */
++
++const { kindOf, frameOf, instructionFor } = require('./kinds');
++const { fetchStartingPage } = require('./egress');
++const { publicStart, containsStart, startPrompt } = require('./start');
++
++const MEDIA_ID = /^[A-Za-z0-9][A-Za-z0-9._~/-]{0,300}$/;
++const MAX_PROMPT = 4000;
++const MAX_START_HTML = 12000;
++const ESTIMATE_OUTPUT_TOKENS = 4000;
++
++const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });
++const micro = (value) => Math.ceil(Number(value || 0) * 1e6) / 1e6;
++
++/**
++ * Resolve a request body into the plan both routes use.
++ * ctx: { options, designs, ai, owner, key, viewer, config }
++ */
++async function resolvePlan(body = {}, ctx = {}) {
++ const { options, designs, ai, owner = null, key = null, viewer = null, config = {} } = ctx;
++ const raw = body && typeof body === 'object' ? body : {};
++
++ const kind = kindOf(raw.kind || 'page');
++ if (!kind) throw fail(422, 'create.kind', 'Choose one of the kinds this page offers.');
++ const wantedFrame = raw.frame == null || raw.frame === '' ? kind.frames[0] : String(raw.frame);
++ const frame = frameOf(wantedFrame);
++ if (!frame || !kind.frames.includes(frame.id)) {
++ const allowed = kind.frames.join(', ');
++ throw fail(422, 'create.frame', `A ${kind.name.toLowerCase()} can be made at: ${allowed}.`);
++ }
++
++ const looks = Array.isArray(options.looks) ? options.looks : [];
++ let look;
++ if (raw.look != null && raw.look !== '') {
++ look = looks.find((item) => item.id === String(raw.look));
++ if (!look) throw fail(422, 'create.look', 'Choose a look from the list this page offers.');
++ } else {
++ look = looks.find((item) => item.source === 'theme') || looks[0] || { id: 'system', name: 'System', source: 'system', variables: {}, swatch: [] };
++ }
++
++ const prompt = String(raw.prompt == null ? '' : raw.prompt).trim();
++ if (prompt.length > MAX_PROMPT) throw fail(422, 'create.prompt', `Describe the design in ${MAX_PROMPT} characters or fewer.`);
++
++ const start = {};
++ const rawStart = raw.start && typeof raw.start === 'object' && !Array.isArray(raw.start) ? raw.start : {};
++ if (rawStart.url) {
++ let page;
++ try { page = await fetchStartingPage(rawStart.url); } catch (err) {
++ throw fail(err.status || 422, err.code || 'create.start_url', err.detail || 'That starting page could not be read.');
++ }
++ start.url = page.url;
++ start.title = page.title;
++ start.excerpt = page.text;
++ }
++ if (rawStart.design_id) {
++ if (!designs || typeof designs.read !== 'function') throw fail(503, 'create.start_design', 'Starting designs are not available here.');
++ const id = String(rawStart.design_id);
++ const sourceKey = rawStart.key || key || null;
++ let data;
++ try { data = await designs.read(id, owner, sourceKey, viewer); } catch (err) {
++ throw fail(err.status || 404, err.code || 'create.start_design', err.detail || 'That design could not be opened.');
++ }
++ const index = data.pick ? Number(data.pick.variantIndex) : data.variants.findIndex((v) => v && v.status === 'done');
++ const picked = index >= 0 ? data.variants[index] : null;
++ if (!picked || !picked.html) throw fail(409, 'create.start_not_ready', 'That design has no finished frame to start from yet.');
++ start.design_id = data.design.id;
++ start.source_design_id = data.design.id;
++ start.excerpt = String(picked.html).slice(0, MAX_START_HTML);
++ }
++ const image = rawStart.image || rawStart.image_id || null;
++ if (image) {
++ const id = String(image);
++ if (!MEDIA_ID.test(id)) throw fail(422, 'create.start_image', 'Upload the picture to OpenVibe.Media and send the id it answers.');
++ start.image = id;
++ const origin = String((config.media && config.media.publicOrigin) || 'https://openvibe.media').replace(/\/+$/, '');
++ start.image_url = `${origin}/f/${encodeURIComponent(id)}`;
++ }
++ if (!prompt && !containsStart(start)) throw fail(422, 'create.prompt', 'Describe the design first.');
++
++ const catalogue = Array.isArray(options.models) ? options.models : [];
++ const wanted = Array.isArray(raw.models) ? raw.models.map((id) => String(id == null ? '' : id).trim()).filter(Boolean) : [];
++ const ids = [...new Set(wanted)];
++ let models = [];
++ let takes;
++ let legacy = false;
++ const slots = [];
++
++ const preferOf = (model) => (model.provider && model.model ? { provider: model.provider, model: model.model } : model.model ? { model: model.model } : null);
++
++ if (ids.length) {
++ if (ids.length > options.limits.models) throw fail(422, 'create.models', `Choose at most ${options.limits.models} models.`);
++ for (const id of ids) {
++ const found = catalogue.find((model) => model.id === id);
++ if (!found) throw fail(422, 'create.model', `"${id}" is not one of the models this page offers.`);
++ models.push({ ...found, prefer: preferOf(found) });
++ }
++ takes = raw.takes == null || raw.takes === '' ? (models.length > 1 ? 1 : options.takes.default) : Number(raw.takes);
++ if (!Number.isInteger(takes) || takes < options.takes.min || takes > options.takes.max) {
++ throw fail(422, 'create.takes', `Choose ${options.takes.min} to ${options.takes.max} takes.`);
++ }
++ if (models.length * takes > options.limits.takes_total) {
++ throw fail(422, 'create.takes', `That is more than ${options.limits.takes_total} takes at once.`);
++ }
++ for (const model of models) for (let i = 0; i < takes; i++) {
++ slots.push({ ...model, take: i, pin: model.pin !== false, prefer: model.prefer });
++ }
++ } else {
++ // Today's behaviour: AI's design plan (Claude first, or the compare three), three takes. The estimate
++ // prices the same slots the old create path would run.
++ legacy = true;
++ takes = 3;
++ let plan = { slots: [] };
++ if (ai && typeof ai.designPlan === 'function') {
++ try { plan = await ai.designPlan(raw.who || 'claude'); } catch { plan = { slots: [] }; }
++ }
++ const planned = (plan.slots || []).slice(0, 3);
++ for (const slot of planned) {
++ const found = catalogue.find((model) => model.provider === slot.provider && model.model === slot.model) || null;
++ const model = found
++ ? { ...found, prefer: preferOf(found) }
++ : { id: `${slot.provider}:${slot.model || ''}`, name: slot.model || slot.provider, provider: slot.provider, model: slot.model || null,
++ price_in: null, price_out: null, source: 'exchange', pin: false, prefer: slot.provider ? { provider: slot.provider, ...(slot.model ? { model: slot.model } : {}) } : null };
++ slots.push({ ...model, take: 0, pin: false, prefer: model.prefer });
++ }
++ while (slots.length < 3) slots.push({ id: 'openvibe:auto', name: 'OpenVibe', provider: 'ai', model: null, price_in: null, price_out: null, source: 'exchange', take: slots.length, pin: false, prefer: null });
++ }
++
++ const instruction = instructionFor(kind.id, frame.id);
++ const lookNames = Object.keys(look.variables || {}).join(', ');
++ const startText = startPrompt(start);
++ const inputChars = (prompt + instruction + lookNames + startText).length;
++ const input_tokens = Math.max(1, Math.ceil(inputChars / 4));
++ const output_tokens = ESTIMATE_OUTPUT_TOKENS;
++
++ return {
++ prompt, kind: kind.id, kindName: kind.name, instruction,
++ frame: frame.id, frameInfo: frame,
++ look: look.id, lookName: look.name, lookSource: look.source, lookVariables: look.variables || {},
++ themeId: look.themeId || (look.source === 'theme' ? look.id : null),
++ start, startText,
++ models, takes, slots, legacy,
++ input_tokens, output_tokens,
++ };
++}
++
++function slotCost(slot, inputTokens, outputTokens) {
++ const input = slot.price_in == null ? null : Number(slot.price_in);
++ const output = slot.price_out == null ? null : Number(slot.price_out);
++ if (input == null && output == null) return { cost: 0, priced: false };
++ const cost = ((Number(inputTokens) || 0) * (Number(input) || 0) + (Number(outputTokens) || 0) * (Number(output) || 0)) / 1_000_000;
++ return { cost: micro(cost), priced: true };
++}
++
++/** Price the exact slots resolvePlan produced. → per model, total, in USD. */
++function estimatePlan(plan) {
++ const per = new Map();
++ let priced = false;
++ for (const slot of plan.slots || []) {
++ const { cost, priced: isPriced } = slotCost(slot, plan.input_tokens, plan.output_tokens);
++ if (isPriced) priced = true;
++ const key = slot.id || `${slot.provider || 'ai'}:${slot.model || ''}`;
++ if (!per.has(key)) {
++ per.set(key, {
++ id: key, name: slot.name || slot.model || slot.provider || 'OpenVibe.Design',
++ provider: slot.provider || null, source: slot.source || 'exchange',
++ takes: 0, cost_usd: 0, price_in: slot.price_in == null ? null : Number(slot.price_in),
++ price_out: slot.price_out == null ? null : Number(slot.price_out), priced: isPriced,
++ });
++ }
++ const entry = per.get(key);
++ entry.takes += 1;
++ entry.cost_usd = micro(entry.cost_usd + cost);
++ entry.priced = entry.priced || isPriced;
++ }
++ const perModel = [...per.values()];
++ const total = micro(perModel.reduce((sum, entry) => sum + entry.cost_usd, 0));
++ const cost = { currency: 'USD', per_model: perModel, total_usd: total, total };
++ return {
++ currency: 'USD',
++ per_model: perModel,
++ total_usd: total,
++ cost,
++ takes: (plan.slots || []).length,
++ models: perModel.length,
++ input_tokens: plan.input_tokens,
++ output_tokens: plan.output_tokens,
++ priced,
++ };
++}
++
++/** Add the person's Balance and whether it covers the estimate. A missing Balance is null, never zero. */
++function withBalance(estimate, balance) {
++ if (!balance) return { ...estimate, balance: null, covers: null };
++ const covers = estimate.total_usd <= Number(balance.available_usd) + 1e-9;
++ return { ...estimate, balance: { ...balance, covers }, covers };
++}
++
++module.exports = { resolvePlan, estimatePlan, withBalance, publicStart, startPrompt, slotCost, ESTIMATE_OUTPUT_TOKENS, MAX_PROMPT };
ddiff --git a/server/create/start.js b/server/create/start.js
nnew file mode 100644
iindex 0000000..916bcbd
---- /dev/null
++++ b/server/create/start.js
@@@ -0,0 +1,29 @@
++'use strict';
++
++/** A starting point's stored shape and the text it adds to the generator's prompt. No I/O here. */
++
++function publicStart(start = {}) {
++ return {
++ ...(start.url ? { url: start.url } : {}),
++ ...(start.title ? { title: start.title } : {}),
++ ...(start.design_id ? { design_id: start.design_id } : {}),
++ ...(start.image ? { image: start.image } : {}),
++ ...(start.image_url ? { image_url: start.image_url } : {}),
++ ...(start.source_design_id ? { source_design_id: start.source_design_id } : {}),
++ };
++}
++
++function containsStart(start) {
++ return Boolean(start && (start.url || start.design_id || start.image));
++}
++
++/** The text a starting point adds to the generator's prompt. */
++function startPrompt(start = {}) {
++ const parts = [];
++ if (start.url) parts.push(`Use this page as the starting point. Keep what is useful and make the design the person asked for.\nStarting page: ${start.title || start.url}\n${start.excerpt || ''}`.trim());
++ if (start.design_id) parts.push(`Start from this existing design. Keep its structure and content unless the request asks otherwise.\n<<<START_DESIGN\n${start.excerpt || ''}\nSTART_DESIGN>>>`);
++ if (start.image) parts.push(`The person added a reference image on OpenVibe.Media: ${start.image_url || start.image}. Use it for visual direction.`);
++ return parts.join('\n\n');
++}
++
++module.exports = { publicStart, containsStart, startPrompt };
ddiff --git a/server/create/thumbnail.js b/server/create/thumbnail.js
nnew file mode 100644
iindex 0000000..9ccc682
---- /dev/null
++++ b/server/create/thumbnail.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++/**
++ * A small, deterministic card for a design in the recent list.
++ *
++ * The repository has no HTML→image renderer: designs are sanitized documents drawn in iframes and the only
++ * raster path (server/glyphs/export.js, @resvg/resvg-js) renders SVG. So a thumbnail is a generated SVG card
++ * in the design's own look, made at save time and served from `/api/create/designs/:id/thumb.svg`. A real
++ * screenshot needs a headless browser or a rendering service; the API says `thumbnailRenderer: "card"` so the
++ * screen can tell them apart.
++ */
++
++const COLOUR = /^(#[0-9a-f]{3,8}|rgba?\([0-9., %]+\)|hsla?\([0-9., %]+\))$/i;
++
++function esc(value) {
++ return String(value == null ? '' : value)
++ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
++ .replace(/"/g, '&quot;').replace(/'/g, '&apos;');
++}
++
++function colourOf(variables, names, fallback) {
++ for (const name of names) {
++ const value = variables && variables[name];
++ if (typeof value === 'string' && COLOUR.test(value.trim())) return value.trim();
++ }
++ return fallback;
++}
++
++function lines(text, max, width) {
++ const words = String(text || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
++ const out = [];
++ let line = '';
++ for (const word of words) {
++ if ((line + ' ' + word).trim().length > width) {
++ if (line) out.push(line);
++ line = word;
++ if (out.length >= max - 1) break;
++ } else {
++ line = (line + ' ' + word).trim();
++ }
++ }
++ if (line && out.length < max) out.push(line);
++ return out;
++}
++
++/**
++ * `look` is { kind, kindName, prompt, model, variables }. The card is 640×400, a theme-coloured ground, the
++ * kind, the first lines of the prompt and the model that drew it.
++ */
++function makeThumbnail({ kind = 'page', kindName = 'Design', prompt = '', model = null, variables = {} } = {}) {
++ const bg = colourOf(variables, ['--bg-primary', '--surface', '--bg-secondary'], '#0b0f1a');
++ const text = colourOf(variables, ['--text-primary', '--on-bg', '--text'], '#e6edf7');
++ const accent = colourOf(variables, ['--accent', '--text-link', '--text-primary'], '#7aa7ff');
++ const muted = colourOf(variables, ['--text-muted', '--text-secondary', '--text'], text);
++ const title = lines(prompt || kindName, 3, 26);
++ const titleText = title.length
++ ? title.map((line, i) => `<text x="48" y="${188 + i * 46}" font-size="34" font-weight="600" fill="${esc(text)}">${esc(line)}</text>`).join('')
++ : '';
++ const byline = model ? String(model).slice(0, 48) : 'OpenVibe.Design';
++ return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="400" viewBox="0 0 640 400" role="img" aria-label="${esc(String(prompt || kindName).slice(0, 120))}">
++<rect width="640" height="400" fill="${esc(bg)}"/>
++<rect x="0" y="0" width="640" height="6" fill="${esc(accent)}"/>
++<g font-family="Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif">
++<text x="48" y="72" font-size="18" letter-spacing="1.5" fill="${esc(muted)}">${esc(kindName.toUpperCase())}</text>
++${titleText}
++<text x="48" y="352" font-size="16" fill="${esc(muted)}">${esc(byline)}</text>
++<text x="592" y="352" text-anchor="end" font-size="16" fill="${esc(accent)}">OpenVibe.Design</text>
++</g>
++</svg>`;
++}
++
++module.exports = { makeThumbnail, COLOUR };
ddiff --git a/server/designs.js b/server/designs.js
iindex 94676bb..97b821d 100644
---- a/server/designs.js
++++ b/server/designs.js
@@@ -4,8 +4,10 @@ const crypto = require('crypto');
const { sanitizeHtml, MAX_BYTES } = require('./arena/sanitize');
const { validateChange, applyChange } = require('./design-edit');
const { vocabulary } = require('./components/registry');
++const { kindOf, instructionFor, categoryFor } = require('./create/kinds');
++const { makeThumbnail } = require('./create/thumbnail');
++const { publicStart, startPrompt } = require('./create/start');
--const KINDS = ['page', 'screen', 'component'];
const random = () => crypto.randomBytes(24).toString('base64url');
const hash = (key) => crypto.createHash('sha256').update(key).digest();
const fail = (status, code, detail) => Object.assign(new Error(detail), { status, code, detail });
@@@ -97,11 +99,20 @@ function editPrompt(instruction, html, names) {
function createDesigns({ s, ai, bench = null, catalog, config = {}, log = console, now = () => Date.now(), sharing = null }) {
const db = s.db;
const chatEditLimit = Math.max(1, Number(config.studio && config.studio.chatEditsPerDesign) || CHAT_EDITS_DEFAULT);
-- const publicRow = d => ({ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
-- status: d.variants.every(v => v.status === 'done' || v.status === 'failed')
-- ? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
-- createdAt: d.created_at, updatedAt: d.updated_at,
-- published: d.space_url ? { url: d.space_url, at: d.published_at || null } : null });
++ const publicRow = d => {
++ const names = [...new Set((d.variants || []).map(v => v.name || v.model).filter(Boolean))];
++ return {
++ id: d.id, prompt: d.prompt, kind: d.kind, themeId: d.theme_id,
++ frame: d.frame || null, look: d.look || d.theme_id || null,
++ model: names[0] || null, models: names,
++ start: publicStart(d.start || {}), sourceDesignId: d.source_design_id || null,
++ estimate: d.estimate || null,
++ status: d.variants.every(v => v.status === 'done' || v.status === 'failed')
++ ? d.variants.some(v => v.status === 'done') ? 'done' : 'failed' : 'running',
++ createdAt: d.created_at, updatedAt: d.updated_at,
++ published: d.space_url ? { url: d.space_url, at: d.published_at || null } : null,
++ };
++ };
function allowed(d, owner, key) {
if (d.owner && owner === d.owner) return true;
if (!d.guest_hash || !key) return false;
@@@ -135,30 +146,97 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
if (!selected) throw fail(422, 'design.invalid_theme', 'Choose a theme from the catalog.');
return selected;
}
-- async function create({ prompt, kind = 'page', themeId, owner = null, ip = '', who = 'claude' }) {
++ /**
++ * The look a design was made in: the theme's variables as they were resolved when it was created (a
++ * person's own theme is not always in the public catalogue), else the public theme it names.
++ */
++ async function lookTheme(d) {
++ if (d && d.look_variables && typeof d.look_variables === 'object' && Object.keys(d.look_variables).length) {
++ return { id: d.look || d.theme_id, name: d.look || d.theme_id, variables: d.look_variables };
++ }
++ return await theme(d && d.theme_id);
++ }
++ async function create({ prompt, kind = 'page', themeId, look = null, lookVariables = null, frame: frameId = null,
++ models = null, takes = null, start = null, sourceDesignId = null, estimate = null,
++ owner = null, ip = '', who = 'claude' }) {
prompt = String(prompt || '').trim();
-- if (!prompt || prompt.length > 4000 || !KINDS.includes(kind))
++ const kindEntry = kindOf(kind);
++ if (!prompt || prompt.length > 4000 || !kindEntry)
throw fail(422, 'design.invalid_input', 'Provide a prompt up to 4000 characters and a valid kind.');
const cutoff = new Date(now() - 86400000).toISOString();
const count = Number(await db.value(`SELECT COUNT(*) FROM designs WHERE ${owner ? 'owner' : 'guest_ip'}=$1 AND created_at >= $2::timestamptz`, [owner || ip, cutoff]));
if (count >= (owner ? 10 : 2)) throw fail(429, 'design.daily_limit', 'Daily design limit reached.');
-- const selected = await theme(themeId);
-- // Claude first (L37) as a soft preference: claude -> Opus 5.5, sonnet -> Sonnet 5.5, any -> no preference;
-- // `compare` is the one pinned mode. An AI client without designPlan (tests, older) uses its slots.
-- const plan = ai.designPlan ? await ai.designPlan(who) : await ai.slotPlan();
-- const prefer = plan.prefer || null, pin = plan.pin === true;
-- const slots = plan.slots.slice(0, 3);
++
++ // The look: the resolved theme variables when the caller read them from the theme centre or the
++ // person's own themes, else the public theme (today's behaviour).
++ let selected, lookId = null;
++ if (look && lookVariables && typeof lookVariables === 'object' && Object.keys(lookVariables).length) {
++ selected = { id: themeId || look, name: look, variables: lookVariables };
++ lookId = look;
++ } else {
++ const wanted = look && look !== 'system' ? look : themeId;
++ selected = await theme(wanted);
++ lookId = look === 'system' ? 'system' : (look || selected.id);
++ }
++ const frame = frameId && kindEntry.frames.includes(frameId) ? frameId : kindEntry.frames[0];
++
++ // The takes: one per chosen model (several models side by side), or today's design plan when no
++ // models were named. Each take records the model it was asked for before the run answers.
++ let slots = [], planPrefer = null, planPin = false, requested = null;
++ if (Array.isArray(models) && models.length) {
++ const per = Math.max(1, Math.min(Number(takes) || 1, 4));
++ requested = models.map((model) => ({
++ id: model.id, name: model.name || null, provider: model.provider || null, model: model.model || null,
++ price_in: model.price_in == null ? null : Number(model.price_in),
++ price_out: model.price_out == null ? null : Number(model.price_out),
++ source: model.source || 'exchange',
++ }));
++ slots = [];
++ for (const model of models) for (let i = 0; i < per; i++) {
++ slots.push({
++ provider: model.provider || null, model: model.model || null,
++ model_id: model.id || null, name: model.name || null,
++ price_in: model.price_in == null ? null : Number(model.price_in),
++ price_out: model.price_out == null ? null : Number(model.price_out),
++ source: model.source || 'exchange',
++ prefer: model.prefer || (model.provider && model.model ? { provider: model.provider, model: model.model } : model.model ? { model: model.model } : null),
++ pin: model.pin !== false,
++ });
++ }
++ } else {
++ const plan = ai.designPlan ? await ai.designPlan(who) : await ai.slotPlan();
++ planPrefer = plan.prefer || null;
++ planPin = plan.pin === true;
++ slots = plan.slots.slice(0, 3).map((slot) => ({
++ provider: slot.provider, model: slot.model, model_id: null, name: null, price_in: null, price_out: null,
++ source: 'exchange', prefer: planPrefer ? { ...planPrefer } : null, pin: planPin,
++ }));
++ }
++
const variants = slots.map((slot, index) => ({ id: s.newId('dva'), slot: index, provider: slot.provider,
-- model: slot.model, status: 'pending', html: '', ms: 0, costUsd: 0, error: null,
-- paid_by: null, prefer: prefer ? { ...prefer } : null, pin }));
++ model: slot.model, model_id: slot.model_id || null, name: slot.name || null,
++ price_in: slot.price_in == null ? null : Number(slot.price_in), price_out: slot.price_out == null ? null : Number(slot.price_out),
++ source: slot.source || null, status: 'pending', html: '', ms: 0, costUsd: 0, error: null,
++ paid_by: null, prefer: slot.prefer || null, pin: slot.pin === true }));
const id = s.newId('dsn'), editKey = owner ? null : random();
-- await db.exec('INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at) VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz)',
-- [id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null, owner ? null : ip, s.iso()]);
++ const startRecord = start && typeof start === 'object' && !Array.isArray(start) ? start : {};
++ const thumbnail = makeThumbnail({ kind, kindName: kindEntry.name, prompt,
++ model: variants[0] ? (variants[0].name || variants[0].model) : null, variables: selected.variables || {} });
++ await db.exec(`INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at,
++ frame,look,look_variables,models,start,source_design_id,estimate,thumbnail_svg)
++ VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,$16::jsonb,$17)`,
++ [id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null,
++ owner ? null : ip, s.iso(), frame, lookId || selected.id, JSON.stringify(selected.variables || {}),
++ requested ? JSON.stringify(requested) : null, JSON.stringify(startRecord), sourceDesignId || null,
++ estimate ? JSON.stringify(estimate) : null, thumbnail]);
// A signed-in maker's new design is Only me until its Share sheet says otherwise (a guest's is its key).
if (sharing && owner) await sharing.register(id, owner).catch((err) => log.warn('[OpenVibe.Design] share row not registered:', err.message));
run(id).catch(err => log.warn('[OpenVibe.Design] generation:', err.message));
-- return { design: publicRow({ id, prompt, kind, theme_id: selected.id, variants, created_at: s.iso(), updated_at: s.iso() }),
-- editKey, editUrl: editKey ? `/d/${id}?key=${encodeURIComponent(editKey)}` : null };
++ return { design: publicRow({ id, prompt, kind, theme_id: selected.id, frame, look: lookId || selected.id,
++ look_variables: selected.variables || {}, models: requested, start: startRecord, source_design_id: sourceDesignId || null,
++ estimate, variants, created_at: s.iso(), updated_at: s.iso() }),
++ takes: variants.map(v => ({ id: v.id, slot: v.slot, provider: v.provider, model: v.model, modelId: v.model_id, name: v.name, status: v.status })),
++ editKey, editUrl: editKey ? `/d/${id}?key=${encodeURIComponent(editKey)}` : null };
}
async function updateVariant(id, index, variant) {
await db.exec('UPDATE designs SET variants=jsonb_set(variants, ARRAY[$2::text], $3::jsonb, false), updated_at=$4::timestamptz WHERE id=$1',
@@@ -172,13 +250,14 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const running = { ...v, status: 'running', error: null };
await updateVariant(id, index, running);
try {
-- const t = await theme(d.theme_id);
-- const prompt = themePrompt(d.prompt, Object.keys(t.variables || {}));
-- const out = await ai.generate({ prompt, category: d.kind === 'component' ? 'logo' : d.kind === 'screen' ? 'mobile_app' : 'landing',
++ const t = await lookTheme(d);
++ const prompt = [themePrompt(d.prompt, Object.keys(t.variables || {})),
++ instructionFor(d.kind, d.frame), startPrompt(d.start || {})].filter(Boolean).join('\n\n');
++ const out = await ai.generate({ prompt, category: categoryFor(d.kind),
slot: { provider: v.provider, model: v.model }, roundId: id, index,
prefer: v.prefer || null, person: d.owner || null, pin: v.pin === true });
-- const current = await db.maybe('SELECT theme_id FROM designs WHERE id=$1', [id]);
-- const currentTheme = await theme(current.theme_id);
++ const current = await db.maybe('SELECT * FROM designs WHERE id=$1', [id]);
++ const currentTheme = await lookTheme(current);
await updateVariant(id, index, { ...running, status: 'done', html: wrap(out.html, currentTheme.variables || {}),
provider: out.provider, model: out.model, ms: out.ms, costUsd: out.cost_usd, error: null,
paid_by: out.paid_by || null, prefer: out.prefer || v.prefer || null });
@@@ -212,7 +291,11 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
canRedo: Array.isArray(p.redo_stack) && p.redo_stack.length > 0,
};
}
-- const categoryOf = (kind) => kind === 'component' ? 'logo' : kind === 'screen' ? 'mobile_app' : 'landing';
++ // Bench's board categories: the closest kind it knows for the new create kinds.
++ const categoryOf = (kind) => kind === 'component' || kind === 'mark' ? 'logo'
++ : kind === 'screen' ? 'mobile_app'
++ : kind === 'slide' ? 'slides'
++ : kind === 'social-card' ? 'logo' : 'landing';
const pickRunId = (v) => `dsp_${v.id}`;
/** Record the pick once: create the Arena round, submit the variants as room entries and cast one vote. */
@@@ -316,7 +399,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
}
async function read(id, owner, key, viewer = null) {
-- const d = await row(id, owner, key, viewer), selected = await theme(d.theme_id);
++ const d = await row(id, owner, key, viewer), selected = await lookTheme(d);
const p = await pickRow(id);
const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;
@@@ -367,7 +450,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const d = await row(id, owner, key, viewer, true);
const p = await pickRow(id);
if (!p) throw fail(409, 'design.no_pick', 'Pick a working frame before editing it.');
-- const selected = await theme(d.theme_id);
++ const selected = await lookTheme(d);
const variant = d.variants[Number(p.variant_index)];
if (!variant || variant.status !== 'done') throw fail(409, 'design.variant_not_ready', 'The working frame is not ready yet.');
let current = await versionRow(id, p.current_seq);
@@@ -396,7 +479,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
? { provider: asked.provider, model: asked.model || null }
: variant.provider && variant.provider !== 'ai' && !String(variant.provider).startsWith('byo:self:') && variant.model
? { provider: variant.provider, model: variant.model } : null;
-- const out = await ai.generate({ prompt, category: categoryOf(d.kind), slot: { provider: variant.provider, model: variant.model },
++ const out = await ai.generate({ prompt, category: categoryFor(d.kind), slot: { provider: variant.provider, model: variant.model },
roundId: d.id, index: variant.slot, prefer, person: subject || d.owner || null, pin: false });
const html = wrap(out.html, variables);
const version = await addVersion(d, p, { kind: 'chat', html, instruction, model: out.model || variant.model,
@@@ -474,17 +557,51 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
async function setTheme(id, themeId, owner, key, viewer = null) {
await row(id, owner, key, viewer, true);
const selected = await theme(themeId);
-- await db.exec('UPDATE designs SET theme_id=$1,updated_at=$2::timestamptz WHERE id=$3', [selected.id, s.iso(), id]);
++ await db.exec('UPDATE designs SET theme_id=$1,look=$1,look_variables=$4::jsonb,updated_at=$2::timestamptz WHERE id=$3',
++ [selected.id, s.iso(), id, JSON.stringify(selected.variables || {})]);
return { themeId: selected.id, variables: selected.variables || {} };
}
-- async function recent(owner, guestLinks = []) {
-- if (owner) return (await db.many('SELECT * FROM designs WHERE owner=$1 ORDER BY created_at DESC LIMIT 3', [owner]))
-- .map(d => ({ ...publicRow(d), url: `/d/${d.id}` }));
++ function recentItem(d, key = null) {
++ const suffix = key ? `?key=${encodeURIComponent(key)}` : '';
++ const thumb = `/api/create/designs/${encodeURIComponent(d.id)}/thumb.svg${suffix}`;
++ return { ...publicRow(d), url: `/d/${d.id}${suffix}`, thumbnail: thumb };
++ }
++ /**
++ * The person's designs, newest first. The present page asks for three (its own list); the create API passes
++ * { limit, before } and pages with the last createdAt. A guest's designs are the ones their draft cookie names.
++ */
++ async function recent(owner, guestLinks = [], { limit = 3, before = null } = {}) {
++ const size = Math.max(1, Math.min(48, Number(limit) || 3));
++ const cursor = before && !Number.isNaN(Date.parse(before)) ? before : null;
++ if (owner) {
++ const rows = await db.many(`SELECT * FROM designs WHERE owner=$1
++ AND ($2::timestamptz IS NULL OR created_at < $2::timestamptz)
++ ORDER BY created_at DESC, id DESC LIMIT $3`, [owner, cursor, size]);
++ return rows.map(d => recentItem(d));
++ }
const result = [];
-- for (const item of guestLinks.slice(0, 3)) {
-- try { const d = await row(item.id, null, item.key); result.push({ ...publicRow(d), url: `/d/${d.id}?key=${encodeURIComponent(item.key)}` }); } catch { /* stale draft */ }
++ for (const item of Array.isArray(guestLinks) ? guestLinks : []) {
++ try {
++ const d = await row(item.id, null, item.key);
++ if (cursor && new Date(d.created_at).getTime() >= new Date(cursor).getTime()) continue;
++ result.push(recentItem(d, item.key));
++ } catch { /* stale draft */ }
++ }
++ result.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
++ return result.slice(0, size);
++ }
++
++ /** The recent list's card for one design, with the same access check every read uses. */
++ async function thumbnail(id, owner, key, viewer = null) {
++ const d = await row(id, owner, key, viewer);
++ let svg = d.thumbnail_svg;
++ if (!svg) {
++ const kindEntry = kindOf(d.kind);
++ const selected = await lookTheme(d).catch(() => ({ variables: {} }));
++ svg = makeThumbnail({ kind: d.kind, kindName: (kindEntry && kindEntry.name) || 'Design', prompt: d.prompt,
++ model: (d.variants[0] && (d.variants[0].name || d.variants[0].model)) || null, variables: selected.variables || {} });
}
-- return result;
++ return { svg, design: publicRow(d) };
}
async function resume() {
const pending = await db.many("SELECT id FROM designs WHERE variants @> '[{\"status\":\"pending\"}]'::jsonb OR variants @> '[{\"status\":\"running\"}]'::jsonb ORDER BY created_at LIMIT 20");
@@@ -506,7 +623,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
return out;
}
-- return { create, read, setTheme, recent, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit, publish };
++ return { create, read, setTheme, recent, thumbnail, resume, theme, pick, chatEdit, clickEdit, versions, undo, redo, chatEditLimit, publish };
}
module.exports = { createDesigns, wrap, tokenCss, themeCss, themePrompt, editPrompt };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 0a9c6bd..e1a3b35 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -14,7 +14,7 @@ const TABLES = [
{ table: 'arena_rounds', subject: 'created_by', file: 'arena_rounds.json', erase: 'delete' },
{ table: 'arena_votes', subject: 'voter_subject', file: 'arena_votes.json', erase: 'delete' },
// The designs a signed-in person asked for; a guest's design has no owner and matches nobody. Versions cascade.
-- { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'space_url', 'published_at', 'created_at', 'updated_at'] },
++ { table: 'designs', subject: 'owner', file: 'designs.json', order: 'created_at', columns: ['id', 'kind', 'prompt', 'theme_id', 'variants', 'frame', 'look', 'look_variables', 'models', 'start', 'source_design_id', 'estimate', 'thumbnail_svg', 'space_url', 'published_at', 'created_at', 'updated_at'] },
// The icons a person asked OpenVibe.AI to draw (/icons). Who reviewed one is not theirs to export. An approved
// drawing already taken into openvibe-shared stays there: it is a drawing, it names nobody.
// The marks a signed-in person picked on /brand/explore (a guest's picks name nobody).
ddiff --git a/test/create-options.test.js b/test/create-options.test.js
nnew file mode 100644
iindex 0000000..989bb36
---- /dev/null
++++ b/test/create-options.test.js
@@@ -0,0 +1,214 @@
++'use strict';
++
++/**
++ * The create page's own API: /api/create/options, /api/create/estimate, /api/create and /api/create/recent.
++ * The AI client is a stand-in for OpenVibe.AI: it offers two runnable models, publishes the same three in the
++ * Exchange catalogue (one 18+), and reports the person's own key and machine when it is given their token.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const html = '<!doctype html><html><head><style>body{background:#ffffff;color:#222222}</style></head><body><h1>Design</h1></body></html>';
++const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
++
++const CATALOG = {
++ models: [
++ { slug: 'alpha/one', canonical: 'alpha/one', name: 'Alpha One', family: 'Alpha', kind: 'chat', uncensored: false,
++ offers: [{ provider: 'alpha', model: 'one', provider_name: 'Alpha', status: 'up', price: { in: 1, out: 2, currency: 'USD', per: '1M tokens' } }] },
++ { slug: 'beta/two', canonical: 'beta/two', name: 'Beta Two', family: 'Beta', kind: 'chat', uncensored: false,
++ offers: [{ provider: 'beta', model: 'two', provider_name: 'Beta', status: 'up', price: { in: 3, out: 6, currency: 'USD', per: '1M tokens' } }] },
++ { slug: 'secret/abliterated', canonical: 'secret/abliterated', name: 'Secret Uncensored', family: 'Secret', kind: 'chat', uncensored: true,
++ offers: [{ provider: 'secret', model: 'abliterated', provider_name: 'Secret', status: 'up', price: { in: 4, out: 8, currency: 'USD', per: '1M tokens' } }] },
++ ],
++};
++
++function fakeAi() {
++ return {
++ async offeredModels() { return [{ provider: 'alpha', model: 'one' }, { provider: 'beta', model: 'two' }, { provider: 'secret', model: 'abliterated' }]; },
++ async exchangeCatalog() { return CATALOG; },
++ async choices({ token } = {}) {
++ if (!token) return { adult: false, options: { openvibe: [], keys: [], machines: [] } };
++ return { adult: true, options: {
++ openvibe: [],
++ keys: [{ model: 'my/model', name: 'My key model', source: 'keys', adult: false }],
++ machines: [{ machine: 'dev_1', machine_name: 'My laptop', model: 'local/model', name: 'Local model', source: 'machines', adult: false }],
++ } };
++ },
++ async designPlan() {
++ return { slots: [{ provider: 'alpha', model: 'one' }, { provider: 'beta', model: 'two' }, { provider: 'alpha', model: 'one' }],
++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' }, pin: false };
++ },
++ async generate({ slot }) {
++ await pause(5);
++ return { html, provider: slot.provider, model: slot.model, cost_usd: 0.002, ms: 12 };
++ },
++ };
++}
++
++const balance = { read: async () => ({ currency: 'USD', balance_usd: 2, held_usd: 0.5, available_usd: 1.5 }) };
++
++(async () => {
++ const t = await boot({ ai: fakeAi(), balance });
++ try {
++ const user = t.network.addUser('kim');
++ let signedOutOptions;
++
++ await check('signed out options list kinds, frames, looks and priced models', async () => {
++ const r = await t.get('/api/create/options');
++ assert.equal(r.status, 200, r.text);
++ signedOutOptions = r.json();
++ assert.ok(signedOutOptions.kinds.some((k) => k.id === 'component' && k.frames.includes('desktop')));
++ assert.ok(signedOutOptions.frames.some((f) => f.id === 'email' && f.width === 600));
++ assert.ok(signedOutOptions.looks.some((l) => l.source === 'theme' && l.id === 'paper'));
++ const alpha = signedOutOptions.models.find((m) => m.id === 'alpha:one');
++ assert.ok(alpha, 'the runnable catalogue model is listed');
++ assert.equal(alpha.price_in, 1);
++ assert.equal(alpha.price_out, 2);
++ assert.equal(alpha.adult, false);
++ assert.match(alpha.exchange_url, /openvibe\.exchange\/models\/alpha\/one/);
++ assert.ok(!signedOutOptions.models.some((m) => m.adult), 'the 18+ model is not listed signed out');
++ assert.equal(signedOutOptions.takes.default, 3);
++ });
++
++ await check('signed in options add adult models and the person own keys and machines', async () => {
++ const r = await t.get('/api/create/options', { as: user });
++ assert.equal(r.status, 200, r.text);
++ const body = r.json();
++ assert.ok(body.models.some((m) => m.id === 'secret:abliterated' && m.adult === true), 'AI says this person may use 18+');
++ assert.ok(body.models.some((m) => m.id === 'keys:my/model' && m.price_in === 0));
++ assert.ok(body.models.some((m) => m.id === 'machines:dev_1:local/model' && m.price_in === 0));
++ });
++
++ await check('the estimate prices the chosen models and says whether Balance covers it', async () => {
++ const r = await t.get('/api/create/estimate', { as: user, origin: 'https://openvibe.design',
++ json: { prompt: 'A pricing card', kind: 'component', frame: 'desktop', models: ['alpha:one', 'beta:two'] } });
++ assert.equal(r.status, 200, r.text);
++ const body = r.json();
++ assert.equal(body.currency, 'USD');
++ assert.equal(body.per_model.length, 2);
++ assert.ok(body.total_usd > 0);
++ assert.equal(body.cost.currency, 'USD');
++ assert.equal(body.cost.total_usd, body.total_usd);
++ assert.equal(body.balance.available_usd, 1.5);
++ assert.equal(body.covers, true);
++ assert.equal(body.takes, 2);
++ });
++
++ await check('two takes per model doubles the estimate through the same function', async () => {
++ const body = { prompt: 'A pricing card', kind: 'component', frame: 'desktop', models: ['alpha:one', 'beta:two'] };
++ const one = (await t.get('/api/create/estimate', { as: user, origin: 'https://openvibe.design', json: body })).json();
++ const two = (await t.get('/api/create/estimate', { as: user, origin: 'https://openvibe.design',
++ json: { ...body, takes: 2 } })).json();
++ assert.equal(two.takes, 4);
++ assert.ok(Math.abs(two.total_usd - one.total_usd * 2) < 1e-6);
++ });
++
++ await check('signed out estimate answers without a Balance', async () => {
++ const r = await t.get('/api/create/estimate', { origin: 'https://openvibe.design',
++ json: { prompt: 'A pricing card', kind: 'component', models: ['alpha:one'] } });
++ assert.equal(r.status, 200, r.text);
++ const body = r.json();
++ assert.ok(body.total_usd > 0);
++ assert.equal(body.balance, null);
++ assert.equal(body.covers, null);
++ });
++
++ let created;
++ await check('a component with two models returns two takes that record their model', async () => {
++ const r = await t.get('/api/create', { as: user, origin: 'https://openvibe.design',
++ json: { prompt: 'A pricing card', kind: 'component', frame: 'desktop', look: 'paper',
++ models: ['alpha:one', 'beta:two'] } });
++ assert.equal(r.status, 201, r.text);
++ created = r.json();
++ assert.equal(created.takes.length, 2);
++ assert.deepEqual(created.takes.map((x) => x.modelId).sort(), ['alpha:one', 'beta:two']);
++ assert.deepEqual(created.takes.map((x) => x.model).sort(), ['one', 'two']);
++ const row = await t.store.db.maybe('SELECT * FROM designs WHERE id=$1', [created.design.id]);
++ assert.equal(row.kind, 'component');
++ assert.equal(row.frame, 'desktop');
++ assert.equal(row.look, 'paper');
++ assert.equal(row.models.length, 2);
++ assert.ok(row.thumbnail_svg && row.thumbnail_svg.startsWith('<svg'));
++ });
++
++ await check('the two takes run and recent lists the design with a thumbnail, kind, model and time', async () => {
++ let data;
++ for (let i = 0; i < 150; i++) {
++ data = (await t.get(`/api/v1/designs/${created.design.id}`, { as: user })).json();
++ if (data.design.status !== 'running') break;
++ await pause(20);
++ }
++ assert.equal(data.design.status, 'done');
++ assert.deepEqual(data.variants.map((v) => v.model).sort(), ['one', 'two']);
++ const r = await t.get('/api/create/recent', { as: user });
++ assert.equal(r.status, 200, r.text);
++ const body = r.json();
++ assert.equal(body.designs[0].id, created.design.id);
++ assert.equal(body.designs[0].kind, 'component');
++ assert.equal(body.designs[0].model, 'Alpha One');
++ assert.ok(body.designs[0].createdAt);
++ assert.match(body.designs[0].thumbnail, /^\/api\/create\/designs\/.+\/thumb\.svg$/);
++ const thumb = await t.get(body.designs[0].thumbnail, { as: user });
++ assert.equal(thumb.status, 200);
++ assert.match(thumb.headers.get('content-type'), /image\/svg\+xml/);
++ assert.match(thumb.text, /^<svg/);
++ });
++
++ await check('paging with before and limit answers the next page', async () => {
++ const first = (await t.get('/api/create/recent?limit=1', { as: user })).json();
++ assert.equal(first.designs.length, 1);
++ assert.ok(first.next);
++ const second = (await t.get(`/api/create/recent?limit=1&before=${encodeURIComponent(first.next)}`, { as: user })).json();
++ assert.ok(!second.designs.length || second.designs[0].id !== first.designs[0].id);
++ });
++
++ await check('an unknown model and a frame the kind does not allow are refused', async () => {
++ const unknown = await t.get('/api/create/estimate', { as: user, origin: 'https://openvibe.design',
++ json: { prompt: 'x', kind: 'component', models: ['nobody/none'] } });
++ assert.equal(unknown.status, 422);
++ const frame = await t.get('/api/create/estimate', { as: user, origin: 'https://openvibe.design',
++ json: { prompt: 'x', kind: 'slide', frame: 'phone' } });
++ assert.equal(frame.status, 422);
++ });
++
++ await check('a starting design is loaded through the ordinary access check', async () => {
++ const listed = (await t.get('/api/create/recent', { as: user })).json();
++ const source = listed.designs[0];
++ const r = await t.get('/api/create/estimate', { as: user, origin: 'https://openvibe.design',
++ json: { prompt: 'A variation on that card', kind: 'component', start: { design_id: source.id } } });
++ assert.equal(r.status, 200, r.text);
++ assert.equal(r.json().kind, 'component');
++ const outsider = (await t.get('/api/create/estimate', { origin: 'https://openvibe.design',
++ json: { prompt: 'A variation on that card', kind: 'component', start: { design_id: source.id } } })).json();
++ assert.ok(outsider.detail);
++ });
++
++ await check('a starting URL is refused unless it is a public https page', async () => {
++ for (const url of ['http://127.0.0.1/private', 'https://localhost/private', 'https://169.254.169.254/latest']) {
++ const r = await t.get('/api/create/estimate', { origin: 'https://openvibe.design',
++ json: { prompt: 'x', kind: 'component', start: { url } } });
++ assert.equal(r.status, 422, `${url} should be refused: ${r.text}`);
++ }
++ });
++
++ await check('the present page and its three-take create still work', async () => {
++ const page = await t.get('/create');
++ assert.equal(page.status, 200);
++ assert.match(page.text, /What will you design/);
++ const r = await t.get('/create', { as: user, origin: 'https://openvibe.design',
++ headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: 'prompt=Old+page+design&kind=page&who=claude' });
++ assert.equal(r.status, 303, r.text);
++ const id = r.headers.get('location').replace('/d/', '');
++ let data;
++ for (let i = 0; i < 150; i++) {
++ data = (await t.get(`/api/v1/designs/${id}`, { as: user })).json();
++ if (data.design.status !== 'running') break;
++ await pause(20);
++ }
++ assert.equal(data.variants.length, 3);
++ assert.equal(data.design.status, 'done');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex a9873bd..0c04c7b 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -47,7 +47,7 @@ async function boot(opts = {}) {
const config = configLib.load(env);
const testdb = opts.store ? null : await require('./db').testDb({ store: opts.testStore });
const store = opts.store || createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai, publisher: opts.publisher });
++ const built = await createApp({ config, store, log, now: opts.now, accountSend: opts.accountSend, random: opts.random, ai: opts.ai, publisher: opts.publisher, balance: opts.balance, createOptions: opts.createOptions });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;