Files

75 lines
9.0 KiB
Diff

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 `<section class="panel" aria-labelledby="token-usage-title"><h2 id="token-usage-title">Token usage</h2><p>Best reading of session-editable transcripts, not proof. Unknown turns may have used more tokens. Account is unassigned until account tracking is configured.</p><div class="table-wrap"><table id="token-usage-table"><thead><tr><th scope="col">Subject</th><th scope="col">Input</th><th scope="col">Output</th><th scope="col">Cache write</th><th scope="col">Cache read</th><th scope="col">Unknown turns</th></tr></thead><tbody>${rows.join('')}</tbody></table></div></section>`;
}
+ // 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 `<form class="assistant-form" data-instance="${k}"><fieldset><legend>${txt(instance)}</legend><div class="filters"><label>Seat name <input data-assistant-field="name" value="${esc(name)}" maxlength="64"${disabled}></label> <button type="button" data-assistant-name="${k}"${disabled}>Save name</button>${named?'':' <span class="small muted">The current name could not be read.</span>'}</div><label>Definition <input data-assistant-field="definition" value="${esc(val('definition'))}" maxlength="64"${disabled}></label><label>Vars (JSON) <textarea data-assistant-field="vars" rows="4"${disabled}>${esc(val('vars'))}</textarea></label><label>Tracker (JSON, empty removes it) <textarea data-assistant-field="tracker" rows="4"${disabled}>${esc(val('tracker'))}</textarea></label><button type="submit"${disabled}>Save assistant settings</button> <button type="button" data-assistant-stop="${k}"${disabled}>Stop its runs</button><p role="status" data-assistant-receipt="${k}">${txt(assistantReceipts[instance]||'')}</p></fieldset></form>`;
+ }).join('');
+ return `<section class="panel" aria-labelledby="assistant-title"><h2 id="assistant-title">Assistant</h2><p>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.</p>${forms}<p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}</p></section>`;
+ }
+ 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'
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${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)}
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
}