@davait/aipictury 1.19.4 → 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/extension.js
CHANGED
|
@@ -16,7 +16,9 @@ const files = require('./files')
|
|
|
16
16
|
const shell = require('./shell')
|
|
17
17
|
const mcp = require('./mcp')
|
|
18
18
|
const voice = require('./voice')
|
|
19
|
-
const
|
|
19
|
+
const attachmentShelf = require('./attachments')
|
|
20
|
+
const { RemoteLink, SavedLinks } = require('./remote')
|
|
21
|
+
const { remoteLive, REMOTE_BEAT } = require('./cloud')
|
|
20
22
|
const context = require('./context')
|
|
21
23
|
const { showAbout } = require('./about')
|
|
22
24
|
const { stripInvisible } = require('./text')
|
|
@@ -24,6 +26,7 @@ const sessions = require('./sessions')
|
|
|
24
26
|
const log = require('./log')
|
|
25
27
|
const workbench = require('./workbench')
|
|
26
28
|
const dialogs = require('./dialogs')
|
|
29
|
+
const feedback = require('./feedback')
|
|
27
30
|
const userHooks = require('./hooks')
|
|
28
31
|
const scheduler = require('./scheduler')
|
|
29
32
|
const usageLog = require('./usage')
|
|
@@ -93,6 +96,7 @@ function appendText(message, text) {
|
|
|
93
96
|
|
|
94
97
|
const STORE = 'aipictury.conversations'
|
|
95
98
|
const LIVE = 'aipictury.live' // the conversation on screen, and whether it was mid-run
|
|
99
|
+
const DICTATION = 'aipictury.dictation' // a recording not written yet: { file, at } (voice.js keeps it)
|
|
96
100
|
const MAX_ATTACH = 10 // files per message, as in the web assistant
|
|
97
101
|
const ATTACH_TYPES = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'pdf', 'txt', 'md', 'json', 'csv',
|
|
98
102
|
'log', 'yaml', 'yml', 'py', 'js', 'jsx', 'ts', 'tsx', 'sh', 'sql', 'html', 'css', 'xml', 'toml',
|
|
@@ -100,6 +104,7 @@ const ATTACH_TYPES = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'pdf', 'txt',
|
|
|
100
104
|
const CHECKPOINTS = 100 // file snapshots kept per conversation, as Claude Code keeps
|
|
101
105
|
const RESTORE_WINDOW = 10 * 60_000 // a side-bar chat comes back after a reload if this recent
|
|
102
106
|
const CONTINUE_WINDOW = 60 * 60_000 // and a run cut by the reload continues if this recent
|
|
107
|
+
const FLUSH_BUDGET = 4000 // ms the account's last pushes get when VS Code closes (it allows ~5 s)
|
|
103
108
|
|
|
104
109
|
// Commands typed after /, handled here without the model. Custom commands from the
|
|
105
110
|
// project's .aipictury/commands or .claude/commands and skills are listed after these.
|
|
@@ -160,13 +165,17 @@ const REVIEW_PROMPT = 'Review the uncommitted changes in this repository (git st
|
|
|
160
165
|
+ 'Do not change any file.'
|
|
161
166
|
|
|
162
167
|
class AgentPanel {
|
|
163
|
-
constructor(context, { kind = 'sidebar', registry } = {}) {
|
|
168
|
+
constructor(context, { kind = 'sidebar', registry, slot } = {}) {
|
|
164
169
|
this.context = context
|
|
165
170
|
this.kind = kind
|
|
171
|
+
// Where this panel's unsent attachments are kept across a reload (attachments.js): the side
|
|
172
|
+
// bar has one; a tab its own, carried in its webview state (postSession).
|
|
173
|
+
this.slot = kind === 'sidebar' ? 'sidebar' : slot || attachmentShelf.newSlot()
|
|
166
174
|
this.registry = registry
|
|
167
175
|
this.history = []
|
|
168
176
|
this.session = { allowAll: false }
|
|
169
177
|
this.host = null // the WebviewView or WebviewPanel showing this conversation
|
|
178
|
+
this.recording = null // dictation in progress (dictate)
|
|
170
179
|
this.abort = null
|
|
171
180
|
this.id = null // which stored conversation this is, once it has content
|
|
172
181
|
this.model = context.globalState.get('aipictury.model') || null // override, or the role default
|
|
@@ -228,6 +237,7 @@ class AgentPanel {
|
|
|
228
237
|
usage: this.usage,
|
|
229
238
|
}
|
|
230
239
|
this.store.save(entry)
|
|
240
|
+
if (this.remote && !this.remote.keptFor) this.savedLinks?.keep(this.id, this.remote).catch(() => {})
|
|
231
241
|
await this.saveCheckpoints()
|
|
232
242
|
if (this.kind === 'tab' && this.host) this.host.title = `AIPictury: ${entry.title.slice(0, 30)}`
|
|
233
243
|
this.postSession()
|
|
@@ -281,7 +291,9 @@ class AgentPanel {
|
|
|
281
291
|
const found = this.store.get(id)
|
|
282
292
|
if (!found) return false
|
|
283
293
|
if (this.abort) { vscode.window.showInformationMessage('Stop the agent before switching conversations.'); return false }
|
|
294
|
+
this.stopDelivering() // the last one's render goes on: delivered when it is opened again
|
|
284
295
|
await this.remember() // do not lose the one on screen
|
|
296
|
+
if (this.remote && this.remote.keptFor !== id) this.letGoOfRemote() // its link goes with it
|
|
285
297
|
this.id = found.id
|
|
286
298
|
this.created = found.created || found.updated
|
|
287
299
|
this.history = found.history
|
|
@@ -295,6 +307,14 @@ class AgentPanel {
|
|
|
295
307
|
this.session = { allowAll: false } // a reopened conversation asks again
|
|
296
308
|
// Older entries predate the stored transcript; rebuild what can be rebuilt.
|
|
297
309
|
this.transcript = found.transcript || fromHistory(found.history)
|
|
310
|
+
// A row the window closed on mid-step said "rendering 40%" for ever: a render with its job id
|
|
311
|
+
// is followed again below, anything else is said to be cut off (1.20.0).
|
|
312
|
+
const followed = new Set(this.history.filter((m) => m.pending).map((m) => m.pending.row))
|
|
313
|
+
for (const t of this.transcript) {
|
|
314
|
+
if (t.kind === 'tool' && ['running', 'asking'].includes(t.status) && !followed.has(t.key)) {
|
|
315
|
+
Object.assign(t, { status: 'failed', result: 'cut off: the window was closed or reloaded while this ran' })
|
|
316
|
+
}
|
|
317
|
+
}
|
|
298
318
|
if (found.unread) this.store.patch(found.id, { unread: undefined }) // seen now
|
|
299
319
|
this.startSource = 'resume' // for the user's SessionStart hooks, at the next run
|
|
300
320
|
if (this.kind === 'tab' && this.host) this.host.title = `AIPictury: ${String(found.title).slice(0, 30)}`
|
|
@@ -302,6 +322,9 @@ class AgentPanel {
|
|
|
302
322
|
if (this.btw.length) this.post({ type: 'btwThread', items: this.btw })
|
|
303
323
|
this.postSession()
|
|
304
324
|
this.registry?.sessionsView?.refresh()
|
|
325
|
+
this.deliverPending()
|
|
326
|
+
this.refreshKeeping().catch(() => {})
|
|
327
|
+
this.rehostRemote(found.id).catch((error) => log.warn('remote control not hosted again', error))
|
|
305
328
|
if (focus && this.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
|
|
306
329
|
return true
|
|
307
330
|
}
|
|
@@ -333,9 +356,11 @@ class AgentPanel {
|
|
|
333
356
|
clearInterval(poll); clearInterval(ticker); subs.forEach((s) => s.dispose())
|
|
334
357
|
clearInterval(this.gpuWatch); this.gpuWatch = null
|
|
335
358
|
this.abort?.abort()
|
|
359
|
+
this.delivering?.abort()
|
|
336
360
|
this.recording?.cancel()
|
|
337
|
-
|
|
338
|
-
|
|
361
|
+
// The panel going (closed, or the window reloading) is not "stop sharing": its link is kept
|
|
362
|
+
// and the same address carries on when the conversation is on screen again (1.20.0).
|
|
363
|
+
this.letGoOfRemote()
|
|
339
364
|
this.remember().catch(() => {})
|
|
340
365
|
this.registry?.remove(this)
|
|
341
366
|
})
|
|
@@ -399,7 +424,7 @@ class AgentPanel {
|
|
|
399
424
|
}
|
|
400
425
|
|
|
401
426
|
postSession() {
|
|
402
|
-
this.post({ type: 'session', id: this.id, title: this.conversationTitle, kind: this.kind })
|
|
427
|
+
this.post({ type: 'session', id: this.id, title: this.conversationTitle, kind: this.kind, slot: this.slot })
|
|
403
428
|
}
|
|
404
429
|
|
|
405
430
|
postCommands() {
|
|
@@ -415,6 +440,7 @@ class AgentPanel {
|
|
|
415
440
|
this.postAccount(this.registry?.account?.user?.() ?? null)
|
|
416
441
|
this.refreshStatus(); this.postMode(); this.refreshCache(); this.postCommands(); this.postSession()
|
|
417
442
|
editorTracker.post(this)
|
|
443
|
+
this.restoreAttachments()
|
|
418
444
|
// Reloaded while a question was open: the agent is still waiting for it.
|
|
419
445
|
if (this.pendingAsk) this.post({ type: 'askUser', id: this.pendingAsk.id, questions: this.pendingAsk.questions })
|
|
420
446
|
for (const [id, p] of this.approvals) this.post({ type: 'approval', id, request: p.request })
|
|
@@ -585,6 +611,7 @@ class AgentPanel {
|
|
|
585
611
|
async reset() {
|
|
586
612
|
if (this.remote) await this.remoteControl('stop') // the link was for the conversation that ends here
|
|
587
613
|
if (this.abort) this.abort.abort()
|
|
614
|
+
this.stopDelivering() // the render goes on: delivered when that one is opened again
|
|
588
615
|
await this.remember()
|
|
589
616
|
if (this.id) this.fireHook('SessionEnd', { reason: 'clear' }).catch(() => {})
|
|
590
617
|
this.startSource = 'clear'
|
|
@@ -635,7 +662,7 @@ class AgentPanel {
|
|
|
635
662
|
const say = (note) => this.say(note)
|
|
636
663
|
if (/^#/.test(text) && /\S/.test(text.replace(/^#+/, ''))) this.post({ type: 'user', text })
|
|
637
664
|
if (/^##\s*\S/.test(text)) {
|
|
638
|
-
say(await memory.addUserRule(serverUrl(), text.replace(/^##\s*/, '')))
|
|
665
|
+
try { say(await memory.addUserRule(serverUrl(), text.replace(/^##\s*/, ''))) } catch (e) { say(e.message) }
|
|
639
666
|
return true
|
|
640
667
|
}
|
|
641
668
|
if (/^#\s*\S/.test(text)) {
|
|
@@ -1011,6 +1038,21 @@ class AgentPanel {
|
|
|
1011
1038
|
this.post({ type: 'attachments', max: MAX_ATTACH, items: this.attachments.map((a) => ({
|
|
1012
1039
|
id: a.id, name: a.name, kind: a.kind,
|
|
1013
1040
|
thumb: a.kind === 'image' ? `data:image/*;base64,${a.image}` : undefined })) })
|
|
1041
|
+
// Every change to what is attached comes through here: kept as it now is, so a reload before
|
|
1042
|
+
// Send brings the chips back and the message still carries them (1.20.0).
|
|
1043
|
+
if (this.attachmentsRestored) attachmentShelf.save(workspaceRoot()?.fsPath, this.slot, this.attachments)
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** The panel (re)loaded: what was attached and not sent before a reload is back, once — and
|
|
1047
|
+
* shown again whenever the webview itself reloads (it forgets its chips). */
|
|
1048
|
+
restoreAttachments() {
|
|
1049
|
+
if (!this.attachmentsRestored) {
|
|
1050
|
+
this.attachmentsRestored = true
|
|
1051
|
+
const here = new Set(this.attachments.map((a) => a.id))
|
|
1052
|
+
const kept = attachmentShelf.load(workspaceRoot()?.fsPath, this.slot).filter((a) => !here.has(a.id))
|
|
1053
|
+
this.attachments = [...this.attachments, ...kept].slice(0, MAX_ATTACH)
|
|
1054
|
+
}
|
|
1055
|
+
this.postAttachments()
|
|
1014
1056
|
}
|
|
1015
1057
|
|
|
1016
1058
|
/** Tells the panel which model is answering, so the user is never guessing. */
|
|
@@ -1057,22 +1099,41 @@ class AgentPanel {
|
|
|
1057
1099
|
|
|
1058
1100
|
postQueue() { this.post({ type: 'queue', items: this.queue.map((q) => q.text) }) }
|
|
1059
1101
|
|
|
1060
|
-
/**
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1102
|
+
/** Where the links shared by remote control are kept across reloads (remote.js). */
|
|
1103
|
+
get savedLinks() {
|
|
1104
|
+
return (this._savedLinks ||= this.context.secrets ? new SavedLinks({ secrets: this.context.secrets, state: this.context.workspaceState }) : null)
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** This panel stops hosting its link without ending it: the server keeps the conversation a
|
|
1108
|
+
* while, the link is kept (SavedLinks) and hosted again with its conversation. */
|
|
1109
|
+
letGoOfRemote() {
|
|
1110
|
+
if (!this.remote) return
|
|
1111
|
+
clearInterval(this.remoteBeat)
|
|
1112
|
+
const link = this.remote
|
|
1113
|
+
this.remote = null
|
|
1114
|
+
if (link.keptFor) this.savedLinks?.keep(link.keptFor, link).catch(() => {}) // hosted until now
|
|
1115
|
+
link.stop({ end: false })
|
|
1116
|
+
this.registry?.account?.cloud?.dropRemote?.(link.id).catch(() => {}) // listed again when hosted again
|
|
1117
|
+
try { this.post({ type: 'remote', on: false }) } catch { /* the panel itself is going */ }
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/** A conversation on screen again whose link was hosted until a moment ago (a reload, a crash,
|
|
1121
|
+
* its panel closed): the same link is hosted again, so whoever has it carries on (1.20.0). */
|
|
1122
|
+
async rehostRemote(conversation) {
|
|
1123
|
+
if (this.remote || !conversation) return
|
|
1124
|
+
const saved = await this.savedLinks?.take(conversation).catch(() => null)
|
|
1125
|
+
if (!saved || this.remote || this.id !== conversation) return
|
|
1126
|
+
await this.remoteControl('start', { restored: saved })
|
|
1068
1127
|
}
|
|
1069
1128
|
|
|
1070
1129
|
/** Remote control: this conversation, carried on from a browser (remote.js). */
|
|
1071
|
-
async remoteControl(action, { asked = false } = {}) {
|
|
1130
|
+
async remoteControl(action, { asked = false, restored = null } = {}) {
|
|
1072
1131
|
if (action === 'stop') {
|
|
1073
1132
|
if (!this.remote) { this.say('remote control is not on', 'remote'); return }
|
|
1133
|
+
clearInterval(this.remoteBeat)
|
|
1074
1134
|
this.remote.stop()
|
|
1075
1135
|
this.registry?.account?.cloud?.dropRemote?.(this.remote.id).catch(() => {})
|
|
1136
|
+
this.savedLinks?.forget(this.remote.keptFor || this.id).catch(() => {}) // stopped on purpose: gone for good
|
|
1076
1137
|
this.remote = null
|
|
1077
1138
|
this.post({ type: 'remote', on: false })
|
|
1078
1139
|
this.say('remote control ended — the link no longer works', 'remote')
|
|
@@ -1084,19 +1145,43 @@ class AgentPanel {
|
|
|
1084
1145
|
token: () => this.registry?.account?.account.idToken(),
|
|
1085
1146
|
log: (line) => log.warn(line),
|
|
1086
1147
|
onViewers: (n) => this.post({ type: 'remote', on: true, viewers: n, url: this.remote?.url }),
|
|
1148
|
+
id: restored?.id, key: restored?.key,
|
|
1087
1149
|
}).start()
|
|
1150
|
+
// Kept with its conversation, and its time refreshed while on: a reload or a crash can then
|
|
1151
|
+
// host the same link again (rehostRemote). A conversation with no id yet is kept at its first save.
|
|
1152
|
+
// Its listing with the account is written again on the same beat: one a crash left behind
|
|
1153
|
+
// stops being fresh, and aipictury.com no longer offers it (cloud.remoteLive, 1.20.0).
|
|
1154
|
+
const link = this.remote
|
|
1155
|
+
this.savedLinks?.keep(this.id, link).catch(() => {})
|
|
1156
|
+
this.remoteBeat = setInterval(() => {
|
|
1157
|
+
if (this.remote !== link) return
|
|
1158
|
+
this.savedLinks?.keep(this.id, link).catch(() => {})
|
|
1159
|
+
this.listRemote()
|
|
1160
|
+
}, REMOTE_BEAT)
|
|
1161
|
+
}
|
|
1162
|
+
if (restored) {
|
|
1163
|
+
this.post({ type: 'remote', on: true, viewers: this.remote.viewers, url: this.remote.url })
|
|
1164
|
+
this.listRemote()
|
|
1165
|
+
this.say('remote control is on again — the link from before still works. /remote-control stop ends it.', 'remote')
|
|
1166
|
+
return
|
|
1088
1167
|
}
|
|
1089
1168
|
if (!asked) await vscode.env.clipboard.writeText(this.remote.url) // asked from the web: it has the link
|
|
1090
1169
|
this.post({ type: 'remote', on: true, viewers: this.remote.viewers, url: this.remote.url })
|
|
1091
|
-
|
|
1092
|
-
const title = this.title || String(this.history.find((m) => m.role === 'user')?.content || 'A conversation').split('\n')[0]
|
|
1093
|
-
this.registry?.account?.cloud?.putRemote?.(this.remote.id, { url: this.remote.url, title: title.slice(0, 200),
|
|
1094
|
-
device: require('os').hostname().slice(0, 80), conversation: this.id || '' }).catch((e) => log.warn(`remote not listed with the account: ${e.message}`))
|
|
1170
|
+
this.listRemote()
|
|
1095
1171
|
if (asked) { this.say('remote control is on — asked from aipictury.com, where it is open now. /remote-control stop ends it.', 'remote'); return }
|
|
1096
1172
|
this.say(`remote control is on — the link is copied: open it in a browser or on your phone (on your network). Anyone with it `
|
|
1097
1173
|
+ 'can drive this conversation; yes or no to a step is given there, rules and modes stay here. /remote-control stop ends it.', 'remote')
|
|
1098
1174
|
}
|
|
1099
1175
|
|
|
1176
|
+
/** Listed with the account, so aipictury.com offers to control it (Live in VS Code). */
|
|
1177
|
+
listRemote() {
|
|
1178
|
+
if (!this.remote) return
|
|
1179
|
+
const title = this.title || String(this.history.find((m) => m.role === 'user')?.content || 'A conversation').split('\n')[0]
|
|
1180
|
+
this.remote.listedAt ||= Date.now()
|
|
1181
|
+
this.registry?.account?.cloud?.putRemote?.(this.remote.id, { url: this.remote.url, title: title.slice(0, 200),
|
|
1182
|
+
device: require('os').hostname().slice(0, 80), conversation: this.id || '', started: this.remote.listedAt }).catch((e) => log.warn(`remote not listed with the account: ${e.message}`))
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1100
1185
|
/** Dictation: recorded here (webviews have no microphone), written by the server's Whisper,
|
|
1101
1186
|
* and put into the input — never sent. 'toggle' starts or stops; 'cancel' discards. */
|
|
1102
1187
|
async dictate(action) {
|
|
@@ -1117,12 +1202,59 @@ class AgentPanel {
|
|
|
1117
1202
|
if (!rec) return
|
|
1118
1203
|
this.recording = null
|
|
1119
1204
|
say('writing')
|
|
1205
|
+
let file
|
|
1206
|
+
try { file = await rec.stop() } catch (error) { say('idle', { error: `Not written: ${error.message}` }); return }
|
|
1207
|
+
await this.writeDictation(file)
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** A recording written by the server into the input. Not written (the server away, restarting,
|
|
1211
|
+
* too slow), it is kept and the user is offered to try again — noted in the workspace, so a
|
|
1212
|
+
* reload offers it too. It used to be deleted whatever happened, and what was said was lost (1.20.0). */
|
|
1213
|
+
async writeDictation(file) {
|
|
1214
|
+
const say = (state, extra = {}) => {
|
|
1215
|
+
this.post({ type: 'dictation', state, text: extra.text })
|
|
1216
|
+
if (extra.error) this.say(extra.error, 'dictation')
|
|
1217
|
+
}
|
|
1218
|
+
say('writing')
|
|
1219
|
+
await this.context.workspaceState.update(DICTATION, { file, at: Date.now() })
|
|
1220
|
+
let r
|
|
1120
1221
|
try {
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1222
|
+
r = await voice.transcribe(serverUrl(), file)
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
say('idle', { error: `Not written: ${error.message}.${error.file ? ' The recording is kept — try again when the server answers.' : ''}` })
|
|
1225
|
+
if (!error.file) { await this.context.workspaceState.update(DICTATION, undefined); return }
|
|
1226
|
+
// Not awaited: the question waits for the user, the panel does not.
|
|
1227
|
+
this.offerDictation(error.file, error.message).catch((e) => log.warn('dictation not offered again', e))
|
|
1228
|
+
return
|
|
1229
|
+
}
|
|
1230
|
+
await this.context.workspaceState.update(DICTATION, undefined)
|
|
1231
|
+
log.info(`dictation: ${r.duration} s written in ${r.seconds} s (${r.language})`)
|
|
1232
|
+
say('idle', r.text ? { text: r.text } : { error: 'Nothing was heard — try again, closer to the microphone.' })
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** A recording the server did not write: tried again, or let go — never lost without asking. */
|
|
1236
|
+
async offerDictation(file, why) {
|
|
1237
|
+
const again = 'Try again'
|
|
1238
|
+
const drop = 'Discard'
|
|
1239
|
+
const choice = await vscode.window.showWarningMessage(`AIPictury: your dictation was not written (${why}). It is kept on this machine.`, again, drop)
|
|
1240
|
+
if (choice === again) return this.writeDictation(file)
|
|
1241
|
+
if (choice === drop) {
|
|
1242
|
+
voice.discard(file)
|
|
1243
|
+
await this.context.workspaceState.update(DICTATION, undefined)
|
|
1244
|
+
}
|
|
1245
|
+
// Closed without a choice: kept, and offered again after the next reload.
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** After a reload: a recording that was being written, or was not, is offered again (once a day at most old). */
|
|
1249
|
+
async resumeDictation() {
|
|
1250
|
+
const left = this.context.workspaceState.get(DICTATION)
|
|
1251
|
+
if (!left?.file) return
|
|
1252
|
+
if (Date.now() - (left.at || 0) > 24 * 3600_000 || !fs.existsSync(left.file)) {
|
|
1253
|
+
voice.discard(left.file)
|
|
1254
|
+
await this.context.workspaceState.update(DICTATION, undefined)
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
await this.offerDictation(left.file, 'the window was reloaded before it was')
|
|
1126
1258
|
}
|
|
1127
1259
|
|
|
1128
1260
|
/** Queued messages, handed to the running agent at its next step — as Claude Code
|
|
@@ -1403,6 +1535,7 @@ class AgentPanel {
|
|
|
1403
1535
|
return
|
|
1404
1536
|
}
|
|
1405
1537
|
if (action === 'fork' || action === 'fork-code') {
|
|
1538
|
+
this.stopDelivering() // followed again below, for the fork
|
|
1406
1539
|
await this.remember()
|
|
1407
1540
|
const historyAt = cp ? cp.history : this.history.length
|
|
1408
1541
|
const forked = { history: this.history.slice(0, historyAt), transcript: this.transcript.slice(0, index),
|
|
@@ -1418,6 +1551,7 @@ class AgentPanel {
|
|
|
1418
1551
|
if (this.history.length) await this.remember()
|
|
1419
1552
|
this.post({ type: 'restore', turns: this.transcript, todos: this.todos })
|
|
1420
1553
|
this.post({ type: 'context', used: agent.estimateTokens(this.history) })
|
|
1554
|
+
this.deliverPending()
|
|
1421
1555
|
this.post({ type: 'draft', text: turn.text })
|
|
1422
1556
|
this.say(`${note ? `${note}. ` : ''}forked: a new conversation from before that message — the original is kept in history`)
|
|
1423
1557
|
return
|
|
@@ -1485,7 +1619,6 @@ class AgentPanel {
|
|
|
1485
1619
|
signal?.addEventListener('abort', onStop, { once: true })
|
|
1486
1620
|
this.post({ type: 'approval', id, request })
|
|
1487
1621
|
this.setBadge('waiting')
|
|
1488
|
-
this.notifyAway('AIPictury is waiting for you', `${request.title || request.tool || 'A step'} needs your yes or no`)
|
|
1489
1622
|
})
|
|
1490
1623
|
}
|
|
1491
1624
|
|
|
@@ -1527,6 +1660,59 @@ class AgentPanel {
|
|
|
1527
1660
|
})
|
|
1528
1661
|
}
|
|
1529
1662
|
|
|
1663
|
+
/** One row per tool call, updated in place as it runs, kept with its output. */
|
|
1664
|
+
toolRow(event) {
|
|
1665
|
+
const key = event.id || `${event.tool}:${JSON.stringify(event.args)}`
|
|
1666
|
+
const existing = this.transcript.find((t) => t.kind === 'tool' && t.key === key)
|
|
1667
|
+
if (existing) Object.assign(existing, event)
|
|
1668
|
+
else this.transcript.push({ kind: 'tool', key, at: Date.now(), ...event })
|
|
1669
|
+
this.post({ type: 'tool', ...event })
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/** How keeping a render with the account goes (keeping.js), on its card — a render of this
|
|
1673
|
+
* run, or of an earlier session whose keeping was taken up again after a reload (1.20.0). */
|
|
1674
|
+
keepState(jobId, state) {
|
|
1675
|
+
for (const t of this.transcript) {
|
|
1676
|
+
if (t.kind !== 'tool' || !jobId || t.media?.job !== jobId) continue
|
|
1677
|
+
t.keep = state
|
|
1678
|
+
this.post({ type: 'tool', tool: t.tool, args: t.args, id: t.key, status: 'done', path: t.path, media: t.media, keep: state })
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** Cards of a reopened conversation that said "Compressing for your account" when the window
|
|
1683
|
+
* went: where their keeping stands now, asked of the account (keeping.js). */
|
|
1684
|
+
async refreshKeeping() {
|
|
1685
|
+
const stale = this.transcript.filter((t) => t.kind === 'tool' && t.media?.job && ['compress', 'upload', 'waiting'].includes(t.keep?.stage))
|
|
1686
|
+
for (const t of stale) {
|
|
1687
|
+
const state = await this.registry?.account?.keptState?.(t.media.job).catch(() => null)
|
|
1688
|
+
this.keepState(t.media.job, state || { stage: 'gone' })
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/** A picture or clip the agent asked for was still rendering when the window closed (or the
|
|
1693
|
+
* run was cut): followed by its job id and written into the workspace now — whether or not
|
|
1694
|
+
* the run carries on — its row showing how far it is, its result kept for the model (1.20.0). */
|
|
1695
|
+
async deliverPending() {
|
|
1696
|
+
if (this.abort || this.delivering || !serverUrl() || !this.history.some((m) => m.pending)) return
|
|
1697
|
+
const id = this.id
|
|
1698
|
+
const stop = this.delivering = new AbortController()
|
|
1699
|
+
try {
|
|
1700
|
+
const settled = await agent.deliverPending(serverUrl(), this.history, { signal: stop.signal,
|
|
1701
|
+
onTool: (event) => { if (this.id === id) this.toolRow(event) } })
|
|
1702
|
+
if (settled && this.id === id) await this.remember()
|
|
1703
|
+
} catch (error) {
|
|
1704
|
+
log.warn('pending render not delivered', error)
|
|
1705
|
+
} finally { if (this.delivering === stop) this.delivering = null }
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/** This panel's own delivery stopped (another conversation on screen, a fork, a clear) and
|
|
1709
|
+
* forgotten at once, so the next one can start without waiting for it to wind down. A run
|
|
1710
|
+
* waiting on the same render leaves on its own Stop (mediagen.resume, 1.20.0). */
|
|
1711
|
+
stopDelivering() {
|
|
1712
|
+
this.delivering?.abort()
|
|
1713
|
+
this.delivering = null
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1530
1716
|
markLive(running) {
|
|
1531
1717
|
if (!this.id || this.kind !== 'sidebar') return
|
|
1532
1718
|
this.context.workspaceState.update(LIVE, { id: this.id, running, at: Date.now() })
|
|
@@ -1572,12 +1758,7 @@ class AgentPanel {
|
|
|
1572
1758
|
onThinking: (delta) => this.post({ type: 'thinking', text: delta }),
|
|
1573
1759
|
onSpeed: (speed) => this.post({ type: 'speed', ...speed }),
|
|
1574
1760
|
onTool: (event) => {
|
|
1575
|
-
|
|
1576
|
-
const key = event.id || `${event.tool}:${JSON.stringify(event.args)}`
|
|
1577
|
-
const existing = this.transcript.find((t) => t.kind === 'tool' && t.key === key)
|
|
1578
|
-
if (existing) Object.assign(existing, event)
|
|
1579
|
-
else this.transcript.push({ kind: 'tool', key, at: Date.now(), ...event })
|
|
1580
|
-
this.post({ type: 'tool', ...event })
|
|
1761
|
+
this.toolRow(event)
|
|
1581
1762
|
if (event.tasks) this.post({ type: 'tasks', items: event.tasks })
|
|
1582
1763
|
if (event.status === 'done') {
|
|
1583
1764
|
this.rememberSoon()
|
|
@@ -1603,8 +1784,12 @@ class AgentPanel {
|
|
|
1603
1784
|
onMonitor: (task, lines, info) => this.onMonitorLines(task, lines, info),
|
|
1604
1785
|
schedule: this.schedulerHooks(),
|
|
1605
1786
|
onHelperDone: (h, text) => this.onHelperDone(h, text),
|
|
1606
|
-
// Saved at every step, so a crash, a reload or closing VS Code loses nothing
|
|
1607
|
-
|
|
1787
|
+
// Saved at every step, so a crash, a reload or closing VS Code loses nothing; a render's job id
|
|
1788
|
+
// at once (`now`), as it is what delivers the render if the window goes in the next second.
|
|
1789
|
+
onMessages: (messages, { now = false } = {}) => {
|
|
1790
|
+
this.history = messages.map(withoutImages); this.markLive(true)
|
|
1791
|
+
if (now) this.remember().catch(() => {}); else this.rememberSoon()
|
|
1792
|
+
},
|
|
1608
1793
|
onDecision: (picks) => decisions.push(...picks),
|
|
1609
1794
|
onWaiting: (event) => this.post({ type: 'waiting', seconds: event.waiting, phase: event.phase }),
|
|
1610
1795
|
onTurn: ({ text: said, thinking, meta, calls }) => {
|
|
@@ -1647,10 +1832,10 @@ class AgentPanel {
|
|
|
1647
1832
|
this.post({ type: 'decisions', items: decisions })
|
|
1648
1833
|
}
|
|
1649
1834
|
stopped = !!result.stopped
|
|
1650
|
-
|
|
1651
|
-
result.error ? String(result.error).split('\n')[0] : (this.title || 'The run finished'))
|
|
1835
|
+
this.ended = { how: stopped ? 'stopped' : result.error ? 'error' : 'done', why: result.error ? String(result.error).split('\n')[0] : undefined }
|
|
1652
1836
|
} catch (error) {
|
|
1653
1837
|
stopped = error.name === 'AbortError'
|
|
1838
|
+
this.ended = { how: stopped ? 'stopped' : 'error', why: stopped ? undefined : error.message }
|
|
1654
1839
|
if (!stopped) log.error('run failed', error)
|
|
1655
1840
|
this.post({ type: 'end', stopped, error: stopped ? undefined : error.message })
|
|
1656
1841
|
} finally {
|
|
@@ -1693,8 +1878,15 @@ class AgentPanel {
|
|
|
1693
1878
|
if (live.running && Date.now() - live.at < CONTINUE_WINDOW && config().get('continueAfterReload', true)) {
|
|
1694
1879
|
this.say('the window was reloaded while the agent was working — continuing from its last step')
|
|
1695
1880
|
this.transcript.push({ kind: 'user', text: '(continuing after the window reloaded)', at: Date.now(), checkpoint: this.newCheckpoint() })
|
|
1696
|
-
|
|
1697
|
-
|
|
1881
|
+
// Said as it is: every finished step is above and a render still going is delivered before the
|
|
1882
|
+
// next turn (deliverPending), but a call the reload cut mid-way has no result. "Nothing is
|
|
1883
|
+
// lost" had the model take an unfinished step for done, or do it again blindly (1.20.0).
|
|
1884
|
+
const cut = cutCalls(this.history)
|
|
1885
|
+
await this.start([{ role: 'user', content: '[The editor window was reloaded while you were working. Every step '
|
|
1886
|
+
+ 'that finished is in the conversation above, with its result.'
|
|
1887
|
+
+ (cut.length ? ` The reload cut off ${cut.join(', ')} before it returned: it has no result, and may or may not `
|
|
1888
|
+
+ 'have taken effect — check before doing it again.' : '')
|
|
1889
|
+
+ ' Continue the task from where you left off.]' }])
|
|
1698
1890
|
}
|
|
1699
1891
|
}
|
|
1700
1892
|
|
|
@@ -1764,6 +1956,14 @@ class AgentPanel {
|
|
|
1764
1956
|
}
|
|
1765
1957
|
}
|
|
1766
1958
|
|
|
1959
|
+
/** The tool calls of the last step that have no result: what a reload cut off mid-way. */
|
|
1960
|
+
function cutCalls(history) {
|
|
1961
|
+
const at = history.findLastIndex((m) => m.role === 'assistant')
|
|
1962
|
+
if (at < 0) return []
|
|
1963
|
+
const answered = history.slice(at + 1).filter((m) => m.role === 'tool').length
|
|
1964
|
+
return (history[at].tool_calls || []).slice(answered).map((c) => c.function?.name).filter(Boolean)
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1767
1967
|
/** Pictures are sent once; the kept conversation notes where they were. */
|
|
1768
1968
|
function withoutImages(m) {
|
|
1769
1969
|
if (!m.images) return m
|
|
@@ -1819,6 +2019,30 @@ class Registry {
|
|
|
1819
2019
|
|
|
1820
2020
|
all() { return [this.sidebar, ...this.tabs] }
|
|
1821
2021
|
|
|
2022
|
+
/** After a reload or a crash: this machine's listings of this project's shared conversations
|
|
2023
|
+
* that no panel hosts again are dropped — aipictury.com offered links nobody was at (1.20.0) —
|
|
2024
|
+
* and any other listing of this machine that its window no longer keeps fresh
|
|
2025
|
+
* (cloud.remoteLive): a window still hosting it lists it again at its next beat. Another
|
|
2026
|
+
* machine's listings are its own to drop, and the web's to hide when stale: a window of an
|
|
2027
|
+
* older version there hosts without a beat, and its live link vanished from aipictury.com
|
|
2028
|
+
* (1.20.0). The links themselves stay kept: reopening the conversation hosts and lists it again. */
|
|
2029
|
+
async pruneRemote() {
|
|
2030
|
+
const cloud = this.account?.cloud
|
|
2031
|
+
if (!cloud?.listRemote || !this.account.signedIn?.()) return 0
|
|
2032
|
+
const hosted = new Set(this.all().map((p) => p?.remote?.id).filter(Boolean))
|
|
2033
|
+
const store = sessions.storeFor(workspaceRoot()?.fsPath)
|
|
2034
|
+
const here = require('os').hostname().slice(0, 80)
|
|
2035
|
+
let dropped = 0
|
|
2036
|
+
for (const r of await cloud.listRemote()) {
|
|
2037
|
+
if (r.device !== here || hosted.has(r.id)) continue
|
|
2038
|
+
const leftHere = r.conversation && store?.get(r.conversation)
|
|
2039
|
+
if (!leftHere && remoteLive(r)) continue
|
|
2040
|
+
await cloud.dropRemote(r.id).catch(() => {})
|
|
2041
|
+
dropped += 1
|
|
2042
|
+
}
|
|
2043
|
+
return dropped
|
|
2044
|
+
}
|
|
2045
|
+
|
|
1822
2046
|
/** Asked from the web: the panel with this conversation shares it by remote control — opened
|
|
1823
2047
|
* first when it is this project's but not on screen. Another project's: some other window's. */
|
|
1824
2048
|
async shareAsked(conversation) {
|
|
@@ -1841,11 +2065,11 @@ class Registry {
|
|
|
1841
2065
|
active() { return this.last?.host ? this.last : this.sidebar }
|
|
1842
2066
|
|
|
1843
2067
|
/** A new conversation in an editor tab — several can run side by side. */
|
|
1844
|
-
openTab(existing) {
|
|
2068
|
+
openTab(existing, { slot } = {}) {
|
|
1845
2069
|
const host = existing || vscode.window.createWebviewPanel('aipictury.agentTab', 'AIPictury Agent',
|
|
1846
2070
|
{ viewColumn: vscode.ViewColumn.Beside, preserveFocus: false }, { enableScripts: true, retainContextWhenHidden: true })
|
|
1847
2071
|
host.iconPath = vscode.Uri.joinPath(this.context.extensionUri, 'media', 'icon.svg')
|
|
1848
|
-
const panel = new AgentPanel(this.context, { kind: 'tab', registry: this })
|
|
2072
|
+
const panel = new AgentPanel(this.context, { kind: 'tab', registry: this, slot })
|
|
1849
2073
|
panel.attach(host)
|
|
1850
2074
|
this.tabs.add(panel)
|
|
1851
2075
|
this.last = panel
|
|
@@ -2325,6 +2549,16 @@ function activate(context) {
|
|
|
2325
2549
|
store: () => sessions.storeFor(workspaceRoot()?.fsPath), server: serverUrl })
|
|
2326
2550
|
// The AIPictury account: needed to use the extension; what is yours is kept with it.
|
|
2327
2551
|
registry.account = require('./accountUi').register(context, { registry, sessionsView: registry.sessionsView, serverUrl })
|
|
2552
|
+
// Remote-control links this window does not host again, unlisted once the panels are back.
|
|
2553
|
+
const pruning = setTimeout(() => registry.pruneRemote().catch((error) => log.warn('remote listings not pruned', error)), 30_000)
|
|
2554
|
+
add({ dispose: () => clearTimeout(pruning) })
|
|
2555
|
+
// Problem reports to send that did not reach the server (feedback.js's outbox): sent once it
|
|
2556
|
+
// answers — a while after the start, then every quarter of an hour (1.20.0).
|
|
2557
|
+
const sendReports = () => feedback.sendWaiting(serverUrl(), dialogs.sendReport)
|
|
2558
|
+
.then((n) => { if (n) log.info(`${n} problem report(s) sent to the server`) }).catch((error) => log.warn('problem reports not sent', error))
|
|
2559
|
+
const reporting = setTimeout(sendReports, 45_000)
|
|
2560
|
+
const reportingAgain = setInterval(sendReports, 15 * 60_000)
|
|
2561
|
+
add({ dispose: () => { clearTimeout(reporting); clearInterval(reportingAgain) } })
|
|
2328
2562
|
add(vscode.commands.registerCommand('aipictury.signIn', () => registry.account.signIn()))
|
|
2329
2563
|
add(vscode.commands.registerCommand('aipictury.signOut', () => registry.account.signOut()))
|
|
2330
2564
|
add(vscode.commands.registerCommand('aipictury.account', () => active().openAccount()))
|
|
@@ -2333,12 +2567,13 @@ function activate(context) {
|
|
|
2333
2567
|
resolveWebviewView(view) {
|
|
2334
2568
|
panel.attach(view)
|
|
2335
2569
|
panel.resumeAfterReload().catch(() => {})
|
|
2570
|
+
panel.resumeDictation().catch((error) => log.warn('dictation not offered again', error))
|
|
2336
2571
|
},
|
|
2337
2572
|
}, { webviewOptions: { retainContextWhenHidden: true } }))
|
|
2338
2573
|
// Agent tabs come back after a reload, with their conversations.
|
|
2339
2574
|
add(vscode.window.registerWebviewPanelSerializer('aipictury.agentTab', {
|
|
2340
2575
|
async deserializeWebviewPanel(host, state) {
|
|
2341
|
-
const tab = registry.openTab(host)
|
|
2576
|
+
const tab = registry.openTab(host, { slot: state?.slot })
|
|
2342
2577
|
if (state?.id) await tab.open(state.id, { focus: false })
|
|
2343
2578
|
},
|
|
2344
2579
|
}))
|
|
@@ -2477,12 +2712,16 @@ function activate(context) {
|
|
|
2477
2712
|
}
|
|
2478
2713
|
|
|
2479
2714
|
// Anything the agent left running — a dev server, MCP servers — goes when VS Code does,
|
|
2480
|
-
// and every conversation on screen is saved first
|
|
2715
|
+
// and every conversation on screen is saved first — then what is still to be pushed to the
|
|
2716
|
+
// account is pushed (its saves wait a few seconds to be sent together, and closing dropped
|
|
2717
|
+
// them: 1.20.0), within the few seconds VS Code gives an extension to stop.
|
|
2481
2718
|
async function deactivate() {
|
|
2482
2719
|
agent.stopBackground()
|
|
2483
2720
|
mcp.closeAll()
|
|
2484
2721
|
await require('./browser').close()
|
|
2485
2722
|
await Promise.all((registry?.all() || []).map((p) => p.remember().catch(() => {})))
|
|
2723
|
+
const cloud = registry?.account?.cloud
|
|
2724
|
+
if (cloud?.flush) await Promise.race([cloud.flush().catch(() => {}), new Promise((r) => setTimeout(r, FLUSH_BUDGET).unref?.())])
|
|
2486
2725
|
}
|
|
2487
2726
|
|
|
2488
|
-
module.exports = { syncCommandEnv, moveConversationsToDisk, rewindQuestion, activate, deactivate, undoOldThinkingSwitch, adoptNewDefaultModel, fuzzyScore, toMarkdown, resolveMentions, mentionCandidates, INIT_PROMPT, REVIEW_PROMPT }
|
|
2727
|
+
module.exports = { syncCommandEnv, moveConversationsToDisk, rewindQuestion, activate, deactivate, undoOldThinkingSwitch, adoptNewDefaultModel, fuzzyScore, toMarkdown, resolveMentions, mentionCandidates, cutCalls, Registry, INIT_PROMPT, REVIEW_PROMPT }
|