openvibers/openvibe.shared ov-ai-choice.js
ov-ai-choice.js
| 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 | ; |
| 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 |