Path: blob/main/src/vs/workbench/contrib/chat/common/chatSessionStore.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Sequencer } from '../../../../base/common/async.js';6import { VSBuffer } from '../../../../base/common/buffer.js';7import { toErrorMessage } from '../../../../base/common/errorMessage.js';8import { MarkdownString } from '../../../../base/common/htmlContent.js';9import { Disposable } from '../../../../base/common/lifecycle.js';10import { revive } from '../../../../base/common/marshalling.js';11import { joinPath } from '../../../../base/common/resources.js';12import { URI } from '../../../../base/common/uri.js';13import { localize } from '../../../../nls.js';14import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';15import { FileOperationResult, IFileService, toFileOperationResult } from '../../../../platform/files/common/files.js';16import { ILogService } from '../../../../platform/log/common/log.js';17import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';18import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';19import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js';20import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';21import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js';22import { ChatModel, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData } from './chatModel.js';23import { ChatAgentLocation, ChatModeKind } from './constants.js';2425const maxPersistedSessions = 25;2627const ChatIndexStorageKey = 'chat.ChatSessionStore.index';28// const ChatTransferIndexStorageKey = 'ChatSessionStore.transferIndex';2930export class ChatSessionStore extends Disposable {31private readonly storageRoot: URI;32private readonly previousEmptyWindowStorageRoot: URI | undefined;33// private readonly transferredSessionStorageRoot: URI;3435private readonly storeQueue = new Sequencer();3637private storeTask: Promise<void> | undefined;38private shuttingDown = false;3940constructor(41@IFileService private readonly fileService: IFileService,42@IEnvironmentService private readonly environmentService: IEnvironmentService,43@ILogService private readonly logService: ILogService,44@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,45@ITelemetryService private readonly telemetryService: ITelemetryService,46@IStorageService private readonly storageService: IStorageService,47@ILifecycleService private readonly lifecycleService: ILifecycleService,48@IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService,49) {50super();5152const workspace = this.workspaceContextService.getWorkspace();53const isEmptyWindow = !workspace.configuration && workspace.folders.length === 0;54const workspaceId = this.workspaceContextService.getWorkspace().id;55this.storageRoot = isEmptyWindow ?56joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'emptyWindowChatSessions') :57joinPath(this.environmentService.workspaceStorageHome, workspaceId, 'chatSessions');5859this.previousEmptyWindowStorageRoot = isEmptyWindow ?60joinPath(this.environmentService.workspaceStorageHome, 'no-workspace', 'chatSessions') :61undefined;6263// TODO tmpdir64// this.transferredSessionStorageRoot = joinPath(this.environmentService.workspaceStorageHome, 'transferredChatSessions');6566this._register(this.lifecycleService.onWillShutdown(e => {67this.shuttingDown = true;68if (!this.storeTask) {69return;70}7172e.join(this.storeTask, {73id: 'join.chatSessionStore',74label: localize('join.chatSessionStore', "Saving chat history")75});76}));77}7879async storeSessions(sessions: ChatModel[]): Promise<void> {80if (this.shuttingDown) {81// Don't start this task if we missed the chance to block shutdown82return;83}8485try {86this.storeTask = this.storeQueue.queue(async () => {87try {88await Promise.all(sessions.map(session => this.writeSession(session)));89await this.trimEntries();90await this.flushIndex();91} catch (e) {92this.reportError('storeSessions', 'Error storing chat sessions', e);93}94});95await this.storeTask;96} finally {97this.storeTask = undefined;98}99}100101// async storeTransferSession(transferData: IChatTransfer, session: ISerializableChatData): Promise<void> {102// try {103// const content = JSON.stringify(session, undefined, 2);104// await this.fileService.writeFile(this.transferredSessionStorageRoot, VSBuffer.fromString(content));105// } catch (e) {106// this.reportError('sessionWrite', 'Error writing chat session', e);107// return;108// }109110// const index = this.getTransferredSessionIndex();111// index[transferData.toWorkspace.toString()] = transferData;112// try {113// this.storageService.store(ChatTransferIndexStorageKey, index, StorageScope.PROFILE, StorageTarget.MACHINE);114// } catch (e) {115// this.reportError('storeTransferSession', 'Error storing chat transfer session', e);116// }117// }118119// private getTransferredSessionIndex(): IChatTransferIndex {120// try {121// const data: IChatTransferIndex = this.storageService.getObject(ChatTransferIndexStorageKey, StorageScope.PROFILE, {});122// return data;123// } catch (e) {124// this.reportError('getTransferredSessionIndex', 'Error reading chat transfer index', e);125// return {};126// }127// }128129private async writeSession(session: ChatModel | ISerializableChatData): Promise<void> {130try {131const index = this.internalGetIndex();132const storageLocation = this.getStorageLocation(session.sessionId);133const content = JSON.stringify(session, undefined, 2);134await this.fileService.writeFile(storageLocation, VSBuffer.fromString(content));135136// Write succeeded, update index137index.entries[session.sessionId] = getSessionMetadata(session);138} catch (e) {139this.reportError('sessionWrite', 'Error writing chat session', e);140}141}142143private async flushIndex(): Promise<void> {144const index = this.internalGetIndex();145try {146this.storageService.store(ChatIndexStorageKey, index, this.getIndexStorageScope(), StorageTarget.MACHINE);147} catch (e) {148// Only if JSON.stringify fails, AFAIK149this.reportError('indexWrite', 'Error writing index', e);150}151}152153private getIndexStorageScope(): StorageScope {154const workspace = this.workspaceContextService.getWorkspace();155const isEmptyWindow = !workspace.configuration && workspace.folders.length === 0;156return isEmptyWindow ? StorageScope.APPLICATION : StorageScope.WORKSPACE;157}158159private async trimEntries(): Promise<void> {160const index = this.internalGetIndex();161const entries = Object.entries(index.entries)162.sort((a, b) => b[1].lastMessageDate - a[1].lastMessageDate)163.map(([id]) => id);164165if (entries.length > maxPersistedSessions) {166const entriesToDelete = entries.slice(maxPersistedSessions);167for (const entry of entriesToDelete) {168delete index.entries[entry];169}170171this.logService.trace(`ChatSessionStore: Trimmed ${entriesToDelete.length} old chat sessions from index`);172}173}174175private async internalDeleteSession(sessionId: string): Promise<void> {176const index = this.internalGetIndex();177if (!index.entries[sessionId]) {178return;179}180181const storageLocation = this.getStorageLocation(sessionId);182try {183await this.fileService.del(storageLocation);184} catch (e) {185if (toFileOperationResult(e) !== FileOperationResult.FILE_NOT_FOUND) {186this.reportError('sessionDelete', 'Error deleting chat session', e);187}188} finally {189delete index.entries[sessionId];190}191}192193hasSessions(): boolean {194return Object.keys(this.internalGetIndex().entries).length > 0;195}196197isSessionEmpty(sessionId: string): boolean {198const index = this.internalGetIndex();199return index.entries[sessionId]?.isEmpty ?? true;200}201202async deleteSession(sessionId: string): Promise<void> {203await this.storeQueue.queue(async () => {204await this.internalDeleteSession(sessionId);205await this.flushIndex();206});207}208209async clearAllSessions(): Promise<void> {210await this.storeQueue.queue(async () => {211const index = this.internalGetIndex();212const entries = Object.keys(index.entries);213this.logService.info(`ChatSessionStore: Clearing ${entries.length} chat sessions`);214await Promise.all(entries.map(entry => this.internalDeleteSession(entry)));215await this.flushIndex();216});217}218219public async setSessionTitle(sessionId: string, title: string): Promise<void> {220await this.storeQueue.queue(async () => {221const index = this.internalGetIndex();222if (index.entries[sessionId]) {223index.entries[sessionId].title = title;224}225});226}227228private reportError(reasonForTelemetry: string, message: string, error?: Error): void {229this.logService.error(`ChatSessionStore: ` + message, toErrorMessage(error));230231const fileOperationReason = error && toFileOperationResult(error);232type ChatSessionStoreErrorData = {233reason: string;234fileOperationReason: number;235// error: Error;236};237type ChatSessionStoreErrorClassification = {238owner: 'roblourens';239comment: 'Detect issues related to managing chat sessions';240reason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Info about the error that occurred' };241fileOperationReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'An error code from the file service' };242// error: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Info about the error that occurred' };243};244this.telemetryService.publicLog2<ChatSessionStoreErrorData, ChatSessionStoreErrorClassification>('chatSessionStoreError', {245reason: reasonForTelemetry,246fileOperationReason: fileOperationReason ?? -1247});248}249250private indexCache: IChatSessionIndexData | undefined;251private internalGetIndex(): IChatSessionIndexData {252if (this.indexCache) {253return this.indexCache;254}255256const data = this.storageService.get(ChatIndexStorageKey, this.getIndexStorageScope(), undefined);257if (!data) {258this.indexCache = { version: 1, entries: {} };259return this.indexCache;260}261262try {263const index = JSON.parse(data) as unknown;264if (isChatSessionIndex(index)) {265// Success266this.indexCache = index;267} else {268this.reportError('invalidIndexFormat', `Invalid index format: ${data}`);269this.indexCache = { version: 1, entries: {} };270}271272return this.indexCache;273} catch (e) {274// Only if JSON.parse fails275this.reportError('invalidIndexJSON', `Index corrupt: ${data}`, e);276this.indexCache = { version: 1, entries: {} };277return this.indexCache;278}279}280281async getIndex(): Promise<IChatSessionIndex> {282return this.storeQueue.queue(async () => {283return this.internalGetIndex().entries;284});285}286287logIndex(): void {288const data = this.storageService.get(ChatIndexStorageKey, this.getIndexStorageScope(), undefined);289this.logService.info('ChatSessionStore index: ', data);290}291292async migrateDataIfNeeded(getInitialData: () => ISerializableChatsData | undefined): Promise<void> {293await this.storeQueue.queue(async () => {294const data = this.storageService.get(ChatIndexStorageKey, this.getIndexStorageScope(), undefined);295const needsMigrationFromStorageService = !data;296if (needsMigrationFromStorageService) {297const initialData = getInitialData();298if (initialData) {299await this.migrate(initialData);300}301}302});303}304305private async migrate(initialData: ISerializableChatsData): Promise<void> {306const numSessions = Object.keys(initialData).length;307this.logService.info(`ChatSessionStore: Migrating ${numSessions} chat sessions from storage service to file system`);308309await Promise.all(Object.values(initialData).map(async session => {310await this.writeSession(session);311}));312313await this.flushIndex();314}315316public async readSession(sessionId: string): Promise<ISerializableChatData | undefined> {317return await this.storeQueue.queue(async () => {318let rawData: string | undefined;319const storageLocation = this.getStorageLocation(sessionId);320try {321rawData = (await this.fileService.readFile(storageLocation)).value.toString();322} catch (e) {323this.reportError('sessionReadFile', `Error reading chat session file ${sessionId}`, e);324325if (toFileOperationResult(e) === FileOperationResult.FILE_NOT_FOUND && this.previousEmptyWindowStorageRoot) {326rawData = await this.readSessionFromPreviousLocation(sessionId);327}328329if (!rawData) {330return undefined;331}332}333334try {335// TODO Copied from ChatService.ts, cleanup336const session: ISerializableChatDataIn = revive(JSON.parse(rawData)); // Revive serialized URIs in session data337// Revive serialized markdown strings in response data338for (const request of session.requests) {339if (Array.isArray(request.response)) {340request.response = request.response.map((response) => {341if (typeof response === 'string') {342return new MarkdownString(response);343}344return response;345});346} else if (typeof request.response === 'string') {347request.response = [new MarkdownString(request.response)];348}349}350351return normalizeSerializableChatData(session);352} catch (err) {353this.reportError('malformedSession', `Malformed session data in ${storageLocation.fsPath}: [${rawData.substring(0, 20)}${rawData.length > 20 ? '...' : ''}]`, err);354return undefined;355}356});357}358359private async readSessionFromPreviousLocation(sessionId: string): Promise<string | undefined> {360let rawData: string | undefined;361362if (this.previousEmptyWindowStorageRoot) {363const storageLocation2 = joinPath(this.previousEmptyWindowStorageRoot, `${sessionId}.json`);364try {365rawData = (await this.fileService.readFile(storageLocation2)).value.toString();366this.logService.info(`ChatSessionStore: Read chat session ${sessionId} from previous location`);367} catch (e) {368this.reportError('sessionReadFile', `Error reading chat session file ${sessionId} from previous location`, e);369return undefined;370}371}372373return rawData;374}375376private getStorageLocation(chatSessionId: string): URI {377return joinPath(this.storageRoot, `${chatSessionId}.json`);378}379380public getChatStorageFolder(): URI {381return this.storageRoot;382}383}384385interface IChatSessionEntryMetadata {386sessionId: string;387title: string;388lastMessageDate: number;389isImported?: boolean;390initialLocation?: ChatAgentLocation;391392/**393* This only exists because the migrated data from the storage service had empty sessions persisted, and it's impossible to know which ones are394* currently in use. Now, `clearSession` deletes empty sessions, so old ones shouldn't take up space in the store anymore, but we still need to395* filter the old ones out of history.396*/397isEmpty?: boolean;398}399400function isChatSessionEntryMetadata(obj: unknown): obj is IChatSessionEntryMetadata {401return (402!!obj &&403typeof obj === 'object' &&404typeof (obj as IChatSessionEntryMetadata).sessionId === 'string' &&405typeof (obj as IChatSessionEntryMetadata).title === 'string' &&406typeof (obj as IChatSessionEntryMetadata).lastMessageDate === 'number'407);408}409410export type IChatSessionIndex = Record<string, IChatSessionEntryMetadata>;411412interface IChatSessionIndexData {413version: 1;414entries: IChatSessionIndex;415}416417// TODO if we update the index version:418// Don't throw away index when moving backwards in VS Code version. Try to recover it. But this scenario is hard.419function isChatSessionIndex(data: unknown): data is IChatSessionIndexData {420if (typeof data !== 'object' || data === null) {421return false;422}423424const index = data as IChatSessionIndexData;425if (index.version !== 1) {426return false;427}428429if (typeof index.entries !== 'object' || index.entries === null) {430return false;431}432433for (const key in index.entries) {434if (!isChatSessionEntryMetadata(index.entries[key])) {435return false;436}437}438439return true;440}441442function getSessionMetadata(session: ChatModel | ISerializableChatData): IChatSessionEntryMetadata {443const title = session instanceof ChatModel ?444session.customTitle || (session.getRequests().length > 0 ? ChatModel.getDefaultTitle(session.getRequests()) : '') :445session.customTitle ?? (session.requests.length > 0 ? ChatModel.getDefaultTitle(session.requests) : '');446447return {448sessionId: session.sessionId,449title, // Empty string for sessions without content - UI will handle display450lastMessageDate: session.lastMessageDate,451isImported: session.isImported,452initialLocation: session.initialLocation,453isEmpty: session instanceof ChatModel ? session.getRequests().length === 0 : session.requests.length === 0454};455}456457export interface IChatTransfer {458toWorkspace: URI;459timestampInMilliseconds: number;460inputValue: string;461location: ChatAgentLocation;462mode: ChatModeKind;463}464465export interface IChatTransfer2 extends IChatTransfer {466chat: ISerializableChatData;467}468469// type IChatTransferDto = Dto<IChatTransfer>;470471/**472* Map of destination workspace URI to chat transfer data473*/474// type IChatTransferIndex = Record<string, IChatTransferDto>;475476477