openvibers/openvibe.actor Commits

Now: the model row is designed (one row, grouped choices, real names) (#401)

OP OpenVibers committed 3 days ago · c367ed0540 · 3 files, +27 −1

The row a run offers when model switching is on was plain markup. It is one row inside Now: the label says what it
changes, the button is one word (Switch), the picker takes the width and both are 46 px tall on a phone. The choices
are grouped by who runs them (On OpenVibe, Your own keys, Your own machines) and named as the chat model menu names
them ("Your OpenAI key", "Your computer") instead of raw provider ids ("Your openai").

Changes

ddiff --git a/public/css/you.css b/public/css/you.css
iindex 4946e20..f4d5f80 100644
---- a/public/css/you.css
++++ b/public/css/you.css
@@@ -1 +1,9 @@
.ay-page{max-width:1120px;margin:28px auto 80px;padding:0 20px;color:var(--text-primary);overflow-wrap:anywhere}.ay-page *{box-sizing:border-box}.ay-page h2{font-size:1.25rem;margin:0}.ay-page h3{font-size:1rem}.ay-header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:12px 0 24px;border-bottom:1px solid var(--border-light)}.ay-avatar{width:52px;height:52px;border-radius:18px;display:grid;place-items:center;background:var(--bg-secondary);font-size:24px;color:var(--accent-light);overflow:hidden}.ay-avatar img{width:100%;height:100%;object-fit:cover}.ay-name{flex:1;min-width:120px}.ay-eyebrow{display:block;color:var(--text-secondary);font-size:12px;letter-spacing:.12em}.ay-name-button{display:block;padding:0;border:0;background:none;color:inherit;font-size:1.6rem;font-weight:700;text-align:left;min-height:44px;cursor:pointer}.ay-chips{display:flex;gap:8px;flex-wrap:wrap}.ay-state{display:flex;align-items:center;gap:7px;min-height:32px}.ay-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px}.ay-card{min-width:0;margin:0;padding:22px;border:1px solid var(--border-light);border-radius:18px;background:var(--bg-card,var(--bg-secondary))}.ay-section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}#ay-now,#ay-asks,#ay-tell,#ay-today,#ay-goals{grid-column:1/-1}.ay-current{font-size:1.2rem;line-height:1.6;margin:6px 0 14px}.ay-pulse{display:inline-block;width:8px;height:8px;border-radius:50%;background:#4cae88;margin-right:8px;animation:ay-pulse 2s ease-in-out infinite}@keyframes ay-pulse{50%{opacity:.35}}@media(prefers-reduced-motion:reduce){.ay-pulse{animation:none}}.ay-looking{display:flex;gap:8px;align-items:center;color:var(--text-secondary)}.ay-looking svg{width:20px;flex:none}.ay-steps,.ay-journal{list-style:none;margin:0;padding:0}.ay-steps li{display:flex;gap:14px;margin:10px 0}.ay-page time,.ay-muted,.ay-next,.ay-page small{color:var(--text-secondary)}.ay-page time{font-size:12px;white-space:nowrap}.ay-spend{display:flex;align-items:center;gap:20px;margin-top:20px;font-size:13px}.ay-spend progress{flex:1;height:5px;min-width:40px;accent-color:var(--accent)}.ay-ask{padding:16px 0;border-top:1px solid var(--border-light)}.ay-ask:first-of-type{border:0;padding-top:0}.ay-ask blockquote{margin:12px 0;padding:12px;border-left:3px solid var(--accent);background:var(--bg-primary);white-space:pre-wrap}.ay-actions{display:flex;gap:8px;flex-wrap:wrap}.ay-page .ovk-btn,.ay-page input:not([type=checkbox]):not([type=hidden]),.ay-page select{min-height:44px}.ay-page .ovk-btn{white-space:normal}.ay-page label{display:grid;gap:7px;font-size:13px;margin:10px 0}.ay-page input:not([type=checkbox]):not([type=hidden]),.ay-page select,.ay-page textarea{width:100%;max-width:100%;padding:10px 12px;border:1px solid var(--border-light);border-radius:10px;background:var(--bg-primary);color:inherit;font:inherit}.ay-page textarea{min-height:90px;resize:vertical}.ay-page .ay-check{display:flex;align-items:center;gap:10px;min-height:44px}.ay-page input[type=checkbox]{width:20px;height:20px;accent-color:var(--accent);flex:none}.ay-page summary{min-height:44px;display:flex;align-items:center;gap:8px;cursor:pointer}.ay-thread{display:block;min-height:0}.ay-thread .ovt-composer{position:static;margin:0;background:var(--bg-primary)}.ay-thread textarea{min-height:60px}.ay-thread .ovt-attach,.ay-thread .ovt-send{width:44px;height:44px;min-height:44px}.ay-thread .ovt-turn{margin-top:18px}.ay-journal li{display:grid;grid-template-columns:42px minmax(0,1fr) auto;gap:10px;margin:18px 0}.ay-journal p{margin:0;line-height:1.5}.ay-journal img{max-width:110px;max-height:70px;object-fit:cover;border-radius:7px;margin-bottom:7px}.ay-goal-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-bottom:18px}.ay-goal{padding:18px;border-radius:12px;border:1px solid var(--border-light);background:var(--bg-primary)}.ay-goal header,.ay-goal footer{display:flex;gap:8px;align-items:center}.ay-goal header>svg{width:18px}.ay-goal footer{justify-content:space-between}.ay-goal .ay-switch{margin-left:auto;display:flex;align-items:center;min-height:44px}.ay-goal h3{font-size:1.1rem}.ay-goal p{font-size:13px;line-height:1.6}.ay-money-total{display:flex;gap:25px;flex-wrap:wrap}.ay-money-total span{display:block;font-size:12px;color:var(--text-secondary)}.ay-money-total strong{font-size:1.7rem}.ay-week{display:grid;grid-template-columns:minmax(0,1fr) minmax(50px,1fr) auto;gap:10px;align-items:center;margin:12px 0;font-size:12px}.ay-week meter{width:100%}.ay-page dialog{color:var(--text-primary)}.ay-page .ay-model{width:100%;display:flex;justify-content:space-between;text-align:left;flex-wrap:wrap;margin:7px 0}.ay-model small{font-weight:400}.ay-lanes{display:grid;gap:10px}.ay-lane{display:flex;align-items:center;gap:14px;width:100%;padding:16px!important}.ay-lane svg{width:24px}.ay-days{display:flex;flex-wrap:wrap;gap:6px}.ay-days label{display:flex;align-items:center;gap:4px;min-height:44px}.ay-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 14px}.ay-page fieldset{border:1px solid var(--border-light);border-radius:10px}.ay-screen{position:relative;aspect-ratio:16/9;overflow:hidden;border-radius:12px;background:var(--bg-primary);margin-bottom:12px}.ay-screen img{width:100%;height:100%;object-fit:contain;display:block}.ay-screen[data-control=true] img{cursor:crosshair;touch-action:none}.ay-screen:fullscreen{border-radius:0}.ay-screen-note{position:absolute;bottom:6px;left:10px;font-size:12px}.ay-error{color:var(--danger,#d45f63)}#ay-notice:empty{display:none}#ay-notice{padding:12px;border:1px solid var(--border-light);border-radius:10px}.ay-page [hidden]{display:none!important}.ay-page a{color:var(--accent-light,var(--accent))}#ay-sites li{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:10px 0}#ay-sites ul{padding:0;list-style:none}@media(max-width:680px){.ay-page{padding:0 14px;margin-top:16px}.ay-layout{grid-template-columns:minmax(0,1fr);gap:14px}.ay-card{padding:18px}.ay-header{gap:8px}.ay-chips{width:100%}.ay-goal-grid{grid-template-columns:minmax(0,1fr)}.ay-spend{gap:12px}.ay-journal li{grid-template-columns:35px minmax(0,1fr) auto;gap:7px}.ay-name-button{font-size:1.35rem}.ay-screen{margin-inline:-4px}.ay-week{grid-template-columns:minmax(0,1fr) 60px auto}}
++/* Now: the model for the next step. One row (the picker takes the width, the button stays one word); a quiet inset so it
++ reads as a control of the run above it, not as another card. */
++.ay-run-model{display:grid;gap:6px;margin:0 0 14px;padding:12px;border:1px solid var(--border);border-radius:12px;background:color-mix(in srgb,var(--text-primary) 3%,transparent)}
++.ay-run-model>label{font-size:.82rem;font-weight:700;color:var(--text-secondary)}
++.ay-run-model-row{display:flex;gap:8px;align-items:stretch}
++.ay-run-model-row select{flex:1;min-width:0}
++.ay-run-model-row .ovk-btn{flex:none;min-height:44px}
++.ay-run-model-note{margin:0;font-size:.82rem;line-height:1.45;color:var(--text-secondary)}
ddiff --git a/server/day/page.js b/server/day/page.js
iindex 10793c9..5a63a9a 100644
---- a/server/day/page.js
++++ b/server/day/page.js
@@@ -26,7 +26,16 @@ function render(data, viewer = {}) {
const rawRunBrain = activeRun?.model_override || activeRun?.brain || a.brain;
const runBrain = rawRunBrain === 'own:node' ? 'own:link' : rawRunBrain;
const runOption = (id,label) => '<option value="'+e(id)+'" '+(runBrain===id?'selected':'')+'>'+e(label)+'</option>';
-- const runModel = modelSwitch && a.active_task ? '<form id="ay-run-model" data-task="'+e(a.active_task)+'"><label>Model for the next step<select name="model">' + runOption('auto','Auto') + runOption('openvibe','OpenVibe') + models.map(m=>runOption(m.id,m.name||m.id)).join('') + ['openai','anthropic','deepseek','google','openrouter','mistral','link'].map(p=>runOption('own:'+p,'Your '+p)).join('') + '</select></label><button class="ovk-btn ovk-btn--primary">Use for next step</button><p class="ay-muted">The run record keeps its model steps and costs. Tools this model cannot use stay unavailable.</p></form>' : '';
++ // The model for the next step: one row inside Now (the label says what it changes, so the button is one word), grouped by
++ // who runs it, named as the chat's model menu names them ("Your OpenAI key", "Your computer").
++ const OWN_KEYS = [['openai','Your OpenAI key'],['anthropic','Your Anthropic key'],['deepseek','Your DeepSeek key'],['google','Your Google key'],['openrouter','Your OpenRouter key'],['mistral','Your Mistral key']];
++ const runModel = modelSwitch && a.active_task ? '<form id="ay-run-model" class="ay-run-model" data-task="'+e(a.active_task)+'"><label for="ay-run-model-pick">Model for the next step</label>'
++ + '<div class="ay-run-model-row"><select id="ay-run-model-pick" name="model">'
++ + '<optgroup label="On OpenVibe">' + runOption('auto','Automatic') + runOption('openvibe','OpenVibe') + models.map(m=>runOption(m.id,m.name||m.id)).join('') + '</optgroup>'
++ + '<optgroup label="Your own keys">' + OWN_KEYS.map(([p,label])=>runOption('own:'+p,label)).join('') + '</optgroup>'
++ + '<optgroup label="Your own machines">' + runOption('own:link','Your computer') + '</optgroup>'
++ + '</select><button class="ovk-btn ovk-btn--quiet">Switch</button></div>'
++ + '<p class="ay-run-model-note">Steps already run keep the model and cost they used. A tool the new model cannot use stays off.</p></form>' : '';
const nowHtml = card('ay-now','Now', screen + '<p class="ay-current">' + (state === 'Working' ? pulse : '') + e(now.current_step || state) + '</p>' + (now.looking_at ? '<p class="ay-looking">' + thread.icon('read') + e(now.looking_at) + '</p>' : '') + runModel + '<ol class="ay-steps">' + (now.last_three_steps || []).map(j => '<li>' + time(j.created_at) + '<span>' + e(j.text) + '</span></li>').join('') + '</ol>' + next + (state === 'Resting' && now.next ? button('Do it now','data-action="do-now"',true) : '') + spend);
const asksHtml = asks.length ? card('ay-asks','Asks <span class="ovk-badge">' + asks.length + '</span>', asks.map(q => '<article class="ay-ask"><h3>' + e(q.text) + '</h3>' + (q.evidence?.text ? '<blockquote>' + e(q.evidence.text) + '</blockquote>' : '') + '<div class="ay-actions">' + q.options.map(o => q.kind === 'sign_in' && o !== 'Done' ? button('Sign in on its screen','data-sign-in',true) : button(o,'data-answer="' + e(q.id) + '" data-text="' + e(o) + '"',true)).join('') + button('Not now','data-decline="' + e(q.id) + '"') + '</div>' + (q.kind === 'answer_question' ? '<form data-answer-form="' + e(q.id) + '">' + input('text','Your answer','required maxlength="4000"') + '<button class="ovk-btn">Answer</button></form>' : '') + '</article>').join('')) : '<div id="ay-asks" hidden></div>';
const tell = card('ay-tell','Tell it','<div class="ovt ay-thread" data-ov-thread>' + thread.composer({ id:'ay-tell-form',action:'/api/v1/me/actor/tell',placeholder:'Tell your Actor something…',maxlength:4000,attach:{name:'files',accept:'.txt,.md,.csv,.json'},note:'Small text files welcome. Your Actor reads this on its next step.' }) + '<div id="ay-replies">' + messages.slice().reverse().map(m => thread.turn({ id:m.id,you:{text:m.text},reply:m.reply ? thread.answer({html:'<p>' + e(m.reply.answer || m.reply.text || '') + '</p>'}) : '<p class="ay-muted">Kept for the next step.</p>' })).join('') + '</div></div>');
ddiff --git a/test/actor-page.test.js b/test/actor-page.test.js
iindex 7a93928..43ac881 100644
---- a/test/actor-page.test.js
++++ b/test/actor-page.test.js
@@@ -20,6 +20,15 @@ const SAME = { 'sec-fetch-site':'same-origin' };
}
assert.doesNotMatch(render(base),/Uncensored/);
assert.doesNotMatch(render({...base, actor:{...a, active_task:'tsk_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}, model_switch_enabled:false}),/ay-run-model/);
++ // On: one row in Now. The label says what it changes, the button is one word, the choices are grouped by who runs
++ // them and named as the chat's model menu names them.
++ const sw = render({...base, actor:{...a, active_task:'tsk_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}, model_switch_enabled:true});
++ assert.match(sw, /<label for="ay-run-model-pick">Model for the next step<\/label>/);
++ assert.match(sw, /<optgroup label="On OpenVibe"><option value="auto"[^>]*>Automatic<\/option>/);
++ assert.match(sw, /<optgroup label="Your own keys"><option value="own:openai"[^>]*>Your OpenAI key<\/option>/);
++ assert.match(sw, /<optgroup label="Your own machines"><option value="own:link"[^>]*>Your computer<\/option>/);
++ assert.match(sw, /<button class="ovk-btn ovk-btn--quiet">Switch<\/button>/);
++ assert.doesNotMatch(sw, /Your (openai|link)</, 'no raw provider ids as labels');
assert.match(render({...base, actor:{...a, active_task:'tsk_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}, active_run:{brain:'openvibe'}, model_switch_enabled:true}),/option value="openvibe" selected/);
assert.match(render({...base,actor:{...a,adult_opt_in:true}}),/Uncensored/);
assert.match(render({...base,actor:{...a,asks:[{id:'ask',kind:'sign_in',text:'Sign in to the site',options:['Sign in on its screen','Done']} ]}}),/Waiting for you/);