diff --git a/packages/ai-chat/src/common/chat-request-parser.ts b/packages/ai-chat/src/common/chat-request-parser.ts index 8c21aaa4304c7..429079f466cd1 100644 --- a/packages/ai-chat/src/common/chat-request-parser.ts +++ b/packages/ai-chat/src/common/chat-request-parser.ts @@ -35,7 +35,7 @@ import { ParsedChatRequest, ParsedChatRequestPart, } from './parsed-chat-request'; -import { AIVariable, AIVariableService, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core'; +import { AIVariable, AIVariableService, PROMPT_FUNCTION_REGEX, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core'; const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent const functionReg = /^~([\w_\-\.]+)(?=(\s|$|\b))/i; // A ~ tool function @@ -202,7 +202,8 @@ export class ChatRequestParserImpl { } private tryParseFunction(message: string, offset: number): ParsedChatRequestFunctionPart | undefined { - const nextFunctionMatch = message.match(functionReg); + // Support both the and chat and prompt formats for functions + const nextFunctionMatch = message.match(functionReg) || message.match(PROMPT_FUNCTION_REGEX); if (!nextFunctionMatch) { return; } diff --git a/packages/ai-core/src/browser/ai-core-frontend-module.ts b/packages/ai-core/src/browser/ai-core-frontend-module.ts index 5f3624609cd1a..a33982988f0a9 100644 --- a/packages/ai-core/src/browser/ai-core-frontend-module.ts +++ b/packages/ai-core/src/browser/ai-core-frontend-module.ts @@ -60,6 +60,7 @@ import { AIActivationService } from './ai-activation-service'; import { AgentService, AgentServiceImpl } from '../common/agent-service'; import { AICommandHandlerFactory } from './ai-command-handler-factory'; import { AISettingsService } from '../common/settings-service'; +import { PromptVariableContribution } from '../common/prompt-variable-contribution'; export default new ContainerModule(bind => { bindContributionProvider(bind, LanguageModelProvider); @@ -109,6 +110,7 @@ export default new ContainerModule(bind => { bind(TheiaVariableContribution).toSelf().inSingletonScope(); bind(AIVariableContribution).toService(TheiaVariableContribution); + bind(AIVariableContribution).to(PromptVariableContribution).inSingletonScope(); bind(AIVariableContribution).to(TodayVariableContribution).inSingletonScope(); bind(AIVariableContribution).to(FileVariableContribution).inSingletonScope(); bind(AIVariableContribution).to(AgentsVariableContribution).inSingletonScope(); diff --git a/packages/ai-core/src/common/prompt-service.ts b/packages/ai-core/src/common/prompt-service.ts index f2ccd165a73d4..528e4d27a184a 100644 --- a/packages/ai-core/src/common/prompt-service.ts +++ b/packages/ai-core/src/common/prompt-service.ts @@ -64,10 +64,27 @@ export interface PromptService { * Allows to directly replace placeholders in the prompt. The supported format is 'Hi {{name}}!'. * The placeholder is then searched inside the args object and replaced. * Function references are also supported via format '~{functionId}'. + * + * All placeholders are replaced before function references are resolved. + * This allows to resolve function references contained in placeholders. + * * @param id the id of the prompt * @param args the object with placeholders, mapping the placeholder key to the value */ getPrompt(id: string, args?: { [key: string]: unknown }, context?: AIVariableContext): Promise; + + /** + * Allows to directly replace placeholders in the prompt. The supported format is 'Hi {{name}}!'. + * The placeholder is then searched inside the args object and replaced. + * + * In contrast to {@link getPrompt}, this method does not resolve function references but leaves them as is. + * This allows resolving them later as part of the prompt or chat message containing the fragment. + * + * @param id the id of the prompt + * @param @param args the object with placeholders, mapping the placeholder key to the value + */ + getPromptFragment(id: string, args?: { [key: string]: unknown }): Promise | undefined>; + /** * Adds a {@link PromptTemplate} to the list of prompts. * @param promptTemplate the prompt template to store @@ -246,27 +263,14 @@ export class PromptServiceImpl implements PromptService { return undefined; } - const matches = matchVariablesRegEx(prompt.template); - const variableAndArgReplacements = await Promise.all(matches.map(async match => { - const completeText = match[0]; - const variableAndArg = match[1]; - let variableName = variableAndArg; - let argument: string | undefined; - const parts = variableAndArg.split(':', 2); - if (parts.length > 1) { - variableName = parts[0]; - argument = parts[1]; - } - return { - placeholder: completeText, - value: String(args?.[variableAndArg] ?? (await this.variableService?.resolveVariable({ - variable: variableName, - arg: argument - }, context ?? {}))?.value ?? completeText) - }; - })); + // First resolve variables and arguments + let resolvedTemplate = prompt.template; + const variableAndArgReplacements = await this.getVariableAndArgReplacements(prompt.template, args); + variableAndArgReplacements.forEach(replacement => resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value)); - const functionMatches = matchFunctionsRegEx(prompt.template); + // Then resolve function references with already resolved variables and arguments + // This allows to resolve function references contained in resolved variables (e.g. prompt fragments) + const functionMatches = matchFunctionsRegEx(resolvedTemplate); const functions = new Map(); const functionReplacements = functionMatches.map(match => { const completeText = match[0]; @@ -280,16 +284,60 @@ export class PromptServiceImpl implements PromptService { value: toolRequest ? toolRequestToPromptText(toolRequest) : completeText }; }); + functionReplacements.forEach(replacement => resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value)); + + return { + id, + text: resolvedTemplate, + functionDescriptions: functions.size > 0 ? functions : undefined + }; + } + + async getPromptFragment(id: string, args?: { [key: string]: unknown }): Promise | undefined> { + const variantId = await this.getVariantId(id); + const prompt = this.getUnresolvedPrompt(variantId); + if (prompt === undefined) { + return undefined; + } + const replacements = await this.getVariableAndArgReplacements(prompt.template, args); let resolvedTemplate = prompt.template; - const replacements = [...variableAndArgReplacements, ...functionReplacements]; replacements.forEach(replacement => resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value)); return { id, text: resolvedTemplate, - functionDescriptions: functions.size > 0 ? functions : undefined }; } + + /** + * Calculates all variable and argument replacements for an unresolved template. + * + * @param template the unresolved template text + * @param args the object with placeholders, mapping the placeholder key to the value + */ + protected async getVariableAndArgReplacements(template: string, args?: { [key: string]: unknown }): Promise<{ placeholder: string; value: string }[]> { + const matches = matchVariablesRegEx(template); + const variableAndArgReplacements = await Promise.all(matches.map(async match => { + const completeText = match[0]; + const variableAndArg = match[1]; + let variableName = variableAndArg; + let argument: string | undefined; + const parts = variableAndArg.split(':', 2); + if (parts.length > 1) { + variableName = parts[0]; + argument = parts[1]; + } + return { + placeholder: completeText, + value: String(args?.[variableAndArg] ?? (await this.variableService?.resolveVariable({ + variable: variableName, + arg: argument + }, context ?? {}))?.value ?? completeText) + }; + })); + return variableAndArgReplacements; + } + getAllPrompts(): PromptMap { if (this.customizationService !== undefined) { const myCustomization = this.customizationService; diff --git a/packages/ai-core/src/common/prompt-variable-contribution.ts b/packages/ai-core/src/common/prompt-variable-contribution.ts new file mode 100644 index 0000000000000..174f857f3224b --- /dev/null +++ b/packages/ai-core/src/common/prompt-variable-contribution.ts @@ -0,0 +1,118 @@ +// ***************************************************************************** +// Copyright (C) 2025 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 { nls } from '@theia/core'; +import { injectable, inject, optional } from '@theia/core/shared/inversify'; +import * as monaco from '@theia/monaco-editor-core'; +import { + AIVariable, + AIVariableContribution, + AIVariableResolver, + AIVariableService, + AIVariableResolutionRequest, + AIVariableContext, + ResolvedAIContextVariable +} from './variable-service'; +import { PromptCustomizationService, PromptService } from './prompt-service'; +import { PromptText } from './prompt-text'; + +export const PROMPT_VARIABLE: AIVariable = { + id: 'prompt-provider', + description: nls.localize('theia/ai/core/promptVariable/description', 'Resolves prompt templates via the prompt service'), + name: 'prompt', + args: [ + { name: 'id', description: nls.localize('theia/ai/core/promptVariable/argDescription', 'The prompt template id to resolve') } + ], + isContextVariable: true +}; + +@injectable() +export class PromptVariableContribution implements AIVariableContribution, AIVariableResolver { + + @inject(PromptService) + protected readonly promptService: PromptService; + + @inject(PromptCustomizationService) @optional() + protected readonly promptCustomizationService: PromptCustomizationService; + + registerVariables(service: AIVariableService): void { + service.registerResolver(PROMPT_VARIABLE, this); + service.registerArgumentCompletionProvider(PROMPT_VARIABLE, this.provideArgumentCompletionItems.bind(this)); + } + + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): number { + if (request.variable.name === PROMPT_VARIABLE.name) { + return 1; + } + return -1; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + if (request.variable.name === PROMPT_VARIABLE.name) { + const promptId = request.arg?.trim(); + if (promptId) { + const resolvedPrompt = await this.promptService.getPromptFragment(promptId); + if (resolvedPrompt) { + return { variable: request.variable, value: resolvedPrompt.text, contextValue: resolvedPrompt.text }; + } + } + } + return undefined; + } + + protected async provideArgumentCompletionItems( + model: monaco.editor.ITextModel, + position: monaco.Position + ): Promise { + const lineContent = model.getLineContent(position.lineNumber); + + // Only provide completions once the variable argument separator is typed + const triggerCharIndex = lineContent.lastIndexOf(PromptText.VARIABLE_SEPARATOR_CHAR, position.column - 1); + if (triggerCharIndex === -1) { + return undefined; + } + + // Check if the text immediately before the trigger is the prompt variable, i.e #prompt + const requiredVariable = `${PromptText.VARIABLE_CHAR}${PROMPT_VARIABLE.name}`; + if (triggerCharIndex < requiredVariable.length || + lineContent.substring(triggerCharIndex - requiredVariable.length, triggerCharIndex) !== requiredVariable) { + return undefined; + } + + const range = new monaco.Range(position.lineNumber, triggerCharIndex + 2, position.lineNumber, position.column); + + const customPromptIds = this.promptCustomizationService?.getCustomPromptTemplateIDs() ?? []; + const builtinPromptIds = Object.keys(this.promptService.getAllPrompts()); + + const customPromptCompletions = customPromptIds.map(promptId => ({ + label: promptId, + kind: monaco.languages.CompletionItemKind.Enum, + insertText: promptId, + range, + detail: nls.localize('theia/ai/core/promptVariable/completions/detail/custom', 'Custom prompt template'), + sortText: `AAA${promptId}` // Sort before everything else including all built-in prompts + })); + const builtinPromptCompletions = builtinPromptIds.map(promptId => ({ + label: promptId, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: promptId, + range, + detail: nls.localize('theia/ai/core/promptVariable/completions/detail/builtin', 'Built-in prompt template'), + sortText: `AAB${promptId}` // Sort after all custom prompts but before others + })); + + return [...customPromptCompletions, ...builtinPromptCompletions]; + } +}