openvibers/openvibe.shared ov-ai-choice.js

ov-ai-choice.js

main 10.1 KB · 93 lines · javascript

1/* One task's model chip and choice sheet. Server rendering keeps the control named before hydration. */
2(function (root, factory) {
3 root.OpenVibeAiChoice = factory(); root.OpenVibeAiChoice.init();
4}(typeof window === 'undefined' ? globalThis : window, function () {
5 'use strict';
6 function init(scope) {
7 (scope || document).querySelectorAll('[data-ai-choice]').forEach(host => {
8 if (host.dataset.ready) return; host.dataset.ready = '1';
9 const chip = host.querySelector('[data-ai-open]'), value = host.querySelector('[data-ai-value]');
10 let selected = value.value ? JSON.parse(value.value) : null, data = null, dialog = null, oldOverflow = '';
11 const same = (a,b) => a && b && a.model === b.model && a.source === b.source && a.machine === b.machine;
12 function mark(model) {
13 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';
14 // The server render carries one template per maker. A page that placed the chip by hand has none: the
15 // chip still names the model, with a neutral mark, rather than breaking on the first click.
16 const template = host.querySelector(`template[data-maker="${family}"]`) || host.querySelector('template[data-maker="Other"]');
17 if (template) return template.content.cloneNode(true);
18 const span = document.createElement('span');
19 span.className = 'ov-aim ov-aim--other';
20 span.setAttribute('aria-hidden', 'true');
21 return span;
22 }
23 function choose(row, picked = true) {
24 selected = {model:row.model,source:row.source,...(row.machine ? {machine:row.machine}: {})};
25 if (picked) value.value = JSON.stringify(selected);
26 host.querySelector('[data-ai-label]').textContent = row.name || row.model;
27 chip.querySelector('.ov-aim')?.remove(); chip.prepend(mark(row.model));
28 if (picked) host.dispatchEvent(new CustomEvent('ai-choice', {bubbles:true,detail:selected}));
29 }
30 async function load() {
31 const response = await fetch(`${host.dataset.endpoint}?task=${encodeURIComponent(host.dataset.task)}`, {credentials:'same-origin',headers:{Accept:'application/json'}});
32 const body = await response.json(); if (!response.ok) throw new Error(body.detail || 'Models are unavailable. Try again.');
33 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;
34 }
35 function close() { if (!dialog) return; dialog.close(); dialog.remove(); dialog=null; document.documentElement.style.overflow=oldOverflow; chip.focus(); }
36 function rows(parent, title, list, empty, rec) {
37 const section=document.createElement('section'), h=document.createElement('h3');h.textContent=title;section.append(h);
38 if (!list.length) {const p=document.createElement('p');p.className='ov-ai-muted';p.textContent=empty || 'No measured options yet.';section.append(p);}
39 list.forEach(row=>{
40 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));
41 const text=document.createElement('span'), name=document.createElement('strong'), detail=document.createElement('small');name.textContent=row.name || row.model;
42 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(' · ');
43 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);
44 b.addEventListener('click',()=>{choose(row);draw();});section.append(b);
45 });parent.append(section);
46 }
47 // One list of the models, in the order the person picks (Recommended, Fastest, Cheapest, Best quality),
48 // then their own keys and machines; the owner's recommendation carries a badge. Opus's design, 11-09: the
49 // first version listed the same models four times over.
50 let order = 'recommended';
51 const ORDERS = [['recommended', 'Recommended'], ['fastest', 'Fastest'], ['cheapest', 'Cheapest'], ['quality', 'Best quality']];
52 function ordered(list) {
53 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)); };
54 if (order === 'fastest') return known('speed_ms');
55 if (order === 'cheapest') return known('price_estimate_usd');
56 if (order === 'quality') return known('quality', true);
57 const rec = list.find(x => same(x, data.recommended || data.choice));
58 return rec ? [rec, ...list.filter(x => x !== rec)] : list;
59 }
60 function draw() {
61 const scrollTop = dialog.querySelector('.ov-ai-options') ? dialog.querySelector('.ov-ai-options').scrollTop : 0;
62 dialog.replaceChildren();
63 const header = document.createElement('header'), titles = document.createElement('div'), title = document.createElement('h2'), sub = document.createElement('p'), x = document.createElement('button');
64 title.textContent = 'Choose a model'; title.id = 'ov-ai-title';
65 sub.textContent = `For ${host.dataset.taskLabel || 'this task'}`; sub.className = 'ov-ai-sub';
66 titles.append(title, sub);
67 x.type = 'button'; x.className = 'ov-ai-x'; x.setAttribute('aria-label', 'Close'); x.textContent = '×'; x.addEventListener('click', close);
68 header.append(titles, x); dialog.append(header); dialog.setAttribute('aria-labelledby', title.id);
69 const sorter = document.createElement('div'); sorter.className = 'ov-ai-order'; sorter.setAttribute('role', 'group'); sorter.setAttribute('aria-label', 'Order the models');
70 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); });
71 dialog.append(sorter);
72 const content = document.createElement('div'); content.className = 'ov-ai-options'; dialog.append(content);
73 const all = data.options.openvibe || [], standard = all.filter(x => !x.adult);
74 rows(content, 'Models', ordered(standard), 'No models are available right now.', data.recommended || data.choice);
75 rows(content, 'Your keys', data.options.keys || [], 'Add your own provider key on Exchange.');
76 rows(content, 'Your machines', data.options.machines || [], 'Connect a computer with OpenVibe Link.');
77 if (data.adult) rows(content, 'Uncensored 18+', all.filter(x => x.adult));
78 content.scrollTop = scrollTop;
79 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';
80 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;}});
81 footer.append(save,manage,status);dialog.append(footer);
82 }
83 chip.addEventListener('click',async()=>{
84 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();
85 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();}});
86 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);}}
87 });
88 load().catch(()=>{});
89 });
90 }
91 return {init};
92}));
93