@davait/aipictury 1.9.2
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/LICENSE.md +77 -0
- package/README.md +39 -0
- package/ext/about.js +209 -0
- package/ext/account.js +242 -0
- package/ext/accountUi.js +287 -0
- package/ext/agent.js +1929 -0
- package/ext/browser.js +263 -0
- package/ext/cloud.js +385 -0
- package/ext/context.js +241 -0
- package/ext/deps.js +142 -0
- package/ext/dialogs.js +803 -0
- package/ext/diff.js +150 -0
- package/ext/extension.js +2483 -0
- package/ext/feedback.js +55 -0
- package/ext/files.js +565 -0
- package/ext/firestore.js +131 -0
- package/ext/gpuRun.js +77 -0
- package/ext/gpuServer.js +76 -0
- package/ext/hooks.js +153 -0
- package/ext/log.js +29 -0
- package/ext/lsp.js +122 -0
- package/ext/mcp.js +469 -0
- package/ext/mcpAuth.js +128 -0
- package/ext/media/vendor/firebase-config.json +9 -0
- package/ext/media/vendor/signin.js +1 -0
- package/ext/mediagen.js +174 -0
- package/ext/memory.js +117 -0
- package/ext/notebook.js +82 -0
- package/ext/package.json +1234 -0
- package/ext/paths.js +50 -0
- package/ext/permissions.js +229 -0
- package/ext/plans.js +110 -0
- package/ext/plugins.js +243 -0
- package/ext/prefs.js +157 -0
- package/ext/remote.js +170 -0
- package/ext/review.js +126 -0
- package/ext/sandbox.js +79 -0
- package/ext/scheduler.js +87 -0
- package/ext/sessions.js +228 -0
- package/ext/sessionsView.js +449 -0
- package/ext/shell.js +357 -0
- package/ext/storage.js +80 -0
- package/ext/tasklist.js +112 -0
- package/ext/text.js +31 -0
- package/ext/tools.js +781 -0
- package/ext/usage.js +77 -0
- package/ext/voice.js +135 -0
- package/ext/workbench.js +162 -0
- package/ext/workflow.js +51 -0
- package/ext/worktree.js +83 -0
- package/main.js +758 -0
- package/package.json +43 -0
- package/terminal.js +124 -0
- package/vscode-shim.js +231 -0
package/ext/sessions.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Conversations kept on disk, one file each, per project:
|
|
3
|
+
//
|
|
4
|
+
// ~/.aipictury/projects/<project>/index.json what the lists show (no history)
|
|
5
|
+
// ~/.aipictury/projects/<project>/groups.json named groups of conversations
|
|
6
|
+
// ~/.aipictury/projects/<project>/sessions/<id>.json the whole conversation
|
|
7
|
+
// ~/.aipictury/projects/<project>/sessions/<id>.checkpoints.json
|
|
8
|
+
//
|
|
9
|
+
// Not VS Code's workspace state: every window of the same folder — and the `aipictury` CLI —
|
|
10
|
+
// sees the same conversations, and a change in one reaches the others through a watch.
|
|
11
|
+
// AIPICTURY_HOME moves the whole tree (the tests use a scratch one).
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const os = require('os')
|
|
14
|
+
const path = require('path')
|
|
15
|
+
const crypto = require('crypto')
|
|
16
|
+
|
|
17
|
+
const home = () => process.env.AIPICTURY_HOME || path.join(os.homedir(), '.aipictury')
|
|
18
|
+
|
|
19
|
+
/** A folder's own directory name: readable, and unique per path. */
|
|
20
|
+
function projectKey(folder) {
|
|
21
|
+
const base = path.basename(folder || 'no-folder').replace(/[^\w.-]+/g, '-').slice(0, 40) || 'root'
|
|
22
|
+
const hash = crypto.createHash('sha1').update(path.resolve(folder || '/')).digest('hex').slice(0, 10)
|
|
23
|
+
return `${base}-${hash}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Written whole or not at all: a reader never sees half a file. */
|
|
27
|
+
function writeAtomic(file, text) {
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
29
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`
|
|
30
|
+
fs.writeFileSync(tmp, text)
|
|
31
|
+
fs.renameSync(tmp, file)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readJson(file, fallback) {
|
|
35
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What the lists keep of a conversation; everything else is only in its own file.
|
|
39
|
+
const META = ['id', 'title', 'renamed', 'created', 'updated', 'server', 'model', 'status', 'group',
|
|
40
|
+
'archived', 'unread', 'mode', 'questions']
|
|
41
|
+
|
|
42
|
+
// Who hears of every change to a conversation (the cloud copy): { type: 'save' | 'patch' |
|
|
43
|
+
// 'remove', folder, id, entry? }. One place, so every path — the panel, the Sessions view, the
|
|
44
|
+
// CLI — keeps the copy in step.
|
|
45
|
+
const writeListeners = new Set()
|
|
46
|
+
function onWrite(fn) { writeListeners.add(fn); return { dispose: () => writeListeners.delete(fn) } }
|
|
47
|
+
function told(event) { for (const fn of writeListeners) { try { fn(event) } catch { /* a listener's own problem */ } } }
|
|
48
|
+
|
|
49
|
+
class SessionStore {
|
|
50
|
+
constructor(folder) {
|
|
51
|
+
this.folder = folder
|
|
52
|
+
this.dir = path.join(home(), 'projects', projectKey(folder))
|
|
53
|
+
this.sessions = path.join(this.dir, 'sessions')
|
|
54
|
+
this.indexFile = path.join(this.dir, 'index.json')
|
|
55
|
+
this.groupsFile = path.join(this.dir, 'groups.json')
|
|
56
|
+
this.listeners = new Set()
|
|
57
|
+
this.watcher = null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---- the list ----
|
|
61
|
+
|
|
62
|
+
/** Every conversation's summary, newest first. */
|
|
63
|
+
list() {
|
|
64
|
+
const index = readJson(this.indexFile, [])
|
|
65
|
+
return (Array.isArray(index) ? index : []).sort((a, b) => (b.updated || 0) - (a.updated || 0))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
writeIndex(entries) {
|
|
69
|
+
writeAtomic(this.indexFile, JSON.stringify(entries, null, 1))
|
|
70
|
+
this.changed()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One conversation whole, with what the list knows of it on top (a rename, a group). */
|
|
74
|
+
get(id) {
|
|
75
|
+
const entry = readJson(this.file(id), null)
|
|
76
|
+
if (!entry) return null
|
|
77
|
+
const meta = this.list().find((m) => m.id === id) || {}
|
|
78
|
+
return { ...entry, ...pick(meta, META) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
file(id) { return path.join(this.sessions, `${String(id).replace(/[^\w-]/g, '')}.json`) }
|
|
82
|
+
checkpointsFile(id) { return path.join(this.sessions, `${String(id).replace(/[^\w-]/g, '')}.checkpoints.json`) }
|
|
83
|
+
|
|
84
|
+
/** Saves a conversation. What only the lists set — its group, archived, unread — is kept. */
|
|
85
|
+
save(entry) {
|
|
86
|
+
if (Array.isArray(entry.history)) entry = { ...entry, questions: entry.history.filter((m) => m.role === 'user').length }
|
|
87
|
+
writeAtomic(this.file(entry.id), JSON.stringify(entry))
|
|
88
|
+
const index = this.list()
|
|
89
|
+
const old = index.find((m) => m.id === entry.id) || {}
|
|
90
|
+
const meta = { ...old, ...pick(entry, META) }
|
|
91
|
+
for (const key of ['group', 'archived', 'unread']) if (old[key] !== undefined && entry[key] === undefined) meta[key] = old[key]
|
|
92
|
+
this.writeIndex([meta, ...index.filter((m) => m.id !== entry.id)])
|
|
93
|
+
told({ type: 'save', folder: this.folder, id: entry.id, entry: { ...entry, group: meta.group, archived: meta.archived } })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Changes what the lists show of a conversation: title, group, archived, unread, status. */
|
|
97
|
+
patch(id, fields) {
|
|
98
|
+
const index = this.list()
|
|
99
|
+
const at = index.findIndex((m) => m.id === id)
|
|
100
|
+
if (at < 0) return false
|
|
101
|
+
index[at] = { ...index[at], ...fields }
|
|
102
|
+
for (const [key, value] of Object.entries(fields)) if (value === undefined) delete index[at][key]
|
|
103
|
+
this.writeIndex(index)
|
|
104
|
+
if ('title' in fields || 'renamed' in fields) {
|
|
105
|
+
const entry = readJson(this.file(id), null)
|
|
106
|
+
if (entry) writeAtomic(this.file(id), JSON.stringify({ ...entry, title: index[at].title, renamed: index[at].renamed }))
|
|
107
|
+
}
|
|
108
|
+
// What the cloud keeps of a conversation's summary: its title, group, archived.
|
|
109
|
+
if (['title', 'renamed', 'group', 'archived'].some((k) => k in fields)) {
|
|
110
|
+
const entry = readJson(this.file(id), null)
|
|
111
|
+
if (entry) told({ type: 'patch', folder: this.folder, id, entry: { ...entry, title: index[at].title, renamed: index[at].renamed, group: index[at].group, archived: index[at].archived } })
|
|
112
|
+
}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
remove(id) {
|
|
117
|
+
for (const f of [this.file(id), this.checkpointsFile(id)]) { try { fs.unlinkSync(f) } catch { /* gone */ } }
|
|
118
|
+
this.writeIndex(this.list().filter((m) => m.id !== id))
|
|
119
|
+
told({ type: 'remove', folder: this.folder, id })
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- checkpoints ----
|
|
123
|
+
|
|
124
|
+
saveCheckpoints(id, checkpoints) {
|
|
125
|
+
try { writeAtomic(this.checkpointsFile(id), JSON.stringify(checkpoints)) } catch { /* a convenience */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
loadCheckpoints(id) { return readJson(this.checkpointsFile(id), []) }
|
|
129
|
+
|
|
130
|
+
// ---- groups ----
|
|
131
|
+
|
|
132
|
+
groups() { return readJson(this.groupsFile, []) }
|
|
133
|
+
|
|
134
|
+
createGroup(name) {
|
|
135
|
+
const clean = String(name || '').trim().slice(0, 60)
|
|
136
|
+
if (!clean) throw new Error('a group needs a name')
|
|
137
|
+
const groups = this.groups()
|
|
138
|
+
const found = groups.find((g) => g.name.toLowerCase() === clean.toLowerCase())
|
|
139
|
+
if (found) return found
|
|
140
|
+
const group = { id: `g${Date.now().toString(36)}${crypto.randomBytes(2).toString('hex')}`, name: clean }
|
|
141
|
+
writeAtomic(this.groupsFile, JSON.stringify([...groups, group], null, 1))
|
|
142
|
+
this.changed()
|
|
143
|
+
return group
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
renameGroup(id, name) {
|
|
147
|
+
const clean = String(name || '').trim().slice(0, 60)
|
|
148
|
+
if (!clean) throw new Error('a group needs a name')
|
|
149
|
+
writeAtomic(this.groupsFile, JSON.stringify(this.groups().map((g) => (g.id === id ? { ...g, name: clean } : g)), null, 1))
|
|
150
|
+
this.changed()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Deleting a group only ungroups its conversations. */
|
|
154
|
+
deleteGroup(id) {
|
|
155
|
+
writeAtomic(this.groupsFile, JSON.stringify(this.groups().filter((g) => g.id !== id), null, 1))
|
|
156
|
+
const index = this.list()
|
|
157
|
+
for (const m of index) if (m.group === id) delete m.group
|
|
158
|
+
this.writeIndex(index)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---- archive ----
|
|
162
|
+
|
|
163
|
+
/** Archives what has had no activity for `days`, unless it is open, unread or grouped. */
|
|
164
|
+
archiveInactive(days, { open = new Set(), now = Date.now() } = {}) {
|
|
165
|
+
if (!days) return 0
|
|
166
|
+
const index = this.list()
|
|
167
|
+
let count = 0
|
|
168
|
+
for (const m of index) {
|
|
169
|
+
if (m.archived || m.unread || m.group || open.has(m.id)) continue
|
|
170
|
+
if (now - (m.updated || m.created || now) > days * 86_400_000) { m.archived = true; count += 1 }
|
|
171
|
+
}
|
|
172
|
+
if (count) this.writeIndex(index)
|
|
173
|
+
return count
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- moving in from VS Code's workspace state (the store before 1.7) ----
|
|
177
|
+
|
|
178
|
+
/** Copies conversations kept the old way; those already on disk are left as they are. */
|
|
179
|
+
migrate(entries, oldCheckpointsDir) {
|
|
180
|
+
if (!Array.isArray(entries) || !entries.length) return 0
|
|
181
|
+
const have = new Set(this.list().map((m) => m.id))
|
|
182
|
+
let moved = 0
|
|
183
|
+
for (const entry of [...entries].reverse()) {
|
|
184
|
+
if (!entry?.id || have.has(entry.id)) continue
|
|
185
|
+
this.save(entry)
|
|
186
|
+
if (oldCheckpointsDir) {
|
|
187
|
+
const old = readJson(path.join(oldCheckpointsDir, `${entry.id}.json`), null)
|
|
188
|
+
if (old) this.saveCheckpoints(entry.id, old)
|
|
189
|
+
}
|
|
190
|
+
moved += 1
|
|
191
|
+
}
|
|
192
|
+
return moved
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- changes from other windows ----
|
|
196
|
+
|
|
197
|
+
onChange(listener) {
|
|
198
|
+
this.listeners.add(listener)
|
|
199
|
+
if (!this.watcher) {
|
|
200
|
+
fs.mkdirSync(this.dir, { recursive: true })
|
|
201
|
+
let timer
|
|
202
|
+
try {
|
|
203
|
+
this.watcher = fs.watch(this.dir, () => { clearTimeout(timer); timer = setTimeout(() => this.emit(), 150) })
|
|
204
|
+
} catch { /* no watching on this system: each window still sees its own changes */ }
|
|
205
|
+
}
|
|
206
|
+
return { dispose: () => this.listeners.delete(listener) }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
changed() { this.emit() }
|
|
210
|
+
emit() { for (const l of this.listeners) { try { l() } catch { /* a listener's own problem */ } } }
|
|
211
|
+
dispose() { this.watcher?.close(); this.watcher = null; this.listeners.clear() }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pick(object, keys) {
|
|
215
|
+
const out = {}
|
|
216
|
+
for (const k of keys) if (object[k] !== undefined) out[k] = object[k]
|
|
217
|
+
return out
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const stores = new Map()
|
|
221
|
+
/** The store of a folder (one per folder, shared by every panel of this window). */
|
|
222
|
+
function storeFor(folder) {
|
|
223
|
+
const key = `${home()}::${path.resolve(folder || '/')}`
|
|
224
|
+
if (!stores.has(key)) stores.set(key, new SessionStore(folder))
|
|
225
|
+
return stores.get(key)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = { SessionStore, storeFor, projectKey, home, onWrite }
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// The Sessions view in the Activity Bar: every conversation of this project, in its groups,
|
|
3
|
+
// with what it is doing — needs you, working, finished, unread — live. Filters and a search
|
|
4
|
+
// (which lists everything flat), archive, and the web app's chats, which can be carried on
|
|
5
|
+
// here. The list is the store on disk, so every window shows the same one.
|
|
6
|
+
const vscode = require('vscode')
|
|
7
|
+
const workbench = require('./workbench')
|
|
8
|
+
const log = require('./log')
|
|
9
|
+
|
|
10
|
+
const FILTER_KEY = 'aipictury.sessionsFilter'
|
|
11
|
+
const STATUSES = [
|
|
12
|
+
{ id: 'waiting', label: 'Needs input', icon: 'bell-dot', color: 'list.warningForeground' },
|
|
13
|
+
{ id: 'working', label: 'Working', icon: 'sync~spin' },
|
|
14
|
+
{ id: 'unread', label: 'Unread', icon: 'circle-filled', color: 'notificationsInfoIcon.foreground' },
|
|
15
|
+
{ id: 'done', label: 'Completed', icon: 'pass' },
|
|
16
|
+
]
|
|
17
|
+
const STATUS = Object.fromEntries(STATUSES.map((s) => [s.id, s]))
|
|
18
|
+
|
|
19
|
+
/** What a conversation is doing: from its open panel when there is one, else from the store. */
|
|
20
|
+
function statusOf(meta, panel) {
|
|
21
|
+
if (panel) {
|
|
22
|
+
if (panel.pendingAsk || panel.approvals?.size || panel.pendingPlan) return 'waiting'
|
|
23
|
+
if (panel.abort) return 'working'
|
|
24
|
+
}
|
|
25
|
+
return meta.unread ? 'unread' : 'done'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ago(when) {
|
|
29
|
+
const s = Math.max(0, Math.round((Date.now() - (when || 0)) / 1000))
|
|
30
|
+
if (s < 60) return 'just now'
|
|
31
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`
|
|
32
|
+
if (s < 86400) return `${Math.round(s / 3600)} h ago`
|
|
33
|
+
return `${Math.round(s / 86400)} d ago`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class SessionsView {
|
|
37
|
+
/** registry: the open panels; store(): this workspace's conversations; server(): its URL. */
|
|
38
|
+
constructor(context, { registry, store, server, fetchImpl = fetch }) {
|
|
39
|
+
this.context = context
|
|
40
|
+
this.registry = registry
|
|
41
|
+
this.store = store
|
|
42
|
+
this.server = server
|
|
43
|
+
this.fetch = fetchImpl
|
|
44
|
+
this.changed = new vscode.EventEmitter()
|
|
45
|
+
this.onDidChangeTreeData = this.changed.event
|
|
46
|
+
this.webChats = null // loaded when the Web section is opened
|
|
47
|
+
this.cloud = null // the account's copy (accountUi.js), when signed in
|
|
48
|
+
this.timer = null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get filter() {
|
|
52
|
+
return { active: false, statuses: [], search: '', ...(this.context.workspaceState.get(FILTER_KEY) || {}) }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async setFilter(change) {
|
|
56
|
+
await this.context.workspaceState.update(FILTER_KEY, { ...this.filter, ...change })
|
|
57
|
+
this.describe()
|
|
58
|
+
this.refresh()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Says in the view what narrows it, so an empty list is never a mystery. */
|
|
62
|
+
describe() {
|
|
63
|
+
if (!this.view) return
|
|
64
|
+
const f = this.filter
|
|
65
|
+
const parts = [f.search && `matching “${f.search}”`, f.active && 'open only',
|
|
66
|
+
f.statuses.length && f.statuses.map((s) => STATUS[s]?.label).join(', ')].filter(Boolean)
|
|
67
|
+
this.view.message = parts.length ? `Showing ${parts.join(' · ')}` : undefined
|
|
68
|
+
vscode.commands.executeCommand('setContext', 'aipictury.sessionsFiltered', parts.length > 0)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Redraws soon — many changes in a row are one redraw. */
|
|
72
|
+
refresh() {
|
|
73
|
+
clearTimeout(this.timer)
|
|
74
|
+
this.timer = setTimeout(() => this.changed.fire(), 80)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The panel holding a conversation, if any — shown or not: what it is doing comes from it. */
|
|
78
|
+
livePanel(id) { return this.registry.all().find((p) => p.id === id) }
|
|
79
|
+
|
|
80
|
+
sessions() {
|
|
81
|
+
const f = this.filter
|
|
82
|
+
const needle = f.search.trim().toLowerCase()
|
|
83
|
+
return this.store().list().map((meta) => ({ meta, panel: this.livePanel(meta.id) }))
|
|
84
|
+
.map((s) => ({ ...s, status: statusOf(s.meta, s.panel) }))
|
|
85
|
+
.filter((s) => !needle || `${s.meta.title} ${s.meta.model || ''}`.toLowerCase().includes(needle))
|
|
86
|
+
.filter((s) => !f.active || s.panel?.host)
|
|
87
|
+
.filter((s) => !f.statuses.length || f.statuses.includes(s.status))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getChildren(node) {
|
|
91
|
+
const store = this.store()
|
|
92
|
+
if (!node) {
|
|
93
|
+
const all = this.sessions()
|
|
94
|
+
// A search lists every match flat, archived ones too: it is how one is found again.
|
|
95
|
+
if (this.filter.search.trim()) return all.map((s) => ({ kind: 'session', ...s }))
|
|
96
|
+
const groups = store.groups()
|
|
97
|
+
const live = all.filter((s) => !s.meta.archived)
|
|
98
|
+
const out = groups.map((g) => ({ kind: 'group', group: g, items: live.filter((s) => s.meta.group === g.id) }))
|
|
99
|
+
out.push(...live.filter((s) => !s.meta.group || !groups.some((g) => g.id === s.meta.group)).map((s) => ({ kind: 'session', ...s })))
|
|
100
|
+
const archived = all.filter((s) => s.meta.archived)
|
|
101
|
+
if (archived.length) out.push({ kind: 'archived', items: archived })
|
|
102
|
+
if (this.cloud) out.push({ kind: 'cloud' })
|
|
103
|
+
out.push({ kind: 'web' })
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
if (node.kind === 'group' || node.kind === 'archived') return node.items.map((s) => ({ kind: 'session', ...s }))
|
|
107
|
+
if (node.kind === 'web') return this.loadWeb()
|
|
108
|
+
if (node.kind === 'cloud') return this.loadCloud()
|
|
109
|
+
return []
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Signed in: the account's copy; signed out (null): the group and its list go. */
|
|
113
|
+
setCloud(cloud) {
|
|
114
|
+
this.cloud = cloud
|
|
115
|
+
this.webChats = null // the last account's list goes with it
|
|
116
|
+
this.refresh()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** This project's conversations kept with the account but not on this machine. */
|
|
120
|
+
async loadCloud() {
|
|
121
|
+
const cloud = this.cloud
|
|
122
|
+
const folder = this.store().folder
|
|
123
|
+
if (!cloud || !folder) return []
|
|
124
|
+
try {
|
|
125
|
+
const local = new Set(this.store().list().map((m) => m.id))
|
|
126
|
+
const remote = (await cloud.listConversations(folder)).filter((c) => !local.has(c.id))
|
|
127
|
+
if (this.cloud !== cloud) return [] // signed out meanwhile: nothing shown
|
|
128
|
+
if (!remote.length) return [{ kind: 'note', label: 'Nothing here that is not on this machine' }]
|
|
129
|
+
return remote.map((c) => ({ kind: 'cloudChat', chat: c }))
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return [{ kind: 'note', label: `Your account cannot be reached (${error.message})` }]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async openCloud(node) {
|
|
136
|
+
const cloud = this.cloud
|
|
137
|
+
if (!cloud) return null
|
|
138
|
+
const entry = await cloud.pullConversation(this.store().folder, node.chat.id)
|
|
139
|
+
if (this.cloud !== cloud) return null
|
|
140
|
+
this.store().save({ ...entry, server: this.server() })
|
|
141
|
+
log.info(`conversation ${entry.id} brought from the account`)
|
|
142
|
+
return this.open({ meta: { id: entry.id } })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async loadWeb() {
|
|
146
|
+
// Signed in, the web app's chats are the account's (its project "web"), wherever they were made.
|
|
147
|
+
if (this.cloud) {
|
|
148
|
+
const cloud = this.cloud
|
|
149
|
+
try {
|
|
150
|
+
const chats = (await cloud.listProject('web')).map((c) => ({ id: c.id, title: c.title, created_at: (c.updated || 0) / 1000, fromAccount: true }))
|
|
151
|
+
if (this.cloud !== cloud) return []
|
|
152
|
+
this.webChats = chats
|
|
153
|
+
if (!chats.length) return [{ kind: 'note', label: 'No chats at aipictury.com yet' }]
|
|
154
|
+
return chats.map((chat) => ({ kind: 'webChat', chat }))
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return [{ kind: 'note', label: `Your account cannot be reached (${error.message})` }]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const controller = new AbortController()
|
|
161
|
+
const timer = setTimeout(() => controller.abort(), 8000)
|
|
162
|
+
const response = await this.fetch(`${this.server()}/api/assistant/chats`, { signal: controller.signal })
|
|
163
|
+
clearTimeout(timer)
|
|
164
|
+
const { chats = [] } = response.ok ? await response.json() : {}
|
|
165
|
+
this.webChats = chats
|
|
166
|
+
if (!chats.length) return [{ kind: 'note', label: 'No chats in the web app yet' }]
|
|
167
|
+
return chats.map((chat) => ({ kind: 'webChat', chat }))
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return [{ kind: 'note', label: `The server cannot be reached (${error.name === 'AbortError' ? 'no answer' : error.message})` }]
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
getTreeItem(node) {
|
|
174
|
+
const T = vscode.TreeItemCollapsibleState
|
|
175
|
+
if (node.kind === 'group') {
|
|
176
|
+
const item = new vscode.TreeItem(node.group.name, T.Expanded)
|
|
177
|
+
item.id = `group:${node.group.id}`
|
|
178
|
+
item.contextValue = 'group'
|
|
179
|
+
item.iconPath = new vscode.ThemeIcon('folder')
|
|
180
|
+
item.description = `${node.items.length}`
|
|
181
|
+
return item
|
|
182
|
+
}
|
|
183
|
+
if (node.kind === 'archived') {
|
|
184
|
+
const item = new vscode.TreeItem('Archived', T.Collapsed)
|
|
185
|
+
item.id = 'archived'
|
|
186
|
+
item.contextValue = 'archivedRoot'
|
|
187
|
+
item.iconPath = new vscode.ThemeIcon('archive')
|
|
188
|
+
item.description = `${node.items.length}`
|
|
189
|
+
return item
|
|
190
|
+
}
|
|
191
|
+
if (node.kind === 'web') {
|
|
192
|
+
const item = new vscode.TreeItem('Web app chats', T.Collapsed)
|
|
193
|
+
item.id = 'web'
|
|
194
|
+
item.contextValue = 'webRoot'
|
|
195
|
+
item.iconPath = new vscode.ThemeIcon('globe')
|
|
196
|
+
item.tooltip = 'Chats from the AIPictury web app — open one to carry it on here'
|
|
197
|
+
return item
|
|
198
|
+
}
|
|
199
|
+
if (node.kind === 'cloud') {
|
|
200
|
+
const item = new vscode.TreeItem('In the cloud', T.Collapsed)
|
|
201
|
+
item.id = 'cloud'
|
|
202
|
+
item.contextValue = 'cloudRoot'
|
|
203
|
+
item.iconPath = new vscode.ThemeIcon('cloud')
|
|
204
|
+
item.tooltip = 'Conversations of this project kept with your AIPictury account, from another machine or the CLI'
|
|
205
|
+
return item
|
|
206
|
+
}
|
|
207
|
+
if (node.kind === 'cloudChat') {
|
|
208
|
+
const item = new vscode.TreeItem(node.chat.title || 'Untitled', T.None)
|
|
209
|
+
item.id = `cloud:${node.chat.id}`
|
|
210
|
+
item.contextValue = 'cloudChat'
|
|
211
|
+
item.iconPath = new vscode.ThemeIcon('cloud-download')
|
|
212
|
+
item.description = `${ago(node.chat.updated || 0)}${node.chat.origin === 'cli' ? ' · CLI' : ''}`
|
|
213
|
+
item.tooltip = 'Open it here: it is brought to this machine, and stays in step with your account'
|
|
214
|
+
item.command = { command: 'aipictury.sessions.openCloud', title: 'Open', arguments: [node] }
|
|
215
|
+
return item
|
|
216
|
+
}
|
|
217
|
+
if (node.kind === 'note') {
|
|
218
|
+
const item = new vscode.TreeItem(node.label, T.None)
|
|
219
|
+
item.iconPath = new vscode.ThemeIcon('info')
|
|
220
|
+
return item
|
|
221
|
+
}
|
|
222
|
+
if (node.kind === 'webChat') {
|
|
223
|
+
const item = new vscode.TreeItem(node.chat.title || 'Untitled', T.None)
|
|
224
|
+
item.id = `web:${node.chat.id}`
|
|
225
|
+
item.contextValue = 'webChat'
|
|
226
|
+
item.iconPath = new vscode.ThemeIcon('comment')
|
|
227
|
+
item.description = ago((node.chat.created_at || 0) * 1000)
|
|
228
|
+
item.tooltip = 'Carry this chat on in VS Code (a copy: the web chat stays as it is)'
|
|
229
|
+
item.command = { command: 'aipictury.sessions.continueWeb', title: 'Continue here', arguments: [node] }
|
|
230
|
+
return item
|
|
231
|
+
}
|
|
232
|
+
const { meta, status, panel } = node
|
|
233
|
+
const look = STATUS[status]
|
|
234
|
+
const item = new vscode.TreeItem(meta.title || 'New conversation', T.None)
|
|
235
|
+
item.id = `session:${meta.id}`
|
|
236
|
+
item.contextValue = `session${meta.archived ? '.archived' : ''}${meta.unread ? '.unread' : ''}${meta.group ? '.grouped' : ''}`
|
|
237
|
+
item.iconPath = new vscode.ThemeIcon(look.icon, look.color ? new vscode.ThemeColor(look.color) : undefined)
|
|
238
|
+
item.description = `${panel?.host ? '● ' : ''}${ago(meta.updated)}`
|
|
239
|
+
item.tooltip = new vscode.MarkdownString(`**${meta.title}**\n\n${look.label}${panel?.host ? ' · open' : ''} · `
|
|
240
|
+
+ `${meta.questions ?? '?'} question(s)${meta.model ? ` · ${meta.model}` : ''}\n\nLast active ${ago(meta.updated)}`)
|
|
241
|
+
item.accessibilityInformation = { label: `${meta.title}, ${look.label}${panel?.host ? ', open' : ''}, ${ago(meta.updated)}` }
|
|
242
|
+
item.command = { command: 'aipictury.sessions.open', title: 'Open', arguments: [node] }
|
|
243
|
+
return item
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
getParent() { return undefined }
|
|
247
|
+
|
|
248
|
+
// ---- actions ----
|
|
249
|
+
|
|
250
|
+
/** The sessions a command acts on: the clicked one, or every selected one. */
|
|
251
|
+
targets(node, selected) {
|
|
252
|
+
const list = selected?.length ? selected : node ? [node] : this.view?.selection || []
|
|
253
|
+
return list.filter((n) => n?.kind === 'session')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async open(node, { where = 'preferred' } = {}) {
|
|
257
|
+
const id = node.meta.id
|
|
258
|
+
const live = this.livePanel(id)
|
|
259
|
+
if (live?.host && where === 'preferred') {
|
|
260
|
+
if (live.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus')
|
|
261
|
+
else live.host.reveal?.()
|
|
262
|
+
return live
|
|
263
|
+
}
|
|
264
|
+
await this.freshen(id)
|
|
265
|
+
const panel = where === 'tab' || where === 'window' ? await workbench.openTab(this.registry) : await workbench.openPreferred(this.registry)
|
|
266
|
+
await panel.open(id, { focus: false })
|
|
267
|
+
if (where === 'window') await vscode.commands.executeCommand('workbench.action.moveEditorToNewWindow')
|
|
268
|
+
this.refresh()
|
|
269
|
+
return panel
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Carried on somewhere else (the web app, another machine) since this copy was saved: the
|
|
273
|
+
* account's newer copy replaces it before it opens. Not while it runs here. */
|
|
274
|
+
async freshen(id) {
|
|
275
|
+
const cloud = this.cloud
|
|
276
|
+
const store = this.store()
|
|
277
|
+
if (!cloud || !store.folder || this.livePanel(id)?.abort) return
|
|
278
|
+
try {
|
|
279
|
+
const local = store.list().find((m) => m.id === id)
|
|
280
|
+
const remote = await cloud.summaryOf(store.folder, id)
|
|
281
|
+
if (!remote || !local || (remote.updated || 0) <= (local.updated || 0) || remote.device === cloud.device.id) return
|
|
282
|
+
const entry = await cloud.pullConversation(store.folder, id)
|
|
283
|
+
if (this.cloud !== cloud) return
|
|
284
|
+
store.save({ ...store.get(id), ...entry, server: this.server() })
|
|
285
|
+
log.info(`conversation ${id}: the newer copy from the account (${remote.origin || 'elsewhere'})`)
|
|
286
|
+
} catch (error) { log.warn(`conversation ${id}: the account's copy not checked: ${error.message}`) }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async moveToGroup(node, selected) {
|
|
290
|
+
const sessions = this.targets(node, selected)
|
|
291
|
+
if (!sessions.length) return
|
|
292
|
+
const store = this.store()
|
|
293
|
+
const groups = store.groups()
|
|
294
|
+
const pick = await vscode.window.showQuickPick([
|
|
295
|
+
...groups.map((g) => ({ label: `$(folder) ${g.name}`, id: g.id })),
|
|
296
|
+
{ label: '$(add) New group…', id: '' }], { title: `Move ${sessions.length > 1 ? `${sessions.length} conversations` : 'to a group'}` })
|
|
297
|
+
if (!pick) return
|
|
298
|
+
let id = pick.id
|
|
299
|
+
if (!id) {
|
|
300
|
+
const name = await vscode.window.showInputBox({ title: 'New group', prompt: 'Its name' })
|
|
301
|
+
if (!name?.trim()) return
|
|
302
|
+
id = store.createGroup(name).id
|
|
303
|
+
}
|
|
304
|
+
for (const s of sessions) store.patch(s.meta.id, { group: id, archived: undefined })
|
|
305
|
+
this.refresh()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Carries a web app chat on here: a copy becomes a new conversation of this project. */
|
|
309
|
+
async continueWeb(node) {
|
|
310
|
+
let chat
|
|
311
|
+
let messages
|
|
312
|
+
if (node.chat.fromAccount && this.cloud) {
|
|
313
|
+
// From the account: the chat as aipictury.com keeps it.
|
|
314
|
+
const got = await this.cloud.pullFrom('web', node.chat.id)
|
|
315
|
+
chat = { id: node.chat.id, title: got.meta.title, model: got.meta.model }
|
|
316
|
+
messages = (got.transcript || []).map((t) => ({ role: t.kind, content: t.text, created_at: (t.at || Date.now()) / 1000 }))
|
|
317
|
+
} else {
|
|
318
|
+
const response = await this.fetch(`${this.server()}/api/assistant/chats/${encodeURIComponent(node.chat.id)}`)
|
|
319
|
+
if (!response.ok) throw new Error(`the server answered ${response.status}`)
|
|
320
|
+
;({ chat, messages = [] } = await response.json())
|
|
321
|
+
}
|
|
322
|
+
const kept = messages.filter((m) => (m.role === 'user' || m.role === 'assistant') && String(m.content || '').trim())
|
|
323
|
+
if (!kept.length) { vscode.window.showInformationMessage('That chat has nothing to carry on yet.'); return null }
|
|
324
|
+
const id = `c${Date.now().toString(36)}`
|
|
325
|
+
const at = (m) => Math.round((m.created_at || Date.now() / 1000) * 1000)
|
|
326
|
+
this.store().save({
|
|
327
|
+
id, title: `${chat.title || 'Web chat'} (from the web)`, renamed: true,
|
|
328
|
+
created: at(kept[0]), updated: Date.now(), server: this.server(), model: chat.model || null,
|
|
329
|
+
history: kept.map((m) => ({ role: m.role, content: m.content })),
|
|
330
|
+
transcript: kept.map((m) => ({ kind: m.role, text: m.content, at: at(m) })),
|
|
331
|
+
todos: [], usage: { turns: 0, prompt: 0, output: 0, seconds: 0 }, from: { web: chat.id },
|
|
332
|
+
})
|
|
333
|
+
log.info(`web chat ${chat.id} carried on as ${id}`)
|
|
334
|
+
return this.open({ meta: { id } })
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Registers the view and its commands. Returns the view object (tests drive it). */
|
|
339
|
+
function register(context, { registry, store, server, activePanel }) {
|
|
340
|
+
const view = new SessionsView(context, { registry, store, server })
|
|
341
|
+
const tree = vscode.window.createTreeView('aipictury.sessions', { treeDataProvider: view, canSelectMany: true, showCollapseAll: true })
|
|
342
|
+
view.view = tree
|
|
343
|
+
view.describe()
|
|
344
|
+
const add = (d) => context.subscriptions.push(d)
|
|
345
|
+
add(tree)
|
|
346
|
+
const safely = (fn) => async (...args) => {
|
|
347
|
+
try { return await fn(...args) } catch (error) {
|
|
348
|
+
log.error('sessions view', error)
|
|
349
|
+
vscode.window.showErrorMessage(`AIPictury: ${error.message}`)
|
|
350
|
+
return undefined
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const cmd = (name, fn) => add(vscode.commands.registerCommand(`aipictury.sessions.${name}`, safely(fn)))
|
|
354
|
+
const one = (node) => node || view.targets(null)[0]
|
|
355
|
+
|
|
356
|
+
cmd('open', (node) => one(node) && view.open(one(node)))
|
|
357
|
+
cmd('openInTab', (node) => one(node) && view.open(one(node), { where: 'tab' }))
|
|
358
|
+
cmd('openInNewWindow', (node) => one(node) && view.open(one(node), { where: 'window' }))
|
|
359
|
+
cmd('rename', async (node) => {
|
|
360
|
+
node = one(node); if (!node) return
|
|
361
|
+
const title = await vscode.window.showInputBox({ title: 'Rename the conversation', value: node.meta.title })
|
|
362
|
+
if (!title?.trim()) return
|
|
363
|
+
store().patch(node.meta.id, { title: title.trim().slice(0, 80), renamed: true })
|
|
364
|
+
const live = view.livePanel(node.meta.id)
|
|
365
|
+
if (live) { live.title = title.trim().slice(0, 80); live.postSession() }
|
|
366
|
+
view.refresh()
|
|
367
|
+
})
|
|
368
|
+
cmd('delete', async (node, selected) => {
|
|
369
|
+
const sessions = view.targets(node, selected)
|
|
370
|
+
if (!sessions.length) return
|
|
371
|
+
const yes = 'Delete'
|
|
372
|
+
const what = sessions.length > 1 ? `${sessions.length} conversations` : `“${sessions[0].meta.title}”`
|
|
373
|
+
if (await vscode.window.showWarningMessage(`Delete ${what}?`, { modal: true, detail: 'This cannot be undone. Your files are not touched.' }, yes) !== yes) return
|
|
374
|
+
for (const s of sessions) { if (!view.livePanel(s.meta.id)?.abort) store().remove(s.meta.id) }
|
|
375
|
+
view.refresh()
|
|
376
|
+
})
|
|
377
|
+
cmd('archive', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { archived: true, group: undefined }); view.refresh() })
|
|
378
|
+
cmd('unarchive', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { archived: undefined }); view.refresh() })
|
|
379
|
+
cmd('unarchiveAll', () => { for (const m of store().list()) if (m.archived) store().patch(m.id, { archived: undefined }); view.refresh() })
|
|
380
|
+
cmd('markUnread', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { unread: true }); view.refresh() })
|
|
381
|
+
cmd('markRead', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { unread: undefined }); view.refresh() })
|
|
382
|
+
cmd('moveToGroup', (node, selected) => view.moveToGroup(node, selected))
|
|
383
|
+
cmd('removeFromGroup', (node, selected) => { for (const s of view.targets(node, selected)) store().patch(s.meta.id, { group: undefined }); view.refresh() })
|
|
384
|
+
cmd('newGroup', async () => {
|
|
385
|
+
const name = await vscode.window.showInputBox({ title: 'New group', prompt: 'Its name' })
|
|
386
|
+
if (name?.trim()) { store().createGroup(name); view.refresh() }
|
|
387
|
+
})
|
|
388
|
+
cmd('renameGroup', async (node) => {
|
|
389
|
+
if (node?.kind !== 'group') return
|
|
390
|
+
const name = await vscode.window.showInputBox({ title: 'Rename the group', value: node.group.name })
|
|
391
|
+
if (name?.trim()) { store().renameGroup(node.group.id, name); view.refresh() }
|
|
392
|
+
})
|
|
393
|
+
cmd('deleteGroup', async (node) => {
|
|
394
|
+
if (node?.kind !== 'group') return
|
|
395
|
+
const yes = 'Delete group'
|
|
396
|
+
if (await vscode.window.showWarningMessage(`Delete the group “${node.group.name}”?`, { modal: true,
|
|
397
|
+
detail: 'Its conversations are kept, outside any group.' }, yes) !== yes) return
|
|
398
|
+
store().deleteGroup(node.group.id)
|
|
399
|
+
view.refresh()
|
|
400
|
+
})
|
|
401
|
+
cmd('addToGroup', async () => {
|
|
402
|
+
const panel = activePanel()
|
|
403
|
+
if (!panel?.id) { vscode.window.showInformationMessage('Ask something first: an empty conversation is not saved yet.'); return }
|
|
404
|
+
await panel.remember()
|
|
405
|
+
const meta = store().list().find((m) => m.id === panel.id)
|
|
406
|
+
if (meta) await view.moveToGroup({ kind: 'session', meta })
|
|
407
|
+
})
|
|
408
|
+
cmd('search', async () => {
|
|
409
|
+
const text = await vscode.window.showInputBox({ title: 'Search conversations', value: view.filter.search,
|
|
410
|
+
prompt: 'Matches titles and models, archived ones included. Empty clears the search.' })
|
|
411
|
+
if (text !== undefined) await view.setFilter({ search: text.trim() })
|
|
412
|
+
})
|
|
413
|
+
cmd('filter', async () => {
|
|
414
|
+
const f = view.filter
|
|
415
|
+
const items = [
|
|
416
|
+
{ label: 'Open only', description: 'conversations open in a panel or tab', id: 'active', picked: f.active },
|
|
417
|
+
...STATUSES.map((s) => ({ label: `$(${s.icon.replace('~spin', '')}) ${s.label}`, id: s.id, picked: f.statuses.includes(s.id) })),
|
|
418
|
+
]
|
|
419
|
+
const picked = await vscode.window.showQuickPick(items, { canPickMany: true, title: 'Show only…',
|
|
420
|
+
placeHolder: 'Nothing picked shows everything' })
|
|
421
|
+
if (!picked) return
|
|
422
|
+
await view.setFilter({ active: picked.some((p) => p.id === 'active'), statuses: picked.map((p) => p.id).filter((id) => id !== 'active') })
|
|
423
|
+
})
|
|
424
|
+
cmd('clearFilter', () => view.setFilter({ active: false, statuses: [], search: '' }))
|
|
425
|
+
cmd('refresh', () => { view.webChats = null; view.refresh() })
|
|
426
|
+
cmd('continueWeb', (node) => node?.kind === 'webChat' && view.continueWeb(node))
|
|
427
|
+
cmd('openCloud', async (node) => {
|
|
428
|
+
if (node?.kind !== 'cloudChat') return
|
|
429
|
+
try { await view.openCloud(node) } catch (error) { vscode.window.showErrorMessage(`AIPictury: not opened — ${error.message}`) }
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
// Kept current: the store changes in any window; the panels change as they run.
|
|
433
|
+
let listening = null
|
|
434
|
+
const watch = () => { listening?.dispose(); listening = store().onChange(() => view.refresh()) }
|
|
435
|
+
watch()
|
|
436
|
+
add({ dispose: () => listening?.dispose() })
|
|
437
|
+
add(vscode.workspace.onDidChangeWorkspaceFolders(() => { watch(); view.refresh() }))
|
|
438
|
+
|
|
439
|
+
// archiveInactiveSessions: what has been idle that many days goes to Archived — never an
|
|
440
|
+
// open, unread or grouped conversation.
|
|
441
|
+
const days = Number(vscode.workspace.getConfiguration('aipictury').get('archiveInactiveSessions', 0)) || 0
|
|
442
|
+
if (days) {
|
|
443
|
+
const moved = store().archiveInactive(days, { open: new Set(registry.all().map((p) => p.id).filter(Boolean)) })
|
|
444
|
+
if (moved) log.info(`archived ${moved} conversation(s) idle for more than ${days} day(s)`)
|
|
445
|
+
}
|
|
446
|
+
return view
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
module.exports = { register, SessionsView, statusOf, STATUSES }
|