openvibers/openvibe.shared Commits
4.175.0: the model chip and its choice sheet (#393)
Changes
@@@ -1,5 +1,15 @@# Changelog++## 4.176.0 — 2026-11-09++++- **The model chip and its choice sheet** (`ai-choice.js`, `ov-ai-choice.js`, `ov-ai-choice.css`): a chip beside any++ Generate button showing the maker mark and the model's name, opening a sheet with one list of the models in the++ order the person picks (Recommended, Fastest, Cheapest, Best quality; the owner's recommendation badged), then Your++ keys, Your machines and Uncensored 18+ (only where the person confirmed). Each row says what it costs a run and how++ fast it is; a check marks the choice. The footer saves "Use this for every <task>" and links to++ Exchange. Server rendering keeps the control named before hydration; the sheet sets no scroll behind it and an++ outside tap closes it.++## 4.175.0 — 2026-11-09- **OpenVibe Link**: the shared navigation's Machines entry points at openvibe.link/machines instead of the old@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.175.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.176.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.175.0",++ "version": "4.176.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",nnew file mode 100644@@@ -0,0 +1,9 @@++'use strict';++const marks = require('./ai-marks');++ const esc = value => String(value == null ? '' : value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));++ function render({ task, label = task, endpoint = '/api/ai/choices', saveEndpoint = '/api/ai/choices', choice = null, name = 'choice' } = {}) {++ const templates = marks.FAMILIES.map(m => `<template data-maker="${esc(m)}">${marks.mark(m, { label: false })}</template>`).join('');++ return `<span class="ov-ai-choice" data-ai-choice data-task="${esc(task)}" data-task-label="${esc(label)}" data-endpoint="${esc(endpoint)}" data-save-endpoint="${esc(saveEndpoint)}"><button class="ovk-btn ovk-btn--quiet ov-ai-chip" type="button" aria-haspopup="dialog" data-ai-open>${marks.mark(choice?.model || 'OpenVibe', { label: false })}<span data-ai-label>${esc(choice?.model || 'Choose model')}</span><span aria-hidden="true">▾</span></button><input type="hidden" name="${esc(name)}" value="${choice ? esc(JSON.stringify(choice)) : ''}" data-ai-value>${templates}<template data-maker="Other">${marks.mark('Other', {label:false})}</template></span>`;++ }++++module.exports = { render };@@@ -13,6 +13,7 @@const path = require('path');const BROWSER = Object.freeze([++ 'ov-ai-choice.js', // the model chip and its choice sheet (openvibe-shared/ov-ai-choice)'navbar.js', // shared top bar (eager on every site)'nav-icons.js', // navbar glyphs beyond its own, fetched by navbar.js after first paint'theme-loader.js', // applies the theme before first paint@@@ -94,6 +95,7 @@ const STYLES = Object.freeze(['ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)'ov-locker.css', // ov-locker.js's look: the locked card, the locker sheets, the recovery kit (docs/locker.md)'ov-wallet.css',++ 'ov-ai-choice.css', // the model chip and its choice sheet (ov-ai-choice.js)'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)'ov-upload.css', // picker, queue and ov-progress states'messages.css', // the room and bubble views' looknnew file mode 100644@@@ -0,0 +1,15 @@++.ov-ai-choice{display:inline-flex;max-width:100%;vertical-align:middle}.ov-ai-chip{display:inline-flex;align-items:center;gap:.5rem;min-height:44px;max-width:100%}.ov-ai-chip [data-ai-label]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ov-ai-sheet{box-sizing:border-box;width:min(560px,calc(100vw - 32px));max-height:85dvh;padding:0;border:1px solid var(--border,#687080);border-radius:20px;background:var(--bg-primary,#171b24);color:var(--text-primary,#edf0f5);box-shadow:0 24px 80px #0007}.ov-ai-sheet::backdrop{background:#0008}.ov-ai-sheet header,.ov-ai-sheet footer{display:flex;gap:12px;align-items:center;padding:16px 20px}.ov-ai-sheet header h2{font-size:1.25rem;margin:0;flex:1}.ov-ai-sheet footer{flex-wrap:wrap;border-top:1px solid var(--border,#687080)}.ov-ai-sheet footer p{margin:0;width:100%}.ov-ai-sheet a{color:var(--accent,#87b8ff);min-height:44px;display:inline-flex;align-items:center}.ov-ai-options{overflow-y:auto;max-height:55dvh;padding:0 12px 16px;overscroll-behavior:contain}.ov-ai-options h3{font-size:.82rem;color:var(--text-secondary,#aab2c0);margin:20px 8px 8px}.ov-ai-option{display:flex;align-items:center;gap:12px;width:100%;min-height:60px;padding:10px 8px;border:0;border-radius:10px;color:inherit;background:transparent;text-align:left;font:inherit;cursor:pointer}.ov-ai-option:hover,.ov-ai-option[aria-pressed=true]{background:color-mix(in srgb,var(--accent,#87b8ff) 14%,transparent)}.ov-ai-option>span:nth-child(2){flex:1;min-width:0}.ov-ai-option strong,.ov-ai-option small{display:block;overflow-wrap:anywhere}.ov-ai-option small,.ov-ai-muted{font-size:13px;color:var(--text-secondary,#aab2c0)}.ov-ai-muted{padding:0 8px}.ov-ai-sheet button{min-height:44px}.ov-ai-sheet button:focus-visible{outline:2px solid var(--accent,#87b8ff);outline-offset:2px}.ov-ai-skeleton{height:320px;margin:20px;border-radius:12px;background:linear-gradient(180deg,transparent,var(--border,#687080),transparent);opacity:.3}.ov-aim{display:inline-grid;place-items:center;flex:none;width:24px;height:24px}.ov-aim svg{display:block}@media(max-width:600px){.ov-ai-sheet{width:100%;max-width:100%;margin:auto 0 0;max-height:90dvh;border-radius:20px 20px 0 0}.ov-ai-sheet footer{padding-bottom:max(16px,env(safe-area-inset-bottom))}}@media(prefers-color-scheme:light){:root:not([data-theme=dark]) .ov-ai-sheet{background:var(--bg-primary,#fff);color:var(--text-primary,#17202b)}}++++/* Opus's design pass (11-09): the header names the task, a segmented order over one list, the recommendation badged,++ kit buttons. */++.ov-ai-sheet header{align-items:flex-start}.ov-ai-sheet header>div{flex:1;min-width:0}.ov-ai-sub{margin:2px 0 0;color:var(--text-secondary,#aab2c0);font-size:13px}++.ov-ai-x{flex:none;display:inline-grid;place-items:center;width:40px;height:40px;min-height:40px;border:0;border-radius:50%;background:color-mix(in srgb,var(--text-primary,#edf0f5) 8%,transparent);color:inherit;font-size:22px;line-height:1;cursor:pointer}++.ov-ai-x:hover{background:color-mix(in srgb,var(--text-primary,#edf0f5) 14%,transparent)}++.ov-ai-order{display:flex;gap:2px;margin:0 20px 8px;padding:3px;border:1px solid var(--border,#687080);border-radius:999px;overflow-x:auto;scrollbar-width:none}++.ov-ai-order::-webkit-scrollbar{display:none}++.ov-ai-order button{flex:1 0 auto;min-height:36px;padding:0 12px;border:0;border-radius:999px;background:transparent;color:var(--text-secondary,#aab2c0);font:inherit;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap}++.ov-ai-order button[aria-pressed=true]{background:color-mix(in srgb,var(--accent,#87b8ff) 20%,transparent);color:var(--text-primary,#edf0f5)}++.ov-ai-rec{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--accent,#87b8ff) 18%,transparent);color:var(--text-primary,#edf0f5);font-size:11px;font-style:normal;font-weight:700;vertical-align:1px}++.ov-ai-option>span:last-child{flex:none;width:20px;text-align:center;color:var(--accent,#87b8ff);font-weight:800}++.ov-ai-sheet footer .ovk-btn{flex:none}++@media(max-width:420px){.ov-ai-order{margin:0 14px 8px}.ov-ai-order button{padding:0 8px;font-size:12.5px}}nnew file mode 100644@@@ -0,0 +1,92 @@++/* One task's model chip and choice sheet. Server rendering keeps the control named before hydration. */++(function (root, factory) {++ root.OpenVibeAiChoice = factory(); root.OpenVibeAiChoice.init();++}(typeof window === 'undefined' ? globalThis : window, function () {++ 'use strict';++ function init(scope) {++ (scope || document).querySelectorAll('[data-ai-choice]').forEach(host => {++ if (host.dataset.ready) return; host.dataset.ready = '1';++ const chip = host.querySelector('[data-ai-open]'), value = host.querySelector('[data-ai-value]');++ let selected = value.value ? JSON.parse(value.value) : null, data = null, dialog = null, oldOverflow = '';++ const same = (a,b) => a && b && a.model === b.model && a.source === b.source && a.machine === b.machine;++ function mark(model) {++ const family = /anthropic|claude/i.test(model) ? 'Anthropic' : /gpt|openai/i.test(model) ? 'OpenAI' : /gemini|google/i.test(model) ? 'Google' : /deepseek/i.test(model) ? 'DeepSeek' : /qwen/i.test(model) ? 'Qwen' : /llama|meta/i.test(model) ? 'Meta' : /mistral/i.test(model) ? 'Mistral' : /minimax/i.test(model) ? 'MiniMax' : /kimi|moonshot/i.test(model) ? 'Kimi' : /glm|zai/i.test(model) ? 'GLM' : 'Other';++ // The server render carries one template per maker. A page that placed the chip by hand has none: the++ // chip still names the model, with a neutral mark, rather than breaking on the first click.++ const template = host.querySelector(`template[data-maker="${family}"]`) || host.querySelector('template[data-maker="Other"]');++ if (template) return template.content.cloneNode(true);++ const span = document.createElement('span');++ span.className = 'ov-aim ov-aim--other';++ span.setAttribute('aria-hidden', 'true');++ return span;++ }++ function choose(row, picked = true) {++ selected = {model:row.model,source:row.source,...(row.machine ? {machine:row.machine}: {})};++ if (picked) value.value = JSON.stringify(selected);++ host.querySelector('[data-ai-label]').textContent = row.name || row.model;++ chip.querySelector('.ov-aim')?.remove(); chip.prepend(mark(row.model));++ if (picked) host.dispatchEvent(new CustomEvent('ai-choice', {bubbles:true,detail:selected}));++ }++ async function load() {++ const response = await fetch(`${host.dataset.endpoint}?task=${encodeURIComponent(host.dataset.task)}`, {credentials:'same-origin',headers:{Accept:'application/json'}});++ const body = await response.json(); if (!response.ok) throw new Error(body.detail || 'Models are unavailable. Try again.');++ data = body; const opts = data.options || {}; const known = [...(opts.openvibe || []), ...(opts.keys || []), ...(opts.machines || [])].find(r => same(r, selected || data.choice)); if (known) choose(known, false); else if (!selected && data.choice) choose(data.choice, false); return body;++ }++ function close() { if (!dialog) return; dialog.close(); dialog.remove(); dialog=null; document.documentElement.style.overflow=oldOverflow; chip.focus(); }++ function rows(parent, title, list, empty, rec) {++ const section=document.createElement('section'), h=document.createElement('h3');h.textContent=title;section.append(h);++ if (!list.length) {const p=document.createElement('p');p.className='ov-ai-muted';p.textContent=empty || 'No measured options yet.';section.append(p);}++ list.forEach(row=>{++ const b=document.createElement('button');b.type='button';b.className='ov-ai-option';b.setAttribute('aria-pressed',String(same(selected,row)));b.append(mark(row.maker || row.model));++ const text=document.createElement('span'), name=document.createElement('strong'), detail=document.createElement('small');name.textContent=row.name || row.model;++ detail.textContent=[row.price_estimate_usd == null ? 'Price not measured' : row.price_estimate_usd === 0 ? row.source === 'openvibe' ? 'Free' : 'Your capacity' : `$${row.price_estimate_usd.toFixed(4)} estimated per run`,row.speed_ms ? `${Math.round(row.speed_ms/1000)} s` : 'Speed not measured',row.machine_name].filter(Boolean).join(' · ');++ if(rec && same(rec,row)){const badge=document.createElement('em');badge.className='ov-ai-rec';badge.textContent='Recommended';name.append(' ',badge);}text.append(name,detail);b.append(text);const tick=document.createElement('span');tick.textContent=same(selected,row) ? '✓' : '';tick.setAttribute('aria-hidden','true');b.append(tick);++ b.addEventListener('click',()=>{choose(row);draw();});section.append(b);++ });parent.append(section);++ }++ // One list of the models, in the order the person picks (Recommended, Fastest, Cheapest, Best quality),++ // then their own keys and machines; the owner's recommendation carries a badge. Opus's design, 11-09: the++ // first version listed the same models four times over.++ let order = 'recommended';++ const ORDERS = [['recommended', 'Recommended'], ['fastest', 'Fastest'], ['cheapest', 'Cheapest'], ['quality', 'Best quality']];++ function ordered(list) {++ const known = (k, desc) => { const a = list.filter(x => x[k] != null).sort((x, y) => desc ? y[k] - x[k] : x[k] - y[k]); return a.concat(list.filter(x => x[k] == null)); };++ if (order === 'fastest') return known('speed_ms');++ if (order === 'cheapest') return known('price_estimate_usd');++ if (order === 'quality') return known('quality', true);++ const rec = list.find(x => same(x, data.recommended || data.choice));++ return rec ? [rec, ...list.filter(x => x !== rec)] : list;++ }++ function draw() {++ const scrollTop = dialog.querySelector('.ov-ai-options') ? dialog.querySelector('.ov-ai-options').scrollTop : 0;++ dialog.replaceChildren();++ const header = document.createElement('header'), titles = document.createElement('div'), title = document.createElement('h2'), sub = document.createElement('p'), x = document.createElement('button');++ title.textContent = 'Choose a model'; title.id = 'ov-ai-title';++ sub.textContent = `For ${host.dataset.taskLabel || 'this task'}`; sub.className = 'ov-ai-sub';++ titles.append(title, sub);++ x.type = 'button'; x.className = 'ov-ai-x'; x.setAttribute('aria-label', 'Close'); x.textContent = '×'; x.addEventListener('click', close);++ header.append(titles, x); dialog.append(header); dialog.setAttribute('aria-labelledby', title.id);++ const sorter = document.createElement('div'); sorter.className = 'ov-ai-order'; sorter.setAttribute('role', 'group'); sorter.setAttribute('aria-label', 'Order the models');++ ORDERS.forEach(([id, label]) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-pressed', String(order === id)); b.addEventListener('click', () => { order = id; draw(); }); sorter.append(b); });++ dialog.append(sorter);++ const content = document.createElement('div'); content.className = 'ov-ai-options'; dialog.append(content);++ const all = data.options.openvibe || [], standard = all.filter(x => !x.adult);++ rows(content, 'Models', ordered(standard), 'No models are available right now.', data.recommended || data.choice);++ rows(content, 'Your keys', data.options.keys || [], 'Add your own provider key on Exchange.');++ rows(content, 'Your machines', data.options.machines || [], 'Connect a computer with OpenVibe Link.');++ if (data.adult) rows(content, 'Uncensored 18+', all.filter(x => x.adult));++ content.scrollTop = scrollTop;++ const footer=document.createElement('footer'), save=document.createElement('button'), manage=document.createElement('a'), status=document.createElement('p');status.setAttribute('role','status');save.type='button';save.className='ovk-btn ovk-btn--primary';save.textContent=`Use this for every ${host.dataset.taskLabel}`;save.disabled=!selected;manage.href='https://openvibe.exchange/console';manage.textContent='Manage on Exchange';++ save.addEventListener('click',async()=>{save.disabled=true;try {const r=await fetch(`${host.dataset.saveEndpoint}/${encodeURIComponent(host.dataset.task)}`,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify(selected)});if(!r.ok) {const e=await r.json();throw new Error(e.detail || 'Sign in on Exchange to save your choice.');}status.textContent='Saved for this task.';}catch(e){status.textContent=e.message;}finally{save.disabled=false;}});++ footer.append(save,manage,status);dialog.append(footer);++ }++ chip.addEventListener('click',async()=>{++ if(dialog) return;dialog=document.createElement('dialog');dialog.className='ov-ai-sheet';dialog.innerHTML='<div class="ov-ai-skeleton" role="status" aria-label="Loading models"></div>';document.body.append(dialog);oldOverflow=document.documentElement.style.overflow;document.documentElement.style.overflow='hidden';dialog.showModal();++ dialog.addEventListener('cancel',e=>{e.preventDefault();close();});dialog.addEventListener('click',e=>{if(e.target===dialog){const r=dialog.getBoundingClientRect();if(e.clientX<r.left || e.clientX>r.right || e.clientY<r.top || e.clientY>r.bottom) close();}});++ try {await load();if(dialog) draw();}catch(e){if(dialog){dialog.textContent=e.message;const b=document.createElement('button');b.type='button';b.textContent='Close';b.className='ovk-btn ovk-btn--quiet';b.addEventListener('click',close);dialog.append(b);}}++ });++ load().catch(()=>{});++ });++ }++ return {init};++}));@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.175.0",++ "version": "4.176.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.175.0",++ "version": "4.176.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.175.0",++ "version": "4.176.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -126,6 +126,8 @@"./thread": "./thread.js","./messages": "./messages.js","./ov-thread": "./ov-thread.js",++ "./ov-ai-choice": "./ai-choice.js",++ "./ov-ai-choice.js": "./ov-ai-choice.js","./ov-wallet": "./ov-wallet.js","./sw": "./sw.js"},nnew file mode 100644@@@ -0,0 +1,8 @@++'use strict';++const assert=require('assert');++const {render}=require('../ai-choice');++const html=render({task:'design.icon',label:'icon',choice:{model:'anthropic/claude-opus-5-5',source:'openvibe'}});++assert(html.includes('aria-haspopup="dialog"'));assert(html.includes('ov-aim--anthropic'));assert(html.includes('name="choice"'));++assert(!render({task:'<script>alert(1)</script>',label:'" onload="x'}).includes('<script>'));++const files=require('../files');assert(files.isBrowserFile('ov-ai-choice.js'));assert(files.isBrowserFile('ov-ai-choice.css'));++console.log('AI choice renders safe, named controls and uses the shared maker marks');
@@ -1,5 +1,15 @@
@@ -1,5 +1,15 @@
# Changelog
# Changelog
+## 4.176.0 — 2026-11-09
+
+- **The model chip and its choice sheet** (`ai-choice.js`, `ov-ai-choice.js`, `ov-ai-choice.css`): a chip beside any
+ Generate button showing the maker mark and the model's name, opening a sheet with one list of the models in the
+ order the person picks (Recommended, Fastest, Cheapest, Best quality; the owner's recommendation badged), then Your
+ keys, Your machines and Uncensored 18+ (only where the person confirmed). Each row says what it costs a run and how
+ fast it is; a check marks the choice. The footer saves "Use this for every <task>" and links to
+ Exchange. Server rendering keeps the control named before hydration; the sheet sets no scroll behind it and an
+ outside tap closes it.
+
## 4.175.0 — 2026-11-09
## 4.175.0 — 2026-11-09
- **OpenVibe Link**: the shared navigation's Machines entry points at openvibe.link/machines instead of the old
- **OpenVibe Link**: the shared navigation's Machines entry points at openvibe.link/machines instead of the old
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.175.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.176.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.175.0",
+ "version": "4.176.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+'use strict';
+const marks = require('./ai-marks');
+ const esc = value => String(value == null ? '' : value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ function render({ task, label = task, endpoint = '/api/ai/choices', saveEndpoint = '/api/ai/choices', choice = null, name = 'choice' } = {}) {
+ const templates = marks.FAMILIES.map(m => `<template data-maker="${esc(m)}">${marks.mark(m, { label: false })}</template>`).join('');
+ return `<span class="ov-ai-choice" data-ai-choice data-task="${esc(task)}" data-task-label="${esc(label)}" data-endpoint="${esc(endpoint)}" data-save-endpoint="${esc(saveEndpoint)}"><button class="ovk-btn ovk-btn--quiet ov-ai-chip" type="button" aria-haspopup="dialog" data-ai-open>${marks.mark(choice?.model || 'OpenVibe', { label: false })}<span data-ai-label>${esc(choice?.model || 'Choose model')}</span><span aria-hidden="true">▾</span></button><input type="hidden" name="${esc(name)}" value="${choice ? esc(JSON.stringify(choice)) : ''}" data-ai-value>${templates}<template data-maker="Other">${marks.mark('Other', {label:false})}</template></span>`;
+ }
+
+module.exports = { render };
@@ -13,6 +13,7 @@
@@ -13,6 +13,7 @@
const path = require('path');
const path = require('path');
const BROWSER = Object.freeze([
const BROWSER = Object.freeze([
+ 'ov-ai-choice.js', // the model chip and its choice sheet (openvibe-shared/ov-ai-choice)
'navbar.js', // shared top bar (eager on every site)
'navbar.js', // shared top bar (eager on every site)
'nav-icons.js', // navbar glyphs beyond its own, fetched by navbar.js after first paint
'nav-icons.js', // navbar glyphs beyond its own, fetched by navbar.js after first paint
'theme-loader.js', // applies the theme before first paint
'theme-loader.js', // applies the theme before first paint
@@ -94,6 +95,7 @@ const STYLES = Object.freeze([
@@ -94,6 +95,7 @@ const STYLES = Object.freeze([
'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
'ov-forms.css', // the look of ov-forms.js's select, the site-coloured checkboxes, switches and sliders, .ov-field (docs/forms.md)
'ov-locker.css', // ov-locker.js's look: the locked card, the locker sheets, the recovery kit (docs/locker.md)
'ov-locker.css', // ov-locker.js's look: the locked card, the locker sheets, the recovery kit (docs/locker.md)
'ov-wallet.css',
'ov-wallet.css',
+ 'ov-ai-choice.css', // the model chip and its choice sheet (ov-ai-choice.js)
'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)
'ov-share.css', // ov-share.js's look: the sheet, the recipient's offer banner, the not-on-the-list gate (docs/share.md)
'ov-upload.css', // picker, queue and ov-progress states
'ov-upload.css', // picker, queue and ov-progress states
'messages.css', // the room and bubble views' look
'messages.css', // the room and bubble views' look
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+.ov-ai-choice{display:inline-flex;max-width:100%;vertical-align:middle}.ov-ai-chip{display:inline-flex;align-items:center;gap:.5rem;min-height:44px;max-width:100%}.ov-ai-chip [data-ai-label]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ov-ai-sheet{box-sizing:border-box;width:min(560px,calc(100vw - 32px));max-height:85dvh;padding:0;border:1px solid var(--border,#687080);border-radius:20px;background:var(--bg-primary,#171b24);color:var(--text-primary,#edf0f5);box-shadow:0 24px 80px #0007}.ov-ai-sheet::backdrop{background:#0008}.ov-ai-sheet header,.ov-ai-sheet footer{display:flex;gap:12px;align-items:center;padding:16px 20px}.ov-ai-sheet header h2{font-size:1.25rem;margin:0;flex:1}.ov-ai-sheet footer{flex-wrap:wrap;border-top:1px solid var(--border,#687080)}.ov-ai-sheet footer p{margin:0;width:100%}.ov-ai-sheet a{color:var(--accent,#87b8ff);min-height:44px;display:inline-flex;align-items:center}.ov-ai-options{overflow-y:auto;max-height:55dvh;padding:0 12px 16px;overscroll-behavior:contain}.ov-ai-options h3{font-size:.82rem;color:var(--text-secondary,#aab2c0);margin:20px 8px 8px}.ov-ai-option{display:flex;align-items:center;gap:12px;width:100%;min-height:60px;padding:10px 8px;border:0;border-radius:10px;color:inherit;background:transparent;text-align:left;font:inherit;cursor:pointer}.ov-ai-option:hover,.ov-ai-option[aria-pressed=true]{background:color-mix(in srgb,var(--accent,#87b8ff) 14%,transparent)}.ov-ai-option>span:nth-child(2){flex:1;min-width:0}.ov-ai-option strong,.ov-ai-option small{display:block;overflow-wrap:anywhere}.ov-ai-option small,.ov-ai-muted{font-size:13px;color:var(--text-secondary,#aab2c0)}.ov-ai-muted{padding:0 8px}.ov-ai-sheet button{min-height:44px}.ov-ai-sheet button:focus-visible{outline:2px solid var(--accent,#87b8ff);outline-offset:2px}.ov-ai-skeleton{height:320px;margin:20px;border-radius:12px;background:linear-gradient(180deg,transparent,var(--border,#687080),transparent);opacity:.3}.ov-aim{display:inline-grid;place-items:center;flex:none;width:24px;height:24px}.ov-aim svg{display:block}@media(max-width:600px){.ov-ai-sheet{width:100%;max-width:100%;margin:auto 0 0;max-height:90dvh;border-radius:20px 20px 0 0}.ov-ai-sheet footer{padding-bottom:max(16px,env(safe-area-inset-bottom))}}@media(prefers-color-scheme:light){:root:not([data-theme=dark]) .ov-ai-sheet{background:var(--bg-primary,#fff);color:var(--text-primary,#17202b)}}
+
+/* Opus's design pass (11-09): the header names the task, a segmented order over one list, the recommendation badged,
+ kit buttons. */
+.ov-ai-sheet header{align-items:flex-start}.ov-ai-sheet header>div{flex:1;min-width:0}.ov-ai-sub{margin:2px 0 0;color:var(--text-secondary,#aab2c0);font-size:13px}
+.ov-ai-x{flex:none;display:inline-grid;place-items:center;width:40px;height:40px;min-height:40px;border:0;border-radius:50%;background:color-mix(in srgb,var(--text-primary,#edf0f5) 8%,transparent);color:inherit;font-size:22px;line-height:1;cursor:pointer}
+.ov-ai-x:hover{background:color-mix(in srgb,var(--text-primary,#edf0f5) 14%,transparent)}
+.ov-ai-order{display:flex;gap:2px;margin:0 20px 8px;padding:3px;border:1px solid var(--border,#687080);border-radius:999px;overflow-x:auto;scrollbar-width:none}
+.ov-ai-order::-webkit-scrollbar{display:none}
+.ov-ai-order button{flex:1 0 auto;min-height:36px;padding:0 12px;border:0;border-radius:999px;background:transparent;color:var(--text-secondary,#aab2c0);font:inherit;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap}
+.ov-ai-order button[aria-pressed=true]{background:color-mix(in srgb,var(--accent,#87b8ff) 20%,transparent);color:var(--text-primary,#edf0f5)}
+.ov-ai-rec{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;background:color-mix(in srgb,var(--accent,#87b8ff) 18%,transparent);color:var(--text-primary,#edf0f5);font-size:11px;font-style:normal;font-weight:700;vertical-align:1px}
+.ov-ai-option>span:last-child{flex:none;width:20px;text-align:center;color:var(--accent,#87b8ff);font-weight:800}
+.ov-ai-sheet footer .ovk-btn{flex:none}
+@media(max-width:420px){.ov-ai-order{margin:0 14px 8px}.ov-ai-order button{padding:0 8px;font-size:12.5px}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+/* One task's model chip and choice sheet. Server rendering keeps the control named before hydration. */
+(function (root, factory) {
+ root.OpenVibeAiChoice = factory(); root.OpenVibeAiChoice.init();
+}(typeof window === 'undefined' ? globalThis : window, function () {
+ 'use strict';
+ function init(scope) {
+ (scope || document).querySelectorAll('[data-ai-choice]').forEach(host => {
+ if (host.dataset.ready) return; host.dataset.ready = '1';
+ const chip = host.querySelector('[data-ai-open]'), value = host.querySelector('[data-ai-value]');
+ let selected = value.value ? JSON.parse(value.value) : null, data = null, dialog = null, oldOverflow = '';
+ const same = (a,b) => a && b && a.model === b.model && a.source === b.source && a.machine === b.machine;
+ function mark(model) {
+ const family = /anthropic|claude/i.test(model) ? 'Anthropic' : /gpt|openai/i.test(model) ? 'OpenAI' : /gemini|google/i.test(model) ? 'Google' : /deepseek/i.test(model) ? 'DeepSeek' : /qwen/i.test(model) ? 'Qwen' : /llama|meta/i.test(model) ? 'Meta' : /mistral/i.test(model) ? 'Mistral' : /minimax/i.test(model) ? 'MiniMax' : /kimi|moonshot/i.test(model) ? 'Kimi' : /glm|zai/i.test(model) ? 'GLM' : 'Other';
+ // The server render carries one template per maker. A page that placed the chip by hand has none: the
+ // chip still names the model, with a neutral mark, rather than breaking on the first click.
+ const template = host.querySelector(`template[data-maker="${family}"]`) || host.querySelector('template[data-maker="Other"]');
+ if (template) return template.content.cloneNode(true);
+ const span = document.createElement('span');
+ span.className = 'ov-aim ov-aim--other';
+ span.setAttribute('aria-hidden', 'true');
+ return span;
+ }
+ function choose(row, picked = true) {
+ selected = {model:row.model,source:row.source,...(row.machine ? {machine:row.machine}: {})};
+ if (picked) value.value = JSON.stringify(selected);
+ host.querySelector('[data-ai-label]').textContent = row.name || row.model;
+ chip.querySelector('.ov-aim')?.remove(); chip.prepend(mark(row.model));
+ if (picked) host.dispatchEvent(new CustomEvent('ai-choice', {bubbles:true,detail:selected}));
+ }
+ async function load() {
+ const response = await fetch(`${host.dataset.endpoint}?task=${encodeURIComponent(host.dataset.task)}`, {credentials:'same-origin',headers:{Accept:'application/json'}});
+ const body = await response.json(); if (!response.ok) throw new Error(body.detail || 'Models are unavailable. Try again.');
+ data = body; const opts = data.options || {}; const known = [...(opts.openvibe || []), ...(opts.keys || []), ...(opts.machines || [])].find(r => same(r, selected || data.choice)); if (known) choose(known, false); else if (!selected && data.choice) choose(data.choice, false); return body;
+ }
+ function close() { if (!dialog) return; dialog.close(); dialog.remove(); dialog=null; document.documentElement.style.overflow=oldOverflow; chip.focus(); }
+ function rows(parent, title, list, empty, rec) {
+ const section=document.createElement('section'), h=document.createElement('h3');h.textContent=title;section.append(h);
+ if (!list.length) {const p=document.createElement('p');p.className='ov-ai-muted';p.textContent=empty || 'No measured options yet.';section.append(p);}
+ list.forEach(row=>{
+ const b=document.createElement('button');b.type='button';b.className='ov-ai-option';b.setAttribute('aria-pressed',String(same(selected,row)));b.append(mark(row.maker || row.model));
+ const text=document.createElement('span'), name=document.createElement('strong'), detail=document.createElement('small');name.textContent=row.name || row.model;
+ detail.textContent=[row.price_estimate_usd == null ? 'Price not measured' : row.price_estimate_usd === 0 ? row.source === 'openvibe' ? 'Free' : 'Your capacity' : `$${row.price_estimate_usd.toFixed(4)} estimated per run`,row.speed_ms ? `${Math.round(row.speed_ms/1000)} s` : 'Speed not measured',row.machine_name].filter(Boolean).join(' · ');
+ if(rec && same(rec,row)){const badge=document.createElement('em');badge.className='ov-ai-rec';badge.textContent='Recommended';name.append(' ',badge);}text.append(name,detail);b.append(text);const tick=document.createElement('span');tick.textContent=same(selected,row) ? '✓' : '';tick.setAttribute('aria-hidden','true');b.append(tick);
+ b.addEventListener('click',()=>{choose(row);draw();});section.append(b);
+ });parent.append(section);
+ }
+ // One list of the models, in the order the person picks (Recommended, Fastest, Cheapest, Best quality),
+ // then their own keys and machines; the owner's recommendation carries a badge. Opus's design, 11-09: the
+ // first version listed the same models four times over.
+ let order = 'recommended';
+ const ORDERS = [['recommended', 'Recommended'], ['fastest', 'Fastest'], ['cheapest', 'Cheapest'], ['quality', 'Best quality']];
+ function ordered(list) {
+ const known = (k, desc) => { const a = list.filter(x => x[k] != null).sort((x, y) => desc ? y[k] - x[k] : x[k] - y[k]); return a.concat(list.filter(x => x[k] == null)); };
+ if (order === 'fastest') return known('speed_ms');
+ if (order === 'cheapest') return known('price_estimate_usd');
+ if (order === 'quality') return known('quality', true);
+ const rec = list.find(x => same(x, data.recommended || data.choice));
+ return rec ? [rec, ...list.filter(x => x !== rec)] : list;
+ }
+ function draw() {
+ const scrollTop = dialog.querySelector('.ov-ai-options') ? dialog.querySelector('.ov-ai-options').scrollTop : 0;
+ dialog.replaceChildren();
+ const header = document.createElement('header'), titles = document.createElement('div'), title = document.createElement('h2'), sub = document.createElement('p'), x = document.createElement('button');
+ title.textContent = 'Choose a model'; title.id = 'ov-ai-title';
+ sub.textContent = `For ${host.dataset.taskLabel || 'this task'}`; sub.className = 'ov-ai-sub';
+ titles.append(title, sub);
+ x.type = 'button'; x.className = 'ov-ai-x'; x.setAttribute('aria-label', 'Close'); x.textContent = '×'; x.addEventListener('click', close);
+ header.append(titles, x); dialog.append(header); dialog.setAttribute('aria-labelledby', title.id);
+ const sorter = document.createElement('div'); sorter.className = 'ov-ai-order'; sorter.setAttribute('role', 'group'); sorter.setAttribute('aria-label', 'Order the models');
+ ORDERS.forEach(([id, label]) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.setAttribute('aria-pressed', String(order === id)); b.addEventListener('click', () => { order = id; draw(); }); sorter.append(b); });
+ dialog.append(sorter);
+ const content = document.createElement('div'); content.className = 'ov-ai-options'; dialog.append(content);
+ const all = data.options.openvibe || [], standard = all.filter(x => !x.adult);
+ rows(content, 'Models', ordered(standard), 'No models are available right now.', data.recommended || data.choice);
+ rows(content, 'Your keys', data.options.keys || [], 'Add your own provider key on Exchange.');
+ rows(content, 'Your machines', data.options.machines || [], 'Connect a computer with OpenVibe Link.');
+ if (data.adult) rows(content, 'Uncensored 18+', all.filter(x => x.adult));
+ content.scrollTop = scrollTop;
+ const footer=document.createElement('footer'), save=document.createElement('button'), manage=document.createElement('a'), status=document.createElement('p');status.setAttribute('role','status');save.type='button';save.className='ovk-btn ovk-btn--primary';save.textContent=`Use this for every ${host.dataset.taskLabel}`;save.disabled=!selected;manage.href='https://openvibe.exchange/console';manage.textContent='Manage on Exchange';
+ save.addEventListener('click',async()=>{save.disabled=true;try {const r=await fetch(`${host.dataset.saveEndpoint}/${encodeURIComponent(host.dataset.task)}`,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify(selected)});if(!r.ok) {const e=await r.json();throw new Error(e.detail || 'Sign in on Exchange to save your choice.');}status.textContent='Saved for this task.';}catch(e){status.textContent=e.message;}finally{save.disabled=false;}});
+ footer.append(save,manage,status);dialog.append(footer);
+ }
+ chip.addEventListener('click',async()=>{
+ if(dialog) return;dialog=document.createElement('dialog');dialog.className='ov-ai-sheet';dialog.innerHTML='<div class="ov-ai-skeleton" role="status" aria-label="Loading models"></div>';document.body.append(dialog);oldOverflow=document.documentElement.style.overflow;document.documentElement.style.overflow='hidden';dialog.showModal();
+ dialog.addEventListener('cancel',e=>{e.preventDefault();close();});dialog.addEventListener('click',e=>{if(e.target===dialog){const r=dialog.getBoundingClientRect();if(e.clientX<r.left || e.clientX>r.right || e.clientY<r.top || e.clientY>r.bottom) close();}});
+ try {await load();if(dialog) draw();}catch(e){if(dialog){dialog.textContent=e.message;const b=document.createElement('button');b.type='button';b.textContent='Close';b.className='ovk-btn ovk-btn--quiet';b.addEventListener('click',close);dialog.append(b);}}
+ });
+ load().catch(()=>{});
+ });
+ }
+ return {init};
+}));
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.175.0",
+ "version": "4.176.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.175.0",
+ "version": "4.176.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.175.0",
+ "version": "4.176.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -126,6 +126,8 @@
@@ -126,6 +126,8 @@
"./thread": "./thread.js",
"./thread": "./thread.js",
"./messages": "./messages.js",
"./messages": "./messages.js",
"./ov-thread": "./ov-thread.js",
"./ov-thread": "./ov-thread.js",
+ "./ov-ai-choice": "./ai-choice.js",
+ "./ov-ai-choice.js": "./ov-ai-choice.js",
"./ov-wallet": "./ov-wallet.js",
"./ov-wallet": "./ov-wallet.js",
"./sw": "./sw.js"
"./sw": "./sw.js"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+'use strict';
+const assert=require('assert');
+const {render}=require('../ai-choice');
+const html=render({task:'design.icon',label:'icon',choice:{model:'anthropic/claude-opus-5-5',source:'openvibe'}});
+assert(html.includes('aria-haspopup="dialog"'));assert(html.includes('ov-aim--anthropic'));assert(html.includes('name="choice"'));
+assert(!render({task:'<script>alert(1)</script>',label:'" onload="x'}).includes('<script>'));
+const files=require('../files');assert(files.isBrowserFile('ov-ai-choice.js'));assert(files.isBrowserFile('ov-ai-choice.css'));
+console.log('AI choice renders safe, named controls and uses the shared maker marks');