openvibers/openvibe.design Commits

Icon references: ask Exchange first, keep Iconify as the fallback (#275)

OP OpenVibers committed 2 days ago · 1eac804b6f · 3 files, +105 −25

Changes

ddiff --git a/README.md b/README.md
iindex 4813dbc..7921ed5 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/server/glyphs/references.js b/server/glyphs/references.js
iindex f010f44..9e12436 100644
---- a/server/glyphs/references.js
++++ b/server/glyphs/references.js
@@@ -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 = 5
return [{ 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 };
ddiff --git a/test/glyph-references.test.js b/test/glyph-references.test.js
iindex 8114796..057d07f 100644
---- a/test/glyph-references.test.js
++++ b/test/glyph-references.test.js
@@@ -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); });