Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/common/chatSessionStore.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Sequencer } from '../../../../base/common/async.js';
7
import { VSBuffer } from '../../../../base/common/buffer.js';
8
import { toErrorMessage } from '../../../../base/common/errorMessage.js';
9
import { MarkdownString } from '../../../../base/common/htmlContent.js';
10
import { Disposable } from '../../../../base/common/lifecycle.js';
11
import { revive } from '../../../../base/common/marshalling.js';
12
import { joinPath } from '../../../../base/common/resources.js';
13
import { URI } from '../../../../base/common/uri.js';
14
import { localize } from '../../../../nls.js';
15
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
16
import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../platform/files/common/files.js';
17
import { ILogService } from '../../../../platform/log/common/log.js';
18
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
19
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
20
import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js';
21
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
22
import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js';
23
import { ChatModel, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData } from './chatModel.js';
24
import { ChatAgentLocation, ChatModeKind } from './constants.js';
25
26
const maxPersistedSessions = 25;
27
28
const ChatIndexStorageKey = 'chat.ChatSessionStore.index';
29
// const ChatTransferIndexStorageKey = 'ChatSessionStore.transferIndex';
30
31
export class ChatSessionStore extends Disposable {
32
private readonly storageRoot: URI;
33
private readonly previousEmptyWindowStorageRoot: URI | undefined;
34
// private readonly transferredSessionStorageRoot: URI;
35
36
private readonly storeQueue = new Sequencer();
37
38
private storeTask: Promise<void> | undefined;
39
private shuttingDown = false;
40
41
constructor(
42
@IFileService private readonly fileService: IFileService,
43
@IEnvironmentService private readonly environmentService: IEnvironmentService,
44
@ILogService private readonly logService: ILogService,
45
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
46
@ITelemetryService private readonly telemetryService: ITelemetryService,
47
@IStorageService private readonly storageService: IStorageService,
48
@ILifecycleService private readonly lifecycleService: ILifecycleService,
49
@IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService,
50
) {
51
super();
52
53
const workspace = this.workspaceContextService.getWorkspace();
54
const isEmptyWindow = !workspace.configuration && workspace.folders.length === 0;
55
const workspaceId = this.workspaceContextService.getWorkspace().id;
56
this.storageRoot = isEmptyWindow ?
57
joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'emptyWindowChatSessions') :
58
joinPath(this.environmentService.workspaceStorageHome, workspaceId, 'chatSessions');
59
60
this.previousEmptyWindowStorageRoot = isEmptyWindow ?
61
joinPath(this.environmentService.workspaceStorageHome, 'no-workspace', 'chatSessions') :
62
undefined;
63
64
// TODO tmpdir
65
// this.transferredSessionStorageRoot = joinPath(this.environmentService.workspaceStorageHome, 'transferredChatSessions');
66
67
this._register(this.lifecycleService.onWillShutdown(e => {
68
this.shuttingDown = true;
69
if (!this.storeTask) {
70
return;
71
}
72
73
e.join(this.storeTask, {
74
id: 'join.chatSessionStore',
75
label: localize('join.chatSessionStore', "Saving chat history")
76
});
77
}));
78
}
79
80
async storeSessions(sessions: ChatModel[]): Promise<void> {
81
if (this.shuttingDown) {
82
// Don't start this task if we missed the chance to block shutdown
83
return;
84
}
85
86
try {
87
this.storeTask = this.storeQueue.queue(async () => {
88
try {
89
await Promise.all(sessions.map(session => this.writeSession(session)));
90
await this.trimEntries();
91
await this.flushIndex();
92
} catch (e) {
93
this.reportError('storeSessions', 'Error storing chat sessions', e);
94
}
95
});
96
await this.storeTask;
97
} finally {
98
this.storeTask = undefined;
99
}
100
}
101
102
// async storeTransferSession(transferData: IChatTransfer, session: ISerializableChatData): Promise<void> {
103
// try {
104
// const content = JSON.stringify(session, undefined, 2);
105
// await this.fileService.writeFile(this.transferredSessionStorageRoot, VSBuffer.fromString(content));
106
// } catch (e) {
107
// this.reportError('sessionWrite', 'Error writing chat session', e);
108
// return;
109
// }
110
111
// const index = this.getTransferredSessionIndex();
112
// index[transferData.toWorkspace.toString()] = transferData;
113
// try {
114
// this.storageService.store(ChatTransferIndexStorageKey, index, StorageScope.PROFILE, StorageTarget.MACHINE);
115
// } catch (e) {
116
// this.reportError('storeTransferSession', 'Error storing chat transfer session', e);
117
// }
118
// }
119
120
// private getTransferredSessionIndex(): IChatTransferIndex {
121
// try {
122
// const data: IChatTransferIndex = this.storageService.getObject(ChatTransferIndexStorageKey, StorageScope.PROFILE, {});
123
// return data;
124
// } catch (e) {
125
// this.reportError('getTransferredSessionIndex', 'Error reading chat transfer index', e);
126
// return {};
127
// }
128
// }
129
130
private async writeSession(session: ChatModel | ISerializableChatData): Promise<void> {
131
try {
132
const index = this.internalGetIndex();
133
const storageLocation = this.getStorageLocation(session.sessionId);
134
const content = JSON.stringify(session, undefined, 2);
135
await this.fileService.writeFile(storageLocation, VSBuffer.fromString(content));
136
137
// Write succeeded, update index
138
index.entries[session.sessionId] = getSessionMetadata(session);
139
} catch (e) {
140
this.reportError('sessionWrite', 'Error writing chat session', e);
141
}
142
}
143
144
private async flushIndex(): Promise<void> {
145
const index = this.internalGetIndex();
146
try {
147
this.storageService.store(ChatIndexStorageKey, index, this.getIndexStorageScope(), StorageTarget.MACHINE);
148
} catch (e) {
149
// Only if JSON.stringify fails, AFAIK
150
this.reportError('indexWrite', 'Error writing index', e);
151
}
152
}
153
154
private getIndexStorageScope(): StorageScope {
155
const workspace = this.workspaceContextService.getWorkspace();
156
const isEmptyWindow = !workspace.configuration && workspace.folders.length === 0;
157
return isEmptyWindow ? StorageScope.APPLICATION : StorageScope.WORKSPACE;
158
}
159
160
private async trimEntries(): Promise<void> {
161
const index = this.internalGetIndex();
162
const entries = Object.entries(index.entries)
163
.sort((a, b) => b[1].lastMessageDate - a[1].lastMessageDate)
164
.map(([id]) => id);
165
166
if (entries.length > maxPersistedSessions) {
167
const entriesToDelete = entries.slice(maxPersistedSessions);
168
for (const entry of entriesToDelete) {
169
delete index.entries[entry];
170
}
171
172
this.logService.trace(`ChatSessionStore: Trimmed ${entriesToDelete.length} old chat sessions from index`);
173
}
174
}
175
176
private async internalDeleteSession(sessionId: string): Promise<void> {
177
const index = this.internalGetIndex();
178
if (!index.entries[sessionId]) {
179
return;
180
}
181
182
const storageLocation = this.getStorageLocation(sessionId);
183
try {
184
await this.fileService.del(storageLocation);
185
} catch (e) {
186
if (toFileOperationResult(e) !== FileOperationResult.FILE_NOT_FOUND) {
187
this.reportError('sessionDelete', 'Error deleting chat session', e);
188
}
189
} finally {
190
delete index.entries[sessionId];
191
}
192
}
193
194
hasSessions(): boolean {
195
return Object.keys(this.internalGetIndex().entries).length > 0;
196
}
197
198
isSessionEmpty(sessionId: string): boolean {
199
const index = this.internalGetIndex();
200
return index.entries[sessionId]?.isEmpty ?? true;
201
}
202
203
async deleteSession(sessionId: string): Promise<void> {
204
await this.storeQueue.queue(async () => {
205
await this.internalDeleteSession(sessionId);
206
await this.flushIndex();
207
});
208
}
209
210
async clearAllSessions(): Promise<void> {
211
await this.storeQueue.queue(async () => {
212
const index = this.internalGetIndex();
213
const entries = Object.keys(index.entries);
214
this.logService.info(`ChatSessionStore: Clearing ${entries.length} chat sessions`);
215
await Promise.all(entries.map(entry => this.internalDeleteSession(entry)));
216
await this.flushIndex();
217
});
218
}
219
220
public async setSessionTitle(sessionId: string, title: string): Promise<void> {
221
await this.storeQueue.queue(async () => {
222
const index = this.internalGetIndex();
223
if (index.entries[sessionId]) {
224
index.entries[sessionId].title = title;
225
}
226
});
227
}
228
229
private reportError(reasonForTelemetry: string, message: string, error?: Error): void {
230
this.logService.error(`ChatSessionStore: ` + message, toErrorMessage(error));
231
232
const fileOperationReason = error && toFileOperationResult(error);
233
type ChatSessionStoreErrorData = {
234
reason: string;
235
fileOperationReason: number;
236
// error: Error;
237
};
238
type ChatSessionStoreErrorClassification = {
239
owner: 'roblourens';
240
comment: 'Detect issues related to managing chat sessions';
241
reason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Info about the error that occurred' };
242
fileOperationReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'An error code from the file service' };
243
// error: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Info about the error that occurred' };
244
};
245
this.telemetryService.publicLog2<ChatSessionStoreErrorData, ChatSessionStoreErrorClassification>('chatSessionStoreError', {
246
reason: reasonForTelemetry,
247
fileOperationReason: fileOperationReason ?? -1
248
});
249
}
250
251
private indexCache: IChatSessionIndexData | undefined;
252
private internalGetIndex(): IChatSessionIndexData {
253
if (this.indexCache) {
254
return this.indexCache;
255
}
256
257
const data = this.storageService.get(ChatIndexStorageKey, this.getIndexStorageScope(), undefined);
258
if (!data) {
259
this.indexCache = { version: 1, entries: {} };
260
return this.indexCache;
261
}
262
263
try {
264
const index = JSON.parse(data) as unknown;
265
if (isChatSessionIndex(index)) {
266
// Success
267
this.indexCache = index;
268
} else {
269
this.reportError('invalidIndexFormat', `Invalid index format: ${data}`);
270
this.indexCache = { version: 1, entries: {} };
271
}
272
273
return this.indexCache;
274
} catch (e) {
275
// Only if JSON.parse fails
276
this.reportError('invalidIndexJSON', `Index corrupt: ${data}`, e);
277
this.indexCache = { version: 1, entries: {} };
278
return this.indexCache;
279
}
280
}
281
282
async getIndex(): Promise<IChatSessionIndex> {
283
return this.storeQueue.queue(async () => {
284
return this.internalGetIndex().entries;
285
});
286
}
287
288
logIndex(): void {
289
const data = this.storageService.get(ChatIndexStorageKey, this.getIndexStorageScope(), undefined);
290
this.logService.info('ChatSessionStore index: ', data);
291
}
292
293
async migrateDataIfNeeded(getInitialData: () => ISerializableChatsData | undefined): Promise<void> {
294
await this.storeQueue.queue(async () => {
295
const data = this.storageService.get(ChatIndexStorageKey, this.getIndexStorageScope(), undefined);
296
const needsMigrationFromStorageService = !data;
297
if (needsMigrationFromStorageService) {
298
const initialData = getInitialData();
299
if (initialData) {
300
await this.migrate(initialData);
301
}
302
}
303
});
304
}
305
306
private async migrate(initialData: ISerializableChatsData): Promise<void> {
307
const numSessions = Object.keys(initialData).length;
308
this.logService.info(`ChatSessionStore: Migrating ${numSessions} chat sessions from storage service to file system`);
309
310
await Promise.all(Object.values(initialData).map(async session => {
311
await this.writeSession(session);
312
}));
313
314
await this.flushIndex();
315
}
316
317
public async readSession(sessionId: string): Promise<ISerializableChatData | undefined> {
318
return await this.storeQueue.queue(async () => {
319
let rawData: string | undefined;
320
const storageLocation = this.getStorageLocation(sessionId);
321
try {
322
rawData = (await this.fileService.readFile(storageLocation)).value.toString();
323
} catch (e) {
324
this.reportError('sessionReadFile', `Error reading chat session file ${sessionId}`, e);
325
326
if (toFileOperationResult(e) === FileOperationResult.FILE_NOT_FOUND && this.previousEmptyWindowStorageRoot) {
327
rawData = await this.readSessionFromPreviousLocation(sessionId);
328
}
329
330
if (!rawData) {
331
return undefined;
332
}
333
}
334
335
try {
336
// TODO Copied from ChatService.ts, cleanup
337
const session: ISerializableChatDataIn = revive(JSON.parse(rawData)); // Revive serialized URIs in session data
338
// Revive serialized markdown strings in response data
339
for (const request of session.requests) {
340
if (Array.isArray(request.response)) {
341
request.response = request.response.map((response) => {
342
if (typeof response === 'string') {
343
return new MarkdownString(response);
344
}
345
return response;
346
});
347
} else if (typeof request.response === 'string') {
348
request.response = [new MarkdownString(request.response)];
349
}
350
}
351
352
return normalizeSerializableChatData(session);
353
} catch (err) {
354
this.reportError('malformedSession', `Malformed session data in ${storageLocation.fsPath}: [${rawData.substring(0, 20)}${rawData.length > 20 ? '...' : ''}]`, err);
355
return undefined;
356
}
357
});
358
}
359
360
private async readSessionFromPreviousLocation(sessionId: string): Promise<string | undefined> {
361
let rawData: string | undefined;
362
363
if (this.previousEmptyWindowStorageRoot) {
364
const storageLocation2 = joinPath(this.previousEmptyWindowStorageRoot, `${sessionId}.json`);
365
try {
366
rawData = (await this.fileService.readFile(storageLocation2)).value.toString();
367
this.logService.info(`ChatSessionStore: Read chat session ${sessionId} from previous location`);
368
} catch (e) {
369
this.reportError('sessionReadFile', `Error reading chat session file ${sessionId} from previous location`, e);
370
return undefined;
371
}
372
}
373
374
return rawData;
375
}
376
377
private getStorageLocation(chatSessionId: string): URI {
378
return joinPath(this.storageRoot, `${chatSessionId}.json`);
379
}
380
381
public getChatStorageFolder(): URI {
382
return this.storageRoot;
383
}
384
}
385
386
interface IChatSessionEntryMetadata {
387
sessionId: string;
388
title: string;
389
lastMessageDate: number;
390
isImported?: boolean;
391
initialLocation?: ChatAgentLocation;
392
393
/**
394
* This only exists because the migrated data from the storage service had empty sessions persisted, and it's impossible to know which ones are
395
* currently in use. Now, `clearSession` deletes empty sessions, so old ones shouldn't take up space in the store anymore, but we still need to
396
* filter the old ones out of history.
397
*/
398
isEmpty?: boolean;
399
}
400
401
function isChatSessionEntryMetadata(obj: unknown): obj is IChatSessionEntryMetadata {
402
return (
403
!!obj &&
404
typeof obj === 'object' &&
405
typeof (obj as IChatSessionEntryMetadata).sessionId === 'string' &&
406
typeof (obj as IChatSessionEntryMetadata).title === 'string' &&
407
typeof (obj as IChatSessionEntryMetadata).lastMessageDate === 'number'
408
);
409
}
410
411
export type IChatSessionIndex = Record<string, IChatSessionEntryMetadata>;
412
413
interface IChatSessionIndexData {
414
version: 1;
415
entries: IChatSessionIndex;
416
}
417
418
// TODO if we update the index version:
419
// Don't throw away index when moving backwards in VS Code version. Try to recover it. But this scenario is hard.
420
function isChatSessionIndex(data: unknown): data is IChatSessionIndexData {
421
if (typeof data !== 'object' || data === null) {
422
return false;
423
}
424
425
const index = data as IChatSessionIndexData;
426
if (index.version !== 1) {
427
return false;
428
}
429
430
if (typeof index.entries !== 'object' || index.entries === null) {
431
return false;
432
}
433
434
for (const key in index.entries) {
435
if (!isChatSessionEntryMetadata(index.entries[key])) {
436
return false;
437
}
438
}
439
440
return true;
441
}
442
443
function getSessionMetadata(session: ChatModel | ISerializableChatData): IChatSessionEntryMetadata {
444
const title = session instanceof ChatModel ?
445
session.customTitle || (session.getRequests().length > 0 ? ChatModel.getDefaultTitle(session.getRequests()) : '') :
446
session.customTitle ?? (session.requests.length > 0 ? ChatModel.getDefaultTitle(session.requests) : '');
447
448
return {
449
sessionId: session.sessionId,
450
title, // Empty string for sessions without content - UI will handle display
451
lastMessageDate: session.lastMessageDate,
452
isImported: session.isImported,
453
initialLocation: session.initialLocation,
454
isEmpty: session instanceof ChatModel ? session.getRequests().length === 0 : session.requests.length === 0
455
};
456
}
457
458
export interface IChatTransfer {
459
toWorkspace: URI;
460
timestampInMilliseconds: number;
461
inputValue: string;
462
location: ChatAgentLocation;
463
mode: ChatModeKind;
464
}
465
466
export interface IChatTransfer2 extends IChatTransfer {
467
chat: ISerializableChatData;
468
}
469
470
// type IChatTransferDto = Dto<IChatTransfer>;
471
472
/**
473
* Map of destination workspace URI to chat transfer data
474
*/
475
// type IChatTransferIndex = Record<string, IChatTransferDto>;
476
477