sidebud 0.2.0 → 0.5.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/CHANGELOG.md +118 -0
- package/README.md +37 -0
- package/dist/main.js +11284 -3582
- package/package.json +3 -1
- package/skills/widget-packs/SKILL.md +9 -8
- package/src/manage/guide.md +5 -0
- package/src/manage/index.html +4 -0
- package/src/manage/manage.css +129 -0
- package/src/manage/manage.js +244 -29
- package/src/manage/packs.js +37 -6
- package/src/manage/settings.js +232 -6
- package/src/manage/setup.js +128 -23
package/src/manage/manage.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const SECTIONS = ['setup', 'connection', 'voice', 'execution', 'mcp', 'phone', 'layouts', 'guide', 'settings'];
|
|
1
|
+
const SECTIONS = ['setup', 'connection', 'voice', 'execution', 'decisions', 'mcp', 'phone', 'layouts', 'guide', 'settings'];
|
|
2
2
|
// Links carry the token in the fragment (never sent to the server): `#t=<token>&s=<section>`.
|
|
3
3
|
const linked = new URLSearchParams(location.hash.startsWith('#t=') ? location.hash.slice(1) : '');
|
|
4
4
|
const token = linked.get('t') || sessionStorage.getItem('rvaManageToken');
|
|
@@ -59,7 +59,8 @@ async function api(path, payload) {
|
|
|
59
59
|
}).catch(() => { throw new Error(RESTARTED); });
|
|
60
60
|
const data = await response.json();
|
|
61
61
|
if (!response.ok)
|
|
62
|
-
|
|
62
|
+
// The body rides along for answers that ask something of the owner (an update that needs consent).
|
|
63
|
+
throw Object.assign(new Error(data.error || `Request failed (${response.status})`), { data });
|
|
63
64
|
return data;
|
|
64
65
|
}
|
|
65
66
|
/** Re-reads companion state; background polls re-render only when it changed, so forms keep their input. */
|
|
@@ -70,8 +71,9 @@ async function reload(force = true) {
|
|
|
70
71
|
state = next;
|
|
71
72
|
if (changed) render();
|
|
72
73
|
clearTimeout(reload.timer);
|
|
73
|
-
// Poll only while something is in progress: an account connection, a setup check,
|
|
74
|
-
|
|
74
|
+
// Poll only while something is in progress: an account connection, a setup check, pairing during setup, a pack's setup, or a sync.
|
|
75
|
+
const packSetupRunning = Object.values(state.packSetup || {}).some((setup) => setup.running);
|
|
76
|
+
if (state.connecting.length || setupWaiting() || packSetupRunning || state.account.status === 'pending' || state.sync?.state === 'syncing') reload.timer = setTimeout(() => reload(false), 1500);
|
|
75
77
|
} catch (error) {
|
|
76
78
|
$('#app').innerHTML = `<div class="card">${esc(error.message)}</div>`;
|
|
77
79
|
}
|
|
@@ -93,7 +95,7 @@ async function applyChanges() {
|
|
|
93
95
|
await pause(500);
|
|
94
96
|
try {
|
|
95
97
|
const next = await api('state');
|
|
96
|
-
if (!next.restart.pending) {
|
|
98
|
+
if (!next.restart.pending && !next.sync?.restartPending) {
|
|
97
99
|
state = next;
|
|
98
100
|
notice('Changes applied.');
|
|
99
101
|
return true;
|
|
@@ -118,8 +120,21 @@ function showsExecution() {
|
|
|
118
120
|
return section() === 'execution' || (section() === 'setup' && currentSetupStep() === 'execution' && Boolean(state.providers));
|
|
119
121
|
}
|
|
120
122
|
function restartBanner() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
// Changes from the account (ADR 0014) that the running companion reads only at start apply the same way.
|
|
124
|
+
if (!state.restart.pending && !state.sync?.restartPending) return '';
|
|
125
|
+
return `<div class="banner"><div><strong>${state.restart.pending ? 'Saved changes apply after a restart.' : 'Changes from your account 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>`;
|
|
126
|
+
}
|
|
127
|
+
/** A newer release is on npm; updating is the user's command (shown exactly), never automatic. */
|
|
128
|
+
function updateBanner() {
|
|
129
|
+
const updates = state.install?.updates;
|
|
130
|
+
const update = state.install?.update;
|
|
131
|
+
if (!updates?.available) return '';
|
|
132
|
+
if (update?.scheduledAt)
|
|
133
|
+
return `<div class="banner"><div><strong>Sidebud ${esc(updates.latest)} installs ${esc(new Date(update.scheduledAt).toLocaleString())}</strong><p class="hint">It waits until no call or job is running, then restarts the companion.</p></div><div class="actions"><button data-action="update" data-when="now">Update now</button><button data-action="update" data-when="cancel">Cancel</button></div></div>`;
|
|
134
|
+
if (update?.canApply)
|
|
135
|
+
return `<div class="banner"><div><strong>Update available: Sidebud ${esc(updates.latest)}</strong><p class="hint">This computer has ${esc(updates.current)}. Updating restarts the companion and ends any call in progress.${update.lastError ? ` The last attempt failed: ${esc(update.lastError.message)}` : ''}</p></div><div class="actions"><button class="primary" data-action="update" data-when="now" ${update.applying ? 'disabled' : ''}>${update.applying ? 'Updating…' : 'Update now'}</button><button data-action="update" data-when="idle" title="As soon as no call is live and no agent work runs here">When idle</button><button data-action="update" data-when="tonight">Tonight</button></div></div>`;
|
|
136
|
+
const restart = state.install.kind === 'npx' ? 'Quit Sidebud first; the command starts the new version.' : state.install.loginItem.running ? 'The restart ends any call in progress.' : 'Then quit and start Sidebud again.';
|
|
137
|
+
return `<div class="banner"><div><strong>Update available: Sidebud ${esc(updates.latest)}</strong><p class="hint">This computer has ${esc(updates.current)}. Run <span class="code">${esc(updates.command)}</span> in a terminal. ${restart}</p></div></div>`;
|
|
123
138
|
}
|
|
124
139
|
function setupBanner() {
|
|
125
140
|
if (state.setup.completedAt || section() === 'setup') return '';
|
|
@@ -134,11 +149,12 @@ function render() {
|
|
|
134
149
|
.querySelectorAll('nav a')
|
|
135
150
|
.forEach((a) => a.classList.toggle('active', a.hash === `#${current}`));
|
|
136
151
|
const scrollTo = location.hash.startsWith('#guide/');
|
|
137
|
-
$('#app').innerHTML = restartBanner() + setupBanner() + {
|
|
152
|
+
$('#app').innerHTML = restartBanner() + updateBanner() + setupBanner() + {
|
|
138
153
|
setup: setupView,
|
|
139
154
|
connection: connectionView,
|
|
140
155
|
voice: voiceView,
|
|
141
156
|
execution: executionView,
|
|
157
|
+
decisions: decisionsView,
|
|
142
158
|
mcp: () => (packPageId() ? packPageView() : libraryPageSlug() ? libraryPackView() : mcpView()),
|
|
143
159
|
phone: phoneView,
|
|
144
160
|
layouts: layoutsView,
|
|
@@ -194,11 +210,47 @@ function connectionView() {
|
|
|
194
210
|
const connected = new Set(state.connectedDeviceIds || []);
|
|
195
211
|
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>`;
|
|
196
212
|
}
|
|
213
|
+
const SOURCES = {
|
|
214
|
+
account: { label: 'Account', title: 'From your Sidebud account' },
|
|
215
|
+
override: { label: 'This computer', title: 'Kept on this computer when your account’s settings change' },
|
|
216
|
+
default: { label: 'Default', title: 'Sidebud’s default; neither your account nor this computer sets it' },
|
|
217
|
+
};
|
|
218
|
+
/** A value's source by dotted path: the path itself, else the nearest enclosing field, else the default. */
|
|
219
|
+
function settingSource(section, path) {
|
|
220
|
+
const sources = state.settingSources?.[section] || {};
|
|
221
|
+
const keys = path.split('.');
|
|
222
|
+
for (let length = keys.length; length > 0; length -= 1) {
|
|
223
|
+
const source = sources[keys.slice(0, length).join('.')];
|
|
224
|
+
if (source) return source;
|
|
225
|
+
}
|
|
226
|
+
return 'default';
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Where a synced value comes from (ADR 0014). While sync is on: Account, This computer, or Default, with a
|
|
230
|
+
* toggle to pin it here or follow the account again. While sync is off, only values pinned here are marked.
|
|
231
|
+
*/
|
|
232
|
+
function sourceBadge(section, path) {
|
|
233
|
+
if (!state.sync?.enabled) return state.settingSources?.[section]?.[path] === 'override' ? `<span class="badge source" title="${SOURCES.override.title}">${SOURCES.override.label}</span>` : '';
|
|
234
|
+
const source = settingSource(section, path);
|
|
235
|
+
const pinned = source === 'override';
|
|
236
|
+
return `<span class="badge source ${source}" title="${SOURCES[source].title}">${SOURCES[source].label}</span><button type="button" class="link pin-toggle" data-action="setting-pin" data-section="${esc(section)}" data-path="${esc(path)}" data-pinned="${pinned}">${pinned ? 'Follow account' : 'Keep on this computer'}</button>`;
|
|
237
|
+
}
|
|
238
|
+
/** A field label with its source badge on the same line; while sync is on, one badge per field (a pinned path first). */
|
|
239
|
+
function sourceLabel(text, section, ...paths) {
|
|
240
|
+
const shown = state.sync?.enabled ? [paths.find((path) => state.settingSources?.[section]?.[path] === 'override') || paths[0]] : paths;
|
|
241
|
+
const badges = shown.map((path) => sourceBadge(section, path)).join('');
|
|
242
|
+
return badges ? `<span class="label-text">${text}${badges}</span>` : text;
|
|
243
|
+
}
|
|
244
|
+
/** A synced voice choice can name a provider whose key is not on this computer; keys never come from the phone. */
|
|
245
|
+
function voiceNeedsKey(providerId) {
|
|
246
|
+
const key = state.voice.keyStates[providerId];
|
|
247
|
+
return Boolean(key && !key.isSet);
|
|
248
|
+
}
|
|
197
249
|
function voiceKeyView(providerId) {
|
|
198
250
|
const provider = state.voice.catalog[providerId];
|
|
199
251
|
if (!provider) return '';
|
|
200
252
|
const key = state.voice.keyStates[providerId];
|
|
201
|
-
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
|
|
253
|
+
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 this provider’s key here to use it. It stays in this computer’s keychain and is never synced or asked for on the phone.'}</p></div><span class="badge ${key.isSet ? 'good' : 'warn'}">${key.isSet ? 'Configured' : 'Needs a key on this computer'}</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>`;
|
|
202
254
|
}
|
|
203
255
|
function voiceKeysView(voiceProvider) {
|
|
204
256
|
return state.voice.catalog[voiceProvider]
|
|
@@ -221,10 +273,14 @@ const voiceSearchSupported = (provider) => Boolean(provider?.capabilities.webSea
|
|
|
221
273
|
/** Region and workspace fields, for providers whose endpoint needs them. */
|
|
222
274
|
function voiceEndpointFields(provider, voice) {
|
|
223
275
|
const regions = provider?.regions || [];
|
|
224
|
-
return `${regions.length ? `<label
|
|
276
|
+
return `${regions.length ? `<label>${sourceLabel('Region', 'voice', '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>${sourceLabel('Workspace ID', 'voice', 'workspaceId')}<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>` : ''}`;
|
|
225
277
|
}
|
|
226
278
|
/** 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. */
|
|
227
279
|
const MODEL_STUDIO_HOST = /^(?:[a-z]+:\/\/)?([a-z0-9-]+)\.(ap-southeast-1|cn-beijing)\.maas\.aliyuncs\.com/i;
|
|
280
|
+
/** The spoken command phrase fields, shared by the Voice page and setup; read back by commandPhrases(). */
|
|
281
|
+
function commandPhraseFields(commands) {
|
|
282
|
+
return `<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(commands.mute.join(', '))}"></label><label>Mute mic only<input id="voice-mute-mic-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.muteMic.join(', '))}"></label><label>Mute voice only<input id="voice-mute-voice-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.muteVoice.join(', '))}"></label><label>Unmute everything<input id="voice-unmute-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.unmute.join(', '))}"></label><label>End the call<input id="voice-end-call-phrases" maxlength="220" autocomplete="off" spellcheck="false" value="${esc(commands.endCall.join(', '))}"></label>`;
|
|
283
|
+
}
|
|
228
284
|
/** Same rules as the companion's voiceCommandPhraseSchema, checked here to say what is wrong. */
|
|
229
285
|
function commandPhrases() {
|
|
230
286
|
const read = (id) => $(id).value.split(',').map((phrase) => phrase.toLowerCase().replace(/[^a-z'\s]/g, ' ').replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
@@ -249,12 +305,47 @@ function voiceUsageView(usage) {
|
|
|
249
305
|
const usd = (value) => `$${value < 0.1 ? value.toFixed(3) : value.toFixed(2)}`;
|
|
250
306
|
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>`;
|
|
251
307
|
}
|
|
308
|
+
/** Its own page while it is in beta: the fast-decision model and its key. */
|
|
309
|
+
function decisionsView() {
|
|
310
|
+
return `<section class="section"><div><h2>Fast decisions <span class="tag-beta">Beta</span></h2><p class="lede">Let simple requests skip the execution agent: a fast decision model answers or acts on this computer in about a second. Anything it is unsure about goes to the execution agent as usual.</p></div>${jevView()}</section>`;
|
|
311
|
+
}
|
|
312
|
+
/** Fast decisions (Jev): the user's own TypeSafe key, or a local server with the same API. */
|
|
313
|
+
function jevView() {
|
|
314
|
+
const jev = state.jev;
|
|
315
|
+
if (!jev) return '';
|
|
316
|
+
const { status, config, key } = jev;
|
|
317
|
+
const running = status.state === 'ready' ? `On · ${status.kind === 'local' ? 'local server' : 'TypeSafe'}` : status.state === 'needs_key' ? 'Off · needs a key' : 'Off';
|
|
318
|
+
const keyHint = key.fromEnv ? 'Provided by this computer’s environment (TYPESAFE_API_KEY).' : key.isSet ? 'Stored in this computer’s keychain.' : 'Use your own key from typesafe.ai. It stays in this computer’s keychain and is only sent to TypeSafe.';
|
|
319
|
+
return `<div class="card stack"><div class="row"><div><h3>Decision model (Jev)</h3><p class="hint">A System One model decides in about a tenth of a second whether the computer can do a request itself: open or quit apps, volume, music, dark mode, lock the screen, reminders, what agents are doing, one integration’s data, and home tile layout. Anything else, or anything it is unsure about, goes to the worker as usual. Without a key or local server, every request goes to the worker.</p></div><span class="badge ${status.state === 'ready' ? 'good' : 'warn'}">${esc(running)}</span></div>
|
|
320
|
+
<div class="check"><input id="jev-on" type="checkbox" ${config.mode === 'auto' ? 'checked' : ''}><label for="jev-on">Use fast decisions when a key or local server is set</label></div>
|
|
321
|
+
<label>Local Jev-compatible server (optional)<input id="jev-url" type="url" placeholder="Empty: TypeSafe’s hosted API" value="${esc(config.url ?? '')}"></label>
|
|
322
|
+
<p class="hint">For a model running on this computer or your network that serves the same <span class="code">POST /v1/systemone</span> API. No key is sent to it.</p>
|
|
323
|
+
<label>Model<input id="jev-model" value="${esc(config.model)}"></label>
|
|
324
|
+
<label>Act only at this confidence or higher (0.5–0.99)<input id="jev-confidence" type="number" min="0.5" max="0.99" step="0.01" value="${config.minConfidence}"></label>
|
|
325
|
+
<label>Give up after (ms)<input id="jev-timeout" type="number" min="200" max="10000" step="100" value="${config.timeoutMs}"></label>
|
|
326
|
+
${jev.computerUse ? computerUseView(jev.computerUse) : ''}
|
|
327
|
+
<div class="actions"><button class="primary" data-action="save-jev">Save fast decisions</button><button data-action="test-jev" ${status.state === 'ready' ? '' : 'disabled'}>Test</button></div>
|
|
328
|
+
<h4>TypeSafe API key</h4><p class="hint">${keyHint}</p>
|
|
329
|
+
<div class="actions"><input id="jev-key" type="password" autocomplete="new-password" aria-label="TypeSafe API key" placeholder="Paste a key to set or replace"><button data-action="save-jev-key">Save key</button>${key.isSet && !key.fromEnv ? '<button class="danger" data-action="clear-jev-key">Clear key</button>' : ''}</div>
|
|
330
|
+
<p class="hint">Changes apply after a restart; you will be prompted.</p></div>`;
|
|
331
|
+
}
|
|
332
|
+
function computerUseView({ available, access }) {
|
|
333
|
+
if (!available) return '<div class="stack"><h4>Computer use</h4><p class="hint">Off: turn on fast decisions to let Jev drive the screen.</p></div>';
|
|
334
|
+
const host = access?.host || 'The app running the companion';
|
|
335
|
+
const badge = !access ? '' : access.accessibility ? `<span class="badge good">Accessibility allowed</span>` : `<span class="badge warn">Needs Accessibility</span>`;
|
|
336
|
+
const note = !access
|
|
337
|
+
? 'The app running the companion needs Accessibility access in System Settings.'
|
|
338
|
+
: access.accessibility
|
|
339
|
+
? `${esc(host)} (the app running the companion) has Accessibility access.`
|
|
340
|
+
: `${esc(host)} (the app running the companion) needs Accessibility access: open System Settings › Privacy & Security › Accessibility, turn on ${esc(host)} (add it with + if it is not listed).`;
|
|
341
|
+
return `<div class="stack"><div class="row"><h4>Computer use</h4>${badge}</div><p class="hint">On: for click, scroll, and type requests that no integration covers, Jev moves the cursor, clicks, scrolls, and types on this computer. ${note}</p></div>`;
|
|
342
|
+
}
|
|
252
343
|
function voiceView() {
|
|
253
344
|
const { voice, catalog } = state.voice;
|
|
254
345
|
const selected = catalog[voice.provider];
|
|
255
346
|
const voices = voiceOptionsFor(selected, voice.model);
|
|
256
347
|
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>
|
|
257
|
-
<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
|
|
348
|
+
<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>${voiceNeedsKey(voice.provider) ? `<p class="hint error-text" role="status">${esc(selected.label)} needs a key on this computer. Add it under the API key below.</p>` : ''}<label>${sourceLabel('Voice provider', 'voice', 'provider')}<select id="voice-provider"><option value="none" ${voice.provider === 'none' ? 'selected' : ''}>Off</option>${Object.values(
|
|
258
349
|
catalog
|
|
259
350
|
)
|
|
260
351
|
.map(
|
|
@@ -263,7 +354,7 @@ function voiceView() {
|
|
|
263
354
|
)
|
|
264
355
|
.join(
|
|
265
356
|
''
|
|
266
|
-
)}<option value="echo" ${voice.provider === 'echo' ? 'selected' : ''}>Echo (audio test)</option></select></label><label
|
|
357
|
+
)}<option value="echo" ${voice.provider === 'echo' ? 'selected' : ''}>Echo (audio test)</option></select></label><label>${sourceLabel('Voice model', '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>${sourceLabel('Voice name', 'voice', 'voiceName')}<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>${sourceLabel('Maximum call length (minutes)', 'voice', 'maxSessionMinutes')}<input id="voice-minutes" type="number" min="1" max="60" value="${voice.maxSessionMinutes}"></label><div class="stack"><h4>${sourceLabel('Spoken mute and unmute', 'voice', 'commands')}</h4>${commandPhraseFields(voice.commands)}<label>${sourceLabel('Auto-mute the mic after this many quiet seconds', 'voice', 'autoMute', 'autoMute.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">${sourceLabel('Allow web search in voice calls', 'voice', 'webSearch')}</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>`;
|
|
267
358
|
}
|
|
268
359
|
function previewStatus(message, error = false) {
|
|
269
360
|
const status = $('#voice-preview-status');
|
|
@@ -481,6 +572,15 @@ function updateModelChoices(selectedModel = '') {
|
|
|
481
572
|
const interval = (seconds) => (seconds < 60 ? `${seconds} sec` : seconds >= 3600 && seconds % 3600 === 0 ? `${seconds / 3600} hr` : `${Math.round(seconds / 60)} min`);
|
|
482
573
|
|
|
483
574
|
/** `embedded` drops the page heading when setup shows these controls under its own. */
|
|
575
|
+
/** The user's default agents: the agents app with their main work agents, separate from the execution agent. */
|
|
576
|
+
function defaultAgentsView() {
|
|
577
|
+
const apps = (state.packs?.packs || []).filter(pack => pack.agentsApp && pack.enabled);
|
|
578
|
+
const chosen = state.packs?.defaultAgents || '';
|
|
579
|
+
const hint = apps.length
|
|
580
|
+
? '“What agents are running?” checks this app’s threads. It is not the execution agent above, which takes requests from voice and does the work.'
|
|
581
|
+
: 'Install and turn on an agents app (for example T3 Agents, Hermes, or OpenClaw) under MCP & widgets to choose one.';
|
|
582
|
+
return `<div class="card stack"><h3>Default agents</h3><p class="hint">${hint}</p>${apps.length ? `<label>Your main work agents<select id="default-agents"><option value="" ${chosen ? '' : 'selected'}>${apps.length === 1 ? 'Automatic (the only agents app)' : 'Not chosen: the execution agent checks'}</option>${apps.map(app => `<option value="${esc(app.id)}" ${app.id === chosen ? 'selected' : ''}>${esc(app.name)}</option>`).join('')}</select></label><div class="actions"><button data-action="save-default-agents">Save default agents</button></div>` : ''}</div>`;
|
|
583
|
+
}
|
|
484
584
|
function executionView({ embedded = false } = {}) {
|
|
485
585
|
const providers = state.providers;
|
|
486
586
|
const executors = state.executors || [];
|
|
@@ -504,9 +604,11 @@ function executionView({ embedded = false } = {}) {
|
|
|
504
604
|
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';
|
|
505
605
|
const profiles = providers?.profiles.filter(profile => instances.some(instance => [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider))) || [];
|
|
506
606
|
const promptFor = executor => executor.connector === 'direct' ? state.profilePrompts?.[executor.id.slice('direct:'.length)] : undefined;
|
|
507
|
-
const
|
|
607
|
+
const needsFor = executor => executor.connector === 'direct' ? needsBadge(executor.id.slice('direct:'.length)) : '';
|
|
608
|
+
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">${needsFor(executor)}${executor.connector === 'direct' ? `<button data-action="edit-profile" data-id="${esc(executor.id.slice('direct:'.length))}" aria-label="Edit ${esc(executor.label)} model, thinking level, permissions, 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('');
|
|
508
609
|
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>'}
|
|
509
|
-
<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>
|
|
610
|
+
<div class="default-agent"><div><small>YOUR DEFAULT AGENT</small><h3>${sourceLabel(esc(selected?.label || 'Choose an agent below'), 'agents', 'defaultProfileId')}</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>
|
|
611
|
+
${waitingProfilesCard(providers)}
|
|
510
612
|
<div class="row"><h3>Choose a provider</h3><button data-action="refresh-executors">Check availability</button></div>
|
|
511
613
|
<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>
|
|
512
614
|
<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>
|
|
@@ -522,11 +624,24 @@ function executionView({ embedded = false } = {}) {
|
|
|
522
624
|
<details><summary>Permissions</summary><label>When the agent changes files or runs commands<select id="profile-mode">${runtimeModeChoices('approval-required')}</select></label></details>
|
|
523
625
|
<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>
|
|
524
626
|
</div></details>
|
|
525
|
-
${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>` : ''}`}
|
|
627
|
+
${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'} ${needsBadge(profile.id)}</span><button class="danger" data-action="remove-profile" data-id="${esc(profile.id)}">Remove agent</button></div>`).join('')}</div></details>` : ''}`}
|
|
526
628
|
</div><div id="profile-editor"></div><div id="provider-editor"></div>
|
|
527
629
|
${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>` : ''}
|
|
630
|
+
${defaultAgentsView()}
|
|
528
631
|
</section>`;
|
|
529
632
|
}
|
|
633
|
+
/** A profile whose app (driver) is not set up here, e.g. one from another computer: "Needs Claude Code on this computer". */
|
|
634
|
+
function needsBadge(profileId) {
|
|
635
|
+
const needs = state.profileNeeds?.[profileId];
|
|
636
|
+
return needs ? `<span class="badge warn">${esc(needs)}</span>` : '';
|
|
637
|
+
}
|
|
638
|
+
/** Profiles with no installation here at all, which no provider tab lists. */
|
|
639
|
+
function waitingProfilesCard(providers) {
|
|
640
|
+
const waiting = Object.keys(state.profileNeeds || {}).map(id => providers?.profiles.find(profile => profile.id === id) || { id, name: id, provider: null })
|
|
641
|
+
.filter(profile => !providers?.instances.some(instance => [instance.instanceId, instance.displayName, instance.driver].includes(profile.provider)));
|
|
642
|
+
if (!waiting.length) return '';
|
|
643
|
+
return `<div class="card stack"><div><h3>Agents waiting for their app</h3><p class="hint">Install and sign in to the app each agent uses on this computer, then check availability.</p></div>${waiting.map(profile => `<div class="row"><strong>${esc(profile.name)}</strong>${needsBadge(profile.id)}</div>`).join('')}</div>`;
|
|
644
|
+
}
|
|
530
645
|
const RUNTIME_MODES = [['approval-required', 'Ask me first'], ['read-only', 'Read only'], ['auto-accept-edits', 'Accept edits automatically'], ['auto', 'Automatic'], ['full-access', 'Full access']];
|
|
531
646
|
function runtimeModeChoices(selected) {
|
|
532
647
|
return RUNTIME_MODES.map(([value, label]) => `<option value="${value}" ${value === selected ? 'selected' : ''}>${label}</option>`).join('');
|
|
@@ -559,18 +674,50 @@ function accountLabel(value) {
|
|
|
559
674
|
}[value] || 'No OAuth sign-in required'
|
|
560
675
|
);
|
|
561
676
|
}
|
|
677
|
+
/** Packs the library pulled: turned off here, and said so at the top of the page until updated, rolled back, or lifted. */
|
|
678
|
+
function pulledBanner() {
|
|
679
|
+
const pulled = state.packs.packs.filter((pack) => pack.pulled);
|
|
680
|
+
return pulled.length ? `<div class="card trust-notice community" role="alert"><strong>${pulled.length === 1 ? `${esc(pulled[0].name)} was turned off` : `${pulled.length} integrations were turned off`}</strong>${pulled.map((pack) => `<p>Sidebud pulled ${esc(pack.name)} ${esc(pack.pulled.version)} from the widget library: ${esc(pack.pulled.reason)}. It stays off until you update it${pack.rollbackVersion ? ` or roll back to ${esc(pack.rollbackVersion)}` : ''}.</p>`).join('')}</div>` : '';
|
|
681
|
+
}
|
|
562
682
|
function packStatusDetail(pack) {
|
|
563
683
|
const detail = pack.status.detail || '';
|
|
564
684
|
return detail.startsWith('Connect your account: run `rva-companion pack auth')
|
|
565
685
|
? 'Connect the account in Settings & permissions.'
|
|
566
686
|
: detail;
|
|
567
687
|
}
|
|
688
|
+
/** How the computer's agent's setup of a pack went: working on it, or its reply once done. */
|
|
689
|
+
function packSetupLine(packId) {
|
|
690
|
+
const setup = (state.packSetup || {})[packId];
|
|
691
|
+
if (!setup) return '';
|
|
692
|
+
if (setup.running) return '<p class="hint" role="status">Setup: this computer\'s agent is working on it…</p>';
|
|
693
|
+
// The agent answers in Markdown; the card shows plain text.
|
|
694
|
+
const text = (setup.reply || '').replace(/\*\*|__|`/g, '').replace(/^#+\s*/gm, '');
|
|
695
|
+
const reply = text.length > 600 ? `${text.slice(0, 600)}…` : text;
|
|
696
|
+
return setup.failed
|
|
697
|
+
? `<p class="hint bad" role="status">Setup failed: ${esc(setup.blocker || reply || 'the agent stopped.')}</p>`
|
|
698
|
+
: `<p class="hint" role="status">Setup finished: ${esc(reply || 'no reply.')}</p>`;
|
|
699
|
+
}
|
|
700
|
+
/** A library card's state line: deprecated, pulled, or edited here, each with what to do about it. */
|
|
701
|
+
function catalogNotes(entry) {
|
|
702
|
+
return [
|
|
703
|
+
entry.pulled ? `<p class="hint"><span class="badge bad">Pulled</span> Sidebud pulled the installed version from the library: ${esc(entry.pulled)}. It is off until you update it${entry.canRollback ? ' or roll back' : ''}.</p>` : '',
|
|
704
|
+
entry.deprecated ? `<p class="hint"><span class="badge warn">Deprecated</span> ${esc(entry.deprecated)}</p>` : '',
|
|
705
|
+
entry.customized ? `<p class="hint"><span class="badge">Changed here</span> This copy was edited on this computer, so library updates are not installed over it. Customize a copy to keep your changes, or remove it and install again.</p>` : '',
|
|
706
|
+
].join('');
|
|
707
|
+
}
|
|
708
|
+
function catalogAction(entry) {
|
|
709
|
+
if ((entry.publisher.source === 'community' || controlsComputer(entry.access)) && !entry.installed) return `<a class="button quiet" href="#mcp/library/${encodeURIComponent(entry.id)}">Review and install</a>`;
|
|
710
|
+
const rollback = entry.canRollback ? `<button data-action="rollback-pack" data-id="${esc(entry.packId)}">Roll back</button>` : '';
|
|
711
|
+
if (entry.updateAvailable) return `<div class="actions"><button data-action="install-library-pack" data-id="${esc(entry.id)}" data-replace="true" ${entry.customized ? 'disabled title="Edited on this computer: customize a copy to keep your changes, or remove it and install again."' : ''} aria-label="Update ${esc(entry.name)} from version ${esc(entry.installedVersion)} to ${esc(entry.version)}">Update to ${esc(entry.version)}</button>${rollback}</div>`;
|
|
712
|
+
const install = `<button data-action="install-library-pack" data-id="${esc(entry.id)}" ${entry.installed ? 'disabled' : ''} ${entry.needsReinstall ? `title="The installed copy no longer loads. Reinstalling keeps its settings and keys."` : ''}>${entry.installed ? `Installed · ${esc(entry.installedVersion)}` : entry.needsReinstall ? `Reinstall ${esc(entry.name)}` : `Install ${esc(entry.name)}`}</button>`;
|
|
713
|
+
return rollback ? `<div class="actions">${install}${rollback}</div>` : install;
|
|
714
|
+
}
|
|
568
715
|
function catalogView() {
|
|
569
716
|
const intro = `<div><h3>Widget library</h3><p class="hint">Widget packs from the Sidebud widget library. Install one 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. Your agent can also build new packs, or change any installed one, when you ask.</p>${state.libraryError ? `<p class="hint">The library could not be reached: ${esc(state.libraryError)}</p>` : ''}</div>`;
|
|
570
|
-
return `${intro}<div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3><a href="#mcp/library/${encodeURIComponent(entry.id)}">${esc(entry.name)}</a></h3><span class="badge ${entry.publisher.source === 'community' ? 'warn' : ''}">${entry.installed ? 'Installed' : entry.detected ? 'App found' : entry.publisher.source === 'community' ? 'Community' : esc(entry.publisher.name)}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.connectsTo)}</p>${entry.
|
|
717
|
+
return `${intro}<div class="grid">${state.integrationCatalog.map(entry => `<article class="card stack"><div class="row"><h3><a href="#mcp/library/${encodeURIComponent(entry.id)}">${esc(entry.name)}</a></h3><span class="badge ${entry.publisher.source === 'community' && !entry.publisher.official ? 'warn' : ''}">${entry.installed ? 'Installed' : entry.detected ? 'App found' : entry.publisher.official ? `Official · ${esc(entry.publisher.official.domain)}` : entry.publisher.source === 'community' ? 'Community' : esc(entry.publisher.name)}</span></div><p>${esc(entry.description)}</p><p class="hint">${esc(entry.account)} · ${esc(entry.connectsTo)}</p><div class="access-line">${accessBadge(entry.access)}${controlsComputer(entry.access) ? `<p class="hint">${esc(entry.access.points[0].text)}</p>` : ''}</div>${catalogNotes(entry)}<a class="hint" href="#mcp/library/${encodeURIComponent(entry.id)}">What it can do and how it looks →</a>${catalogAction(entry)}</article>`).join('')}</div>`;
|
|
571
718
|
}
|
|
572
719
|
function mcpView() {
|
|
573
|
-
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. New here? Start with the <a href="#guide">Guide</a>.</p></div
|
|
720
|
+
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. New here? Start with the <a href="#guide">Guide</a>.</p></div>${pulledBanner()}<h3>Installed integrations</h3><div class="grid">${state.packs.packs.map(p => `<div class="card"><div class="row"><h3><a href="#mcp/${encodeURIComponent(p.id)}">${esc(p.name)}</a></h3>${p.pulled ? '<span class="badge bad">Pulled</span>' : `<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><div class="access-line">${accessBadge(state.packAccess[p.id])}</div><p class="hint">${esc(packStatusDetail(p))}</p>${packSetupLine(p.id)}${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">${p.enabled ? '' : `<button class="primary" data-action="toggle-pack" data-id="${esc(p.id)}" data-enabled="false">Turn on</button>`}<button ${p.enabled ? '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><a role="menuitem" class="button" href="#mcp/${encodeURIComponent(p.id)}">Settings & permissions</a><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><button role="menuitem" data-action="remove-pack" data-id="${esc(p.id)}">Remove</button></div></details></div></div>`).join('') || '<div class="card muted">No integrations installed. Choose one from the widget library below, or ask your agent to build one.</div>'}</div><div id="pack-editor"></div>${catalogView()}<div class="card"><h3>Import a widget pack</h3><p class="hint">Choose a pack file exported from Sidebud or downloaded from the widget library. It installs disabled, then this computer's agent sets it up. Pack files hold the manifest, logo, skill, and any server files, 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>Build or change a pack</h3><p class="hint">Ask your agent: "make a widget that shows …" or "change my Gmail widget to …". It follows the widget-packs skill, builds or edits the pack, and asks before turning it on. See how it will look under <a href="#phone">Phone preview</a>, and how it all works in the <a href="#guide">Guide</a>.</p><p class="hint">Community submissions and reviewed release distribution are planned. Local packs can be installed now.</p></div></section>`;
|
|
574
721
|
}
|
|
575
722
|
function customizePackEditor(p) {
|
|
576
723
|
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>`;
|
|
@@ -738,6 +885,13 @@ document.addEventListener('click', async (event) => {
|
|
|
738
885
|
await applyChanges();
|
|
739
886
|
return;
|
|
740
887
|
}
|
|
888
|
+
if (action === 'setting-pin') {
|
|
889
|
+
const pinned = button.dataset.pinned !== 'true';
|
|
890
|
+
await api('settings/pin', { section: button.dataset.section, path: button.dataset.path.split('.'), pinned });
|
|
891
|
+
notice(pinned ? 'Kept on this computer. Changes from your account no longer replace it.' : 'This setting follows your account again.');
|
|
892
|
+
await reload();
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
741
895
|
if (await handleSetupAction(action, button)) return;
|
|
742
896
|
if (await handleSettingsAction(action, button)) return;
|
|
743
897
|
if (action === 'pair-phone') {
|
|
@@ -777,6 +931,32 @@ document.addEventListener('click', async (event) => {
|
|
|
777
931
|
await reload();
|
|
778
932
|
return;
|
|
779
933
|
}
|
|
934
|
+
if (action === 'save-jev') {
|
|
935
|
+
const url = $('#jev-url').value.trim();
|
|
936
|
+
await api('jev', {
|
|
937
|
+
jev: {
|
|
938
|
+
mode: $('#jev-on').checked ? 'auto' : 'off',
|
|
939
|
+
url: url || null,
|
|
940
|
+
model: $('#jev-model').value.trim() || 'jev-latest',
|
|
941
|
+
minConfidence: Number($('#jev-confidence').value),
|
|
942
|
+
timeoutMs: Number($('#jev-timeout').value),
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
notice('Fast decision settings saved. Restart to apply them.');
|
|
946
|
+
await reload();
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (action === 'save-jev-key' || action === 'clear-jev-key') {
|
|
950
|
+
await api('jev-key', { value: action === 'clear-jev-key' ? '' : $('#jev-key').value });
|
|
951
|
+
notice('Key updated in this computer’s keychain. Restart to apply it.');
|
|
952
|
+
await reload();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
if (action === 'test-jev') {
|
|
956
|
+
const result = await api('jev/test', {});
|
|
957
|
+
notice(`Fast decisions work: ${result.model} answered “${result.choice}” in ${result.ms} ms.`);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
780
960
|
if (action === 'save-voice-key' || action === 'clear-voice-key') {
|
|
781
961
|
await api('voice-key', {
|
|
782
962
|
provider: button.dataset.provider,
|
|
@@ -791,6 +971,12 @@ document.addEventListener('click', async (event) => {
|
|
|
791
971
|
await reload();
|
|
792
972
|
return;
|
|
793
973
|
}
|
|
974
|
+
if (action === 'save-default-agents') {
|
|
975
|
+
await api('default-agents', { packId: $('#default-agents').value || null });
|
|
976
|
+
notice('Default agents saved.');
|
|
977
|
+
await reload();
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
794
980
|
if (action === 'set-default-executor') {
|
|
795
981
|
await api('executor', { executorId: id });
|
|
796
982
|
notice('Default executor updated.');
|
|
@@ -898,6 +1084,10 @@ document.addEventListener('click', async (event) => {
|
|
|
898
1084
|
return;
|
|
899
1085
|
}
|
|
900
1086
|
if (action === 'toggle-pack') {
|
|
1087
|
+
const turningOn = button.dataset.enabled !== 'true';
|
|
1088
|
+
const access = state.packAccess[id];
|
|
1089
|
+
const pack = state.packs.packs.find((candidate) => candidate.id === id);
|
|
1090
|
+
if (turningOn && controlsComputer(access) && !confirm(`Turn on ${pack?.name || id}?\n\n${computerConfirmText(pack?.name || id, access)}`)) return;
|
|
901
1091
|
await api('pack', {
|
|
902
1092
|
packId: id,
|
|
903
1093
|
enabled: button.dataset.enabled !== 'true',
|
|
@@ -947,7 +1137,8 @@ document.addEventListener('click', async (event) => {
|
|
|
947
1137
|
}
|
|
948
1138
|
if (action === 'setup-pack') {
|
|
949
1139
|
await api('pack/setup', { packId: id });
|
|
950
|
-
notice("This computer's agent is setting it up
|
|
1140
|
+
notice("This computer's agent is setting it up; how it goes shows on its card.");
|
|
1141
|
+
await reload();
|
|
951
1142
|
return;
|
|
952
1143
|
}
|
|
953
1144
|
if (action === 'check-pack') {
|
|
@@ -1009,18 +1200,42 @@ document.addEventListener('click', async (event) => {
|
|
|
1009
1200
|
return;
|
|
1010
1201
|
}
|
|
1011
1202
|
if (action === 'install-library-pack') {
|
|
1203
|
+
const replace = button.dataset.replace === 'true';
|
|
1204
|
+
const entry = state.integrationCatalog.find((candidate) => candidate.id === button.dataset.id);
|
|
1205
|
+
const installed = entry && state.packs.packs.find((pack) => pack.id === entry.packId);
|
|
1206
|
+
// An update keeps a pack that is on turned on, so a pack that controls the computer is confirmed first, as when turning it on.
|
|
1207
|
+
if (replace && installed?.enabled && controlsComputer(entry.access) && !confirm(`Update ${entry.name} to ${entry.version} and keep it on?\n\n${computerConfirmText(entry.name, entry.access)}`)) return;
|
|
1012
1208
|
libraryPage = null;
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
result
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1209
|
+
button.disabled = true;
|
|
1210
|
+
button.textContent = replace ? 'Updating…' : 'Installing…';
|
|
1211
|
+
const request = { id: button.dataset.id, ...(replace ? { replace: true } : {}) };
|
|
1212
|
+
let result;
|
|
1213
|
+
try {
|
|
1214
|
+
result = await api('pack/install-library', request);
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
// An update that lets the pack do more waits for the owner: show what grows, in words, and ask.
|
|
1217
|
+
const asked = error.data?.consentRequired ? error.data : null;
|
|
1218
|
+
if (!asked) throw error;
|
|
1219
|
+
const lines = asked.changes.map((change) => `${change.grows ? '•' : '◦'} ${change.text}`).join('\n');
|
|
1220
|
+
if (!confirm(`Updating ${asked.name} to ${asked.version} lets it do more on this computer:\n\n${lines}\n\nInstall the update?`)) {
|
|
1221
|
+
notice(`${asked.name} was not updated. The installed version keeps working.`);
|
|
1222
|
+
await reload();
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
result = await api('pack/install-library', { ...request, acceptChanges: true });
|
|
1226
|
+
}
|
|
1227
|
+
if (result.keptOn && result.check && !result.check.ok) notice(`${result.name} updated to ${result.version} and turned back on, but its connection check failed: ${result.check.error}`, true);
|
|
1228
|
+
else if (result.keptOn) notice(`${result.name} updated to ${result.version} and still on${result.check ? '; its connection check passed' : ''}. Its settings and keys were kept.`);
|
|
1229
|
+
else if (result.setup.started) notice(`${result.name} ${result.updated ? `updated to ${result.version}` : 'installed'}. This computer's agent is setting it up; how it goes shows on its card.`);
|
|
1230
|
+
else notice(`${result.name} ${result.updated ? 'updated' : 'installed'}, but setup did not start: ${result.setup.note} Set it up in Settings & permissions.`, true);
|
|
1231
|
+
await reload();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (action === 'rollback-pack') {
|
|
1235
|
+
const pack = state.packs.packs.find((candidate) => candidate.id === id);
|
|
1236
|
+
if (!confirm(`Go back to ${pack?.name || id} ${pack?.rollbackVersion || 'its previous version'}? Its settings and keys are kept.`)) return;
|
|
1237
|
+
const result = await api('pack/rollback', { packId: id });
|
|
1238
|
+
notice(`${result.name} is back to version ${result.version}${result.enabled ? ' and still on.' : '. Turn it on when you are ready.'}`);
|
|
1024
1239
|
await reload();
|
|
1025
1240
|
return;
|
|
1026
1241
|
}
|