diff --git a/packages/ai-chat/src/common/universal-chat-agent.ts b/packages/ai-chat/src/common/universal-chat-agent.ts index ab8838d7578e6..95318ac3ab360 100644 --- a/packages/ai-chat/src/common/universal-chat-agent.ts +++ b/packages/ai-chat/src/common/universal-chat-agent.ts @@ -76,6 +76,12 @@ simple solutions. ` }; +export const universalTemplateVariant: PromptTemplate = { + id: 'universal-system-empty', + template: '', + variantOf: universalTemplate.id, +}; + @injectable() export class UniversalChatAgent extends AbstractStreamParsingChatAgent implements ChatAgent { name: string; @@ -96,7 +102,7 @@ export class UniversalChatAgent extends AbstractStreamParsingChatAgent implement + 'questions the user might ask. The universal agent currently does not have any context by default, i.e. it cannot ' + 'access the current user context or the workspace.'; this.variables = []; - this.promptTemplates = [universalTemplate]; + this.promptTemplates = [universalTemplate, universalTemplateVariant]; this.functions = []; this.agentSpecificVariables = []; } diff --git a/packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx b/packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx index 35abb84b328e5..8329e25ab5e1f 100644 --- a/packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx +++ b/packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx @@ -137,14 +137,31 @@ export class AIAgentConfigurationWidget extends ReactWidget { Enable Agent -
- {agent.promptTemplates?.map(template => - )} +
+ Prompt Templates
+
+ {(() => { + const defaultTemplates = agent.promptTemplates?.filter(template => !template.variantOf) || []; + return defaultTemplates.length > 0 ? ( + defaultTemplates.map(template => ( +
+ +
+ )) + ) : ( +
No default template available
+ ); + })()} +
+
= ({ agentId, template, promptCustomizationService }) => { +export const TemplateRenderer: React.FC = ({ + agentId, + template, + promptCustomizationService, + promptService, + aiSettingsService, +}) => { + const [variantIds, setVariantIds] = React.useState([]); + const [selectedVariant, setSelectedVariant] = React.useState(DEFAULT_VARIANT); + + React.useEffect(() => { + (async () => { + const variants = promptService.getVariantIds(template.id); + setVariantIds([DEFAULT_VARIANT, ...variants]); + + const agentSettings = await aiSettingsService.getAgentSettings(agentId); + const currentVariant = + agentSettings?.selectedVariants?.[template.id] || DEFAULT_VARIANT; + setSelectedVariant(currentVariant); + })(); + }, [template.id, promptService, aiSettingsService, agentId]); + + const handleVariantChange = async (event: React.ChangeEvent) => { + const newVariant = event.target.value; + setSelectedVariant(newVariant); + + const agentSettings = await aiSettingsService.getAgentSettings(agentId); + const selectedVariants = agentSettings?.selectedVariants || {}; + + const updatedVariants = { ...selectedVariants }; + if (newVariant === DEFAULT_VARIANT) { + delete updatedVariants[template.id]; + } else { + updatedVariants[template.id] = newVariant; + } + + await aiSettingsService.updateAgentSettings(agentId, { + selectedVariants: updatedVariants, + }); + }; + const openTemplate = React.useCallback(async () => { - promptCustomizationService.editTemplate(template.id, template.template); - }, [template, promptCustomizationService]); + const templateId = selectedVariant === DEFAULT_VARIANT ? template.id : selectedVariant; + const selectedTemplate = promptService.getRawPrompt(templateId); + promptCustomizationService.editTemplate(templateId, selectedTemplate?.template || ''); + }, [selectedVariant, template.id, promptService, promptCustomizationService]); + const resetTemplate = React.useCallback(async () => { - promptCustomizationService.resetTemplate(template.id); - }, [promptCustomizationService, template]); - - return <> - {template.id} - - - ; + const templateId = selectedVariant === DEFAULT_VARIANT ? template.id : selectedVariant; + promptCustomizationService.resetTemplate(templateId); + }, [selectedVariant, template.id, promptCustomizationService]); + + return ( +
+
+ {template.id} +
+
+ {variantIds.length > 1 && ( + <> + + + + )} + + +
+
+ ); }; diff --git a/packages/ai-core/src/browser/style/index.css b/packages/ai-core/src/browser/style/index.css index 36cdad9c19221..a42e41b29a9a2 100644 --- a/packages/ai-core/src/browser/style/index.css +++ b/packages/ai-core/src/browser/style/index.css @@ -14,14 +14,42 @@ margin-left: var(--theia-ui-padding); } +.theia-settings-container .settings-section-subcategory-title.ai-settings-section-subcategory-title { + padding-left: 0; +} + .ai-templates { - display: grid; - /** Display content in 3 columns */ - grid-template-columns: 1fr auto auto; - /** add a 3px gap between rows */ - row-gap: 3px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.template-renderer { + display: flex; + flex-direction: column; + padding: 10px; +} + +.template-header { + margin-bottom: 8px; } +.template-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.template-select-label { + margin-right: 5px; +} + +.template-variant-selector { + min-width: 120px; +} + + + #ai-variable-configuration-container-widget, #ai-agent-configuration-container-widget { margin-top: 5px; diff --git a/packages/ai-core/src/common/agent-service.ts b/packages/ai-core/src/common/agent-service.ts index 4b351fb93250d..4c8a4ac2e7bdf 100644 --- a/packages/ai-core/src/common/agent-service.ts +++ b/packages/ai-core/src/common/agent-service.ts @@ -99,7 +99,7 @@ export class AgentServiceImpl implements AgentService { registerAgent(agent: Agent): void { this._agents.push(agent); agent.promptTemplates.forEach( - template => this.promptService.storePrompt(template.id, template.template) + template => this.promptService.storePromptTemplate(template) ); this.onDidChangeAgentsEmitter.fire(); } diff --git a/packages/ai-core/src/common/prompt-service.spec.ts b/packages/ai-core/src/common/prompt-service.spec.ts index 106ef4fc85df0..00807e59db11a 100644 --- a/packages/ai-core/src/common/prompt-service.spec.ts +++ b/packages/ai-core/src/common/prompt-service.spec.ts @@ -37,10 +37,10 @@ describe('PromptService', () => { container.bind(AIVariableService).toConstantValue(variableService); promptService = container.get(PromptService); - promptService.storePrompt('1', 'Hello, {{name}}!'); - promptService.storePrompt('2', 'Goodbye, {{name}}!'); - promptService.storePrompt('3', 'Ciao, {{invalid}}!'); - promptService.storePrompt('8', 'Hello, {{{name}}}'); + promptService.storePromptTemplate({ id: '1', template: 'Hello, {{name}}!' }); + promptService.storePromptTemplate({ id: '2', template: 'Goodbye, {{name}}!' }); + promptService.storePromptTemplate({ id: '3', template: 'Ciao, {{invalid}}!' }); + promptService.storePromptTemplate({ id: '8', template: 'Hello, {{{name}}}' }); }); it('should initialize prompts from PromptCollectionService', () => { @@ -62,7 +62,7 @@ describe('PromptService', () => { }); it('should store a new prompt', () => { - promptService.storePrompt('3', 'Welcome, {{name}}!'); + promptService.storePromptTemplate({ id: '3', template: 'Welcome, {{name}}!' }); const newPrompt = promptService.getRawPrompt('3'); expect(newPrompt?.template).to.equal('Welcome, {{name}}!'); }); @@ -88,10 +88,10 @@ describe('PromptService', () => { }); it('should ignore whitespace in variables', async () => { - promptService.storePrompt('4', 'Hello, {{name }}!'); - promptService.storePrompt('5', 'Hello, {{ name}}!'); - promptService.storePrompt('6', 'Hello, {{ name }}!'); - promptService.storePrompt('7', 'Hello, {{ name }}!'); + promptService.storePromptTemplate({ id: '4', template: 'Hello, {{name }}!' }); + promptService.storePromptTemplate({ id: '5', template: 'Hello, {{ name}}!' }); + promptService.storePromptTemplate({ id: '6', template: 'Hello, {{ name }}!' }); + promptService.storePromptTemplate({ id: '7', template: 'Hello, {{ name }}!' }); for (let i = 4; i <= 7; i++) { const prompt = await promptService.getPrompt(`${i}`, { name: 'John' }); expect(prompt?.text).to.equal('Hello, John!'); @@ -109,10 +109,10 @@ describe('PromptService', () => { }); it('should ignore whitespace in variables (three bracket)', async () => { - promptService.storePrompt('9', 'Hello, {{{name }}}'); - promptService.storePrompt('10', 'Hello, {{{ name}}}'); - promptService.storePrompt('11', 'Hello, {{{ name }}}'); - promptService.storePrompt('12', 'Hello, {{{ name }}}'); + promptService.storePromptTemplate({ id: '9', template: 'Hello, {{{name }}}' }); + promptService.storePromptTemplate({ id: '10', template: 'Hello, {{{ name}}}' }); + promptService.storePromptTemplate({ id: '11', template: 'Hello, {{{ name }}}' }); + promptService.storePromptTemplate({ id: '12', template: 'Hello, {{{ name }}}' }); for (let i = 9; i <= 12; i++) { const prompt = await promptService.getPrompt(`${i}`, { name: 'John' }); expect(prompt?.text).to.equal('Hello, John'); @@ -120,26 +120,24 @@ describe('PromptService', () => { }); it('should ignore invalid prompts with unmatched brackets', async () => { - promptService.storePrompt('9', 'Hello, {{name'); - promptService.storePrompt('10', 'Hello, {{{name'); - promptService.storePrompt('11', 'Hello, name}}}}'); + promptService.storePromptTemplate({ id: '9', template: 'Hello, {{name' }); + promptService.storePromptTemplate({ id: '10', template: 'Hello, {{{name' }); + promptService.storePromptTemplate({ id: '11', template: 'Hello, name}}}}' }); const prompt1 = await promptService.getPrompt('9', { name: 'John' }); expect(prompt1?.text).to.equal('Hello, {{name'); // Not matching due to missing closing brackets - const prompt2 = await promptService.getPrompt('10', { name: 'John' }); expect(prompt2?.text).to.equal('Hello, {{{name'); // Matches pattern due to valid three-start-two-end brackets - const prompt3 = await promptService.getPrompt('11', { name: 'John' }); expect(prompt3?.text).to.equal('Hello, name}}}}'); // Extra closing bracket, does not match cleanly }); it('should handle a mixture of two and three brackets correctly', async () => { - promptService.storePrompt('12', 'Hi, {{name}}}'); // (invalid) - promptService.storePrompt('13', 'Hello, {{{name}}'); // (invalid) - promptService.storePrompt('14', 'Greetings, {{{name}}}}'); // (invalid) - promptService.storePrompt('15', 'Bye, {{{{name}}}'); // (invalid) - promptService.storePrompt('16', 'Ciao, {{{{name}}}}'); // (invalid) - promptService.storePrompt('17', 'Hi, {{name}}! {{{name}}}'); // Mixed valid patterns + promptService.storePromptTemplate({ id: '12', template: 'Hi, {{name}}}' }); // (invalid) + promptService.storePromptTemplate({ id: '13', template: 'Hello, {{{name}}' }); // (invalid) + promptService.storePromptTemplate({ id: '14', template: 'Greetings, {{{name}}}}' }); // (invalid) + promptService.storePromptTemplate({ id: '15', template: 'Bye, {{{{name}}}' }); // (invalid) + promptService.storePromptTemplate({ id: '16', template: 'Ciao, {{{{name}}}}' }); // (invalid) + promptService.storePromptTemplate({ id: '17', template: 'Hi, {{name}}! {{{name}}}' }); // Mixed valid patterns const prompt12 = await promptService.getPrompt('12', { name: 'John' }); expect(prompt12?.text).to.equal('Hi, {{name}}}'); @@ -161,87 +159,143 @@ describe('PromptService', () => { }); it('should strip single-line comments at the start of the template', () => { - promptService.storePrompt('comment-basic', '{{!-- Comment --}}Hello, {{name}}!'); + promptService.storePromptTemplate({ id: 'comment-basic', template: '{{!-- Comment --}}Hello, {{name}}!' }); const prompt = promptService.getUnresolvedPrompt('comment-basic'); expect(prompt?.template).to.equal('Hello, {{name}}!'); }); it('should remove line break after first-line comment', () => { - promptService.storePrompt('comment-line-break', '{{!-- Comment --}}\nHello, {{name}}!'); + promptService.storePromptTemplate({ id: 'comment-line-break', template: '{{!-- Comment --}}\nHello, {{name}}!' }); const prompt = promptService.getUnresolvedPrompt('comment-line-break'); expect(prompt?.template).to.equal('Hello, {{name}}!'); }); it('should strip multiline comments at the start of the template', () => { - promptService.storePrompt('comment-multiline', '{{!--\nMultiline comment\n--}}\nGoodbye, {{name}}!'); + promptService.storePromptTemplate({ id: 'comment-multiline', template: '{{!--\nMultiline comment\n--}}\nGoodbye, {{name}}!' }); const prompt = promptService.getUnresolvedPrompt('comment-multiline'); expect(prompt?.template).to.equal('Goodbye, {{name}}!'); }); it('should not strip comments not in the first line', () => { - promptService.storePrompt('comment-second-line', 'Hello, {{name}}!\n{{!-- Comment --}}'); + promptService.storePromptTemplate({ id: 'comment-second-line', template: 'Hello, {{name}}!\n{{!-- Comment --}}' }); const prompt = promptService.getUnresolvedPrompt('comment-second-line'); expect(prompt?.template).to.equal('Hello, {{name}}!\n{{!-- Comment --}}'); }); it('should treat unclosed comments as regular text', () => { - promptService.storePrompt('comment-unclosed', '{{!-- Unclosed comment'); + promptService.storePromptTemplate({ id: 'comment-unclosed', template: '{{!-- Unclosed comment' }); const prompt = promptService.getUnresolvedPrompt('comment-unclosed'); expect(prompt?.template).to.equal('{{!-- Unclosed comment'); }); it('should treat standalone closing delimiters as regular text', () => { - promptService.storePrompt('comment-standalone', '--}} Hello, {{name}}!'); + promptService.storePromptTemplate({ id: 'comment-standalone', template: '--}} Hello, {{name}}!' }); const prompt = promptService.getUnresolvedPrompt('comment-standalone'); expect(prompt?.template).to.equal('--}} Hello, {{name}}!'); }); it('should handle nested comments and stop at the first closing tag', () => { - promptService.storePrompt('nested-comment', '{{!-- {{!-- Nested comment --}} --}}text'); + promptService.storePromptTemplate({ id: 'nested-comment', template: '{{!-- {{!-- Nested comment --}} --}}text' }); const prompt = promptService.getUnresolvedPrompt('nested-comment'); expect(prompt?.template).to.equal('--}}text'); }); it('should handle templates with only comments', () => { - promptService.storePrompt('comment-only', '{{!-- Only comments --}}'); + promptService.storePromptTemplate({ id: 'comment-only', template: '{{!-- Only comments --}}' }); const prompt = promptService.getUnresolvedPrompt('comment-only'); expect(prompt?.template).to.equal(''); }); it('should handle mixed delimiters on the same line', () => { - promptService.storePrompt('comment-mixed', '{{!-- Unclosed comment --}}'); + promptService.storePromptTemplate({ id: 'comment-mixed', template: '{{!-- Unclosed comment --}}' }); const prompt = promptService.getUnresolvedPrompt('comment-mixed'); expect(prompt?.template).to.equal(''); }); it('should resolve variables after stripping single-line comments', async () => { - promptService.storePrompt('comment-resolve', '{{!-- Comment --}}Hello, {{name}}!'); + promptService.storePromptTemplate({ id: 'comment-resolve', template: '{{!-- Comment --}}Hello, {{name}}!' }); const prompt = await promptService.getPrompt('comment-resolve', { name: 'John' }); expect(prompt?.text).to.equal('Hello, John!'); }); it('should resolve variables in multiline templates with comments', async () => { - promptService.storePrompt('comment-multiline-vars', '{{!--\nMultiline comment\n--}}\nHello, {{name}}!'); + promptService.storePromptTemplate({ id: 'comment-multiline-vars', template: '{{!--\nMultiline comment\n--}}\nHello, {{name}}!' }); const prompt = await promptService.getPrompt('comment-multiline-vars', { name: 'John' }); expect(prompt?.text).to.equal('Hello, John!'); }); it('should resolve variables with standalone closing delimiters', async () => { - promptService.storePrompt('comment-standalone-vars', '--}} Hello, {{name}}!'); + promptService.storePromptTemplate({ id: 'comment-standalone-vars', template: '--}} Hello, {{name}}!' }); const prompt = await promptService.getPrompt('comment-standalone-vars', { name: 'John' }); expect(prompt?.text).to.equal('--}} Hello, John!'); }); it('should treat unclosed comments as text and resolve variables', async () => { - promptService.storePrompt('comment-unclosed-vars', '{{!-- Unclosed comment\nHello, {{name}}!'); + promptService.storePromptTemplate({ id: 'comment-unclosed-vars', template: '{{!-- Unclosed comment\nHello, {{name}}!' }); const prompt = await promptService.getPrompt('comment-unclosed-vars', { name: 'John' }); expect(prompt?.text).to.equal('{{!-- Unclosed comment\nHello, John!'); }); it('should handle templates with mixed comments and variables', async () => { - promptService.storePrompt('comment-mixed-vars', '{{!-- Comment --}}Hi, {{name}}! {{!-- Another comment --}}'); + promptService.storePromptTemplate({ id: 'comment-mixed-vars', template: '{{!-- Comment --}}Hi, {{name}}! {{!-- Another comment --}}' }); const prompt = await promptService.getPrompt('comment-mixed-vars', { name: 'John' }); expect(prompt?.text).to.equal('Hi, John! {{!-- Another comment --}}'); }); + it('should return all variant IDs of a given prompt', () => { + promptService.storePromptTemplate({ id: 'main', template: 'Main template' }); + + promptService.storePromptTemplate({ + id: 'variant1', + template: 'Variant 1', + variantOf: 'main' + }); + promptService.storePromptTemplate({ + id: 'variant2', + template: 'Variant 2', + variantOf: 'main' + }); + promptService.storePromptTemplate({ + id: 'variant3', + template: 'Variant 3', + variantOf: 'main' + }); + + const variantIds = promptService.getVariantIds('main'); + expect(variantIds).to.deep.equal(['variant1', 'variant2', 'variant3']); + }); + + it('should return an empty array if no variants exist for a given prompt', () => { + promptService.storePromptTemplate({ id: 'main', template: 'Main template' }); + + const variantIds = promptService.getVariantIds('main'); + expect(variantIds).to.deep.equal([]); + }); + + it('should return an empty array if the main prompt ID does not exist', () => { + const variantIds = promptService.getVariantIds('nonExistent'); + expect(variantIds).to.deep.equal([]); + }); + + it('should not influence prompts without variants when other prompts have variants', () => { + promptService.storePromptTemplate({ id: 'mainWithVariants', template: 'Main template with variants' }); + promptService.storePromptTemplate({ id: 'mainWithoutVariants', template: 'Main template without variants' }); + + promptService.storePromptTemplate({ + id: 'variant1', + template: 'Variant 1', + variantOf: 'mainWithVariants' + }); + promptService.storePromptTemplate({ + id: 'variant2', + template: 'Variant 2', + variantOf: 'mainWithVariants' + }); + + const variantsForMainWithVariants = promptService.getVariantIds('mainWithVariants'); + const variantsForMainWithoutVariants = promptService.getVariantIds('mainWithoutVariants'); + + expect(variantsForMainWithVariants).to.deep.equal(['variant1', 'variant2']); + expect(variantsForMainWithoutVariants).to.deep.equal([]); + }); }); diff --git a/packages/ai-core/src/common/prompt-service.ts b/packages/ai-core/src/common/prompt-service.ts index d4bc9aaea0ce5..aef16da8fdc50 100644 --- a/packages/ai-core/src/common/prompt-service.ts +++ b/packages/ai-core/src/common/prompt-service.ts @@ -21,10 +21,16 @@ import { ToolInvocationRegistry } from './tool-invocation-registry'; import { toolRequestToPromptText } from './language-model-util'; import { ToolRequest } from './language-model'; import { matchFunctionsRegEx, matchVariablesRegEx } from './prompt-service-util'; +import { AISettingsService } from './settings-service'; export interface PromptTemplate { id: string; template: string; + /** + * (Optional) The ID of the main template for which this template is a variant. + * If present, this indicates that the current template represents an alternative version of the specified main template. + */ + variantOf?: string; } export interface PromptMap { [id: string]: PromptTemplate } @@ -63,11 +69,10 @@ export interface PromptService { */ getPrompt(id: string, args?: { [key: string]: unknown }): Promise; /** - * Adds a prompt to the list of prompts. - * @param id the id of the prompt - * @param prompt the prompt template to store + * Adds a {@link PromptTemplate} to the list of prompts. + * @param promptTemplate the prompt template to store */ - storePrompt(id: string, prompt: string): void; + storePromptTemplate(promptTemplate: PromptTemplate): void; /** * Removes a prompt from the list of prompts. * @param id the id of the prompt @@ -77,6 +82,20 @@ export interface PromptService { * Return all known prompts as a {@link PromptMap map}. */ getAllPrompts(): PromptMap; + /** + * Retrieve all variant IDs of a given {@link PromptTemplate}. + * @param id the id of the main {@link PromptTemplate} + * @returns an array of string IDs representing the variants of the given template + */ + getVariantIds(id: string): string[]; + /** + * Retrieve the currently selected variant ID for a given main prompt ID. + * If a variant is selected for the main prompt, it will be returned. + * Otherwise, the main prompt ID will be returned. + * @param id the id of the main prompt + * @returns the variant ID if one is selected, or the main prompt ID otherwise + */ + getVariantId(id: string): Promise; } export interface CustomAgentDescription { @@ -163,6 +182,9 @@ export interface PromptCustomizationService { @injectable() export class PromptServiceImpl implements PromptService { + @inject(AISettingsService) @optional() + protected readonly settingsService: AISettingsService | undefined; + @inject(PromptCustomizationService) @optional() protected readonly customizationService: PromptCustomizationService | undefined; @@ -203,8 +225,22 @@ export class PromptServiceImpl implements PromptService { return commentRegex.test(template) ? template.replace(commentRegex, '').trimStart() : template; } + async getVariantId(id: string): Promise { + if (this.settingsService !== undefined) { + const agentSettingsMap = await this.settingsService.getSettings(); + + for (const agentSettings of Object.values(agentSettingsMap)) { + if (agentSettings.selectedVariants && agentSettings.selectedVariants[id]) { + return agentSettings.selectedVariants[id]; + } + } + } + return id; + } + async getPrompt(id: string, args?: { [key: string]: unknown }): Promise { - const prompt = this.getUnresolvedPrompt(id); + const variantId = await this.getVariantId(id); + const prompt = this.getUnresolvedPrompt(variantId); if (prompt === undefined) { return undefined; } @@ -274,10 +310,15 @@ export class PromptServiceImpl implements PromptService { return { ...this._prompts }; } } - storePrompt(id: string, prompt: string): void { - this._prompts[id] = { id, template: prompt }; - } removePrompt(id: string): void { delete this._prompts[id]; } + getVariantIds(id: string): string[] { + return Object.values(this._prompts) + .filter(prompt => prompt.variantOf === id) + .map(variant => variant.id); + } + storePromptTemplate(promptTemplate: PromptTemplate): void { + this._prompts[promptTemplate.id] = promptTemplate; + } } diff --git a/packages/ai-core/src/common/settings-service.ts b/packages/ai-core/src/common/settings-service.ts index 007daec366250..8610a36b92ea4 100644 --- a/packages/ai-core/src/common/settings-service.ts +++ b/packages/ai-core/src/common/settings-service.ts @@ -30,4 +30,9 @@ export type AISettings = Record; export interface AgentSettings { languageModelRequirements?: LanguageModelRequirement[]; enable?: boolean; + /** + * A mapping of main template IDs to their selected variant IDs. + * If a main template is not present in this mapping, it means the main template is used. + */ + selectedVariants?: Record; }