226 lines
10 KiB
TypeScript
226 lines
10 KiB
TypeScript
// *****************************************************************************
|
|
// Copyright (C) 2025 EclipseSource GmbH and others.
|
|
//
|
|
// This program and the accompanying materials are made available under the
|
|
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
// http://www.eclipse.org/legal/epl-2.0.
|
|
//
|
|
// This Source Code may also be made available under the following Secondary
|
|
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
// with the GNU Classpath Exception which is available at
|
|
// https://www.gnu.org/software/classpath/license.html.
|
|
//
|
|
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
// *****************************************************************************
|
|
|
|
import { Summary, SummaryMetadata, TaskContextStorageService } from '@theia/ai-chat/lib/browser/task-context-service';
|
|
import { InMemoryTaskContextStorage } from '@theia/ai-chat/lib/browser/task-context-storage-service';
|
|
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
|
import { DisposableCollection, EOL, Emitter, ILogger, Path, PreferenceService, URI, unreachable } from '@theia/core';
|
|
import { OpenerService, open } from '@theia/core/lib/browser';
|
|
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
|
import { WorkspaceService } from '@theia/workspace/lib/browser';
|
|
import * as yaml from 'js-yaml';
|
|
import { FileChange, FileChangeType } from '@theia/filesystem/lib/common/files';
|
|
import { TASK_CONTEXT_STORAGE_DIRECTORY_PREF } from '../common/workspace-preferences';
|
|
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
|
|
|
|
@injectable()
|
|
export class TaskContextFileStorageService implements TaskContextStorageService {
|
|
@inject(InMemoryTaskContextStorage) protected readonly inMemoryStorage: InMemoryTaskContextStorage;
|
|
@inject(PreferenceService) protected readonly preferenceService: PreferenceService;
|
|
@inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
|
|
@inject(FileService) protected readonly fileService: FileService;
|
|
@inject(OpenerService) protected readonly openerService: OpenerService;
|
|
@inject(ILogger) protected readonly logger: ILogger;
|
|
protected readonly onDidChangeEmitter = new Emitter<void>();
|
|
readonly onDidChange = this.onDidChangeEmitter.event;
|
|
|
|
protected sanitizeLabel(label: string): string {
|
|
return label.replace(/^[^\p{L}\p{N}]+/ug, '');
|
|
}
|
|
|
|
protected getStorageLocation(): URI | undefined {
|
|
if (!this.workspaceService.opened) { return; }
|
|
const values = this.preferenceService.inspect(TASK_CONTEXT_STORAGE_DIRECTORY_PREF);
|
|
const configuredPath = values?.globalValue === undefined ? values?.defaultValue : values?.globalValue;
|
|
if (!configuredPath || typeof configuredPath !== 'string') { return; }
|
|
const asPath = new Path(configuredPath);
|
|
return asPath.isAbsolute ? new URI(configuredPath) : this.workspaceService.tryGetRoots().at(0)?.resource.resolve(configuredPath);
|
|
}
|
|
|
|
@postConstruct()
|
|
protected init(): void {
|
|
this.doInit();
|
|
}
|
|
|
|
protected get ready(): Promise<void> {
|
|
return Promise.all([
|
|
this.workspaceService.ready,
|
|
this.preferenceService.ready,
|
|
]).then(() => undefined);
|
|
}
|
|
|
|
protected async doInit(): Promise<void> {
|
|
await this.ready;
|
|
this.watchStorage();
|
|
this.preferenceService.onPreferenceChanged(e => {
|
|
if (e.preferenceName === TASK_CONTEXT_STORAGE_DIRECTORY_PREF) {
|
|
this.watchStorage().catch(error => this.logger.error(error));
|
|
}
|
|
});
|
|
}
|
|
|
|
protected toDisposeOnStorageChange?: DisposableCollection;
|
|
protected async watchStorage(): Promise<void> {
|
|
const newStorage = await this.getStorageLocation();
|
|
this.toDisposeOnStorageChange?.dispose();
|
|
this.toDisposeOnStorageChange = undefined;
|
|
if (!newStorage) { return; }
|
|
this.toDisposeOnStorageChange = new DisposableCollection(
|
|
this.fileService.watch(newStorage, { recursive: true, excludes: [] }),
|
|
this.fileService.onDidFilesChange(event => {
|
|
const relevantChanges = event.changes.filter(candidate => newStorage.isEqualOrParent(candidate.resource));
|
|
this.handleChanges(relevantChanges);
|
|
}),
|
|
{ dispose: () => this.clearInMemoryStorage() },
|
|
);
|
|
this.cacheNewTasks(newStorage).catch(this.logger.error.bind(this.logger));
|
|
}
|
|
|
|
protected async handleChanges(changes: FileChange[]): Promise<void> {
|
|
await Promise.all(changes.map(change => {
|
|
switch (change.type) {
|
|
case FileChangeType.DELETED: return this.deleteFileReference(change.resource);
|
|
case FileChangeType.ADDED:
|
|
case FileChangeType.UPDATED:
|
|
return this.readFile(change.resource);
|
|
default: return unreachable(change.type);
|
|
}
|
|
}));
|
|
}
|
|
|
|
protected clearInMemoryStorage(): void {
|
|
this.inMemoryStorage.clear();
|
|
}
|
|
|
|
protected deleteFileReference(uri: URI): boolean {
|
|
if (this.inMemoryStorage.delete(uri.path.base)) {
|
|
return true;
|
|
}
|
|
for (const summary of this.inMemoryStorage.getAll()) {
|
|
if (summary.uri?.isEqual(uri)) {
|
|
return this.inMemoryStorage.delete(summary.id);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
protected async cacheNewTasks(storageLocation: URI): Promise<void> {
|
|
const contents = await this.fileService.resolve(storageLocation).catch(() => undefined);
|
|
if (!contents?.children?.length) { return; }
|
|
await Promise.all(contents.children.map(child => this.readFile(child.resource)));
|
|
this.onDidChangeEmitter.fire();
|
|
}
|
|
|
|
protected async readFile(uri: URI): Promise<void> {
|
|
const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined);
|
|
if (content === undefined) { return; }
|
|
const { frontmatter, body } = this.maybeReadFrontmatter(content);
|
|
const rawLabel = frontmatter?.label || uri.path.base.slice(0, (-1 * uri.path.ext.length) || uri.path.base.length);
|
|
const summary = {
|
|
...frontmatter,
|
|
summary: body,
|
|
label: this.sanitizeLabel(rawLabel),
|
|
uri,
|
|
id: frontmatter?.id || frontmatter?.sessionId || uri.path.base
|
|
};
|
|
const existingSummary = !frontmatter?.id && summary.sessionId && this.getAll().find(candidate => candidate.sessionId === summary.sessionId);
|
|
if (existingSummary) {
|
|
summary.id = existingSummary.id;
|
|
}
|
|
this.inMemoryStorage.store(summary);
|
|
}
|
|
|
|
async store(summary: Summary): Promise<void> {
|
|
await this.ready;
|
|
const label = this.sanitizeLabel(summary.label);
|
|
const storageLocation = this.getStorageLocation();
|
|
if (storageLocation) {
|
|
const frontmatter = {
|
|
id: summary.id,
|
|
sessionId: summary.sessionId,
|
|
date: new Date().toISOString(),
|
|
label,
|
|
};
|
|
const derivedName = label.trim().replace(/[^\p{L}\p{N}]/ug, '-').replace(/^-+|-+$/g, '');
|
|
const filename = (derivedName.length > 32 ? derivedName.slice(0, derivedName.indexOf('-', 32)) : derivedName) + '.md';
|
|
const content = yaml.dump(frontmatter).trim() + `${EOL}---${EOL}` + summary.summary;
|
|
const uri = storageLocation.resolve(filename);
|
|
summary.uri = uri;
|
|
await this.fileService.writeFile(uri, BinaryBuffer.fromString(content));
|
|
}
|
|
this.inMemoryStorage.store({ ...summary, label });
|
|
this.onDidChangeEmitter.fire();
|
|
}
|
|
|
|
getAll(): Summary[] {
|
|
return this.inMemoryStorage.getAll();
|
|
}
|
|
|
|
async get(identifier: string): Promise<Summary | undefined> {
|
|
const cached = this.inMemoryStorage.get(identifier);
|
|
if (!cached?.uri) {
|
|
return cached;
|
|
}
|
|
// Read fresh content from disk
|
|
const content = await this.fileService.read(cached.uri).then(read => read.value).catch(reason => {
|
|
this.logger.error(`Failed to read file ${cached.uri}: ${reason}`);
|
|
return undefined;
|
|
});
|
|
if (content === undefined) {
|
|
return cached; // Fall back to cache if read fails
|
|
}
|
|
const { body } = this.maybeReadFrontmatter(content);
|
|
return { ...cached, summary: body };
|
|
}
|
|
|
|
async delete(identifier: string): Promise<boolean> {
|
|
const summary = this.inMemoryStorage.get(identifier);
|
|
if (summary?.uri) {
|
|
await this.fileService.delete(summary.uri);
|
|
}
|
|
this.inMemoryStorage.delete(identifier);
|
|
if (summary) {
|
|
this.onDidChangeEmitter.fire();
|
|
}
|
|
return !!summary;
|
|
}
|
|
|
|
protected maybeReadFrontmatter(content: string): { body: string, frontmatter: SummaryMetadata | undefined } {
|
|
const frontmatterEnd = content.indexOf('---');
|
|
if (frontmatterEnd !== -1) {
|
|
try {
|
|
const frontmatter = yaml.load(content.slice(0, frontmatterEnd));
|
|
if (this.hasLabel(frontmatter)) {
|
|
return { frontmatter, body: content.slice(frontmatterEnd + 3).trim() };
|
|
}
|
|
} catch { /* Probably not frontmatter, then. */ }
|
|
}
|
|
return { body: content, frontmatter: undefined };
|
|
}
|
|
|
|
protected hasLabel(candidate: unknown): candidate is SummaryMetadata {
|
|
return !!candidate && typeof candidate === 'object' && !Array.isArray(candidate) && 'label' in candidate && typeof candidate.label === 'string';
|
|
}
|
|
|
|
async open(identifier: string): Promise<void> {
|
|
const summary = await this.get(identifier);
|
|
if (!summary) {
|
|
throw new Error('Unable to open requested task context: none found with specified identifier.');
|
|
}
|
|
await (summary.uri ? open(this.openerService, summary.uri) : this.inMemoryStorage.open(identifier));
|
|
}
|
|
}
|