diff --git a/package-lock.json b/package-lock.json index a0a689b80427c..0eb977b30ab8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30478,6 +30478,7 @@ "@theia/variable-resolver": "1.60.0", "@theia/workspace": "1.60.0", "@types/js-yaml": "^4.0.9", + "fast-deep-equal": "^3.1.3", "js-yaml": "^4.1.0", "minimatch": "^5.1.0", "tslib": "^2.6.2" @@ -30549,6 +30550,7 @@ "@theia/workspace": "1.60.0", "date-fns": "^4.1.0", "ignore": "^6.0.0", + "js-yaml": "^4.1.0", "minimatch": "^9.0.0" }, "devDependencies": { diff --git a/package.json b/package.json index ec8c8d24fee38..53aafac2e86e4 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "lint": "lerna run lint", "lint:fix": "lerna run lint -- --fix", "lint:clean": "rimraf .eslintcache", + "lint:fix": "lerna run lint -- --fix", "preinstall": "node-gyp install", "postinstall": "theia-patch && npm run -s compute-references && lerna run afterInstall", "publish:latest": "lerna publish --exact --yes", diff --git a/packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts b/packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts index 3dfa6d48206ea..d4ae8cf9bec84 100644 --- a/packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts +++ b/packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts @@ -15,9 +15,13 @@ // ***************************************************************************** import { inject, injectable } from '@theia/core/shared/inversify'; -import { CommandRegistry, isOSX, nls, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core'; +import { CommandRegistry, Emitter, isOSX, MessageService, nls, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core'; import { Widget } from '@theia/core/lib/browser'; -import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_SHOW_CHATS_COMMAND, ChatCommands } from './chat-view-commands'; +import { + AI_CHAT_NEW_CHAT_WINDOW_COMMAND, + AI_CHAT_SHOW_CHATS_COMMAND, + ChatCommands +} from './chat-view-commands'; import { ChatAgentLocation, ChatService } from '@theia/ai-chat'; import { AbstractViewContribution } from '@theia/core/lib/browser/shell/view-contribution'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; @@ -30,6 +34,8 @@ import { AI_SHOW_SETTINGS_COMMAND } from '@theia/ai-core/lib/browser'; import { OPEN_AI_HISTORY_VIEW } from '@theia/ai-history/lib/browser/ai-history-contribution'; import { ChatNodeToolbarCommands } from './chat-node-toolbar-action-contribution'; import { isEditableRequestNode, type EditableRequestNode } from './chat-tree-view'; +import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable'; +import { TaskContextService } from '@theia/ai-chat/lib/browser/task-context-service'; export const AI_CHAT_TOGGLE_COMMAND_ID = 'aiChat:toggle'; @@ -40,6 +46,10 @@ export class AIChatContribution extends AbstractViewContribution protected readonly chatService: ChatService; @inject(QuickInputService) protected readonly quickInputService: QuickInputService; + @inject(TaskContextService) + protected readonly taskContextService: TaskContextService; + @inject(MessageService) + protected readonly messageService: MessageService; protected static readonly RENAME_CHAT_BUTTON: QuickInputButton = { iconClass: 'codicon-edit', @@ -85,14 +95,52 @@ export class AIChatContribution extends AbstractViewContribution }) }); registry.registerCommand(AI_CHAT_NEW_CHAT_WINDOW_COMMAND, { - execute: () => this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }), - isEnabled: widget => this.withWidget(widget, () => true), + execute: () => this.openView().then(() => this.chatService.createSession(ChatAgentLocation.Panel, { focus: true })), isVisible: widget => this.withWidget(widget, () => true), }); + registry.registerCommand(ChatCommands.AI_CHAT_NEW_WITH_TASK_CONTEXT, { + execute: async () => { + const activeSession = this.chatService.getActiveSession(); + const id = await this.summarizeActiveSession(); + if (!id || !activeSession) { return; } + const newSession = this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }, activeSession.pinnedAgent); + const summaryVariable = { variable: TASK_CONTEXT_VARIABLE, arg: id }; + newSession.model.context.addVariables(summaryVariable); + }, + isVisible: () => false + }); + registry.registerCommand(ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION, { + execute: async () => this.summarizeActiveSession(), + isVisible: widget => { + if (widget && !this.withWidget(widget)) { return false; } + const activeSession = this.chatService.getActiveSession(); + return activeSession?.model.location === ChatAgentLocation.Panel + && !this.taskContextService.hasSummary(activeSession); + }, + isEnabled: widget => { + if (widget && !this.withWidget(widget)) { return false; } + const activeSession = this.chatService.getActiveSession(); + return activeSession?.model.location === ChatAgentLocation.Panel + && !activeSession.model.isEmpty() + && !this.taskContextService.hasSummary(activeSession); + } + }); + registry.registerCommand(ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION, { + execute: async () => { + const id = await this.summarizeActiveSession(); + if (!id) { return; } + await this.taskContextService.open(id); + }, + isVisible: widget => { + if (widget && !this.withWidget(widget)) { return false; } + const activeSession = this.chatService.getActiveSession(); + return !!activeSession && this.taskContextService.hasSummary(activeSession); + } + }); registry.registerCommand(AI_CHAT_SHOW_CHATS_COMMAND, { execute: () => this.selectChat(), - isEnabled: widget => this.withWidget(widget, () => true) && this.chatService.getSessions().length > 1, - isVisible: widget => this.withWidget(widget, () => true) + isEnabled: widget => this.withWidget(widget) && this.chatService.getSessions().length > 1, + isVisible: widget => this.withWidget(widget) }); registry.registerCommand(ChatNodeToolbarCommands.EDIT, { isEnabled: node => isEditableRequestNode(node) && !node.request.isEditing, @@ -139,6 +187,19 @@ export class AIChatContribution extends AbstractViewContribution priority: 1, isVisible: widget => this.withWidget(widget), }); + const sessionSummarizibilityChangedEmitter = new Emitter(); + this.taskContextService.onDidChange(() => sessionSummarizibilityChangedEmitter.fire()); + this.chatService.onSessionEvent(event => event.type === 'activeChange' && sessionSummarizibilityChangedEmitter.fire()); + registry.registerItem({ + id: 'chat-view.' + ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION.id, + command: ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION.id, + onDidChange: sessionSummarizibilityChangedEmitter.event + }); + registry.registerItem({ + id: 'chat-view.' + ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION.id, + command: ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION.id, + onDidChange: sessionSummarizibilityChangedEmitter.event + }); } protected async selectChat(sessionId?: string): Promise { @@ -227,6 +288,16 @@ export class AIChatContribution extends AbstractViewContribution canExtractChatView(chatView: ChatViewWidget): boolean { return !chatView.secondaryWindow; } + + protected async summarizeActiveSession(): Promise { + const activeSession = this.chatService.getActiveSession(); + if (!activeSession) { return; } + return this.taskContextService.summarize(activeSession).catch(err => { + console.warn('Error while summarizing session:', err); + this.messageService.error('Unable to summarize current session. Please confirm that the summary agent is not disabled.'); + return undefined; + }); + } } function getDateFnsLocale(): locales.Locale { diff --git a/packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx b/packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx new file mode 100644 index 0000000000000..f30bd289c8b30 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx @@ -0,0 +1,85 @@ +// ***************************************************************************** +// 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 * as React from '@theia/core/shared/react'; +import { DeclaredEventsEventListenerObject, useMarkdownRendering } from './chat-response-renderer/markdown-part-renderer'; +import { OpenerService } from '@theia/core/lib/browser'; +import { ChatSuggestion, ChatSuggestionCallback } from '@theia/ai-chat'; +import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; + +interface ChatInputAgentSuggestionsProps { + suggestions: readonly ChatSuggestion[]; + opener: OpenerService; +} + +function getText(suggestion: ChatSuggestion): string { + if (typeof suggestion === 'string') { return suggestion; } + if ('value' in suggestion) { return suggestion.value; } + if (typeof suggestion.content === 'string') { return suggestion.content; } + return suggestion.content.value; +} + +function getContent(suggestion: ChatSuggestion): string | MarkdownString { + if (typeof suggestion === 'string') { return suggestion; } + if ('value' in suggestion) { return suggestion; } + return suggestion.content; +} + +export const ChatInputAgentSuggestions: React.FC = ({ suggestions, opener }) => ( + !!suggestions?.length &&
+ {suggestions.map(suggestion => )} +
+); + +interface ChatInputAgestSuggestionProps { + suggestion: ChatSuggestion; + opener: OpenerService; + handler?: DeclaredEventsEventListenerObject; +} + +const ChatInputAgentSuggestion: React.FC = ({ suggestion, opener, handler }) => { + const ref = useMarkdownRendering(getContent(suggestion), opener, true, handler); + return
; +}; + +class ChatSuggestionClickHandler implements DeclaredEventsEventListenerObject { + constructor(protected readonly suggestion: ChatSuggestionCallback) { } + handleEvent(event: Event): boolean { + const { target, currentTarget } = event; + if (event.type !== 'click' || !(target instanceof Element)) { return false; } + const link = target.closest('a[href^="_callback"]'); + if (link) { + this.suggestion.callback(); + return true; + } + if (!(currentTarget instanceof Element)) { + this.suggestion.callback(); + return true; + } + const containedLink = currentTarget.querySelector('a[href^="_callback"]'); + // Whole body should count. + if (!containedLink) { + this.suggestion.callback(); + return true; + } + return false; + } +} diff --git a/packages/ai-chat-ui/src/browser/chat-input-widget.tsx b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx index cdd65ee8c1eac..00432964aecef 100644 --- a/packages/ai-chat-ui/src/browser/chat-input-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx @@ -13,9 +13,9 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { ChangeSet, ChatAgent, ChatChangeEvent, ChatModel, ChatRequestModel } from '@theia/ai-chat'; +import { ChangeSet, ChatAgent, ChatChangeEvent, ChatModel, ChatRequestModel, ChatService, ChatSuggestion } from '@theia/ai-chat'; import { Disposable, DisposableCollection, InMemoryResources, URI, nls } from '@theia/core'; -import { ContextMenuRenderer, LabelProvider, Message, ReactWidget } from '@theia/core/lib/browser'; +import { ContextMenuRenderer, LabelProvider, Message, OpenerService, ReactWidget } from '@theia/core/lib/browser'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify'; import * as React from '@theia/core/shared/react'; @@ -28,12 +28,14 @@ import { FrontendVariableService } from '@theia/ai-core/lib/browser'; import { ContextVariablePicker } from './context-variable-picker'; import { ChangeSetActionRenderer, ChangeSetActionService } from './change-set-actions/change-set-action-service'; import { ChangeSetDecoratorService } from '@theia/ai-chat/lib/browser/change-set-decorator-service'; +import { ChatInputAgentSuggestions } from './chat-input-agent-suggestions'; type Query = (query: string) => Promise; type Unpin = () => void; type Cancel = (requestModel: ChatRequestModel) => void; type DeleteChangeSet = (requestModel: ChatRequestModel) => void; type DeleteChangeSetElement = (requestModel: ChatRequestModel, index: number) => void; +type OpenContextElement = (request: AIVariableResolutionRequest) => unknown; export const AIChatInputConfiguration = Symbol('AIChatInputConfiguration'); export interface AIChatInputConfiguration { @@ -74,6 +76,12 @@ export class AIChatInputWidget extends ReactWidget { @inject(ChangeSetDecoratorService) protected readonly changeSetDecoratorService: ChangeSetDecoratorService; + @inject(OpenerService) + protected readonly openerService: OpenerService; + + @inject(ChatService) + protected readonly chatService: ChatService; + protected editorRef: SimpleMonacoEditor | undefined = undefined; protected readonly editorReady = new Deferred(); @@ -157,6 +165,7 @@ export class AIChatInputWidget extends ReactWidget { onAddContextElement={this.addContextElement.bind(this)} onDeleteContextElement={this.deleteContextElement.bind(this)} context={this.getContext()} + onOpenContextElement={this.openContextElement.bind(this)} chatModel={this._chatModel} pinnedAgent={this._pinnedAgent} editorProvider={this.editorProvider} @@ -175,6 +184,8 @@ export class AIChatInputWidget extends ReactWidget { actionService={this.changeSetActionService} decoratorService={this.changeSetDecoratorService} initialValue={this._initialValue} + openerService={this.openerService} + suggestions={this._chatModel.suggestions} /> ); } @@ -213,6 +224,12 @@ export class AIChatInputWidget extends ReactWidget { }); } + protected async openContextElement(request: AIVariableResolutionRequest): Promise { + const session = this.chatService.getSessions().find(candidate => candidate.model.id === this._chatModel.id); + const context = { session }; + await this.variableService.open(request, context); + } + public setEnabled(enabled: boolean): void { this.isEnabled = enabled; this.update(); @@ -259,6 +276,7 @@ interface ChatInputProperties { onDeleteChangeSetElement: (sessionId: string, index: number) => void; onAddContextElement: () => void; onDeleteContextElement: (index: number) => void; + onOpenContextElement: OpenContextElement; context?: readonly AIVariableResolutionRequest[]; isEnabled?: boolean; chatModel: ChatModel; @@ -275,6 +293,8 @@ interface ChatInputProperties { actionService: ChangeSetActionService; decoratorService: ChangeSetDecoratorService; initialValue?: string; + openerService: OpenerService; + suggestions: readonly ChatSuggestion[] } const ChatInput: React.FunctionComponent = (props: ChatInputProperties) => { @@ -570,9 +590,10 @@ const ChatInput: React.FunctionComponent = (props: ChatInpu disabled: isInputEmpty || !props.isEnabled }]; - const contextUI = buildContextUI(props.context, props.labelProvider, props.onDeleteContextElement); + const contextUI = buildContextUI(props.context, props.labelProvider, props.onDeleteContextElement, props.onOpenContextElement); return
+ {} {props.showChangeSet && changeSetUI?.elements && } @@ -746,7 +767,12 @@ function getLatestRequest(chatModel: ChatModel): ChatRequestModel | undefined { return requests.length > 0 ? requests[requests.length - 1] : undefined; } -function buildContextUI(context: readonly AIVariableResolutionRequest[] | undefined, labelProvider: LabelProvider, onDeleteContextElement: (index: number) => void): ChatContextUI { +function buildContextUI( + context: readonly AIVariableResolutionRequest[] | undefined, + labelProvider: LabelProvider, + onDeleteContextElement: (index: number) => void, + onOpen: OpenContextElement +): ChatContextUI { if (!context) { return { context: [] }; } @@ -758,6 +784,7 @@ function buildContextUI(context: readonly AIVariableResolutionRequest[] | undefi additionalInfo: labelProvider.getDetails(element), details: labelProvider.getLongName(element), delete: () => onDeleteContextElement(index), + open: () => onOpen(element) })) }; } @@ -788,7 +815,7 @@ const ChatContext: React.FunctionComponent = ({ context }) => ( {element.additionalInfo}
- element.delete()} /> + { e.stopPropagation(); element.delete(); }} /> ))} diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/markdown-part-renderer.tsx b/packages/ai-chat-ui/src/browser/chat-response-renderer/markdown-part-renderer.tsx index ee369973ecfa7..fa69ebcccec6a 100644 --- a/packages/ai-chat-ui/src/browser/chat-response-renderer/markdown-part-renderer.tsx +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/markdown-part-renderer.tsx @@ -60,6 +60,10 @@ const MarkdownRender = ({ response, openerService }: { response: MarkdownChatRes return
; }; +export interface DeclaredEventsEventListenerObject extends EventListenerObject { + handledEvents?: (keyof HTMLElementEventMap)[]; +} + /** * This hook uses markdown-it directly to render markdown. * The reason to use markdown-it directly is that the MarkdownRenderer is @@ -72,9 +76,17 @@ const MarkdownRender = ({ response, openerService }: { response: MarkdownChatRes * @param markdown the string to render as markdown * @param skipSurroundingParagraph whether to remove a surrounding paragraph element (default: false) * @param openerService the service to handle link opening + * @param eventHandler `handleEvent` will be called by default for `click` events and additionally + * for all events enumerated in {@link DeclaredEventsEventListenerObject.handledEvents}. If `handleEvent` returns `true`, + * no additional handlers will be run for the event. * @returns the ref to use in an element to render the markdown */ -export const useMarkdownRendering = (markdown: string | MarkdownString, openerService: OpenerService, skipSurroundingParagraph: boolean = false) => { +export const useMarkdownRendering = ( + markdown: string | MarkdownString, + openerService: OpenerService, + skipSurroundingParagraph: boolean = false, + eventHandler?: DeclaredEventsEventListenerObject +) => { // null is valid in React // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -98,6 +110,7 @@ export const useMarkdownRendering = (markdown: string | MarkdownString, openerSe // intercept link clicks to use the Theia OpenerService instead of the default browser behavior const handleClick = (event: MouseEvent) => { + if ((eventHandler?.handleEvent(event) as unknown) === true) {return; } let target = event.target as HTMLElement; while (target && target.tagName !== 'A') { target = target.parentElement as HTMLElement; @@ -112,7 +125,11 @@ export const useMarkdownRendering = (markdown: string | MarkdownString, openerSe }; ref?.current?.addEventListener('click', handleClick); - return () => ref.current?.removeEventListener('click', handleClick); + eventHandler?.handledEvents?.forEach(eventType => eventType !== 'click' && ref?.current?.addEventListener(eventType, eventHandler)); + return () => { + ref.current?.removeEventListener('click', handleClick); + eventHandler?.handledEvents?.forEach(eventType => eventType !== 'click' && ref?.current?.removeEventListener(eventType, eventHandler)); + }; }, [markdownString, skipSurroundingParagraph, openerService]); return ref; diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx index af3a3b93856e6..93e6c03eda83d 100644 --- a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx @@ -29,7 +29,7 @@ import { } from '@theia/ai-chat'; import { AIVariableService } from '@theia/ai-core'; import { AIActivationService } from '@theia/ai-core/lib/browser'; -import { CommandRegistry, ContributionProvider, DisposableCollection, Emitter } from '@theia/core'; +import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter } from '@theia/core'; import { codicon, CompositeTreeNode, @@ -60,7 +60,6 @@ import { ChatResponsePartRenderer } from '../chat-response-part-renderer'; import { useMarkdownRendering } from '../chat-response-renderer/markdown-part-renderer'; import { ProgressMessage } from '../chat-progress-message'; import { AIChatTreeInputFactory, type AIChatTreeInputWidget } from './chat-view-tree-input-widget'; -import { Disposable } from '@theia/core/shared/vscode-languageserver-protocol'; // TODO Instead of directly operating on the ChatRequestModel we could use an intermediate view model export interface RequestNode extends TreeNode { diff --git a/packages/ai-chat-ui/src/browser/chat-view-commands.ts b/packages/ai-chat-ui/src/browser/chat-view-commands.ts index 9c8108862210d..a40e41e595b88 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-commands.ts +++ b/packages/ai-chat-ui/src/browser/chat-view-commands.ts @@ -38,6 +38,24 @@ export namespace ChatCommands { category: CHAT_CATEGORY, iconClass: codicon('bracket') }, 'Set Session Settings', CHAT_CATEGORY_KEY); + + export const AI_CHAT_NEW_WITH_TASK_CONTEXT: Command = { + id: 'ai-chat.new-with-task-context', + }; + + export const AI_CHAT_SUMMARIZE_CURRENT_SESSION = Command.toLocalizedCommand({ + id: 'ai-chat-summary-current-session', + iconClass: codicon('go-to-editing-session'), + label: 'Summarize Current Session', + category: CHAT_CATEGORY + }, undefined, CHAT_CATEGORY_KEY); + + export const AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION = Command.toLocalizedCommand({ + id: 'ai-chat-open-current-session-summary', + iconClass: codicon('note'), + label: 'Open Current Session Summary', + category: CHAT_CATEGORY + }, undefined, CHAT_CATEGORY_KEY); } export const AI_CHAT_NEW_CHAT_WINDOW_COMMAND: Command = { diff --git a/packages/ai-chat-ui/src/browser/chat-view-contribution.ts b/packages/ai-chat-ui/src/browser/chat-view-contribution.ts index befcf07834241..a5cad528fb951 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-contribution.ts +++ b/packages/ai-chat-ui/src/browser/chat-view-contribution.ts @@ -147,7 +147,6 @@ export class ChatViewMenuContribution implements MenuContribution, CommandContri commandId: CommonCommands.PASTE.id }); } - } function hasAsFirstArg(args: unknown[], guard: (arg: unknown) => arg is T): args is [T, ...unknown[]] { diff --git a/packages/ai-chat-ui/src/browser/chat-view-language-contribution.ts b/packages/ai-chat-ui/src/browser/chat-view-language-contribution.ts index 15622ab211bf7..9cb3a2982c1aa 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-language-contribution.ts +++ b/packages/ai-chat-ui/src/browser/chat-view-language-contribution.ts @@ -14,15 +14,15 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { ChatAgentService } from '@theia/ai-chat'; -import { AIContextVariable, AIVariableService } from '@theia/ai-core/lib/common'; +import { AIVariableService } from '@theia/ai-core/lib/common'; import { PromptText } from '@theia/ai-core/lib/common/prompt-text'; import { ToolInvocationRegistry } from '@theia/ai-core/lib/common/tool-invocation-registry'; import { MaybePromise, nls } from '@theia/core'; -import { ApplicationShell, FrontendApplication, FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { FrontendApplication, FrontendApplicationContribution } from '@theia/core/lib/browser'; import { inject, injectable } from '@theia/core/shared/inversify'; import * as monaco from '@theia/monaco-editor-core'; import { ProviderResult } from '@theia/monaco-editor-core/esm/vs/editor/common/languages'; -import { ChatViewWidget } from './chat-view-widget'; +import { AIChatFrontendContribution, VARIABLE_ADD_CONTEXT_COMMAND } from '@theia/ai-chat/lib/browser/ai-chat-frontend-contribution'; export const CHAT_VIEW_LANGUAGE_ID = 'theia-ai-chat-view-language'; export const SETTINGS_LANGUAGE_ID = 'theia-ai-chat-settings-language'; @@ -30,7 +30,6 @@ export const CHAT_VIEW_LANGUAGE_EXTENSION = 'aichatviewlanguage'; const VARIABLE_RESOLUTION_CONTEXT = { context: 'chat-input-autocomplete' }; const VARIABLE_ARGUMENT_PICKER_COMMAND = 'trigger-variable-argument-picker'; -const VARIABLE_ADD_CONTEXT_COMMAND = 'add-context-variable'; interface CompletionSource { triggerCharacter: string; @@ -54,8 +53,8 @@ export class ChatViewLanguageContribution implements FrontendApplicationContribu @inject(ToolInvocationRegistry) protected readonly toolInvocationRegistry: ToolInvocationRegistry; - @inject(ApplicationShell) - protected readonly shell: ApplicationShell; + @inject(AIChatFrontendContribution) + protected readonly chatFrontendContribution: AIChatFrontendContribution; onStart(_app: FrontendApplication): MaybePromise { monaco.languages.register({ id: CHAT_VIEW_LANGUAGE_ID, extensions: [CHAT_VIEW_LANGUAGE_EXTENSION] }); @@ -64,7 +63,6 @@ export class ChatViewLanguageContribution implements FrontendApplicationContribu this.registerCompletionProviders(); monaco.editor.registerCommand(VARIABLE_ARGUMENT_PICKER_COMMAND, this.triggerVariableArgumentPicker.bind(this)); - monaco.editor.registerCommand(VARIABLE_ADD_CONTEXT_COMMAND, (_, ...args) => args.length > 1 ? this.addContextVariable(args[0], args[1]) : undefined); } protected registerCompletionProviders(): void { @@ -205,12 +203,12 @@ export class ChatViewLanguageContribution implements FrontendApplicationContribu const items = await provider(model, position); if (items) { suggestions.push(...items.map(item => ({ - ...item, command: { - title: nls.localize('theia/ai/chat-ui/addContextVariable', 'Add context variable'), - id: VARIABLE_ADD_CONTEXT_COMMAND, + title: VARIABLE_ADD_CONTEXT_COMMAND.label!, + id: VARIABLE_ADD_CONTEXT_COMMAND.id, arguments: [variable.name, item.insertText] - } + }, + ...item, }))); } } @@ -262,22 +260,10 @@ export class ChatViewLanguageContribution implements FrontendApplicationContribu text: arg }]); - await this.addContextVariable(variableName, arg); + await this.chatFrontendContribution.addContextVariable(variableName, arg); } protected getCharacterBeforePosition(model: monaco.editor.ITextModel, position: monaco.Position): string { return model.getLineContent(position.lineNumber)[position.column - 1 - 1]; } - - protected async addContextVariable(variableName: string, arg: string | undefined): Promise { - const variable = this.variableService.getVariable(variableName); - if (!variable || !AIContextVariable.is(variable)) { - return; - } - - const widget = this.shell.getWidgetById(ChatViewWidget.ID); - if (widget instanceof ChatViewWidget) { - widget.addContext({ variable, arg }); - } - } } diff --git a/packages/ai-chat-ui/src/browser/chat-view-widget.tsx b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx index 2d64863715201..42c6603143397 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx @@ -22,6 +22,8 @@ import { AIChatInputWidget } from './chat-input-widget'; import { ChatViewTreeWidget } from './chat-tree-view/chat-view-tree-widget'; import { AIActivationService } from '@theia/ai-core/lib/browser/ai-activation-service'; import { AIVariableResolutionRequest } from '@theia/ai-core'; +import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory'; +import { FrontendVariableService } from '@theia/ai-core/lib/browser'; export namespace ChatViewWidget { export interface State { @@ -50,6 +52,12 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta @inject(AIActivationService) protected readonly activationService: AIActivationService; + @inject(FrontendVariableService) + protected readonly variableService: FrontendVariableService; + + @inject(ProgressBarFactory) + protected readonly progressBarFactory: ProgressBarFactory; + protected chatSession: ChatSession; protected _state: ChatViewWidget.State = { locked: false }; @@ -110,6 +118,7 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta this.inputWidget.setEnabled(change); this.update(); }); + this.toDispose.push(this.progressBarFactory({ container: this.node, insertMode: 'prepend', locationId: 'ai-chat' })); } protected initListeners(): void { diff --git a/packages/ai-chat-ui/src/browser/style/index.css b/packages/ai-chat-ui/src/browser/style/index.css index 3167509c7dd38..567bb09c07b50 100644 --- a/packages/ai-chat-ui/src/browser/style/index.css +++ b/packages/ai-chat-ui/src/browser/style/index.css @@ -212,6 +212,8 @@ div:last-child > .theia-ChatNode { height: 18px; line-height: 16px; min-width: 0; + user-select: none; + cursor: pointer; } .theia-ChatInput-ChatContext-labelParts { @@ -744,3 +746,9 @@ details[open].collapsible-arguments .collapsible-arguments-summary { .session-settings-container .monaco-editor { outline-color: var(--theia-editor-background); } + +.chat-agent-suggestions { + padding-inline: 16px; + padding-block-end: 8px; + user-select: none; +} diff --git a/packages/ai-chat/src/browser/ai-chat-frontend-contribution.ts b/packages/ai-chat/src/browser/ai-chat-frontend-contribution.ts new file mode 100644 index 0000000000000..d5ec8d07f1068 --- /dev/null +++ b/packages/ai-chat/src/browser/ai-chat-frontend-contribution.ts @@ -0,0 +1,49 @@ +// ***************************************************************************** +// 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 { AIContextVariable, AIVariableService } from '@theia/ai-core'; +import { Command, CommandContribution, CommandRegistry } from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { ChatService } from '../common'; + +export const VARIABLE_ADD_CONTEXT_COMMAND: Command = Command.toLocalizedCommand({ + id: 'add-context-variable', + label: 'Add context variable' +}, 'theia/ai/chat-ui/addContextVariable'); + +@injectable() +export class AIChatFrontendContribution implements CommandContribution { + @inject(AIVariableService) + protected readonly variableService: AIVariableService; + @inject(ChatService) + protected readonly chatService: ChatService; + + registerCommands(registry: CommandRegistry): void { + registry.registerCommand(VARIABLE_ADD_CONTEXT_COMMAND, { + execute: (...args) => args.length > 1 && this.addContextVariable(args[0], args[1]), + isVisible: () => false, + }); + } + + async addContextVariable(variableName: string, arg: string | undefined): Promise { + const variable = this.variableService.getVariable(variableName); + if (!variable || !AIContextVariable.is(variable)) { + return; + } + + this.chatService.getActiveSession()?.model.context.addVariables({ variable, arg }); + } +} diff --git a/packages/ai-chat/src/browser/ai-chat-frontend-module.ts b/packages/ai-chat/src/browser/ai-chat-frontend-module.ts index 62310957b7d9d..a614eafcf98ad 100644 --- a/packages/ai-chat/src/browser/ai-chat-frontend-module.ts +++ b/packages/ai-chat/src/browser/ai-chat-frontend-module.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { Agent, AgentService, AIVariableContribution } from '@theia/ai-core/lib/common'; -import { bindContributionProvider, ResourceResolver } from '@theia/core'; +import { bindContributionProvider, CommandContribution } from '@theia/core'; import { FrontendApplicationContribution, LabelProviderContribution, PreferenceContribution } from '@theia/core/lib/browser'; import { ContainerModule } from '@theia/core/shared/inversify'; import { @@ -37,7 +37,6 @@ import { AICustomAgentsFrontendApplicationContribution } from './custom-agent-fr import { FrontendChatServiceImpl } from './frontend-chat-service'; import { CustomAgentFactory } from './custom-agent-factory'; import { ChatToolRequestService } from '../common/chat-tool-request-service'; -import { ChangeSetFileResourceResolver } from './change-set-file-resource'; import { ChangeSetFileService } from './change-set-file-service'; import { ContextVariableLabelProvider } from './context-variable-label-provider'; import { ContextFileVariableLabelProvider } from './context-file-variable-label-provider'; @@ -47,6 +46,12 @@ import { ContextDetailsVariableContribution } from '../common/context-details-va import { ChangeSetVariableContribution } from './change-set-variable'; import { ChatSessionNamingAgent, ChatSessionNamingService } from '../common/chat-session-naming-service'; import { ChangeSetDecorator, ChangeSetDecoratorService } from './change-set-decorator-service'; +import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; +import { TaskContextVariableContribution } from './task-context-variable-contribution'; +import { TaskContextVariableLabelProvider } from './task-context-variable-label-provider'; +import { TaskContextService, TaskContextStorageService } from './task-context-service'; +import { InMemoryTaskContextStorage } from './task-context-storage-service'; +import { AIChatFrontendContribution } from './ai-chat-frontend-contribution'; export default new ContainerModule(bind => { bindContributionProvider(bind, Agent); @@ -111,12 +116,22 @@ export default new ContainerModule(bind => { bind(ChangeSetDecoratorService).toSelf().inSingletonScope(); bind(FrontendApplicationContribution).toService(ChangeSetDecoratorService); bindContributionProvider(bind, ChangeSetDecorator); - - bind(ChangeSetFileResourceResolver).toSelf().inSingletonScope(); - bind(ResourceResolver).toService(ChangeSetFileResourceResolver); bind(ToolCallChatResponseContentFactory).toSelf().inSingletonScope(); bind(AIVariableContribution).to(FileChatVariableContribution).inSingletonScope(); bind(AIVariableContribution).to(ContextSummaryVariableContribution).inSingletonScope(); bind(AIVariableContribution).to(ContextDetailsVariableContribution).inSingletonScope(); bind(AIVariableContribution).to(ChangeSetVariableContribution).inSingletonScope(); + + bind(ChatSessionSummaryAgent).toSelf().inSingletonScope(); + bind(Agent).toService(ChatSessionSummaryAgent); + bind(TaskContextVariableContribution).toSelf().inSingletonScope(); + bind(AIVariableContribution).toService(TaskContextVariableContribution); + bind(TaskContextVariableLabelProvider).toSelf().inSingletonScope(); + bind(LabelProviderContribution).toService(TaskContextVariableLabelProvider); + + bind(TaskContextService).toSelf().inSingletonScope(); + bind(InMemoryTaskContextStorage).toSelf().inSingletonScope(); + bind(TaskContextStorageService).toService(InMemoryTaskContextStorage); + bind(AIChatFrontendContribution).toSelf().inSingletonScope(); + bind(CommandContribution).toService(AIChatFrontendContribution); }); diff --git a/packages/ai-chat/src/browser/change-set-file-element.ts b/packages/ai-chat/src/browser/change-set-file-element.ts index 98185a4d7b0b5..9eba22e7358d1 100644 --- a/packages/ai-chat/src/browser/change-set-file-element.ts +++ b/packages/ai-chat/src/browser/change-set-file-element.ts @@ -17,8 +17,9 @@ import { DisposableCollection, Emitter, URI } from '@theia/core'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Replacement } from '@theia/core/lib/common/content-replacer'; +import { ConfigurableInMemoryResources, ConfigurableMutableReferenceResource } from '@theia/ai-core'; import { ChangeSetElement, ChangeSetImpl } from '../common'; -import { ChangeSetFileResourceResolver, createChangeSetFileUri, UpdatableReferenceResource } from './change-set-file-resource'; +import { createChangeSetFileUri } from './change-set-file-resource'; import { ChangeSetFileService } from './change-set-file-service'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { ConfirmDialog } from '@theia/core/lib/browser'; @@ -67,8 +68,8 @@ export class ChangeSetFileElement implements ChangeSetElement { @inject(FileService) protected readonly fileService: FileService; - @inject(ChangeSetFileResourceResolver) - protected readonly resourceResolver: ChangeSetFileResourceResolver; + @inject(ConfigurableInMemoryResources) + protected readonly inMemoryResources: ConfigurableInMemoryResources; protected readonly toDispose = new DisposableCollection(); protected _state: ChangeSetElementState; @@ -77,9 +78,8 @@ export class ChangeSetFileElement implements ChangeSetElement { protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange = this.onDidChangeEmitter.event; - - protected readOnlyResource: UpdatableReferenceResource; - protected changeResource: UpdatableReferenceResource; + protected readOnlyResource: ConfigurableMutableReferenceResource; + protected changeResource: ConfigurableMutableReferenceResource; @postConstruct() init(): void { @@ -98,17 +98,17 @@ export class ChangeSetFileElement implements ChangeSetElement { } protected getResources(): void { - this.readOnlyResource = this.resourceResolver.tryGet(this.readOnlyUri) ?? this.resourceResolver.add(this.readOnlyUri, { autosaveable: false, readOnly: true }); - let changed = this.resourceResolver.tryGet(this.changedUri); - if (changed) { - changed.update({ contents: this.targetState, onSave: content => this.writeChanges(content) }); - } else { - changed = this.resourceResolver.add(this.changedUri, { contents: this.targetState, onSave: content => this.writeChanges(content), autosaveable: false }); - } - this.changeResource = changed; + this.readOnlyResource = this.getInMemoryUri(this.readOnlyUri); + this.readOnlyResource.update({ autosaveable: false, readOnly: true }); + this.changeResource = this.getInMemoryUri(this.changedUri); + this.changeResource.update({ contents: this.targetState, onSave: content => this.writeChanges(content), autosaveable: false }); this.toDispose.pushAll([this.readOnlyResource, this.changeResource]); } + protected getInMemoryUri(uri: URI): ConfigurableMutableReferenceResource { + try { return this.inMemoryResources.resolve(uri); } catch { return this.inMemoryResources.add(uri, { contents: '' }); } + } + protected listenForOriginalFileChanges(): void { this.toDispose.push(this.fileService.onDidFilesChange(async event => { if (!event.contains(this.uri)) { return; } diff --git a/packages/ai-chat/src/browser/change-set-file-resource.ts b/packages/ai-chat/src/browser/change-set-file-resource.ts index 739eec68891ce..0c292d072cbf7 100644 --- a/packages/ai-chat/src/browser/change-set-file-resource.ts +++ b/packages/ai-chat/src/browser/change-set-file-resource.ts @@ -14,147 +14,10 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { MutableResource, Reference, ReferenceMutableResource, Resource, ResourceResolver, URI } from '@theia/core'; -import { injectable } from '@theia/core/shared/inversify'; +import { URI } from '@theia/core'; export const CHANGE_SET_FILE_RESOURCE_SCHEME = 'changeset-file'; -export type ResourceInitializationOptions = Pick & { contents?: string, onSave?: Resource['saveContents'] }; -export type ResourceUpdateOptions = Pick; export function createChangeSetFileUri(chatSessionId: string, elementUri: URI): URI { return elementUri.withScheme(CHANGE_SET_FILE_RESOURCE_SCHEME).withAuthority(chatSessionId); } - -export class UpdatableReferenceResource extends ReferenceMutableResource { - static acquire(resource: UpdatableReferenceResource): UpdatableReferenceResource { - DisposableRefCounter.acquire(resource.reference); - return resource; - } - - constructor(protected override reference: DisposableRefCounter) { - super(reference); - } - - update(options: ResourceUpdateOptions): void { - this.reference.object.update(options); - } - - get readOnly(): Resource['readOnly'] { - return this.reference.object.readOnly; - } - - get initiallyDirty(): boolean { - return this.reference.object.initiallyDirty; - } - - get autosaveable(): boolean { - return this.reference.object.autosaveable; - } -} - -export class DisposableMutableResource extends MutableResource { - onSave: Resource['saveContents'] | undefined; - constructor(uri: URI, protected readonly options?: ResourceInitializationOptions) { - super(uri); - this.onSave = options?.onSave; - this.contents = options?.contents ?? ''; - } - - get readOnly(): Resource['readOnly'] { - return this.options?.readOnly || !this.onSave; - } - - get autosaveable(): boolean { - return this.options?.autosaveable !== false; - } - - get initiallyDirty(): boolean { - return !!this.options?.initiallyDirty; - } - - override async saveContents(contents: string): Promise { - if (this.options?.onSave) { - await this.options.onSave(contents); - this.update({ contents }); - } - } - - update(options: ResourceUpdateOptions): void { - if (options.contents !== undefined && options.contents !== this.contents) { - this.contents = options.contents; - this.fireDidChangeContents(); - } - if ('onSave' in options && options.onSave !== this.onSave) { - this.onSave = options.onSave; - } - } - - override dispose(): void { - this.onDidChangeContentsEmitter.dispose(); - } -} - -export class DisposableRefCounter implements Reference { - static acquire(item: DisposableRefCounter): DisposableRefCounter { - item.refs++; - return item; - } - static create(value: V, onDispose: () => void): DisposableRefCounter { - return this.acquire(new this(value, onDispose)); - } - readonly object: V; - protected refs = 0; - protected constructor(value: V, protected readonly onDispose: () => void) { - this.object = value; - } - dispose(): void { - this.refs--; - if (this.refs === 0) { - this.onDispose(); - } - } -} - -@injectable() -export class ChangeSetFileResourceResolver implements ResourceResolver { - protected readonly cache = new Map(); - - add(uri: URI, options?: ResourceInitializationOptions): UpdatableReferenceResource { - const key = uri.toString(); - if (this.cache.has(key)) { - throw new Error(`Resource ${key} already exists.`); - } - const underlyingResource = new DisposableMutableResource(uri, options); - const ref = DisposableRefCounter.create(underlyingResource, () => { - underlyingResource.dispose(); - this.cache.delete(key); - }); - const refResource = new UpdatableReferenceResource(ref); - this.cache.set(key, refResource); - return refResource; - } - - tryGet(uri: URI): UpdatableReferenceResource | undefined { - try { - return this.resolve(uri); - } catch { - return undefined; - } - } - - update(uri: URI, contents: string): void { - const key = uri.toString(); - const resource = this.cache.get(key); - if (!resource) { - throw new Error(`No resource for ${key}.`); - } - resource.update({ contents }); - } - - resolve(uri: URI): UpdatableReferenceResource { - const key = uri.toString(); - const ref = this.cache.get(key); - if (!ref) { throw new Error(`No resource for ${key}.`); } - return UpdatableReferenceResource.acquire(ref); - } -} diff --git a/packages/ai-chat/src/browser/change-set-variable.ts b/packages/ai-chat/src/browser/change-set-variable.ts index 33f653b669232..f85f72640f01a 100644 --- a/packages/ai-chat/src/browser/change-set-variable.ts +++ b/packages/ai-chat/src/browser/change-set-variable.ts @@ -18,13 +18,13 @@ import { MaybePromise, nls } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; import { AIVariable, ResolvedAIVariable, AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext } from '@theia/ai-core'; import { WorkspaceService } from '@theia/workspace/lib/browser'; -import { ChatSessionContext } from '../common'; +import { CHANGE_SET_SUMMARY_VARIABLE_ID, ChatSessionContext } from '../common'; export const CHANGE_SET_SUMMARY_VARIABLE: AIVariable = { - id: 'changeSetSummary', + id: CHANGE_SET_SUMMARY_VARIABLE_ID, description: nls.localize('theia/ai/core/changeSetSummaryVariable/description', 'Provides a summary of the files in a change set and their contents.'), - name: 'changeSetSummary', + name: CHANGE_SET_SUMMARY_VARIABLE_ID, }; @injectable() @@ -53,8 +53,10 @@ export class ChangeSetVariableContribution implements AIVariableContribution, AI ); return { variable: CHANGE_SET_SUMMARY_VARIABLE, - value: entries.join('\n') + value: `## Previously Proposed Changes +You have previously proposed changes for the following files. Some suggestions may have been accepted by the user, while others may still be pending. +${entries.join('\n')} +` }; } } - diff --git a/packages/ai-chat/src/browser/file-chat-variable-contribution.ts b/packages/ai-chat/src/browser/file-chat-variable-contribution.ts index a5dce2081b48b..6bbcf1a6f2a02 100644 --- a/packages/ai-chat/src/browser/file-chat-variable-contribution.ts +++ b/packages/ai-chat/src/browser/file-chat-variable-contribution.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { AIVariableContext, AIVariableResolutionRequest, PromptText } from '@theia/ai-core'; -import { AIVariableDropResult, FrontendVariableContribution, FrontendVariableService } from '@theia/ai-core/lib/browser'; +import { AIVariableCompletionContext, AIVariableDropResult, FrontendVariableContribution, FrontendVariableService } from '@theia/ai-core/lib/browser'; import { FILE_VARIABLE } from '@theia/ai-core/lib/browser/file-variable-contribution'; import { CancellationToken, QuickInputService, URI } from '@theia/core'; import { inject, injectable } from '@theia/core/shared/inversify'; @@ -23,6 +23,7 @@ import * as monaco from '@theia/monaco-editor-core'; import { FileQuickPickItem, QuickFileSelectService } from '@theia/file-search/lib/browser/quick-file-select-service'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { VARIABLE_ADD_CONTEXT_COMMAND } from './ai-chat-frontend-contribution'; @injectable() export class FileChatVariableContribution implements FrontendVariableContribution { @@ -72,40 +73,36 @@ export class FileChatVariableContribution implements FrontendVariableContributio position: monaco.Position, matchString?: string ): Promise { - const lineContent = model.getLineContent(position.lineNumber); - const indexOfVariableTrigger = lineContent.lastIndexOf(matchString ?? PromptText.VARIABLE_CHAR, position.column - 1); + const context = AIVariableCompletionContext.get(FILE_VARIABLE.name, model, position, matchString); + if (!context) { return undefined; } + const { userInput, range, prefix } = context; - // check if there is a variable trigger and no space typed between the variable trigger and the cursor - if (indexOfVariableTrigger === -1 || lineContent.substring(indexOfVariableTrigger).includes(' ')) { - return undefined; - } - - // determine whether we are providing completions before or after the variable argument separator - const indexOfVariableArgSeparator = lineContent.lastIndexOf(PromptText.VARIABLE_SEPARATOR_CHAR, position.column - 1); - const triggerCharIndex = Math.max(indexOfVariableTrigger, indexOfVariableArgSeparator); - - const typedWord = lineContent.substring(triggerCharIndex + 1, position.column - 1); - const range = new monaco.Range(position.lineNumber, triggerCharIndex + 2, position.lineNumber, position.column); - const picks = await this.quickFileSelectService.getPicks(typedWord, CancellationToken.None); - const matchVariableChar = lineContent[triggerCharIndex] === (matchString ? matchString : PromptText.VARIABLE_CHAR); - const prefix = matchVariableChar ? FILE_VARIABLE.name + PromptText.VARIABLE_SEPARATOR_CHAR : ''; + const picks = await this.quickFileSelectService.getPicks(userInput, CancellationToken.None); return Promise.all( picks .filter(FileQuickPickItem.is) // only show files with highlights, if the user started typing to filter down the results - .filter(p => !typedWord || p.highlights?.label) - .map(async (pick, index) => ({ - label: pick.label, - kind: monaco.languages.CompletionItemKind.File, - range, - insertText: `${prefix}${await this.wsService.getWorkspaceRelativePath(pick.uri)}`, - detail: await this.wsService.getWorkspaceRelativePath(pick.uri.parent), - // don't let monaco filter the items, as we only return picks that are filtered - filterText: typedWord, - // keep the order of the items, but move them to the end of the list - sortText: `ZZ${index.toString().padStart(4, '0')}_${pick.label}`, - })) + .filter(p => !userInput || p.highlights?.label) + .map(async (pick, index) => { + const relativePath = await this.wsService.getWorkspaceRelativePath(pick.uri); + return { + label: pick.label, + kind: monaco.languages.CompletionItemKind.File, + range, + insertText: `${prefix}${relativePath}`, + detail: await this.wsService.getWorkspaceRelativePath(pick.uri.parent), + // don't let monaco filter the items, as we only return picks that are filtered + filterText: userInput, + // keep the order of the items, but move them to the end of the list + sortText: `ZZ${index.toString().padStart(4, '0')}_${pick.label}`, + command: { + title: VARIABLE_ADD_CONTEXT_COMMAND.label!, + id: VARIABLE_ADD_CONTEXT_COMMAND.id, + arguments: [FILE_VARIABLE.name, relativePath] + } + }; + }) ); } diff --git a/packages/ai-chat/src/browser/task-context-service.ts b/packages/ai-chat/src/browser/task-context-service.ts new file mode 100644 index 0000000000000..178da823e1e11 --- /dev/null +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -0,0 +1,144 @@ +// ***************************************************************************** +// 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { MaybePromise, ProgressService, URI, generateUuid, Event } from '@theia/core'; +import { ChatAgent, ChatAgentLocation, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel, ParsedChatRequestTextPart } from '../common'; +import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { AgentService, PromptService } from '@theia/ai-core'; +import { CHAT_SESSION_SUMMARY_PROMPT } from '../common/chat-session-summary-agent-prompt'; + +export interface SummaryMetadata { + label: string; + uri?: URI; + sessionId?: string; +} + +export interface Summary extends SummaryMetadata { + summary: string; + id: string; +} + +export const TaskContextStorageService = Symbol('TextContextStorageService'); +export interface TaskContextStorageService { + onDidChange: Event; + store(summary: Summary): MaybePromise; + getAll(): Summary[]; + get(identifier: string): Summary | undefined; + delete(identifier: string): MaybePromise; + open(identifier: string): Promise; +} + +@injectable() +export class TaskContextService { + + protected pendingSummaries = new Map>(); + + @inject(ChatService) protected readonly chatService: ChatService; + @inject(AgentService) protected readonly agentService: AgentService; + @inject(PromptService) protected readonly promptService: PromptService; + @inject(TaskContextStorageService) protected readonly storageService: TaskContextStorageService; + @inject(ProgressService) protected readonly progressService: ProgressService; + + get onDidChange(): Event { + return this.storageService.onDidChange; + } + + getAll(): Array { + return this.storageService.getAll(); + } + + async getSummary(sessionIdOrFilePath: string): Promise { + const existing = this.storageService.get(sessionIdOrFilePath); + if (existing) { return existing.summary; } + const pending = this.pendingSummaries.get(sessionIdOrFilePath); + if (pending) { + return pending.then(({ summary }) => summary); + } + const session = this.chatService.getSession(sessionIdOrFilePath); + if (session) { + return this.summarize(session); + } + throw new Error('Unable to resolve summary request.'); + } + + /** Returns an ID that can be used to refer to the summary in the future. */ + async summarize(session: ChatSession, promptId?: string, agent?: ChatAgent): Promise { + const pending = this.pendingSummaries.get(session.id); + if (pending) { return pending.then(({ id }) => id); } + const existing = this.getSummaryForSession(session); + if (existing) { return existing.id; } + const summaryId = generateUuid(); + const summaryDeferred = new Deferred(); + const progress = await this.progressService.showProgress({ text: `Summarize: ${session.title || session.id}`, options: { location: 'ai-chat' } }); + this.pendingSummaries.set(session.id, summaryDeferred.promise); + try { + const newSummary: Summary = { + summary: await this.getLlmSummary(session, promptId, agent), + label: session.title || session.id, + sessionId: session.id, + id: summaryId + }; + await this.storageService.store(newSummary); + return summaryId; + } catch (err) { + summaryDeferred.reject(err); + throw err; + } finally { + progress.cancel(); + this.pendingSummaries.delete(session.id); + } + } + + protected async getLlmSummary(session: ChatSession, promptId: string = CHAT_SESSION_SUMMARY_PROMPT.id, agent?: ChatAgent): Promise { + agent = agent || this.agentService.getAgents().find((candidate): candidate is ChatAgent => + 'invoke' in candidate + && typeof candidate.invoke === 'function' + && candidate.id === ChatSessionSummaryAgent.ID + ); + if (!agent) { throw new Error('Unable to identify agent for summary.'); } + const model = new MutableChatModel(ChatAgentLocation.Panel); + const prompt = await this.promptService.getPrompt(promptId || CHAT_SESSION_SUMMARY_PROMPT.id, undefined, { model: session.model }); + if (!prompt) { return ''; } + const messages = session.model.getRequests().filter((candidate): candidate is MutableChatRequestModel => candidate instanceof MutableChatRequestModel); + messages.forEach(message => model['_hierarchy'].append(message)); + const summaryRequest = model.addRequest({ + variables: prompt.variables ?? [], + request: { text: prompt.text }, + parts: [new ParsedChatRequestTextPart({ start: 0, endExclusive: prompt.text.length }, prompt.text)], + toolRequests: prompt.functionDescriptions ?? new Map() + }, agent.id); + await agent.invoke(summaryRequest); + return summaryRequest.response.response.asDisplayString(); + } + + hasSummary(chatSession: ChatSession): boolean { + return !!this.getSummaryForSession(chatSession); + } + + protected getSummaryForSession(chatSession: ChatSession): Summary | undefined { + return this.storageService.getAll().find(candidate => candidate.sessionId === chatSession.id); + } + + getLabel(id: string): string | undefined { + return this.storageService.get(id)?.label; + } + + open(id: string): Promise { + return this.storageService.open(id); + } +} diff --git a/packages/ai-chat/src/browser/task-context-storage-service.ts b/packages/ai-chat/src/browser/task-context-storage-service.ts new file mode 100644 index 0000000000000..a08073a2466a4 --- /dev/null +++ b/packages/ai-chat/src/browser/task-context-storage-service.ts @@ -0,0 +1,75 @@ +// ***************************************************************************** +// 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { Summary, TaskContextStorageService } from './task-context-service'; +import { Emitter } from '@theia/core'; +import { AIVariableResourceResolver } from '@theia/ai-core'; +import { TASK_CONTEXT_VARIABLE } from './task-context-variable'; +import { open, OpenerService } from '@theia/core/lib/browser'; + +@injectable() +export class InMemoryTaskContextStorage implements TaskContextStorageService { + protected summaries = new Map(); + + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + + @inject(AIVariableResourceResolver) + protected readonly variableResourceResolver: AIVariableResourceResolver; + + @inject(OpenerService) + protected readonly openerService: OpenerService; + + store(summary: Summary): void { + this.summaries.set(summary.id, summary); + this.onDidChangeEmitter.fire(); + } + + getAll(): Summary[] { + return Array.from(this.summaries.values()); + } + + get(identifier: string): Summary | undefined { + return this.summaries.get(identifier); + } + + delete(identifier: string): boolean { + const didDelete = this.summaries.delete(identifier); + if (didDelete) { + this.onDidChangeEmitter.fire(); + } + return didDelete; + } + + clear(): void { + if (this.summaries.size) { + this.summaries.clear(); + this.onDidChangeEmitter.fire(); + } + } + + async open(identifier: string): Promise { + const summary = this.get(identifier); + if (!summary) { + throw new Error('Unable to upon requested task context: none found.'); + } + const resource = this.variableResourceResolver.getOrCreate({ variable: TASK_CONTEXT_VARIABLE, arg: identifier }, {}, summary.summary); + resource.update({ onSave: async content => { summary.summary = content; }, readOnly: false }); + await open(this.openerService, resource.uri); + resource.dispose(); + } +} diff --git a/packages/ai-chat/src/browser/task-context-variable-contribution.ts b/packages/ai-chat/src/browser/task-context-variable-contribution.ts new file mode 100644 index 0000000000000..f0ae5e8449799 --- /dev/null +++ b/packages/ai-chat/src/browser/task-context-variable-contribution.ts @@ -0,0 +1,93 @@ +// ***************************************************************************** +// 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { AIVariableContext, AIVariableOpener, AIVariableResolutionRequest, AIVariableResolver, ResolvedAIContextVariable } from '@theia/ai-core'; +import { AIVariableCompletionContext, FrontendVariableContribution, FrontendVariableService } from '@theia/ai-core/lib/browser'; +import { MaybePromise, QuickInputService, QuickPickItem } from '@theia/core'; +import { ChatService } from '../common'; +import * as monaco from '@theia/monaco-editor-core'; +import { TaskContextService } from './task-context-service'; +import { TASK_CONTEXT_VARIABLE } from './task-context-variable'; +import { VARIABLE_ADD_CONTEXT_COMMAND } from './ai-chat-frontend-contribution'; + +@injectable() +export class TaskContextVariableContribution implements FrontendVariableContribution, AIVariableResolver, AIVariableOpener { + @inject(QuickInputService) protected readonly quickInputService: QuickInputService; + @inject(ChatService) protected readonly chatService: ChatService; + @inject(TaskContextService) protected readonly taskContextService: TaskContextService; + + registerVariables(service: FrontendVariableService): void { + service.registerResolver(TASK_CONTEXT_VARIABLE, this); + service.registerArgumentPicker(TASK_CONTEXT_VARIABLE, this.pickSession.bind(this)); + service.registerArgumentCompletionProvider(TASK_CONTEXT_VARIABLE, this.provideCompletionItems.bind(this)); + service.registerOpener(TASK_CONTEXT_VARIABLE, this); + } + + protected async pickSession(): Promise { + const items = this.getItems(); + const selection = await this.quickInputService.showQuickPick(items); + return selection?.id; + } + + protected async provideCompletionItems( + model: monaco.editor.ITextModel, + position: monaco.Position, + matchString?: string + ): Promise { + const context = AIVariableCompletionContext.get(TASK_CONTEXT_VARIABLE.name, model, position, matchString); + if (!context) { return undefined; } + const { userInput, range, prefix } = context; + return this.getItems().filter(candidate => QuickPickItem.is(candidate) && candidate.label.startsWith(userInput)).map(({ label, id }: QuickPickItem) => ({ + label, + kind: monaco.languages.CompletionItemKind.Class, + range, + insertText: `${prefix}${id}`, + detail: id, + filterText: userInput, + command: { + title: VARIABLE_ADD_CONTEXT_COMMAND.label!, + id: VARIABLE_ADD_CONTEXT_COMMAND.id, + arguments: [TASK_CONTEXT_VARIABLE.name, id] + } + })); + } + + protected getItems(): QuickPickItem[] { + const currentSession = this.chatService.getSessions().find(candidate => candidate.isActive); + const existingSummaries = this.taskContextService.getAll().filter(candidate => !currentSession || currentSession.id !== candidate.sessionId); + return existingSummaries; + } + + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { + return request.variable.id === TASK_CONTEXT_VARIABLE.id ? 10000 : -5; + } + + async resolve(request: AIVariableResolutionRequest, _context: AIVariableContext): Promise { + if (request.variable.id !== TASK_CONTEXT_VARIABLE.id || !request.arg) { return; } + const value = await this.taskContextService.getSummary(request.arg).catch(() => undefined); + return value ? { ...request, value, contextValue: value } : undefined; + } + + canOpen(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { + return this.canResolve(request, context); + } + + async open(request: AIVariableResolutionRequest, _context: AIVariableContext): Promise { + if (request.variable.id !== TASK_CONTEXT_VARIABLE.id || !request.arg) { throw new Error('Unable to service open request.'); } + return this.taskContextService.open(request.arg); + } +} diff --git a/packages/ai-chat/src/browser/task-context-variable-label-provider.ts b/packages/ai-chat/src/browser/task-context-variable-label-provider.ts new file mode 100644 index 0000000000000..11d634b1dda5b --- /dev/null +++ b/packages/ai-chat/src/browser/task-context-variable-label-provider.ts @@ -0,0 +1,67 @@ +// ***************************************************************************** +// Copyright (C) 2025 Eclipse 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 { AIVariableResolutionRequest } from '@theia/ai-core'; +import { URI } from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { codicon, LabelProviderContribution } from '@theia/core/lib/browser'; +import { TaskContextVariableContribution } from './task-context-variable-contribution'; +import { ChatService } from '../common'; +import { TaskContextService } from './task-context-service'; +import { TASK_CONTEXT_VARIABLE } from './task-context-variable'; + +@injectable() +export class TaskContextVariableLabelProvider implements LabelProviderContribution { + @inject(ChatService) protected readonly chatService: ChatService; + @inject(TaskContextVariableContribution) protected readonly chatVariableContribution: TaskContextVariableContribution; + @inject(TaskContextService) protected readonly taskContextService: TaskContextService; + protected isMine(element: object): element is AIVariableResolutionRequest & { arg: string } { + return AIVariableResolutionRequest.is(element) && element.variable.id === TASK_CONTEXT_VARIABLE.id && !!element.arg; + } + + canHandle(element: object): number { + return this.isMine(element) ? 10 : -1; + } + + getIcon(element: object): string | undefined { + if (!this.isMine(element)) { return undefined; } + return codicon('clippy'); + } + + getName(element: object): string | undefined { + if (!this.isMine(element)) { return undefined; } + const session = this.chatService.getSession(element.arg); + return session?.title ?? this.taskContextService.getLabel(element.arg) ?? session?.id ?? element.arg; + } + + getLongName(element: object): string | undefined { + const short = this.getName(element); + const details = this.getDetails(element); + return `'${short}' (${details})`; + } + + getDetails(element: object): string | undefined { + if (!this.isMine(element)) { return undefined; } + return `id: ${element.arg}`; + } + + protected getUri(element: object): URI | undefined { + if (!AIVariableResolutionRequest.is(element)) { + return undefined; + } + return new URI(element.arg); + } +} diff --git a/packages/ai-chat/src/browser/task-context-variable.ts b/packages/ai-chat/src/browser/task-context-variable.ts new file mode 100644 index 0000000000000..6704141e491ed --- /dev/null +++ b/packages/ai-chat/src/browser/task-context-variable.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// 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 { AIVariable } from '@theia/ai-core'; +import { codiconArray } from '@theia/core/lib/browser'; + +export const TASK_CONTEXT_VARIABLE: AIVariable = { + id: 'taskContext', + description: 'Provides context information for a task, e.g. the plan for completing a task or a summary of a previous sessions', + name: 'taskContext', + label: 'Task Context', + iconClasses: codiconArray('clippy'), + isContextVariable: true, + args: [{ name: 'context-id', description: 'The ID of the task context to retrieve, or a chat session to summarize.' }] +}; diff --git a/packages/ai-chat/src/common/chat-agents.ts b/packages/ai-chat/src/common/chat-agents.ts index 165a03cdb9be4..344b38c5495bf 100644 --- a/packages/ai-chat/src/common/chat-agents.ts +++ b/packages/ai-chat/src/common/chat-agents.ts @@ -60,7 +60,7 @@ import { MarkdownChatResponseContentImpl, ToolCallChatResponseContentImpl, ChatRequestModel, - ThinkingChatResponseContentImpl + ThinkingChatResponseContentImpl, } from './chat-model'; import { parseContents } from './parse-contents'; import { DefaultResponseContentFactory, ResponseContentMatcher, ResponseContentMatcherProvider } from './response-content-matcher'; diff --git a/packages/ai-chat/src/common/chat-model.ts b/packages/ai-chat/src/common/chat-model.ts index 8f39875ba56f4..299e75860c4fb 100644 --- a/packages/ai-chat/src/common/chat-model.ts +++ b/packages/ai-chat/src/common/chat-model.ts @@ -38,6 +38,7 @@ export type ChatChangeEvent = | ChatSetVariablesEvent | ChatRemoveRequestEvent | ChatSetChangeSetEvent + | ChatSuggestionsChangedEvent | ChatUpdateChangeSetEvent | ChatRemoveChangeSetEvent | ChatEditRequestEvent @@ -107,6 +108,11 @@ export interface ChatSetVariablesEvent { kind: 'setVariables'; } +export interface ChatSuggestionsChangedEvent { + kind: 'suggestionsChanged'; + suggestions: ChatSuggestion[]; +} + export namespace ChatChangeEvent { export function isChangeSetEvent(event: ChatChangeEvent): event is ChatSetChangeSetEvent | ChatUpdateChangeSetEvent | ChatRemoveChangeSetEvent { return event.kind === 'setChangeSet' || event.kind === 'removeChangeSet' || event.kind === 'updateChangeSet'; @@ -188,6 +194,7 @@ export interface ChatModel { readonly location: ChatAgentLocation; readonly changeSet?: ChangeSet; readonly context: ChatContextManager; + readonly suggestions: readonly ChatSuggestion[]; readonly settings?: { [key: string]: unknown }; getRequests(): ChatRequestModel[]; getBranches(): ChatHierarchyBranch[]; @@ -201,6 +208,24 @@ export interface ChangeSet extends Disposable { dispose(): void; } +export interface ChatSuggestionCallback { + kind: 'callback', + callback: () => unknown; + content: string | MarkdownString; +} +export namespace ChatSuggestionCallback { + export function is(candidate: ChatSuggestion): candidate is ChatSuggestionCallback { + return typeof candidate === 'object' && 'callback' in candidate; + } + export function containsCallbackLink(candidate: ChatSuggestion): candidate is ChatSuggestionCallback { + if (!is(candidate)) { return false; } + const text = typeof candidate.content === 'string' ? candidate.content : candidate.content.value; + return text.includes('](_callback)'); + } +} + +export type ChatSuggestion = | string | MarkdownString | ChatSuggestionCallback; + export interface ChatContextManager { onDidChange: Event; getVariables(): readonly AIVariableResolutionRequest[] @@ -670,6 +695,7 @@ export class MutableChatModel implements ChatModel, Disposable { protected _hierarchy: ChatRequestHierarchy; protected _id: string; protected _changeSet?: ChangeSetImpl; + protected _suggestions: readonly ChatSuggestion[] = []; protected readonly _contextManager = new ChatContextManagerImpl(); protected _settings: { [key: string]: unknown }; @@ -712,6 +738,10 @@ export class MutableChatModel implements ChatModel, Disposable { return this._changeSet; } + get suggestions(): readonly ChatSuggestion[] { + return this._suggestions; + } + get context(): ChatContextManager { return this._contextManager; } @@ -771,6 +801,14 @@ export class MutableChatModel implements ChatModel, Disposable { return requestModel; } + setSuggestions(suggestions: ChatSuggestion[]): void { + this._suggestions = Object.freeze(suggestions); + this._onDidChangeEmitter.fire({ + kind: 'suggestionsChanged', + suggestions + }); + } + isEmpty(): boolean { return this.getRequests().length === 0; } @@ -1513,8 +1551,7 @@ export const COMMAND_CHAT_RESPONSE_COMMAND: Command = { export class CommandChatResponseContentImpl implements CommandChatResponseContent { readonly kind = 'command'; - constructor(public command?: Command, public customCallback?: CustomCallback, protected args?: unknown[]) { - } + constructor(public command?: Command, public customCallback?: CustomCallback, protected args?: unknown[]) { } get arguments(): unknown[] { return this.args ?? []; diff --git a/packages/ai-chat/src/common/chat-request-parser.ts b/packages/ai-chat/src/common/chat-request-parser.ts index 7fe452236182e..d6a4c70c4061d 100644 --- a/packages/ai-chat/src/common/chat-request-parser.ts +++ b/packages/ai-chat/src/common/chat-request-parser.ts @@ -55,7 +55,7 @@ function offsetRange(start: number, endExclusive: number): OffsetRange { return { start, endExclusive }; } @injectable() -export class ChatRequestParserImpl { +export class ChatRequestParserImpl implements ChatRequestParser { constructor( @inject(ChatAgentService) private readonly agentService: ChatAgentService, @inject(AIVariableService) private readonly variableService: AIVariableService, @@ -90,7 +90,7 @@ export class ChatRequestParserImpl { } // Get resolved variables from variable cache after all variables have been resolved. - // We want to return all recursilvely resolved variables, thus use the whole cache. + // We want to return all recursively resolved variables, thus use the whole cache. const resolvedVariables = await getAllResolvedAIVariables(variableCache); return { request, parts, toolRequests, variables: resolvedVariables }; diff --git a/packages/ai-chat/src/common/chat-service.ts b/packages/ai-chat/src/common/chat-service.ts index a7a49bfb3260b..85dc3ecc1d1f1 100644 --- a/packages/ai-chat/src/common/chat-service.ts +++ b/packages/ai-chat/src/common/chat-service.ts @@ -38,6 +38,7 @@ import { import { ChatRequestParser } from './chat-request-parser'; import { ParsedChatRequest, ParsedChatRequestAgentPart } from './parsed-chat-request'; import { ChatSessionNamingService } from './chat-session-naming-service'; +import { Deferred } from '@theia/core/lib/common/promise-util'; export interface ChatRequestInvocation { /** @@ -125,6 +126,7 @@ export interface ChatService { getSessions(): ChatSession[]; createSession(location?: ChatAgentLocation, options?: SessionOptions, pinnedAgent?: ChatAgent): ChatSession; deleteSession(sessionId: string): void; + getActiveSession(): ChatSession | undefined; setActiveSession(sessionId: string, options?: SessionOptions): void; sendRequest( @@ -208,6 +210,12 @@ export class ChatServiceImpl implements ChatService { this.onSessionEventEmitter.fire({ type: 'deleted', sessionId: sessionId }); } + getActiveSession(): ChatSession | undefined { + const activeSessions = this._sessions.filter(candidate => candidate.isActive); + if (activeSessions.length > 1) { throw new Error('More than one session marked as active. This indicates an error in ChatService.'); } + return activeSessions.at(0); + } + setActiveSession(sessionId: string | undefined, options?: SessionOptions): void { this._sessions.forEach(session => { session.isActive = session.id === sessionId; @@ -244,33 +252,23 @@ export class ChatServiceImpl implements ChatService { this.updateSessionMetadata(session, requestModel); resolutionContext.request = requestModel; - let resolveResponseCreated: (responseModel: ChatResponseModel) => void; - let resolveResponseCompleted: (responseModel: ChatResponseModel) => void; + const responseCompletionDeferred = new Deferred(); const invocation: ChatRequestInvocation = { requestCompleted: Promise.resolve(requestModel), - responseCreated: new Promise(resolve => { - resolveResponseCreated = resolve; - }), - responseCompleted: new Promise(resolve => { - resolveResponseCompleted = resolve; - }), + responseCreated: Promise.resolve(requestModel.response), + responseCompleted: responseCompletionDeferred.promise, }; - resolveResponseCreated!(requestModel.response); requestModel.response.onDidChange(() => { if (requestModel.response.isComplete) { - resolveResponseCompleted!(requestModel.response); + responseCompletionDeferred.resolve(requestModel.response); } if (requestModel.response.isError) { - resolveResponseCompleted!(requestModel.response); + responseCompletionDeferred.resolve(requestModel.response); } }); - if (agent) { - agent.invoke(requestModel).catch(error => requestModel.response.error(error)); - } else { - this.logger.error('No ChatAgents available to handle request!', requestModel); - } + agent.invoke(requestModel).catch(error => requestModel.response.error(error)); return invocation; } @@ -304,15 +302,8 @@ export class ChatServiceImpl implements ChatService { context: ChatSessionContext, ): Promise { // TODO use a common cache to resolve variables and return recursively resolved variables? - const resolvedVariables = await Promise.all( - resolutionRequests.map(async contextVariable => { - const resolvedVariable = await this.variableService.resolveVariable(contextVariable, context); - if (ResolvedAIContextVariable.is(resolvedVariable)) { - return resolvedVariable; - } - return undefined; - }) - ).then(results => results.filter((result): result is ResolvedAIContextVariable => result !== undefined)); + const resolvedVariables = await Promise.all(resolutionRequests.map(async contextVariable => this.variableService.resolveVariable(contextVariable, context))) + .then(results => results.filter(ResolvedAIContextVariable.is)); return { variables: resolvedVariables }; } diff --git a/packages/ai-chat/src/common/chat-session-naming-service.ts b/packages/ai-chat/src/common/chat-session-naming-service.ts index c10f6fc24bf4c..35f3e055baa3c 100644 --- a/packages/ai-chat/src/common/chat-session-naming-service.ts +++ b/packages/ai-chat/src/common/chat-session-naming-service.ts @@ -18,6 +18,7 @@ import { Agent, AgentService, CommunicationRecordingService, + CommunicationRequestEntryParam, getTextOfResponse, LanguageModelRegistry, LanguageModelRequirement, @@ -38,7 +39,7 @@ const CHAT_SESSION_NAMING_PROMPT = { 'Use the same language for the chat conversation name as used in the provided conversation, if in doubt default to English. ' + 'Start the chat conversation name with an upper-case letter. ' + 'Below we also provide the already existing other conversation names, make sure your suggestion for a name is unique with respect to the existing ones.\n\n' + - 'IMPORTANT: Your answer MUST ONLY CONTAIN THE PROPOSED NAME and must not be preceded or succeeded with any other text.' + + 'IMPORTANT: Your answer MUST ONLY CONTAIN THE PROPOSED NAME and must not be preceded or followed by any other text.' + '\n\nOther session names:\n{{listOfSessionNames}}' + '\n\nConversation:\n{{conversation}}', }; @@ -92,7 +93,7 @@ export class ChatSessionNamingAgent implements Agent { } const conversation = chatSession.model.getRequests() - .map(req => `${req.request.text}` + + .map(req => `${req.message.parts.map(chunk => chunk.promptText).join('')}` + (req.response.response ? `${req.response.response.asString()}` : '')) .join('\n\n'); const listOfSessionNames = otherNames.map(name => name).join(', '); @@ -115,7 +116,7 @@ export class ChatSessionNamingAgent implements Agent { sessionId, agentId: this.id }; - this.recordingService.recordRequest(request); + this.recordingService.recordRequest({ ...request, request: request.messages } satisfies CommunicationRequestEntryParam); const result = await lm.request(request); const response = await getTextOfResponse(result); diff --git a/packages/ai-chat/src/common/chat-session-summary-agent-prompt.ts b/packages/ai-chat/src/common/chat-session-summary-agent-prompt.ts new file mode 100644 index 0000000000000..c97a663094084 --- /dev/null +++ b/packages/ai-chat/src/common/chat-session-summary-agent-prompt.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/tslint/config */ +// ***************************************************************************** +// Copyright (C) 2025 EclipseSource GmbH and others. +// +// This file is licensed under the MIT License. +// See LICENSE-MIT.txt in the project root for license information. +// https://opensource.org/license/mit. +// +// SPDX-License-Identifier: MIT + +import { CHANGE_SET_SUMMARY_VARIABLE_ID } from './context-variables'; + +export const CHAT_SESSION_SUMMARY_PROMPT = { + id: 'chat-session-summary-prompt', + template: `{{!-- !-- This prompt is licensed under the MIT License (https://opensource.org/license/mit). +Made improvements or adaptations to this prompt template? We\'d love for you to share it with the community! Contribute back here: ' + + 'https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}}\n\n' + + 'You are a chat agent for summarizing AI agent chat sessions for later use. \ +Review the conversation above and generate a concise summary that captures every crucial detail, \ +including all requirements, decisions, and pending tasks. \ +Ensure that the summary is sufficiently comprehensive to allow seamless continuation of the workflow. The summary will primarily be used by other AI agents, so tailor your \ +response for use by AI agents. \ +Also consider the system message. +Make sure you include all necessary context information and use unique references (such as URIs, file paths, etc.). +If the conversation was about a task, describe the state of the task, i.e. what has been completed and what is open. +If a changeset is open in the session, describe the state of the suggested changes. +{{${CHANGE_SET_SUMMARY_VARIABLE_ID}}}`, +}; diff --git a/packages/ai-chat/src/common/chat-session-summary-agent.ts b/packages/ai-chat/src/common/chat-session-summary-agent.ts new file mode 100644 index 0000000000000..fd12d5d2f25bd --- /dev/null +++ b/packages/ai-chat/src/common/chat-session-summary-agent.ts @@ -0,0 +1,42 @@ +// ***************************************************************************** +// 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 { + LanguageModelRequirement, + PromptTemplate +} from '@theia/ai-core'; +import { injectable } from '@theia/core/shared/inversify'; +import { AbstractStreamParsingChatAgent, ChatAgent } from './chat-agents'; +import { CHAT_SESSION_SUMMARY_PROMPT } from './chat-session-summary-agent-prompt'; + +@injectable() +export class ChatSessionSummaryAgent extends AbstractStreamParsingChatAgent implements ChatAgent { + static ID = 'chat-session-summary-agent'; + id = ChatSessionSummaryAgent.ID; + name = 'Chat Session Summary'; + override description = 'Agent for generating chat session summaries.'; + override variables = []; + override promptTemplates: PromptTemplate[] = [CHAT_SESSION_SUMMARY_PROMPT]; + protected readonly defaultLanguageModelPurpose = 'chat-session-summary'; + languageModelRequirements: LanguageModelRequirement[] = [{ + purpose: 'chat-session-summary', + identifier: 'openai/gpt-4o-mini', + }]; + override agentSpecificVariables = []; + override functions = []; + override locations = []; + override tags = []; +} diff --git a/packages/ai-core/package.json b/packages/ai-core/package.json index d73743a3d2c1a..741afaa8fed73 100644 --- a/packages/ai-core/package.json +++ b/packages/ai-core/package.json @@ -12,6 +12,7 @@ "@theia/variable-resolver": "1.60.0", "@theia/workspace": "1.60.0", "@types/js-yaml": "^4.0.9", + "fast-deep-equal": "^3.1.3", "js-yaml": "^4.1.0", "minimatch": "^5.1.0", "tslib": "^2.6.2" 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 dd7a794a410c6..ea86f0c810966 100644 --- a/packages/ai-core/src/browser/ai-core-frontend-module.ts +++ b/packages/ai-core/src/browser/ai-core-frontend-module.ts @@ -13,7 +13,7 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { bindContributionProvider, CommandContribution, CommandHandler } from '@theia/core'; +import { bindContributionProvider, CommandContribution, CommandHandler, ResourceResolver } from '@theia/core'; import { RemoteConnectionProvider, ServiceConnectionProvider, @@ -38,17 +38,17 @@ import { ToolProvider, TokenUsageService, TOKEN_USAGE_SERVICE_PATH, - TokenUsageServiceClient + TokenUsageServiceClient, + AIVariableResourceResolver, + ConfigurableInMemoryResources } from '../common'; import { FrontendLanguageModelRegistryImpl, LanguageModelDelegateClientImpl, } from './frontend-language-model-registry'; - -import { FrontendApplicationContribution, PreferenceContribution } from '@theia/core/lib/browser'; +import { FrontendApplicationContribution, LabelProviderContribution, PreferenceContribution } from '@theia/core/lib/browser'; import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; import { LanguageGrammarDefinitionContribution } from '@theia/monaco/lib/browser/textmate'; - import { AICoreFrontendApplicationContribution } from './ai-core-frontend-application-contribution'; import { bindAICorePreferences } from './ai-core-preferences'; import { AgentSettingsPreferenceSchema } from './agent-preferences'; @@ -70,6 +70,7 @@ import { LanguageModelService } from '../common/language-model-service'; import { FrontendLanguageModelServiceImpl } from './frontend-language-model-service'; import { TokenUsageFrontendService } from './token-usage-frontend-service'; import { TokenUsageFrontendServiceImpl, TokenUsageServiceClientImpl } from './token-usage-frontend-service-impl'; +import { AIVariableUriLabelProvider } from './ai-variable-uri-label-provider'; export default new ContainerModule(bind => { bindContributionProvider(bind, LanguageModelProvider); @@ -158,4 +159,10 @@ export default new ContainerModule(bind => { const client = ctx.container.get(TokenUsageServiceClient); return connection.createProxy(TOKEN_USAGE_SERVICE_PATH, client); }).inSingletonScope(); + bind(AIVariableResourceResolver).toSelf().inSingletonScope(); + bind(ResourceResolver).toService(AIVariableResourceResolver); + bind(AIVariableUriLabelProvider).toSelf().inSingletonScope(); + bind(LabelProviderContribution).toService(AIVariableUriLabelProvider); + bind(ConfigurableInMemoryResources).toSelf().inSingletonScope(); + bind(ResourceResolver).toService(ConfigurableInMemoryResources); }); diff --git a/packages/ai-core/src/browser/ai-variable-uri-label-provider.ts b/packages/ai-core/src/browser/ai-variable-uri-label-provider.ts new file mode 100644 index 0000000000000..d9c3b41a44f38 --- /dev/null +++ b/packages/ai-core/src/browser/ai-variable-uri-label-provider.ts @@ -0,0 +1,66 @@ +// ***************************************************************************** +// Copyright (C) 2025 Eclipse 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { URI } from '@theia/core'; +import { LabelProvider, LabelProviderContribution } from '@theia/core/lib/browser'; +import { AI_VARIABLE_RESOURCE_SCHEME, AIVariableResourceResolver } from '../common/ai-variable-resource'; +import { AIVariableResolutionRequest, AIVariableService } from '../common/variable-service'; + +@injectable() +export class AIVariableUriLabelProvider implements LabelProviderContribution { + + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + @inject(AIVariableResourceResolver) protected variableResourceResolver: AIVariableResourceResolver; + @inject(AIVariableService) protected readonly variableService: AIVariableService; + + protected isMine(element: object): element is URI { + return element instanceof URI && element.scheme === AI_VARIABLE_RESOURCE_SCHEME; + } + + canHandle(element: object): number { + return this.isMine(element) ? 150 : -1; + } + + getIcon(element: object): string | undefined { + if (!this.isMine(element)) { return undefined; } + return this.labelProvider.getIcon(this.getResolutionRequest(element)!); + } + + getName(element: object): string | undefined { + if (!this.isMine(element)) { return undefined; } + return this.labelProvider.getName(this.getResolutionRequest(element)!); + } + + getLongName(element: object): string | undefined { + if (!this.isMine(element)) { return undefined; } + return this.labelProvider.getLongName(this.getResolutionRequest(element)!); + } + + getDetails(element: object): string | undefined { + if (!this.isMine(element)) { return undefined; } + return this.labelProvider.getDetails(this.getResolutionRequest(element)!); + } + + protected getResolutionRequest(element: object): AIVariableResolutionRequest | undefined { + if (!this.isMine(element)) { return undefined; } + const metadata = this.variableResourceResolver.fromUri(element); + if (!metadata) { return undefined; } + const { variableName, arg } = metadata; + const variable = this.variableService.getVariable(variableName); + return variable && { variable, arg }; + } +} diff --git a/packages/ai-core/src/browser/file-variable-contribution.ts b/packages/ai-core/src/browser/file-variable-contribution.ts index bfae40b7850cf..4595e80d37543 100644 --- a/packages/ai-core/src/browser/file-variable-contribution.ts +++ b/packages/ai-core/src/browser/file-variable-contribution.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { Path, URI } from '@theia/core'; -import { codiconArray } from '@theia/core/lib/browser'; +import { OpenerService, codiconArray, open } from '@theia/core/lib/browser'; import { inject, injectable } from '@theia/core/shared/inversify'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service'; @@ -23,11 +23,12 @@ import { AIVariable, AIVariableContext, AIVariableContribution, + AIVariableOpener, AIVariableResolutionRequest, AIVariableResolver, - AIVariableService, ResolvedAIContextVariable, } from '../common/variable-service'; +import { FrontendVariableService } from './frontend-variable-service'; export namespace FileVariableArgs { export const uri = 'uri'; @@ -44,15 +45,19 @@ export const FILE_VARIABLE: AIVariable = { }; @injectable() -export class FileVariableContribution implements AIVariableContribution, AIVariableResolver { +export class FileVariableContribution implements AIVariableContribution, AIVariableResolver, AIVariableOpener { @inject(FileService) protected readonly fileService: FileService; @inject(WorkspaceService) protected readonly wsService: WorkspaceService; - registerVariables(service: AIVariableService): void { + @inject(OpenerService) + protected readonly openerService: OpenerService; + + registerVariables(service: FrontendVariableService): void { service.registerResolver(FILE_VARIABLE, this); + service.registerOpener(FILE_VARIABLE, this); } async canResolve(request: AIVariableResolutionRequest, _: AIVariableContext): Promise { @@ -60,21 +65,15 @@ export class FileVariableContribution implements AIVariableContribution, AIVaria } async resolve(request: AIVariableResolutionRequest, _: AIVariableContext): Promise { - if (request.variable.name !== FILE_VARIABLE.name || request.arg === undefined) { - return undefined; - } + const uri = await this.toUri(request); - const path = request.arg; - const absoluteUri = await this.makeAbsolute(path); - if (!absoluteUri) { - return undefined; - } + if (!uri) { return undefined; } try { - const content = await this.fileService.readFile(absoluteUri); + const content = await this.fileService.readFile(uri); return { variable: request.variable, - value: await this.wsService.getWorkspaceRelativePath(absoluteUri), + value: await this.wsService.getWorkspaceRelativePath(uri), contextValue: content.value.toString(), }; } catch (error) { @@ -82,6 +81,27 @@ export class FileVariableContribution implements AIVariableContribution, AIVaria } } + protected async toUri(request: AIVariableResolutionRequest): Promise { + if (request.variable.name !== FILE_VARIABLE.name || request.arg === undefined) { + return undefined; + } + + const path = request.arg; + return this.makeAbsolute(path); + } + + canOpen(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + return this.canResolve(request, context); + } + + async open(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + const uri = await this.toUri(request); + if (!uri) { + throw new Error('Unable to resolve URI for request.'); + } + await open(this.openerService, uri); + } + protected async makeAbsolute(pathStr: string): Promise { const path = new Path(Path.normalizePathSeparator(pathStr)); if (!path.isAbsolute) { diff --git a/packages/ai-core/src/browser/frontend-variable-service.ts b/packages/ai-core/src/browser/frontend-variable-service.ts index 7ca858ab731ad..9ba46764cf68d 100644 --- a/packages/ai-core/src/browser/frontend-variable-service.ts +++ b/packages/ai-core/src/browser/frontend-variable-service.ts @@ -14,10 +14,21 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Disposable } from '@theia/core'; -import { FrontendApplicationContribution } from '@theia/core/lib/browser'; -import { injectable } from '@theia/core/shared/inversify'; -import { AIVariableContext, AIVariableResolutionRequest, AIVariableService, DefaultAIVariableService } from '../common'; +import { Disposable, MessageService, Prioritizeable } from '@theia/core'; +import { FrontendApplicationContribution, OpenerService, open } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { + AIVariable, + AIVariableArg, + AIVariableContext, + AIVariableOpener, + AIVariableResolutionRequest, + AIVariableResourceResolver, + AIVariableService, + DefaultAIVariableService, + PromptText +} from '../common'; +import * as monaco from '@theia/monaco-editor-core'; export type AIVariableDropHandler = (event: DragEvent, context: AIVariableContext) => Promise; @@ -26,11 +37,52 @@ export interface AIVariableDropResult { text?: string }; +export interface AIVariableCompletionContext { + /** Portion of user input to be used for filtering completion candidates. */ + userInput: string; + /** The range of suggestion completions. */ + range: monaco.Range + /** A prefix to be applied to each completion item's text */ + prefix: string +} + +export namespace AIVariableCompletionContext { + export function get( + variableName: string, + model: monaco.editor.ITextModel, + position: monaco.Position, + matchString?: string + ): AIVariableCompletionContext | undefined { + const lineContent = model.getLineContent(position.lineNumber); + const indexOfVariableTrigger = lineContent.lastIndexOf(matchString ?? PromptText.VARIABLE_CHAR, position.column - 1); + + // check if there is a variable trigger and no space typed between the variable trigger and the cursor + if (indexOfVariableTrigger === -1 || lineContent.substring(indexOfVariableTrigger).includes(' ')) { + return undefined; + } + + // determine whether we are providing completions before or after the variable argument separator + const indexOfVariableArgSeparator = lineContent.lastIndexOf(PromptText.VARIABLE_SEPARATOR_CHAR, position.column - 1); + const triggerCharIndex = Math.max(indexOfVariableTrigger, indexOfVariableArgSeparator); + + const userInput = lineContent.substring(triggerCharIndex + 1, position.column - 1); + const range = new monaco.Range(position.lineNumber, triggerCharIndex + 2, position.lineNumber, position.column); + const matchVariableChar = lineContent[triggerCharIndex] === (matchString ? matchString : PromptText.VARIABLE_CHAR); + const prefix = matchVariableChar ? variableName + PromptText.VARIABLE_SEPARATOR_CHAR : ''; + return { range, userInput, prefix }; + } +} + export const FrontendVariableService = Symbol('FrontendVariableService'); export interface FrontendVariableService extends AIVariableService { registerDropHandler(handler: AIVariableDropHandler): Disposable; unregisterDropHandler(handler: AIVariableDropHandler): void; getDropResult(event: DragEvent, context: AIVariableContext): Promise; + + registerOpener(variable: AIVariable, opener: AIVariableOpener): Disposable; + unregisterOpener(variable: AIVariable, opener: AIVariableOpener): void; + getOpener(name: string, arg: string | undefined, context: AIVariableContext): Promise; + open(variable: AIVariableArg, context?: AIVariableContext): Promise } export interface FrontendVariableContribution { @@ -38,9 +90,13 @@ export interface FrontendVariableContribution { } @injectable() -export class DefaultFrontendVariableService extends DefaultAIVariableService implements FrontendApplicationContribution { +export class DefaultFrontendVariableService extends DefaultAIVariableService implements FrontendApplicationContribution, FrontendVariableService { protected dropHandlers = new Set(); + @inject(MessageService) protected readonly messageService: MessageService; + @inject(AIVariableResourceResolver) protected readonly aiResourceResolver: AIVariableResourceResolver; + @inject(OpenerService) protected readonly openerService: OpenerService; + onStart(): void { this.initContributions(); } @@ -68,4 +124,58 @@ export class DefaultFrontendVariableService extends DefaultAIVariableService imp } return { variables, text }; } + + registerOpener(variable: AIVariable, opener: AIVariableOpener): Disposable { + const key = this.getKey(variable.name); + if (!this.variables.get(key)) { + this.variables.set(key, variable); + this.onDidChangeVariablesEmitter.fire(); + } + const openers = this.openers.get(key) ?? []; + openers.push(opener); + this.openers.set(key, openers); + return Disposable.create(() => this.unregisterOpener(variable, opener)); + } + + unregisterOpener(variable: AIVariable, opener: AIVariableOpener): void { + const key = this.getKey(variable.name); + const registeredOpeners = this.openers.get(key); + registeredOpeners?.splice(registeredOpeners.indexOf(opener), 1); + } + + async getOpener(name: string, arg: string | undefined, context: AIVariableContext = {}): Promise { + const variable = this.getVariable(name); + return variable && Prioritizeable.prioritizeAll( + this.openers.get(this.getKey(name)) ?? [], + opener => (async () => opener.canOpen({ variable, arg }, context))().catch(() => 0) + ) + .then(prioritized => prioritized.at(0)?.value); + } + + async open(request: AIVariableArg, context?: AIVariableContext | undefined): Promise { + const { variableName, arg } = this.parseRequest(request); + const variable = this.getVariable(variableName); + if (!variable) { + this.messageService.warn('No variable found for open request.'); + return; + } + const opener = await this.getOpener(variableName, arg, context); + try { + return opener ? opener.open({ variable, arg }, context ?? {}) : this.openReadonly({ variable, arg }, context); + } catch (err) { + console.error('Unable to open variable:', err); + this.messageService.error('Unable to display variable value.'); + } + } + + protected async openReadonly(request: AIVariableResolutionRequest, context: AIVariableContext = {}): Promise { + const resolved = await this.resolveVariable(request, context); + if (resolved === undefined) { + this.messageService.warn('Unable to resolve variable.'); + return; + } + const resource = this.aiResourceResolver.getOrCreate(request, context, resolved.value); + await open(this.openerService, resource.uri); + resource.dispose(); + } } diff --git a/packages/ai-core/src/common/ai-variable-resource.ts b/packages/ai-core/src/common/ai-variable-resource.ts new file mode 100644 index 0000000000000..c345d44164132 --- /dev/null +++ b/packages/ai-core/src/common/ai-variable-resource.ts @@ -0,0 +1,86 @@ +// ***************************************************************************** +// 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 * as deepEqual from 'fast-deep-equal'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { Resource, URI, generateUuid } from '@theia/core'; +import { AIVariableContext, AIVariableResolutionRequest } from './variable-service'; +import stableJsonStringify = require('fast-json-stable-stringify'); +import { ConfigurableInMemoryResources, ConfigurableMutableReferenceResource } from './configurable-in-memory-resources'; + +export const AI_VARIABLE_RESOURCE_SCHEME = 'ai-variable'; +export const NO_CONTEXT_AUTHORITY = 'context-free'; + +@injectable() +export class AIVariableResourceResolver { + @inject(ConfigurableInMemoryResources) protected readonly inMemoryResources: ConfigurableInMemoryResources; + + @postConstruct() + protected init(): void { + this.inMemoryResources.onWillDispose(resource => this.cache.delete(resource.uri.toString())); + } + + protected readonly cache = new Map(); + + getOrCreate(request: AIVariableResolutionRequest, context: AIVariableContext, value: string): ConfigurableMutableReferenceResource { + const uri = this.toUri(request, context); + try { + const existing = this.inMemoryResources.resolve(uri); + existing.update({ contents: value }); + return existing; + } catch { /* No-op */ } + const fresh = this.inMemoryResources.add(uri, { contents: value, readOnly: true, initiallyDirty: false }); + const key = uri.toString(); + this.cache.set(key, [fresh, context]); + return fresh; + } + + protected toUri(request: AIVariableResolutionRequest, context: AIVariableContext): URI { + return URI.fromComponents({ + scheme: AI_VARIABLE_RESOURCE_SCHEME, + query: stableJsonStringify({ arg: request.arg, name: request.variable.name }), + path: '/', + authority: this.toAuthority(context), + fragment: '' + }); + } + + protected toAuthority(context: AIVariableContext): string { + try { + if (deepEqual(context, {})) { return NO_CONTEXT_AUTHORITY; } + for (const [resource, cachedContext] of this.cache.values()) { + if (deepEqual(context, cachedContext)) { + return resource.uri.authority; + } + } + } catch (err) { + // Mostly that deep equal could overflow the stack, but it should run into === or inequality before that. + console.warn('Problem evaluating context in AIVariableResourceResolver', err); + } + return generateUuid(); + } + + fromUri(uri: URI): { variableName: string, arg: string | undefined } | undefined { + if (uri.scheme !== AI_VARIABLE_RESOURCE_SCHEME) { return undefined; } + try { + const { name: variableName, arg } = JSON.parse(uri.query); + return variableName ? { + variableName, + arg, + } : undefined; + } catch { return undefined; } + } +} diff --git a/packages/ai-core/src/common/configurable-in-memory-resources.ts b/packages/ai-core/src/common/configurable-in-memory-resources.ts new file mode 100644 index 0000000000000..51e73dfd6dc38 --- /dev/null +++ b/packages/ai-core/src/common/configurable-in-memory-resources.ts @@ -0,0 +1,156 @@ +// ***************************************************************************** +// Copyright (C) 2025 EclispeSource 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 { injectable } from '@theia/core/shared/inversify'; +import { SyncReferenceCollection, Reference, ResourceResolver, Resource, Event, Emitter, URI } from '@theia/core'; +import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; + +@injectable() +/** For creating highly configurable in-memory resources */ +export class ConfigurableInMemoryResources implements ResourceResolver { + + protected readonly resources = new SyncReferenceCollection(uri => new ConfigurableMutableResource(new URI(uri))); + + get onWillDispose(): Event { + return this.resources.onWillDispose; + } + + add(uri: URI, options: ResourceInitializationOptions): ConfigurableMutableReferenceResource { + const resourceUri = uri.toString(); + if (this.resources.has(resourceUri)) { + throw new Error(`Cannot add already existing in-memory resource '${resourceUri}'`); + } + const resource = this.acquire(resourceUri); + resource.update(options); + return resource; + } + + update(uri: URI, options: ResourceInitializationOptions): Resource { + const resourceUri = uri.toString(); + const resource = this.resources.get(resourceUri); + if (!resource) { + throw new Error(`Cannot update non-existent in-memory resource '${resourceUri}'`); + } + resource.update(options); + return resource; + } + + resolve(uri: URI): ConfigurableMutableReferenceResource { + const uriString = uri.toString(); + if (!this.resources.has(uriString)) { + throw new Error(`In memory '${uriString}' resource does not exist.`); + } + return this.acquire(uriString); + } + + protected acquire(uri: string): ConfigurableMutableReferenceResource { + const reference = this.resources.acquire(uri); + return new ConfigurableMutableReferenceResource(reference); + } +} + +export type ResourceInitializationOptions = Pick + & { contents?: string | Promise, onSave?: Resource['saveContents'] }; + +export class ConfigurableMutableResource implements Resource { + protected readonly onDidChangeContentsEmitter = new Emitter(); + readonly onDidChangeContents = this.onDidChangeContentsEmitter.event; + protected fireDidChangeContents(): void { + this.onDidChangeContentsEmitter.fire(); + } + + protected readonly onDidChangeReadonlyEmitter = new Emitter(); + readonly onDidChangeReadOnly = this.onDidChangeReadonlyEmitter.event; + + constructor(readonly uri: URI, protected options?: ResourceInitializationOptions) { } + + get readOnly(): Resource['readOnly'] { + return this.options?.readOnly; + } + + get autosaveable(): boolean { + return this.options?.autosaveable !== false; + } + + get initiallyDirty(): boolean { + return !!this.options?.initiallyDirty; + } + + readContents(): Promise { + return Promise.resolve(this.options?.contents ?? ''); + } + + async saveContents(contents: string): Promise { + await this.options?.onSave?.(contents); + this.update({ contents }); + } + + update(options: ResourceInitializationOptions): void { + const didContentsChange = 'contents' in options && options.contents !== this.options?.contents; + const didReadOnlyChange = 'readOnly' in options && options.readOnly !== this.options?.readOnly; + this.options = { ...this.options, ...options }; + if (didContentsChange) { + this.onDidChangeContentsEmitter.fire(); + } + if (didReadOnlyChange) { + this.onDidChangeReadonlyEmitter.fire(this.readOnly ?? false); + } + } + + dispose(): void { + this.onDidChangeContentsEmitter.dispose(); + } +} + +export class ConfigurableMutableReferenceResource implements Resource { + constructor(protected reference: Reference) { } + + get uri(): URI { + return this.reference.object.uri; + } + + get onDidChangeContents(): Event { + return this.reference.object.onDidChangeContents; + } + + dispose(): void { + this.reference.dispose(); + } + + readContents(): Promise { + return this.reference.object.readContents(); + } + + saveContents(contents: string): Promise { + return this.reference.object.saveContents(contents); + } + + update(options: ResourceInitializationOptions): void { + this.reference.object.update(options); + } + + get readOnly(): Resource['readOnly'] { + return this.reference.object.readOnly; + } + + get initiallyDirty(): boolean { + return this.reference.object.initiallyDirty; + } + + get autosaveable(): boolean { + return this.reference.object.autosaveable; + } +} diff --git a/packages/ai-core/src/common/index.ts b/packages/ai-core/src/common/index.ts index 798cfbead329d..a8877e3f3a73b 100644 --- a/packages/ai-core/src/common/index.ts +++ b/packages/ai-core/src/common/index.ts @@ -30,3 +30,5 @@ export * from './variable-service'; export * from './settings-service'; export * from './language-model-service'; export * from './token-usage-service'; +export * from './ai-variable-resource'; +export * from './configurable-in-memory-resources'; diff --git a/packages/ai-core/src/common/prompt-service.ts b/packages/ai-core/src/common/prompt-service.ts index 306bddf0d3330..87f11f64b4b5b 100644 --- a/packages/ai-core/src/common/prompt-service.ts +++ b/packages/ai-core/src/common/prompt-service.ts @@ -84,7 +84,7 @@ export interface PromptService { * * @param id the id of the prompt * @param args the object with placeholders, mapping the placeholder key to the value - * @param context the {@link AIVariableContext} to use during variable resolvement + * @param context the {@link AIVariableContext} to use during variable resolution * @param resolveVariable the variable resolving method. Fall back to using the {@link AIVariableService} if not given. */ getPromptFragment( @@ -341,7 +341,7 @@ export class PromptServiceImpl implements PromptService { * * @param template the unresolved template text * @param args the object with placeholders, mapping the placeholder key to the value - * @param context the {@link AIVariableContext} to use during variable resolvement + * @param context the {@link AIVariableContext} to use during variable resolution * @param resolveVariable the variable resolving method. Fall back to using the {@link AIVariableService} if not given. */ protected async getVariableAndArgReplacements( diff --git a/packages/ai-core/src/common/variable-service.ts b/packages/ai-core/src/common/variable-service.ts index dc5bae299226c..6a142bb6881a4 100644 --- a/packages/ai-core/src/common/variable-service.ts +++ b/packages/ai-core/src/common/variable-service.ts @@ -143,6 +143,11 @@ export interface AIVariableResolver { resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise; } +export interface AIVariableOpener { + canOpen(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise; + open(request: AIVariableResolutionRequest, context: AIVariableContext): Promise; +} + export interface AIVariableResolverWithVariableDependencies extends AIVariableResolver { resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise; /** @@ -173,6 +178,7 @@ export interface AIVariableService { registerResolver(variable: AIVariable, resolver: AIVariableResolver): Disposable; unregisterResolver(variable: AIVariable, resolver: AIVariableResolver): void; getResolver(name: string, arg: string | undefined, context: AIVariableContext): Promise; + resolveVariable(variable: AIVariableArg, context: AIVariableContext, cache?: Map): Promise; registerArgumentPicker(variable: AIVariable, argPicker: AIVariableArgPicker): Disposable; unregisterArgumentPicker(variable: AIVariable, argPicker: AIVariableArgPicker): void; @@ -181,8 +187,6 @@ export interface AIVariableService { registerArgumentCompletionProvider(variable: AIVariable, argPicker: AIVariableArgCompletionProvider): Disposable; unregisterArgumentCompletionProvider(variable: AIVariable, argPicker: AIVariableArgCompletionProvider): void; getArgumentCompletionProvider(name: string): Promise; - - resolveVariable(variable: AIVariableArg, context: AIVariableContext, cache?: Map): Promise; } /** Contributions on the frontend can optionally implement `FrontendVariableContribution`. */ @@ -198,7 +202,7 @@ export interface ResolveAIVariableCacheEntry { export type ResolveAIVariableCache = Map; /** - * Creates a new, empty cache for AI variable resolvement to hand into `AIVariableService.resolveVariable`. + * Creates a new, empty cache for AI variable resolution to hand into `AIVariableService.resolveVariable`. */ export function createAIResolveVariableCache(): Map { return new Map(); @@ -223,6 +227,7 @@ export class DefaultAIVariableService implements AIVariableService { protected variables = new Map(); protected resolvers = new Map(); protected argPickers = new Map(); + protected openers = new Map(); protected argCompletionProviders = new Map(); protected readonly onDidChangeVariablesEmitter = new Emitter(); @@ -232,8 +237,7 @@ export class DefaultAIVariableService implements AIVariableService { @inject(ContributionProvider) @named(AIVariableContribution) protected readonly contributionProvider: ContributionProvider, @inject(ILogger) protected readonly logger: ILogger - ) { - } + ) { } protected initContributions(): void { this.contributionProvider.getContributions().forEach(contribution => contribution.registerVariables(this)); @@ -346,22 +350,27 @@ export class DefaultAIVariableService implements AIVariableService { return this.argCompletionProviders.get(this.getKey(name)) ?? undefined; } - async resolveVariable( - request: AIVariableArg, - context: AIVariableContext, - cache: ResolveAIVariableCache = createAIResolveVariableCache() - ): Promise { - // Calculate unique variable cache key from variable name and argument + protected parseRequest(request: AIVariableArg): { variableName: string, arg: string | undefined } { const variableName = typeof request === 'string' ? request : typeof request.variable === 'string' ? request.variable : request.variable.name; const arg = typeof request === 'string' ? undefined : request.arg; + return { variableName, arg }; + } + + async resolveVariable( + request: AIVariableArg, + context: AIVariableContext, + cache: ResolveAIVariableCache = createAIResolveVariableCache() + ): Promise { + // Calculate unique variable cache key from variable name and argument + const { variableName, arg } = this.parseRequest(request); const cacheKey = `${variableName}${PromptText.VARIABLE_SEPARATOR_CHAR}${arg ?? ''}`; // If the current cache key exists and is still in progress, we reached a cycle. - // If we reach it but it has been resolved, it was part of another resolvement branch and we can simply return it. + // If we reach it but it has been resolved, it was part of another resolution branch and we can simply return it. if (cache.has(cacheKey)) { const existingEntry = cache.get(cacheKey)!; if (existingEntry.inProgress) { @@ -371,40 +380,38 @@ export class DefaultAIVariableService implements AIVariableService { return existingEntry.promise; } - const entry: ResolveAIVariableCacheEntry = { promise: Promise.resolve(undefined), inProgress: true }; + const entry: ResolveAIVariableCacheEntry = { promise: this.doResolve(variableName, arg, context, cache), inProgress: true }; + entry.promise.finally(() => entry.inProgress = false); cache.set(cacheKey, entry); - // Asynchronously resolves a variable, handling its dependencies while preventing cyclical resolution. - // Selects the appropriate resolver and resolution strategy based on whether nested dependency resolution is supported. - const promise = (async () => { - const variable = this.getVariable(variableName); - if (!variable) { - return undefined; - } - const resolver = await this.getResolver(variableName, arg, context); - let resolved: ResolvedAIVariable | undefined; - if (isResolverWithDependencies(resolver)) { - // Explicit cast needed because Typescript does not consider the method parameter length of the type guard at compile time - resolved = await (resolver as AIVariableResolverWithVariableDependencies).resolve( - { variable, arg }, - context, - async (depRequest: AIVariableResolutionRequest) => - this.resolveVariable(depRequest, context, cache) - ); - } else if (resolver) { - // Explicit cast needed because Typescript does not consider the method parameter length of the type guard at compile time - resolved = await (resolver as AIVariableResolver).resolve({ variable, arg }, context); - } else { - resolved = undefined; - } - return resolved ? { ...resolved, arg } : undefined; - })(); - - entry.promise = promise; - promise.finally(() => { - entry.inProgress = false; - }); + return entry.promise; + } - return promise; + /** + * Asynchronously resolves a variable, handling its dependencies while preventing cyclical resolution. + * Selects the appropriate resolver and resolution strategy based on whether nested dependency resolution is supported. + */ + protected async doResolve(variableName: string, arg: string | undefined, context: AIVariableContext, cache: ResolveAIVariableCache): Promise { + const variable = this.getVariable(variableName); + if (!variable) { + return undefined; + } + const resolver = await this.getResolver(variableName, arg, context); + let resolved: ResolvedAIVariable | undefined; + if (isResolverWithDependencies(resolver)) { + // Explicit cast needed because Typescript does not consider the method parameter length of the type guard at compile time + resolved = await (resolver as AIVariableResolverWithVariableDependencies).resolve( + { variable, arg }, + context, + async (depRequest: AIVariableResolutionRequest) => + this.resolveVariable(depRequest, context, cache) + ); + } else if (resolver) { + // Explicit cast needed because Typescript does not consider the method parameter length of the type guard at compile time + resolved = await (resolver as AIVariableResolver).resolve({ variable, arg }, context); + } else { + resolved = undefined; + } + return resolved ? { ...resolved, arg } : undefined; } } diff --git a/packages/ai-ide/package.json b/packages/ai-ide/package.json index 05e299b1dffa9..32171099c4601 100644 --- a/packages/ai-ide/package.json +++ b/packages/ai-ide/package.json @@ -29,6 +29,7 @@ "@theia/search-in-workspace": "1.60.0", "@theia/task": "1.60.0", "ignore": "^6.0.0", + "js-yaml": "^4.1.0", "minimatch": "^9.0.0", "date-fns": "^4.1.0" }, diff --git a/packages/ai-ide/src/browser/coder-agent.ts b/packages/ai-ide/src/browser/coder-agent.ts index 1bfed0018b61f..9edff9c3b3949 100644 --- a/packages/ai-ide/src/browser/coder-agent.ts +++ b/packages/ai-ide/src/browser/coder-agent.ts @@ -13,16 +13,19 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { AbstractStreamParsingChatAgent } from '@theia/ai-chat/lib/common'; -import { injectable } from '@theia/core/shared/inversify'; +import { AbstractStreamParsingChatAgent, ChatRequestModel, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel } from '@theia/ai-chat/lib/common'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID, GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID } from '../common/workspace-functions'; import { CODER_REPLACE_PROMPT_TEMPLATE_ID, getCoderReplacePromptTemplate, getCoderReplacePromptTemplateNext } from '../common/coder-replace-prompt-template'; import { WriteChangeToFileProvider } from './file-changeset-functions'; import { LanguageModelRequirement } from '@theia/ai-core'; import { nls } from '@theia/core'; +import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering'; +import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, ChatCommands } from '@theia/ai-chat-ui/lib/browser/chat-view-commands'; @injectable() export class CoderAgent extends AbstractStreamParsingChatAgent { + @inject(ChatService) protected readonly chatService: ChatService; id: string = 'Coder'; name = 'Coder'; languageModelRequirements: LanguageModelRequirement[] = [{ @@ -38,5 +41,26 @@ export class CoderAgent extends AbstractStreamParsingChatAgent { override promptTemplates = [getCoderReplacePromptTemplate(true), getCoderReplacePromptTemplate(false), getCoderReplacePromptTemplateNext()]; override functions = [GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID, FILE_CONTENT_FUNCTION_ID, WriteChangeToFileProvider.ID]; protected override systemPromptId: string | undefined = CODER_REPLACE_PROMPT_TEMPLATE_ID; - + override async invoke(request: MutableChatRequestModel): Promise { + await super.invoke(request); + this.suggest(request); + } + async suggest(context: ChatSession | ChatRequestModel): Promise { + const contextIsRequest = ChatRequestModel.is(context); + const model = contextIsRequest ? context.session : context.model; + const session = contextIsRequest ? this.chatService.getSessions().find(candidate => candidate.model.id === model.id) : context; + if (!(model instanceof MutableChatModel) || !session) { return; } + if (model.isEmpty()) { + model.setSuggestions([ + { + kind: 'callback', + callback: () => this.chatService.sendRequest(session.id, { text: '@Coder please look at #_f and fix any problems.' }), + content: '[Fix problems](_callback) in the current file.' + }, + ]); + } else { + model.setSuggestions([new MarkdownStringImpl(`Keep chats short and focused. [Start a new chat](command:${AI_CHAT_NEW_CHAT_WINDOW_COMMAND.id}) for a new task` + + ` or [start a new chat with a summary of this one](command:${ChatCommands.AI_CHAT_NEW_WITH_TASK_CONTEXT.id}).`)]); + } + } } diff --git a/packages/ai-ide/src/browser/frontend-module.ts b/packages/ai-ide/src/browser/frontend-module.ts index 8765abc4b86d2..251913a3da04d 100644 --- a/packages/ai-ide/src/browser/frontend-module.ts +++ b/packages/ai-ide/src/browser/frontend-module.ts @@ -47,8 +47,11 @@ import { AIMCPConfigurationWidget } from './ai-configuration/mcp-configuration-w import { ChatWelcomeMessageProvider } from '@theia/ai-chat-ui/lib/browser/chat-tree-view'; import { IdeChatWelcomeMessageProvider } from './ide-chat-welcome-message-provider'; import { AITokenUsageConfigurationWidget } from './ai-configuration/token-usage-configuration-widget'; +import { TaskContextSummaryVariableContribution } from './task-background-summary-variable'; +import { TaskContextFileStorageService } from './task-context-file-storage-service'; +import { TaskContextStorageService } from '@theia/ai-chat/lib/browser/task-context-service'; -export default new ContainerModule(bind => { +export default new ContainerModule((bind, _unbind, _isBound, rebind) => { bind(PreferenceContribution).toConstantValue({ schema: WorkspacePreferencesSchema }); bind(ArchitectAgent).toSelf().inSingletonScope(); @@ -140,4 +143,9 @@ export default new ContainerModule(bind => { createWidget: () => ctx.container.get(AITokenUsageConfigurationWidget) })) .inSingletonScope(); + + bind(TaskContextSummaryVariableContribution).toSelf().inSingletonScope(); + bind(AIVariableContribution).toService(TaskContextSummaryVariableContribution); + bind(TaskContextFileStorageService).toSelf().inSingletonScope(); + rebind(TaskContextStorageService).toService(TaskContextFileStorageService); }); diff --git a/packages/ai-ide/src/browser/task-background-summary-variable.ts b/packages/ai-ide/src/browser/task-background-summary-variable.ts new file mode 100644 index 0000000000000..c2735b5a7b2a4 --- /dev/null +++ b/packages/ai-ide/src/browser/task-background-summary-variable.ts @@ -0,0 +1,67 @@ +// ***************************************************************************** +// 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 { MaybePromise, nls } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { + AIVariable, + ResolvedAIVariable, + AIVariableContribution, + AIVariableService, + AIVariableResolutionRequest, + AIVariableContext, + AIVariableResolverWithVariableDependencies, + AIVariableArg +} from '@theia/ai-core'; +import { ChatSessionContext } from '@theia/ai-chat'; +import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable'; +import { TASK_CONTEXT_SUMMARY_VARIABLE_ID } from '../common/context-variables'; + +export const TASK_CONTEXT_SUMMARY_VARIABLE: AIVariable = { + id: TASK_CONTEXT_SUMMARY_VARIABLE_ID, + description: nls.localize('theia/ai/core/taskContextSummary/description', 'Resolves all task context items present in the session context.'), + name: TASK_CONTEXT_SUMMARY_VARIABLE_ID, +}; + +@injectable() +/** + * @class provides a summary of all TaskContextVariables in the context of a given session. Oriented towards use in prompts. + */ +export class TaskContextSummaryVariableContribution implements AIVariableContribution, AIVariableResolverWithVariableDependencies { + registerVariables(service: AIVariableService): void { + service.registerResolver(TASK_CONTEXT_SUMMARY_VARIABLE, this); + } + + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { + return request.variable.name === TASK_CONTEXT_SUMMARY_VARIABLE.name ? 50 : 0; + } + + async resolve( + request: AIVariableResolutionRequest, + context: AIVariableContext, + resolveDependency?: (variable: AIVariableArg) => Promise + ): Promise { + if (!resolveDependency || !ChatSessionContext.is(context) || request.variable.name !== TASK_CONTEXT_SUMMARY_VARIABLE.name) { return undefined; } + const allSummaryRequests = context.model.context.getVariables().filter(candidate => candidate.variable.id === TASK_CONTEXT_VARIABLE.id); + if (!allSummaryRequests.length) { return { ...request, value: '' }; } + const allSummaries = await Promise.all(allSummaryRequests.map(summaryRequest => resolveDependency(summaryRequest).then(resolved => resolved?.value))); + const value = `# Current Task Context\n\n${allSummaries.map((content, index) => `## Task ${index + 1}\n\n${content}`).join('\n\n')}`; + return { + ...request, + value + }; + } +} diff --git a/packages/ai-ide/src/browser/task-context-file-storage-service.ts b/packages/ai-ide/src/browser/task-context-file-storage-service.ts new file mode 100644 index 0000000000000..d3bec464a7995 --- /dev/null +++ b/packages/ai-ide/src/browser/task-context-file-storage-service.ts @@ -0,0 +1,189 @@ +// ***************************************************************************** +// 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, Path, URI, unreachable } from '@theia/core'; +import { PreferenceService, 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 './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; + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + + 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.watchStorage(); + this.preferenceService.onPreferenceChanged(e => { + if (e.affects(TASK_CONTEXT_STORAGE_DIRECTORY_PREF)) { this.watchStorage(); } + }); + } + + protected toDisposeOnStorageChange?: DisposableCollection; + protected async watchStorage(): Promise { + this.toDisposeOnStorageChange?.dispose(); + this.toDisposeOnStorageChange = undefined; + const newStorage = this.getStorageLocation(); + 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() }, + ); + await this.cacheNewTasks(newStorage); + } + + protected async handleChanges(changes: FileChange[]): Promise { + 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 { + 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 { + const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined); + if (content === undefined) { return; } + const { frontmatter, body } = this.maybeReadFrontmatter(content); + const summary = { + ...frontmatter, + summary: body, + label: frontmatter?.label || uri.path.base.slice(0, (-1 * uri.path.ext.length) || uri.path.base.length), + uri, + id: frontmatter?.sessionId || uri.path.base + }; + const existingSummary = 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 { + const storageLocation = this.getStorageLocation(); + if (storageLocation) { + const frontmatter = { + sessionId: summary.sessionId, + date: new Date().toISOString(), + label: summary.label, + }; + const derivedName = summary.label.trim().replace(/[^\p{L}\p{N}]/vg, '-').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); + this.onDidChangeEmitter.fire(); + } + + getAll(): Summary[] { + return this.inMemoryStorage.getAll(); + } + + get(identifier: string): Summary | undefined { + return this.inMemoryStorage.get(identifier); + } + + async delete(identifier: string): Promise { + 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 { + const summary = 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)); + } +} diff --git a/packages/ai-ide/src/browser/workspace-preferences.ts b/packages/ai-ide/src/browser/workspace-preferences.ts index c2c7ec033970b..0a94b6c85fce8 100644 --- a/packages/ai-ide/src/browser/workspace-preferences.ts +++ b/packages/ai-ide/src/browser/workspace-preferences.ts @@ -22,6 +22,7 @@ export const USER_EXCLUDE_PATTERN_PREF = 'ai-features.workspaceFunctions.userExc export const PROMPT_TEMPLATE_WORKSPACE_DIRECTORIES_PREF = 'ai-features.promptTemplates.WorkspaceTemplateDirectories'; export const PROMPT_TEMPLATE_ADDITIONAL_EXTENSIONS_PREF = 'ai-features.promptTemplates.TemplateExtensions'; export const PROMPT_TEMPLATE_WORKSPACE_FILES_PREF = 'ai-features.promptTemplates.WorkspaceTemplateFiles'; +export const TASK_CONTEXT_STORAGE_DIRECTORY_PREF = 'ai-features.promptTemplates.taskContextStorageDirectory'; const CONFLICT_RESOLUTION_DESCRIPTION = 'When templates with the same ID (filename) exist in multiple locations, conflicts are resolved by priority: specific template files \ (highest) > workspace directories > global directories (lowest).'; @@ -75,6 +76,14 @@ export const WorkspacePreferencesSchema: PreferenceSchema = { items: { type: 'string' } + }, + [TASK_CONTEXT_STORAGE_DIRECTORY_PREF]: { + type: 'string', + description: nls.localize('theia/ai/chat/taskContextStorageDirectory/description', + 'A workspace relative path in which to persist and from which to retrieve task context descriptions.' + + ' If set to empty value, generated task contexts will be stored in memory rather than on disk.' + ), + default: '.prompts/task-contexts' } } }; diff --git a/packages/ai-ide/src/common/coder-replace-prompt-template.ts b/packages/ai-ide/src/common/coder-replace-prompt-template.ts index 9d6b5c396cae5..e470e7e19ee5d 100644 --- a/packages/ai-ide/src/common/coder-replace-prompt-template.ts +++ b/packages/ai-ide/src/common/coder-replace-prompt-template.ts @@ -12,15 +12,15 @@ import { PromptTemplate } from '@theia/ai-core/lib/common'; import { CHANGE_SET_SUMMARY_VARIABLE_ID } from '@theia/ai-chat'; import { - GET_WORKSPACE_FILE_LIST_FUNCTION_ID, - FILE_CONTENT_FUNCTION_ID, - GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID, - GET_FILE_DIAGNOSTICS_ID, - SEARCH_IN_WORKSPACE_FUNCTION_ID, - LIST_TASKS_FUNCTION_ID, - RUN_TASK_FUNCTION_ID + GET_WORKSPACE_FILE_LIST_FUNCTION_ID, + FILE_CONTENT_FUNCTION_ID, + GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID, + GET_FILE_DIAGNOSTICS_ID, + SEARCH_IN_WORKSPACE_FUNCTION_ID, + LIST_TASKS_FUNCTION_ID, + RUN_TASK_FUNCTION_ID } from './workspace-functions'; -import { CONTEXT_FILES_VARIABLE_ID } from './context-variables'; +import { CONTEXT_FILES_VARIABLE_ID, TASK_CONTEXT_SUMMARY_VARIABLE_ID } from './context-variables'; import { UPDATE_CONTEXT_FILES_FUNCTION_ID } from './context-functions'; export const CODER_REWRITE_PROMPT_TEMPLATE_ID = 'coder-rewrite'; @@ -28,9 +28,9 @@ export const CODER_REPLACE_PROMPT_TEMPLATE_ID = 'coder-search-replace'; export const CODER_REPLACE_PROMPT_TEMPLATE_NEXT_ID = 'coder-search-replace-next'; export function getCoderReplacePromptTemplateNext(): PromptTemplate { - return { - id: CODER_REPLACE_PROMPT_TEMPLATE_NEXT_ID, - template: `{{!-- This prompt is licensed under the MIT License (https://opensource.org/license/mit). + return { + id: CODER_REPLACE_PROMPT_TEMPLATE_NEXT_ID, + template: `{{!-- This prompt is licensed under the MIT License (https://opensource.org/license/mit). Made improvements or adaptations to this prompt template? We’d love for you to share it with the community! Contribute back here: https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}} You are an AI assistant integrated into Theia IDE, designed to assist software developers with code tasks. You can interact with the code base and suggest changes. @@ -76,13 +76,13 @@ You have previously proposed changes for the following files. Some suggestions m {{prompt:project-info}} `, - ...({ variantOf: CODER_REPLACE_PROMPT_TEMPLATE_ID }), - }; + ...({ variantOf: CODER_REPLACE_PROMPT_TEMPLATE_ID }), + }; } export function getCoderReplacePromptTemplate(withSearchAndReplace: boolean = false): PromptTemplate { - return { - id: withSearchAndReplace ? CODER_REPLACE_PROMPT_TEMPLATE_ID : CODER_REWRITE_PROMPT_TEMPLATE_ID, - template: `{{!-- This prompt is licensed under the MIT License (https://opensource.org/license/mit). + return { + id: withSearchAndReplace ? CODER_REPLACE_PROMPT_TEMPLATE_ID : CODER_REWRITE_PROMPT_TEMPLATE_ID, + template: `{{!-- This prompt is licensed under the MIT License (https://opensource.org/license/mit). Made improvements or adaptations to this prompt template? We’d love for you to share it with the community! Contribute back here: https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}} You are an AI assistant integrated into Theia IDE, designed to assist software developers with code tasks. You can interact with the code base and suggest changes. @@ -107,19 +107,19 @@ Instead, for each file you want to propose changes for: ${withSearchAndReplace ? ' If ~{changeSet_replaceContentInFile} continously fails use ~{changeSet_writeChangeToFile}. Calling a function on a file will override previous \ function calls on the same file, so you need exactly one successful call with all proposed changes per changed file. The changes will be presented as a applicable diff to \ the user in any case.' : ''} - + ## Additional Context The following files have been provided for additional context. Some of them may also be referred to by the user. \ Always look at the relevant files to understand your task using the function ~{${FILE_CONTENT_FUNCTION_ID}} {{${CONTEXT_FILES_VARIABLE_ID}}} -## Previously Proposed Changes -You have previously proposed changes for the following files. Some suggestions may have been accepted by the user, while others may still be pending. {{${CHANGE_SET_SUMMARY_VARIABLE_ID}}} {{prompt:project-info}} + +{{${TASK_CONTEXT_SUMMARY_VARIABLE_ID}}} `, - ...(!withSearchAndReplace ? { variantOf: CODER_REPLACE_PROMPT_TEMPLATE_ID } : {}), - }; + ...(!withSearchAndReplace ? { variantOf: CODER_REPLACE_PROMPT_TEMPLATE_ID } : {}), + }; } diff --git a/packages/ai-ide/src/common/context-variables.ts b/packages/ai-ide/src/common/context-variables.ts index 4044d9e4444d4..d2b91ff20ddcd 100644 --- a/packages/ai-ide/src/common/context-variables.ts +++ b/packages/ai-ide/src/common/context-variables.ts @@ -15,3 +15,4 @@ // ***************************************************************************** export const CONTEXT_FILES_VARIABLE_ID = 'contextFiles'; +export const TASK_CONTEXT_SUMMARY_VARIABLE_ID = 'taskContextSummary'; diff --git a/packages/filesystem/src/browser/file-service.ts b/packages/filesystem/src/browser/file-service.ts index 3945bf64787ee..c49200e7a630a 100644 --- a/packages/filesystem/src/browser/file-service.ts +++ b/packages/filesystem/src/browser/file-service.ts @@ -504,7 +504,7 @@ export class FileService { /** * Try to resolve file information and metadata for the given resource. * @param resource `URI` of the resource that should be resolved. - * @param options Options to customize the resolvement process. + * @param options Options to customize the resolution process. * * @return A promise that resolves if the resource could be successfully resolved. */ @@ -601,7 +601,7 @@ export class FileService { /** * Try to resolve file information and metadata for all given resource. - * @param toResolve An array of all the resources (and corresponding resolvement options) that should be resolved. + * @param toResolve An array of all the resources (and corresponding resolution options) that should be resolved. * * @returns A promise of all resolved resources. The promise is not rejected if any of the given resources cannot be resolved. * Instead this is reflected with the `success` flag of the corresponding {@link ResolveFileResult}.