openvibers/openvibe.space Commits

Give the AI builder OpenVibe.Design's component registry as its vocabulary (#248)

OP OpenVibers committed 3 days ago · b2cb554a67 · 4 files, +100 −2

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 48e6be2..fc0832c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,7 @@
## Unreleased
++- **The AI builder speaks the shared component registry** (design 2026-11-09): before starting a `space.app.build` run, Space reads OpenVibe.Design's public `design.component@1` registry (`GET /api/v1/components`) and passes the definitions as the run's `components` input, so a built space is made from the same navbar, menu, Dock, composer, sheet, rows and settings as the rest of OpenVibe. The read is cached for an hour, times out after 2.5 s, and a missing registry only means a build without the vocabulary: it never fails a build. The URL is `OV_DESIGN_URL` (default `https://openvibe.design`). `test/spaces-registry.test.js`.
- **The builder is the person's own agent** (plan 2026-10-22 §Z, stage Z1): "Describe it and OpenVibe AI builds it" starts a task of the person's one agent (OpenVibe.Actor) instead of Space's own AI loop. The space is the task's subject; the agent writes its files through Space's builder API (`/api/v1/builder/spaces`, the capabilities `space.build.read|create|write|publish`), and the editor opens the Dock at that task and shows the space as the agent writes it. The step reaches the network's agent namespace (`agent.step.started|updated|ended`, product `space`), so the Dock's one timeline shows it. Publishing is never done on its own: the agent asks, the person's answer lands in the Dock ("Needs you") and nothing goes live until they say yes (`POST /api/v1/agent/approvals/:id/decide`). The old builder stays behind `SPACE_BUILDER=legacy` until the new one is proven; nothing is deleted. `test/spaces-agent.test.js`.
- **The forum's links follow Community's move to `/forum`** (design 2026-11-07): Space's forum redirect now
maps `/s` and `/s/*` straight to `/forum` and `/forum/<space>/<thread>` (the `/t/` segment is gone) instead
ddiff --git a/server/hosting-config.js b/server/hosting-config.js
iindex 1b032f6..8c99672 100644
---- a/server/hosting-config.js
++++ b/server/hosting-config.js
@@@ -188,6 +188,9 @@ function load(env = process.env) {
ai: {
enabled: String(env.SPACE_AI || 'on').trim().toLowerCase() !== 'off',
url: trim(env.OV_AI_INTERNAL_URL || env.OV_AI_URL || 'http://127.0.0.1:4700'),
++ // The AI builder reads OpenVibe.Design's public component registry (design.component@1) as its
++ // interface vocabulary. A build never waits long for it: on failure the prompt goes without it.
++ designUrl: trim(env.OV_DESIGN_URL || 'https://openvibe.design'),
},
// Guest drafts (openvibe.website, plan 5.9): a visitor who is not signed in makes a draft with a
// secret edit link and claims it later. These limits are the abuse screen's first half: what one
ddiff --git a/server/spaces/ai.js b/server/spaces/ai.js
iindex e262441..fee5602 100644
---- a/server/spaces/ai.js
++++ b/server/spaces/ai.js
@@@ -14,6 +14,38 @@
*/
const { ApiError } = require('../http/errors');
++/**
++ * OpenVibe.Design's public registry (design.component@1) as the AI builder's interface vocabulary. It is read from
++ * https://openvibe.design/api/v1/components and cached; a slow or missing registry never fails a build, it just
++ * builds without the vocabulary. The definitions are public and need no token.
++ */
++let vocabularyCache = { key: '', at: 0, text: '' };
++async function registryVocabulary(config, { fetchImpl = global.fetch, log = console, now = Date.now } = {}) {
++ if (typeof fetchImpl !== 'function') return '';
++ const base = String((config.spaces && config.spaces.ai && config.spaces.ai.designUrl) || 'https://openvibe.design').replace(/\/+$/, '');
++ const age = vocabularyCache.key === base ? now() - vocabularyCache.at : Infinity;
++ if (age < (vocabularyCache.text ? 60 * 60 * 1000 : 5 * 60 * 1000)) return vocabularyCache.text;
++ const controller = typeof AbortController === 'function' ? new AbortController() : null;
++ const timer = controller ? setTimeout(() => controller.abort(), 2500) : null;
++ try {
++ const res = await fetchImpl(`${base}/api/v1/components`, { headers: { accept: 'application/json' }, signal: controller && controller.signal });
++ if (!res.ok) throw new Error(`HTTP ${res.status}`);
++ const body = await res.json();
++ const lines = (body.components || []).map((c) => {
++ const p = c.platforms || {};
++ return `${c.name} — ${c.summary} States: ${(c.states || []).join(', ')}. Tokens: ${(c.tokens || []).join(', ')}. Web: ${p.web && p.web.markup}. Android: ${p.android && p.android.material3}. iOS: ${p.ios && p.ios.specification}`;
++ }).filter(Boolean);
++ vocabularyCache = { key: base, at: now(), text: lines.join('\n') };
++ return vocabularyCache.text;
++ } catch (err) {
++ log.warn(`[Spaces AI] component registry: ${err && err.message}`);
++ vocabularyCache = { key: base, at: now(), text: '' };
++ return '';
++ } finally {
++ if (timer) clearTimeout(timer);
++ }
++}
++
function createSpacesAi({ config, client: injected = null, log = console }) {
let ai = injected;
const enabled = () => Boolean(injected || (config.spaces.ai.enabled && config.oauth.clientSecret));
@@@ -39,7 +71,9 @@ function createSpacesAi({ config, client: injected = null, log = console }) {
*/
async function build(person, { prompt, name, current, spaceId }) {
if (!enabled()) throw unavailable();
-- const input = { prompt: String(prompt || '').slice(0, 4000), ...(name ? { name: String(name).slice(0, 40) } : {}), ...(current && current.length ? { current } : {}) };
++ // The public component registry is the builder's vocabulary. Tests inject the AI client, so they never fetch it.
++ const components = injected ? '' : await registryVocabulary(config, { log });
++ const input = { prompt: String(prompt || '').slice(0, 4000), ...(name ? { name: String(name).slice(0, 40) } : {}), ...(current && current.length ? { current } : {}), ...(components ? { components } : {}) };
let body;
try {
body = await client().runs.create('space.app.build', input, {
@@@ -92,4 +126,4 @@ function createSpacesAi({ config, client: injected = null, log = console }) {
return { enabled, build, result, ask };
}
--module.exports = { createSpacesAi };
++module.exports = { createSpacesAi, registryVocabulary };
ddiff --git a/test/spaces-registry.test.js b/test/spaces-registry.test.js
nnew file mode 100644
iindex 0000000..2003470
---- /dev/null
++++ b/test/spaces-registry.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++/**
++ * The AI builder's component vocabulary: OpenVibe.Design's public design.component@1 registry is read once and cached,
++ * a failed read never fails a build, and an injected AI client (every other test) makes no network call at all.
++ */
++const assert = require('assert');
++const http = require('http');
++const { registryVocabulary, createSpacesAi } = require('../server/spaces/ai');
++
++const LISTEN = (handler) => new Promise((resolve) => {
++ const server = http.createServer(handler);
++ server.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${server.address().port}`,
++ close: () => new Promise((r) => server.close(r)),
++ }));
++});
++
++const configFor = (url) => ({ spaces: { ai: { enabled: true, url: 'http://127.0.0.1:1', designUrl: url } }, oauth: { clientSecret: 'x' } });
++const quiet = { warn: () => {} };
++
++(async () => {
++ let hits = 0;
++ const server = await LISTEN((req, res) => {
++ hits++;
++ assert.strictEqual(req.url, '/api/v1/components');
++ res.writeHead(200, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ schema: 'design.component@1', components: [
++ { name: 'sheet', summary: 'A modal task surface.', states: ['closed', 'open'], tokens: ['color.bg.raised'], platforms: { web: { markup: 'kit.sheet()' }, android: { material3: 'SurfaceContainerHigh' }, ios: { specification: '.sheet' } } },
++ { name: 'ai-choice', summary: 'The selected model.', states: ['default'], tokens: ['color.accent.base'], platforms: { web: { markup: '.ov-ai-choice' }, android: { material3: 'AssistChip' }, ios: { specification: 'Menu' } } },
++ ] }));
++ });
++ const text = await registryVocabulary(configFor(server.url), { log: quiet });
++ assert.match(text, /^sheet — A modal task surface\./);
++ assert.match(text, /Android: SurfaceContainerHigh/);
++ assert.match(text, /ai-choice/);
++ assert.strictEqual(hits, 1, 'one read');
++ await registryVocabulary(configFor(server.url), { log: quiet });
++ assert.strictEqual(hits, 1, 'the second build uses the cache');
++ await server.close();
++
++ const bad = await LISTEN((req, res) => { res.writeHead(503); res.end('no'); });
++ const warnings = [];
++ assert.strictEqual(await registryVocabulary(configFor(bad.url), { log: { warn: (m) => warnings.push(m) } }), '', 'a failed registry is no vocabulary');
++ assert.ok(warnings.length && /503/.test(warnings[0]));
++ await bad.close();
++
++ let fetched = 0;
++ const calls = [];
++ const fake = { runs: { create: async (workflow, input) => { calls.push({ workflow, input }); return { run: { id: 'run-1' } }; } } };
++ const ai = createSpacesAi({ config: configFor('http://127.0.0.1:1'), client: fake, log: quiet });
++ const originalFetch = global.fetch;
++ global.fetch = async () => { fetched++; throw new Error('must not fetch'); };
++ assert.strictEqual(await ai.build({ subject: 'usr_1' }, { prompt: 'A pizza poll' }), 'run-1');
++ global.fetch = originalFetch;
++ assert.strictEqual(fetched, 0, 'an injected AI client makes no registry call');
++ assert.deepStrictEqual(Object.keys(calls[0].input).includes('components'), false);
++ assert.strictEqual(calls[0].workflow, 'space.app.build');
++
++ console.log(' ok the builder reads design.component@1, caches it, and an injected client never fetches');
++})().catch((e) => { console.error(' FAIL', e); process.exit(1); });