sidebud 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/cli.js +7 -0
- package/dist/main.js +12201 -0
- package/package.json +41 -0
- package/packs/BRAND_ASSETS.md +8 -0
- package/packs/calendar/logo.png +0 -0
- package/packs/calendar/pack.json +127 -0
- package/packs/discord/pack.json +107 -0
- package/packs/filesystem/pack.json +84 -0
- package/packs/github/pack.json +127 -0
- package/packs/gmail/logo.png +0 -0
- package/packs/gmail/pack.json +274 -0
- package/packs/obsidian/pack.json +88 -0
- package/packs/t3-agents/logo.png +0 -0
- package/packs/t3-agents/pack.json +492 -0
- package/packs/telegram/pack.json +137 -0
- package/packs/trello/pack.json +102 -0
- package/packs/whatsapp/pack.json +118 -0
- package/src/manage/favicon.png +0 -0
- package/src/manage/index.html +91 -0
- package/src/manage/manage.css +1246 -0
- package/src/manage/manage.js +1054 -0
- package/src/manage/settings.js +112 -0
- package/src/manage/setup.js +210 -0
- package/src/manage/sidebud-icon.png +0 -0
|
@@ -0,0 +1,1054 @@
|
|
|
1
|
+
const SECTIONS = ['setup', 'connection', 'voice', 'execution', 'mcp', 'layouts', 'settings'];
|
|
2
|
+
// Links carry the token in the fragment (never sent to the server): `#t=<token>&s=<section>`.
|
|
3
|
+
const linked = new URLSearchParams(location.hash.startsWith('#t=') ? location.hash.slice(1) : '');
|
|
4
|
+
const token = linked.get('t') || sessionStorage.getItem('rvaManageToken');
|
|
5
|
+
if (token) {
|
|
6
|
+
sessionStorage.setItem('rvaManageToken', token);
|
|
7
|
+
if (linked.get('t'))
|
|
8
|
+
history.replaceState(null, '', `#${SECTIONS.includes(linked.get('s')) ? linked.get('s') : 'connection'}`);
|
|
9
|
+
}
|
|
10
|
+
// Dark is the default; a light choice is remembered per browser.
|
|
11
|
+
function applyTheme(theme) {
|
|
12
|
+
document.documentElement.dataset.theme = theme;
|
|
13
|
+
document
|
|
14
|
+
.querySelector('[data-theme-toggle]')
|
|
15
|
+
?.setAttribute(
|
|
16
|
+
'aria-label',
|
|
17
|
+
`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
applyTheme(localStorage.getItem('rvaManageTheme') === 'light' ? 'light' : 'dark');
|
|
21
|
+
document.querySelector('[data-theme-toggle]')?.addEventListener('click', () => {
|
|
22
|
+
const next =
|
|
23
|
+
document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
24
|
+
localStorage.setItem('rvaManageTheme', next);
|
|
25
|
+
applyTheme(next);
|
|
26
|
+
});
|
|
27
|
+
let state = null;
|
|
28
|
+
let previewController = null;
|
|
29
|
+
let previewAudio = null;
|
|
30
|
+
let previewUrl = null;
|
|
31
|
+
let previewVersion = 0;
|
|
32
|
+
const $ = (selector) => document.querySelector(selector);
|
|
33
|
+
const esc = (value) =>
|
|
34
|
+
String(value ?? '').replace(
|
|
35
|
+
/[&<>"']/g,
|
|
36
|
+
(char) =>
|
|
37
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[
|
|
38
|
+
char
|
|
39
|
+
]
|
|
40
|
+
);
|
|
41
|
+
const code = (value) => `<span class="code">${esc(value)}</span>`;
|
|
42
|
+
const notice = (message, error = false) => {
|
|
43
|
+
const el = $('#message');
|
|
44
|
+
el.textContent = message;
|
|
45
|
+
el.classList.toggle('error', error);
|
|
46
|
+
if (message) scrollTo({ top: 0, behavior: 'smooth' });
|
|
47
|
+
};
|
|
48
|
+
const RESTARTED = 'Lost the connection to the companion. If it restarted, reopen this page with rva-companion manage.';
|
|
49
|
+
async function api(path, payload) {
|
|
50
|
+
// A restarted companion listens on a new port with a new token, so this page can no longer reach it.
|
|
51
|
+
const response = await fetch(`/api/${path}`, {
|
|
52
|
+
method: payload === undefined ? 'GET' : 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${token}`,
|
|
55
|
+
...(payload === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
56
|
+
},
|
|
57
|
+
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
|
|
58
|
+
}).catch(() => { throw new Error(RESTARTED); });
|
|
59
|
+
const data = await response.json();
|
|
60
|
+
if (!response.ok)
|
|
61
|
+
throw new Error(data.error || `Request failed (${response.status})`);
|
|
62
|
+
return data;
|
|
63
|
+
}
|
|
64
|
+
/** Re-reads companion state; background polls re-render only when it changed, so forms keep their input. */
|
|
65
|
+
async function reload(force = true) {
|
|
66
|
+
try {
|
|
67
|
+
const next = await api('state');
|
|
68
|
+
const changed = force || JSON.stringify(next) !== JSON.stringify(state);
|
|
69
|
+
state = next;
|
|
70
|
+
if (changed) render();
|
|
71
|
+
clearTimeout(reload.timer);
|
|
72
|
+
// Poll only while something is in progress: an account connection, a setup check, or pairing during setup.
|
|
73
|
+
if (state.connecting.length || setupWaiting() || state.account.status === 'pending') reload.timer = setTimeout(() => reload(false), 1500);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
$('#app').innerHTML = `<div class="card">${esc(error.message)}</div>`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
79
|
+
let restarting = false;
|
|
80
|
+
/** Restarts the companion in place to apply saved settings; the page keeps its address and token. */
|
|
81
|
+
async function applyChanges() {
|
|
82
|
+
if (!state.restart.available) {
|
|
83
|
+
notice('Restart the companion to apply saved changes.');
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
restarting = true;
|
|
87
|
+
render();
|
|
88
|
+
notice('Applying changes. The companion restarts; any call in progress ends.');
|
|
89
|
+
try {
|
|
90
|
+
await api('restart', {});
|
|
91
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
92
|
+
await pause(500);
|
|
93
|
+
try {
|
|
94
|
+
const next = await api('state');
|
|
95
|
+
if (!next.restart.pending) {
|
|
96
|
+
state = next;
|
|
97
|
+
notice('Changes applied.');
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// The companion is between stop and start.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
notice('The companion did not come back. Check its terminal, then reload this page.', true);
|
|
105
|
+
return false;
|
|
106
|
+
} finally {
|
|
107
|
+
restarting = false;
|
|
108
|
+
if (state) render();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function section() {
|
|
112
|
+
const hash = location.hash.slice(1).split('/')[0];
|
|
113
|
+
return SECTIONS.includes(hash) ? hash : state && !state.setup.completedAt ? 'setup' : 'connection';
|
|
114
|
+
}
|
|
115
|
+
/** The execution controls are on the Execution page and in setup's agent step. */
|
|
116
|
+
function showsExecution() {
|
|
117
|
+
return section() === 'execution' || (section() === 'setup' && currentSetupStep() === 'execution' && Boolean(state.providers));
|
|
118
|
+
}
|
|
119
|
+
function restartBanner() {
|
|
120
|
+
if (!state.restart.pending) return '';
|
|
121
|
+
return `<div class="banner"><div><strong>Saved changes apply after a restart.</strong><p class="hint">Restarting takes a few seconds and ends any call in progress.</p></div><button class="primary" data-action="apply-changes" ${restarting ? 'disabled' : ''}>${restarting ? 'Applying…' : 'Apply now'}</button></div>`;
|
|
122
|
+
}
|
|
123
|
+
function setupBanner() {
|
|
124
|
+
if (state.setup.completedAt || section() === 'setup') return '';
|
|
125
|
+
const steps = Object.values(state.setup.steps);
|
|
126
|
+
return `<div class="banner"><div><strong>Finish setting up Sidebud</strong><p class="hint">${steps.filter((step) => step.done).length} of ${steps.length} steps done.</p></div><a class="button" href="#setup">Continue setup</a></div>`;
|
|
127
|
+
}
|
|
128
|
+
function render() {
|
|
129
|
+
stopVoicePreview();
|
|
130
|
+
const current = section();
|
|
131
|
+
sidebarIdentity();
|
|
132
|
+
document
|
|
133
|
+
.querySelectorAll('nav a')
|
|
134
|
+
.forEach((a) => a.classList.toggle('active', a.hash === `#${current}`));
|
|
135
|
+
$('#app').innerHTML = restartBanner() + setupBanner() + {
|
|
136
|
+
setup: setupView,
|
|
137
|
+
connection: connectionView,
|
|
138
|
+
voice: voiceView,
|
|
139
|
+
execution: executionView,
|
|
140
|
+
mcp: mcpView,
|
|
141
|
+
layouts: layoutsView,
|
|
142
|
+
settings: settingsView,
|
|
143
|
+
}[current]();
|
|
144
|
+
if (showsExecution()) void discoverModels();
|
|
145
|
+
if (current === 'setup') afterSetupRender();
|
|
146
|
+
if (
|
|
147
|
+
current === 'connection' &&
|
|
148
|
+
!pairingAttempted &&
|
|
149
|
+
!state.devices.some((device) => !device.revokedAt)
|
|
150
|
+
)
|
|
151
|
+
void loadPairing();
|
|
152
|
+
}
|
|
153
|
+
let pairing = null;
|
|
154
|
+
try {
|
|
155
|
+
const saved = JSON.parse(sessionStorage.getItem('rvaPairingOffer') || 'null');
|
|
156
|
+
if (
|
|
157
|
+
saved?.qr?.startsWith('data:image/png;base64,') &&
|
|
158
|
+
Number.isFinite(Date.parse(saved.expiresAt))
|
|
159
|
+
)
|
|
160
|
+
pairing = saved;
|
|
161
|
+
} catch {
|
|
162
|
+
sessionStorage.removeItem('rvaPairingOffer');
|
|
163
|
+
}
|
|
164
|
+
let pairingAttempted = Boolean(pairing);
|
|
165
|
+
let pairingLoading = false;
|
|
166
|
+
let pairingError = null;
|
|
167
|
+
async function loadPairing() {
|
|
168
|
+
if (pairingLoading) return;
|
|
169
|
+
pairingAttempted = true;
|
|
170
|
+
pairingLoading = true;
|
|
171
|
+
pairingError = null;
|
|
172
|
+
render();
|
|
173
|
+
try {
|
|
174
|
+
pairing = await api('pairing', {});
|
|
175
|
+
sessionStorage.setItem(
|
|
176
|
+
'rvaPairingOffer',
|
|
177
|
+
JSON.stringify({ qr: pairing.qr, expiresAt: pairing.expiresAt })
|
|
178
|
+
);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
pairingError = error.message;
|
|
181
|
+
} finally {
|
|
182
|
+
pairingLoading = false;
|
|
183
|
+
render();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function connectionView() {
|
|
187
|
+
const active = state.devices.filter((device) => !device.revokedAt);
|
|
188
|
+
const connected = new Set(state.connectedDeviceIds || []);
|
|
189
|
+
return `<section class="section"><div><h2>Connect a phone</h2><p class="lede">Pair your phone with ${esc(state.name)}. This page manages the companion on this computer; choose among your computers in the phone app. Rename this computer under <a href="#settings">Settings</a>.</p></div><div class="card stack"><div><h3>Pairing code</h3><p class="hint">On your phone, choose Add computer → Scan pairing code. Each code works once and expires shortly.</p></div>${pairing ? `<img class="pairing-qr" src="${esc(pairing.qr)}" alt="Pairing QR code"><p class="hint">${Date.parse(pairing.expiresAt) <= Date.now() ? 'This code has expired. Generate a new one.' : `Expires ${esc(new Date(pairing.expiresAt).toLocaleString())}.`}</p>` : `<p class="muted">${pairingLoading ? 'Preparing pairing code…' : 'Generate a code to connect this phone.'}</p>`}${pairingError ? `<p role="alert" class="hint error-text">Could not create a pairing code: ${esc(pairingError)} Check this computer’s reachable address in config.json, then try again.</p>` : ''}<div class="actions"><button class="primary" data-action="pair-phone" ${pairingLoading ? 'disabled' : ''}>${pairingLoading ? 'Preparing…' : pairing ? 'Generate a new code' : pairingError ? 'Try again' : 'Generate pairing code'}</button></div></div><div><h3>Paired phones</h3><p class="lede">${active.length ? 'Phones authorized to use this computer.' : 'No phone is paired yet. Scan the code above to connect your first phone.'}</p></div><div class="grid">${active.map((device) => `<div class="card"><div class="row"><div><h3>${esc(device.name)}</h3><p class="hint">${esc(device.platform)} · Paired ${esc(new Date(device.createdAt).toLocaleDateString())}</p></div><span class="badge ${connected.has(device.id) ? 'good' : ''}">${connected.has(device.id) ? 'Connected now' : 'Offline'}</span></div><p class="hint">${device.lastSeenAt ? `Last seen ${esc(new Date(device.lastSeenAt).toLocaleString())}` : 'Has not connected yet'} · Credential expires ${esc(new Date(device.expiresAt).toLocaleDateString())}</p><div class="actions"><button class="danger" data-action="revoke-phone" data-id="${esc(device.id)}">Remove phone</button></div></div>`).join('') || '<div class="card muted">Your paired phones will appear here.</div>'}</div><div class="actions"><button data-action="refresh">Refresh status</button></div></section>`;
|
|
190
|
+
}
|
|
191
|
+
function voiceKeyView(providerId) {
|
|
192
|
+
const provider = state.voice.catalog[providerId];
|
|
193
|
+
if (!provider) return '';
|
|
194
|
+
const key = state.voice.keyStates[providerId];
|
|
195
|
+
return `<div class="card"><div class="row"><div><h3>${esc(provider.label)} API key</h3><p class="hint">${key.fromEnv ? 'Provided by this computer’s environment. Environment keys take priority.' : key.isSet ? 'Stored in this computer’s keychain. Enter a new key to replace it.' : 'Add a key to use this provider. It stays in this computer’s keychain.'}</p></div><span class="badge ${key.isSet ? 'good' : 'warn'}">${key.isSet ? 'Configured' : 'Missing'}</span></div><div class="actions"><input data-voice-key="${esc(providerId)}" type="password" autocomplete="new-password" aria-label="${esc(provider.label)} API key" placeholder="Paste a key to set or replace"><button data-action="save-voice-key" data-provider="${esc(providerId)}">Save key</button>${key.isSet && !key.fromEnv ? `<button class="danger" data-action="clear-voice-key" data-provider="${esc(providerId)}">Clear key</button>` : ''}</div></div>`;
|
|
196
|
+
}
|
|
197
|
+
function voiceKeysView(voiceProvider) {
|
|
198
|
+
return state.voice.catalog[voiceProvider]
|
|
199
|
+
? voiceKeyView(voiceProvider)
|
|
200
|
+
: '<div class="card"><p class="hint">Choose a voice provider to configure its key.</p></div>';
|
|
201
|
+
}
|
|
202
|
+
const voicePrice = (usd) => `≈ $${usd < 0.01 ? usd.toFixed(3) : usd.toFixed(2)}/min`;
|
|
203
|
+
function voiceModelOptions(provider, model) {
|
|
204
|
+
if (!provider) return '<option value="">No model needed</option>';
|
|
205
|
+
const current = provider.models.some((option) => option.id === model) ? model : provider.defaultModel;
|
|
206
|
+
return provider.models.map((option) => `<option value="${esc(option.id)}" ${option.id === current ? 'selected' : ''}>${esc(option.label)} (${esc(voicePrice(option.usdPerMinute))} ${option.billing === 'connected' ? 'while connected' : 'of speech'})</option>`).join('');
|
|
207
|
+
}
|
|
208
|
+
/** Voices for the chosen model: its own list, else the provider's. */
|
|
209
|
+
function voiceOptionsFor(provider, modelId) {
|
|
210
|
+
const model = provider?.models.find((option) => option.id === modelId) || provider?.models.find((option) => option.id === provider.defaultModel);
|
|
211
|
+
return model?.voices || provider?.voices || [];
|
|
212
|
+
}
|
|
213
|
+
const voiceNameOptions = (voices, selected) => `<option value="">Provider default</option>${voices.map((option) => `<option value="${esc(option.id)}" ${selected === option.id ? 'selected' : ''}>${esc(option.label)} — ${esc(option.description)}</option>`).join('')}`;
|
|
214
|
+
const voiceSearchSupported = (provider) => Boolean(provider?.capabilities.webSearch);
|
|
215
|
+
/** Region and workspace fields, for providers whose endpoint needs them. */
|
|
216
|
+
function voiceEndpointFields(provider, voice) {
|
|
217
|
+
const regions = provider?.regions || [];
|
|
218
|
+
return `${regions.length ? `<label>Region<select id="voice-region">${regions.map((region) => `<option value="${esc(region.id)}" ${(voice.region || regions[0].id) === region.id ? 'selected' : ''}>${esc(region.label)}</option>`).join('')}</select></label>` : ''}${provider?.needsWorkspaceId ? `<label>Workspace ID<input id="voice-workspace" maxlength="200" autocomplete="off" spellcheck="false" value="${esc(voice.workspaceId || '')}" placeholder="ws-… or paste the API Host"></label><p class="hint">In Model Studio, paste the <strong>API Host</strong> (or base URL) shown with your API key, such as <span class="code">ws-abc123.ap-southeast-1.maas.aliyuncs.com</span>; the workspace ID and region are read from it. It is not a secret; the API key below is.</p>` : ''}`;
|
|
219
|
+
}
|
|
220
|
+
/** Model Studio shows an API host (`ws-….ap-southeast-1.maas.aliyuncs.com`) or base URL, not a bare ID; both carry the ID and region. */
|
|
221
|
+
const MODEL_STUDIO_HOST = /^(?:[a-z]+:\/\/)?([a-z0-9-]+)\.(ap-southeast-1|cn-beijing)\.maas\.aliyuncs\.com/i;
|
|
222
|
+
/** Same rules as the companion's voiceCommandPhraseSchema, checked here to say what is wrong. */
|
|
223
|
+
function commandPhrases() {
|
|
224
|
+
const read = (id) => $(id).value.split(',').map((phrase) => phrase.toLowerCase().replace(/[^a-z'\s]/g, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
225
|
+
const commands = { mute: read('#voice-mute-phrases'), muteMic: read('#voice-mute-mic-phrases'), muteVoice: read('#voice-mute-voice-phrases'), unmute: read('#voice-unmute-phrases'), endCall: read('#voice-end-call-phrases') };
|
|
226
|
+
const all = Object.values(commands).flat();
|
|
227
|
+
const invalid = all.find((phrase) => phrase.split(' ').length < 2 || phrase.length > 40);
|
|
228
|
+
if (!commands.mute.length || !commands.unmute.length) return { error: 'Add at least one phrase to mute everything and one to unmute.' };
|
|
229
|
+
if (Object.values(commands).some((list) => list.length > 5)) return { error: 'Use at most five phrases per command.' };
|
|
230
|
+
if (invalid) return { error: `“${invalid}”: use two to a few words, so ordinary speech does not trigger it.` };
|
|
231
|
+
if (new Set(all).size !== all.length) return { error: 'Each phrase can only do one thing.' };
|
|
232
|
+
return { commands };
|
|
233
|
+
}
|
|
234
|
+
function voiceEndpoint() {
|
|
235
|
+
const raw = $('#voice-workspace')?.value.trim() || '';
|
|
236
|
+
const host = raw.match(MODEL_STUDIO_HOST);
|
|
237
|
+
const region = host ? (host[2].toLowerCase() === 'cn-beijing' ? 'beijing' : 'singapore') : $('#voice-region')?.value || null;
|
|
238
|
+
return { region, workspaceId: host ? host[1] : raw || null };
|
|
239
|
+
}
|
|
240
|
+
function voiceUsageView(usage) {
|
|
241
|
+
if (!usage.calls) return '<p class="hint">No voice calls this month.</p>';
|
|
242
|
+
const minutes = (value) => `${value < 10 ? value.toFixed(1) : Math.round(value)} min`;
|
|
243
|
+
const usd = (value) => `$${value < 0.1 ? value.toFixed(3) : value.toFixed(2)}`;
|
|
244
|
+
return `<p><strong>This month ≈ ${esc(usd(usage.estimatedUsd))}</strong> · ${esc(minutes(usage.minutes))} connected, ${esc(minutes(usage.speakingMinutes))} speaking, across ${usage.calls} ${usage.calls === 1 ? 'call' : 'calls'}</p><ul class="hint">${usage.models.map((row) => `<li>${esc(row.providerLabel)} · ${esc(row.model)}: ${esc(minutes(row.minutes))} connected, ${esc(minutes(row.speakingMinutes))} speaking${row.estimatedUsd === null ? ' (no list price)' : ` ≈ ${esc(usd(row.estimatedUsd))} (billed ${row.billing === 'connected' ? 'while connected' : 'for speech'})`}</li>`).join('')}</ul><p class="hint">An estimate from list prices, not a bill. Speaking time counts your voiced audio and the assistant's replies. Check your provider's billing page for actual charges.</p>`;
|
|
245
|
+
}
|
|
246
|
+
function voiceView() {
|
|
247
|
+
const { voice, catalog } = state.voice;
|
|
248
|
+
const selected = catalog[voice.provider];
|
|
249
|
+
const voices = voiceOptionsFor(selected, voice.model);
|
|
250
|
+
return `<section class="section"><div><h2>Voice agent</h2><p class="lede">The voice agent talks with you in calls and answers typed messages in the chat, as one assistant. It hands all computer work to the execution agent below and cannot run local tools itself.</p></div>
|
|
251
|
+
<div class="card stack"><h3>Voice agent model</h3><p class="hint">Used for calls and typed chat. Qwen and OpenAI Realtime reply to typed messages in text; the others speak, and their transcript is the reply (billed as audio). GPT-Live cannot read typed messages, so they go straight to the execution agent. With voice off, typed messages get simple local replies.</p><label>Voice provider<select id="voice-provider"><option value="none" ${voice.provider === 'none' ? 'selected' : ''}>Off</option>${Object.values(
|
|
252
|
+
catalog
|
|
253
|
+
)
|
|
254
|
+
.map(
|
|
255
|
+
(provider) =>
|
|
256
|
+
`<option value="${esc(provider.id)}" ${voice.provider === provider.id ? 'selected' : ''}>${esc(provider.label)}${provider.untested ? ' (untested)' : ''}</option>`
|
|
257
|
+
)
|
|
258
|
+
.join(
|
|
259
|
+
''
|
|
260
|
+
)}<option value="echo" ${voice.provider === 'echo' ? 'selected' : ''}>Echo (audio test)</option></select></label><label>Voice model<select id="voice-model" ${selected ? '' : 'disabled'}>${voiceModelOptions(selected, voice.model)}</select></label><p id="voice-untested" class="hint" ${selected?.untested ? '' : 'hidden'}>Untested: this provider has not been run against the real service yet, so expect rough edges.</p><p class="hint">Prices are estimates from list prices. Most models charge only for speech, so a quiet or muted call costs little; models priced "while connected" charge for every minute the call is open, muted or not.</p><div id="voice-endpoint" class="stack">${voiceEndpointFields(selected, voice)}</div><label>Voice name<select id="voice-name">${voiceNameOptions(voices, voice.voiceName)}</select></label><div class="voice-preview"><button type="button" data-action="preview-voice" ${selected ? '' : 'disabled'}>Play voice sample</button><button type="button" data-action="stop-voice-preview" hidden>Stop sample</button><audio id="voice-preview-audio" controls preload="none" hidden></audio></div><p id="voice-preview-status" class="hint" role="status" aria-live="polite">${selected ? `Sample phrase: “Hi, I'm your assistant. What would you like to work on today?”` : 'Choose a live voice provider to hear a sample.'}</p><label>Maximum call length (minutes)<input id="voice-minutes" type="number" min="1" max="60" value="${voice.maxSessionMinutes}"></label><div class="stack"><h4>Spoken mute and unmute</h4><p class="hint">Your phone listens for these during a call, on the phone itself. Muting the mic stops what the assistant hears (it still speaks reports); muting the voice stops it speaking on the phone (replies still arrive as text). Use at least two words each; separate alternatives with commas. Unmute phrases work while muted. Changes apply from the next call after you apply them.</p><label>Mute mic and voice<input id="voice-mute-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.mute.join(', '))}"></label><label>Mute mic only<input id="voice-mute-mic-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.muteMic.join(', '))}"></label><label>Mute voice only<input id="voice-mute-voice-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.muteVoice.join(', '))}"></label><label>Unmute everything<input id="voice-unmute-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.unmute.join(', '))}"></label><label>End the call<input id="voice-end-call-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(voice.commands.endCall.join(', '))}"></label><label>Auto-mute the mic after this many quiet seconds<input id="voice-auto-mute" type="number" min="5" max="300" value="${voice.autoMute.seconds}"></label><div class="check"><input id="voice-auto-mute-on" type="checkbox" ${voice.autoMute.enabled ? 'checked' : ''}><label for="voice-auto-mute-on">Start calls with auto-mute on</label></div><p class="hint">Quiet means neither you nor the assistant is speaking. The call screen has an auto-mute toggle to change it during a call. Ending the call stops voice only; messages and task updates keep arriving in the app. Auto-mute works when the phone can listen for your unmute phrase.</p></div><div id="voice-search-row" class="check" ${voiceSearchSupported(selected) ? '' : 'hidden'}><input id="voice-search" type="checkbox" ${voice.webSearch ? 'checked' : ''}><label for="voice-search">Allow web search in voice calls</label></div><p id="voice-search-note" class="hint" ${selected && !voiceSearchSupported(selected) ? '' : 'hidden'}>This provider's built-in web search is not available in this companion.</p><div class="actions"><button class="primary" data-action="save-voice">Save voice settings</button></div><p class="hint">Provider and model changes apply after a restart; you will be prompted.</p></div><div class="card stack"><h3>Voice usage</h3>${voiceUsageView(state.voice.usage)}</div><div id="voice-key-panel" class="stack">${voiceKeysView(voice.provider)}</div></section>`;
|
|
261
|
+
}
|
|
262
|
+
function previewStatus(message, error = false) {
|
|
263
|
+
const status = $('#voice-preview-status');
|
|
264
|
+
if (!status) return;
|
|
265
|
+
status.textContent = message;
|
|
266
|
+
status.classList.toggle('error-text', error);
|
|
267
|
+
}
|
|
268
|
+
function stopVoicePreview() {
|
|
269
|
+
previewVersion += 1;
|
|
270
|
+
previewController?.abort();
|
|
271
|
+
previewController = null;
|
|
272
|
+
previewAudio?.pause();
|
|
273
|
+
if (previewAudio) {
|
|
274
|
+
previewAudio.removeAttribute('src');
|
|
275
|
+
previewAudio.load();
|
|
276
|
+
}
|
|
277
|
+
previewAudio = null;
|
|
278
|
+
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
279
|
+
previewUrl = null;
|
|
280
|
+
const start = $('[data-action="preview-voice"]');
|
|
281
|
+
if (start) start.disabled = false;
|
|
282
|
+
const stop = $('[data-action="stop-voice-preview"]');
|
|
283
|
+
if (stop) stop.hidden = true;
|
|
284
|
+
const audio = $('#voice-preview-audio');
|
|
285
|
+
if (audio) audio.hidden = true;
|
|
286
|
+
}
|
|
287
|
+
async function playVoicePreview() {
|
|
288
|
+
stopVoicePreview();
|
|
289
|
+
const provider = $('#voice-provider').value;
|
|
290
|
+
const catalog = state.voice.catalog[provider];
|
|
291
|
+
if (!catalog) return previewStatus('Choose a live voice provider to hear a sample.', true);
|
|
292
|
+
const voice = $('#voice-name').value || catalog.defaultVoice;
|
|
293
|
+
const model = $('#voice-model').value;
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const version = ++previewVersion;
|
|
296
|
+
previewController = controller;
|
|
297
|
+
$('[data-action="preview-voice"]').disabled = true;
|
|
298
|
+
$('[data-action="stop-voice-preview"]').hidden = false;
|
|
299
|
+
previewStatus('Preparing voice sample…');
|
|
300
|
+
try {
|
|
301
|
+
const response = await fetch('/api/voice/preview', {
|
|
302
|
+
method: 'POST',
|
|
303
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
304
|
+
body: JSON.stringify({ provider, model, voice, ...voiceEndpoint() }),
|
|
305
|
+
signal: controller.signal,
|
|
306
|
+
});
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
const body = await response.json().catch(() => null);
|
|
309
|
+
throw new Error(body?.error || 'Could not generate a voice sample.');
|
|
310
|
+
}
|
|
311
|
+
if (!response.headers.get('content-type')?.startsWith('audio/wav')) throw new Error('The companion returned an invalid voice sample.');
|
|
312
|
+
const blob = await response.blob();
|
|
313
|
+
if (version !== previewVersion || controller.signal.aborted) return;
|
|
314
|
+
previewUrl = URL.createObjectURL(blob);
|
|
315
|
+
previewAudio = $('#voice-preview-audio');
|
|
316
|
+
previewAudio.src = previewUrl;
|
|
317
|
+
previewAudio.hidden = false;
|
|
318
|
+
previewAudio.addEventListener('ended', () => {
|
|
319
|
+
if (version === previewVersion) previewStatus('Sample ready. Press play to hear it again.');
|
|
320
|
+
}, { once: true });
|
|
321
|
+
try {
|
|
322
|
+
await previewAudio.play();
|
|
323
|
+
if (version === previewVersion) previewStatus('Playing sample. Use the audio controls to replay it.');
|
|
324
|
+
} catch {
|
|
325
|
+
if (version === previewVersion) previewStatus('Sample ready. Press play to hear it.');
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (version === previewVersion && !controller.signal.aborted) {
|
|
329
|
+
stopVoicePreview();
|
|
330
|
+
previewStatus(error.message || 'Could not generate a voice sample.', true);
|
|
331
|
+
}
|
|
332
|
+
} finally {
|
|
333
|
+
if (version === previewVersion) {
|
|
334
|
+
previewController = null;
|
|
335
|
+
const start = $('[data-action="preview-voice"]');
|
|
336
|
+
if (start) start.disabled = false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
let executionProvider = null;
|
|
341
|
+
const attemptedModelDiscovery = new Set();
|
|
342
|
+
const pendingModelRefresh = new Set();
|
|
343
|
+
function selectedModelInstance() {
|
|
344
|
+
return $('#profile-instance')?.value || '';
|
|
345
|
+
}
|
|
346
|
+
function modelRefreshKey(driver, instanceId) {
|
|
347
|
+
return `${driver}:${instanceId || 'new'}`;
|
|
348
|
+
}
|
|
349
|
+
function showModelLoading(loading) {
|
|
350
|
+
const button = $('[data-action="refresh-models"]');
|
|
351
|
+
if (button) {
|
|
352
|
+
button.disabled = loading;
|
|
353
|
+
button.textContent = loading ? 'Loading models…' : 'Refresh models';
|
|
354
|
+
}
|
|
355
|
+
const add = $('[data-action="quick-add-agent"]');
|
|
356
|
+
if (add) add.disabled = loading;
|
|
357
|
+
const model = $('#profile-model');
|
|
358
|
+
if (model) model.disabled = loading;
|
|
359
|
+
if (loading) {
|
|
360
|
+
const status = $('#profile-model-status');
|
|
361
|
+
if (status) {
|
|
362
|
+
status.textContent = 'Loading models from this computer…';
|
|
363
|
+
status.classList.remove('error-text');
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function discoverModels() {
|
|
368
|
+
const driver = executionProvider;
|
|
369
|
+
if (!state.providers || !$('#profile-name')) return;
|
|
370
|
+
const instanceId = selectedModelInstance();
|
|
371
|
+
const instance = state.providers.instances.find(candidate => candidate.instanceId === instanceId);
|
|
372
|
+
const key = modelRefreshKey(driver, instanceId);
|
|
373
|
+
if (pendingModelRefresh.has(key)) {
|
|
374
|
+
showModelLoading(true);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (instance?.modelsCheckedAt || attemptedModelDiscovery.has(key)) return;
|
|
378
|
+
void refreshModels(driver);
|
|
379
|
+
}
|
|
380
|
+
async function refreshModels(driver) {
|
|
381
|
+
if (!showsExecution() || executionProvider !== driver || !$('#profile-name')) return;
|
|
382
|
+
const initialInstanceId = selectedModelInstance();
|
|
383
|
+
const key = modelRefreshKey(driver, initialInstanceId);
|
|
384
|
+
if (pendingModelRefresh.has(key)) return;
|
|
385
|
+
attemptedModelDiscovery.add(key);
|
|
386
|
+
pendingModelRefresh.add(key);
|
|
387
|
+
showModelLoading(true);
|
|
388
|
+
let instanceId = initialInstanceId;
|
|
389
|
+
let errorMessage = null;
|
|
390
|
+
try {
|
|
391
|
+
if (!instanceId) {
|
|
392
|
+
instanceId = `${driver}_${crypto.randomUUID().slice(0, 8)}`;
|
|
393
|
+
await api('provider', { instanceId, driver, enabled: true });
|
|
394
|
+
attemptedModelDiscovery.add(modelRefreshKey(driver, instanceId));
|
|
395
|
+
}
|
|
396
|
+
await api('provider/refresh', { instanceId });
|
|
397
|
+
} catch (error) {
|
|
398
|
+
errorMessage = error.message || 'Could not refresh models. Check this installation and try again.';
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
const refreshedState = await api('state');
|
|
402
|
+
state = refreshedState;
|
|
403
|
+
if (showsExecution() && executionProvider === driver && selectedModelInstance() === initialInstanceId) {
|
|
404
|
+
const model = $('#profile-model');
|
|
405
|
+
const selectedModel = model?.value || '';
|
|
406
|
+
if (!initialInstanceId && refreshedState.providers.instances.some(instance => instance.instanceId === instanceId)) $('#profile-instance').value = instanceId;
|
|
407
|
+
updateModelChoices(selectedModel);
|
|
408
|
+
if (errorMessage) {
|
|
409
|
+
const status = $('#profile-model-status');
|
|
410
|
+
if (status) {
|
|
411
|
+
status.textContent = errorMessage;
|
|
412
|
+
status.classList.add('error-text');
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
errorMessage ||= error.message || 'Could not load refreshed models. Try again.';
|
|
418
|
+
if (showsExecution() && executionProvider === driver && selectedModelInstance() === initialInstanceId) {
|
|
419
|
+
const status = $('#profile-model-status');
|
|
420
|
+
if (status) {
|
|
421
|
+
status.textContent = errorMessage;
|
|
422
|
+
status.classList.add('error-text');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
} finally {
|
|
426
|
+
pendingModelRefresh.delete(key);
|
|
427
|
+
if (showsExecution() && executionProvider === driver && [initialInstanceId, instanceId].includes(selectedModelInstance())) showModelLoading(false);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function modelChoices(instance, selectedModel = '') {
|
|
431
|
+
const models = instance && !instance.modelsError ? instance.models || [] : [];
|
|
432
|
+
return `<option value="" ${!selectedModel ? 'selected' : ''}>Provider default</option>${models.map(model => `<option value="${esc(model.id)}" ${selectedModel === model.id ? 'selected' : ''}>${esc(model.label || model.id)}</option>`).join('')}`;
|
|
433
|
+
}
|
|
434
|
+
function modelMessage(instance) {
|
|
435
|
+
if (!instance) return 'No installation is set up yet. Refresh models to add one and check what it offers.';
|
|
436
|
+
if (instance.modelsError) return `Could not load models: ${esc(instance.modelsError)} Check this installation and sign-in, then refresh models.`;
|
|
437
|
+
if (!instance.modelsCheckedAt) return 'Refresh models to see what this installation offers.';
|
|
438
|
+
if (!instance.models?.length) return 'No models were returned. Check this installation and sign-in, then refresh models.';
|
|
439
|
+
return `Models from ${esc(instance.displayName)} · checked ${esc(new Date(instance.modelsCheckedAt).toLocaleString())}.`;
|
|
440
|
+
}
|
|
441
|
+
// New agents think at Low when the model offers it; the provider default stays one choice away.
|
|
442
|
+
const DEFAULT_EFFORT = 'low';
|
|
443
|
+
function effortLevelsFor(instance, modelId) {
|
|
444
|
+
const models = instance && !instance.modelsError ? instance.models || [] : [];
|
|
445
|
+
const model = modelId ? models.find(candidate => candidate.id === modelId) : models.find(candidate => candidate.isDefault);
|
|
446
|
+
return model?.efforts || [];
|
|
447
|
+
}
|
|
448
|
+
const effortLabel = level => ({ xhigh: 'Extra high' })[level] || `${level[0].toUpperCase()}${level.slice(1)}`;
|
|
449
|
+
function effortChoices(levels, selected) {
|
|
450
|
+
return `<option value="" ${!selected ? 'selected' : ''}>Provider default</option>${levels.map(level => `<option value="${esc(level)}" ${selected === level ? 'selected' : ''}>${esc(effortLabel(level))}</option>`).join('')}`;
|
|
451
|
+
}
|
|
452
|
+
function effortHint(levels) {
|
|
453
|
+
return levels.length ? 'How much the agent reasons before acting. Lower is faster and cheaper; higher suits harder tasks.' : 'This model does not report thinking levels, so it uses the provider default.';
|
|
454
|
+
}
|
|
455
|
+
function updateEffortChoices() {
|
|
456
|
+
const effort = $('#profile-effort');
|
|
457
|
+
if (!effort) return;
|
|
458
|
+
const instance = state.providers?.instances.find(candidate => candidate.instanceId === $('#profile-instance')?.value);
|
|
459
|
+
const levels = effortLevelsFor(instance, $('#profile-model')?.value);
|
|
460
|
+
const wanted = effort.dataset.touched ? effort.value : DEFAULT_EFFORT;
|
|
461
|
+
effort.innerHTML = effortChoices(levels, levels.includes(wanted) ? wanted : '');
|
|
462
|
+
effort.disabled = !levels.length;
|
|
463
|
+
$('#profile-effort-status').textContent = effortHint(levels);
|
|
464
|
+
}
|
|
465
|
+
function updateModelChoices(selectedModel = '') {
|
|
466
|
+
const instanceId = $('#profile-instance')?.value;
|
|
467
|
+
const instance = state.providers?.instances.find(candidate => candidate.instanceId === instanceId);
|
|
468
|
+
const model = $('#profile-model');
|
|
469
|
+
if (!model) return;
|
|
470
|
+
model.innerHTML = modelChoices(instance, selectedModel);
|
|
471
|
+
model.value = [...model.options].some(option => option.value === selectedModel) ? selectedModel : '';
|
|
472
|
+
$('#profile-model-status').innerHTML = modelMessage(instance);
|
|
473
|
+
updateEffortChoices();
|
|
474
|
+
}
|
|
475
|
+
const interval = (seconds) => (seconds < 60 ? `${seconds} sec` : seconds >= 3600 && seconds % 3600 === 0 ? `${seconds / 3600} hr` : `${Math.round(seconds / 60)} min`);
|
|
476
|
+
|
|
477
|
+
/** Which widget updates the voice agent speaks during calls; each widget can override it. */
|
|
478
|
+
function announceView() {
|
|
479
|
+
const mode = state.packs.announceMode || 'agents';
|
|
480
|
+
const option = (value, label) => `<option value="${value}" ${mode === value ? 'selected' : ''}>${label}</option>`;
|
|
481
|
+
return `<div class="card stack"><div><h3>Spoken updates during calls</h3><p class="hint">In a call or work mode, the voice agent tells you at the next pause when a widget reports something: an agent thread finished, failed, or needs you; new mail; and so on. Each widget below can override this.</p></div><label>Speak updates from<select id="announce-mode">${option('agents', 'Agents only (recommended)')}${option('all', 'Every widget')}${option('none', 'No widgets')}</select></label></div>`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** `embedded` drops the page heading when setup shows these controls under its own. */
|
|
485
|
+
function executionView({ embedded = false } = {}) {
|
|
486
|
+
const providers = state.providers;
|
|
487
|
+
const executors = state.executors || [];
|
|
488
|
+
const selected = executors.find(executor => executor.isDefault);
|
|
489
|
+
const groups = [
|
|
490
|
+
...(providers?.drivers || [
|
|
491
|
+
{ kind: 'codex', label: 'Codex' }, { kind: 'claudeAgent', label: 'Claude' }, { kind: 'opencode', label: 'OpenCode' },
|
|
492
|
+
]).map(driver => ({ id: driver.kind, label: driver.label, agents: executors.filter(executor => {
|
|
493
|
+
if (executor.connector !== 'direct') return false;
|
|
494
|
+
const profile = providers?.profiles.find(profile => executor.id === `direct:${profile.id}`);
|
|
495
|
+
return profile && providers.instances.some(instance => instance.driver === driver.kind && [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider));
|
|
496
|
+
}) })),
|
|
497
|
+
];
|
|
498
|
+
const other = executors.filter(executor => executor.connector !== 'direct');
|
|
499
|
+
if (other.length) groups.push({ id: 'other', label: state.connector.label || 'Other agents', agents: other });
|
|
500
|
+
if (!groups.some(group => group.id === executionProvider)) executionProvider = groups.find(group => group.agents.some(agent => agent.isDefault))?.id || groups.find(group => group.agents.length)?.id || 'codex';
|
|
501
|
+
const group = groups.find(candidate => candidate.id === executionProvider) || groups[0];
|
|
502
|
+
const instances = providers?.instances.filter(instance => instance.driver === group.id) || [];
|
|
503
|
+
const enabled = instances.filter(instance => instance.enabled);
|
|
504
|
+
const selectedInstance = enabled[0] || null;
|
|
505
|
+
const readiness = instance => !instance.enabled ? 'Disabled' : !instance.status ? 'Not checked' : !instance.status.installed ? 'Not installed' : instance.status.auth === 'unauthenticated' ? 'Sign-in needed' : instance.status.auth === 'authenticated' ? 'Ready' : 'Installed · sign-in not verified';
|
|
506
|
+
const profiles = providers?.profiles.filter(profile => instances.some(instance => [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider))) || [];
|
|
507
|
+
const promptFor = executor => executor.connector === 'direct' ? state.profilePrompts?.[executor.id.slice('direct:'.length)] : undefined;
|
|
508
|
+
const agentRows = group.agents.map(executor => `<div class="agent-choice ${executor.isDefault ? 'chosen' : ''}"><div><strong>${esc(executor.label)}</strong><p class="hint">${esc(executor.detail || 'Uses the provider’s default model')}${promptFor(executor) ? ' · Custom system prompt' : ''}</p></div><div class="agent-actions">${executor.connector === 'direct' ? `<button data-action="edit-profile" data-id="${esc(executor.id.slice('direct:'.length))}" aria-label="Edit ${esc(executor.label)} thinking level and system prompt">Edit</button>` : ''}${executor.isDefault ? '<span class="badge good">Default</span>' : `<button data-action="set-default-executor" data-id="${esc(executor.id)}" aria-label="Use ${esc(executor.label)} by default">Use by default</button>`}</div></div>`).join('');
|
|
509
|
+
return `<section class="section execution">${embedded ? '<p class="lede">Choose the agent on this computer that does the work you ask for. Voice hands requests to it.</p>' : '<div><h2>Who does the work?</h2><p class="lede">Choose the agent that handles tasks you send from your phone.</p></div>'}
|
|
510
|
+
<div class="default-agent"><div><small>YOUR DEFAULT AGENT</small><h3>${esc(selected?.label || 'Choose an agent below')}</h3><p>${esc(selected?.detail || 'Add an agent, then choose it as your default.')}</p></div><span class="badge">${selected ? 'Used unless you name another agent' : 'Not set up yet'}</span></div>
|
|
511
|
+
<div class="row"><h3>Choose a provider</h3><button data-action="refresh-executors">Check availability</button></div>
|
|
512
|
+
<div class="provider-tabs" role="tablist" aria-label="Execution providers">${groups.map(candidate => `<button role="tab" aria-selected="${candidate.id === group.id}" class="provider-tab ${candidate.id === group.id ? 'selected' : ''}" data-action="select-execution-provider" data-id="${esc(candidate.id)}"><strong>${esc(candidate.label)}</strong><span>${candidate.agents.length ? `${candidate.agents.length} agent${candidate.agents.length === 1 ? '' : 's'}` : 'Set up'}</span></button>`).join('')}</div>
|
|
513
|
+
<div class="card stack" role="tabpanel" aria-label="${esc(group.label)} agents"><div class="row"><div><h3>${esc(group.label)}</h3><p class="hint">Agents and models running on this computer.</p></div></div>
|
|
514
|
+
${instances.map(instance => `<div class="instance-status"><span>${esc(instance.displayName)}</span><span class="badge ${readiness(instance) === 'Ready' ? 'good' : 'warn'}">${esc(readiness(instance))}</span></div>${instance.status?.message ? `<p class="hint">${esc(instance.status.message)}</p>` : ''}`).join('')}
|
|
515
|
+
<div class="agent-list">${agentRows || `<p class="empty-agents">No agents added yet. Add one below to get started.</p>`}</div>
|
|
516
|
+
${group.id === 'other' ? '' : !providers ? '<button data-action="enable-providers">Enable local agents</button><p class="hint">Apply the change when prompted, then add your first agent here.</p>' : `
|
|
517
|
+
<details class="add-agent" ${group.agents.length ? '' : 'open'}><summary>Add ${esc(group.label)} agent</summary><div class="setup-form">
|
|
518
|
+
<label>Agent name<input id="profile-name" placeholder="My ${esc(group.label)} agent" value="${group.agents.length ? '' : `My ${esc(group.label)}`}" maxlength="80"></label>
|
|
519
|
+
${enabled.length > 1 ? `<label>Installation<select id="profile-instance">${enabled.map(instance => `<option value="${esc(instance.instanceId)}">${esc(instance.displayName)}</option>`).join('')}</select></label>` : `<input id="profile-instance" type="hidden" value="${esc(enabled[0]?.instanceId || '')}">`}
|
|
520
|
+
<label>Model<select id="profile-model">${modelChoices(selectedInstance)}</select></label><div class="actions"><button type="button" data-action="refresh-models" data-driver="${esc(group.id)}">Refresh models</button></div><p id="profile-model-status" class="hint" role="status" aria-live="polite">${modelMessage(selectedInstance)}</p>
|
|
521
|
+
<label>Thinking level<select id="profile-effort" ${effortLevelsFor(selectedInstance, '').length ? '' : 'disabled'}>${effortChoices(effortLevelsFor(selectedInstance, ''), effortLevelsFor(selectedInstance, '').includes(DEFAULT_EFFORT) ? DEFAULT_EFFORT : '')}</select></label><p id="profile-effort-status" class="hint">${effortHint(effortLevelsFor(selectedInstance, ''))}</p>
|
|
522
|
+
<label><span>System prompt <span class="optional">· Optional</span></span><textarea id="profile-prompt" maxlength="32000" placeholder="For example: Keep changes small, explain your plan before editing, and run the tests you touch."></textarea></label><p class="hint">Added to ${esc(group.label)}’s own instructions for every task this agent runs.</p>
|
|
523
|
+
<details><summary>Permissions</summary><label>When the agent changes files or runs commands<select id="profile-mode"><option value="approval-required">Ask me first</option><option value="read-only">Read only</option><option value="auto-accept-edits">Accept edits automatically</option><option value="auto">Automatic</option><option value="full-access">Full access</option></select></label></details>
|
|
524
|
+
<button class="primary" data-action="quick-add-agent" data-driver="${esc(group.id)}">Add agent and use by default</button><p class="hint">Uses this provider’s local installation and sign-in. Adding an agent does not install the provider or grant access to additional folders.</p>
|
|
525
|
+
</div></details>
|
|
526
|
+
${instances.length ? `<details><summary>Manage ${esc(group.label)} installation and agents</summary><div class="stack">${instances.map(instance => `<div class="row"><strong>${esc(instance.displayName)}</strong><div class="actions"><button data-action="edit-provider" data-id="${esc(instance.instanceId)}">Settings</button><button data-action="toggle-provider" data-id="${esc(instance.instanceId)}" data-driver="${esc(instance.driver)}" data-enabled="${instance.enabled}">${instance.enabled ? 'Disable' : 'Enable'}</button><button class="danger" data-action="remove-provider" data-id="${esc(instance.instanceId)}">Remove installation</button></div></div>`).join('')}${profiles.map(profile => `<div class="row"><span>${esc(profile.name)}${profile.available ? '' : ' · unavailable'}</span><button class="danger" data-action="remove-profile" data-id="${esc(profile.id)}">Remove agent</button></div>`).join('')}</div></details>` : ''}`}
|
|
527
|
+
</div><div id="profile-editor"></div><div id="provider-editor"></div>
|
|
528
|
+
${providers ? `<div class="card stack"><h3>Where local agents work</h3><p class="hint">${providers.folderRoots?.length ? `Any folder inside ${providers.folderRoots.map(root => `<span class="code">${esc(root)}</span>`).join(', ')}. Ask for a project by name or path; the companion lists the git projects it finds there.` : 'Only the named folders below.'} Set <span class="code">providers.folderRoots</span> in config.json to change this.</p>${providers.workspaces.length ? `<details><summary>Named folders · ${providers.workspaces.length}</summary>${providers.workspaces.map(workspace => `<p><strong>${esc(workspace.name)}</strong><br><span class="code">${esc(workspace.path)}</span></p>`).join('')}</details>` : ''}</div>` : ''}
|
|
529
|
+
</section>`;
|
|
530
|
+
}
|
|
531
|
+
function profileEditor(profile) {
|
|
532
|
+
const current = state.profilePrompts?.[profile.id] || '';
|
|
533
|
+
const instance = state.providers.instances.find(candidate => [candidate.instanceId, candidate.displayName].includes(profile.provider));
|
|
534
|
+
const levels = effortLevelsFor(instance, profile.model);
|
|
535
|
+
// Keep a saved level visible even if the model list has not loaded yet.
|
|
536
|
+
const shown = profile.reasoningEffort && !levels.includes(profile.reasoningEffort) ? [...levels, profile.reasoningEffort] : levels;
|
|
537
|
+
return `<div class="card stack"><div class="row"><div><h3>${esc(profile.name)}</h3><p class="hint">${esc(profile.provider)}${profile.model ? ` · ${esc(profile.model)}` : ''}. Changes apply to tasks started after you save.</p></div><button data-action="close-profile">Close</button></div><label>Thinking level<select id="profile-effort-edit" ${shown.length ? '' : 'disabled'}>${effortChoices(shown, profile.reasoningEffort || '')}</select></label><p class="hint">${effortHint(shown)}</p><label>System prompt<textarea id="profile-prompt-edit" class="prompt-text" maxlength="32000" placeholder="Describe how this agent should work. Added to ${esc(profile.provider)}’s own instructions at the start of each task.">${esc(current)}</textarea></label><div class="actions"><button class="primary" data-action="save-profile" data-id="${esc(profile.id)}">Save agent</button>${current ? `<button class="danger" data-action="clear-profile-prompt" data-id="${esc(profile.id)}">Remove prompt</button>` : ''}</div></div>`;
|
|
538
|
+
}
|
|
539
|
+
function providerEditor(p) {
|
|
540
|
+
return `<div class="card stack"><div class="row"><h3>${esc(p.displayName)} settings</h3><button data-action="close-provider">Close</button></div><label>Display name<input id="provider-name" value="${esc(p.displayName)}"></label>${p.settingsForm.map((field) => `<label>${esc(field.label)}${field.control === 'switch' ? `<input data-setting="${esc(field.id)}" type="checkbox" ${p.settings[field.id] ? 'checked' : ''}>` : field.control === 'select' ? `<select data-setting="${esc(field.id)}">${(field.options || []).map((o) => `<option value="${esc(o.id)}" ${p.settings[field.id] === o.id ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>` : `<input data-setting="${esc(field.id)}" type="${field.control === 'password' ? 'password' : 'text'}" value="${field.control === 'password' ? '' : esc(p.settings[field.id] || '')}" placeholder="${field.control === 'password' && p.secretsSet.includes(field.id) ? 'Already set — leave blank to keep' : esc(field.placeholder || '')}">`}</label>`).join('')}<details><summary>Environment variables</summary><p class="hint">One per line: NAME=value. Prefix a name with ! for a secret. Existing secret values are kept when left blank.</p><textarea id="provider-env">${esc(p.environment.map((v) => `${v.sensitive ? '!' : ''}${v.name}=${v.value || ''}`).join('\n'))}</textarea></details><div class="actions"><button class="primary" data-action="save-provider" data-id="${esc(p.instanceId)}">Save provider</button></div></div>`;
|
|
541
|
+
}
|
|
542
|
+
function accountLabel(value) {
|
|
543
|
+
return (
|
|
544
|
+
{
|
|
545
|
+
authorized: 'Connected',
|
|
546
|
+
needs_client: 'OAuth client ID needed',
|
|
547
|
+
needs_consent: 'Consent needed',
|
|
548
|
+
}[value] || 'No OAuth sign-in required'
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
function packStatusDetail(pack) {
|
|
552
|
+
const detail = pack.status.detail || '';
|
|
553
|
+
return detail.startsWith('Connect your account: run `rva-companion pack auth')
|
|
554
|
+
? 'Connect the account in Settings & permissions.'
|
|
555
|
+
: detail;
|
|
556
|
+
}
|
|
557
|
+
function catalogView() {
|
|
558
|
+
return `<div><h3>Integration catalog</h3><p class="hint">Install a baseline and this computer's agent sets it up: it finds settings and keys already on this computer and reports what needs you. Every new install starts disabled until setup enables it.</p></div><div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3>${esc(entry.name)}</h3><span class="badge">${entry.kind === 'planned' ? 'Planned' : entry.kind === 'executor' ? 'Executor' : entry.installed ? 'Installed' : 'Baseline'}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.upstream)}</p><details><summary>Setup & access</summary><ul>${entry.setup.map(step => `<li>${esc(step)}</li>`).join('')}</ul><p class="hint">${esc(entry.verification)}</p>${entry.permissions.length ? `<p class="hint">${entry.permissions.map(tool => `${code(tool.name)} (${esc(tool.access)})`).join(', ')}</p>` : ''}</details>${entry.kind === 'pack' ? `<button data-action="install-bundled-pack" data-service="${esc(entry.id)}" ${entry.installed ? 'disabled' : ''}>${entry.installed ? 'Installed' : `Install ${esc(entry.name)}`}</button>` : entry.kind === 'executor' ? '<a href="#execution">Set up execution agent</a>' : '<p class="hint">Not available for installation yet.</p>'}</article>`).join('')}</div>`;
|
|
559
|
+
}
|
|
560
|
+
function mcpView() {
|
|
561
|
+
return `<section class="section"><div><h2>Integrations & widgets</h2><p class="lede">Connect tools to Sidebud and choose which ones appear on your phone. Credentials and connections stay on this computer.</p></div>${announceView()}<h3>Installed integrations</h3><div class="grid">${state.packs.packs.map(p => `<div class="card"><div class="row"><h3>${esc(p.name)}</h3><span class="badge ${{ ok: 'good', needs_setup: 'warn', error: 'bad' }[p.status.state] || ''}">${esc({ ok: 'Enabled', needs_setup: 'Needs setup', error: 'Error', disabled: 'Disabled' }[p.status.state] || p.status.state)}</span></div><p class="hint">${esc(p.description)}</p><p class="hint">${esc(packStatusDetail(p))}</p>${state.packAuth[p.id] ? `<p class="hint">Account: ${esc(accountLabel(state.packAuth[p.id]))}</p>` : ''}${state.connectionErrors[p.id] ? `<p class="hint">Connection failed: ${esc(state.connectionErrors[p.id])}</p>` : ''}${p.refresh ? `<label class="hint">Refresh every <select data-refresh-pack="${esc(p.id)}" aria-label="Refresh ${esc(p.name)} every">${[...new Set([...p.refresh.options, p.refresh.defaultSeconds, p.refresh.seconds])].sort((a, b) => a - b).map((seconds) => `<option value="${seconds}" ${seconds === p.refresh.seconds ? 'selected' : ''}>${esc(interval(seconds))}${seconds === p.refresh.defaultSeconds ? ' (default)' : ''}</option>`).join('')}</select></label>` : ''}${p.announce ? `<p class="hint">Spoken updates: ${p.announce.on ? 'on' : 'off'}${p.announce.custom ? '' : ' (default)'} · <button class="link" data-action="toggle-announce" data-id="${esc(p.id)}" data-on="${p.announce.on}">${p.announce.on ? 'Turn off' : 'Turn on'}</button>${p.announce.custom ? ` · <button class="link" data-action="default-announce" data-id="${esc(p.id)}">Use default</button>` : ''}</p>` : ''}<div class="actions pack-actions"><button class="primary" data-action="setup-pack" data-id="${esc(p.id)}">${p.status.state === 'ok' ? 'Check setup with my agent' : 'Set up with my agent'}</button><button data-action="share-pack" disabled title="Publishing to your Sidebud account arrives with accounts">Share</button><details class="menu"><summary aria-label="More actions for ${esc(p.name)}">…</summary><div class="menu-list" role="menu"><button role="menuitem" data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="${p.enabled}">${p.enabled ? 'Disable' : 'Enable'}</button><button role="menuitem" data-action="edit-pack" data-id="${esc(p.id)}">Settings & permissions</button><button role="menuitem" data-action="check-pack" data-id="${esc(p.id)}" ${p.enabled ? '' : 'disabled'}>Check connection</button><button role="menuitem" data-action="customize-pack" data-id="${esc(p.id)}">Customize a copy</button><button role="menuitem" data-action="export-pack" data-id="${esc(p.id)}">Export</button></div></details></div></div>`).join('') || '<div class="card muted">No integrations installed. Choose a baseline below.</div>'}</div><div id="pack-editor"></div>${catalogView()}<div class="card"><h3>Import a widget pack</h3><p class="hint">Choose a <span class="code">.sidebud-widget.json</span> file exported from Sidebud. It installs disabled, then this computer's agent sets it up. Exports contain the pack and its logo only, never settings, keys, or account tokens.</p><div class="actions"><input id="pack-import" type="file" accept=".json,application/json" aria-label="Widget pack export file"><button data-action="import-pack">Import</button></div></div><div class="card"><h3>Install your own widget pack</h3><p class="hint">Provide the absolute folder containing pack.json. Existing installations are never overwritten.</p><div class="actions"><input id="pack-folder" aria-label="Widget pack folder" placeholder="/path/to/widget-pack"><button data-action="install-pack">Validate & install</button></div></div><div class="card"><h3>Create or share an adapter</h3><p class="hint">Use the <span class="code">skills/connect-mcp-widget/SKILL.md</span> guide to author a pack. A custom copy contains the manifest and logo only; configure its own credentials. Review any external server requirements before enabling it.</p><p class="hint">Community submissions and reviewed release distribution are planned. Local packs can be installed now.</p></div></section>`;
|
|
562
|
+
}
|
|
563
|
+
function customizePackEditor(p) {
|
|
564
|
+
return `<div class="card stack"><h3>Customize ${esc(p.name)}</h3><p class="hint">Creates a disabled copy with its own settings and credentials. The original is preserved.</p><label>New integration ID<input id="copy-pack-id" placeholder="dev.yourname.notes"></label><label>New display name<input id="copy-pack-name" placeholder="My notes"></label><div class="actions"><button data-action="save-pack-copy" data-id="${esc(p.id)}">Create copy</button><button data-action="close-pack">Cancel</button></div></div>`;
|
|
565
|
+
}
|
|
566
|
+
function packEditor(p) {
|
|
567
|
+
return `<div class="card stack"><div class="row"><div><h3>${esc(p.name)}</h3><p class="hint">${esc(p.author)} · ${esc(p.license)} · ${esc(p.version)}</p></div><button data-action="close-pack">Close</button></div><div><strong>Declared tools</strong><p class="hint">${p.permissions.tools.map((t) => `${code(t.name)} <span class="pill">${esc(t.access)}</span>`).join(' ') || 'None'}</p><p class="hint">Network: ${esc(p.permissions.network.join(', ') || 'none declared')}</p></div>${p.settings.map((setting) => `<label>${esc(setting.label)}${setting.type === 'boolean' ? `<input data-pack-setting="${esc(setting.id)}" type="checkbox" ${setting.value ? 'checked' : ''}>` : setting.type === 'select' ? `<select data-pack-setting="${esc(setting.id)}">${setting.options.map((o) => `<option value="${esc(o.id)}" ${setting.value === o.id ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>` : `<input data-pack-setting="${esc(setting.id)}" value="${esc(setting.value || '')}">`}</label>${setting.description ? `<p class="hint">${esc(setting.description)}</p>` : ''}`).join('')}${p.secrets.map((s) => `<label>${esc(s.name)} ${s.isSet ? '(set)' : ''}<input data-pack-secret="${esc(s.name)}" type="password" autocomplete="new-password" placeholder="${s.isSet ? 'Leave blank to keep' : 'Set on this computer'}"></label><p class="hint">${esc(s.description)}</p>${s.isSet ? `<button class="danger" data-action="clear-pack-secret" data-id="${esc(p.id)}" data-secret="${esc(s.name)}">Clear ${esc(s.name)}</button>` : ''}`).join('')}<div class="actions"><button class="primary" data-action="save-pack" data-id="${esc(p.id)}">Save settings</button>${state.packAuth[p.id] ? `<button data-action="connect-pack" data-id="${esc(p.id)}" ${state.connecting.includes(p.id) ? 'disabled' : ''}>${state.connecting.includes(p.id) ? 'Connecting…' : 'Connect account'}</button><button class="danger" data-action="disconnect-pack" data-id="${esc(p.id)}">Disconnect (local)</button>` : ''}</div><p class="hint">Account: ${esc(accountLabel(state.packAuth[p.id]))}. ${state.packAuth[p.id] ? "OAuth consent opens in this computer's browser. Disconnect removes local credentials and disables this pack; it does not revoke the provider grant." : 'Configure any required credentials above. Disable blocks new calls; clear saved credentials here and revoke them at the provider when removing access.'}</p></div>`;
|
|
568
|
+
}
|
|
569
|
+
function layoutsView() {
|
|
570
|
+
const active = state.devices.filter((d) => !d.revokedAt);
|
|
571
|
+
return `<section class="section"><div><h2>Phone layouts</h2><p class="lede">Review each paired phone's tile order, visibility, and size. Changes sync to that phone through the companion.</p></div>${
|
|
572
|
+
active.length
|
|
573
|
+
? active
|
|
574
|
+
.map((device) => {
|
|
575
|
+
const saved = state.layouts[device.id]?.tiles || [];
|
|
576
|
+
const tiles = [
|
|
577
|
+
...saved,
|
|
578
|
+
...state.widgets
|
|
579
|
+
.filter((w) => !saved.some((t) => t.widgetId === w.id))
|
|
580
|
+
.map((w) => ({
|
|
581
|
+
widgetId: w.id,
|
|
582
|
+
size: null,
|
|
583
|
+
hidden: false,
|
|
584
|
+
controls: null,
|
|
585
|
+
})),
|
|
586
|
+
];
|
|
587
|
+
return `<div class="card" data-device="${esc(device.id)}"><h3>${esc(device.name)}</h3><p class="hint">${esc(device.id)}</p><div class="tiles">${tiles.map((t, i) => `<div class="tile" data-widget="${esc(t.widgetId)}"><div><strong>${esc(state.widgets.find((w) => w.id === t.widgetId)?.title || t.widgetId)}</strong><div class="hint">${esc(t.widgetId)}</div></div><select class="size" aria-label="Tile size"><option value="" ${!t.size ? 'selected' : ''}>Default</option>${['small', 'medium', 'large'].map((size) => `<option value="${size}" ${t.size === size ? 'selected' : ''}>${size[0].toUpperCase()}${size.slice(1)}</option>`).join('')}</select><label class="check"><input class="hidden" type="checkbox" ${t.hidden ? 'checked' : ''}> Hide</label><input class="controls" type="hidden" value="${esc(JSON.stringify(t.controls))}"><div><button data-action="move-up" ${i === 0 ? 'disabled' : ''}>↑</button><button data-action="move-down" ${i === tiles.length - 1 ? 'disabled' : ''}>↓</button></div></div>`).join('')}</div><div class="actions"><button class="primary" data-action="save-layout" data-id="${esc(device.id)}">Save layout</button></div></div>`;
|
|
588
|
+
})
|
|
589
|
+
.join('')
|
|
590
|
+
: '<div class="card muted">Pair a phone to manage its layout. You can also arrange tiles directly on the phone.</div>'
|
|
591
|
+
}</section>`;
|
|
592
|
+
}
|
|
593
|
+
window.addEventListener('hashchange', () => { stopVoicePreview(); notice(''); render(); });
|
|
594
|
+
document.addEventListener('change', (event) => {
|
|
595
|
+
if (!state) return;
|
|
596
|
+
if (event.target.dataset.refreshPack) {
|
|
597
|
+
const pack = state.packs.packs.find((candidate) => candidate.id === event.target.dataset.refreshPack);
|
|
598
|
+
const seconds = Number(event.target.value);
|
|
599
|
+
void api('pack', { packId: pack.id, refreshSeconds: seconds === pack.refresh.defaultSeconds ? null : seconds }).then(() => { notice('Refresh interval saved.'); return reload(); }, (error) => notice(error.message || 'Could not save.'));
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (event.target.id === 'announce-mode') {
|
|
603
|
+
void api('announce-mode', { mode: event.target.value }).then(() => { notice('Spoken updates saved.'); return reload(); }, (error) => notice(error.message || 'Could not save.'));
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (event.target.id === 'profile-effort') {
|
|
607
|
+
event.target.dataset.touched = 'true';
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (event.target.id === 'profile-model') {
|
|
611
|
+
updateEffortChoices();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (event.target.id === 'profile-instance') {
|
|
615
|
+
updateModelChoices();
|
|
616
|
+
showModelLoading(false);
|
|
617
|
+
discoverModels();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (['voice-provider', 'voice-name', 'voice-model', 'voice-region'].includes(event.target.id)) {
|
|
621
|
+
stopVoicePreview();
|
|
622
|
+
previewStatus('Press Play voice sample to hear the selected voice.');
|
|
623
|
+
}
|
|
624
|
+
if (event.target.id === 'voice-model') {
|
|
625
|
+
const provider = state.voice.catalog[$('#voice-provider').value];
|
|
626
|
+
const voices = voiceOptionsFor(provider, event.target.value);
|
|
627
|
+
const keep = voices.some((option) => option.id === $('#voice-name').value) ? $('#voice-name').value : null;
|
|
628
|
+
$('#voice-name').innerHTML = voiceNameOptions(voices, keep);
|
|
629
|
+
if (!keep) $('#voice-name').value = voices.some((option) => option.id === provider?.defaultVoice) ? provider.defaultVoice : voices[0]?.id || '';
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (event.target.id !== 'voice-provider') return;
|
|
633
|
+
const provider = state.voice.catalog[event.target.value];
|
|
634
|
+
$('#voice-model').innerHTML = voiceModelOptions(provider, null);
|
|
635
|
+
$('#voice-endpoint').innerHTML = voiceEndpointFields(provider, state.voice.voice.provider === event.target.value ? state.voice.voice : { region: null, workspaceId: null });
|
|
636
|
+
$('#voice-model').disabled = !provider;
|
|
637
|
+
$('#voice-untested').hidden = !provider?.untested;
|
|
638
|
+
const voices = voiceOptionsFor(provider, $('#voice-model').value);
|
|
639
|
+
$('#voice-name').innerHTML = voiceNameOptions(voices, null);
|
|
640
|
+
$('#voice-name').value = voices.some((option) => option.id === provider?.defaultVoice) ? provider.defaultVoice : voices[0]?.id || '';
|
|
641
|
+
$('[data-action="preview-voice"]').disabled = !provider;
|
|
642
|
+
if (!provider) previewStatus('Choose a live voice provider to hear a sample.');
|
|
643
|
+
// Setup shows only the provider, model, voice, and key; the full Voice page also has search.
|
|
644
|
+
if ($('#voice-search-row')) $('#voice-search-row').hidden = !voiceSearchSupported(provider);
|
|
645
|
+
if ($('#voice-search-note')) $('#voice-search-note').hidden = !provider || voiceSearchSupported(provider);
|
|
646
|
+
$('#voice-key-panel').innerHTML = voiceKeysView(event.target.value);
|
|
647
|
+
});
|
|
648
|
+
// One open "…" menu at a time; clicking elsewhere or choosing an item closes it.
|
|
649
|
+
document.addEventListener('toggle', (event) => {
|
|
650
|
+
if (event.target.matches?.('details.menu[open]'))
|
|
651
|
+
document.querySelectorAll('details.menu[open]').forEach((menu) => menu !== event.target && menu.removeAttribute('open'));
|
|
652
|
+
}, true);
|
|
653
|
+
document.addEventListener('click', (event) => {
|
|
654
|
+
document.querySelectorAll('details.menu[open]').forEach((menu) => {
|
|
655
|
+
if (!menu.contains(event.target) || event.target.closest('.menu-list button')) menu.removeAttribute('open');
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
document.addEventListener('click', async (event) => {
|
|
659
|
+
const button = event.target.closest('button[data-action]');
|
|
660
|
+
if (!button || !state) return;
|
|
661
|
+
const action = button.dataset.action,
|
|
662
|
+
id = button.dataset.id;
|
|
663
|
+
try {
|
|
664
|
+
if (action === 'preview-voice') {
|
|
665
|
+
await playVoicePreview();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (action === 'stop-voice-preview') {
|
|
669
|
+
stopVoicePreview();
|
|
670
|
+
previewStatus('Sample stopped.');
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (action === 'select-execution-provider') {
|
|
674
|
+
executionProvider = id;
|
|
675
|
+
render();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (action === 'refresh-executors') {
|
|
679
|
+
const originalLabel = button.textContent;
|
|
680
|
+
button.disabled = true; button.textContent = 'Checking…';
|
|
681
|
+
try { await api('executors/refresh', {}); await reload(); }
|
|
682
|
+
finally { button.disabled = false; button.textContent = originalLabel; }
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (action === 'refresh-models') {
|
|
686
|
+
await refreshModels(button.dataset.driver);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (action === 'quick-add-agent') {
|
|
690
|
+
const name = $('#profile-name').value.trim();
|
|
691
|
+
if (!name) throw new Error('Give this agent a name.');
|
|
692
|
+
const driver = button.dataset.driver;
|
|
693
|
+
const instanceId = $('#profile-instance').value || `${driver}_${crypto.randomUUID().slice(0, 8)}`;
|
|
694
|
+
const profileId = button.dataset.profileId || `agent_${crypto.randomUUID().slice(0, 8)}`;
|
|
695
|
+
button.dataset.profileId = profileId;
|
|
696
|
+
const model = $('#profile-model').value.trim();
|
|
697
|
+
const runtimeMode = $('#profile-mode').value;
|
|
698
|
+
const systemPrompt = $('#profile-prompt').value.trim();
|
|
699
|
+
const reasoningEffort = $('#profile-effort').value;
|
|
700
|
+
button.disabled = true;
|
|
701
|
+
try {
|
|
702
|
+
if (!$('#profile-instance').value) {
|
|
703
|
+
await api('provider', { instanceId, driver, enabled: true });
|
|
704
|
+
$('#profile-instance').value = instanceId;
|
|
705
|
+
}
|
|
706
|
+
await api('profile', { id: profileId, name, providerLabel: instanceId, runtimeMode, ...(model ? { modelLabel: model } : {}), ...(systemPrompt ? { systemPrompt } : {}), ...(reasoningEffort ? { reasoningEffort } : {}) });
|
|
707
|
+
await api('executor', { executorId: `direct:${profileId}` });
|
|
708
|
+
notice(`${name} is now your default agent.`);
|
|
709
|
+
if (section() === 'setup') await api('setup/check', { kind: 'execution' });
|
|
710
|
+
await reload();
|
|
711
|
+
} finally { button.disabled = false; }
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (action === 'refresh') {
|
|
715
|
+
await reload();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (action === 'apply-changes') {
|
|
719
|
+
await applyChanges();
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (await handleSetupAction(action, button)) return;
|
|
723
|
+
if (await handleSettingsAction(action, button)) return;
|
|
724
|
+
if (action === 'pair-phone') {
|
|
725
|
+
await loadPairing();
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
if (action === 'revoke-phone') {
|
|
729
|
+
if (
|
|
730
|
+
!confirm(
|
|
731
|
+
'Remove this phone from this computer? It will need a new pairing code to reconnect.'
|
|
732
|
+
)
|
|
733
|
+
)
|
|
734
|
+
return;
|
|
735
|
+
await api('device/revoke', { deviceId: id });
|
|
736
|
+
notice('Phone removed from this computer.');
|
|
737
|
+
await reload();
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (action === 'save-voice') {
|
|
741
|
+
const liveProvider = state.voice.catalog[$('#voice-provider').value];
|
|
742
|
+
if (liveProvider?.needsWorkspaceId && !voiceEndpoint().workspaceId) return notice(`Add your ${liveProvider.label} workspace ID.`, true);
|
|
743
|
+
const phrases = commandPhrases();
|
|
744
|
+
if (phrases.error) return notice(phrases.error, true);
|
|
745
|
+
await api('voice', {
|
|
746
|
+
voice: {
|
|
747
|
+
provider: $('#voice-provider').value,
|
|
748
|
+
model: $('#voice-model').value || null,
|
|
749
|
+
voiceName: $('#voice-name').value || null,
|
|
750
|
+
...voiceEndpoint(),
|
|
751
|
+
commands: phrases.commands,
|
|
752
|
+
autoMute: { seconds: Number($('#voice-auto-mute').value), enabled: $('#voice-auto-mute-on').checked },
|
|
753
|
+
webSearch: $('#voice-search').checked,
|
|
754
|
+
maxSessionMinutes: Number($('#voice-minutes').value),
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
notice('Voice settings saved.');
|
|
758
|
+
await reload();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (action === 'save-voice-key' || action === 'clear-voice-key') {
|
|
762
|
+
await api('voice-key', {
|
|
763
|
+
provider: button.dataset.provider,
|
|
764
|
+
value:
|
|
765
|
+
action === 'clear-voice-key'
|
|
766
|
+
? ''
|
|
767
|
+
: document.querySelector(
|
|
768
|
+
`[data-voice-key="${button.dataset.provider}"]`
|
|
769
|
+
).value,
|
|
770
|
+
});
|
|
771
|
+
notice('Key updated in this computer’s keychain.');
|
|
772
|
+
await reload();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (action === 'set-default-executor') {
|
|
776
|
+
await api('executor', { executorId: id });
|
|
777
|
+
notice('Default executor updated.');
|
|
778
|
+
await reload();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (action === 'enable-providers') {
|
|
782
|
+
await api('enable-providers', {});
|
|
783
|
+
notice('Local agents enabled.');
|
|
784
|
+
await reload();
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (action === 'toggle-provider') {
|
|
788
|
+
await api('provider', {
|
|
789
|
+
instanceId: id,
|
|
790
|
+
driver: button.dataset.driver,
|
|
791
|
+
enabled: button.dataset.enabled !== 'true',
|
|
792
|
+
});
|
|
793
|
+
notice('Provider updated.');
|
|
794
|
+
await reload();
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (action === 'remove-profile') {
|
|
798
|
+
if (!confirm(`Remove agent profile ${id}?`)) return;
|
|
799
|
+
await api('profile/remove', { profileId: id });
|
|
800
|
+
notice('Agent profile removed.');
|
|
801
|
+
await reload();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
if (action === 'remove-provider') {
|
|
805
|
+
if (!confirm(`Remove provider ${id} and its stored secrets?`)) return;
|
|
806
|
+
await api('provider/remove', { instanceId: id });
|
|
807
|
+
notice('Provider removed.');
|
|
808
|
+
await reload();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (action === 'edit-profile') {
|
|
812
|
+
const profile = state.providers.profiles.find((candidate) => candidate.id === id);
|
|
813
|
+
$('#profile-editor').innerHTML = profileEditor(profile);
|
|
814
|
+
$('#profile-editor').scrollIntoView({ behavior: 'smooth' });
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (action === 'close-profile') {
|
|
818
|
+
$('#profile-editor').innerHTML = '';
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (action === 'save-profile') {
|
|
822
|
+
await api('profile/update', { profileId: id, systemPrompt: $('#profile-prompt-edit').value, reasoningEffort: $('#profile-effort-edit').value || null });
|
|
823
|
+
notice('Agent saved. Changes apply to its next task.');
|
|
824
|
+
await reload();
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (action === 'clear-profile-prompt') {
|
|
828
|
+
await api('profile/update', { profileId: id, systemPrompt: '' });
|
|
829
|
+
notice('System prompt removed.');
|
|
830
|
+
await reload();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (action === 'edit-provider') {
|
|
834
|
+
$('#provider-editor').innerHTML = providerEditor(
|
|
835
|
+
state.providers.instances.find((p) => p.instanceId === id)
|
|
836
|
+
);
|
|
837
|
+
$('#provider-editor').scrollIntoView({ behavior: 'smooth' });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (action === 'close-provider') {
|
|
841
|
+
$('#provider-editor').innerHTML = '';
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (action === 'save-provider') {
|
|
845
|
+
const p = state.providers.instances.find((x) => x.instanceId === id),
|
|
846
|
+
settings = {};
|
|
847
|
+
document.querySelectorAll('[data-setting]').forEach((el) => {
|
|
848
|
+
if (el.type === 'password' && !el.value) return;
|
|
849
|
+
settings[el.dataset.setting] =
|
|
850
|
+
el.type === 'checkbox' ? el.checked : el.value;
|
|
851
|
+
});
|
|
852
|
+
const environment = $('#provider-env')
|
|
853
|
+
.value.split('\n')
|
|
854
|
+
.filter((line) => line.trim())
|
|
855
|
+
.map((line) => {
|
|
856
|
+
const match = line.match(/^(!?)([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
857
|
+
if (!match) throw Error(`Invalid environment line: ${line}`);
|
|
858
|
+
return {
|
|
859
|
+
name: match[2],
|
|
860
|
+
value: match[3] || (match[1] ? null : ''),
|
|
861
|
+
sensitive: Boolean(match[1]),
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
await api('provider', {
|
|
865
|
+
instanceId: id,
|
|
866
|
+
driver: p.driver,
|
|
867
|
+
displayName: $('#provider-name').value,
|
|
868
|
+
settings,
|
|
869
|
+
environment,
|
|
870
|
+
});
|
|
871
|
+
notice('Provider settings saved.');
|
|
872
|
+
await reload();
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
if (action === 'toggle-announce' || action === 'default-announce') {
|
|
876
|
+
await api('pack', { packId: id, announce: action === 'default-announce' ? null : button.dataset.on !== 'true' });
|
|
877
|
+
notice('Spoken updates saved.');
|
|
878
|
+
await reload();
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
if (action === 'toggle-pack') {
|
|
882
|
+
await api('pack', {
|
|
883
|
+
packId: id,
|
|
884
|
+
enabled: button.dataset.enabled !== 'true',
|
|
885
|
+
});
|
|
886
|
+
notice('Widget pack updated.');
|
|
887
|
+
await reload();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (action === 'customize-pack') {
|
|
891
|
+
$('#pack-editor').innerHTML = customizePackEditor(state.packs.packs.find(p => p.id === id));
|
|
892
|
+
$('#pack-editor').scrollIntoView({ behavior: 'smooth' });
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (action === 'save-pack-copy') {
|
|
896
|
+
const result = await api('pack/customize', { packId: id, id: $('#copy-pack-id').value.trim(), name: $('#copy-pack-name').value.trim() });
|
|
897
|
+
notice(`Copy created at ${result.folder}. Set its credentials and settings before enabling it.`);
|
|
898
|
+
await reload();
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (action === 'export-pack') {
|
|
902
|
+
const bundle = await api('pack/export', { packId: id });
|
|
903
|
+
const link = document.createElement('a');
|
|
904
|
+
link.href = URL.createObjectURL(new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }));
|
|
905
|
+
link.download = `${id}.sidebud-widget.json`;
|
|
906
|
+
link.click();
|
|
907
|
+
URL.revokeObjectURL(link.href);
|
|
908
|
+
notice('Exported. The file holds the pack and its logo only; settings and keys stay on this computer.');
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (action === 'import-pack') {
|
|
912
|
+
const file = $('#pack-import').files[0];
|
|
913
|
+
if (!file) return notice('Choose an exported widget pack file first.');
|
|
914
|
+
let bundle;
|
|
915
|
+
try {
|
|
916
|
+
bundle = JSON.parse(await file.text());
|
|
917
|
+
} catch {
|
|
918
|
+
return notice('That file is not a Sidebud widget pack export.');
|
|
919
|
+
}
|
|
920
|
+
const result = await api('pack/import', { bundle });
|
|
921
|
+
notice(
|
|
922
|
+
result.setup.started
|
|
923
|
+
? `${result.name} imported. This computer's agent is setting it up and reports back when it finishes.`
|
|
924
|
+
: `${result.name} imported, but setup did not start: ${result.setup.note}`
|
|
925
|
+
);
|
|
926
|
+
await reload();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (action === 'setup-pack') {
|
|
930
|
+
await api('pack/setup', { packId: id });
|
|
931
|
+
notice("This computer's agent is setting it up. Its task reports back on your phone when it finishes or if something needs you.");
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (action === 'check-pack') {
|
|
935
|
+
const result = await api('pack/check', { packId: id });
|
|
936
|
+
notice(`Connected. All ${result.tools} declared tools are available. This check did not execute them.`);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (action === 'edit-pack') {
|
|
940
|
+
$('#pack-editor').innerHTML = packEditor(
|
|
941
|
+
state.packs.packs.find((p) => p.id === id)
|
|
942
|
+
);
|
|
943
|
+
$('#pack-editor').scrollIntoView({ behavior: 'smooth' });
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (action === 'close-pack') {
|
|
947
|
+
$('#pack-editor').innerHTML = '';
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if (action === 'save-pack') {
|
|
951
|
+
const settings = {},
|
|
952
|
+
secrets = [];
|
|
953
|
+
document
|
|
954
|
+
.querySelectorAll('[data-pack-setting]')
|
|
955
|
+
.forEach(
|
|
956
|
+
(el) =>
|
|
957
|
+
(settings[el.dataset.packSetting] =
|
|
958
|
+
el.type === 'checkbox' ? el.checked : el.value)
|
|
959
|
+
);
|
|
960
|
+
document.querySelectorAll('[data-pack-secret]').forEach((el) => {
|
|
961
|
+
if (el.value)
|
|
962
|
+
secrets.push({ name: el.dataset.packSecret, value: el.value });
|
|
963
|
+
});
|
|
964
|
+
await api('pack', { packId: id, settings, secrets });
|
|
965
|
+
notice('Pack settings saved on this computer.');
|
|
966
|
+
await reload();
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (action === 'clear-pack-secret') {
|
|
970
|
+
if (
|
|
971
|
+
!confirm(
|
|
972
|
+
`Clear ${button.dataset.secret} from this computer's keychain?`
|
|
973
|
+
)
|
|
974
|
+
)
|
|
975
|
+
return;
|
|
976
|
+
await api('pack', {
|
|
977
|
+
packId: id,
|
|
978
|
+
secrets: [{ name: button.dataset.secret, value: '' }],
|
|
979
|
+
});
|
|
980
|
+
notice('Pack secret cleared from this computer.');
|
|
981
|
+
await reload();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (action === 'install-bundled-pack') {
|
|
985
|
+
const result = await api('pack/install-bundled', {
|
|
986
|
+
service: button.dataset.service,
|
|
987
|
+
});
|
|
988
|
+
notice(
|
|
989
|
+
result.setup.started
|
|
990
|
+
? `${result.name} installed. This computer's agent is setting it up and reports back when it finishes.`
|
|
991
|
+
: `${result.name} installed, but setup did not start: ${result.setup.note} Set it up in Settings & permissions.`
|
|
992
|
+
);
|
|
993
|
+
await reload();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
if (action === 'install-pack') {
|
|
997
|
+
const folder = $('#pack-folder').value.trim();
|
|
998
|
+
const result = await api('pack/install', { folder });
|
|
999
|
+
notice(
|
|
1000
|
+
`${result.name} installed. Review its permissions before enabling it.`
|
|
1001
|
+
);
|
|
1002
|
+
await reload();
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (action === 'connect-pack' || action === 'disconnect-pack') {
|
|
1006
|
+
if (
|
|
1007
|
+
action === 'disconnect-pack' &&
|
|
1008
|
+
!confirm(
|
|
1009
|
+
'Remove the local account token and disable the pack? The Google grant is not revoked.'
|
|
1010
|
+
)
|
|
1011
|
+
)
|
|
1012
|
+
return;
|
|
1013
|
+
await api(
|
|
1014
|
+
`pack/${action === 'connect-pack' ? 'connect' : 'disconnect'}`,
|
|
1015
|
+
{ packId: id }
|
|
1016
|
+
);
|
|
1017
|
+
notice(
|
|
1018
|
+
action === 'connect-pack'
|
|
1019
|
+
? 'Continue consent in the browser window. This page will refresh when it finishes.'
|
|
1020
|
+
: 'Local account token removed and pack disabled.'
|
|
1021
|
+
);
|
|
1022
|
+
await reload();
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (action === 'move-up' || action === 'move-down') {
|
|
1026
|
+
const row = button.closest('.tile'),
|
|
1027
|
+
sibling =
|
|
1028
|
+
action === 'move-up'
|
|
1029
|
+
? row.previousElementSibling
|
|
1030
|
+
: row.nextElementSibling;
|
|
1031
|
+
if (sibling)
|
|
1032
|
+
row.parentElement.insertBefore(
|
|
1033
|
+
row,
|
|
1034
|
+
action === 'move-up' ? sibling : sibling.nextElementSibling
|
|
1035
|
+
);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (action === 'save-layout') {
|
|
1039
|
+
const card = button.closest('[data-device]'),
|
|
1040
|
+
tiles = [...card.querySelectorAll('.tile')].map((row) => ({
|
|
1041
|
+
widgetId: row.dataset.widget,
|
|
1042
|
+
size: row.querySelector('.size').value || null,
|
|
1043
|
+
hidden: row.querySelector('.hidden').checked,
|
|
1044
|
+
controls: JSON.parse(row.querySelector('.controls').value),
|
|
1045
|
+
}));
|
|
1046
|
+
await api('layout', { deviceId: id, tiles });
|
|
1047
|
+
notice('Phone layout saved.');
|
|
1048
|
+
await reload();
|
|
1049
|
+
}
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
notice(error.message, true);
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
reload();
|