1130 lines
47 KiB
TypeScript
1130 lines
47 KiB
TypeScript
// *****************************************************************************
|
|
// Copyright (C) 2024 EclipseSource GmbH.
|
|
//
|
|
// 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 { DisposableCollection, URI, Event, Emitter, nls } from '@theia/core';
|
|
import { OpenerService } from '@theia/core/lib/browser';
|
|
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
|
import { PromptFragmentCustomizationService, CustomAgentDescription, CustomizedPromptFragment, CommandPromptFragmentMetadata } from '../common';
|
|
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
|
|
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
|
import { FileChangesEvent } from '@theia/filesystem/lib/common/files';
|
|
import { AICorePreferences, PREFERENCE_NAME_PROMPT_TEMPLATES } from '../common/ai-core-preferences';
|
|
import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
|
|
import { dump, load } from 'js-yaml';
|
|
import { PROMPT_TEMPLATE_EXTENSION } from './prompttemplate-contribution';
|
|
import { parseTemplateWithMetadata, ParsedTemplate } from './prompttemplate-parser';
|
|
|
|
/**
|
|
* Default template entry for creating custom agents
|
|
*/
|
|
const newCustomAgentEntry = {
|
|
id: 'my_agent',
|
|
name: 'My Agent',
|
|
description: nls.localize('theia/ai/core/customAgentTemplate/description', 'This is an example agent. Please adapt the properties to fit your needs.'),
|
|
prompt: `{{!-- Note: The context section below will resolve all context elements (e.g. files) to their full content
|
|
in the system prompt. Context elements can be added by the user in the default chat view (e.g. via DnD or the "+" button).
|
|
If you want a more fine-grained, on demand resolvement of context elements, you can also resolve files to their paths only
|
|
and equip the agent with functions so that the LLM can retrieve files on demand. See the Coder Agent prompt for an example.--}}
|
|
|
|
# Role
|
|
You are an example agent. Be nice and helpful to the user.
|
|
|
|
## Current Context
|
|
Some files and other pieces of data may have been added by the user to the context of the chat. If any have, the details can be found below.
|
|
{{contextDetails}}`,
|
|
defaultLLM: 'openai/gpt-4o'
|
|
};
|
|
|
|
export enum CustomizationSource {
|
|
CUSTOMIZED = 1,
|
|
FOLDER = 2,
|
|
FILE = 3,
|
|
}
|
|
|
|
export function getCustomizationSourceString(origin: CustomizationSource): string {
|
|
switch (origin) {
|
|
case CustomizationSource.FILE:
|
|
return 'Workspace Template Files';
|
|
case CustomizationSource.FOLDER:
|
|
return 'Workspace Template Directories';
|
|
default:
|
|
return 'Prompt Templates Folder';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Interface defining properties that can be updated in the customization service
|
|
*/
|
|
export interface PromptFragmentCustomizationProperties {
|
|
/** Array of directory paths to load templates from */
|
|
directoryPaths?: string[];
|
|
|
|
/** Array of file paths to treat as templates */
|
|
filePaths?: string[];
|
|
|
|
/** Array of file extensions to consider as template files */
|
|
extensions?: string[];
|
|
}
|
|
|
|
/**
|
|
* Internal representation of a fragment entry in the customization service
|
|
* Extends TemplateMetadata to include command-related properties
|
|
*/
|
|
interface PromptFragmentCustomization extends CommandPromptFragmentMetadata {
|
|
/** The template content */
|
|
template: string;
|
|
|
|
/** Source URI where this template is stored */
|
|
sourceUri: string;
|
|
|
|
/** Source type of the customization */
|
|
origin: CustomizationSource;
|
|
|
|
/** Priority level (higher values override lower ones) */
|
|
priority: number;
|
|
|
|
/** Fragment ID */
|
|
id: string;
|
|
|
|
/** Unique customization ID */
|
|
customizationId: string;
|
|
}
|
|
|
|
/**
|
|
* Information about a template file being watched for changes
|
|
*/
|
|
interface WatchedFileInfo {
|
|
/** The URI of the watched file */
|
|
uri: URI;
|
|
|
|
/** The fragment ID associated with this file */
|
|
fragmentId: string;
|
|
|
|
/** The customization ID for this file */
|
|
customizationId: string;
|
|
}
|
|
|
|
@injectable()
|
|
export class DefaultPromptFragmentCustomizationService implements PromptFragmentCustomizationService {
|
|
@inject(EnvVariablesServer)
|
|
protected readonly envVariablesServer: EnvVariablesServer;
|
|
|
|
@inject(AICorePreferences)
|
|
protected readonly preferences: AICorePreferences;
|
|
|
|
@inject(FileService)
|
|
protected readonly fileService: FileService;
|
|
|
|
@inject(OpenerService)
|
|
protected readonly openerService: OpenerService;
|
|
|
|
/** Stores URI strings of template files from directories currently being monitored for changes. */
|
|
protected trackedTemplateURIs = new Set<string>();
|
|
|
|
/** Contains the currently active customization, mapped by prompt fragment ID. */
|
|
protected activeCustomizations = new Map<string, PromptFragmentCustomization>();
|
|
|
|
/** Tracks all loaded customizations, including overridden ones, mapped by source URI. */
|
|
protected allCustomizations = new Map<string, PromptFragmentCustomization>();
|
|
|
|
/** Stores additional directory paths for loading template files. */
|
|
protected additionalTemplateDirs = new Set<string>();
|
|
|
|
/** Contains file extensions that identify prompt template files. */
|
|
protected templateExtensions = new Set<string>([PROMPT_TEMPLATE_EXTENSION]);
|
|
|
|
/** Stores specific file paths, provided by the settings, that should be treated as templates. */
|
|
protected workspaceTemplateFiles = new Set<string>();
|
|
|
|
/** Maps URI strings to WatchedFileInfo objects for individually watched template files. */
|
|
protected watchedFiles = new Map<string, WatchedFileInfo>();
|
|
|
|
/** Collection of disposable resources for cleanup when the service updates or is disposed. */
|
|
protected toDispose = new DisposableCollection();
|
|
|
|
protected readonly onDidChangePromptFragmentCustomizationEmitter = new Emitter<string[]>();
|
|
readonly onDidChangePromptFragmentCustomization: Event<string[]> = this.onDidChangePromptFragmentCustomizationEmitter.event;
|
|
|
|
protected readonly onDidChangeCustomAgentsEmitter = new Emitter<void>();
|
|
readonly onDidChangeCustomAgents: Event<void> = this.onDidChangeCustomAgentsEmitter.event;
|
|
|
|
@postConstruct()
|
|
protected init(): void {
|
|
this.preferences.onPreferenceChanged(event => {
|
|
if (event.preferenceName === PREFERENCE_NAME_PROMPT_TEMPLATES) {
|
|
this.update();
|
|
}
|
|
});
|
|
this.update();
|
|
}
|
|
|
|
/**
|
|
* Updates the service by reloading all template files and watching for changes
|
|
*/
|
|
protected async update(): Promise<void> {
|
|
this.toDispose.dispose();
|
|
// we need to assign local variables, so that updates running in parallel don't interfere with each other
|
|
const activeCustomizationsCopy = new Map<string, PromptFragmentCustomization>();
|
|
const trackedTemplateURIsCopy = new Set<string>();
|
|
const allCustomizationsCopy = new Map<string, PromptFragmentCustomization>();
|
|
const watchedFilesCopy = new Map<string, WatchedFileInfo>();
|
|
|
|
// Process in order of priority (lowest to highest)
|
|
// First process the main templates directory (lowest priority)
|
|
const templatesURI = await this.getTemplatesDirectoryURI();
|
|
await this.processTemplateDirectory(
|
|
activeCustomizationsCopy, trackedTemplateURIsCopy, allCustomizationsCopy, templatesURI, 1, CustomizationSource.CUSTOMIZED); // Priority 1 for customized fragments
|
|
|
|
// Process additional template directories (medium priority)
|
|
for (const dirPath of this.additionalTemplateDirs) {
|
|
const dirURI = URI.fromFilePath(dirPath);
|
|
await this.processTemplateDirectory(
|
|
activeCustomizationsCopy, trackedTemplateURIsCopy, allCustomizationsCopy, dirURI, 2, CustomizationSource.FOLDER); // Priority 2 for folder fragments
|
|
}
|
|
|
|
// Process specific template files (highest priority)
|
|
await this.processTemplateFiles(activeCustomizationsCopy, trackedTemplateURIsCopy, allCustomizationsCopy, watchedFilesCopy);
|
|
|
|
this.activeCustomizations = activeCustomizationsCopy;
|
|
this.trackedTemplateURIs = trackedTemplateURIsCopy;
|
|
this.allCustomizations = allCustomizationsCopy;
|
|
this.watchedFiles = watchedFilesCopy;
|
|
|
|
this.onDidChangeCustomAgentsEmitter.fire();
|
|
}
|
|
|
|
/**
|
|
* Adds a template to the customizations map, handling conflicts based on priority
|
|
* @param activeCustomizationsCopy The map to add the customization to
|
|
* @param id The fragment ID
|
|
* @param template The template content
|
|
* @param sourceUri The URI of the source file (used to distinguish updates from conflicts)
|
|
* @param allCustomizationsCopy The map to track all loaded customizations
|
|
* @param priority The customization priority
|
|
* @param origin The source type of the customization
|
|
* @param metadata Optional command metadata
|
|
*/
|
|
protected addTemplate(
|
|
activeCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
id: string,
|
|
template: string,
|
|
sourceUri: string,
|
|
allCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
priority: number,
|
|
origin: CustomizationSource,
|
|
metadata?: CommandPromptFragmentMetadata
|
|
): void {
|
|
// Generate a unique customization ID based on source URI and priority
|
|
const customizationId = this.generateCustomizationId(id, sourceUri);
|
|
|
|
// Create customization object with metadata
|
|
const customization: PromptFragmentCustomization = {
|
|
id,
|
|
template,
|
|
sourceUri,
|
|
priority,
|
|
customizationId,
|
|
origin,
|
|
...(metadata && {
|
|
isCommand: metadata.isCommand,
|
|
commandName: metadata.commandName,
|
|
commandDescription: metadata.commandDescription,
|
|
commandArgumentHint: metadata.commandArgumentHint,
|
|
commandAgents: metadata.commandAgents,
|
|
})
|
|
};
|
|
|
|
// Always add to allCustomizationsCopy to keep track of all customizations including overridden ones
|
|
if (sourceUri) {
|
|
allCustomizationsCopy.set(sourceUri, customization);
|
|
}
|
|
|
|
const existingEntry = activeCustomizationsCopy.get(id);
|
|
|
|
if (existingEntry) {
|
|
// If this is an update to the same file (same source URI)
|
|
if (sourceUri && existingEntry.sourceUri === sourceUri) {
|
|
// Update the content while keeping the same priority and source
|
|
activeCustomizationsCopy.set(id, customization);
|
|
return;
|
|
}
|
|
|
|
// If the new customization has higher priority, replace the existing one
|
|
if (priority > existingEntry.priority) {
|
|
activeCustomizationsCopy.set(id, customization);
|
|
return;
|
|
} else if (priority === existingEntry.priority) {
|
|
// There is a conflict with the same priority, we ignore the new customization
|
|
const conflictSourceUri = existingEntry.sourceUri ? ` (Existing source: ${existingEntry.sourceUri}, New source: ${sourceUri})` : '';
|
|
console.warn(`Fragment conflict detected for ID '${id}' with equal priority.${conflictSourceUri}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// No conflict at all, add the customization
|
|
activeCustomizationsCopy.set(id, customization);
|
|
}
|
|
|
|
/**
|
|
* Generates a unique customization ID based on the fragment ID, source URI, and priority
|
|
* @param id The fragment ID
|
|
* @param sourceUri The source URI of the template
|
|
* @returns A unique customization ID
|
|
*/
|
|
protected generateCustomizationId(id: string, sourceUri: string): string {
|
|
// Create a customization ID that contains information about the source and priority
|
|
// This ensures uniqueness across different customization sources
|
|
const sourceHash = this.hashString(sourceUri);
|
|
return `${id}_${sourceHash}`;
|
|
}
|
|
|
|
/**
|
|
* Simple hash function to generate a short identifier from a string
|
|
* @param str The string to hash
|
|
* @returns A string hash
|
|
*/
|
|
protected hashString(str: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
const char = str.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
return Math.abs(hash).toString(36).substring(0, 8);
|
|
}
|
|
|
|
/**
|
|
* Parses a template file that may contain YAML front matter
|
|
* @param fileContent The raw file content
|
|
* @returns Parsed metadata and template content
|
|
*/
|
|
protected parseTemplateWithMetadata(fileContent: string): ParsedTemplate {
|
|
return parseTemplateWithMetadata(fileContent);
|
|
}
|
|
|
|
/**
|
|
* Removes a customization from customizations maps based on the source URI.
|
|
* Also checks for any lower-priority customizations with the same ID that might need to be loaded.
|
|
* @param sourceUri The URI of the source file being removed
|
|
* @param allCustomizationsCopy The map of all loaded customizations
|
|
* @param activeCustomizationsCopy The map of active customizations
|
|
* @param trackedTemplateURIsCopy Optional set of tracked URIs to update
|
|
* @returns The fragment ID that was removed, or undefined if no customization was found
|
|
*/
|
|
protected removeCustomizationFromMaps(
|
|
sourceUri: string,
|
|
allCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
activeCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
trackedTemplateURIsCopy: Set<string>
|
|
): string | undefined {
|
|
// Get the customization entry from allCustomizationsCopy
|
|
const removedCustomization = allCustomizationsCopy.get(sourceUri);
|
|
if (!removedCustomization) {
|
|
return undefined;
|
|
}
|
|
const fragmentId = removedCustomization.id;
|
|
allCustomizationsCopy.delete(sourceUri);
|
|
trackedTemplateURIsCopy.delete(sourceUri);
|
|
|
|
// If the customization is in the active customizations map, we check if there is another customization previously conflicting with it
|
|
const activeCustomization = activeCustomizationsCopy.get(fragmentId);
|
|
if (activeCustomization && activeCustomization.sourceUri === sourceUri) {
|
|
activeCustomizationsCopy.delete(fragmentId);
|
|
// Find any lower-priority customizations with the same ID that were previously ignored
|
|
const lowerPriorityCustomizations = Array.from(allCustomizationsCopy.values())
|
|
.filter(t => t.id === fragmentId)
|
|
.sort((a, b) => b.priority - a.priority); // Sort by priority (highest first)
|
|
|
|
// If there are any lower-priority customizations, add the highest priority one
|
|
if (lowerPriorityCustomizations.length > 0) {
|
|
const highestRemainingCustomization = lowerPriorityCustomizations[0];
|
|
activeCustomizationsCopy.set(fragmentId, highestRemainingCustomization);
|
|
}
|
|
|
|
}
|
|
|
|
return fragmentId;
|
|
}
|
|
|
|
/**
|
|
* Process the template files specified by path, watching for changes
|
|
* and loading their content into the customizations map
|
|
* @param activeCustomizationsCopy Map to store active customizations
|
|
* @param trackedTemplateURIsCopy Set to track URIs being monitored
|
|
* @param allCustomizationsCopy Map to store all loaded customizations
|
|
* @param watchedFilesCopy Map to store file watch information
|
|
*/
|
|
protected async processTemplateFiles(
|
|
activeCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
trackedTemplateURIsCopy: Set<string>,
|
|
allCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
watchedFilesCopy: Map<string, WatchedFileInfo>
|
|
): Promise<void> {
|
|
const priority = 3; // Highest priority for specific files
|
|
|
|
const parsedPromptFragments = new Set<string>();
|
|
|
|
for (const filePath of this.workspaceTemplateFiles) {
|
|
const fileURI = URI.fromFilePath(filePath);
|
|
const fragmentId = this.getFragmentIdFromFilePath(filePath);
|
|
const uriString = fileURI.toString();
|
|
const customizationId = this.generateCustomizationId(fragmentId, uriString);
|
|
|
|
watchedFilesCopy.set(uriString, { uri: fileURI, fragmentId, customizationId });
|
|
this.toDispose.push(this.fileService.watch(fileURI, { recursive: false, excludes: [] }));
|
|
|
|
if (await this.fileService.exists(fileURI)) {
|
|
trackedTemplateURIsCopy.add(uriString);
|
|
const fileContent = await this.fileService.read(fileURI);
|
|
const parsed = this.parseTemplateWithMetadata(fileContent.value);
|
|
this.addTemplate(activeCustomizationsCopy, fragmentId, parsed.template, uriString, allCustomizationsCopy, priority, CustomizationSource.FILE, parsed.metadata);
|
|
parsedPromptFragments.add(fragmentId);
|
|
}
|
|
}
|
|
|
|
this.onDidChangePromptFragmentCustomizationEmitter.fire(Array.from(parsedPromptFragments));
|
|
|
|
this.toDispose.push(this.fileService.onDidFilesChange(async (event: FileChangesEvent) => {
|
|
// Only watch for changes that are in the watchedFiles map
|
|
if (!event.changes.some(change => this.watchedFiles.get(change.resource.toString()))) {
|
|
return;
|
|
}
|
|
// Track changes for batched notification
|
|
const changedFragmentIds = new Set<string>();
|
|
|
|
// Handle deleted files
|
|
for (const deletedFile of event.getDeleted()) {
|
|
const fileUriString = deletedFile.resource.toString();
|
|
const fileInfo = this.watchedFiles.get(fileUriString);
|
|
|
|
if (fileInfo) {
|
|
const removedFragmentId = this.removeCustomizationFromMaps(fileUriString, allCustomizationsCopy, activeCustomizationsCopy, trackedTemplateURIsCopy);
|
|
if (removedFragmentId) {
|
|
changedFragmentIds.add(removedFragmentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle updated files
|
|
for (const updatedFile of event.getUpdated()) {
|
|
const fileUriString = updatedFile.resource.toString();
|
|
const fileInfo = this.watchedFiles.get(fileUriString);
|
|
|
|
if (fileInfo) {
|
|
const fileContent = await this.fileService.read(fileInfo.uri);
|
|
const parsed = this.parseTemplateWithMetadata(fileContent.value);
|
|
this.addTemplate(
|
|
this.activeCustomizations,
|
|
fileInfo.fragmentId,
|
|
parsed.template,
|
|
fileUriString,
|
|
this.allCustomizations,
|
|
priority,
|
|
CustomizationSource.FILE,
|
|
parsed.metadata
|
|
);
|
|
changedFragmentIds.add(fileInfo.fragmentId);
|
|
}
|
|
}
|
|
|
|
// Handle newly created files
|
|
for (const addedFile of event.getAdded()) {
|
|
const fileUriString = addedFile.resource.toString();
|
|
const fileInfo = this.watchedFiles.get(fileUriString);
|
|
|
|
if (fileInfo) {
|
|
const fileContent = await this.fileService.read(fileInfo.uri);
|
|
const parsed = this.parseTemplateWithMetadata(fileContent.value);
|
|
this.addTemplate(
|
|
this.activeCustomizations,
|
|
fileInfo.fragmentId,
|
|
parsed.template,
|
|
fileUriString,
|
|
this.allCustomizations,
|
|
priority,
|
|
CustomizationSource.FILE,
|
|
parsed.metadata
|
|
);
|
|
this.trackedTemplateURIs.add(fileUriString);
|
|
changedFragmentIds.add(fileInfo.fragmentId);
|
|
}
|
|
}
|
|
|
|
const changedFragmentIdsArray = Array.from(changedFragmentIds);
|
|
if (changedFragmentIdsArray.length > 0) {
|
|
this.onDidChangePromptFragmentCustomizationEmitter.fire(changedFragmentIdsArray);
|
|
};
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Extract a fragment ID from a file path
|
|
* @param filePath The path to the template file
|
|
* @returns A fragment ID derived from the file name
|
|
*/
|
|
protected getFragmentIdFromFilePath(filePath: string): string {
|
|
const uri = URI.fromFilePath(filePath);
|
|
return this.removePromptTemplateSuffix(uri.path.name);
|
|
}
|
|
|
|
/**
|
|
* Processes a directory for template files, adding them to the customizations map
|
|
* and setting up file watching
|
|
* @param activeCustomizationsCopy Map to store active customizations
|
|
* @param trackedTemplateURIsCopy Set to track URIs being monitored
|
|
* @param allCustomizationsCopy Map to store all loaded customizations
|
|
* @param dirURI URI of the directory to process
|
|
* @param priority Priority level for customizations in this directory
|
|
* @param customizationSource Source type of the customization
|
|
*/
|
|
protected async processTemplateDirectory(
|
|
activeCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
trackedTemplateURIsCopy: Set<string>,
|
|
allCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
dirURI: URI,
|
|
priority: number,
|
|
customizationSource: CustomizationSource
|
|
): Promise<void> {
|
|
const dirExists = await this.fileService.exists(dirURI);
|
|
|
|
// Process existing files if directory exists
|
|
if (dirExists) {
|
|
await this.processExistingTemplateDirectory(
|
|
activeCustomizationsCopy,
|
|
trackedTemplateURIsCopy,
|
|
allCustomizationsCopy,
|
|
dirURI,
|
|
priority,
|
|
customizationSource
|
|
);
|
|
}
|
|
|
|
// Set up file watching for the directory (works for both existing and non-existing directories)
|
|
this.setupDirectoryWatcher(dirURI, priority, customizationSource);
|
|
}
|
|
|
|
/**
|
|
* Processes an existing directory for template files
|
|
* @param activeCustomizationsCopy Map to store active customizations
|
|
* @param trackedTemplateURIsCopy Set to track URIs being monitored
|
|
* @param allCustomizationsCopy Map to store all loaded customizations
|
|
* @param dirURI URI of the directory to process
|
|
* @param priority Priority level for customizations in this directory
|
|
* @param customizationSource Source type of the customization
|
|
*/
|
|
protected async processExistingTemplateDirectory(
|
|
activeCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
trackedTemplateURIsCopy: Set<string>,
|
|
allCustomizationsCopy: Map<string, PromptFragmentCustomization>,
|
|
dirURI: URI,
|
|
priority: number,
|
|
customizationSource: CustomizationSource
|
|
): Promise<void> {
|
|
const stat = await this.fileService.resolve(dirURI);
|
|
if (stat.children === undefined) {
|
|
return;
|
|
}
|
|
|
|
const parsedPromptFragments = new Set<string>();
|
|
for (const file of stat.children) {
|
|
if (!file.isFile) {
|
|
continue;
|
|
}
|
|
const fileURI = file.resource;
|
|
if (this.isPromptTemplateExtension(fileURI.path.ext)) {
|
|
trackedTemplateURIsCopy.add(fileURI.toString());
|
|
const fileContent = await this.fileService.read(fileURI);
|
|
const parsed = this.parseTemplateWithMetadata(fileContent.value);
|
|
const fragmentId = this.removePromptTemplateSuffix(file.name);
|
|
this.addTemplate(activeCustomizationsCopy, fragmentId, parsed.template, fileURI.toString(), allCustomizationsCopy, priority, customizationSource, parsed.metadata);
|
|
parsedPromptFragments.add(fragmentId);
|
|
}
|
|
}
|
|
this.onDidChangePromptFragmentCustomizationEmitter.fire(Array.from(parsedPromptFragments));
|
|
this.onDidChangeCustomAgentsEmitter.fire();
|
|
}
|
|
|
|
/**
|
|
* Sets up file watching for a template directory (works for both existing and non-existing directories)
|
|
* @param dirURI URI of the directory to watch
|
|
* @param priority Priority level for customizations in this directory
|
|
* @param customizationSource Source type of the customization
|
|
*/
|
|
protected setupDirectoryWatcher(
|
|
dirURI: URI,
|
|
priority: number,
|
|
customizationSource: CustomizationSource
|
|
): void {
|
|
this.toDispose.push(this.fileService.watch(dirURI, { recursive: true, excludes: [] }));
|
|
this.toDispose.push(this.fileService.onDidFilesChange(async (event: FileChangesEvent) => {
|
|
// Filter for changes within the watched directory
|
|
if (!event.changes.some(change => change.resource.toString().startsWith(dirURI.toString()))) {
|
|
return;
|
|
}
|
|
|
|
// Handle directory creation or deletion (when watching a previously non-existent directory)
|
|
if (event.getAdded().some(addedFile => addedFile.resource.toString() === dirURI.toString()) ||
|
|
event.getDeleted().some(deletedFile => deletedFile.resource.toString() === dirURI.toString())) {
|
|
// Directory was created or deleted, restart the update process to handle the change
|
|
await this.update();
|
|
return;
|
|
}
|
|
|
|
if (event.changes.some(change => change.resource.toString().endsWith('customAgents.yml'))) {
|
|
this.onDidChangeCustomAgentsEmitter.fire();
|
|
}
|
|
|
|
// Track changes for batched notification
|
|
const changedFragmentIds = new Set<string>();
|
|
|
|
// Handle deleted templates
|
|
for (const deletedFile of event.getDeleted()) {
|
|
const uriString = deletedFile.resource.toString();
|
|
if (this.trackedTemplateURIs.has(uriString)) {
|
|
const removedFragmentId = this.removeCustomizationFromMaps(
|
|
uriString,
|
|
this.allCustomizations,
|
|
this.activeCustomizations,
|
|
this.trackedTemplateURIs
|
|
);
|
|
if (removedFragmentId) {
|
|
changedFragmentIds.add(removedFragmentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle updated templates
|
|
for (const updatedFile of event.getUpdated()) {
|
|
const uriString = updatedFile.resource.toString();
|
|
if (this.trackedTemplateURIs.has(uriString)) {
|
|
const fileContent = await this.fileService.read(updatedFile.resource);
|
|
const parsed = this.parseTemplateWithMetadata(fileContent.value);
|
|
const fragmentId = this.removePromptTemplateSuffix(updatedFile.resource.path.name);
|
|
this.addTemplate(
|
|
this.activeCustomizations,
|
|
fragmentId,
|
|
parsed.template,
|
|
uriString,
|
|
this.allCustomizations,
|
|
priority,
|
|
customizationSource,
|
|
parsed.metadata
|
|
);
|
|
changedFragmentIds.add(fragmentId);
|
|
}
|
|
}
|
|
|
|
// Handle new templates
|
|
for (const addedFile of event.getAdded()) {
|
|
if (addedFile.resource.parent.toString() === dirURI.toString() &&
|
|
this.isPromptTemplateExtension(addedFile.resource.path.ext)) {
|
|
const uriString = addedFile.resource.toString();
|
|
this.trackedTemplateURIs.add(uriString);
|
|
const fileContent = await this.fileService.read(addedFile.resource);
|
|
const parsed = this.parseTemplateWithMetadata(fileContent.value);
|
|
const fragmentId = this.removePromptTemplateSuffix(addedFile.resource.path.name);
|
|
this.addTemplate(
|
|
this.activeCustomizations,
|
|
fragmentId,
|
|
parsed.template,
|
|
uriString,
|
|
this.allCustomizations,
|
|
priority,
|
|
customizationSource,
|
|
parsed.metadata
|
|
);
|
|
changedFragmentIds.add(fragmentId);
|
|
}
|
|
}
|
|
|
|
const changedFragmentIdsArray = Array.from(changedFragmentIds);
|
|
if (changedFragmentIdsArray.length > 0) {
|
|
this.onDidChangePromptFragmentCustomizationEmitter.fire(changedFragmentIdsArray);
|
|
}
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Checks if the given file extension is registered as a prompt template extension
|
|
* @param extension The file extension including the leading dot (e.g., '.prompttemplate')
|
|
* @returns True if the extension is registered as a prompt template extension
|
|
*/
|
|
protected isPromptTemplateExtension(extension: string): boolean {
|
|
return this.templateExtensions.has(extension);
|
|
}
|
|
|
|
/**
|
|
* Gets the list of additional template directories that are being watched.
|
|
* @returns Array of directory paths
|
|
*/
|
|
getAdditionalTemplateDirectories(): string[] {
|
|
return Array.from(this.additionalTemplateDirs);
|
|
}
|
|
|
|
/**
|
|
* Gets the list of file extensions that are considered prompt templates.
|
|
* @returns Array of file extensions including the leading dot (e.g., '.prompttemplate')
|
|
*/
|
|
getTemplateFileExtensions(): string[] {
|
|
return Array.from(this.templateExtensions);
|
|
}
|
|
|
|
/**
|
|
* Gets the list of specific template files that are being watched.
|
|
* @returns Array of file paths
|
|
*/
|
|
getTemplateFiles(): string[] {
|
|
return Array.from(this.workspaceTemplateFiles);
|
|
}
|
|
|
|
/**
|
|
* Updates multiple configuration properties at once, triggering only a single update process.
|
|
* @param properties An object containing the properties to update
|
|
* @returns Promise that resolves when the update is complete
|
|
*/
|
|
async updateConfiguration(properties: PromptFragmentCustomizationProperties): Promise<void> {
|
|
if (properties.directoryPaths !== undefined) {
|
|
this.additionalTemplateDirs.clear();
|
|
for (const path of properties.directoryPaths) {
|
|
this.additionalTemplateDirs.add(path);
|
|
}
|
|
}
|
|
|
|
if (properties.extensions !== undefined) {
|
|
this.templateExtensions.clear();
|
|
for (const ext of properties.extensions) {
|
|
this.templateExtensions.add(ext);
|
|
}
|
|
// Always include the default PROMPT_TEMPLATE_EXTENSION
|
|
this.templateExtensions.add(PROMPT_TEMPLATE_EXTENSION);
|
|
}
|
|
|
|
if (properties.filePaths !== undefined) {
|
|
this.workspaceTemplateFiles.clear();
|
|
for (const path of properties.filePaths) {
|
|
this.workspaceTemplateFiles.add(path);
|
|
}
|
|
}
|
|
|
|
// Only run the update process once, no matter how many properties were changed
|
|
await this.update();
|
|
}
|
|
|
|
/**
|
|
* Gets the URI of the templates directory
|
|
* @returns URI of the templates directory
|
|
*/
|
|
protected async getTemplatesDirectoryURI(): Promise<URI> {
|
|
const templatesFolder = this.preferences[PREFERENCE_NAME_PROMPT_TEMPLATES];
|
|
if (templatesFolder && templatesFolder.trim().length > 0) {
|
|
return URI.fromFilePath(templatesFolder);
|
|
}
|
|
const theiaConfigDir = await this.envVariablesServer.getConfigDirUri();
|
|
return new URI(theiaConfigDir).resolve('prompt-templates');
|
|
}
|
|
|
|
/**
|
|
* Gets the URI for a specific template file
|
|
* @param fragmentId The fragment ID
|
|
* @returns URI for the template file
|
|
*/
|
|
protected async getTemplateURI(fragmentId: string): Promise<URI> {
|
|
return (await this.getTemplatesDirectoryURI()).resolve(`${fragmentId}${PROMPT_TEMPLATE_EXTENSION}`);
|
|
}
|
|
|
|
/**
|
|
* Removes the prompt template extension from a filename
|
|
* @param filename The filename with extension
|
|
* @returns The filename without the extension
|
|
*/
|
|
protected removePromptTemplateSuffix(filename: string): string {
|
|
for (const ext of this.templateExtensions) {
|
|
if (filename.endsWith(ext)) {
|
|
return filename.slice(0, -ext.length);
|
|
}
|
|
}
|
|
return filename;
|
|
}
|
|
|
|
// PromptFragmentCustomizationService interface implementation
|
|
|
|
isPromptFragmentCustomized(id: string): boolean {
|
|
return this.activeCustomizations.has(id);
|
|
}
|
|
|
|
getActivePromptFragmentCustomization(id: string): CustomizedPromptFragment | undefined {
|
|
const entry = this.activeCustomizations.get(id);
|
|
if (!entry) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
id: entry.id,
|
|
template: entry.template,
|
|
customizationId: entry.customizationId,
|
|
priority: entry.priority,
|
|
// Pass through command metadata
|
|
isCommand: entry.isCommand,
|
|
commandName: entry.commandName,
|
|
commandDescription: entry.commandDescription,
|
|
commandArgumentHint: entry.commandArgumentHint,
|
|
commandAgents: entry.commandAgents,
|
|
};
|
|
}
|
|
|
|
getAllCustomizations(id: string): CustomizedPromptFragment[] {
|
|
const fragments: CustomizedPromptFragment[] = [];
|
|
|
|
// Collect all customizations with matching ID
|
|
this.allCustomizations.forEach(value => {
|
|
if (value.id === id) {
|
|
fragments.push({
|
|
id: value.id,
|
|
template: value.template,
|
|
customizationId: value.customizationId,
|
|
priority: value.priority,
|
|
// Pass through command metadata
|
|
isCommand: value.isCommand,
|
|
commandName: value.commandName,
|
|
commandDescription: value.commandDescription,
|
|
commandArgumentHint: value.commandArgumentHint,
|
|
commandAgents: value.commandAgents,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Sort by priority (highest first)
|
|
return fragments.sort((a, b) => b.priority - a.priority);
|
|
}
|
|
|
|
getCustomizedPromptFragmentIds(): string[] {
|
|
return Array.from(this.activeCustomizations.keys());
|
|
}
|
|
|
|
async createPromptFragmentCustomization(id: string, defaultContent?: string): Promise<void> {
|
|
await this.editTemplate(id, defaultContent);
|
|
}
|
|
|
|
async createBuiltInPromptFragmentCustomization(id: string, defaultContent?: string): Promise<void> {
|
|
await this.createPromptFragmentCustomization(id, defaultContent);
|
|
}
|
|
|
|
async editPromptFragmentCustomization(id: string, customizationId: string): Promise<void> {
|
|
// Find the customization with the given customization ID
|
|
const customization = Array.from(this.allCustomizations.values()).find(t =>
|
|
t.id === id && t.customizationId === customizationId
|
|
);
|
|
|
|
if (customization) {
|
|
const uri = new URI(customization.sourceUri);
|
|
const openHandler = await this.openerService.getOpener(uri);
|
|
openHandler.open(uri);
|
|
} else {
|
|
// Fall back to editing by fragment ID if customization ID not found
|
|
await this.editTemplate(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Edits a template by opening it in the editor, creating it if it doesn't exist
|
|
* @param id The fragment ID
|
|
* @param defaultContent Optional default content for new templates
|
|
*/
|
|
protected async editTemplate(id: string, defaultContent?: string): Promise<void> {
|
|
const editorUri = await this.getTemplateURI(id);
|
|
if (!(await this.fileService.exists(editorUri))) {
|
|
await this.fileService.createFile(editorUri, BinaryBuffer.fromString(defaultContent ?? ''));
|
|
}
|
|
const openHandler = await this.openerService.getOpener(editorUri);
|
|
openHandler.open(editorUri);
|
|
}
|
|
|
|
async removePromptFragmentCustomization(id: string, customizationId: string): Promise<void> {
|
|
// Find the customization with the given customization ID
|
|
const customization = Array.from(this.allCustomizations.values()).find(t =>
|
|
t.id === id && t.customizationId === customizationId
|
|
);
|
|
|
|
if (customization) {
|
|
const sourceUri = customization.sourceUri;
|
|
|
|
// Delete the file if it exists
|
|
const uri = new URI(sourceUri);
|
|
if (await this.fileService.exists(uri)) {
|
|
await this.fileService.delete(uri);
|
|
}
|
|
}
|
|
}
|
|
|
|
async removeAllPromptFragmentCustomizations(id: string): Promise<void> {
|
|
// Get all customizations for this fragment ID
|
|
const customizations = this.getAllCustomizations(id);
|
|
|
|
if (customizations.length === 0) {
|
|
return; // Nothing to reset
|
|
}
|
|
|
|
// Find and delete all customization files
|
|
for (const customization of customizations) {
|
|
const fragment = Array.from(this.allCustomizations.values()).find(t =>
|
|
t.id === id && t.customizationId === customization.customizationId
|
|
);
|
|
|
|
if (fragment) {
|
|
const sourceUri = fragment.sourceUri;
|
|
// Delete the file if it exists
|
|
const uri = new URI(sourceUri);
|
|
if (await this.fileService.exists(uri)) {
|
|
await this.fileService.delete(uri);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async resetToCustomization(id: string, customizationId: string): Promise<void> {
|
|
const customization = Array.from(this.allCustomizations.values()).find(t =>
|
|
t.id === id && t.customizationId === customizationId
|
|
);
|
|
|
|
if (customization) {
|
|
// Get all customizations for this fragment ID
|
|
const customizations = this.getAllCustomizations(id);
|
|
|
|
if (customizations.length === 0) {
|
|
return; // Nothing to reset
|
|
}
|
|
|
|
// Find the target customization
|
|
const targetCustomization = customizations.find(c => c.customizationId === customizationId);
|
|
if (!targetCustomization) {
|
|
return; // Target customization not found
|
|
}
|
|
|
|
// Find and delete all higher-priority customization files
|
|
for (const cust of customizations) {
|
|
if (cust.priority > targetCustomization.priority) {
|
|
const fragmentToDelete = Array.from(this.allCustomizations.values()).find(t =>
|
|
t.id === cust.id && t.customizationId === cust.customizationId
|
|
);
|
|
if (fragmentToDelete) {
|
|
const sourceUri = fragmentToDelete.sourceUri;
|
|
|
|
// Delete the file if it exists
|
|
const uri = new URI(sourceUri);
|
|
if (await this.fileService.exists(uri)) {
|
|
await this.fileService.delete(uri);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async getPromptFragmentCustomizationDescription(id: string, customizationId: string): Promise<string | undefined> {
|
|
// Find the customization with the given customization ID
|
|
const customization = Array.from(this.allCustomizations.values()).find(t =>
|
|
t.id === id && t.customizationId === customizationId
|
|
);
|
|
|
|
if (customization) {
|
|
return customization.sourceUri;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async getPromptFragmentCustomizationType(id: string, customizationId: string): Promise<string | undefined> {
|
|
// Find the customization with the given customization ID
|
|
const customization = Array.from(this.allCustomizations.values()).find(t =>
|
|
t.id === id && t.customizationId === customizationId
|
|
);
|
|
|
|
if (customization) {
|
|
return getCustomizationSourceString(customization.origin);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async editBuiltIn(id: string, defaultContent = ''): Promise<void> {
|
|
// Find an existing built-in customization (those with priority 1)
|
|
const builtInCustomization = Array.from(this.allCustomizations.values()).find(t =>
|
|
t.id === id && t.priority === 1
|
|
);
|
|
|
|
if (builtInCustomization) {
|
|
// Edit the existing built-in customization
|
|
const uri = new URI(builtInCustomization.sourceUri);
|
|
const openHandler = await this.openerService.getOpener(uri);
|
|
openHandler.open(uri);
|
|
} else {
|
|
// Create a new built-in customization
|
|
// Get the template URI in the main templates directory (priority 1)
|
|
const templateUri = await this.getTemplateURI(id);
|
|
|
|
// If template doesn't exist, create it with default content
|
|
if (!(await this.fileService.exists(templateUri))) {
|
|
await this.fileService.createFile(templateUri, BinaryBuffer.fromString(defaultContent));
|
|
}
|
|
|
|
// Open the template in the editor
|
|
const openHandler = await this.openerService.getOpener(templateUri);
|
|
openHandler.open(templateUri);
|
|
}
|
|
}
|
|
|
|
async resetBuiltInCustomization(id: string): Promise<void> {
|
|
// Find a built-in customization (those with priority 1)
|
|
const builtInCustomization = Array.from(this.allCustomizations.values()).find(t =>
|
|
t.id === id && t.priority === 1
|
|
);
|
|
|
|
if (!builtInCustomization) {
|
|
return; // No built-in customization found
|
|
}
|
|
|
|
const sourceUri = builtInCustomization.sourceUri;
|
|
|
|
// Delete the file if it exists
|
|
const uri = new URI(sourceUri);
|
|
if (await this.fileService.exists(uri)) {
|
|
await this.fileService.delete(uri);
|
|
}
|
|
}
|
|
|
|
async editBuiltInPromptFragmentCustomization(id: string, defaultContent?: string): Promise<void> {
|
|
return this.editBuiltIn(id, defaultContent);
|
|
}
|
|
|
|
/**
|
|
* Gets the fragment ID from a URI
|
|
* @param uri URI to check
|
|
* @returns Fragment ID or undefined if not found
|
|
*/
|
|
protected getFragmentIDFromURI(uri: URI): string | undefined {
|
|
const id = this.removePromptTemplateSuffix(uri.path.name);
|
|
if (this.activeCustomizations.has(id)) {
|
|
return id;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Implementation of the generic getPromptFragmentIDFromResource method in the interface
|
|
* Accepts any resource identifier but only processes URIs
|
|
* @param resourceId Resource to check
|
|
* @returns Fragment ID or undefined if not found
|
|
*/
|
|
getPromptFragmentIDFromResource(resourceId: unknown): string | undefined {
|
|
// Check if the resource is a URI
|
|
if (resourceId instanceof URI) {
|
|
return this.getFragmentIDFromURI(resourceId);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async getCustomAgents(): Promise<CustomAgentDescription[]> {
|
|
const agentsById = new Map<string, CustomAgentDescription>();
|
|
// First, process additional (workspace) template directories to give them precedence
|
|
for (const dirPath of this.additionalTemplateDirs) {
|
|
const dirURI = URI.fromFilePath(dirPath);
|
|
await this.loadCustomAgentsFromDirectory(dirURI, agentsById);
|
|
}
|
|
// Then process global templates directory (only adding agents that don't conflict)
|
|
const globalTemplatesDir = await this.getTemplatesDirectoryURI();
|
|
await this.loadCustomAgentsFromDirectory(globalTemplatesDir, agentsById);
|
|
// Return the merged list of agents
|
|
return Array.from(agentsById.values());
|
|
}
|
|
|
|
/**
|
|
* Load custom agents from a specific directory
|
|
* @param directoryURI The URI of the directory to load from
|
|
* @param agentsById Map to store the loaded agents by ID
|
|
*/
|
|
protected async loadCustomAgentsFromDirectory(
|
|
directoryURI: URI,
|
|
agentsById: Map<string, CustomAgentDescription>
|
|
): Promise<void> {
|
|
const customAgentYamlUri = directoryURI.resolve('customAgents.yml');
|
|
const yamlExists = await this.fileService.exists(customAgentYamlUri);
|
|
if (!yamlExists) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const fileContent = await this.fileService.read(customAgentYamlUri, { encoding: 'utf-8' });
|
|
const doc = load(fileContent.value);
|
|
|
|
if (!Array.isArray(doc) || !doc.every(entry => CustomAgentDescription.is(entry))) {
|
|
console.debug(`Invalid customAgents.yml file content in ${directoryURI.toString()}`);
|
|
return;
|
|
}
|
|
|
|
const readAgents = doc as CustomAgentDescription[];
|
|
|
|
// Add agents to the map if they don't already exist
|
|
for (const agent of readAgents) {
|
|
if (!agentsById.has(agent.id)) {
|
|
agentsById.set(agent.id, agent);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug(`Error loading customAgents.yml from ${directoryURI.toString()}: ${e.message}`, e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns all locations of existing customAgents.yml files and potential locations where
|
|
* new customAgents.yml files could be created.
|
|
*
|
|
* @returns An array of objects containing the URI and whether the file exists
|
|
*/
|
|
async getCustomAgentsLocations(): Promise<{ uri: URI, exists: boolean }[]> {
|
|
const locations: { uri: URI, exists: boolean }[] = [];
|
|
// Check global templates directory
|
|
const globalTemplatesDir = await this.getTemplatesDirectoryURI();
|
|
const globalAgentsUri = globalTemplatesDir.resolve('customAgents.yml');
|
|
const globalExists = await this.fileService.exists(globalAgentsUri);
|
|
locations.push({ uri: globalAgentsUri, exists: globalExists });
|
|
// Check additional (workspace) template directories
|
|
for (const dirPath of this.additionalTemplateDirs) {
|
|
const dirURI = URI.fromFilePath(dirPath);
|
|
const agentsUri = dirURI.resolve('customAgents.yml');
|
|
const exists = await this.fileService.exists(agentsUri);
|
|
locations.push({ uri: agentsUri, exists: exists });
|
|
}
|
|
return locations;
|
|
}
|
|
|
|
/**
|
|
* Opens an existing customAgents.yml file at the given URI, or creates a new one if it doesn't exist.
|
|
*
|
|
* @param uri The URI of the customAgents.yml file to open or create
|
|
*/
|
|
async openCustomAgentYaml(uri: URI): Promise<void> {
|
|
const content = dump([newCustomAgentEntry]);
|
|
if (! await this.fileService.exists(uri)) {
|
|
await this.fileService.createFile(uri, BinaryBuffer.fromString(content));
|
|
} else {
|
|
const fileContent = (await this.fileService.readFile(uri)).value;
|
|
await this.fileService.writeFile(uri, BinaryBuffer.concat([fileContent, BinaryBuffer.fromString(content)]));
|
|
}
|
|
const openHandler = await this.openerService.getOpener(uri);
|
|
openHandler.open(uri);
|
|
}
|
|
}
|