openvibers/openvibe.design Commits
Icon references: ask Exchange first, keep Iconify as the fallback (#275)
Changes
@@@ -43,7 +43,7 @@ Network's theme API.`/create` takes one prompt and shows three model variants on `/d/:id`. The variants use the Arena's OpenVibe.AI route pins and HTML sanitizer. The canvas supports phone, tablet and desktop widths, and changing the token theme repaints all frames. A signed-in design belongs to its Network subject. A guest receives a secret link, kept among the last three drafts in a private cookie. Guests can create two designs per day per IP; signed-in people can create ten. Generation progress is polled, and completed frames appear individually.--First-party services can request a missing glyph with `POST /api/v1/glyphs/drafts` and a service token for audience `openvibe.design` carrying `design.glyph.draft`. Send `{ "name": "daily-2026-11-09", "sentence": "What this icon should mean" }`. The response names pending draft IDs; repeated requests return the same IDs without drawing again. Candidates use the cheapest AI preset and wait in the existing `/icons` staff review queue. Only approved drafts appear in `GET /api/v1/glyphs/drafts?status=approved`; a requesting site keeps a bundled glyph until then.++First-party services can request a missing glyph with `POST /api/v1/glyphs/drafts` and a service token for audience `openvibe.design` carrying `design.glyph.draft`. Send `{ "name": "daily-2026-11-09", "sentence": "What this icon should mean" }`. The response names pending draft IDs; repeated requests return the same IDs without drawing again. Reference icons for a draft come from OpenVibe.Exchange (`POST /v1/search/icons`) when `EXCHANGE_SEARCH_KEY` is set in the service environment; without it Design reads Iconify, applies the same licence filter and sends only names and concepts to the model. Candidates use the cheapest AI preset and wait in the existing `/icons` staff review queue. Only approved drafts appear in `GET /api/v1/glyphs/drafts?status=approved`; a requesting site keeps a bundled glyph until then.Pick one frame with **Use this design**: it becomes the working frame, its HTML becomes version 1 and the pick is recorded as one Arena vote through OpenVibe.Bench (a signed-in person votes once; a guest's pick is saved without a vote). Edit it by chat (`POST /api/v1/designs/:id/edit`) or by clicking an element: the iframe reports `body > …:nth-of-type(n)`, and the floating panel changes its text, theme-token colour, padding or alignment. The server validates the path, tag and values, escapes the text, rebuilds only the inline style and saves a new version; the overlay script is injected at render time and is never stored with the model HTML. Versions are append-only in `design_versions`; undo/redo move the pointer (`design_picks.current_seq`), and an edit after undo appends a new version instead of deleting the old ones. Chat edits cost AI money, so each design has a small AI-edit budget (`DESIGN_CHAT_EDITS_PER_DESIGN`, default 5); a failed attempt spends budget but never replaces the last good version.@@@ -1,15 +1,43 @@'use strict';--// Iconify is the temporary reference source until Exchange exposes /v1/search/icons.--// The model receives names and concepts only; SVG paths are never sent to it.--const API = 'https://api.iconify.design';++// Icon references for the glyph drafter. OpenVibe.Exchange is the network's own search (Seller and buyer API,++// POST /v1/search/icons with an Exchange key); Iconify is the fallback while this service has no key. The model++// receives names and concepts only; SVG paths are never sent to it.++const EXCHANGE_API = 'https://api.openvibe.exchange';++const ICONIFY_API = 'https://api.iconify.design';--async function searchReferences(query, { fetchImpl = globalThis.fetch, limit = 5 } = {}) {-- const q = String(query || '').trim().slice(0, 120);-- if (!q) return [];-- const url = new URL('/search', API);-- url.searchParams.set('query', q);-- url.searchParams.set('limit', String(Math.min(Math.max(Number(limit) || 5, 1), 8)));++const clean = (value, max) => String(value == null ? '' : value).trim().slice(0, max);++const nameOf = (icon) => String(icon || '').split(':').pop().replace(/[^a-z0-9-]/gi, '');++++/** Exchange's /v1/search/icons answer → the drafter's reference shape; entries without a licence are dropped. */++function fromExchange(body, limit) {++ const items = Array.isArray(body && body.results) ? body.results : [];++ return items.slice(0, limit).flatMap((item) => {++ const name = nameOf(item && item.name);++ const set = clean(item && item.set, 60);++ const license = clean(item && item.license, 80);++ if (!name || !set || !license) return [];++ return [{ name, set, setName: clean(item.title || item.name || set, 100).replace(/^[^:]+:\s*/, ''),++ author: clean(item.author, 100), license,++ licenseUrl: clean(item.license_url, 300), preview: clean(item.url || item.thumbnail, 500) }];++ });++}++++async function searchExchange(query, { fetchImpl, limit, key, baseUrl }) {++ const response = await fetchImpl(`${String(baseUrl || EXCHANGE_API).replace(/\/+$/, '')}/v1/search/icons`, {++ method: 'POST',++ headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${key}` },++ body: JSON.stringify({ q: query, limit }),++ signal: AbortSignal.timeout(5000),++ });++ if (!response.ok) throw new Error(`Exchange icon references returned ${response.status}`);++ return fromExchange(await response.json(), limit);++}++++async function searchIconify(query, { fetchImpl, limit }) {++ const url = new URL('/search', ICONIFY_API);++ url.searchParams.set('query', query);++ url.searchParams.set('limit', String(limit));const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });if (!response.ok) throw new Error(`Icon references returned ${response.status}`);const body = await response.json();@@@ -21,8 +49,28 @@ async function searchReferences(query, { fetchImpl = globalThis.fetch, limit = 5return [{ name, set: prefix, setName: String(collection.name || prefix).slice(0, 100),author: String((collection.author && collection.author.name) || '').slice(0, 100),license: String(license.spdx).slice(0, 80), licenseUrl: String(license.url || '').slice(0, 300),-- preview: `${API}/${prefix}/${name}.svg` }];++ preview: `${ICONIFY_API}/${prefix}/${name}.svg` }];});}--module.exports = { searchReferences };++/**++ * Reference icons for a glyph name and sentence, from the Exchange when this service holds an Exchange key++ * (EXCHANGE_SEARCH_KEY; the owner creates it on openvibe.exchange) and from Iconify otherwise.++ */++async function searchReferences(query, {++ fetchImpl = globalThis.fetch,++ limit = 5,++ key = process.env.EXCHANGE_SEARCH_KEY,++ baseUrl = process.env.EXCHANGE_API_URL,++} = {}) {++ const q = String(query || '').trim().slice(0, 120);++ const n = Math.min(Math.max(Number(limit) || 5, 1), 8);++ if (!q) return [];++ if (key) {++ try { return await searchExchange(q, { fetchImpl, limit: n, key, baseUrl }); }++ catch { /* the fallback keeps a draft working while Exchange is unreachable */ }++ }++ return await searchIconify(q, { fetchImpl, limit: n });++}++++module.exports = { searchReferences, fromExchange };@@@ -3,17 +3,49 @@ const assert = require('node:assert/strict');const { searchReferences } = require('../server/glyphs/references');(async () => {-- let requested;-- const refs = await searchReferences('hot tub', { fetchImpl: async (url) => {-- requested = new URL(url);-- return { ok: true, async json() { return {-- icons: ['lucide:hot-tub', 'unknown:no-license'],-- collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },-- }; } };-- } });-- assert.equal(requested.pathname, '/search');-- assert.equal(requested.searchParams.get('query'), 'hot tub');-- assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'Lucide', author: 'Lucide', license: 'ISC',-- licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);-- console.log('glyph references: Iconify search keeps attribution and filters unlicensed results');++ // With an Exchange key the drafter reads the network's own icon search, not Iconify.++ {++ let requested, sent;++ const refs = await searchReferences('hot tub', { key: 'ovx_test', baseUrl: 'https://exchange.test', fetchImpl: async (url, opts) => {++ requested = new URL(url); sent = opts;++ return { ok: true, async json() { return { results: [++ { name: 'lucide:hot-tub', set: 'lucide', title: 'Lucide: hot tub', author: 'Lucide', license: 'ISC', license_url: 'https://example.test/license', url: 'https://cdn.test/lucide/hot-tub.svg' },++ { name: 'unknown:no-license', set: 'unknown', license: null },++ ] }; } };++ } });++ assert.equal(requested.origin, 'https://exchange.test');++ assert.equal(requested.pathname, '/v1/search/icons');++ assert.equal(sent.method, 'POST');++ assert.equal(sent.headers.authorization, 'Bearer ovx_test');++ assert.deepEqual(JSON.parse(sent.body), { q: 'hot tub', limit: 5 });++ assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'hot tub', author: 'Lucide', license: 'ISC',++ licenseUrl: 'https://example.test/license', preview: 'https://cdn.test/lucide/hot-tub.svg' }]);++ }++ // Without one it keeps Iconify as the fallback, with the same attribution and the same licence filter.++ {++ let requested;++ const refs = await searchReferences('hot tub', { key: '', fetchImpl: async (url) => {++ requested = new URL(url);++ return { ok: true, async json() { return {++ icons: ['lucide:hot-tub', 'unknown:no-license'],++ collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },++ }; } };++ } });++ assert.equal(requested.pathname, '/search');++ assert.equal(requested.searchParams.get('query'), 'hot tub');++ assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'Lucide', author: 'Lucide', license: 'ISC',++ licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);++ }++ // A configured Exchange that fails does not stop a draft: the fallback answers.++ {++ let calls = 0;++ const refs = await searchReferences('hot tub', { key: 'ovx_test', fetchImpl: async (url) => {++ calls += 1;++ if (calls === 1) return { ok: false, status: 503 };++ return { ok: true, async json() { return { icons: ['lucide:hot-tub'], collections: { lucide: { name: 'Lucide', license: { spdx: 'ISC' } } } }; } };++ } });++ assert.equal(calls, 2);++ assert.equal(refs[0].name, 'hot-tub');++ }++ console.log('glyph references: Exchange first, Iconify fallback, attribution kept and unlicensed results filtered');})().catch((err) => { console.error(err); process.exit(1); });
@@ -43,7 +43,7 @@ Network's theme API.
@@ -43,7 +43,7 @@ Network's theme API.
`/create` takes one prompt and shows three model variants on `/d/:id`. The variants use the Arena's OpenVibe.AI route pins and HTML sanitizer. The canvas supports phone, tablet and desktop widths, and changing the token theme repaints all frames. A signed-in design belongs to its Network subject. A guest receives a secret link, kept among the last three drafts in a private cookie. Guests can create two designs per day per IP; signed-in people can create ten. Generation progress is polled, and completed frames appear individually.
`/create` takes one prompt and shows three model variants on `/d/:id`. The variants use the Arena's OpenVibe.AI route pins and HTML sanitizer. The canvas supports phone, tablet and desktop widths, and changing the token theme repaints all frames. A signed-in design belongs to its Network subject. A guest receives a secret link, kept among the last three drafts in a private cookie. Guests can create two designs per day per IP; signed-in people can create ten. Generation progress is polled, and completed frames appear individually.
-First-party services can request a missing glyph with `POST /api/v1/glyphs/drafts` and a service token for audience `openvibe.design` carrying `design.glyph.draft`. Send `{ "name": "daily-2026-11-09", "sentence": "What this icon should mean" }`. The response names pending draft IDs; repeated requests return the same IDs without drawing again. Candidates use the cheapest AI preset and wait in the existing `/icons` staff review queue. Only approved drafts appear in `GET /api/v1/glyphs/drafts?status=approved`; a requesting site keeps a bundled glyph until then.
+First-party services can request a missing glyph with `POST /api/v1/glyphs/drafts` and a service token for audience `openvibe.design` carrying `design.glyph.draft`. Send `{ "name": "daily-2026-11-09", "sentence": "What this icon should mean" }`. The response names pending draft IDs; repeated requests return the same IDs without drawing again. Reference icons for a draft come from OpenVibe.Exchange (`POST /v1/search/icons`) when `EXCHANGE_SEARCH_KEY` is set in the service environment; without it Design reads Iconify, applies the same licence filter and sends only names and concepts to the model. Candidates use the cheapest AI preset and wait in the existing `/icons` staff review queue. Only approved drafts appear in `GET /api/v1/glyphs/drafts?status=approved`; a requesting site keeps a bundled glyph until then.
Pick one frame with **Use this design**: it becomes the working frame, its HTML becomes version 1 and the pick is recorded as one Arena vote through OpenVibe.Bench (a signed-in person votes once; a guest's pick is saved without a vote). Edit it by chat (`POST /api/v1/designs/:id/edit`) or by clicking an element: the iframe reports `body > …:nth-of-type(n)`, and the floating panel changes its text, theme-token colour, padding or alignment. The server validates the path, tag and values, escapes the text, rebuilds only the inline style and saves a new version; the overlay script is injected at render time and is never stored with the model HTML. Versions are append-only in `design_versions`; undo/redo move the pointer (`design_picks.current_seq`), and an edit after undo appends a new version instead of deleting the old ones. Chat edits cost AI money, so each design has a small AI-edit budget (`DESIGN_CHAT_EDITS_PER_DESIGN`, default 5); a failed attempt spends budget but never replaces the last good version.
Pick one frame with **Use this design**: it becomes the working frame, its HTML becomes version 1 and the pick is recorded as one Arena vote through OpenVibe.Bench (a signed-in person votes once; a guest's pick is saved without a vote). Edit it by chat (`POST /api/v1/designs/:id/edit`) or by clicking an element: the iframe reports `body > …:nth-of-type(n)`, and the floating panel changes its text, theme-token colour, padding or alignment. The server validates the path, tag and values, escapes the text, rebuilds only the inline style and saves a new version; the overlay script is injected at render time and is never stored with the model HTML. Versions are append-only in `design_versions`; undo/redo move the pointer (`design_picks.current_seq`), and an edit after undo appends a new version instead of deleting the old ones. Chat edits cost AI money, so each design has a small AI-edit budget (`DESIGN_CHAT_EDITS_PER_DESIGN`, default 5); a failed attempt spends budget but never replaces the last good version.
@@ -1,15 +1,43 @@
@@ -1,15 +1,43 @@
'use strict';
'use strict';
-// Iconify is the temporary reference source until Exchange exposes /v1/search/icons.
-// The model receives names and concepts only; SVG paths are never sent to it.
-const API = 'https://api.iconify.design';
+// Icon references for the glyph drafter. OpenVibe.Exchange is the network's own search (Seller and buyer API,
+// POST /v1/search/icons with an Exchange key); Iconify is the fallback while this service has no key. The model
+// receives names and concepts only; SVG paths are never sent to it.
+const EXCHANGE_API = 'https://api.openvibe.exchange';
+const ICONIFY_API = 'https://api.iconify.design';
-async function searchReferences(query, { fetchImpl = globalThis.fetch, limit = 5 } = {}) {
- const q = String(query || '').trim().slice(0, 120);
- if (!q) return [];
- const url = new URL('/search', API);
- url.searchParams.set('query', q);
- url.searchParams.set('limit', String(Math.min(Math.max(Number(limit) || 5, 1), 8)));
+const clean = (value, max) => String(value == null ? '' : value).trim().slice(0, max);
+const nameOf = (icon) => String(icon || '').split(':').pop().replace(/[^a-z0-9-]/gi, '');
+
+/** Exchange's /v1/search/icons answer → the drafter's reference shape; entries without a licence are dropped. */
+function fromExchange(body, limit) {
+ const items = Array.isArray(body && body.results) ? body.results : [];
+ return items.slice(0, limit).flatMap((item) => {
+ const name = nameOf(item && item.name);
+ const set = clean(item && item.set, 60);
+ const license = clean(item && item.license, 80);
+ if (!name || !set || !license) return [];
+ return [{ name, set, setName: clean(item.title || item.name || set, 100).replace(/^[^:]+:\s*/, ''),
+ author: clean(item.author, 100), license,
+ licenseUrl: clean(item.license_url, 300), preview: clean(item.url || item.thumbnail, 500) }];
+ });
+}
+
+async function searchExchange(query, { fetchImpl, limit, key, baseUrl }) {
+ const response = await fetchImpl(`${String(baseUrl || EXCHANGE_API).replace(/\/+$/, '')}/v1/search/icons`, {
+ method: 'POST',
+ headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${key}` },
+ body: JSON.stringify({ q: query, limit }),
+ signal: AbortSignal.timeout(5000),
+ });
+ if (!response.ok) throw new Error(`Exchange icon references returned ${response.status}`);
+ return fromExchange(await response.json(), limit);
+}
+
+async function searchIconify(query, { fetchImpl, limit }) {
+ const url = new URL('/search', ICONIFY_API);
+ url.searchParams.set('query', query);
+ url.searchParams.set('limit', String(limit));
const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(5000) });
if (!response.ok) throw new Error(`Icon references returned ${response.status}`);
if (!response.ok) throw new Error(`Icon references returned ${response.status}`);
const body = await response.json();
const body = await response.json();
@@ -21,8 +49,28 @@ async function searchReferences(query, { fetchImpl = globalThis.fetch, limit = 5
@@ -21,8 +49,28 @@ async function searchReferences(query, { fetchImpl = globalThis.fetch, limit = 5
return [{ name, set: prefix, setName: String(collection.name || prefix).slice(0, 100),
return [{ name, set: prefix, setName: String(collection.name || prefix).slice(0, 100),
author: String((collection.author && collection.author.name) || '').slice(0, 100),
author: String((collection.author && collection.author.name) || '').slice(0, 100),
license: String(license.spdx).slice(0, 80), licenseUrl: String(license.url || '').slice(0, 300),
license: String(license.spdx).slice(0, 80), licenseUrl: String(license.url || '').slice(0, 300),
- preview: `${API}/${prefix}/${name}.svg` }];
+ preview: `${ICONIFY_API}/${prefix}/${name}.svg` }];
});
});
}
}
-module.exports = { searchReferences };
+/**
+ * Reference icons for a glyph name and sentence, from the Exchange when this service holds an Exchange key
+ * (EXCHANGE_SEARCH_KEY; the owner creates it on openvibe.exchange) and from Iconify otherwise.
+ */
+async function searchReferences(query, {
+ fetchImpl = globalThis.fetch,
+ limit = 5,
+ key = process.env.EXCHANGE_SEARCH_KEY,
+ baseUrl = process.env.EXCHANGE_API_URL,
+} = {}) {
+ const q = String(query || '').trim().slice(0, 120);
+ const n = Math.min(Math.max(Number(limit) || 5, 1), 8);
+ if (!q) return [];
+ if (key) {
+ try { return await searchExchange(q, { fetchImpl, limit: n, key, baseUrl }); }
+ catch { /* the fallback keeps a draft working while Exchange is unreachable */ }
+ }
+ return await searchIconify(q, { fetchImpl, limit: n });
+}
+
+module.exports = { searchReferences, fromExchange };
@@ -3,17 +3,49 @@ const assert = require('node:assert/strict');
@@ -3,17 +3,49 @@ const assert = require('node:assert/strict');
const { searchReferences } = require('../server/glyphs/references');
const { searchReferences } = require('../server/glyphs/references');
(async () => {
(async () => {
- let requested;
- const refs = await searchReferences('hot tub', { fetchImpl: async (url) => {
- requested = new URL(url);
- return { ok: true, async json() { return {
- icons: ['lucide:hot-tub', 'unknown:no-license'],
- collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },
- }; } };
- } });
- assert.equal(requested.pathname, '/search');
- assert.equal(requested.searchParams.get('query'), 'hot tub');
- assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'Lucide', author: 'Lucide', license: 'ISC',
- licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);
- console.log('glyph references: Iconify search keeps attribution and filters unlicensed results');
+ // With an Exchange key the drafter reads the network's own icon search, not Iconify.
+ {
+ let requested, sent;
+ const refs = await searchReferences('hot tub', { key: 'ovx_test', baseUrl: 'https://exchange.test', fetchImpl: async (url, opts) => {
+ requested = new URL(url); sent = opts;
+ return { ok: true, async json() { return { results: [
+ { name: 'lucide:hot-tub', set: 'lucide', title: 'Lucide: hot tub', author: 'Lucide', license: 'ISC', license_url: 'https://example.test/license', url: 'https://cdn.test/lucide/hot-tub.svg' },
+ { name: 'unknown:no-license', set: 'unknown', license: null },
+ ] }; } };
+ } });
+ assert.equal(requested.origin, 'https://exchange.test');
+ assert.equal(requested.pathname, '/v1/search/icons');
+ assert.equal(sent.method, 'POST');
+ assert.equal(sent.headers.authorization, 'Bearer ovx_test');
+ assert.deepEqual(JSON.parse(sent.body), { q: 'hot tub', limit: 5 });
+ assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'hot tub', author: 'Lucide', license: 'ISC',
+ licenseUrl: 'https://example.test/license', preview: 'https://cdn.test/lucide/hot-tub.svg' }]);
+ }
+ // Without one it keeps Iconify as the fallback, with the same attribution and the same licence filter.
+ {
+ let requested;
+ const refs = await searchReferences('hot tub', { key: '', fetchImpl: async (url) => {
+ requested = new URL(url);
+ return { ok: true, async json() { return {
+ icons: ['lucide:hot-tub', 'unknown:no-license'],
+ collections: { lucide: { name: 'Lucide', author: { name: 'Lucide' }, license: { spdx: 'ISC', url: 'https://example.test/license' } } },
+ }; } };
+ } });
+ assert.equal(requested.pathname, '/search');
+ assert.equal(requested.searchParams.get('query'), 'hot tub');
+ assert.deepEqual(refs, [{ name: 'hot-tub', set: 'lucide', setName: 'Lucide', author: 'Lucide', license: 'ISC',
+ licenseUrl: 'https://example.test/license', preview: 'https://api.iconify.design/lucide/hot-tub.svg' }]);
+ }
+ // A configured Exchange that fails does not stop a draft: the fallback answers.
+ {
+ let calls = 0;
+ const refs = await searchReferences('hot tub', { key: 'ovx_test', fetchImpl: async (url) => {
+ calls += 1;
+ if (calls === 1) return { ok: false, status: 503 };
+ return { ok: true, async json() { return { icons: ['lucide:hot-tub'], collections: { lucide: { name: 'Lucide', license: { spdx: 'ISC' } } } }; } };
+ } });
+ assert.equal(calls, 2);
+ assert.equal(refs[0].name, 'hot-tub');
+ }
+ console.log('glyph references: Exchange first, Iconify fallback, attribution kept and unlicensed results filtered');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });