heyquilt 0.3.116 → 0.3.119
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/RELEASES.md +21 -0
- package/package.json +1 -1
- package/src/account.js +3 -3
- package/src/ai-vm.js +26 -2
- package/src/api/memory-store.js +2 -2
- package/src/api/routes/ai-vm.js +70 -4
- package/src/api/server.js +4 -4
- package/src/api/supabase-store.js +3 -3
- package/src/autowork.js +54 -6
- package/src/hooks.js +9 -4
- package/src/integrations.js +6 -4
- package/src/quilts/AGENTS.md +1 -0
- package/src/quilts/CLAUDE.md +1 -0
- package/src/quilts/tools.js +0 -2
- package/src/relay-ai-vm.js +5 -3
- package/src/runner.js +3 -2
- package/src/session.js +20 -4
- package/src/setup.js +9 -4
- package/src/ui/app.css +162 -31
- package/src/ui/cloud-ai.js +371 -153
- package/src/ui/home.js +29 -16
- package/src/ui/session.js +12 -1
- package/src/ui-server.js +21 -4
package/src/ui/cloud-ai.js
CHANGED
|
@@ -1,40 +1,198 @@
|
|
|
1
|
-
// Settings →
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// poll (GET /api/cloud-ai?wait=<version>)
|
|
6
|
-
import { I, state, $, esc, toast, api,
|
|
1
|
+
// Settings → Agents: your AI on its own cloud machine (docs/superpowers/specs/2026-10-17-ai-vm.md).
|
|
2
|
+
// Not set up: a card that invites you to. Setting it up takes over the Settings panel (the pop-up's
|
|
3
|
+
// right side, or the page's tab panel) as five steps: its name and color, its AI tool, what it may
|
|
4
|
+
// do, where it works, then the tool's own sign-in. Set up: its card, with what it does changed in
|
|
5
|
+
// place. The card and the sign-in follow the API's long poll (GET /api/cloud-ai?wait=<version>).
|
|
6
|
+
import { I, state, $, esc, toast, api, loadingNote } from './common.js'
|
|
7
7
|
import { toolLogo } from './tool-logo.js'
|
|
8
|
-
import { CLOUD_TOOLS, cloudTool, cloudPhase, cloudStatus, signInSteps, cloudUsage } from './cloud-ai-model.js'
|
|
8
|
+
import { CLOUD_TOOLS, cloudTool, cloudPhase, cloudStatus, signInSteps, cloudUsage, shortTokens } from './cloud-ai-model.js'
|
|
9
9
|
|
|
10
|
-
const
|
|
10
|
+
const STEPS = ['Name', 'AI tool', 'What it does', 'Sessions', 'Sign in']
|
|
11
|
+
export const AI_COLORS = ['#2e7a80', '#4f4fa0', '#4a7a45', '#855a9c', '#a8701c', '#9c4f6b', '#3b6a9a']
|
|
12
|
+
const NAMES = ['Juniper', 'Atlas', 'Wren', 'Moss', 'Pilot']
|
|
13
|
+
const TASKS = [['asked', 'When asked'], ['mine', 'Assigned to it'], ['any', 'Any open task']]
|
|
14
|
+
const SESSIONS = [['every', 'Every session I join'], ['pick', 'Only the ones I pick'], ['none', 'None for now']]
|
|
15
|
+
const LIMITS = [[0, 'No limit'], [1e6, '1 million tokens'], [2e6, '2 million tokens'], [5e6, '5 million tokens'], [1e7, '10 million tokens']]
|
|
16
|
+
const NAME_OK = /^[\p{L}\p{N}][\p{L}\p{N} .'’-]{0,39}$/u
|
|
11
17
|
|
|
12
|
-
|
|
18
|
+
// The person's cloud AI as the API last said (undefined: not asked yet; null: none), shown in
|
|
19
|
+
// whichever Settings is open (`hosts`: root element -> its redraw).
|
|
20
|
+
const cloud = { vm: undefined, loading: false, polling: false, tools: null, hosts: new Map(), ui: { busy: '', error: '', confirming: false } }
|
|
21
|
+
// The setup while it is open: { step, draft, editing, busy, error }.
|
|
22
|
+
let setup = null
|
|
23
|
+
|
|
24
|
+
const person = () => (state.account && state.account.name) || (state.profile && state.profile.name) || 'You'
|
|
25
|
+
const own = () => `${person()}'s AI`
|
|
26
|
+
const at = (n) => `@${n}`
|
|
27
|
+
const redraw = () => { for (const [root, draw] of [...cloud.hosts]) { if (root.isConnected) draw(); else cloud.hosts.delete(root) } }
|
|
28
|
+
const seg = (list, on, attr) => `<div class="seg" role="group">${list.map(([v, l]) => `<button type="button" class="${v === on ? 'on' : ''}" ${attr}="${esc(String(v))}" aria-pressed="${v === on}">${esc(l)}</button>`).join('')}</div>`
|
|
29
|
+
const sw = (on, attr, label) => `<button type="button" class="ai-switch${on ? ' on' : ''}" role="switch" aria-checked="${on}" aria-label="${esc(label)}" ${attr}></button>`
|
|
30
|
+
const pill = (s) => `<span class="pill cai-pill ${s.tone}"><span class="dot"></span>${esc(s.label)}</span>`
|
|
31
|
+
|
|
32
|
+
/** Whether the setup is open: Settings then shows it in place of the Agents tab. */
|
|
33
|
+
export const cloudSetupOn = () => !!setup
|
|
34
|
+
/** Which of its five steps the setup shows (0 to 4), or -1. */
|
|
35
|
+
export const cloudSetupStep = () => setup ? setup.step : -1
|
|
36
|
+
|
|
37
|
+
/** What setting it up starts from: the AI as it is (changing it), or the person's defaults. */
|
|
38
|
+
function draftFrom (vm) {
|
|
39
|
+
if (vm) return { name: vm.name || own(), color: vm.color || AI_COLORS[0], tool: vm.tool, model: vm.model || '', cap: vm.cap || 0, prompt: vm.prompt || '', tasks: vm.tasks || 'mine', files: vm.files !== false, wake: vm.wake !== false, sessionsMode: vm.sessionsMode || 'every', rooms: [...(vm.rooms || [])] }
|
|
40
|
+
return { name: own(), color: AI_COLORS[0], tool: CLOUD_TOOLS[0].tool, model: '', cap: 0, prompt: 'Keep chat messages short. Test before you hand work back. Ask me before anything risky or expensive.', tasks: 'mine', files: true, wake: true, sessionsMode: 'every', rooms: openSessions().map((s) => s.room) }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The sessions open on this computer, for "Only the ones I pick": { room, name, people }. */
|
|
44
|
+
function openSessions () {
|
|
45
|
+
return [...(state.sessions ? state.sessions.values() : [])].map((s) => {
|
|
46
|
+
const st = s.status || {}
|
|
47
|
+
const people = ['You', ...(st.peers || []).filter((p) => p && p.kind !== 'agent' && !p.persona).map((p) => p.name).filter(Boolean)]
|
|
48
|
+
return { room: st.room, name: st.sessionName || String(s.dir || '').split(/[\\/]/).pop() || st.room, people }
|
|
49
|
+
}).filter((s) => s.room)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------- the AI's card --
|
|
53
|
+
|
|
54
|
+
function aiCard ({ name, color, tool, status, compact = false, now = '' }) {
|
|
55
|
+
const mine = name === own()
|
|
56
|
+
return `<div class="ai-card${compact ? ' compact' : ''}"><span class="avatar" style="background:${esc(color)}">${esc((name || '?').trim()[0] || '?')}</span>
|
|
57
|
+
<div class="who"><div class="name"><b>${esc(name)}</b>${mine ? '' : `<span class="ai-tag">${esc(own())}</span>`}</div>
|
|
58
|
+
<div class="sub">${esc(tool)}${compact ? ' · in the cloud' : ' on its own cloud machine'}</div>
|
|
59
|
+
${compact ? '' : `<div class="sub"><span class="at">${esc(at(name))}</span>${mine ? '' : ` or <span class="at">@${esc(own())}</span>`} reaches it</div>`}
|
|
60
|
+
${now ? `<div class="sub now">${esc(now)}</div>` : ''}</div>
|
|
61
|
+
${status ? pill(status) : ''}</div>`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ------------------------------------------------------------------ the card --
|
|
65
|
+
|
|
66
|
+
/** The card's frame in Settings → Agents; bindCloudAi fills it. */
|
|
13
67
|
export function cloudAiCardHtml () {
|
|
14
68
|
return `<section class="card settings-sec cloud-ai" id="cloud-ai-sec" aria-live="polite">
|
|
15
|
-
<div class="sec-intro"><h2
|
|
69
|
+
<div class="sec-intro"><h2>Your AI</h2><p>On its own cloud machine.</p></div>
|
|
16
70
|
<div class="sec-body"><p class="hint">${loadingNote()}</p></div>
|
|
17
71
|
</section>`
|
|
18
72
|
}
|
|
19
73
|
|
|
20
|
-
|
|
74
|
+
function inviteHtml (ui) {
|
|
75
|
+
return `<div class="ai-invite">
|
|
76
|
+
<div class="ai-patch" aria-hidden="true">${I.plus}</div>
|
|
77
|
+
<div><h2>Give your AI a seat in your sessions</h2><p>An AI of yours on its own cloud machine. People write to it by name, and it keeps working when your computer is closed.</p></div>
|
|
78
|
+
<div class="ai-facts">
|
|
79
|
+
<div class="ai-fact"><i>${I.chat}</i><div><b>Answers for you</b><span>@mentions and messages for your AI.</span></div></div>
|
|
80
|
+
<div class="ai-fact"><i>${I.board}</i><div><b>Takes tasks you allow</b><span>Each on a branch of its own.</span></div></div>
|
|
81
|
+
<div class="ai-fact"><i>${I.lock}</i><div><b>Runs on your plan</b><span>Claude, ChatGPT or Cursor.</span></div></div>
|
|
82
|
+
</div>
|
|
83
|
+
${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
|
|
84
|
+
<div class="ai-invite-foot"><span class="grow">Five short steps. You can change everything later.</span><button class="btn primary" type="button" data-ai-setup>${I.sparkle}<span>Set up your AI</span></button></div>
|
|
85
|
+
</div>`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function confirmHtml (vm) {
|
|
89
|
+
return `<div class="cai-confirm" role="group" aria-label="Disconnect your cloud AI?">
|
|
90
|
+
<p><b>Disconnect ${esc(vm.name)}?</b> Its machine and disk are deleted, with any work it hasn't pushed. Your ${esc(cloudTool(vm.tool).account)} account itself is not touched.</p>
|
|
91
|
+
<div class="cai-two"><button class="btn" type="button" data-cloud-keep>Keep it</button><button class="btn danger" type="button" data-cloud-delete>${I.trash}<span>Delete machine and disk</span></button></div>
|
|
92
|
+
</div>`
|
|
93
|
+
}
|
|
21
94
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
${toolLogo(t.tool, { className: 'cai-logo' })}
|
|
27
|
-
<b>${esc(t.tool)}</b><span class="hint">Sign in with ${esc(t.account)}</span>
|
|
28
|
-
<button class="btn sm${i === 0 ? ' primary' : ''}" type="button" data-cloud-connect="${esc(t.tool)}" ${ui.busy ? 'disabled' : ''}>${ui.busy === t.tool ? loadingNote('Starting…') : `Connect ${esc(t.short)}`}</button>
|
|
29
|
-
</div>`).join('')}</div>
|
|
95
|
+
/** Set up but not signed in yet (the setup was closed on its last step): the way back to it. */
|
|
96
|
+
function signingHtml (vm, ui) {
|
|
97
|
+
return `<div class="sec-intro"><h2>Your AI</h2><p>Almost there: ${esc(cloudTool(vm.tool).short)} still needs signing in on its machine.</p></div>
|
|
98
|
+
<div class="sec-body ai-run">${aiCard({ name: vm.name, color: vm.color || AI_COLORS[0], tool: vm.tool, status: cloudStatus(vm) })}
|
|
30
99
|
${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
|
|
31
|
-
|
|
100
|
+
${ui.confirming ? confirmHtml(vm) : `<div class="sec-actions"><button class="btn danger" type="button" data-cloud-disconnect>Disconnect…</button><button class="btn primary" type="button" data-ai-signin>Continue setup</button></div>`}
|
|
32
101
|
</div>`
|
|
33
102
|
}
|
|
34
103
|
|
|
104
|
+
function doesHtml (d, name) {
|
|
105
|
+
return `<div class="ai-list">
|
|
106
|
+
<div class="ai-row"><i>${I.chat}</i><div><b>Answer people who write to it</b><span class="hint">@mentions and direct messages for ${esc(name)}${name === own() ? '' : ` or ${esc(own())}`}.</span></div><span class="ai-always">Always</span></div>
|
|
107
|
+
<div class="ai-row wide"><i>${I.board}</i><div><b>Pick up tasks from the board</b><span class="hint">When it finishes one, it starts the next.</span></div><div class="ctl">${seg(TASKS, d.tasks, 'data-ai-tasks')}</div></div>
|
|
108
|
+
<div class="ai-row"><i>${I.branch}</i><div><b>Change files and push its own branches</b><span class="hint">Each task on a branch of its own with a pull request. A person merges it.</span></div>${sw(d.files, 'data-ai-files', 'Change files and push its own branches')}</div>
|
|
109
|
+
<div class="ai-row"><i>${I.power}</i><div><b>Wake for every message</b><span class="hint">${d.wake ? 'A message to it wakes its machine within a minute.' : 'Messages wait until it next wakes: for a task, or when you work in one of its sessions.'}</span></div>${sw(d.wake, 'data-ai-wake', 'Wake for every message')}</div>
|
|
110
|
+
</div>`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readyHtml (vm, ui) {
|
|
114
|
+
const u = cloudUsage(vm)
|
|
115
|
+
const sleeping = ['suspended', 'stopped', 'failed'].includes(vm.state)
|
|
116
|
+
const sessions = vm.sessions || []
|
|
117
|
+
const where = { every: 'Joins every session you join', pick: 'Joins the sessions you picked', none: 'Joins no session for now' }[vm.sessionsMode || 'every']
|
|
118
|
+
return `<div class="sec-intro"><h2>Your AI</h2><p>On its own cloud machine. What people write to it, and the tasks you let it take, go here.</p></div>
|
|
119
|
+
<div class="sec-body ai-run">
|
|
120
|
+
${aiCard({ name: vm.name, color: vm.color || AI_COLORS[0], tool: vm.tool, status: cloudStatus(vm) })}
|
|
121
|
+
${vm.state === 'failed' && vm.error ? `<p class="error">${esc(vm.error)}</p>` : ''}
|
|
122
|
+
${sleeping ? `<div class="ai-asleep"><span class="hint">${vm.state === 'suspended' ? 'Asleep while there is nothing to do. It wakes for a task, a message to it, or when you work in one of its sessions.' : 'Its machine stopped.'}</span><button class="btn sm" type="button" data-cloud-wake ${ui.busy === 'wake' ? 'disabled' : ''}>${vm.state === 'suspended' ? 'Wake now' : 'Start it again'}</button></div>` : ''}
|
|
123
|
+
<div class="ai-meters">
|
|
124
|
+
<div title="${esc(where)}"><b>${sessions.length}</b><span>session${sessions.length === 1 ? '' : 's'}</span></div>
|
|
125
|
+
<div><b>${esc(u.tokens)}</b><span>tokens today${u.pct === null ? '' : `, ${u.pct}% of ${esc(u.cap)}`}</span></div>
|
|
126
|
+
<div><b>${u.runs}</b><span>run${u.runs === 1 ? '' : 's'} today</span></div>
|
|
127
|
+
</div>
|
|
128
|
+
${u.over ? '<p class="hint warn">The daily limit is reached: work for it waits on the board until tomorrow.</p>' : ''}
|
|
129
|
+
${doesHtml({ tasks: vm.tasks || 'mine', files: vm.files !== false, wake: vm.wake !== false }, vm.name)}
|
|
130
|
+
${sessions.length ? `<div class="ai-list">${sessions.map((x) => `<div class="ai-sess static"><span class="ai-on"></span><b>${esc(x.name || x.room)}</b><span class="hint">joined</span></div>`).join('')}</div>` : ''}
|
|
131
|
+
${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
|
|
132
|
+
${ui.confirming ? confirmHtml(vm) : `<div class="sec-actions"><button class="btn danger" type="button" data-cloud-disconnect>Disconnect…</button><button class="btn" type="button" data-ai-edit>${I.pencil}<span>Change its setup</span></button></div>`}
|
|
133
|
+
</div>`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The card's inside for `vm` (null: not set up) and what the person is doing in it. */
|
|
137
|
+
export function cloudAiInnerHtml (vm, ui = {}) {
|
|
138
|
+
const phase = cloudPhase(vm)
|
|
139
|
+
if (phase === 'none') return inviteHtml(ui)
|
|
140
|
+
return phase === 'signin' ? signingHtml(vm, ui) : readyHtml(vm, ui)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ----------------------------------------------------------------- the setup --
|
|
144
|
+
|
|
145
|
+
const q = (h, p, inner) => `<section class="ai-q"><header><h3>${h}</h3><p>${p}</p></header>${inner}</section>`
|
|
146
|
+
|
|
147
|
+
function nameStep (d) {
|
|
148
|
+
return q('What should your AI be called?', 'People see this name in chat, on the task board and on the files it changes. It is always shown as yours.',
|
|
149
|
+
`<input class="input ai-name" id="ai-name" value="${esc(d.name)}" maxlength="40" autocomplete="off" spellcheck="false" aria-label="Its name">
|
|
150
|
+
<div class="ai-chips">${[own(), ...NAMES].map((n) => `<button class="ai-chip${n === d.name ? ' on' : ''}" type="button" data-ai-name="${esc(n)}">${esc(n)}</button>`).join('')}</div>
|
|
151
|
+
<div class="ai-sub">Color</div>
|
|
152
|
+
<div class="ai-colors">${AI_COLORS.map((c) => `<button class="ai-color${c === d.color ? ' on' : ''}" style="background:${c}" type="button" data-ai-color="${c}" aria-label="Color ${c}" aria-pressed="${c === d.color}"></button>`).join('')}</div>`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toolStep (d) {
|
|
156
|
+
const models = ((cloud.tools || []).find((t) => t.tool === d.tool) || {}).models || []
|
|
157
|
+
return q(`Which AI does ${esc(d.name)} run on?`, 'Your own tool, signed in with your own account on its machine. Your plan pays for what it uses; Quilt never sees the login.',
|
|
158
|
+
`<div class="ai-tools">${CLOUD_TOOLS.map((t) => `<button class="ai-tool${t.tool === d.tool ? ' on' : ''}" type="button" data-ai-tool="${esc(t.tool)}" aria-pressed="${t.tool === d.tool}">${toolLogo(t.tool, { className: 'cai-logo' })}<b>${esc(t.tool)}</b><span class="hint">Sign in with ${esc(t.account)}</span><span class="tick">${t.tool === d.tool ? I.check : ''}</span></button>`).join('')}</div>
|
|
159
|
+
<div class="ai-pair"><div class="field"><label for="ai-model">Model</label><select class="input" id="ai-model" data-ai-model><option value="">The tool's default</option>${models.map((m) => `<option value="${esc(m)}" ${m === d.model ? 'selected' : ''}>${esc(m)}</option>`).join('')}${d.model && !models.includes(d.model) ? `<option value="${esc(d.model)}" selected>${esc(d.model)}</option>` : ''}</select></div>
|
|
160
|
+
<div class="field"><label for="ai-cap">Daily limit</label><select class="input" id="ai-cap" data-ai-cap>${LIMITS.map(([v, l]) => `<option value="${v}" ${v === d.cap ? 'selected' : ''}>${l}</option>`).join('')}${LIMITS.some(([v]) => v === d.cap) ? '' : `<option value="${d.cap}" selected>${esc(shortTokens(d.cap))} tokens</option>`}</select></div></div>`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function doesStep (d) {
|
|
164
|
+
return q(`What may ${esc(d.name)} do?`, `It always answers people who write to ${esc(at(d.name))}. The rest is up to you, and you can change it any time.`,
|
|
165
|
+
`${doesHtml(d, d.name)}
|
|
166
|
+
<div class="field ai-rules"><label for="ai-rules">House rules</label><textarea class="input" id="ai-rules" rows="3" maxlength="4000" data-ai-rules placeholder="Anything it should always do, or never do.">${esc(d.prompt)}</textarea><span class="hint">Read at the start of every run, on top of the project's own rules.</span></div>`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sessionsStep (d) {
|
|
170
|
+
const list = openSessions()
|
|
171
|
+
const rows = list.length
|
|
172
|
+
? `<div class="ai-list">${list.map((s) => `<label class="ai-sess${d.sessionsMode === 'pick' ? '' : ' off'}"><input type="checkbox" data-ai-room="${esc(s.room)}" ${d.sessionsMode === 'every' || (d.sessionsMode === 'pick' && d.rooms.includes(s.room)) ? 'checked' : ''} ${d.sessionsMode === 'pick' ? '' : 'disabled'}><span><b>${esc(s.name)}</b><span class="hint">${esc(s.people.length > 1 ? s.people.join(', ') : 'Only you')}</span></span><span class="ai-faces">${s.people.slice(0, 5).map((n) => `<span class="avatar" title="${esc(n)}">${esc((n === 'You' ? person() : n)[0] || '')}</span>`).join('')}</span></label>`).join('')}</div>
|
|
173
|
+
<p class="hint">${d.sessionsMode === 'every' ? 'And every session you open or join from now on.' : 'Sessions you open later: pick them here when you want it there.'}</p>`
|
|
174
|
+
: `<p class="hint">${d.sessionsMode === 'every' ? 'No session is open on this computer now: it joins the ones you open or join from now on.' : 'No session is open on this computer now. Open one, then pick it here.'}</p>`
|
|
175
|
+
return q(`Where does ${esc(d.name)} work?`, 'It joins sessions as you do, and stays in them when you close your computer.',
|
|
176
|
+
`${seg(SESSIONS, d.sessionsMode, 'data-ai-sessions')}${d.sessionsMode === 'none' ? '<p class="hint">It joins no session until you pick some here.</p>' : rows}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function signInStep (vm, d) {
|
|
180
|
+
const t = cloudTool(d.tool)
|
|
181
|
+
const steps = vm ? signInSteps(vm) : []
|
|
182
|
+
return q(`Sign ${esc(d.tool)} in on ${esc(d.name)}'s machine`, `The last step: ${esc(t.short)}'s own sign-in, running on the machine. Quilt shows you its steps.`,
|
|
183
|
+
`<div class="ai-signin"><ol class="cai-steps">${steps.map(stepHtml).join('')}</ol>
|
|
184
|
+
<div class="ai-signin-again">${vm && vm.state === 'failed' ? `<button class="btn sm" type="button" data-cloud-wake>${I.refresh}<span>Start the machine again</span></button>` : `<button class="btn sm ghost" type="button" data-cloud-restart>${I.refresh}<span>Start the sign-in again</span></button>`}</div></div>
|
|
185
|
+
<div class="ai-sub">What you chose</div>
|
|
186
|
+
<div class="ai-review">
|
|
187
|
+
<div><span>Name</span><b>${esc(d.name)}</b><button class="ai-change" type="button" data-ai-goto="0">Change</button></div>
|
|
188
|
+
<div><span>AI</span><b>${esc(d.tool)}${d.model ? ` · ${esc(d.model)}` : ''} · ${d.cap ? `${esc(shortTokens(d.cap))} tokens a day` : 'no daily limit'}</b><button class="ai-change" type="button" data-ai-goto="1">Change</button></div>
|
|
189
|
+
<div><span>Does</span><b>Answers; tasks ${esc(Object.fromEntries(TASKS)[d.tasks].toLowerCase())}; ${d.files ? 'pushes its own branches' : 'changes no files'}</b><button class="ai-change" type="button" data-ai-goto="2">Change</button></div>
|
|
190
|
+
<div><span>Works in</span><b>${esc(Object.fromEntries(SESSIONS)[d.sessionsMode])}</b><button class="ai-change" type="button" data-ai-goto="3">Change</button></div>
|
|
191
|
+
</div>`)
|
|
192
|
+
}
|
|
193
|
+
|
|
35
194
|
function stepHtml (s) {
|
|
36
195
|
const body = []
|
|
37
|
-
// While the tool checks a pasted code there is nothing more to type.
|
|
38
196
|
const pasting = s.key === 'paste' && (s.canPaste || !s.now)
|
|
39
197
|
if (s.hint && (s.key !== 'paste' || pasting)) body.push(`<span class="hint">${esc(s.hint)}</span>`)
|
|
40
198
|
if (s.error) body.push(`<span class="error">${esc(s.error)}</span>`)
|
|
@@ -52,169 +210,229 @@ function stepHtml (s) {
|
|
|
52
210
|
return `<li class="cai-step${s.done ? ' done' : ''}${s.now ? ' now' : ''}${s.error ? ' bad' : ''}"><div><b>${esc(s.title)}</b>${body.join('')}</div></li>`
|
|
53
211
|
}
|
|
54
212
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
213
|
+
/**
|
|
214
|
+
* The setup, in three parts for its host: `head` (back to Agents, its title, the steps), `body`
|
|
215
|
+
* (the AI's card and the question) and `foot` (Back, a line, the step's button).
|
|
216
|
+
*/
|
|
217
|
+
export function cloudSetupHtml () {
|
|
218
|
+
const { step, draft: d, editing, busy, error } = setup
|
|
219
|
+
const vm = cloud.vm || null
|
|
220
|
+
const head = `<div class="ai-flow-head"><button class="back" type="button" data-ai-close>${I.arrowLeft}<span>Agents</span></button><span class="sep"></span><h4>${editing ? `Change ${esc(vm ? vm.name : d.name)}'s setup` : 'Set up your AI'}</h4><span class="count">Step ${step + 1} of ${STEPS.length}</span></div>
|
|
221
|
+
<ol class="ai-steps">${STEPS.map((s, i) => `<li class="${i < step ? 'done' : i === step ? 'now' : ''}">${s}</li>`).join('')}</ol>`
|
|
222
|
+
const asks = [nameStep, toolStep, doesStep, sessionsStep]
|
|
223
|
+
const body = `<div class="ai-flow-body">${aiCard({ name: d.name || '?', color: d.color, tool: d.tool, status: step === 4 && vm ? cloudStatus(vm) : null, compact: step > 0 })}
|
|
224
|
+
${step < 4 ? asks[step](d) : signInStep(vm, d)}</div>`
|
|
225
|
+
const line = error ? `<span class="error">${esc(error)}</span>`
|
|
226
|
+
: step === 3 && !editing ? `Next: sign ${esc(d.tool)} in.`
|
|
227
|
+
: step === 4 ? `${esc(d.name)} starts as soon as ${esc(cloudTool(d.tool).short)} is signed in.` : 'You can change all of this later.'
|
|
228
|
+
const next = step < 3 ? '<button class="btn primary" type="button" data-ai-next>Continue</button>'
|
|
229
|
+
: step === 3 ? `<button class="btn primary" type="button" data-ai-start ${busy ? 'disabled' : ''}>${busy ? loadingNote(editing ? 'Saving…' : 'Starting…') : editing ? 'Save changes' : `Start ${esc(d.name)}'s machine`}</button>`
|
|
230
|
+
: ''
|
|
231
|
+
const foot = `<div class="ai-flow-foot"><button class="btn" type="button" data-ai-prev ${step && step < 4 ? '' : 'disabled'}>Back</button><span class="grow">${line}</span>${next}</div>`
|
|
232
|
+
return { head, body, foot }
|
|
60
233
|
}
|
|
61
234
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
|
|
72
|
-
${ui.confirming ? confirmHtml(vm) : `<div class="cai-two">${again}<button class="btn danger" type="button" data-cloud-disconnect>Disconnect…</button></div>`}
|
|
73
|
-
</div>`
|
|
235
|
+
/** Closes the setup: Settings shows the Agents tab again, at the AI's card. */
|
|
236
|
+
const closeSetup = () => { setup = null; cloud.toCard = true }
|
|
237
|
+
|
|
238
|
+
/** Opens the setup: `editing` changes the AI there is; `step` starts it there (4: the sign-in). */
|
|
239
|
+
function openSetup ({ editing = false, step = 0 } = {}) {
|
|
240
|
+
setup = { step, draft: draftFrom(editing || step === 4 ? cloud.vm : null), editing, busy: false, error: '' }
|
|
241
|
+
if (!cloud.tools) api('GET', '/api/cloud-ai/tools').then((r) => { cloud.tools = r.tools || []; if (setup && setup.step === 1) redraw() }, () => {})
|
|
242
|
+
redraw()
|
|
243
|
+
watch()
|
|
74
244
|
}
|
|
75
245
|
|
|
76
|
-
function
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
: '<p class="hint">In no session yet. It joins the sessions you join, and stays when you leave for the day.</p>'
|
|
84
|
-
return `<div class="sec-intro"><h2>${TITLE}</h2><p>This is your AI: tasks, @mentions and messages for it come here, even while your computer is closed.</p></div>
|
|
85
|
-
<div class="sec-body cai-body">
|
|
86
|
-
<div class="cai-who">${avatar(vm.name || 'AI', state.profile && state.profile.color, online)}
|
|
87
|
-
<div class="grow"><b>${esc(vm.name || 'Cloud AI')}</b><span class="hint">${esc(vm.tool)} on your cloud machine</span></div>
|
|
88
|
-
${pill(st)}
|
|
89
|
-
${sleeping ? `<button class="btn sm" type="button" data-cloud-wake ${ui.busy === 'wake' ? 'disabled' : ''}>${vm.state === 'suspended' ? 'Wake now' : 'Start it again'}</button>` : ''}
|
|
90
|
-
</div>
|
|
91
|
-
${vm.state === 'failed' && vm.error ? `<p class="error">${esc(vm.error)}</p>` : ''}
|
|
92
|
-
${vm.state === 'suspended' ? '<p class="hint">Asleep while there is nothing to do. It wakes by itself when you open or work in one of its sessions, or on a task, an @mention or a message for it.</p>' : ''}
|
|
93
|
-
${sessions}
|
|
94
|
-
<div class="cai-kv">
|
|
95
|
-
<div><b>${esc(u.tokens)}</b><span>tokens today</span></div>
|
|
96
|
-
<div><b>${u.runs}</b><span>run${u.runs === 1 ? '' : 's'} today</span></div>
|
|
97
|
-
<div><b>${u.pct === null ? 'No cap' : `${u.pct}%`}</b><span>${u.pct === null ? 'set a daily cap below' : `of ${esc(u.cap)} daily cap`}</span></div>
|
|
98
|
-
</div>
|
|
99
|
-
${u.pct === null ? '' : `<div class="cai-meter${u.over ? ' over' : ''}" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${u.pct}" aria-label="Today's use of the daily cap"><i style="width:${u.pct}%"></i></div>`}
|
|
100
|
-
${u.over ? '<p class="hint warn">The daily cap is reached: work for your AI waits on the board until tomorrow.</p>' : ''}
|
|
101
|
-
<hr class="cai-sep">
|
|
102
|
-
<form class="cai-form" id="cloud-settings-form" autocomplete="off">
|
|
103
|
-
<div class="cai-two">
|
|
104
|
-
<div class="field"><label for="cai-model">Model</label>
|
|
105
|
-
<input class="input" id="cai-model" name="model" list="cai-models" value="${esc(vm.model || '')}" placeholder="${esc((vm.models || [])[0] || 'The tool’s default')}">
|
|
106
|
-
<datalist id="cai-models">${(vm.models || []).map((m) => `<option value="${esc(m)}">`).join('')}</datalist></div>
|
|
107
|
-
<div class="field"><label for="cai-cap">Daily cap (tokens)</label>
|
|
108
|
-
<input class="input" id="cai-cap" name="cap" type="number" min="0" step="1000" value="${vm.cap || ''}" placeholder="No limit"></div>
|
|
109
|
-
</div>
|
|
110
|
-
<div class="field"><label for="cai-prompt">Instructions for every run</label>
|
|
111
|
-
<textarea class="input" id="cai-prompt" name="prompt" rows="3" placeholder="Anything it should always do, or never do.">${esc(vm.prompt || '')}</textarea></div>
|
|
112
|
-
${ui.error ? `<p class="error">${esc(ui.error)}</p>` : ''}
|
|
113
|
-
${ui.confirming ? confirmHtml(vm) : `<div class="cai-two"><button class="btn primary" type="submit" ${ui.busy === 'save' ? 'disabled' : ''}>Save</button><button class="btn danger" type="button" data-cloud-disconnect>Disconnect…</button></div>`}
|
|
114
|
-
</form>
|
|
115
|
-
</div>`
|
|
246
|
+
function checkName () {
|
|
247
|
+
const n = String(setup.draft.name || '').trim().replace(/\s+/g, ' ')
|
|
248
|
+
if (!n) return 'Give your AI a name.'
|
|
249
|
+
if (!NAME_OK.test(n)) return 'Its name: up to 40 letters, numbers and spaces.'
|
|
250
|
+
if (n.toLowerCase() === person().toLowerCase()) return 'That is your own name: pick another one for your AI.'
|
|
251
|
+
setup.draft.name = n
|
|
252
|
+
return ''
|
|
116
253
|
}
|
|
117
254
|
|
|
118
|
-
/** The
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const take = (r) => {
|
|
139
|
-
const before = cloudPhase(vm)
|
|
140
|
-
vm = r.vm
|
|
141
|
-
if (cloudPhase(vm) !== before) { edited.clear(); ui.confirming = false }
|
|
142
|
-
draw()
|
|
143
|
-
if (vm) poll()
|
|
255
|
+
/** The draft as the API takes it ("<person>'s AI" is its name anyway: sent blank, so it follows theirs). */
|
|
256
|
+
const settingsOf = (d) => ({ tool: d.tool, name: d.name === own() ? '' : d.name, color: d.color, model: d.model, cap: d.cap, prompt: d.prompt, tasks: d.tasks, files: d.files, wake: d.wake, sessionsMode: d.sessionsMode, rooms: d.sessionsMode === 'pick' ? d.rooms : [] })
|
|
257
|
+
|
|
258
|
+
async function finish () {
|
|
259
|
+
const d = setup.draft
|
|
260
|
+
setup.busy = true
|
|
261
|
+
setup.error = ''
|
|
262
|
+
redraw()
|
|
263
|
+
try {
|
|
264
|
+
if (setup.editing && cloud.vm) {
|
|
265
|
+
cloud.vm = (await api('POST', '/api/cloud-ai/settings', settingsOf(d))).vm
|
|
266
|
+
toast(`Saved ${d.name}'s setup`)
|
|
267
|
+
closeSetup()
|
|
268
|
+
} else {
|
|
269
|
+
cloud.vm = (await api('POST', '/api/cloud-ai/connect', settingsOf(d))).vm
|
|
270
|
+
Object.assign(setup, { step: 4, busy: false })
|
|
271
|
+
watch()
|
|
272
|
+
}
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (setup) Object.assign(setup, { busy: false, error: err.message })
|
|
144
275
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
276
|
+
redraw()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ------------------------------------------------------------ keeping current --
|
|
280
|
+
|
|
281
|
+
/** Follows the API while a Settings shows the card or the setup: the card, and the sign-in as it goes. */
|
|
282
|
+
function watch () {
|
|
283
|
+
if (cloud.polling || !cloud.vm) return
|
|
284
|
+
cloud.polling = true
|
|
285
|
+
;(async () => {
|
|
286
|
+
while (cloud.vm && [...cloud.hosts.keys()].some((r) => r.isConnected)) {
|
|
151
287
|
try {
|
|
152
|
-
|
|
153
|
-
if (!sec.isConnected) break
|
|
154
|
-
if (!r.vm || !vm || r.vm.version !== vm.version || cloudPhase(r.vm) !== cloudPhase(vm)) take(r)
|
|
288
|
+
take((await api('GET', `/api/cloud-ai?wait=${encodeURIComponent(cloud.vm.version || 0)}`)).vm)
|
|
155
289
|
} catch (err) {
|
|
156
|
-
if (err.signedOut
|
|
290
|
+
if (err.signedOut) break
|
|
157
291
|
await new Promise((resolve) => setTimeout(resolve, 5000))
|
|
158
292
|
}
|
|
159
293
|
}
|
|
160
|
-
polling = false
|
|
161
|
-
}
|
|
294
|
+
cloud.polling = false
|
|
295
|
+
})()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function take (vm) {
|
|
299
|
+
const before = cloud.vm
|
|
300
|
+
cloud.vm = vm || null
|
|
301
|
+
// Signed in while the setup showed its last step: it is done.
|
|
302
|
+
if (setup && setup.step === 4 && vm && vm.signedIn) { closeSetup(); toast(`${vm.name} is ready`) }
|
|
303
|
+
const changed = before === undefined || !before !== !vm || (before && vm && (before.version !== vm.version || cloudPhase(before) !== cloudPhase(vm)))
|
|
304
|
+
if (changed) redraw()
|
|
305
|
+
watch()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// --------------------------------------------------------------- the binding --
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Binds the card (in Settings → Agents) and the setup when it is open. `root` holds both: the
|
|
312
|
+
* Settings page, or the pop-up (whose header and footer the setup takes too). `refresh` draws that
|
|
313
|
+
* Settings again, so the setup takes over its panel, and gives it back when it closes.
|
|
314
|
+
*/
|
|
315
|
+
export function bindCloudAi (root, refresh) {
|
|
316
|
+
const ui = cloud.ui
|
|
317
|
+
cloud.hosts.set(root, () => {
|
|
318
|
+
const flow = !!root.querySelector('.ai-flow-body')
|
|
319
|
+
const sec = $('#cloud-ai-sec', root)
|
|
320
|
+
if (!flow && !sec) return // another tab
|
|
321
|
+
if (setup || flow) return refresh()
|
|
322
|
+
if (cloud.vm !== undefined) sec.innerHTML = cloudAiInnerHtml(cloud.vm, ui)
|
|
323
|
+
})
|
|
324
|
+
const sec = $('#cloud-ai-sec', root)
|
|
325
|
+
if (sec && cloud.vm !== undefined) sec.innerHTML = cloudAiInnerHtml(cloud.vm, ui)
|
|
326
|
+
if (sec && cloud.toCard) { cloud.toCard = false; requestAnimationFrame(() => sec.scrollIntoView({ block: 'start' })) }
|
|
327
|
+
if (cloud.vm === undefined && !cloud.loading) {
|
|
328
|
+
cloud.loading = true
|
|
329
|
+
api('GET', '/api/cloud-ai').then((r) => take(r.vm), (err) => {
|
|
330
|
+
for (const s of document.querySelectorAll('#cloud-ai-sec .sec-body')) s.innerHTML = `<p class="hint warn">${esc(err.message)}</p>`
|
|
331
|
+
}).finally(() => { cloud.loading = false })
|
|
332
|
+
} else watch()
|
|
333
|
+
if (root.dataset.cloudBound) return
|
|
334
|
+
root.dataset.cloudBound = '1'
|
|
335
|
+
|
|
162
336
|
const act = async (busy, fn) => {
|
|
163
337
|
ui.busy = busy
|
|
164
338
|
ui.error = ''
|
|
165
|
-
|
|
166
|
-
try { await fn() } catch (err) { ui.error = err.message }
|
|
339
|
+
redraw()
|
|
340
|
+
try { await fn() } catch (err) { ui.error = err.message; if (setup) setup.error = err.message }
|
|
167
341
|
ui.busy = ''
|
|
168
|
-
|
|
342
|
+
redraw()
|
|
169
343
|
}
|
|
344
|
+
const signInUrl = () => (cloud.vm && cloud.vm.signIn && cloud.vm.signIn.url) || ''
|
|
345
|
+
// A change goes into the setup's draft while it is open; on the card (set up) it is saved at once.
|
|
346
|
+
const change = async (patch) => {
|
|
347
|
+
if (setup) { Object.assign(setup.draft, patch); setup.error = ''; redraw(); return }
|
|
348
|
+
if (!cloud.vm) return
|
|
349
|
+
try { take((await api('POST', '/api/cloud-ai/settings', patch)).vm); toast('Saved') } catch (err) { toast(err.message) }
|
|
350
|
+
}
|
|
351
|
+
const current = (k, dflt) => setup ? setup.draft[k] : cloud.vm && cloud.vm[k] !== undefined ? cloud.vm[k] : dflt
|
|
170
352
|
|
|
171
|
-
|
|
172
|
-
sec.addEventListener('click', async (e) => {
|
|
353
|
+
root.addEventListener('click', async (e) => {
|
|
173
354
|
const b = e.target.closest('button')
|
|
174
|
-
if (!b || b.disabled) return
|
|
175
|
-
|
|
176
|
-
if (
|
|
177
|
-
if (
|
|
355
|
+
if (!b || b.disabled || !root.contains(b)) return
|
|
356
|
+
const data = b.dataset
|
|
357
|
+
if ('aiSetup' in data) return openSetup()
|
|
358
|
+
if ('aiEdit' in data) return openSetup({ editing: true })
|
|
359
|
+
if ('aiSignin' in data) return openSetup({ step: 4 })
|
|
360
|
+
if ('aiClose' in data) { closeSetup(); return redraw() }
|
|
361
|
+
if ('aiPrev' in data) { setup.step = Math.max(0, setup.step - 1); setup.error = ''; return redraw() }
|
|
362
|
+
if ('aiNext' in data) {
|
|
363
|
+
if (setup.step === 0) { const bad = checkName(); if (bad) { setup.error = bad; return redraw() } }
|
|
364
|
+
setup.step++
|
|
365
|
+
setup.error = ''
|
|
366
|
+
return redraw()
|
|
367
|
+
}
|
|
368
|
+
// From the sign-in, back to a question: what is changed there is saved (the AI exists by then).
|
|
369
|
+
if ('aiGoto' in data) { setup.step = Number(data.aiGoto); setup.editing = !!cloud.vm; return redraw() }
|
|
370
|
+
if ('aiStart' in data) {
|
|
371
|
+
const bad = checkName()
|
|
372
|
+
if (bad) { Object.assign(setup, { step: 0, error: bad }); return redraw() }
|
|
373
|
+
return finish()
|
|
374
|
+
}
|
|
375
|
+
if (data.aiName !== undefined) return change({ name: data.aiName })
|
|
376
|
+
if (data.aiColor !== undefined) return change({ color: data.aiColor })
|
|
377
|
+
if (data.aiTool !== undefined) return change({ tool: data.aiTool, model: '' })
|
|
378
|
+
if (data.aiTasks !== undefined) return change({ tasks: data.aiTasks })
|
|
379
|
+
if ('aiFiles' in data) return change({ files: !current('files', true) })
|
|
380
|
+
if ('aiWake' in data) return change({ wake: !current('wake', true) })
|
|
381
|
+
if (data.aiSessions !== undefined) {
|
|
382
|
+
const rooms = setup && data.aiSessions === 'pick' && !setup.draft.rooms.length ? openSessions().map((s) => s.room) : current('rooms', [])
|
|
383
|
+
return change({ sessionsMode: data.aiSessions, rooms })
|
|
384
|
+
}
|
|
385
|
+
if ('cloudOpen' in data) { window.open(signInUrl(), '_blank', 'noopener'); return }
|
|
386
|
+
if ('cloudCopy' in data) {
|
|
178
387
|
try { await navigator.clipboard.writeText(signInUrl()); toast('Copied the link') } catch { toast('Select the link and press ⌘/Ctrl+C to copy') }
|
|
179
388
|
return
|
|
180
389
|
}
|
|
181
|
-
if (
|
|
182
|
-
if (
|
|
183
|
-
if (
|
|
184
|
-
if (
|
|
185
|
-
if (
|
|
390
|
+
if ('cloudRestart' in data) return act('restart', async () => { await api('POST', '/api/cloud-ai/sign-in', {}); toast(`Starting ${cloudTool(cloud.vm.tool).short}'s sign-in again`) })
|
|
391
|
+
if ('cloudWake' in data) return act('wake', async () => take((await api('POST', '/api/cloud-ai/wake')).vm))
|
|
392
|
+
if ('cloudDisconnect' in data) { ui.confirming = true; ui.error = ''; return redraw() }
|
|
393
|
+
if ('cloudKeep' in data) { ui.confirming = false; return redraw() }
|
|
394
|
+
if ('cloudDelete' in data) {
|
|
186
395
|
return act('delete', async () => {
|
|
187
396
|
await api('POST', '/api/cloud-ai/disconnect')
|
|
188
397
|
ui.confirming = false
|
|
189
|
-
take(
|
|
398
|
+
take(null)
|
|
190
399
|
toast('Disconnected: its machine and disk are deleted')
|
|
191
400
|
})
|
|
192
401
|
}
|
|
193
402
|
})
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
toast(`Sent to ${cloudTool(vm.tool).short} on your machine`)
|
|
204
|
-
})
|
|
403
|
+
// Typing keeps the field's focus: the AI's card and the name chips follow without a redraw.
|
|
404
|
+
root.addEventListener('input', (e) => {
|
|
405
|
+
if (!setup) return
|
|
406
|
+
const t = e.target
|
|
407
|
+
if (t.id === 'ai-name') {
|
|
408
|
+
setup.draft.name = t.value
|
|
409
|
+
const card = $('.ai-card', root)
|
|
410
|
+
if (card) card.outerHTML = aiCard({ name: t.value || '?', color: setup.draft.color, tool: setup.draft.tool })
|
|
411
|
+
for (const c of root.querySelectorAll('[data-ai-name]')) c.classList.toggle('on', c.dataset.aiName === t.value)
|
|
205
412
|
}
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
413
|
+
if (t.matches('[data-ai-rules]')) setup.draft.prompt = t.value
|
|
414
|
+
})
|
|
415
|
+
root.addEventListener('change', (e) => {
|
|
416
|
+
const t = e.target
|
|
417
|
+
if (t.matches('[data-ai-model]')) change({ model: t.value })
|
|
418
|
+
if (t.matches('[data-ai-cap]')) change({ cap: Number(t.value) || 0 })
|
|
419
|
+
if (t.matches('[data-ai-room]') && setup) {
|
|
420
|
+
const rooms = new Set(setup.draft.rooms)
|
|
421
|
+
if (t.checked) rooms.add(t.dataset.aiRoom); else rooms.delete(t.dataset.aiRoom)
|
|
422
|
+
setup.draft.rooms = [...rooms]
|
|
213
423
|
}
|
|
214
424
|
})
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
425
|
+
root.addEventListener('keydown', (e) => {
|
|
426
|
+
if (e.key === 'Enter' && e.target.id === 'ai-name') { e.preventDefault(); $('[data-ai-next]', root)?.click() }
|
|
427
|
+
})
|
|
428
|
+
root.addEventListener('submit', (e) => {
|
|
429
|
+
if (e.target.id !== 'cloud-code-form') return
|
|
430
|
+
e.preventDefault()
|
|
431
|
+
const code = String(new FormData(e.target).get('code') || '').trim()
|
|
432
|
+
if (!code) return
|
|
433
|
+
act('code', async () => {
|
|
434
|
+
await api('POST', '/api/cloud-ai/sign-in', { code })
|
|
435
|
+
toast(`Sent to ${cloudTool(cloud.vm.tool).short} on its machine`)
|
|
436
|
+
})
|
|
219
437
|
})
|
|
220
438
|
}
|