openvibers/openvibe.design Commits

Design's icon generator asks AI for the design.icon task (#218)

OP OpenVibers committed 4 days ago · e9771db1e3 · 5 files, +20 −16

* Design's icon generator asks AI for the design.icon task

The glyph drafts no longer name a model: they run as the design.icon task, so
OpenVibe.AI resolves which model answers (the owner's default for the task, the
person's saved choice, their default for the kind). What Design does today
becomes that default when AI seeds it: cheapest signed out, Claude signed in.

- A different model per run overrides it (the draft route takes `choice`, and
  the glyph API passes it through).
- A person still runs as themselves, so their own key is tried first; a guest
  still runs on OpenVibe's supply, inside the guest caps, and the daily limits
  are unchanged.

The model chip beside "Draw it" follows once openvibe-shared 4.174.0 (its
`ov-ai-choice` export) is released and pinned here.

* The service draft test expects the design.icon task, not the cheapest preset

* Merge remote-tracking branch 'origin/main' into ai-choices/c1-design

* Merge remote-tracking branch 'origin/main' into ai-choices/c1-design

* Retry CI after incomplete GitHub workflow scheduling

* Restart CI after GitHub Actions outage

Changes

ddiff --git a/server/arena/ai.js b/server/arena/ai.js
iindex 2cc2fc0..20f6a2d 100644
---- a/server/arena/ai.js
++++ b/server/arena/ai.js
@@@ -380,7 +380,7 @@ 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, paid_by, prefer }
*/
-- async function text({ prompt, system, maxTokens = 2000, target = null, prefer = null, preset = null, person = null }) {
++ async function text({ prompt, system, maxTokens = 2000, target = null, prefer = null, preset = null, person = null, task = null, choice = null }) {
const startedAt = Date.now();
const who = onBehalfOf(person);
const wait = Math.min(ai.waitMs, 60_000);
@@@ -388,6 +388,9 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
...(system ? { system } : {}), ...(who ? { on_behalf_of: who } : {}), ...(target ? { target } : {}) };
const created = await createRun({
workflow: 'ai.chat',
++ // A task lets AI resolve the model (the owner's default for it, the person's saved choice, their kind
++ // default): Design no longer names a model for its icon drafts. A per-run choice overrides it.
++ ...(task ? { task } : {}), ...(choice ? { choice } : {}),
input: { ...(system ? { system } : {}), messages: [{ role: 'user', content: String(prompt == null ? '' : prompt) }], max_tokens: maxTokens },
options: { cache: false },
...(prefer ? { prefer } : {}),
ddiff --git a/server/glyphs/drafts.js b/server/glyphs/drafts.js
iindex ac548a0..d4f79f1 100644
---- a/server/glyphs/drafts.js
++++ b/server/glyphs/drafts.js
@@@ -21,7 +21,6 @@ const { fitDrawing } = require('./fit');
const { searchReferences } = require('./references');
const PER_DAY = 12;
--const CLAUDE = Object.freeze({ provider: 'anthropic', model: 'claude-opus-5-5' });
const CANDIDATES = 4;
const NAME_RE = /^[a-z][a-z0-9-]{1,31}$/;
@@@ -64,7 +63,7 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
db.exec("DELETE FROM glyph_guest_draws WHERE created_at < now() - interval '2 days'").catch(() => {});
}
-- async function draft({ subject, name, sentence = '', guest = null, service = false, styles = null, sourceSvg = '' }) {
++ async function draft({ subject, name, sentence = '', guest = null, service = false, styles = null, sourceSvg = '', choice = null }) {
if (!subject && !guest) throw Object.assign(new Error('sign in to draw an icon'), { status: 401 });
if (guest && !/^[0-9a-f]{16,64}$/.test(String(guest.id || ''))) throw Object.assign(new Error('this browser cannot draw without signing in'), { status: 400 });
const who = subject || `guest:${guest.id}`;
@@@ -92,11 +91,8 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() }, guests =
prompt: `${sourceLine ? `Make a solid twin of this existing line drawing for "${n}": ${sourceLine.svg}` : `The icon: "${n}".`}${what ? ` What it stands for: ${what}` : ''}${referenceWords ? `\nRelated open icon concepts for vocabulary only: ${referenceWords}. Draw an original OpenVibe glyph; do not copy a reference.` : ''}${extra}`,
maxTokens: 1800,
target: { service: 'design', type: 'glyph_draft', id: n },
-- // Two tiers (owner, 11-09: "the free one should use the cheapest or most cost-effective model if people
-- // aren't logged in… log in for the maximum tier, Anthropic"). Signed in: Claude Opus 5.5 first, on the
-- // person's own key when they have one, else OpenVibe's Anthropic offer. A guest: the cheapest offer that
-- // can do the job, on OpenVibe's, inside the guest caps above.
-- ...(subject && !service ? { person: subject, prefer: CLAUDE } : { preset: { name: 'cheapest' } }),
++ task: 'design.icon', choice,
++ ...(subject && !service ? { person: subject } : {}),
});
let got = [];
try {
ddiff --git a/server/http/icons.js b/server/http/icons.js
iindex 40698fb..47a9278 100644
---- a/server/http/icons.js
++++ b/server/http/icons.js
@@@ -273,7 +273,7 @@ function createIconRoutes(ctx) {
guest = guestOf(req, res, { make: true });
}
try {
-- const out = await drafts.draft({ subject, guest, name: (req.body || {}).name, sentence: (req.body || {}).sentence });
++ const out = await drafts.draft({ subject, guest, name: (req.body || {}).name, sentence: (req.body || {}).sentence, choice: req.body.choice ? JSON.parse(req.body.choice) : null });
back(res, { drafted: String(out.kept.length), draw: String((req.body || {}).name || '') });
} catch (err) {
if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] glyph draft failed:', err && err.message);
@@@ -289,7 +289,7 @@ function createIconRoutes(ctx) {
if (!writeAllowed(req, config)) return problem(res, req, 403, 'design.same_origin_required', 'Open this request from an authorized OpenVibe client.');
try {
const body = req.body || {};
-- const out = await drafts.draft({ subject, name: body.name, sentence: body.sentence, styles: body.styles, sourceSvg: body.sourceSvg });
++ const out = await drafts.draft({ subject, name: body.name, sentence: body.sentence, styles: body.styles, sourceSvg: body.sourceSvg, choice: body.choice });
return res.status(201).set('Cache-Control', 'private, no-store').json({ kept: out.kept, refused: out.refused });
} catch (err) {
if (!err.status || err.status >= 500) log.warn('[OpenVibe.Design] glyph API draft failed:', err && err.message);
ddiff --git a/test/glyph-draft-tiers.test.js b/test/glyph-draft-tiers.test.js
iindex e5a2eab..09d5bfd 100644
---- a/test/glyph-draft-tiers.test.js
++++ b/test/glyph-draft-tiers.test.js
@@@ -27,7 +27,7 @@ function fakeAi(calls) {
enabled: true,
async text(args) {
calls.push(args);
-- const claude = args.prefer && args.prefer.provider === 'anthropic';
++ const claude = !!args.person;
return { text: JSON.stringify({ candidates: [{ line: LINE, solid: SOLID }, { line: '<path d="M12 5v14"/>', solid: SOLID }] }),
provider: claude ? 'anthropic' : 'shared', model: claude ? 'claude-opus-5-5' : 'gpt-5-nano' };
},
@@@ -43,7 +43,8 @@ function fakeAi(calls) {
const out = await d.draft({ subject: 'usr_1', name: 'hot-tub', sentence: 'a round tub with steam' });
assert.ok(out.kept.length >= 1, 'drafts are kept');
assert.strictEqual(calls.length, 1);
-- assert.deepStrictEqual(calls[0].prefer, { provider: 'anthropic', model: 'claude-opus-5-5' }, 'signed in asks for Claude Opus 5.5');
++ assert.equal(calls[0].task, 'design.icon', 'AI owns the signed-in default');
++ assert.equal(calls[0].prefer, undefined);
assert.strictEqual(calls[0].person, 'usr_1', 'as the person, so their own key is tried first');
assert.strictEqual(calls[0].preset, undefined, 'no cheapest preset for a signed-in person');
assert.strictEqual(out.kept[0].model, 'claude-opus-5-5', 'the draft records the model that drew it');
@@@ -55,7 +56,8 @@ function fakeAi(calls) {
const d = createGlyphDrafts({ db, ai: fakeAi(calls), referenceSearch: async () => [] });
const out = await d.draft({ subject: null, guest: { id: 'a'.repeat(32), ipHash: 'h1' }, name: 'hot-tub' });
assert.ok(out.kept.length >= 1, 'a guest gets drafts too');
-- assert.deepStrictEqual(calls[0].preset, { name: 'cheapest' }, 'a guest runs on the cheapest offer');
++ assert.equal(calls[0].task, 'design.icon', 'AI owns the guest default');
++ assert.equal(calls[0].preset, undefined);
assert.strictEqual(calls[0].prefer, undefined, 'a guest never asks for Claude');
assert.strictEqual(calls[0].person, undefined, 'a guest is nobody\'s key');
}
@@@ -64,7 +66,9 @@ function fakeAi(calls) {
const calls = [], db = fakeDb();
const d = createGlyphDrafts({ db, ai: fakeAi(calls), referenceSearch: async () => [] });
const sourceSvg = '<path d="M5 12h14"/>';
-- const out = await d.draft({ subject: 'usr_2', name: 'hot-tub', sourceSvg, styles: ['solid'] });
++ const choice = {model:'qwen/qwen3',source:'keys'};
++ const out = await d.draft({ subject: 'usr_2', name: 'hot-tub', sourceSvg, styles: ['solid'], choice });
++ assert.deepEqual(calls[0].choice, choice, 'per-run choice reaches AI');
assert.ok(out.kept.length && out.kept[0].solid_svg, 'the model solid is fitted and kept');
assert.ok(calls[0].system.includes('filled twins for existing'), 'solid work uses the solid-specific instructions');
assert.ok(calls[0].prompt.includes(sourceSvg), 'the existing line drawing is supplied to match');
ddiff --git a/test/glyph-service-api.test.js b/test/glyph-service-api.test.js
iindex e150688..53ce027 100644
---- a/test/glyph-service-api.test.js
++++ b/test/glyph-service-api.test.js
@@@ -34,13 +34,14 @@ function token(t, cap = ['design.glyph.draft'], sub = 'svc:quest') {
assert.equal((await post(token(t, ['design.glyph.draft']), { ...body, svg: '<svg/>' })).status, 422);
assert.equal(calls.length, 0);
});
-- await check('a service request creates review drafts on the cheapest tier and deduplicates', async () => {
++ await check('a service request creates review drafts on the design.icon task and deduplicates', async () => {
const first = await post(token(t));
assert.equal(first.status, 201, first.text.slice(0, 180));
assert.equal(first.json().status, 'pending');
assert.ok(first.json().draft_ids.length > 0);
assert.equal(calls.length, 1);
-- assert.deepEqual(calls[0].preset, { name: 'cheapest' });
++ assert.equal(calls[0].task, 'design.icon', 'AI resolves the model for the task');
++ assert.equal(calls[0].preset, undefined, 'Design no longer names a routing preset');
assert.equal(calls[0].person, undefined, 'a service request never spends a person’s key');
const again = await post(token(t));
assert.equal(again.status, 200);