@davait/aipictury 1.19.5 → 1.20.1
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/ext/about.js +2 -2
- package/ext/accountUi.js +83 -21
- package/ext/agent.js +84 -11
- package/ext/attachments.js +55 -0
- package/ext/cloud.js +165 -32
- package/ext/dialogs.js +9 -5
- package/ext/extension.js +281 -42
- package/ext/feedback.js +78 -6
- package/ext/firestore.js +37 -6
- package/ext/gpuRun.js +31 -7
- package/ext/keeping.js +171 -0
- package/ext/mediagen.js +186 -20
- package/ext/memory.js +17 -7
- package/ext/package.json +1 -1
- package/ext/plans.js +4 -1
- package/ext/remote.js +72 -12
- package/ext/voice.js +62 -11
- package/ext/workbench.js +9 -2
- package/main.js +96 -27
- package/package.json +1 -1
- package/vscode-shim.js +3 -0
package/ext/mediagen.js
CHANGED
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
// into the workspace — to add one, or to replace one the code already uses. A replaced
|
|
4
4
|
// file keeps its size and format, so whatever references it still fits.
|
|
5
5
|
|
|
6
|
+
const crypto = require('crypto')
|
|
6
7
|
const vscode = require('vscode')
|
|
7
8
|
const { resolve, root } = require('./tools')
|
|
8
9
|
|
|
9
10
|
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'])
|
|
10
11
|
const VIDEO_EXT = new Set(['mp4', 'webm', 'mov', 'm4v'])
|
|
11
|
-
/** fn(server, jobId
|
|
12
|
-
*
|
|
12
|
+
/** fn(server, jobId) after a render the agent asked for is done: the account keeps it (keeping.js),
|
|
13
|
+
* its card following by the job id in its media. */
|
|
13
14
|
const rendered = new Set()
|
|
14
15
|
function onRendered(fn) { rendered.add(fn); return { dispose: () => rendered.delete(fn) } }
|
|
15
16
|
const STALL = 120_000 // the server repeats its progress every 10s; this is a lost link
|
|
17
|
+
// A render followed by its job id (its stream lost): looked at every `poll` ms; the server silent
|
|
18
|
+
// for `unreachable` ms meanwhile, it is left pending for next time. (A check shortens them.)
|
|
19
|
+
const timing = { poll: 5000, unreachable: 10 * 60_000 }
|
|
16
20
|
|
|
17
21
|
/** The width and height of a PNG, JPEG, GIF or WebP, read from its header; null if unknown. */
|
|
18
22
|
function imageSize(bytes) {
|
|
@@ -94,12 +98,78 @@ async function follow(response, onCard, signal) {
|
|
|
94
98
|
return last
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
const pause = (ms, signal) => new Promise((done) => {
|
|
102
|
+
const stop = () => { clearTimeout(timer); done() }
|
|
103
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', stop); done() }, ms)
|
|
104
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
/** The render's state read back by its job id, in the stream's shape: queued or running with
|
|
108
|
+
* its progress, done with its asset, or an error. */
|
|
109
|
+
async function jobState(server, jobId, signal) {
|
|
110
|
+
// Stop, or 20 s without an answer, ends this one request (AbortSignal.any is not in every Node 20).
|
|
111
|
+
const ask = new AbortController()
|
|
112
|
+
const timer = setTimeout(() => ask.abort(), 20_000)
|
|
113
|
+
const onStop = () => ask.abort()
|
|
114
|
+
signal?.addEventListener('abort', onStop, { once: true })
|
|
115
|
+
let r
|
|
116
|
+
try {
|
|
117
|
+
r = await fetch(`${server}/api/jobs/${encodeURIComponent(jobId)}`, { signal: ask.signal })
|
|
118
|
+
} finally { clearTimeout(timer); signal?.removeEventListener('abort', onStop) }
|
|
119
|
+
if (r.status === 404) return { status: 'error', error: 'the render is no longer on the server' }
|
|
120
|
+
if (!r.ok) throw new Error(`the server answered ${r.status}`)
|
|
121
|
+
const job = await r.json()
|
|
122
|
+
if (job.status === 'queued' || job.status === 'running') return { status: job.status, progress: job.progress || 0 }
|
|
123
|
+
if (job.status === 'done') return job.assets?.[0] ? { status: 'done', asset: job.assets[0] } : { status: 'error', error: 'the render finished without a file' }
|
|
124
|
+
return { status: 'error', error: job.error || `the render ${job.status || 'failed'}` }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Follows a render by its job id until it ends — after its stream was lost (a dropped link, the
|
|
128
|
+
* 45-minute limit of the stream) or after a reload or an exit cut the run (1.20.0): the job goes
|
|
129
|
+
* on on the server whatever happens to the editor. A server that does not answer (restarting,
|
|
130
|
+
* the Wi-Fi) is waited for; null when stopped. */
|
|
131
|
+
async function settle(server, jobId, onCard, signal) {
|
|
132
|
+
let silentSince = null
|
|
133
|
+
while (!signal?.aborted) {
|
|
134
|
+
let card = null
|
|
135
|
+
try {
|
|
136
|
+
card = await jobState(server, jobId, signal)
|
|
137
|
+
silentSince = null
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (signal?.aborted) break
|
|
140
|
+
silentSince ??= Date.now()
|
|
141
|
+
if (Date.now() - silentSince > timing.unreachable) {
|
|
142
|
+
const lost = new Error(`the server could not be reached to finish the render (${error.message})`)
|
|
143
|
+
lost.unreachable = true
|
|
144
|
+
throw lost
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (card?.status === 'done' || card?.status === 'error') return card
|
|
148
|
+
if (card) onCard(card)
|
|
149
|
+
await pause(timing.poll, signal)
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Stop pressed mid-render: the GPU job is cancelled too. Only the reading of it stopped before,
|
|
155
|
+
* and the GPU went on making a picture nobody would collect (1.20.0). */
|
|
156
|
+
async function cancel(server, jobId) {
|
|
157
|
+
if (!jobId) return false
|
|
158
|
+
try {
|
|
159
|
+
const r = await fetch(`${server}/api/jobs/${encodeURIComponent(jobId)}/cancel`, { method: 'POST', signal: AbortSignal.timeout(10_000) })
|
|
160
|
+
return r.ok
|
|
161
|
+
} catch { return false }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const progressOf = (update) => Math.round((update.progress || 0) * (update.progress > 1 ? 1 : 100))
|
|
165
|
+
|
|
97
166
|
/**
|
|
98
167
|
* Makes the picture or clip and writes it into the workspace.
|
|
99
|
-
* `ask(question)` is the approval — true to go ahead. `report(update)` keeps the
|
|
100
|
-
* card current.
|
|
168
|
+
* `ask(question, { path })` is the approval — true to go ahead. `report(update)` keeps the
|
|
169
|
+
* panel's card current. `onJob(pending)`, once the server has queued it: what is needed to
|
|
170
|
+
* deliver it later by its job id (resume), for the caller to keep. Returns what to tell the model.
|
|
101
171
|
*/
|
|
102
|
-
async function generate(server, args, { ask, report, signal }) {
|
|
172
|
+
async function generate(server, args, { ask, report, signal, onJob }) {
|
|
103
173
|
const prompt = String(args.prompt || '').trim()
|
|
104
174
|
if (!prompt) throw new Error('prompt is required: describe the picture or clip in full')
|
|
105
175
|
const { kind, path, format } = target(args)
|
|
@@ -120,30 +190,57 @@ async function generate(server, args, { ask, report, signal }) {
|
|
|
120
190
|
}
|
|
121
191
|
|
|
122
192
|
const verb = existing ? 'Replace' : 'Create'
|
|
123
|
-
if (!(await ask(`${verb} ${path} with a generated ${kind}?\n\n${prompt.slice(0, 300)}
|
|
193
|
+
if (!(await ask(`${verb} ${path} with a generated ${kind}?\n\n${prompt.slice(0, 300)}`, { path }))) {
|
|
124
194
|
return { refused: true, text: 'the user declined this' }
|
|
125
195
|
}
|
|
126
196
|
|
|
127
197
|
report({ status: 'running', progress: 0, path })
|
|
128
|
-
|
|
198
|
+
// A seed of its own, sent with the request and kept with the pending call: /api/agent/media
|
|
199
|
+
// renders with it rather than one of its own from the clock (1.20.0: its request model dropped
|
|
200
|
+
// the field without a word, so the seed sent did nothing).
|
|
201
|
+
const seed = crypto.randomInt(1, 2 ** 31 - 1)
|
|
202
|
+
const body = { kind, prompt, aspect: args.aspect || 'landscape', seconds: Number(args.seconds) || 5, seed }
|
|
129
203
|
if (width && height) Object.assign(body, { width, height })
|
|
130
204
|
if (source) Object.assign(body, { source: Buffer.from(source).toString('base64'), source_name: args.source })
|
|
131
205
|
const response = await fetch(`${server}/api/agent/media`, {
|
|
132
206
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal,
|
|
133
207
|
})
|
|
134
208
|
if (!response.ok) throw new Error(`the server could not start it (${response.status}): ${(await response.text()).slice(0, 200)}`)
|
|
135
|
-
|
|
209
|
+
// Its job id, from the first line: kept by the caller at once, so a render cut off by a reload,
|
|
210
|
+
// an exit or a lost link is delivered later instead of being lost or made a second time (1.20.0).
|
|
211
|
+
const job = { job: null, seed, path, kind, format, width, height, existing: !!existing, saved: !!args.save_to }
|
|
212
|
+
const onCard = (update) => {
|
|
213
|
+
if (update.job_id && !job.job) {
|
|
214
|
+
job.job = update.job_id
|
|
215
|
+
try { onJob?.({ ...job }) } catch { /* kept or not, the render goes on */ }
|
|
216
|
+
}
|
|
136
217
|
if (update.status === 'queued' || update.status === 'running') {
|
|
137
|
-
report({ status: 'running', progress:
|
|
218
|
+
report({ status: 'running', progress: progressOf(update), path, queued: update.status === 'queued' })
|
|
138
219
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
220
|
+
}
|
|
221
|
+
let card = null
|
|
222
|
+
try {
|
|
223
|
+
card = await follow(response, onCard, signal)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// Stopped: the job is cancelled below. The stream lost: the job goes on, followed by its id.
|
|
226
|
+
if (!(signal?.aborted || job.job)) throw error
|
|
227
|
+
}
|
|
228
|
+
if (job.job && !signal?.aborted && card?.status !== 'done') card = await settle(server, job.job, onCard, signal)
|
|
229
|
+
if (signal?.aborted) {
|
|
230
|
+
const cancelled = await cancel(server, job.job)
|
|
231
|
+
return { stopped: true, text: cancelled ? 'stopped by the user: the render was cancelled on the server' : 'stopped by the user' }
|
|
232
|
+
}
|
|
141
233
|
if (!card || card.status !== 'done') throw new Error(card?.error || 'the render ended without a file')
|
|
234
|
+
return deliver(server, job, card.asset, { report, signal })
|
|
235
|
+
}
|
|
142
236
|
|
|
143
|
-
|
|
237
|
+
/** A render, finished, written into the workspace: the file at the size and in the format the
|
|
238
|
+
* project needs, a preview for the card, the account told. Returns what to tell the model. */
|
|
239
|
+
async function deliver(server, job, asset, { report, signal }) {
|
|
240
|
+
const { kind, path, format, width, height, existing } = job
|
|
144
241
|
const query = new URLSearchParams({ format })
|
|
145
242
|
if (width && height && kind === 'image') { query.set('width', width); query.set('height', height) }
|
|
146
|
-
const file = await fetch(`${server}/api/agent/media/${
|
|
243
|
+
const file = await fetch(`${server}/api/agent/media/${asset}?${query}`, { signal })
|
|
147
244
|
if (!file.ok) throw new Error(`could not fetch the result (${file.status}): ${(await file.text()).slice(0, 200)}`)
|
|
148
245
|
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
149
246
|
await vscode.workspace.fs.createDirectory(vscode.Uri.joinPath(resolve(path), '..'))
|
|
@@ -155,20 +252,89 @@ async function generate(server, args, { ask, report, signal }) {
|
|
|
155
252
|
try {
|
|
156
253
|
const w = 320
|
|
157
254
|
const h = size ? Math.max(16, Math.round(w * size.height / size.width)) : 180
|
|
158
|
-
const still = await fetch(`${server}/api/agent/media/${
|
|
255
|
+
const still = await fetch(`${server}/api/agent/media/${asset}?format=jpg&width=${w}&height=${h}`, { signal })
|
|
159
256
|
if (still.ok) preview = `data:image/jpeg;base64,${Buffer.from(await still.arrayBuffer()).toString('base64')}`
|
|
160
257
|
} catch { /* the card shows the path without a picture */ }
|
|
161
258
|
|
|
162
259
|
const described = `${size ? `${size.width}×${size.height} ` : ''}${format.toUpperCase()}, ${Math.round(bytes.length / 1024)} KB`
|
|
163
|
-
|
|
260
|
+
// Its job, so its card can follow its keeping with the account after a reload (keeping.js).
|
|
261
|
+
const media = { kind, path, preview, width: size?.width, height: size?.height, bytes: bytes.length, job: job.job || undefined }
|
|
164
262
|
report({ status: 'done', path, media })
|
|
165
|
-
// Kept with the account now (
|
|
166
|
-
const
|
|
167
|
-
for (const fn of rendered) { try { fn(server, card.job_id, keep) } catch { /* the account's own problem */ } }
|
|
263
|
+
// Kept with the account now (keeping.js): its progress, then the figures, on this card.
|
|
264
|
+
for (const fn of rendered) { try { fn(server, job.job) } catch { /* the account's own problem */ } }
|
|
168
265
|
return {
|
|
169
266
|
text: `Saved ${path} (${described})${existing ? ', replacing the previous file' : ''}.`
|
|
170
|
-
+ (
|
|
267
|
+
+ (job.saved ? '' : ' It was saved under .aipictury/media/ to show the user; move it into the project if it belongs there.'),
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Renders being delivered by their job id, so the panel (on reopening) and the run (carrying on)
|
|
272
|
+
// asking for the same one share a single delivery: one follow, one write, every caller told.
|
|
273
|
+
const delivering = new Map()
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A render a cut-off run left pending (what onJob gave): followed by its job id to the end and
|
|
277
|
+
* written where it was asked for. Resolves to what to tell the model — "Saved …", or what went
|
|
278
|
+
* wrong — or null when stopped or the server is out of reach (it stays pending for next time).
|
|
279
|
+
*
|
|
280
|
+
* Each caller's own signal is its own: the delivery shared with the panel was made with the
|
|
281
|
+
* panel's signal, so Stop in a run waiting on it did nothing until the render ended — up to ten
|
|
282
|
+
* minutes with the server out of reach (1.20.0). Now a caller that stops leaves at once with
|
|
283
|
+
* null; the follow itself stops only when the last caller has left.
|
|
284
|
+
*/
|
|
285
|
+
function resume(server, pending, { report = () => {}, signal } = {}) {
|
|
286
|
+
if (!pending?.job) return Promise.resolve('error: the render was cut off before the server had queued it')
|
|
287
|
+
if (signal?.aborted) return Promise.resolve(null)
|
|
288
|
+
let shared = delivering.get(pending.job)
|
|
289
|
+
if (!shared) {
|
|
290
|
+
const stop = new AbortController()
|
|
291
|
+
const callers = new Set()
|
|
292
|
+
const tell = (update) => { for (const fn of callers) { try { fn(update) } catch { /* one caller's row */ } } }
|
|
293
|
+
const path = pending.path
|
|
294
|
+
const running = (async () => {
|
|
295
|
+
tell({ status: 'running', progress: 0, path })
|
|
296
|
+
let card
|
|
297
|
+
try {
|
|
298
|
+
card = await settle(server, pending.job, (update) => tell({ status: 'running', progress: progressOf(update), path, queued: update.status === 'queued' }), stop.signal)
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error.unreachable) return null
|
|
301
|
+
throw error
|
|
302
|
+
}
|
|
303
|
+
if (!card) return null
|
|
304
|
+
if (card.status !== 'done') {
|
|
305
|
+
tell({ status: 'failed', path, result: card.error })
|
|
306
|
+
return `error: ${card.error}`
|
|
307
|
+
}
|
|
308
|
+
return (await deliver(server, pending, card.asset, { report: tell, signal: stop.signal })).text
|
|
309
|
+
})().catch((error) => {
|
|
310
|
+
if (stop.signal.aborted || error.name === 'AbortError') return null
|
|
311
|
+
tell({ status: 'failed', path, result: error.message })
|
|
312
|
+
return `error: ${error.message}`
|
|
313
|
+
}).finally(() => { if (delivering.get(pending.job) === shared) delivering.delete(pending.job) })
|
|
314
|
+
shared = { running, stop, callers }
|
|
315
|
+
delivering.set(pending.job, shared)
|
|
171
316
|
}
|
|
317
|
+
const mine = (update) => report(update) // a function of its own, even for the same report twice
|
|
318
|
+
shared.callers.add(mine)
|
|
319
|
+
const joined = shared
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
const leave = (value) => {
|
|
322
|
+
signal?.removeEventListener('abort', stopped)
|
|
323
|
+
joined.callers.delete(mine)
|
|
324
|
+
resolve(value)
|
|
325
|
+
}
|
|
326
|
+
const stopped = () => {
|
|
327
|
+
leave(null)
|
|
328
|
+
// The last one gone: nobody waits for it, so it is not followed further — and a later
|
|
329
|
+
// caller starts a delivery of its own rather than joining this stopping one.
|
|
330
|
+
if (!joined.callers.size) {
|
|
331
|
+
if (delivering.get(pending.job) === joined) delivering.delete(pending.job)
|
|
332
|
+
joined.stop.abort()
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
signal?.addEventListener('abort', stopped, { once: true })
|
|
336
|
+
joined.running.then(leave)
|
|
337
|
+
})
|
|
172
338
|
}
|
|
173
339
|
|
|
174
|
-
module.exports = { generate, imageSize, target, root, onRendered }
|
|
340
|
+
module.exports = { generate, resume, imageSize, target, root, onRendered, timing }
|
package/ext/memory.js
CHANGED
|
@@ -59,8 +59,13 @@ async function request(server, path, init = {}) {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
// Your rules live with your AIPictury account when you are signed in (accountUi.js gives this
|
|
62
|
-
// module the account's list), else on the AIPictury server, as before.
|
|
63
|
-
|
|
62
|
+
// module the account's list), else on the AIPictury server, as before. A rule is added or removed
|
|
63
|
+
// on the account in one step (cloud.addRules / removeRule), never by writing back the list read a
|
|
64
|
+
// moment before: two machines saving a rule at once kept only one of them (1.20.0).
|
|
65
|
+
let accountRules = null // { active: async () => bool, get: async () => [...], add: async (rule), remove: async (id) }
|
|
66
|
+
// The account keeps 500 at most (firestore.rules): one appended past that is refused with a
|
|
67
|
+
// permission error, and the rule was lost with no word of why (1.20.0) — said before it is tried.
|
|
68
|
+
const MAX_ACCOUNT_RULES = 500
|
|
64
69
|
function useAccountRules(adapter) { accountRules = adapter }
|
|
65
70
|
|
|
66
71
|
async function userRules(server) {
|
|
@@ -70,10 +75,15 @@ async function userRules(server) {
|
|
|
70
75
|
|
|
71
76
|
async function addUserRule(server, text) {
|
|
72
77
|
if (await accountRules?.active()) {
|
|
78
|
+
const clean = String(text).trim()
|
|
79
|
+
// Refused with a reason, never clipped: what GET /api/memory says as max_chars (1.20.0).
|
|
80
|
+
if (clean.length > 2000) throw new Error(`not remembered: a rule is at most 2,000 characters and this one is ${clean.length.toLocaleString('en')}`)
|
|
73
81
|
const rules = await accountRules.get()
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
82
|
+
if (rules.some((r) => r.text === clean)) return 'remembered for you, in every project — kept with your AIPictury account'
|
|
83
|
+
if (rules.length >= MAX_ACCOUNT_RULES) {
|
|
84
|
+
throw new Error(`not remembered: your account keeps ${MAX_ACCOUNT_RULES} rules at most and has ${rules.length} — remove one (/memory) and add it again`)
|
|
85
|
+
}
|
|
86
|
+
await accountRules.add({ id: `r${Date.now().toString(36)}`, text: clean, created: Date.now() })
|
|
77
87
|
return 'remembered for you, in every project — kept with your AIPictury account'
|
|
78
88
|
}
|
|
79
89
|
await request(server, '/api/memory', {
|
|
@@ -84,7 +94,7 @@ async function addUserRule(server, text) {
|
|
|
84
94
|
|
|
85
95
|
async function deleteUserRule(server, id) {
|
|
86
96
|
if (await accountRules?.active()) {
|
|
87
|
-
await accountRules.
|
|
97
|
+
await accountRules.remove(id)
|
|
88
98
|
return
|
|
89
99
|
}
|
|
90
100
|
await request(server, `/api/memory/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
@@ -114,4 +124,4 @@ async function block(server) {
|
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
module.exports = { PROJECT_FILE, projectUri, projectRules, addProjectRule, removeProjectRule,
|
|
117
|
-
userRules, addUserRule, deleteUserRule, block, useAccountRules, onProjectRules }
|
|
127
|
+
userRules, addUserRule, deleteUserRule, block, useAccountRules, onProjectRules, MAX_ACCOUNT_RULES }
|
package/ext/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "aipictury",
|
|
3
3
|
"displayName": "AIPictury",
|
|
4
4
|
"description": "A coding agent and a generation studio running on your own GPU, with uncensored local models.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.20.1",
|
|
6
6
|
"publisher": "aipictury",
|
|
7
7
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
8
8
|
"engines": {
|
package/ext/plans.js
CHANGED
|
@@ -12,7 +12,10 @@ const { home, projectKey } = require('./sessions')
|
|
|
12
12
|
const MODES = [
|
|
13
13
|
{ id: 'edits', label: 'Approve — and edit automatically', detail: 'File changes go ahead; commands are asked' },
|
|
14
14
|
{ id: 'ask', label: 'Approve — and ask before each change', detail: 'Manual mode' },
|
|
15
|
-
|
|
15
|
+
// Said as Auto works since 1.10.4: it asks nothing (only your own ask rules and hooks do), and
|
|
16
|
+
// what would have been asked is listed at the end. "Anything risky is asked" promised a question
|
|
17
|
+
// Auto never stops for (1.20.0).
|
|
18
|
+
{ id: 'never', label: 'Approve — in Auto mode', detail: 'Asks nothing: what would have been asked goes ahead and is listed at the end' },
|
|
16
19
|
]
|
|
17
20
|
|
|
18
21
|
function planFile(folder, id) {
|
package/ext/remote.js
CHANGED
|
@@ -17,6 +17,7 @@ const TEXT_CAP = 8000 // characters of one entry
|
|
|
17
17
|
const LIVE_CAP = 20_000
|
|
18
18
|
const DEBOUNCE = 150
|
|
19
19
|
const RETRY = [1000, 2000, 5000, 10_000, 30_000]
|
|
20
|
+
const KEEP = 10 * 60_000 // as long as the server waits for its editor (remote.py KEEP)
|
|
20
21
|
|
|
21
22
|
const cut = (text, n) => { const t = String(text ?? ''); return t.length > n ? `${t.slice(0, n)}…` : t }
|
|
22
23
|
|
|
@@ -26,13 +27,16 @@ function toolLabel(t) {
|
|
|
26
27
|
return cut(a.command || a.path || a.url || a.pattern || a.query || a.description || a.title || a.action || '', 160)
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
/** What an approval asks, for the remote view: kind, the command or file, why
|
|
30
|
+
/** What an approval asks, for the remote view: kind, the command or file, why — and the question
|
|
31
|
+
* as the editor words it, which every client can show when it does not know the kind: a render
|
|
32
|
+
* (kind 'media') read "Use generate_media?" on aipictury.com, with no file and no prompt (1.20.0). */
|
|
30
33
|
function approvalOf(id, request = {}) {
|
|
31
34
|
return {
|
|
32
35
|
id, kind: request.kind,
|
|
33
36
|
tool: request.tool, command: cut(request.command, 2000), path: request.path, site: request.site,
|
|
34
37
|
because: request.because, description: request.description,
|
|
35
|
-
|
|
38
|
+
question: request.question ? cut(request.question, 1200) : undefined,
|
|
39
|
+
args: ['tool', 'mcp', 'media'].includes(request.kind) ? cut(JSON.stringify(request.args || {}), 1200) : undefined,
|
|
36
40
|
// An edit as a diff: each changed part, its removed lines (-) and added ones (+), from line n.
|
|
37
41
|
diff: request.kind === 'edit' ? cut((request.hunks || []).map((h) => [`@@ line ${h.line}`, ...h.removed.map((l) => `- ${l}`),
|
|
38
42
|
...h.added.map((l) => `+ ${l}`)].join('\n')).join('\n') || request.after, 6000) : undefined,
|
|
@@ -56,18 +60,21 @@ function snapshot(panel, live) {
|
|
|
56
60
|
question: panel.pendingAsk ? { id: panel.pendingAsk.id, questions: panel.pendingAsk.questions } : null,
|
|
57
61
|
plan: panel.pendingPlan ? { id: panel.pendingPlan.id, plan: cut(panel.pendingPlan.plan, 20_000) } : null,
|
|
58
62
|
queue: (panel.queue || []).map((q) => cut(q.text, 400)),
|
|
63
|
+
// How the last run ended: the relay tells the account's devices (remote.py), knowing a stop from a finish.
|
|
64
|
+
ended: panel.ended || undefined,
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
class RemoteLink {
|
|
63
|
-
/** `panel`: the AgentPanel; `server`: its http(s) address.
|
|
64
|
-
|
|
69
|
+
/** `panel`: the AgentPanel; `server`: its http(s) address. `id` and `key`: a link kept from
|
|
70
|
+
* before a reload (SavedLinks), hosted again so the same address carries on. */
|
|
71
|
+
constructor(panel, server, { log = () => {}, onViewers = () => {}, webApp = 'https://aipictury.com', token = async () => null, id = null, key = null } = {}) {
|
|
65
72
|
this.webApp = webApp
|
|
66
73
|
this.token = token // the account's ID token: the server may ask for it
|
|
67
74
|
this.panel = panel
|
|
68
75
|
this.server = server.replace(/\/+$/, '')
|
|
69
|
-
this.id = crypto.randomUUID()
|
|
70
|
-
this.key = crypto.randomBytes(24).toString('base64url')
|
|
76
|
+
this.id = id || crypto.randomUUID()
|
|
77
|
+
this.key = key || crypto.randomBytes(24).toString('base64url')
|
|
71
78
|
this.log = log
|
|
72
79
|
this.onViewers = onViewers
|
|
73
80
|
this.live = ''
|
|
@@ -106,7 +113,7 @@ class RemoteLink {
|
|
|
106
113
|
this.ws = ws
|
|
107
114
|
const opened = Date.now()
|
|
108
115
|
logs.event('debug', 'ws.connecting', { path: '/ws/remote/host', attempt: this.tries + 1, signedIn: Boolean(token) })
|
|
109
|
-
ws.onopen = () => { logs.event('debug', 'ws.open', { path: '/ws/remote/host', ms: Date.now() - opened }); ws.send(JSON.stringify({ type: 'hello', id: this.id, key: this.key })) }
|
|
116
|
+
ws.onopen = () => { logs.event('debug', 'ws.open', { path: '/ws/remote/host', ms: Date.now() - opened }); ws.send(JSON.stringify({ type: 'hello', id: this.id, key: this.key, notify: 'server', server: this.server })) }
|
|
110
117
|
ws.onmessage = (event) => {
|
|
111
118
|
let msg
|
|
112
119
|
try { msg = JSON.parse(String(event.data)) } catch { logs.event('debug', 'ws.message', { path: '/ws/remote/host', unreadable: true }); return }
|
|
@@ -117,8 +124,10 @@ class RemoteLink {
|
|
|
117
124
|
}
|
|
118
125
|
ws.onclose = (event) => {
|
|
119
126
|
this.connected = false
|
|
120
|
-
|
|
127
|
+
// 4401: not signed in, or an account this server does not let in: trying again changes nothing.
|
|
128
|
+
const refused = event.code === 4401 || event.code === 4403 || event.code === 4409
|
|
121
129
|
logs.event(refused ? 'warn' : 'debug', 'ws.close', { path: '/ws/remote/host', code: event.code, reason: event.reason || undefined, seconds: Math.round((Date.now() - opened) / 1000), retrying: this.active && !refused })
|
|
130
|
+
if (refused && this.active) this.log?.(`remote control refused: ${event.reason || 'sign in, or check that this server lets your account in'}`)
|
|
122
131
|
if (!this.active || refused) return
|
|
123
132
|
const wait = RETRY[Math.min(this.tries, RETRY.length - 1)]
|
|
124
133
|
this.tries += 1
|
|
@@ -162,15 +171,66 @@ class RemoteLink {
|
|
|
162
171
|
this.schedule()
|
|
163
172
|
}
|
|
164
173
|
|
|
165
|
-
/** Ends the sharing: the link stops working for everyone.
|
|
166
|
-
|
|
174
|
+
/** Ends the sharing: the link stops working for everyone. With `end: false` only this editor
|
|
175
|
+
* lets go (its panel closed, the window reloading): the server keeps the conversation for
|
|
176
|
+
* KEEP, and the same link carries on once it is hosted again. */
|
|
177
|
+
stop({ end = true } = {}) {
|
|
167
178
|
this.active = false
|
|
168
179
|
clearTimeout(this.retry)
|
|
169
180
|
clearTimeout(this.pending)
|
|
170
181
|
this.restorePost?.()
|
|
171
|
-
try { if (this.ws?.readyState === 1) this.ws.send(JSON.stringify({ type: 'end' })) } catch { /* closing anyway */ }
|
|
182
|
+
try { if (end && this.ws?.readyState === 1) this.ws.send(JSON.stringify({ type: 'end' })) } catch { /* closing anyway */ }
|
|
172
183
|
setTimeout(() => { try { this.ws?.close() } catch { /* gone */ } }, 200)
|
|
173
184
|
}
|
|
174
185
|
}
|
|
175
186
|
|
|
176
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The links of conversations shared by remote control, kept so a reload or a crash no longer ends
|
|
189
|
+
* them for good — the id and key were never stored, and a restarted editor could not host the same
|
|
190
|
+
* link again (1.20.0). The id and key go in the secret store (`secrets`: VS Code's), when each was
|
|
191
|
+
* last hosted in `state` (the workspace's). A link hosted within KEEP is hosted again when its
|
|
192
|
+
* conversation is on screen; an older one is forgotten, so an old link never wakes days later.
|
|
193
|
+
*/
|
|
194
|
+
class SavedLinks {
|
|
195
|
+
constructor({ secrets, state, now = () => Date.now() }) {
|
|
196
|
+
this.secrets = secrets
|
|
197
|
+
this.state = state
|
|
198
|
+
this.now = now
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
static secretName(conversation) { return `aipictury.remote.${conversation}` }
|
|
202
|
+
|
|
203
|
+
seen() { return { ...(this.state.get('aipictury.remote') || {}) } }
|
|
204
|
+
|
|
205
|
+
/** Hosting `link` for `conversation`: kept, and its time refreshed (called now and then while on). */
|
|
206
|
+
async keep(conversation, link) {
|
|
207
|
+
if (!conversation || !link) return
|
|
208
|
+
if (link.keptFor !== conversation) {
|
|
209
|
+
await this.secrets.store(SavedLinks.secretName(conversation), JSON.stringify({ id: link.id, key: link.key }))
|
|
210
|
+
link.keptFor = conversation
|
|
211
|
+
}
|
|
212
|
+
await this.state.update('aipictury.remote', { ...this.seen(), [conversation]: this.now() })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The link to host again for `conversation` ({ id, key }), or null: none, or hosted too long ago. */
|
|
216
|
+
async take(conversation) {
|
|
217
|
+
const at = this.seen()[conversation]
|
|
218
|
+
if (!at) return null
|
|
219
|
+
if (this.now() - at > KEEP) { await this.forget(conversation); return null }
|
|
220
|
+
try {
|
|
221
|
+
const saved = JSON.parse(await this.secrets.get(SavedLinks.secretName(conversation)) || 'null')
|
|
222
|
+
return saved?.id && saved?.key ? saved : null
|
|
223
|
+
} catch { return null }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Sharing stopped on purpose: the link is gone for good. */
|
|
227
|
+
async forget(conversation) {
|
|
228
|
+
if (!conversation) return
|
|
229
|
+
const seen = this.seen()
|
|
230
|
+
delete seen[conversation]
|
|
231
|
+
await this.state.update('aipictury.remote', seen)
|
|
232
|
+
await this.secrets.delete(SavedLinks.secretName(conversation))
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = { RemoteLink, SavedLinks, snapshot, approvalOf, KEEP }
|
package/ext/voice.js
CHANGED
|
@@ -119,17 +119,68 @@ class Recording {
|
|
|
119
119
|
discard() { try { fs.rmSync(this.file, { force: true }) } catch { /* gone */ } }
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
// Writing a recording waits this long for the server (a 5-minute recording takes Whisper well
|
|
123
|
+
// under a minute; a server that never answers no longer keeps "writing…" on for ever), and a
|
|
124
|
+
// failure that may clear — no answer, a restart (5xx), a busy GPU (423, 429) — is tried again
|
|
125
|
+
// after each of these pauses before it is said (1.20.0).
|
|
126
|
+
const TRANSCRIBE_TIMEOUT = 180_000
|
|
127
|
+
const RETRY_WAITS = [2000, 6000]
|
|
128
|
+
|
|
129
|
+
const pause = (ms, signal) => new Promise((done) => {
|
|
130
|
+
const stop = () => { clearTimeout(timer); done() }
|
|
131
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', stop); done() }, ms)
|
|
132
|
+
signal?.addEventListener('abort', stop, { once: true })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
/** One try: the server's answer, or an error that says whether trying again may help (`transient`). */
|
|
136
|
+
async function transcribeOnce(server, file, { language, signal, timeoutMs, fetchImpl }) {
|
|
137
|
+
const form = new FormData()
|
|
138
|
+
form.append('audio', new Blob([fs.readFileSync(file)], { type: 'audio/wav' }), 'dictation.wav')
|
|
139
|
+
if (language) form.append('language', language)
|
|
140
|
+
// Stop, or no answer within timeoutMs, ends this try (AbortSignal.any is not in every Node 20).
|
|
141
|
+
const ask = new AbortController()
|
|
142
|
+
const timer = setTimeout(() => ask.abort(), timeoutMs)
|
|
143
|
+
const onStop = () => ask.abort()
|
|
144
|
+
signal?.addEventListener('abort', onStop, { once: true })
|
|
145
|
+
let response
|
|
124
146
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
147
|
+
response = await fetchImpl(`${server}/api/transcribe`, { method: 'POST', body: form, signal: ask.signal })
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (signal?.aborted) throw error
|
|
150
|
+
throw Object.assign(new Error(ask.signal.aborted ? `the server did not answer in ${Math.round(timeoutMs / 1000)} s`
|
|
151
|
+
: `the server could not be reached (${error.cause?.code || error.message})`), { transient: true })
|
|
152
|
+
} finally { clearTimeout(timer); signal?.removeEventListener('abort', onStop) }
|
|
153
|
+
const data = await response.json().catch(() => ({}))
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
throw Object.assign(new Error(data.detail || `the server answered ${response.status}${response.status === 404 ? ' — it is older than dictation; update it' : ''}`),
|
|
156
|
+
{ status: response.status, transient: response.status >= 500 || [408, 423, 429].includes(response.status) })
|
|
157
|
+
}
|
|
158
|
+
return data
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The server writes it: { text, language, duration, seconds }. The recording is removed once it
|
|
163
|
+
* is written, and only then: a failure leaves it where it is — the error carries it (`file`) — to
|
|
164
|
+
* be written again. It was removed whatever happened, so a server restarting or out of reach lost
|
|
165
|
+
* what was said, with no way to try again (1.20.0).
|
|
166
|
+
*/
|
|
167
|
+
async function transcribe(server, file, { language = '', signal, timeoutMs = TRANSCRIBE_TIMEOUT, waits = RETRY_WAITS, fetchImpl = fetch } = {}) {
|
|
168
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
169
|
+
try {
|
|
170
|
+
const data = await transcribeOnce(server, file, { language, signal, timeoutMs, fetchImpl })
|
|
171
|
+
fs.rmSync(file, { force: true })
|
|
172
|
+
return data
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (signal?.aborted || !error.transient || attempt >= waits.length) {
|
|
175
|
+
error.file = fs.existsSync(file) ? file : null
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
await pause(waits[attempt], signal)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
133
181
|
}
|
|
134
182
|
|
|
135
|
-
|
|
183
|
+
/** A recording that was not written, given up on: removed. */
|
|
184
|
+
function discard(file) { try { if (file) fs.rmSync(file, { force: true }) } catch { /* gone */ } }
|
|
185
|
+
|
|
186
|
+
module.exports = { recorder, Recording, transcribe, discard, missing, which, soundBytes, MAX_SECONDS, TRANSCRIBE_TIMEOUT, RETRY_WAITS }
|
package/ext/workbench.js
CHANGED
|
@@ -139,16 +139,23 @@ function uriHandler(registry) {
|
|
|
139
139
|
|
|
140
140
|
// ---- telling the user, when VS Code is not in front ----
|
|
141
141
|
|
|
142
|
-
/** A system notification — only when VS Code is not the focused window, and `notifications` is on.
|
|
142
|
+
/** A system notification — only when VS Code is not the focused window, and `notifications` is on.
|
|
143
|
+
* In the terminal (cli/) the same notification, and the terminal's bell beside it. */
|
|
143
144
|
function notify(title, body) {
|
|
144
|
-
if (!config().get('notifications', true) || vscode.window.state.focused) return false
|
|
145
145
|
const text = String(body || '').slice(0, 200)
|
|
146
146
|
try {
|
|
147
|
+
// Inside the try, and asked with ?.: 1.20.0 — the CLI's stand-in had no window.state, and
|
|
148
|
+
// the TypeError here ended the agent's run.
|
|
149
|
+
if (!config().get('notifications', true) || vscode.window.state?.focused) return false
|
|
150
|
+
if (vscode.cli && process.stderr.isTTY) process.stderr.write('\x07')
|
|
147
151
|
if (process.platform === 'darwin') {
|
|
148
152
|
const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
149
153
|
execFile('osascript', ['-e', `display notification "${esc(text)}" with title "${esc(title)}"`], { timeout: 5000 }, () => {})
|
|
150
154
|
} else if (process.platform === 'linux') {
|
|
151
155
|
execFile('notify-send', [title, text], { timeout: 5000 }, () => {})
|
|
156
|
+
} else if (vscode.cli) {
|
|
157
|
+
// No system notification to send from a terminal here: the bell is it, if there is one.
|
|
158
|
+
return !!process.stderr.isTTY
|
|
152
159
|
} else {
|
|
153
160
|
vscode.window.showInformationMessage(`${title}: ${text}`)
|
|
154
161
|
}
|