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,112 @@
|
|
|
1
|
+
// Settings: the Sidebud account, this computer's name, and appearance. Loaded after manage.js and shares its globals.
|
|
2
|
+
let renaming = false;
|
|
3
|
+
|
|
4
|
+
/** The sidebar's computer card: the saved name, and who this computer is signed in as. */
|
|
5
|
+
function sidebarIdentity() {
|
|
6
|
+
const card = $('#computer-card');
|
|
7
|
+
if (!card || renaming) return;
|
|
8
|
+
$('#companion-name').textContent = state.savedName;
|
|
9
|
+
$('#companion-name').title = state.savedName !== state.name ? `Renamed from ${state.name}; apply changes to use it` : state.savedName;
|
|
10
|
+
const { account } = state;
|
|
11
|
+
$('#account-line').textContent = account.status === 'signed_in' ? account.user.email || account.user.name || 'Signed in' : 'Local only';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function startInlineRename() {
|
|
15
|
+
const card = $('#computer-card');
|
|
16
|
+
const text = card.querySelector('.local-text');
|
|
17
|
+
renaming = true;
|
|
18
|
+
text.innerHTML = `<input class="rename" id="computer-name-inline" maxlength="100" aria-label="Computer name" value="${esc(state.savedName)}"><span class="rename-hint">Enter to save · Esc to cancel</span>`;
|
|
19
|
+
const input = $('#computer-name-inline');
|
|
20
|
+
input.focus();
|
|
21
|
+
input.select();
|
|
22
|
+
const finish = () => {
|
|
23
|
+
renaming = false;
|
|
24
|
+
text.innerHTML = '<strong id="companion-name"></strong><span id="account-line"></span>';
|
|
25
|
+
sidebarIdentity();
|
|
26
|
+
};
|
|
27
|
+
input.addEventListener('keydown', async (event) => {
|
|
28
|
+
if (event.key === 'Escape') return finish();
|
|
29
|
+
if (event.key !== 'Enter') return;
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
try {
|
|
32
|
+
await saveComputerName(input.value);
|
|
33
|
+
finish();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
notice(error.message, true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
input.addEventListener('blur', () => { if (renaming) finish(); });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function saveComputerName(value) {
|
|
42
|
+
const name = value.trim();
|
|
43
|
+
if (!name) throw new Error('Give this computer a name.');
|
|
44
|
+
if (name === state.savedName) return;
|
|
45
|
+
await api('name', { name });
|
|
46
|
+
notice(`Renamed to ${name}. Apply changes to show it on your phones.`);
|
|
47
|
+
await reload();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function accountCard() {
|
|
51
|
+
const { account } = state;
|
|
52
|
+
const site = esc(account.server.replace(/^https?:\/\//, ''));
|
|
53
|
+
const head = (text) => `<div><h3>Sidebud account</h3><p class="hint">${text}</p></div>`;
|
|
54
|
+
if (account.status === 'signed_in') {
|
|
55
|
+
const who = account.user.name && account.user.email ? `${esc(account.user.name)} · ${esc(account.user.email)}` : esc(account.user.email || account.user.name || 'Your account');
|
|
56
|
+
return `<div class="card stack">${head(`This computer is signed in to ${site}.`)}<div class="account-row"><span class="avatar" aria-hidden="true">${esc((account.user.name || account.user.email || 'S').slice(0, 1).toUpperCase())}</span><div><strong>${who}</strong><p class="hint">Signed in ${esc(new Date(account.signedInAt).toLocaleString())}</p></div></div><div class="actions"><button class="danger" data-action="account-sign-out">Sign out</button></div></div>`;
|
|
57
|
+
}
|
|
58
|
+
if (account.status === 'pending') {
|
|
59
|
+
const link = account.verificationUriComplete || account.verificationUri;
|
|
60
|
+
return `<div class="card stack">${head(`Approve this computer from a browser where you are signed in to ${site}.`)}<div class="device-code"><span class="hint">Your code</span><strong>${esc(account.userCode)}</strong></div><ol class="hint steps"><li>Open <a href="${esc(link)}" target="_blank" rel="noopener">${esc(account.verificationUri.replace(/^https?:\/\//, ''))}</a>.</li><li>Check that the code matches, then approve.</li></ol><p class="hint" role="status">Waiting for approval… The code expires ${esc(new Date(account.expiresAt).toLocaleTimeString())}.</p><div class="actions"><a class="button primary" href="${esc(link)}" target="_blank" rel="noopener">Open ${site}</a><button data-action="account-cancel">Cancel</button></div></div>`;
|
|
61
|
+
}
|
|
62
|
+
if (account.status === 'unavailable')
|
|
63
|
+
return `<div class="card stack">${head('Sign in to connect this computer to your account.')}<p class="hint">${esc(account.error)} Everything on this page keeps working without an account.</p><div class="actions"><button data-action="account-sign-in">Try again</button></div></div>`;
|
|
64
|
+
return `<div class="card stack">${head(`Sign in to ${site} to connect this computer to your account. You approve it in your browser; no password is typed here, and the sign-in token stays on this computer.`)}${account.error ? `<p role="alert" class="hint error-text">${esc(account.error)}</p>` : ''}<div class="actions"><button class="primary" data-action="account-sign-in">Sign in with ${site}</button></div></div>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function settingsView() {
|
|
68
|
+
const theme = document.documentElement.dataset.theme;
|
|
69
|
+
const renamed = state.savedName !== state.name;
|
|
70
|
+
return `<section class="section"><div><h2>Settings</h2><p class="lede">Your account and preferences for ${esc(state.name)}.</p></div>${accountCard()}<div class="card stack"><div><h3>Computer name</h3><p class="hint">Shown on your phones, on the system call screen, and to the voice assistant.</p></div><div class="actions"><input id="computer-name" maxlength="100" aria-label="Computer name" value="${esc(state.savedName)}"><button data-action="save-name">Save name</button></div>${renamed ? '<p class="hint">The new name is used after you apply changes.</p>' : ''}</div><div class="card stack"><div><h3>Appearance</h3><p class="hint">Remembered in this browser.</p></div><div class="segmented" role="group" aria-label="Theme"><button data-action="set-theme" data-id="dark" aria-pressed="${theme === 'dark'}">Dark</button><button data-action="set-theme" data-id="light" aria-pressed="${theme === 'light'}">Light</button></div></div></section>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Handles Settings and sidebar actions; returns true when it took the action. */
|
|
74
|
+
async function handleSettingsAction(action, button) {
|
|
75
|
+
if (action === 'rename-inline') {
|
|
76
|
+
startInlineRename();
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (action === 'save-name') {
|
|
80
|
+
await saveComputerName($('#computer-name').value);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (action === 'set-theme') {
|
|
84
|
+
localStorage.setItem('rvaManageTheme', button.dataset.id);
|
|
85
|
+
applyTheme(button.dataset.id);
|
|
86
|
+
render();
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
if (action === 'account-sign-in') {
|
|
90
|
+
button.disabled = true;
|
|
91
|
+
try {
|
|
92
|
+
const account = await api('account/sign-in', {});
|
|
93
|
+
// Open the approval page straight away; the card keeps the link if the browser blocks it.
|
|
94
|
+
if (account.status === 'pending') window.open(account.verificationUriComplete || account.verificationUri, '_blank', 'noopener');
|
|
95
|
+
await reload();
|
|
96
|
+
} finally { button.disabled = false; }
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (action === 'account-cancel') {
|
|
100
|
+
await api('account/cancel', {});
|
|
101
|
+
await reload();
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (action === 'account-sign-out') {
|
|
105
|
+
if (!confirm('Sign this computer out of your Sidebud account?')) return true;
|
|
106
|
+
await api('account/sign-out', {});
|
|
107
|
+
notice('Signed out.');
|
|
108
|
+
await reload();
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// First-run setup. Loaded after manage.js and shares its state and helpers (state, api, esc, render, reload, notice…).
|
|
2
|
+
const SETUP_STEPS = [
|
|
3
|
+
{ id: 'app', title: 'Get the app', short: 'Phone app' },
|
|
4
|
+
{ id: 'execution', title: 'Choose who does the work', short: 'Execution agent' },
|
|
5
|
+
{ id: 'voice', title: 'Choose a voice', short: 'Voice agent' },
|
|
6
|
+
{ id: 'integrations', title: 'Add integrations', short: 'Integrations' },
|
|
7
|
+
{ id: 'phone', title: 'Pair your phone', short: 'Pair phone' },
|
|
8
|
+
{ id: 'done', title: 'All set', short: 'Finish' },
|
|
9
|
+
];
|
|
10
|
+
/** Recommended on a first setup; the owner can untick any of them. */
|
|
11
|
+
const DEFAULT_INTEGRATIONS = new Set(['t3-agents', 'obsidian']);
|
|
12
|
+
/** Catalog packs offered in setup; T3 Agents is only offered once the app is on this computer. */
|
|
13
|
+
const SETUP_INTEGRATIONS = ['t3-agents', 'obsidian', 'calendar', 'gmail'];
|
|
14
|
+
let setupSelections = null;
|
|
15
|
+
let setupBusy = false;
|
|
16
|
+
|
|
17
|
+
function currentSetupStep() {
|
|
18
|
+
const wanted = location.hash.slice(1).split('/')[1];
|
|
19
|
+
if (SETUP_STEPS.some((step) => step.id === wanted)) return wanted;
|
|
20
|
+
if (!state) return 'app';
|
|
21
|
+
// Once finished, Setup opens on the completion page; each step stays reachable from the stepper.
|
|
22
|
+
if (state.setup.completedAt) return 'done';
|
|
23
|
+
return SETUP_STEPS.find((step) => step.id !== 'done' && !state.setup.steps[step.id].done)?.id || 'done';
|
|
24
|
+
}
|
|
25
|
+
function goToSetupStep(step) {
|
|
26
|
+
location.hash = `#setup/${step}`;
|
|
27
|
+
}
|
|
28
|
+
function setupWaiting() {
|
|
29
|
+
if (!state || section() !== 'setup') return false;
|
|
30
|
+
const { steps } = state.setup;
|
|
31
|
+
if (steps.execution.check?.status === 'running' || steps.voice.check?.status === 'running') return true;
|
|
32
|
+
// Notice a phone pairing without a refresh button.
|
|
33
|
+
return currentSetupStep() === 'phone' && !steps.phone.done;
|
|
34
|
+
}
|
|
35
|
+
function afterSetupRender() {
|
|
36
|
+
const step = currentSetupStep();
|
|
37
|
+
// Steps change by hash without a reload, so the poll starts here too (a pairing, a running test).
|
|
38
|
+
if (setupWaiting()) {
|
|
39
|
+
clearTimeout(reload.timer);
|
|
40
|
+
reload.timer = setTimeout(() => reload(false), 1500);
|
|
41
|
+
}
|
|
42
|
+
if (step === 'phone' && !state.setup.steps.phone.done && !pairingAttempted) void loadPairing();
|
|
43
|
+
if (step === 'execution' && !setupBusy && (!state.providers || state.connector.kind === 'memory')) void enableLocalAgents();
|
|
44
|
+
}
|
|
45
|
+
/** Fresh installs run a demo agent with local agents off; setup switches to local agents and applies it. */
|
|
46
|
+
async function enableLocalAgents() {
|
|
47
|
+
setupBusy = true;
|
|
48
|
+
try {
|
|
49
|
+
const result = await api('setup/local-agents', {});
|
|
50
|
+
if (result.restartRequired) await applyChanges();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
notice(error.message, true);
|
|
53
|
+
} finally {
|
|
54
|
+
setupBusy = false;
|
|
55
|
+
render();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function checkCard(kind, intro) {
|
|
59
|
+
const check = state.setup.steps[kind].check;
|
|
60
|
+
const status = !check ? 'Not run' : check.status === 'running' ? 'Running…' : !check.current ? 'Out of date' : check.status === 'passed' ? 'Passed' : 'Failed';
|
|
61
|
+
const tone = { Passed: 'good', Failed: 'bad', 'Out of date': 'warn' }[status] || '';
|
|
62
|
+
const blocked = kind === 'voice' && state.restart.pending;
|
|
63
|
+
const ready = Boolean(state.setup.companionAgent);
|
|
64
|
+
return `<div class="card stack check-card"><div class="row"><div><h3>${kind === 'execution' ? 'Test the execution agent' : 'Test voice to agent'}</h3><p class="hint">${intro}</p></div><span class="badge ${tone}">${esc(status)}</span></div>${check ? `<p class="${check.status === 'failed' ? 'error-text' : 'hint'}" role="status" aria-live="polite">${esc(check.detail)}${!check.current && check.status !== 'running' ? ' Your selection changed since this ran.' : ''}</p>` : ''}<div class="actions"><button class="primary" data-action="setup-check" data-kind="${kind}" ${check?.status === 'running' || !ready ? 'disabled' : ''}>${blocked ? 'Apply changes and run the test' : check ? 'Run the test again' : 'Run the test'}</button></div></div>`;
|
|
65
|
+
}
|
|
66
|
+
function setupStepper(current) {
|
|
67
|
+
return `<ol class="setup-steps">${SETUP_STEPS.map((step, index) => {
|
|
68
|
+
const done = step.id === 'done' ? Boolean(state.setup.completedAt) : state.setup.steps[step.id].done;
|
|
69
|
+
return `<li><button data-action="setup-step" data-step="${step.id}" class="${step.id === current ? 'current' : ''} ${done ? 'done' : ''}" ${step.id === current ? 'aria-current="step"' : ''}><span class="step-mark" aria-hidden="true">${done ? '✓' : index + 1}</span><span>${esc(step.short)}</span></button></li>`;
|
|
70
|
+
}).join('')}</ol>`;
|
|
71
|
+
}
|
|
72
|
+
function setupNav(current, { next = 'Continue', nextAction = 'setup-next', skip = true } = {}) {
|
|
73
|
+
const index = SETUP_STEPS.findIndex((step) => step.id === current);
|
|
74
|
+
return `<div class="setup-nav">${index > 0 ? `<button data-action="setup-step" data-step="${SETUP_STEPS[index - 1].id}">Back</button>` : '<span></span>'}<div class="actions">${skip ? '<button data-action="setup-next">Skip for now</button>' : ''}<button class="primary" data-action="${nextAction}">${esc(next)}</button></div></div>`;
|
|
75
|
+
}
|
|
76
|
+
function appStep() {
|
|
77
|
+
return `<div class="setup-grid"><div class="card stack"><h3>Install Sidebud on your phone</h3><p>Scan this code with your phone’s camera. It opens the right store for your phone.</p><img class="download-qr" src="${esc(state.download.qr)}" alt="QR code to download the Sidebud app"><p class="hint">Or open <a href="${esc(state.download.url)}" target="_blank" rel="noopener">${esc(state.download.url.replace(/^https:\/\//, ''))}</a> on your phone. iPhone builds are available through TestFlight during the beta.</p></div><div class="card stack"><h3>What happens next</h3><ol class="plain-steps"><li><strong>Pick an execution agent.</strong> It does the work on this computer, and we test it with a real prompt.</li><li><strong>Pick a voice.</strong> We test that the voice can hand requests to your agent.</li><li><strong>Add integrations</strong> such as T3 Agents and Obsidian.</li><li><strong>Pair your phone</strong> by scanning a code in the app. You can <a href="#setup/phone">pair now</a> if the app is already installed.</li></ol></div></div>${setupNav('app', { next: 'I have the app', nextAction: 'setup-app-done', skip: false })}`;
|
|
78
|
+
}
|
|
79
|
+
function executionStep() {
|
|
80
|
+
if (!state.providers || state.connector.kind === 'memory')
|
|
81
|
+
return `<div class="card"><p class="muted">Turning on local agents…</p></div>`;
|
|
82
|
+
const agent = state.setup.companionAgent;
|
|
83
|
+
return `${executionView({ embedded: true })}${checkCard('execution', agent ? `Asks ${esc(agent.label)} to answer a short prompt on this computer. It changes nothing.` : 'Add a local agent above, then test it.')}${setupNav('execution', { next: state.setup.steps.execution.done ? 'Continue' : 'Continue without testing' , skip: false })}`;
|
|
84
|
+
}
|
|
85
|
+
function voiceStep() {
|
|
86
|
+
const { voice, catalog } = state.voice;
|
|
87
|
+
const selectedId = catalog[voice.provider] ? voice.provider : 'gemini';
|
|
88
|
+
const provider = catalog[selectedId];
|
|
89
|
+
const saved = voice.provider === selectedId ? voice : { model: null, voiceName: null, region: null, workspaceId: null };
|
|
90
|
+
const model = provider.models.some((option) => option.id === saved.model) ? saved.model : provider.defaultModel;
|
|
91
|
+
const agent = state.setup.companionAgent;
|
|
92
|
+
// Same controls and ids as the Voice page, so its provider/model handlers and the voice sample work here too.
|
|
93
|
+
return `<div class="card stack"><h3>Voice agent</h3><p class="hint">The voice agent talks with you in calls and typed chat, and hands work to your execution agent. Its API key stays on this computer.</p><label>Voice provider<select id="voice-provider">${Object.values(catalog).map((option) => `<option value="${esc(option.id)}" ${option.id === selectedId ? 'selected' : ''}>${esc(option.label)}${option.id === 'gemini' ? ' (recommended)' : ''}</option>`).join('')}</select></label><p id="voice-untested" class="hint" ${provider.untested ? '' : 'hidden'}>Untested: this provider has not been run against the real service yet, so expect rough edges.</p><label>Voice model<select id="voice-model">${voiceModelOptions(provider, model)}</select></label><div id="voice-endpoint" class="stack">${voiceEndpointFields(provider, saved)}</div><label>Voice<select id="voice-name">${voiceNameOptions(voiceOptionsFor(provider, model), saved.voiceName || provider.defaultVoice)}</select></label><div class="voice-preview"><button type="button" data-action="preview-voice">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">Save a key below, then play a sample.</p></div><div id="voice-key-panel" class="stack">${voiceKeysView(selectedId)}</div><div class="actions"><button class="primary" data-action="setup-save-voice">Save voice and test it</button></div>${checkCard('voice', agent ? `Sends the voice agent a request as if you typed it, and checks that ${esc(agent.label)} receives and answers it.` : 'Finish the execution agent step first.')}${setupNav('voice', { next: state.setup.steps.voice.done ? 'Continue' : 'Continue without testing', skip: false })}`;
|
|
94
|
+
}
|
|
95
|
+
function integrationSelections() {
|
|
96
|
+
if (!setupSelections) {
|
|
97
|
+
setupSelections = new Set([...DEFAULT_INTEGRATIONS].filter((id) => !state.integrationCatalog.find((entry) => entry.id === id)?.installed && (id !== 't3-agents' || state.t3Agents.installed)));
|
|
98
|
+
}
|
|
99
|
+
return setupSelections;
|
|
100
|
+
}
|
|
101
|
+
function integrationsStep() {
|
|
102
|
+
const selected = integrationSelections();
|
|
103
|
+
const option = (entry) => {
|
|
104
|
+
const t3 = entry.id === 't3-agents' ? state.t3Agents : null;
|
|
105
|
+
const unavailable = t3 && !t3.installed;
|
|
106
|
+
const note = entry.installed ? 'Added' : t3?.note || entry.account;
|
|
107
|
+
return `<label class="integration-option ${unavailable ? 'unavailable' : ''}"><input type="checkbox" data-integration="${esc(entry.id)}" ${entry.installed ? 'checked disabled' : unavailable ? 'disabled' : selected.has(entry.id) ? 'checked' : ''}><span><strong>${esc(entry.name)}</strong>${DEFAULT_INTEGRATIONS.has(entry.id) ? ' <span class="pill">Recommended</span>' : ''}<br><span class="hint">${esc(entry.description)}</span><br><span class="hint">${esc(note)}</span></span></label>`;
|
|
108
|
+
};
|
|
109
|
+
const packs = SETUP_INTEGRATIONS.map((id) => state.integrationCatalog.find((entry) => entry.id === id)).filter(Boolean);
|
|
110
|
+
return `<div class="card stack"><h3>What should Sidebud reach?</h3><p class="hint">Each integration is added disabled. Your execution agent then sets it up, uses settings and keys it finds on this computer, and reports on your phone what it needs from you.</p><div class="integration-options">${packs.map(option).join('')}</div><p class="hint">More integrations, and ways to make your own, are under <a href="#mcp">MCP & widgets</a>.</p></div>${setupNav('integrations', { next: 'Add selected and continue', nextAction: 'setup-add-integrations' })}`;
|
|
111
|
+
}
|
|
112
|
+
function phoneStep() {
|
|
113
|
+
const active = state.devices.filter((device) => !device.revokedAt);
|
|
114
|
+
if (active.length)
|
|
115
|
+
return `<div class="card stack"><h3>Your phone is paired</h3><p>${active.map((device) => `<strong>${esc(device.name)}</strong> (${esc(device.platform)})`).join(', ')} can use ${esc(state.name)}. Open Sidebud on your phone and check that it shows this computer as connected.</p><p class="hint">Pair more phones, or remove one, under <a href="#connection">Connection</a>.</p></div>${setupNav('phone', { skip: false })}`;
|
|
116
|
+
return `<div class="setup-grid"><div class="card stack"><h3>Scan with the Sidebud app</h3>${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.' : `Works once. Expires ${esc(new Date(pairing.expiresAt).toLocaleTimeString())}.`}</p>` : `<p class="muted">${pairingLoading ? 'Preparing pairing code…' : 'Generate a code to pair your phone.'}</p>`}${pairingError ? `<p role="alert" class="hint error-text">Could not create a pairing code: ${esc(pairingError)}</p>` : ''}<div class="actions"><button data-action="pair-phone" ${pairingLoading ? 'disabled' : ''}>${pairing ? 'Generate a new code' : 'Generate pairing code'}</button></div></div><div class="card stack"><h3>On your phone</h3><ol class="plain-steps"><li>Open Sidebud. No app yet? <a href="#setup/app">Get it here</a>.</li><li>Choose <strong>Add computer</strong>, then <strong>Scan pairing code</strong>.</li><li>Point the camera at this code. This page updates when the phone is paired.</li></ol><p class="hint">Your phone must reach this computer on the same network or over Tailscale.</p></div></div>${setupNav('phone', { next: 'Continue', skip: true })}`;
|
|
117
|
+
}
|
|
118
|
+
/** Shown once setup is finished: carry on in the phone app; this page stays for managing the computer. */
|
|
119
|
+
function completedView() {
|
|
120
|
+
const manage = [
|
|
121
|
+
{ hash: 'execution', title: 'Execution agents', text: 'Add agents, change the default, set thinking levels and prompts.' },
|
|
122
|
+
{ hash: 'mcp', title: 'MCP & widgets', text: 'Add integrations, set them up, and choose what your phone shows and hears.' },
|
|
123
|
+
{ hash: 'voice', title: 'Voice', text: 'Change the voice agent, model, and voice.' },
|
|
124
|
+
{ hash: 'connection', title: 'Connection', text: 'Pair another phone or remove one.' },
|
|
125
|
+
];
|
|
126
|
+
return `<div class="card stack setup-complete"><div class="row"><span class="done-badge" aria-hidden="true">✓</span><div><h3>Well done. Sidebud is set up on ${esc(state.name)}.</h3><p>Continue in the Sidebud app on your phone from here: start a voice chat, send a message, and check on your widgets and running work.</p></div></div></div><div class="card stack"><div><h3>Manage everything from this dashboard, any time</h3><p class="hint">Keep the companion running on this computer. Open this page again with <span class="code">sidebud manage</span> or <span class="code">sidebud setup</span>.</p></div><div class="manage-links">${manage.map((item) => `<a class="manage-link" href="#${item.hash}"><strong>${esc(item.title)}</strong><span class="hint">${esc(item.text)}</span></a>`).join('')}</div></div><div class="actions"><button data-action="setup-reopen">Run setup again</button></div>`;
|
|
127
|
+
}
|
|
128
|
+
function doneStep() {
|
|
129
|
+
if (state.setup.completedAt) return completedView();
|
|
130
|
+
const rows = SETUP_STEPS.filter((step) => step.id !== 'done').map((step) => {
|
|
131
|
+
const done = state.setup.steps[step.id].done;
|
|
132
|
+
return `<li class="${done ? 'done' : ''}"><span class="step-mark" aria-hidden="true">${done ? '✓' : '•'}</span><span>${esc(step.title)}</span>${done ? '' : `<button data-action="setup-step" data-step="${step.id}">Finish this</button>`}</li>`;
|
|
133
|
+
}).join('');
|
|
134
|
+
const ready = SETUP_STEPS.every((step) => step.id === 'done' || state.setup.steps[step.id].done);
|
|
135
|
+
return `<div class="card stack"><h3>${state.setup.completedAt ? 'Setup is finished' : ready ? 'Everything is ready' : 'Almost there'}</h3><ul class="setup-summary">${rows}</ul><p class="hint">Change any of this later from the sections on the left. Your phone shows your widgets, running tasks, and a button to start a voice chat.</p><div class="actions">${state.setup.completedAt ? '<button data-action="setup-reopen">Run setup again</button>' : '<button class="primary" data-action="setup-complete">Finish setup</button>'}</div></div>`;
|
|
136
|
+
}
|
|
137
|
+
function setupView() {
|
|
138
|
+
const current = currentSetupStep();
|
|
139
|
+
const step = SETUP_STEPS.find((candidate) => candidate.id === current);
|
|
140
|
+
const body = { app: appStep, execution: executionStep, voice: voiceStep, integrations: integrationsStep, phone: phoneStep, done: doneStep }[current]();
|
|
141
|
+
const title = current === 'done' && state.setup.completedAt ? 'Well done' : step.title;
|
|
142
|
+
return `<section class="section setup"><div><h2>${esc(title)}</h2><p class="lede">Set up Sidebud on ${esc(state.name)}. Keys, accounts, and agents stay on this computer.</p></div>${setupStepper(current)}${body}</section>`;
|
|
143
|
+
}
|
|
144
|
+
/** Handles setup buttons; returns false for actions it does not own. */
|
|
145
|
+
async function handleSetupAction(action, button) {
|
|
146
|
+
if (!action.startsWith('setup-')) return false;
|
|
147
|
+
const current = currentSetupStep();
|
|
148
|
+
const next = () => goToSetupStep(SETUP_STEPS[SETUP_STEPS.findIndex((step) => step.id === current) + 1]?.id || 'done');
|
|
149
|
+
if (action === 'setup-step') goToSetupStep(button.dataset.step);
|
|
150
|
+
else if (action === 'setup-next') next();
|
|
151
|
+
else if (action === 'setup-app-done') {
|
|
152
|
+
await api('setup/ack', { step: 'app' });
|
|
153
|
+
await reload();
|
|
154
|
+
next();
|
|
155
|
+
} else if (action === 'setup-check') {
|
|
156
|
+
if (button.dataset.kind === 'voice' && state.restart.pending && !(await applyChanges())) return true;
|
|
157
|
+
await api('setup/check', { kind: button.dataset.kind });
|
|
158
|
+
await reload();
|
|
159
|
+
} else if (action === 'setup-save-voice') {
|
|
160
|
+
const provider = $('#voice-provider').value;
|
|
161
|
+
const live = state.voice.catalog[provider];
|
|
162
|
+
const endpoint = voiceEndpoint();
|
|
163
|
+
if (live?.needsWorkspaceId && !endpoint.workspaceId) throw new Error(`Add your ${live.label} workspace ID.`);
|
|
164
|
+
const typedKey = document.querySelector(`[data-voice-key="${provider}"]`)?.value;
|
|
165
|
+
if (typedKey) await api('voice-key', { provider, value: typedKey });
|
|
166
|
+
else if (!state.voice.keyStates[provider]?.isSet) throw new Error(`Add a ${live.label} API key first.`);
|
|
167
|
+
// Keep the owner's other voice settings (phrases, call limits); setup changes only the agent.
|
|
168
|
+
await api('voice', { voice: { ...state.voice.voice, provider, model: $('#voice-model').value || null, voiceName: $('#voice-name').value || null, ...endpoint } });
|
|
169
|
+
await reload();
|
|
170
|
+
if (await applyChanges()) {
|
|
171
|
+
await api('setup/check', { kind: 'voice' });
|
|
172
|
+
await reload();
|
|
173
|
+
}
|
|
174
|
+
} else if (action === 'setup-add-integrations') {
|
|
175
|
+
const chosen = [...document.querySelectorAll('[data-integration]')].filter((box) => box.checked && !box.disabled).map((box) => box.dataset.integration);
|
|
176
|
+
const failures = [];
|
|
177
|
+
button.disabled = true;
|
|
178
|
+
// Each pack installs disabled and starts the execution agent on its setup, the same as from MCP & widgets.
|
|
179
|
+
for (const id of chosen) {
|
|
180
|
+
try {
|
|
181
|
+
const result = await api('pack/install-bundled', { service: id });
|
|
182
|
+
if (!result.setup.started) failures.push(`${result.name}: ${result.setup.note}`);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
failures.push(`${state.integrationCatalog.find((entry) => entry.id === id)?.name || id}: ${error.message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
await api('setup/ack', { step: 'integrations' });
|
|
188
|
+
setupSelections = null;
|
|
189
|
+
await reload();
|
|
190
|
+
if (failures.length) notice(`Added, but some need attention: ${failures.join(' ')}`, true);
|
|
191
|
+
else if (chosen.length) notice(`Added. Your execution agent is setting up ${chosen.length === 1 ? 'the integration' : 'the integrations'} and reports on your phone when it needs you.`);
|
|
192
|
+
next();
|
|
193
|
+
} else if (action === 'setup-complete') {
|
|
194
|
+
await api('setup/complete', {});
|
|
195
|
+
history.pushState(null, '', '#setup/done');
|
|
196
|
+
await reload();
|
|
197
|
+
} else if (action === 'setup-reopen') {
|
|
198
|
+
await api('setup/reopen', {});
|
|
199
|
+
setupSelections = null;
|
|
200
|
+
await reload();
|
|
201
|
+
goToSetupStep('app');
|
|
202
|
+
} else return false;
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
document.addEventListener('change', (event) => {
|
|
206
|
+
const id = event.target.dataset?.integration;
|
|
207
|
+
if (!id) return;
|
|
208
|
+
if (event.target.checked) integrationSelections().add(id);
|
|
209
|
+
else integrationSelections().delete(id);
|
|
210
|
+
});
|
|
Binary file
|