diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 64a7d6674..1e0be849d 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -621,16 +621,67 @@ } return `

Token usage

Best reading of session-editable transcripts, not proof. Unknown turns may have used more tokens. Account is unassigned until account tracking is configured.

${rows.join('')}
SubjectInputOutputCache writeCache readUnknown turns
`; } + // Row 155 (#1649, Q169): each assistant instance's settings, its seat name and a stop. A save + // sends only the fields that changed, with the read's revision. Drafts survive polling and + // refusals as in the limits form, and nothing retries. + let assistantSnapshot=null, assistantDrafts=Object.create(null), nameDrafts=Object.create(null), assistantBusy=false, assistantReceipts=Object.create(null); + const asShown=(entry,field)=>field==='definition'?entry.definition??'':entry[field]==null?'':JSON.stringify(entry[field],null,2); + function assistantSection(value,names){ + if(!value||value.refused||!value.assistants||!Object.keys(value.assistants).length)return ''; + if(!Object.keys(assistantDrafts).length)assistantSnapshot=value; + const v=assistantSnapshot, disabled=!canSend||out()||assistantBusy?' disabled':''; + const named=names?Object.fromEntries(names.map(r=>[r.instance,r.name])):null; + const forms=Object.entries(v.assistants).map(([instance,entry])=>{ + const d=assistantDrafts[instance]||{}, val=f=>Object.hasOwn(d,f)?d[f]:asShown(entry,f), k=esc(instance); + const name=Object.hasOwn(nameDrafts,instance)?nameDrafts[instance]:named?.[instance]??''; + return `
${txt(instance)}
${named?'':' The current name could not be read.'}

${txt(assistantReceipts[instance]||'')}

`; + }).join(''); + return `

Assistant

The definition stays an assistant one. Holder and credentials stay hand edits. Stop ends the runs of your threads; the assistant starts again when you next write to it.

${forms}

${canSend?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}

`; + } + function assistantPayload(instance,form){ + const entry=assistantSnapshot.assistants[instance], patch={}; + for(const f of ['definition','vars','tracker']){ + const text=form.querySelector(`[data-assistant-field="${f}"]`).value, what=f==='vars'?'Vars':'The tracker'; + if(text===asShown(entry,f))continue; + if(f==='definition'){patch.definition=text.trim();continue;} + if(f==='tracker'&&text.trim()===''){patch.tracker=null;continue;} + let parsed;try{parsed=JSON.parse(text);}catch{throw Error(`${what} must be JSON.`);} + if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error(`${what} must be a JSON object.`); + patch[f]=parsed; + } + if(!Object.keys(patch).length)throw Error('Nothing changed.'); + return {revision:assistantSnapshot.revision,assistants:{[instance]:patch}}; + } + const unknownAssistant='Outcome unknown. Read current settings before trying again. Draft kept.'; + async function assistantPost(url,payload,instance,ok){ + assistantBusy=true;assistantReceipts[instance]='';render(false); + try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}} + catch{assistantReceipts[instance]=unknownAssistant;}finally{assistantBusy=false;if(!out())render(false);} + } + const assistantForm=el=>el?.closest?.('.assistant-form'); + for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=assistantForm(e.target);if(!form)return;const instance=form.dataset.instance,field=e.target.dataset.assistantField;if(field==='name')nameDrafts[instance]=e.target.value;else if(field)assistantDrafts[instance]={...Object.fromEntries(['definition','vars','tracker'].map(f=>[f,form.querySelector(`[data-assistant-field="${f}"]`).value]))};}); + document.addEventListener('submit',async e=>{ + const form=assistantForm(e.target);if(!form||e.target!==form)return;e.preventDefault();if(!canSend||out()||assistantBusy)return;const instance=form.dataset.instance;let payload; + try{payload=assistantPayload(instance,form);}catch(err){assistantReceipts[instance]=err.message;render(false);return;} + await assistantPost('/api/settings',payload,instance,result=>{if(!/^[a-f0-9]{64}$/.test(result?.revision??''))return unknownAssistant;delete assistantDrafts[instance];assistantSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});return 'Assistant settings saved.';}); + }); + document.addEventListener('click',async e=>{ + const form=assistantForm(e.target);if(!form||!canSend||out()||assistantBusy)return;const instance=form.dataset.instance; + if(e.target.dataset.assistantName!==undefined){const name=form.querySelector('[data-assistant-field="name"]').value.trim(); + await assistantPost('/api/name',{instance,name},instance,result=>{if(typeof result?.name!=='string')return unknownAssistant;delete nameDrafts[instance];const prior=last.get('names');if(prior)last.set('names',{...prior,rows:[...prior.rows.filter(r=>r.instance!==result.instance),{instance:result.instance,name:result.name,at:result.at}]});return `Seat name set to ${result.name}.`;});} + else if(e.target.dataset.assistantStop!==undefined)await assistantPost('/api/assistant-stop',{instance},instance,result=>Array.isArray(result?.stopped)?`Stopped ${plural(result.stopped.length,'run')}.`:unknownAssistant); + }); async function settingsView(get) { const s = await get.api('settings'), n = s.notifier || {}; - let usage; + let usage, names = null; try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } + try { names = await get('names'); } catch { /* the section says the name could not be read */ } let limitRows = [], limitsRefused = false; try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } const notify = typeof n.binding === 'string' ? `
Binding
${txt(n.binding)}

The binding name only. Its channels, server and user ids stay in the binding file.

` : empty('No notifier binding to show.', `${n.refused ? `${txt(n.refused)}: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`); - return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)} + return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}

Notifications

${notify}

About

Address
${txt(s.address) || 'unknown'}
Release
${s.release ? `${txt(s.release)}` : 'unknown: no RELEASE file was read'}
Business
${txt(s.business) || none}
Board
${txt(s.board) || 'unknown'}
`; }