openvibers/openvibe.design Commits
openvibe.design is Claude-first: Opus 5.5 by default, or compare models (#45)
Changes
@@@ -258,6 +258,18 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef.dv-create textarea { display: block; width: 100%; min-height: 150px; padding: 16px; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 1.15rem; }.dv-create textarea::placeholder { color: var(--text-muted); }.dv-create form button { display: block; margin: 0 0 2px auto; border: 0; border-radius: 9px; background: var(--accent); color: var(--on-accent); padding: 12px 18px; font: inherit; font-weight: 700; cursor: pointer; }++/* Who designs it (L37): Claude first, or three providers compared. Plain radios, so it works without JavaScript. */++.dv-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; justify-content: space-between; }++.dv-foot button { margin: 0 !important; }++.dv-who { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; border: 0; min-width: 0; }++.dv-who label { position: relative; cursor: pointer; }++.dv-who input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }++.dv-who span { display: grid; align-content: start; gap: 1px; min-height: 44px; box-sizing: border-box; padding: 6px 12px; border: 1px solid var(--border); border-radius: 11px; color: var(--text-secondary); }++.dv-who b { font-size: .9rem; color: var(--text-primary); }++.dv-who small { font-size: .74rem; color: var(--text-muted); }++.dv-who input:checked + span { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }++.dv-who input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }++@media (max-width: 520px) { .dv-who { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .dv-who span { height: 100%; } .dv-foot button { width: 100%; } }.dv-examples { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }.dv-examples button { min-height: 38px; border: 1px solid var(--border); border-radius: 99px; padding: 8px 13px; background: var(--bg-card); color: var(--text-muted); font: inherit; font-size: .84rem; cursor: pointer; }.dv-examples button:hover, .dv-examples button:focus-visible { color: var(--text-primary); border-color: var(--accent); }@@@ -183,6 +183,31 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })async function slots() { return (await slotPlan()).slots; }++ /**++ * Who draws a design on openvibe.design (plan L37, the owner: "default to Opus 5.5… most of the site++ * Anthropic-powered, but configurable"). `claude`, the default, asks for Claude Opus 5.5, Claude Sonnet 5.5 and++ * a second take from Opus, from the offers AI makes this service; a model AI does not offer is replaced by the++ * Arena's next pick, and `claude: false` in the answer says no Claude model was offered at all. `compare` is the++ * Arena's three, one per provider.++ */++ const CLAUDE = [/^claude-opus-5-5\b/, /^claude-sonnet-5-5\b/, /^claude-opus-5-5\b/];++ async function designPlan(who = 'claude') {++ if (who === 'compare') return { ...(await slotPlan()), who: 'compare', claude: null };++ let list = configuredModels();++ if (!list.length) list = await offeredModels();++ const offers = rankOffers(list);++ const slots = [];++ for (const re of CLAUDE) {++ const o = offers.find((x) => x.model && re.test(x.model));++ if (o) slots.push(o);++ }++ const claude = slots.length > 0;++ for (const o of offers) if (slots.length < 3 && !slots.some((x) => x.provider === o.provider && x.model === o.model)) slots.push(o);++ const poolFallback = slots.length < 3;++ while (slots.length < 3) slots.push({ provider: 'ai', model: null });++ return { slots, pool_fallback: poolFallback, offered: offers.length, who: 'claude', claude };++ }++function systemPrompt(category) {return ['You are a senior product designer and front-end engineer competing in the OpenVibe Design Arena.',@@@ -299,17 +324,25 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })* Plain text from OpenVibe.AI's routing (the glyph drafts, L32): the same token and run as the Arena, one answer.* → { text, model, provider, run_id }*/-- async function text({ prompt, system, maxTokens = 2000, target = null }) {++ async function text({ prompt, system, maxTokens = 2000, target = null, prefer = null }) {const startedAt = Date.now();++ // `prefer` (a model pattern) pins the first offer that matches it (L37: Claude first); no match lets AI's++ // routing choose, as before.++ let route = null;++ if (prefer) {++ const list = configuredModels().length ? configuredModels() : await offeredModels().catch(() => []);++ const o = list.find((x) => x.model && prefer.test(x.model));++ if (o) route = { provider: o.provider, model: o.model };++ }const created = await call('POST', `/api/v1/generate?wait=${Math.min(ai.waitMs, 60_000)}`, {-- prompt, system, max_tokens: maxTokens, options: { cache: false }, ...(target ? { target } : {}),++ prompt, system, max_tokens: maxTokens, options: { cache: false }, ...(target ? { target } : {}), ...(route ? { route } : {}),});const { run } = await waitFor(created && created.run ? created.run : created, startedAt);const prov = run.provenance || {};return { text: textOf(run.output), model: String(prov.model || run.model || 'unknown').slice(0, 120), provider: String(prov.provider || run.provider || 'ai').slice(0, 80), run_id: run.id || null };}-- return { enabled, slots, slotPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };++ return { enabled, slots, slotPlan, designPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };}module.exports = { createArenaAi, ArenaAiError, CATEGORY_BRIEF };@@@ -99,7 +99,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolif (!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 = '' }) {++ async function create({ prompt, kind = 'page', themeId, owner = null, ip = '', who = 'claude' }) {prompt = String(prompt || '').trim();if (!prompt || prompt.length > 4000 || !KINDS.includes(kind))throw fail(422, 'design.invalid_input', 'Provide a prompt up to 4000 characters and a valid kind.');@@@ -107,7 +107,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolconst 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);-- const plan = await ai.slotPlan();++ // Claude first (L37); `compare` asks the Arena's three. An AI client without designPlan (tests, older) uses slots.++ const plan = ai.designPlan ? await ai.designPlan(who === 'compare' ? 'compare' : 'claude') : await ai.slotPlan();const slots = plan.slots.slice(0, 3);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 }));@@@ -39,6 +39,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}`,maxTokens: 1800,target: { service: 'design', type: 'glyph_draft', id: n },++ prefer: /^claude-opus-5-5\b/, // Claude first on openvibe.design (L37)});let list = [];try {@@@ -39,7 +39,7 @@ function createDesignApi(ctx) {routes.post('/', sameOrigin, safe(async (req, res) => {const body = req.body || {};const result = await ctx.designs.create({ prompt: body.prompt, kind: body.kind || 'page',-- themeId: body.themeId, owner: owner(req), ip: req.ip });++ themeId: body.themeId, owner: owner(req), ip: req.ip, who: body.who });if (result.editKey) {let links = [];try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }@@@ -90,7 +90,7 @@ function createPageRoutes(ctx) {if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');try {const out = await ctx.designs.create({ prompt: req.body.prompt, kind: req.body.kind || 'page',-- owner: subjectOf(req), ip: req.ip });++ owner: subjectOf(req), ip: req.ip, who: req.body.who });if (out.editKey) {let links = [];try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }@@@ -10,7 +10,10 @@ function create({ recent = [] } = {}) {const body = `<section class="dv-create"><h1>What will you design?</h1><form method="post" action="/create"><label class="dv-visually-hidden" for="dv-prompt">Describe your design</label><textarea id="dv-prompt" name="prompt" required maxlength="4000" placeholder="Describe a page, an app screen or a component…"></textarea>--<button type="submit">Create designs</button></form>++<div class="dv-foot"><fieldset class="dv-who"><legend class="dv-visually-hidden">Who designs it</legend>++<label><input type="radio" name="who" value="claude" checked><span><b>Claude Opus 5.5</b><small>three takes, Claude first</small></span></label>++<label><input type="radio" name="who" value="compare"><span><b>Compare models</b><small>three providers side by side</small></span></label>++</fieldset><button type="submit">Create designs</button></div></form><div class="dv-examples" aria-label="Try an example">${examples.map(x => `<button type="button" data-example="${esc(x)}">${esc(x)}</button>`).join('')}</div>${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map(d => `<li><a href="${esc(d.url)}">${esc(d.prompt)}</a></li>`).join('')}</ul></section>` : ''}</section>`;return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });@@@ -219,7 +219,7 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}function developers() {const rows = [-- ['POST /api/v1/designs', 'Create three model variants with {prompt, kind, themeId?}; guests receive a secret edit link.'],++ ['POST /api/v1/designs', 'Create three variants with {prompt, kind, themeId?, who?}: Claude Opus 5.5 first by default, or who: "compare" for three providers; guests receive a secret edit link.'],['GET /api/v1/designs/:id', 'Poll generation status, read each frame, the pick and the version list; guests send X-Design-Key.'],['POST /api/v1/designs/:id/theme', 'Change the token theme with {themeId}.'],['POST /api/v1/designs/:id/pick', 'Choose the working frame with {variantIndex}; the pick is recorded as one Arena vote.'],nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++// openvibe.design is Claude-first (plan L37, 2026-10-31): a design's three takes are Claude Opus 5.5, Claude Sonnet 5.5++// and Opus again when OpenVibe.AI offers them; a missing one is filled by the Arena's next pick, and no Claude at all++// is said in the answer. "Compare models" is the Arena's three providers. The create form offers both, Claude checked.++const assert = require('node:assert/strict');++const { createArenaAi } = require('../server/arena/ai');++const { boot, check, done } = require('./helpers/boot');++++const clientOf = (models) => createArenaAi({++ config: { ai: { url: 'https://ai.test', models, waitMs: 1000 }, oauth: { clientId: 'design', clientSecret: 'x' }, networkUrl: 'https://network.test' },++ fetchImpl: async () => { throw new Error('no network in this test'); },++ log: { warn() {}, info() {}, log() {}, error() {} },++});++++(async () => {++ await check('Claude first: Opus 5.5, Sonnet 5.5, then Opus again', async () => {++ const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-sonnet-5-5', 'deepseek:deepseek-chat', 'anthropic:claude-opus-5-5']).designPlan();++ assert.deepEqual(plan.slots, [++ { provider: 'anthropic', model: 'claude-opus-5-5' },++ { provider: 'anthropic', model: 'claude-sonnet-5-5' },++ { provider: 'anthropic', model: 'claude-opus-5-5' },++ ]);++ assert.equal(plan.who, 'claude');++ assert.equal(plan.claude, true);++ });++++ await check('a Claude model AI does not offer is filled by the Arena\'s next pick', async () => {++ const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-opus-5-5', 'deepseek:deepseek-chat']).designPlan('claude');++ assert.deepEqual(plan.slots.map((s) => s.model), ['claude-opus-5-5', 'claude-opus-5-5', 'gpt-5.5']);++ });++++ await check('no Claude offered: the Arena\'s picks, and the answer says so', async () => {++ const plan = await clientOf(['openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan();++ assert.equal(plan.claude, false);++ assert.deepEqual(plan.slots, [{ provider: 'openai', model: 'gpt-5.5' }, { provider: 'deepseek', model: 'deepseek-chat' }, { provider: 'ai', model: null }]);++ assert.equal(plan.pool_fallback, true);++ });++++ await check('compare: one per provider, as the Arena asks', async () => {++ const plan = await clientOf(['anthropic:claude-opus-5-5', 'anthropic:claude-sonnet-5-5', 'openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan('compare');++ assert.equal(plan.who, 'compare');++ assert.deepEqual(plan.slots.map((s) => s.provider), ['anthropic', 'openai', 'deepseek']);++ });++++ const asked = [];++ const ai = {++ designPlan: async (who) => { asked.push(who); return { slots: [{ provider: 'anthropic', model: 'claude-opus-5-5' }, { provider: 'anthropic', model: 'claude-sonnet-5-5' }, { provider: 'anthropic', model: 'claude-opus-5-5' }] }; },++ generate: () => new Promise(() => {}),++ };++ const t = await boot({ ai });++ try {++ await check('the create form offers Claude Opus 5.5 (checked) or a comparison, and passes the choice on', async () => {++ const page = await t.get('/create');++ assert.ok(page.text.includes('<input type="radio" name="who" value="claude" checked>') && page.text.includes('value="compare"'));++ const ana = t.network.addUser('ana');++ const form = (who) => ({ method: 'POST', as: ana, body: `prompt=A+coffee+roaster&who=${who}`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, origin: 'https://openvibe.design' });++ await t.get('/create', form('compare'));++ await t.get('/create', form('claude'));++ await t.get('/create', form('anything'));++ assert.deepEqual(asked, ['compare', 'claude', 'claude'], 'anything else is Claude');++ const api = await t.get('/api/v1/designs', { as: ana, json: { prompt: 'Pricing section', kind: 'page' }, origin: 'https://openvibe.design' });++ assert.equal(api.status, 201);++ assert.equal(asked[asked.length - 1], 'claude', 'the API defaults to Claude too');++ const read = await t.get(`/api/v1/designs/${api.json().design.id}`, { as: ana });++ assert.deepEqual(read.json().variants.map((v) => v.model), ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-opus-5-5']);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -258,6 +258,18 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
@@ -258,6 +258,18 @@ html.ar-fit .ar-shot--desktop .ar-shot-frame, html.ar-fit .ar-shot--desktop::bef
.dv-create textarea { display: block; width: 100%; min-height: 150px; padding: 16px; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 1.15rem; }
.dv-create textarea { display: block; width: 100%; min-height: 150px; padding: 16px; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text-primary); font: inherit; font-size: 1.15rem; }
.dv-create textarea::placeholder { color: var(--text-muted); }
.dv-create textarea::placeholder { color: var(--text-muted); }
.dv-create form button { display: block; margin: 0 0 2px auto; border: 0; border-radius: 9px; background: var(--accent); color: var(--on-accent); padding: 12px 18px; font: inherit; font-weight: 700; cursor: pointer; }
.dv-create form button { display: block; margin: 0 0 2px auto; border: 0; border-radius: 9px; background: var(--accent); color: var(--on-accent); padding: 12px 18px; font: inherit; font-weight: 700; cursor: pointer; }
+/* Who designs it (L37): Claude first, or three providers compared. Plain radios, so it works without JavaScript. */
+.dv-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; justify-content: space-between; }
+.dv-foot button { margin: 0 !important; }
+.dv-who { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; border: 0; min-width: 0; }
+.dv-who label { position: relative; cursor: pointer; }
+.dv-who input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
+.dv-who span { display: grid; align-content: start; gap: 1px; min-height: 44px; box-sizing: border-box; padding: 6px 12px; border: 1px solid var(--border); border-radius: 11px; color: var(--text-secondary); }
+.dv-who b { font-size: .9rem; color: var(--text-primary); }
+.dv-who small { font-size: .74rem; color: var(--text-muted); }
+.dv-who input:checked + span { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
+.dv-who input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
+@media (max-width: 520px) { .dv-who { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .dv-who span { height: 100%; } .dv-foot button { width: 100%; } }
.dv-examples { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.dv-examples { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.dv-examples button { min-height: 38px; border: 1px solid var(--border); border-radius: 99px; padding: 8px 13px; background: var(--bg-card); color: var(--text-muted); font: inherit; font-size: .84rem; cursor: pointer; }
.dv-examples button { min-height: 38px; border: 1px solid var(--border); border-radius: 99px; padding: 8px 13px; background: var(--bg-card); color: var(--text-muted); font: inherit; font-size: .84rem; cursor: pointer; }
.dv-examples button:hover, .dv-examples button:focus-visible { color: var(--text-primary); border-color: var(--accent); }
.dv-examples button:hover, .dv-examples button:focus-visible { color: var(--text-primary); border-color: var(--accent); }
@@ -183,6 +183,31 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
@@ -183,6 +183,31 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
async function slots() { return (await slotPlan()).slots; }
async function slots() { return (await slotPlan()).slots; }
+ /**
+ * Who draws a design on openvibe.design (plan L37, the owner: "default to Opus 5.5… most of the site
+ * Anthropic-powered, but configurable"). `claude`, the default, asks for Claude Opus 5.5, Claude Sonnet 5.5 and
+ * a second take from Opus, from the offers AI makes this service; a model AI does not offer is replaced by the
+ * Arena's next pick, and `claude: false` in the answer says no Claude model was offered at all. `compare` is the
+ * Arena's three, one per provider.
+ */
+ const CLAUDE = [/^claude-opus-5-5\b/, /^claude-sonnet-5-5\b/, /^claude-opus-5-5\b/];
+ async function designPlan(who = 'claude') {
+ if (who === 'compare') return { ...(await slotPlan()), who: 'compare', claude: null };
+ let list = configuredModels();
+ if (!list.length) list = await offeredModels();
+ const offers = rankOffers(list);
+ const slots = [];
+ for (const re of CLAUDE) {
+ const o = offers.find((x) => x.model && re.test(x.model));
+ if (o) slots.push(o);
+ }
+ const claude = slots.length > 0;
+ for (const o of offers) if (slots.length < 3 && !slots.some((x) => x.provider === o.provider && x.model === o.model)) slots.push(o);
+ const poolFallback = slots.length < 3;
+ while (slots.length < 3) slots.push({ provider: 'ai', model: null });
+ return { slots, pool_fallback: poolFallback, offered: offers.length, who: 'claude', claude };
+ }
+
function systemPrompt(category) {
function systemPrompt(category) {
return [
return [
'You are a senior product designer and front-end engineer competing in the OpenVibe Design Arena.',
'You are a senior product designer and front-end engineer competing in the OpenVibe Design Arena.',
@@ -299,17 +324,25 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
@@ -299,17 +324,25 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
* Plain text from OpenVibe.AI's routing (the glyph drafts, L32): the same token and run as the Arena, one answer.
* Plain text from OpenVibe.AI's routing (the glyph drafts, L32): the same token and run as the Arena, one answer.
* → { text, model, provider, run_id }
* → { text, model, provider, run_id }
*/
*/
- async function text({ prompt, system, maxTokens = 2000, target = null }) {
+ async function text({ prompt, system, maxTokens = 2000, target = null, prefer = null }) {
const startedAt = Date.now();
const startedAt = Date.now();
+ // `prefer` (a model pattern) pins the first offer that matches it (L37: Claude first); no match lets AI's
+ // routing choose, as before.
+ let route = null;
+ if (prefer) {
+ const list = configuredModels().length ? configuredModels() : await offeredModels().catch(() => []);
+ const o = list.find((x) => x.model && prefer.test(x.model));
+ if (o) route = { provider: o.provider, model: o.model };
+ }
const created = await call('POST', `/api/v1/generate?wait=${Math.min(ai.waitMs, 60_000)}`, {
const created = await call('POST', `/api/v1/generate?wait=${Math.min(ai.waitMs, 60_000)}`, {
- prompt, system, max_tokens: maxTokens, options: { cache: false }, ...(target ? { target } : {}),
+ prompt, system, max_tokens: maxTokens, options: { cache: false }, ...(target ? { target } : {}), ...(route ? { route } : {}),
});
});
const { run } = await waitFor(created && created.run ? created.run : created, startedAt);
const { run } = await waitFor(created && created.run ? created.run : created, startedAt);
const prov = run.provenance || {};
const prov = run.provenance || {};
return { text: textOf(run.output), model: String(prov.model || run.model || 'unknown').slice(0, 120), provider: String(prov.provider || run.provider || 'ai').slice(0, 80), run_id: run.id || null };
return { text: textOf(run.output), model: String(prov.model || run.model || 'unknown').slice(0, 120), provider: String(prov.provider || run.provider || 'ai').slice(0, 80), run_id: run.id || null };
}
}
- return { enabled, slots, slotPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };
+ return { enabled, slots, slotPlan, designPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };
}
}
module.exports = { createArenaAi, ArenaAiError, CATEGORY_BRIEF };
module.exports = { createArenaAi, ArenaAiError, CATEGORY_BRIEF };
@@ -99,7 +99,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -99,7 +99,7 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
if (!selected) throw fail(422, 'design.invalid_theme', 'Choose a theme from the catalog.');
if (!selected) throw fail(422, 'design.invalid_theme', 'Choose a theme from the catalog.');
return selected;
return selected;
}
}
- async function create({ prompt, kind = 'page', themeId, owner = null, ip = '' }) {
+ async function create({ prompt, kind = 'page', themeId, owner = null, ip = '', who = 'claude' }) {
prompt = String(prompt || '').trim();
prompt = String(prompt || '').trim();
if (!prompt || prompt.length > 4000 || !KINDS.includes(kind))
if (!prompt || prompt.length > 4000 || !KINDS.includes(kind))
throw fail(422, 'design.invalid_input', 'Provide a prompt up to 4000 characters and a valid kind.');
throw fail(422, 'design.invalid_input', 'Provide a prompt up to 4000 characters and a valid kind.');
@@ -107,7 +107,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -107,7 +107,8 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const count = Number(await db.value(`SELECT COUNT(*) FROM designs WHERE ${owner ? 'owner' : 'guest_ip'}=$1 AND created_at >= $2::timestamptz`, [owner || ip, cutoff]));
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.');
if (count >= (owner ? 10 : 2)) throw fail(429, 'design.daily_limit', 'Daily design limit reached.');
const selected = await theme(themeId);
const selected = await theme(themeId);
- const plan = await ai.slotPlan();
+ // Claude first (L37); `compare` asks the Arena's three. An AI client without designPlan (tests, older) uses slots.
+ const plan = ai.designPlan ? await ai.designPlan(who === 'compare' ? 'compare' : 'claude') : await ai.slotPlan();
const slots = plan.slots.slice(0, 3);
const slots = plan.slots.slice(0, 3);
const variants = slots.map((slot, index) => ({ id: s.newId('dva'), slot: index, provider: slot.provider,
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 }));
model: slot.model, status: 'pending', html: '', ms: 0, costUsd: 0, error: null }));
@@ -39,6 +39,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
@@ -39,6 +39,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}`,
prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}`,
maxTokens: 1800,
maxTokens: 1800,
target: { service: 'design', type: 'glyph_draft', id: n },
target: { service: 'design', type: 'glyph_draft', id: n },
+ prefer: /^claude-opus-5-5\b/, // Claude first on openvibe.design (L37)
});
});
let list = [];
let list = [];
try {
try {
@@ -39,7 +39,7 @@ function createDesignApi(ctx) {
@@ -39,7 +39,7 @@ function createDesignApi(ctx) {
routes.post('/', sameOrigin, safe(async (req, res) => {
routes.post('/', sameOrigin, safe(async (req, res) => {
const body = req.body || {};
const body = req.body || {};
const result = await ctx.designs.create({ prompt: body.prompt, kind: body.kind || 'page',
const result = await ctx.designs.create({ prompt: body.prompt, kind: body.kind || 'page',
- themeId: body.themeId, owner: owner(req), ip: req.ip });
+ themeId: body.themeId, owner: owner(req), ip: req.ip, who: body.who });
if (result.editKey) {
if (result.editKey) {
let links = [];
let links = [];
try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
@@ -90,7 +90,7 @@ function createPageRoutes(ctx) {
@@ -90,7 +90,7 @@ function createPageRoutes(ctx) {
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
if (!writeAllowed(req, config)) return res.status(403).send('Open this form on openvibe.design.');
try {
try {
const out = await ctx.designs.create({ prompt: req.body.prompt, kind: req.body.kind || 'page',
const out = await ctx.designs.create({ prompt: req.body.prompt, kind: req.body.kind || 'page',
- owner: subjectOf(req), ip: req.ip });
+ owner: subjectOf(req), ip: req.ip, who: req.body.who });
if (out.editKey) {
if (out.editKey) {
let links = [];
let links = [];
try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
try { links = JSON.parse(req.cookies.design_drafts || '[]'); if (!Array.isArray(links)) links = []; } catch { /* empty */ }
@@ -10,7 +10,10 @@ function create({ recent = [] } = {}) {
@@ -10,7 +10,10 @@ function create({ recent = [] } = {}) {
const body = `<section class="dv-create"><h1>What will you design?</h1>
const body = `<section class="dv-create"><h1>What will you design?</h1>
<form method="post" action="/create"><label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
<form method="post" action="/create"><label class="dv-visually-hidden" for="dv-prompt">Describe your design</label>
<textarea id="dv-prompt" name="prompt" required maxlength="4000" placeholder="Describe a page, an app screen or a component…"></textarea>
<textarea id="dv-prompt" name="prompt" required maxlength="4000" placeholder="Describe a page, an app screen or a component…"></textarea>
-<button type="submit">Create designs</button></form>
+<div class="dv-foot"><fieldset class="dv-who"><legend class="dv-visually-hidden">Who designs it</legend>
+<label><input type="radio" name="who" value="claude" checked><span><b>Claude Opus 5.5</b><small>three takes, Claude first</small></span></label>
+<label><input type="radio" name="who" value="compare"><span><b>Compare models</b><small>three providers side by side</small></span></label>
+</fieldset><button type="submit">Create designs</button></div></form>
<div class="dv-examples" aria-label="Try an example">${examples.map(x => `<button type="button" data-example="${esc(x)}">${esc(x)}</button>`).join('')}</div>
<div class="dv-examples" aria-label="Try an example">${examples.map(x => `<button type="button" data-example="${esc(x)}">${esc(x)}</button>`).join('')}</div>
${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map(d => `<li><a href="${esc(d.url)}">${esc(d.prompt)}</a></li>`).join('')}</ul></section>` : ''}</section>`;
${recent.length ? `<section class="dv-recent"><h2>Your recent designs</h2><ul>${recent.map(d => `<li><a href="${esc(d.url)}">${esc(d.prompt)}</a></li>`).join('')}</ul></section>` : ''}</section>`;
return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });
return layout({ title: 'Create a design', path: '/create', body, active: '/create', scripts: ['js/design.js'] });
@@ -219,7 +219,7 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
@@ -219,7 +219,7 @@ ${rows ? `<dl class="ds-colours">${rows}</dl>` : ''}
function developers() {
function developers() {
const rows = [
const rows = [
- ['POST /api/v1/designs', 'Create three model variants with {prompt, kind, themeId?}; guests receive a secret edit link.'],
+ ['POST /api/v1/designs', 'Create three variants with {prompt, kind, themeId?, who?}: Claude Opus 5.5 first by default, or who: "compare" for three providers; guests receive a secret edit link.'],
['GET /api/v1/designs/:id', 'Poll generation status, read each frame, the pick and the version list; guests send X-Design-Key.'],
['GET /api/v1/designs/:id', 'Poll generation status, read each frame, the pick and the version list; guests send X-Design-Key.'],
['POST /api/v1/designs/:id/theme', 'Change the token theme with {themeId}.'],
['POST /api/v1/designs/:id/theme', 'Change the token theme with {themeId}.'],
['POST /api/v1/designs/:id/pick', 'Choose the working frame with {variantIndex}; the pick is recorded as one Arena vote.'],
['POST /api/v1/designs/:id/pick', 'Choose the working frame with {variantIndex}; the pick is recorded as one Arena vote.'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+// openvibe.design is Claude-first (plan L37, 2026-10-31): a design's three takes are Claude Opus 5.5, Claude Sonnet 5.5
+// and Opus again when OpenVibe.AI offers them; a missing one is filled by the Arena's next pick, and no Claude at all
+// is said in the answer. "Compare models" is the Arena's three providers. The create form offers both, Claude checked.
+const assert = require('node:assert/strict');
+const { createArenaAi } = require('../server/arena/ai');
+const { boot, check, done } = require('./helpers/boot');
+
+const clientOf = (models) => createArenaAi({
+ config: { ai: { url: 'https://ai.test', models, waitMs: 1000 }, oauth: { clientId: 'design', clientSecret: 'x' }, networkUrl: 'https://network.test' },
+ fetchImpl: async () => { throw new Error('no network in this test'); },
+ log: { warn() {}, info() {}, log() {}, error() {} },
+});
+
+(async () => {
+ await check('Claude first: Opus 5.5, Sonnet 5.5, then Opus again', async () => {
+ const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-sonnet-5-5', 'deepseek:deepseek-chat', 'anthropic:claude-opus-5-5']).designPlan();
+ assert.deepEqual(plan.slots, [
+ { provider: 'anthropic', model: 'claude-opus-5-5' },
+ { provider: 'anthropic', model: 'claude-sonnet-5-5' },
+ { provider: 'anthropic', model: 'claude-opus-5-5' },
+ ]);
+ assert.equal(plan.who, 'claude');
+ assert.equal(plan.claude, true);
+ });
+
+ await check('a Claude model AI does not offer is filled by the Arena\'s next pick', async () => {
+ const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-opus-5-5', 'deepseek:deepseek-chat']).designPlan('claude');
+ assert.deepEqual(plan.slots.map((s) => s.model), ['claude-opus-5-5', 'claude-opus-5-5', 'gpt-5.5']);
+ });
+
+ await check('no Claude offered: the Arena\'s picks, and the answer says so', async () => {
+ const plan = await clientOf(['openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan();
+ assert.equal(plan.claude, false);
+ assert.deepEqual(plan.slots, [{ provider: 'openai', model: 'gpt-5.5' }, { provider: 'deepseek', model: 'deepseek-chat' }, { provider: 'ai', model: null }]);
+ assert.equal(plan.pool_fallback, true);
+ });
+
+ await check('compare: one per provider, as the Arena asks', async () => {
+ const plan = await clientOf(['anthropic:claude-opus-5-5', 'anthropic:claude-sonnet-5-5', 'openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan('compare');
+ assert.equal(plan.who, 'compare');
+ assert.deepEqual(plan.slots.map((s) => s.provider), ['anthropic', 'openai', 'deepseek']);
+ });
+
+ const asked = [];
+ const ai = {
+ designPlan: async (who) => { asked.push(who); return { slots: [{ provider: 'anthropic', model: 'claude-opus-5-5' }, { provider: 'anthropic', model: 'claude-sonnet-5-5' }, { provider: 'anthropic', model: 'claude-opus-5-5' }] }; },
+ generate: () => new Promise(() => {}),
+ };
+ const t = await boot({ ai });
+ try {
+ await check('the create form offers Claude Opus 5.5 (checked) or a comparison, and passes the choice on', async () => {
+ const page = await t.get('/create');
+ assert.ok(page.text.includes('<input type="radio" name="who" value="claude" checked>') && page.text.includes('value="compare"'));
+ const ana = t.network.addUser('ana');
+ const form = (who) => ({ method: 'POST', as: ana, body: `prompt=A+coffee+roaster&who=${who}`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, origin: 'https://openvibe.design' });
+ await t.get('/create', form('compare'));
+ await t.get('/create', form('claude'));
+ await t.get('/create', form('anything'));
+ assert.deepEqual(asked, ['compare', 'claude', 'claude'], 'anything else is Claude');
+ const api = await t.get('/api/v1/designs', { as: ana, json: { prompt: 'Pricing section', kind: 'page' }, origin: 'https://openvibe.design' });
+ assert.equal(api.status, 201);
+ assert.equal(asked[asked.length - 1], 'claude', 'the API defaults to Claude too');
+ const read = await t.get(`/api/v1/designs/${api.json().design.id}`, { as: ana });
+ assert.deepEqual(read.json().variants.map((v) => v.model), ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-opus-5-5']);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });