diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 40c6b6758..b2bb52d0a 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -671,17 +671,70 @@ 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); }); + // Row 165 (#1662, Q171): role instances. Add one, or change one's definition, vars and launch + // membership, with the read's revision. A definition that declares credentials is named but not + // offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt + // says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries. + let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusy=false, instanceReceipt=''; + const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.'; + const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.'; + function instancesSection(view){ + const title='

Role instances

'; + if(!view)return `
${title}${empty('The role instances could not be read.','They are read from the bus host with your identity.')}
`; + if(!instanceDrafts.add&&!instanceDrafts.change)instanceView=view; + const v=instanceView, disabled=!canSend||out()||instanceBusy?' disabled':''; + const defs=Object.entries(v.definitions), accepted=defs.filter(([,d])=>d.accepted).map(([n])=>n); + const bound=defs.filter(([,d])=>Array.isArray(d.services)&&d.services.length).map(([n,d])=>`${txt(n)} (${d.services.map(txt).join(', ')})`); + const changeable=Object.entries(v.instances).filter(([,e])=>!e.protected).map(([n])=>n); + const rows=Object.entries(v.instances).map(([name,e])=>`${txt(name)}${txt(e.definition)}${e.launcher?'launcher':e.launched?'yes':'no'}${e.vars&&Object.keys(e.vars).length?`${txt(JSON.stringify(e.vars))}`:'none'}`).join(''); + const opts=(list,sel,keep)=>`${keep?``:''}${list.map(n=>``).join('')}`; + const a=instanceDrafts.add||{}, c=instanceDrafts.change||{}; + const addLaunch=v.launch?``:''; + const changeLaunch=v.launch?``:''; + const addForm=`
Add an instance
${addLaunch}
`; + const changeForm=changeable.length?`
Change an instance
${changeLaunch}
`:''; + return `
${title}

Add an instance or change one's definition, vars and launch membership. Holder, credentials and tracker stay hand edits, and so does an instance of a definition that declares credentials. An assistant changes in the Assistant section.

${rows}
InstanceDefinitionLaunchedVars
${addForm}${changeForm}${bound.length?`

Definitions that declare credentials, a hand edit: ${bound.join(', ')}.

`:''}

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

${txt(instanceReceipt)}

`; + } + const instanceFields=form=>Object.fromEntries([...form.querySelectorAll('[data-instance-field]')].map(el=>[el.dataset.instanceField,el.type==='checkbox'?el.checked:el.value])); + function instancePayload(op,f){ + const payload={revision:instanceView.revision,instance:String(f.instance??'').trim(),op}; + if(!payload.instance)throw Error('Name the instance.'); + if(f.definition)payload.definition=f.definition; + if(op==='add'&&!payload.definition)throw Error('Choose a definition.'); + if(String(f.vars??'').trim()!==''){let parsed;try{parsed=JSON.parse(f.vars);}catch{throw Error('Vars must be JSON.');}if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error('Vars must be a JSON object.');payload.vars=parsed;} + if(op==='add'&&f.launch===true)payload.launch=true; + if(op==='change'&&(f.launch==='yes'||f.launch==='no'))payload.launch=f.launch==='yes'; + if(op==='change'&&!['definition','vars','launch'].some(k=>Object.hasOwn(payload,k)))throw Error('Nothing changed.'); + return payload; + } + const unknownInstance='Outcome unknown. Read the role instances again before trying. Draft kept.'; + // The answer replaces the view the next render draws, so it must have the read's whole shape. + const instanceAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&typeof r.restart==='boolean'&&[r.instances,r.definitions].every(o=>o&&typeof o==='object'&&!Array.isArray(o)); + for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('.instance-form');if(form)instanceDrafts[form.dataset.op]=instanceFields(form);}); + document.addEventListener('click',e=>{if(e.target.id!=='instances-reload')return;instanceDrafts={add:null,change:null};instanceView=null;instanceReceipt='';last.delete('instances');render(true);}); + document.addEventListener('submit',async e=>{ + const form=e.target?.closest?.('.instance-form');if(!form||e.target!==form)return;e.preventDefault();if(!canSend||out()||instanceBusy||!instanceView)return; + const op=form.dataset.op;instanceDrafts[op]=instanceFields(form);let payload; + try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;render(false);return;} + instanceBusy=true;instanceReceipt='';render(false); + try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json(); + if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?RESTART_TEXT:NO_RESTART_TEXT}`;} + else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}} + catch{instanceReceipt=unknownInstance;}finally{instanceBusy=false;if(!out())render(false);} + }); async function settingsView(get) { const s = await get.api('settings'), n = s.notifier || {}; 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 instances = null; + try { [instances] = await get('instances'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } 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)}${assistantSection(s.launch, names)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)} + return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${instancesSection(instances)}${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'}
`; }