From bd855c55c7454ff6ec5c135d2b063d70e5cc2e28 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 3 Apr 2025 15:26:54 -0600 Subject: [PATCH 01/28] Agent prompt suggestions & chat summary --- package-lock.json | 1 + package.json | 1 + .../src/browser/ai-chat-ui-contribution.ts | 18 +- .../browser/chat-input-agent-suggestions.tsx | 41 ++++ .../src/browser/chat-input-widget.tsx | 30 ++- .../src/browser/chat-view-commands.ts | 4 + .../src/browser/chat-view-widget.tsx | 18 +- .../ai-chat-ui/src/browser/style/index.css | 6 + .../src/browser/ai-chat-frontend-module.ts | 10 + .../src/browser/change-set-file-resource.ts | 96 +------- .../file-chat-variable-contribution.ts | 26 +-- .../session-summary-variable-contribution.ts | 115 +++++++++ ...session-summary-variable-label-provider.ts | 64 +++++ packages/ai-chat/src/common/chat-agents.ts | 2 + packages/ai-chat/src/common/chat-service.ts | 6 +- .../src/common/chat-session-naming-service.ts | 3 +- .../src/common/chat-session-summary-agent.ts | 118 ++++++++++ packages/ai-core/package.json | 1 + .../src/browser/ai-core-frontend-module.ts | 14 +- .../browser/ai-variable-uri-label-provider.ts | 61 +++++ .../src/browser/frontend-variable-service.ts | 39 +++- .../src/common/ai-variable-resource.ts | 220 ++++++++++++++++++ packages/ai-core/src/common/index.ts | 1 + packages/ai-ide/src/browser/coder-agent.ts | 4 + .../context-session-summary-variable.ts | 63 +++++ .../ai-ide/src/browser/frontend-module.ts | 4 + .../common/coder-replace-prompt-template.ts | 8 +- .../ai-ide/src/common/context-variables.ts | 1 + 28 files changed, 839 insertions(+), 136 deletions(-) create mode 100644 packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx create mode 100644 packages/ai-chat/src/browser/session-summary-variable-contribution.ts create mode 100644 packages/ai-chat/src/browser/session-summary-variable-label-provider.ts create mode 100644 packages/ai-chat/src/common/chat-session-summary-agent.ts create mode 100644 packages/ai-core/src/browser/ai-variable-uri-label-provider.ts create mode 100644 packages/ai-core/src/common/ai-variable-resource.ts create mode 100644 packages/ai-ide/src/browser/context-session-summary-variable.ts diff --git a/package-lock.json b/package-lock.json index 4b9c286423da1..ee865291ada23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30472,6 +30472,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/package.json b/package.json index 6220e8f2bfaaa..18aadddc35443 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "license:check:review": "node scripts/check_3pp_licenses.js --review", "lint": "lerna run lint", "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 c2cf5db2dfebc..88f5f2160abff 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 @@ -17,7 +17,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { CommandRegistry, isOSX, 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_NEW_WITH_MEMORY, 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'; @@ -28,6 +28,7 @@ import { formatDistance } from 'date-fns'; import * as locales from 'date-fns/locale'; 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 { SESSION_SUMMARY_VARIABLE } from '@theia/ai-chat/lib/browser/session-summary-variable-contribution'; export const AI_CHAT_TOGGLE_COMMAND_ID = 'aiChat:toggle'; @@ -83,10 +84,21 @@ 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(AI_CHAT_NEW_WITH_MEMORY, { + execute: () => { + const activeSessions = this.chatService.getSessions().filter(candidate => candidate.isActive); + if (activeSessions.length !== 1) { + return; + } + const activeSession = activeSessions[0]; + const newSession = this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }, activeSession.pinnedAgent); + newSession.model.context.addVariables({ variable: SESSION_SUMMARY_VARIABLE, arg: activeSession.id }); + }, + isVisible: () => false + }); registry.registerCommand(AI_CHAT_SHOW_CHATS_COMMAND, { execute: () => this.selectChat(), isEnabled: widget => this.withWidget(widget, () => true) && this.chatService.getSessions().length > 1, 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..d5c8c1eb4bc0c --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx @@ -0,0 +1,41 @@ +// ***************************************************************************** +// 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 { useMarkdownRendering } from './chat-response-renderer'; +import { OpenerService } from '@theia/core/lib/browser'; +import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; + +interface ChatInputAgentSuggestionsProps { + suggestions: (string | MarkdownString)[]; + opener: OpenerService; +} + +export const ChatInputAgentSuggestions: React.FC = ({suggestions, opener}) => ( +
+ {suggestions.map(suggestion => )} +
+); + +interface ChatInputAgestSuggestionProps { + suggestion: string | MarkdownString; + opener: OpenerService; +} + +const ChatInputAgentSuggestion: React.FC = ({suggestion, opener}) => { + const ref = useMarkdownRendering(suggestion, opener, true); + return
; +}; 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 d5649b28bd0a2..9e3b2adfa2626 100644 --- a/packages/ai-chat-ui/src/browser/chat-input-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx @@ -15,7 +15,7 @@ // ***************************************************************************** import { ChangeSet, ChatAgent, ChatChangeEvent, ChatModel, ChatRequestModel } 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'; @@ -27,12 +27,14 @@ import { AIVariableResolutionRequest } from '@theia/ai-core'; 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 { 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 { @@ -69,6 +71,9 @@ export class AIChatInputWidget extends ReactWidget { @inject(ChangeSetActionService) protected readonly changeSetActionService: ChangeSetActionService; + @inject(OpenerService) + protected readonly openerService: OpenerService; + protected editorRef: MonacoEditor | undefined = undefined; private editorReady = new Deferred(); @@ -94,6 +99,10 @@ export class AIChatInputWidget extends ReactWidget { set onDeleteChangeSetElement(deleteChangeSetElement: DeleteChangeSetElement) { this._onDeleteChangeSetElement = deleteChangeSetElement; } + private _onOpenContextELement: OpenContextElement; + set onOpenContextElement(opener: OpenContextElement) { + this._onOpenContextELement = opener; + } protected onDisposeForChatModel = new DisposableCollection(); private _chatModel: ChatModel; @@ -142,6 +151,7 @@ export class AIChatInputWidget extends ReactWidget { onDeleteChangeSetElement={this._onDeleteChangeSetElement.bind(this)} onAddContextElement={this.addContextElement.bind(this)} onDeleteContextElement={this.deleteContextElement.bind(this)} + onOpenContextElement={this._onOpenContextELement.bind(this)} context={this._chatModel.context.getVariables()} chatModel={this._chatModel} pinnedAgent={this._pinnedAgent} @@ -157,6 +167,7 @@ export class AIChatInputWidget extends ReactWidget { showPinnedAgent={this.configuration?.showPinnedAgent} labelProvider={this.labelProvider} actionService={this.changeSetActionService} + openerService={this.openerService} /> ); } @@ -237,6 +248,7 @@ interface ChatInputProperties { onDeleteChangeSetElement: (sessionId: string, index: number) => void; onAddContextElement: () => void; onDeleteContextElement: (index: number) => void; + onOpenContextElement: OpenContextElement; context?: readonly AIVariableResolutionRequest[]; isEnabled?: boolean; chatModel: ChatModel; @@ -249,6 +261,7 @@ interface ChatInputProperties { showPinnedAgent?: boolean; labelProvider: LabelProvider; actionService: ChangeSetActionService; + openerService: OpenerService; } const ChatInput: React.FunctionComponent = (props: ChatInputProperties) => { @@ -515,9 +528,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
+ return
+ {!!props.pinnedAgent?.suggestions?.length && } {changeSetUI?.elements && } @@ -685,7 +699,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: [] }; } @@ -697,6 +716,7 @@ function buildContextUI(context: readonly AIVariableResolutionRequest[] | undefi additionalInfo: labelProvider.getDetails(element), details: labelProvider.getLongName(element), delete: () => onDeleteContextElement(index), + open: () => onOpen(element) })) }; } @@ -727,7 +747,7 @@ const ChatContext: React.FunctionComponent = ({ context }) => ( {element.additionalInfo}
- element.delete()} /> + { e.stopPropagation(); element.delete(); }} /> ))} 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..de29a5b53867b 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-commands.ts +++ b/packages/ai-chat-ui/src/browser/chat-view-commands.ts @@ -45,6 +45,10 @@ export const AI_CHAT_NEW_CHAT_WINDOW_COMMAND: Command = { iconClass: codicon('add') }; +export const AI_CHAT_NEW_WITH_MEMORY: Command = { + id: 'ai-chat.new-with-memory', +}; + export const AI_CHAT_SHOW_CHATS_COMMAND: Command = { id: 'ai-chat-ui.show-chats', iconClass: codicon('history') 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 a404a38b84544..c456418d732e7 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx @@ -15,13 +15,13 @@ // ***************************************************************************** import { CommandService, deepClone, Emitter, Event, MessageService } from '@theia/core'; import { ChatRequest, ChatRequestModel, ChatService, ChatSession, isActiveSessionChangedEvent, MutableChatModel } from '@theia/ai-chat'; -import { BaseWidget, codicon, ExtractableWidget, Message, PanelLayout, PreferenceService, StatefulWidget } from '@theia/core/lib/browser'; +import { BaseWidget, codicon, ExtractableWidget, Message, open, OpenerService, PanelLayout, PreferenceService, StatefulWidget } from '@theia/core/lib/browser'; import { nls } from '@theia/core/lib/common/nls'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; 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 { AIVariableResolutionRequest, AIVariableResourceResolver } from '@theia/ai-core'; export namespace ChatViewWidget { export interface State { @@ -50,6 +50,12 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta @inject(AIActivationService) protected readonly activationService: AIActivationService; + @inject(AIVariableResourceResolver) + protected readonly variableResourceResolver: AIVariableResourceResolver; + + @inject(OpenerService) + protected readonly openerService: OpenerService; + protected chatSession: ChatSession; protected _state: ChatViewWidget.State = { locked: false }; @@ -98,6 +104,7 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent; this.inputWidget.onDeleteChangeSet = this.onDeleteChangeSet.bind(this); this.inputWidget.onDeleteChangeSetElement = this.onDeleteChangeSetElement.bind(this); + this.inputWidget.onOpenContextElement = this.onOpenContextElement.bind(this); this.treeWidget.trackChatModel(this.chatSession.model); this.initListeners(); @@ -201,6 +208,13 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta this.chatService.deleteChangeSetElement(sessionId, index); } + protected async onOpenContextElement(request: AIVariableResolutionRequest): Promise { + const context = {session: this.chatSession}; + const resource = this.variableResourceResolver.get(request, context); + await open(this.openerService, resource.uri); + resource.dispose(); + } + lock(): void { this.state = { ...deepClone(this.state), locked: true }; } diff --git a/packages/ai-chat-ui/src/browser/style/index.css b/packages/ai-chat-ui/src/browser/style/index.css index 1d74efed9f985..b2daf77cca331 100644 --- a/packages/ai-chat-ui/src/browser/style/index.css +++ b/packages/ai-chat-ui/src/browser/style/index.css @@ -181,6 +181,8 @@ div:last-child > .theia-ChatNode { height: 18px; line-height: 16px; min-width: 0; + user-select: none; + cursor: pointer; } .theia-ChatInput-ChatContext-labelParts { @@ -702,3 +704,7 @@ details[open].collapsible-arguments .collapsible-arguments-summary { .session-settings-container .monaco-editor { outline-color: var(--theia-editor-background); } + +.chat-agent-suggestions { + padding-inline: 16px; +} 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 60156a43e87fe..d2fbf50b85f55 100644 --- a/packages/ai-chat/src/browser/ai-chat-frontend-module.ts +++ b/packages/ai-chat/src/browser/ai-chat-frontend-module.ts @@ -46,6 +46,9 @@ import { ContextSummaryVariableContribution } from '../common/context-summary-va import { ContextDetailsVariableContribution } from '../common/context-details-variable'; import { ChangeSetVariableContribution } from './change-set-variable'; import { ChatSessionNamingAgent, ChatSessionNamingService } from '../common/chat-session-naming-service'; +import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; +import { SessionSumaryVariableContribution } from './session-summary-variable-contribution'; +import { SessionSummaryVariableLabelProvider } from './session-summary-variable-label-provider'; export default new ContainerModule(bind => { bindContributionProvider(bind, Agent); @@ -113,4 +116,11 @@ export default new ContainerModule(bind => { bind(AIVariableContribution).to(ContextSummaryVariableContribution).inSingletonScope(); bind(AIVariableContribution).to(ContextDetailsVariableContribution).inSingletonScope(); bind(AIVariableContribution).to(ChangeSetVariableContribution).inSingletonScope(); + + bind(ChatSessionSummaryAgent).toSelf().inSingletonScope(); + bind(Agent).toService(ChatSessionSummaryAgent); + bind(SessionSumaryVariableContribution).toSelf().inSingletonScope(); + bind(AIVariableContribution).toService(SessionSumaryVariableContribution); + bind(SessionSummaryVariableLabelProvider).toSelf().inSingletonScope(); + bind(LabelProviderContribution).toService(SessionSummaryVariableLabelProvider); }); 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..13aa3ea793ba9 100644 --- a/packages/ai-chat/src/browser/change-set-file-resource.ts +++ b/packages/ai-chat/src/browser/change-set-file-resource.ts @@ -14,107 +14,17 @@ // 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 { ResourceResolver, URI } from '@theia/core'; +import {DisposableMutableResource, DisposableRefCounter, ResourceInitializationOptions, UpdatableReferenceResource} from '@theia/ai-core'; +export {DisposableMutableResource, DisposableRefCounter, ResourceInitializationOptions, UpdatableReferenceResource}; 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(); 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..f25d650227c71 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'; @@ -72,29 +72,17 @@ 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) + .filter(p => !userInput || p.highlights?.label) .map(async (pick, index) => ({ label: pick.label, kind: monaco.languages.CompletionItemKind.File, @@ -102,7 +90,7 @@ export class FileChatVariableContribution implements FrontendVariableContributio 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, + 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}`, })) diff --git a/packages/ai-chat/src/browser/session-summary-variable-contribution.ts b/packages/ai-chat/src/browser/session-summary-variable-contribution.ts new file mode 100644 index 0000000000000..8f97b74bb67a9 --- /dev/null +++ b/packages/ai-chat/src/browser/session-summary-variable-contribution.ts @@ -0,0 +1,115 @@ +// ***************************************************************************** +// 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 { AIVariable, AIVariableContext, AIVariableResolutionRequest, AIVariableResolver, ResolvedAIContextVariable } from '@theia/ai-core'; +import { AIVariableCompletionContext, FrontendVariableContribution, FrontendVariableService } from '@theia/ai-core/lib/browser'; +import { MaybePromise, QuickInputService, QuickPickItem, QuickPickItemOrSeparator, QuickPickSeparator } from '@theia/core'; +import { ChatService, ChatSession } from '../common'; +import { codiconArray } from '@theia/core/lib/browser'; +import * as monaco from '@theia/monaco-editor-core'; +import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; + +export const SESSION_SUMMARY_VARIABLE: AIVariable = { + id: 'session-summary', + description: 'Resolves to a summary of the session with the given ID.', + name: 'session-summary', + label: 'Session Summary', + iconClasses: codiconArray('clippy'), + isContextVariable: true, + args: [{ name: 'session-id', description: 'The ID of the session to summarize.' }] +}; + +@injectable() +export class SessionSumaryVariableContribution implements FrontendVariableContribution, AIVariableResolver { + protected summaries = new Map(); + @inject(QuickInputService) protected readonly quickInputService: QuickInputService; + @inject(ChatService) protected readonly chatService: ChatService; + @inject(ChatSessionSummaryAgent) protected readonly summaryAgent: ChatSessionSummaryAgent; + + registerVariables(service: FrontendVariableService): void { + service.registerResolver(SESSION_SUMMARY_VARIABLE, this); + service.registerArgumentPicker(SESSION_SUMMARY_VARIABLE, this.pickSession.bind(this)); + service.registerArgumentCompletionProvider(SESSION_SUMMARY_VARIABLE, this.provideCompletionItems.bind(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(SESSION_SUMMARY_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, + })); + } + + protected getItems(): QuickPickItemOrSeparator[] { + return [ + ...(this.summaries.size ? [{ type: 'separator', label: 'Recent Summaries' }] satisfies QuickPickSeparator[] : []), + ...Array.from(this.summaries.entries(), ([id, { label }]) => ({ + type: 'item', + label, + id + })) satisfies QuickPickItem[], + ...(this.summaries.size ? [{ type: 'separator', label: 'Other Sessions' }] satisfies QuickPickSeparator[] : []), + ...this.chatService.getSessions() + .filter(candidate => !this.summaries.has(candidate.id) && candidate.model.getRequests().length) + .map(session => ({ type: 'item', label: session.title || session.id, id: session.id })) + ]; + } + + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { + return request.variable.id === SESSION_SUMMARY_VARIABLE.id ? 10000 : -5; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + if (request.variable.id !== SESSION_SUMMARY_VARIABLE.id || !request.arg) { return; } + const existingSession = this.chatService.getSession(request.arg); + const existingSummary = this.summaries.get(request.arg); + const newSummaryPossibleAndNecessary = !!existingSession && (!existingSummary || existingSummary.length !== existingSession.model.getRequests().length); + try { + const value = newSummaryPossibleAndNecessary ? await this.summarizeSession(existingSession) : existingSummary?.summary; + return value ? { ...request, value, contextValue: value } : undefined; + } catch (err) { + console.warn('Error retrieving chat session summary for session', request.arg, err); + return; + } + } + + protected async summarizeSession(session: ChatSession): Promise { + const summary = await this.summaryAgent.generateChatSessionSummary(session); + this.summaries.set(session.id, { label: session.title || session.id, summary, length: session.model.getRequests().length }); + return summary; + } + + getLabel(id: string): string | undefined { + return this.summaries.get(id)?.label; + } +} diff --git a/packages/ai-chat/src/browser/session-summary-variable-label-provider.ts b/packages/ai-chat/src/browser/session-summary-variable-label-provider.ts new file mode 100644 index 0000000000000..e902f198dfc56 --- /dev/null +++ b/packages/ai-chat/src/browser/session-summary-variable-label-provider.ts @@ -0,0 +1,64 @@ +// ***************************************************************************** +// 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 { SessionSumaryVariableContribution, SESSION_SUMMARY_VARIABLE } from './session-summary-variable-contribution'; +import { ChatService } from '../common'; + +@injectable() +export class SessionSummaryVariableLabelProvider implements LabelProviderContribution { + @inject(ChatService) protected readonly chatService: ChatService; + @inject(SessionSumaryVariableContribution) protected readonly chatVariableContribution: SessionSumaryVariableContribution; + protected isMine(element: object): element is AIVariableResolutionRequest & { arg: string } { + return AIVariableResolutionRequest.is(element) && element.variable.id === SESSION_SUMMARY_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.chatVariableContribution.getLabel(element.arg) ?? session?.id ?? element.arg; + } + + getLongName(element: object): string | undefined { + const short = this.getName(element); + const details = this.getDetails(element); + return `Summary of '${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/common/chat-agents.ts b/packages/ai-chat/src/common/chat-agents.ts index e37d16f38560b..c944a579140d9 100644 --- a/packages/ai-chat/src/common/chat-agents.ts +++ b/packages/ai-chat/src/common/chat-agents.ts @@ -64,6 +64,7 @@ import { import { parseContents } from './parse-contents'; import { DefaultResponseContentFactory, ResponseContentMatcher, ResponseContentMatcherProvider } from './response-content-matcher'; import { ChatToolRequest, ChatToolRequestService } from './chat-tool-request-service'; +import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; /** * System message content, enriched with function descriptions. @@ -126,6 +127,7 @@ export interface ChatAgent extends Agent { locations: ChatAgentLocation[]; iconClass?: string; invoke(request: MutableChatRequestModel, chatAgentService?: ChatAgentService): Promise; + readonly suggestions?: (string | MarkdownString)[]; } @injectable() diff --git a/packages/ai-chat/src/common/chat-service.ts b/packages/ai-chat/src/common/chat-service.ts index a8ed124bf7e65..edc2e99b0100b 100644 --- a/packages/ai-chat/src/common/chat-service.ts +++ b/packages/ai-chat/src/common/chat-service.ts @@ -266,11 +266,7 @@ export class ChatServiceImpl implements ChatService { } }); - 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; } 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..8da14931f741b 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, @@ -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.ts b/packages/ai-chat/src/common/chat-session-summary-agent.ts new file mode 100644 index 0000000000000..57a45528308ab --- /dev/null +++ b/packages/ai-chat/src/common/chat-session-summary-agent.ts @@ -0,0 +1,118 @@ +// ***************************************************************************** +// 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 { + Agent, + CommunicationRecordingService, + CommunicationRequestEntryParam, + getTextOfResponse, + LanguageModelRegistry, + LanguageModelRequirement, + PromptService, + PromptTemplate, + UserRequest +} from '@theia/ai-core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { ChatSession } from './chat-service'; +import { generateUuid } from '@theia/core'; + +const CHAT_SESSION_SUMMARY_PROMPT = { + id: 'chat-session-summary-prompt', + template: '{{!-- 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 below 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. \ +\ +Conversation:\n{{conversation}}', +}; + +@injectable() +export class ChatSessionSummaryAgent implements Agent { + static ID = 'chat-session-summary-agent'; + id = ChatSessionSummaryAgent.ID; + name = 'Chat Session Summary'; + description = 'Agent for generating chat session summaries.'; + variables = []; + promptTemplates: PromptTemplate[] = [CHAT_SESSION_SUMMARY_PROMPT]; + languageModelRequirements: LanguageModelRequirement[] = [{ + purpose: 'chat-session-summary', + identifier: 'openai/gpt-4o-mini', + }]; + agentSpecificVariables = [ + { name: 'conversation', usedInPrompt: true, description: 'The content of the chat conversation.' }, + ]; + functions = []; + + @inject(LanguageModelRegistry) + protected readonly lmRegistry: LanguageModelRegistry; + + @inject(CommunicationRecordingService) + protected recordingService: CommunicationRecordingService; + + @inject(PromptService) + protected promptService: PromptService; + + async generateChatSessionSummary(chatSession: ChatSession): Promise { + const lm = await this.lmRegistry.selectLanguageModel({ agent: this.id, ...this.languageModelRequirements[0] }); + if (!lm) { + throw new Error('No language model found for chat session summary.'); + } + if (chatSession.model.getRequests().length < 1) { + throw new Error('No chat request available to generate chat session summary.'); + } + + const conversation = chatSession.model.getRequests() + .map(req => `${req.request.text}` + + (req.response.response ? `${req.response.response.asString()}` : '')) + .join('\n\n'); + + const prompt = await this.promptService.getPrompt(CHAT_SESSION_SUMMARY_PROMPT.id, { conversation }); + const message = prompt?.text; + if (!message) { + throw new Error('Unable to create prompt message for generating chat session summary.'); + } + + const sessionId = generateUuid(); + const requestId = generateUuid(); + const request = { + messages: [{ + actor: 'user', + text: message, + type: 'text' + }], + sessionId, + requestId, + agentId: this.id + } satisfies UserRequest; + + this.recordingService.recordRequest({ ...request, request: request.messages } satisfies CommunicationRequestEntryParam); + + const result = await lm.request(request); + const response = await getTextOfResponse(result); + this.recordingService.recordResponse({ + agentId: this.id, + sessionId, + requestId, + response: [{ actor: 'ai', text: response, type: 'text' }] + }); + + return response; + } + +} 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..c183b09d42bcf 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,16 @@ import { ToolProvider, TokenUsageService, TOKEN_USAGE_SERVICE_PATH, - TokenUsageServiceClient + TokenUsageServiceClient, + AIVariableResourceResolver } 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 +69,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 +158,8 @@ 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); }); 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..e4bea57615ff2 --- /dev/null +++ b/packages/ai-core/src/browser/ai-variable-uri-label-provider.ts @@ -0,0 +1,61 @@ +// ***************************************************************************** +// 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 } from '../common/variable-service'; + +@injectable() +export class AIVariableUriLabelProvider implements LabelProviderContribution { + + @inject(LabelProvider) protected readonly labelProvider: LabelProvider; + @inject(AIVariableResourceResolver) protected variableResourceResolver: AIVariableResourceResolver; + + 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; } + return this.variableResourceResolver.fromUri(element); + } +} diff --git a/packages/ai-core/src/browser/frontend-variable-service.ts b/packages/ai-core/src/browser/frontend-variable-service.ts index 7ca858ab731ad..f4583680e6c1f 100644 --- a/packages/ai-core/src/browser/frontend-variable-service.ts +++ b/packages/ai-core/src/browser/frontend-variable-service.ts @@ -17,7 +17,8 @@ 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 { AIVariableContext, AIVariableResolutionRequest, AIVariableService, DefaultAIVariableService, PromptText } from '../common'; +import * as monaco from '@theia/monaco-editor-core'; export type AIVariableDropHandler = (event: DragEvent, context: AIVariableContext) => Promise; @@ -26,6 +27,42 @@ 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; 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..0a7543c53b605 --- /dev/null +++ b/packages/ai-core/src/common/ai-variable-resource.ts @@ -0,0 +1,220 @@ +// ***************************************************************************** +// 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 { injectable, inject } from '@theia/core/shared/inversify'; +import { Resource, ResourceResolver, Reference, URI, Emitter, Event, generateUuid } from '@theia/core'; +import { AIVariableContext, AIVariableResolutionRequest, AIVariableService, ResolvedAIContextVariable } from './variable-service'; +import stableJsonStringify = require('fast-json-stable-stringify'); + +export const AI_VARIABLE_RESOURCE_SCHEME = 'ai-variable'; +export const NO_CONTEXT_AUTHORITY = 'context-free'; + +export type ResourceInitializationOptions = Pick + & { contents?: string | Promise, onSave?: Resource['saveContents'] }; +export type ResourceUpdateOptions = Pick; + +export class UpdatableReferenceResource implements Resource { + static acquire(resource: UpdatableReferenceResource): UpdatableReferenceResource { + DisposableRefCounter.acquire(resource.reference); + return resource; + } + + constructor(protected reference: DisposableRefCounter) { } + + 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: 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 implements Resource { + protected onSave: Resource['saveContents'] | undefined; + protected contents: string | Promise; + protected readonly onDidChangeContentsEmitter = new Emitter(); + readonly onDidChangeContents = this.onDidChangeContentsEmitter.event; + + constructor(readonly uri: URI, protected readonly options?: ResourceInitializationOptions) { + 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; + } + + readContents(): Promise { + return Promise.resolve(this.contents); + } + + 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.onDidChangeContentsEmitter.fire(); + } + if ('onSave' in options && options.onSave !== this.onSave) { + this.onSave = options.onSave; + } + } + + 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 AIVariableResourceResolver implements ResourceResolver { + protected readonly cache = new Map(); + @inject(AIVariableService) protected readonly variableService: AIVariableService; + + resolve(uri: URI): Resource { + const existing = this.tryGet(uri); + if (!existing) { + throw new Error('Unknown URI'); + } + return existing; + } + + protected tryGet(uri: URI): UpdatableReferenceResource | undefined { + const existing = this.cache.get(uri.toString()); + if (existing) { + return UpdatableReferenceResource.acquire(existing[0]); + } + } + + get(request: AIVariableResolutionRequest, context: AIVariableContext): Resource { + const uri = this.toUri(request, context); + const existing = this.tryGet(uri); + if (existing) { return existing; } + const key = uri.toString(); + const underlying = new DisposableMutableResource(uri, { readOnly: true }); + const ref = DisposableRefCounter.create(underlying, () => { + underlying.dispose(); + this.cache.delete(key); + }); + const refResource = new UpdatableReferenceResource(ref); + this.cache.set(key, [refResource, context]); + this.variableService.resolveVariable(request, context) + .then((value: ResolvedAIContextVariable) => value && refResource.update({ contents: value.contextValue || value.value })); + return refResource; + } + + 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): AIVariableResolutionRequest | undefined { + if (uri.scheme !== AI_VARIABLE_RESOURCE_SCHEME) { return undefined; } + try { + const { name, arg } = JSON.parse(uri.query); + if (!name) { return undefined; } + const variable = this.variableService.getVariable(name); + if (!variable) { return undefined; } + return { + variable, + arg, + }; + } catch { return undefined; } + } +} diff --git a/packages/ai-core/src/common/index.ts b/packages/ai-core/src/common/index.ts index 798cfbead329d..bd1984d0969c9 100644 --- a/packages/ai-core/src/common/index.ts +++ b/packages/ai-core/src/common/index.ts @@ -30,3 +30,4 @@ export * from './variable-service'; export * from './settings-service'; export * from './language-model-service'; export * from './token-usage-service'; +export * from './ai-variable-resource'; diff --git a/packages/ai-ide/src/browser/coder-agent.ts b/packages/ai-ide/src/browser/coder-agent.ts index 64a4bbfaab59d..09da650058b34 100644 --- a/packages/ai-ide/src/browser/coder-agent.ts +++ b/packages/ai-ide/src/browser/coder-agent.ts @@ -20,6 +20,8 @@ import { CODER_REPLACE_PROMPT_TEMPLATE_ID, getCoderReplacePromptTemplate } from 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, AI_CHAT_NEW_WITH_MEMORY } from '@theia/ai-chat-ui/lib/browser/chat-view-commands'; @injectable() export class CoderAgent extends AbstractStreamParsingChatAgent { @@ -30,6 +32,8 @@ export class CoderAgent extends AbstractStreamParsingChatAgent { identifier: 'openai/gpt-4o', }]; protected defaultLanguageModelPurpose: string = 'chat'; + readonly suggestions = [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:${AI_CHAT_NEW_WITH_MEMORY.id}).`)]; override description = nls.localize('theia/ai/workspace/coderAgent/description', 'An AI assistant integrated into Theia IDE, designed to assist software developers. This agent can access the users workspace, it can get a list of all available files \ diff --git a/packages/ai-ide/src/browser/context-session-summary-variable.ts b/packages/ai-ide/src/browser/context-session-summary-variable.ts new file mode 100644 index 0000000000000..b6ad6a57c0a6c --- /dev/null +++ b/packages/ai-ide/src/browser/context-session-summary-variable.ts @@ -0,0 +1,63 @@ +// ***************************************************************************** +// 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 { CONTEXT_SESSION_MEMORY_VARIABLE_ID } from '../common/context-variables'; +import { SESSION_SUMMARY_VARIABLE } from '@theia/ai-chat/lib/browser/session-summary-variable-contribution'; + +export const CONTEXT_SESSION_MEMORY_VARIABLE: AIVariable = { + id: CONTEXT_SESSION_MEMORY_VARIABLE_ID, + description: nls.localize('theia/ai/core/contextSummaryVariable/description', 'Resolves any summaries present in the context.'), + name: CONTEXT_SESSION_MEMORY_VARIABLE_ID, +}; + +@injectable() +export class ContextSessionSummaryVariable implements AIVariableContribution, AIVariableResolverWithVariableDependencies { + registerVariables(service: AIVariableService): void { + service.registerResolver(CONTEXT_SESSION_MEMORY_VARIABLE, this); + } + + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { + return request.variable.name === CONTEXT_SESSION_MEMORY_VARIABLE.name ? 50 : 0; + } + + async resolve( + request: AIVariableResolutionRequest, + context: AIVariableContext, + resolveDependency?: (variable: AIVariableArg) => Promise + ): Promise { + if (!resolveDependency || !ChatSessionContext.is(context) || request.variable.name !== CONTEXT_SESSION_MEMORY_VARIABLE.name) { return undefined; } + const allSummaryRequests = context.model.context.getVariables().filter(candidate => candidate.variable.id === SESSION_SUMMARY_VARIABLE.id); + const allSummaries = await Promise.all(allSummaryRequests.map(summaryRequest => resolveDependency(summaryRequest).then(resolved => resolved?.value))); + const value = allSummaries.map((content, index) => `# Context Memory ${index + 1}\n\n${content}`).join('\n\n'); + return { + ...request, + value + }; + } +} diff --git a/packages/ai-ide/src/browser/frontend-module.ts b/packages/ai-ide/src/browser/frontend-module.ts index a06deb3fbba43..d77ef7e4df82f 100644 --- a/packages/ai-ide/src/browser/frontend-module.ts +++ b/packages/ai-ide/src/browser/frontend-module.ts @@ -45,6 +45,7 @@ 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 { ContextSessionSummaryVariable } from './context-session-summary-variable'; export default new ContainerModule(bind => { bind(PreferenceContribution).toConstantValue({ schema: WorkspacePreferencesSchema }); @@ -135,4 +136,7 @@ export default new ContainerModule(bind => { createWidget: () => ctx.container.get(AITokenUsageConfigurationWidget) })) .inSingletonScope(); + + bind(ContextSessionSummaryVariable).toSelf().inSingletonScope(); + bind(AIVariableContribution).toService(ContextSessionSummaryVariable); }); 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 773479b5a4325..71327d5f4a7eb 100644 --- a/packages/ai-ide/src/common/coder-replace-prompt-template.ts +++ b/packages/ai-ide/src/common/coder-replace-prompt-template.ts @@ -17,7 +17,7 @@ import { GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID, GET_FILE_DIAGNOSTICS_ID } from './workspace-functions'; -import { CONTEXT_FILES_VARIABLE_ID } from './context-variables'; +import { CONTEXT_FILES_VARIABLE_ID, CONTEXT_SESSION_MEMORY_VARIABLE_ID } from './context-variables'; import { UPDATE_CONTEXT_FILES_FUNCTION_ID } from './context-functions'; export const CODER_REWRITE_PROMPT_TEMPLATE_ID = 'coder-rewrite'; @@ -51,7 +51,11 @@ 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.' : ''} - + +## Previous Interactions and Background + +{{${CONTEXT_SESSION_MEMORY_VARIABLE_ID}}} + ## Additional Context The following files have been provided for additional context. Some of them may also be referred to by the user. \ diff --git a/packages/ai-ide/src/common/context-variables.ts b/packages/ai-ide/src/common/context-variables.ts index 4044d9e4444d4..3043303078813 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 CONTEXT_SESSION_MEMORY_VARIABLE_ID = 'contextMemories'; From 54e2a55910908bffcc8fb0475051e6f8aaa812d2 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 15 Apr 2025 14:12:15 -0600 Subject: [PATCH 02/28] Progress towards servicification --- package-lock.json | 1 + packages/ai-chat/package.json | 1 + .../src/browser/ai-chat-frontend-module.ts | 3 + .../src/browser/ai-chat-preferences.ts | 9 + .../session-summary-variable-contribution.ts | 42 ++--- .../src/browser/task-context-service.ts | 164 ++++++++++++++++++ packages/ai-chat/src/common/chat-model.ts | 24 ++- 7 files changed, 210 insertions(+), 34 deletions(-) create mode 100644 packages/ai-chat/src/browser/task-context-service.ts diff --git a/package-lock.json b/package-lock.json index ee865291ada23..05e7b69cabfea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30409,6 +30409,7 @@ "@theia/monaco": "1.60.0", "@theia/monaco-editor-core": "1.96.302", "@theia/workspace": "1.60.0", + "js-yaml": "^4.1.0", "minimatch": "^5.1.0", "tslib": "^2.6.2" }, diff --git a/packages/ai-chat/package.json b/packages/ai-chat/package.json index fccd2421293f2..89638a2218bd6 100644 --- a/packages/ai-chat/package.json +++ b/packages/ai-chat/package.json @@ -12,6 +12,7 @@ "@theia/monaco": "1.60.0", "@theia/monaco-editor-core": "1.96.302", "@theia/workspace": "1.60.0", + "js-yaml": "^4.1.0", "minimatch": "^5.1.0", "tslib": "^2.6.2" }, 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 d2fbf50b85f55..1bc69e695fb91 100644 --- a/packages/ai-chat/src/browser/ai-chat-frontend-module.ts +++ b/packages/ai-chat/src/browser/ai-chat-frontend-module.ts @@ -49,6 +49,7 @@ import { ChatSessionNamingAgent, ChatSessionNamingService } from '../common/chat import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; import { SessionSumaryVariableContribution } from './session-summary-variable-contribution'; import { SessionSummaryVariableLabelProvider } from './session-summary-variable-label-provider'; +import { TaskContextService } from './task-context-service'; export default new ContainerModule(bind => { bindContributionProvider(bind, Agent); @@ -123,4 +124,6 @@ export default new ContainerModule(bind => { bind(AIVariableContribution).toService(SessionSumaryVariableContribution); bind(SessionSummaryVariableLabelProvider).toSelf().inSingletonScope(); bind(LabelProviderContribution).toService(SessionSummaryVariableLabelProvider); + + bind(TaskContextService).toSelf().inSingletonScope(); }); diff --git a/packages/ai-chat/src/browser/ai-chat-preferences.ts b/packages/ai-chat/src/browser/ai-chat-preferences.ts index c4995c5cece2a..1877a7c7cc2ba 100644 --- a/packages/ai-chat/src/browser/ai-chat-preferences.ts +++ b/packages/ai-chat/src/browser/ai-chat-preferences.ts @@ -20,6 +20,7 @@ import { PreferenceSchema } from '@theia/core/lib/browser/preferences/preference export const DEFAULT_CHAT_AGENT_PREF = 'ai-features.chat.defaultChatAgent'; export const PIN_CHAT_AGENT_PREF = 'ai-features.chat.pinChatAgent'; +export const TASK_CONTEXT_STORAGE_DIRECTORY_PREF = 'ai-features.chat.taskContextStorageDirectory'; export const aiChatPreferences: PreferenceSchema = { type: 'object', @@ -38,6 +39,14 @@ If no Default Agent is configured, Theia´s defaults will be applied.'), You can manually unpin or switch agents anytime.'), default: true, title: AI_CORE_PREFERENCES_TITLE, + }, + [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-chat/src/browser/session-summary-variable-contribution.ts b/packages/ai-chat/src/browser/session-summary-variable-contribution.ts index 8f97b74bb67a9..3c79523824ea5 100644 --- a/packages/ai-chat/src/browser/session-summary-variable-contribution.ts +++ b/packages/ai-chat/src/browser/session-summary-variable-contribution.ts @@ -18,10 +18,10 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { AIVariable, AIVariableContext, AIVariableResolutionRequest, AIVariableResolver, ResolvedAIContextVariable } from '@theia/ai-core'; import { AIVariableCompletionContext, FrontendVariableContribution, FrontendVariableService } from '@theia/ai-core/lib/browser'; import { MaybePromise, QuickInputService, QuickPickItem, QuickPickItemOrSeparator, QuickPickSeparator } from '@theia/core'; -import { ChatService, ChatSession } from '../common'; +import { ChatService } from '../common'; import { codiconArray } from '@theia/core/lib/browser'; import * as monaco from '@theia/monaco-editor-core'; -import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; +import { TaskContextService } from './task-context-service'; export const SESSION_SUMMARY_VARIABLE: AIVariable = { id: 'session-summary', @@ -35,10 +35,9 @@ export const SESSION_SUMMARY_VARIABLE: AIVariable = { @injectable() export class SessionSumaryVariableContribution implements FrontendVariableContribution, AIVariableResolver { - protected summaries = new Map(); @inject(QuickInputService) protected readonly quickInputService: QuickInputService; @inject(ChatService) protected readonly chatService: ChatService; - @inject(ChatSessionSummaryAgent) protected readonly summaryAgent: ChatSessionSummaryAgent; + @inject(TaskContextService) protected readonly taskContextService: TaskContextService; registerVariables(service: FrontendVariableService): void { service.registerResolver(SESSION_SUMMARY_VARIABLE, this); @@ -71,16 +70,13 @@ export class SessionSumaryVariableContribution implements FrontendVariableContri } protected getItems(): QuickPickItemOrSeparator[] { + const existingSummaries = this.taskContextService.getSummaries(); return [ - ...(this.summaries.size ? [{ type: 'separator', label: 'Recent Summaries' }] satisfies QuickPickSeparator[] : []), - ...Array.from(this.summaries.entries(), ([id, { label }]) => ({ - type: 'item', - label, - id - })) satisfies QuickPickItem[], - ...(this.summaries.size ? [{ type: 'separator', label: 'Other Sessions' }] satisfies QuickPickSeparator[] : []), + ...(existingSummaries.length ? [{ type: 'separator', label: 'Saved Tasks' }] satisfies QuickPickSeparator[] : []), + ...existingSummaries satisfies QuickPickItem[], + ...(existingSummaries.length ? [{ type: 'separator', label: 'Other Sessions' }] satisfies QuickPickSeparator[] : []), ...this.chatService.getSessions() - .filter(candidate => !this.summaries.has(candidate.id) && candidate.model.getRequests().length) + .filter(candidate => !this.taskContextService.hasSummary(candidate.id) && candidate.model.getRequests().length) .map(session => ({ type: 'item', label: session.title || session.id, id: session.id })) ]; } @@ -91,25 +87,7 @@ export class SessionSumaryVariableContribution implements FrontendVariableContri async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { if (request.variable.id !== SESSION_SUMMARY_VARIABLE.id || !request.arg) { return; } - const existingSession = this.chatService.getSession(request.arg); - const existingSummary = this.summaries.get(request.arg); - const newSummaryPossibleAndNecessary = !!existingSession && (!existingSummary || existingSummary.length !== existingSession.model.getRequests().length); - try { - const value = newSummaryPossibleAndNecessary ? await this.summarizeSession(existingSession) : existingSummary?.summary; - return value ? { ...request, value, contextValue: value } : undefined; - } catch (err) { - console.warn('Error retrieving chat session summary for session', request.arg, err); - return; - } - } - - protected async summarizeSession(session: ChatSession): Promise { - const summary = await this.summaryAgent.generateChatSessionSummary(session); - this.summaries.set(session.id, { label: session.title || session.id, summary, length: session.model.getRequests().length }); - return summary; - } - - getLabel(id: string): string | undefined { - return this.summaries.get(id)?.label; + const value = await this.taskContextService.getSummary(request.arg).catch(() => undefined); + return value ? { ...request, value, contextValue: value } : undefined; } } 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..abe3e3e9eec95 --- /dev/null +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -0,0 +1,164 @@ +// ***************************************************************************** +// 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, postConstruct } from '@theia/core/shared/inversify'; +import { DisposableCollection, EOL, Path, URI, unreachable } from '@theia/core'; +import { ChatService, ChatSession } from '../common'; +import { PreferenceService } from '@theia/core/lib/browser'; +import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { TASK_CONTEXT_STORAGE_DIRECTORY_PREF } from './ai-chat-preferences'; +import { load, dump } from 'js-yaml'; +import { BinaryBuffer } from '@theia/core/lib/common/buffer'; +import { FileChange, FileChangeType } from '@theia/filesystem/lib/common/files'; + +@injectable() +export class TaskContextService { + protected summaries = new Map(); + @inject(PreferenceService) protected readonly preferenceService: PreferenceService; + @inject(ChatService) protected readonly chatService: ChatService; + @inject(ChatSessionSummaryAgent) protected readonly summaryAgent: ChatSessionSummaryAgent; + @inject(FileService) protected readonly fileService: FileService; + @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + + @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), + this.fileService.onDidFilesChange(event => { + const relevantChanges = event.changes.filter(candidate => newStorage.isEqualOrParent(candidate.resource)); + this.handleChanges(relevantChanges); + }), + { dispose: () => this.clearFileReferences() }, + ); + await this.cacheNewTasks(newStorage); + } + + protected clearFileReferences(): void { + for (const [key, value] of this.summaries.entries()) { + if (value.uri) { + this.summaries.delete(key); + } + } + } + + protected getStorageLocation(): URI | undefined { + if (!this.workspaceService.opened) { return; } + const configuredPath = this.preferenceService.inspect(TASK_CONTEXT_STORAGE_DIRECTORY_PREF)?.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); + } + + 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))); + } + + protected async handleChanges(changes: FileChange[]): Promise { + await Promise.all(changes.map(change => { + switch (change.type) { + case FileChangeType.DELETED: return this.summaries.delete(change.resource.path.base); + case FileChangeType.ADDED: + case FileChangeType.UPDATED: + return this.readFile(change.resource); + default: return unreachable(change.type); + } + })); + } + + getSummaries(): Array<{ id: string, label: string, summary: string }> { + return Array.from(this.summaries.entries(), ([id, { label, summary }]) => ({ id, label, summary })); + } + + hasSummary(id: string): boolean { + return this.summaries.has(id); + } + + async getSummary(sessionIdOrFilePath: string): Promise { + const existing = this.summaries.get(sessionIdOrFilePath); + if (existing) { return existing.summary; } + const session = this.chatService.getSession(sessionIdOrFilePath); + if (session) { + return this.summarizeAndStore(session); + } + throw new Error('Unable to resolve summary request.'); + } + + protected async summarizeAndStore(session: ChatSession): Promise { + const summary = await this.summaryAgent.generateChatSessionSummary(session); + const storageLocation = this.getStorageLocation(); + if (storageLocation) { + const frontmatter = { + session: session.id, + date: new Date().toISOString(), + label: session.title || undefined, + }; + const content = dump(frontmatter) + `${EOL}---${EOL}` + summary; + const derivedName = (session.title || session.id).replace(/\W/g, '-').replace(/-+/g, '-'); + const filename = (derivedName.length > 32 ? derivedName.slice(0, derivedName.indexOf('-', 32)) : derivedName) + '.md'; + const uri = storageLocation.resolve(filename); + await this.fileService.writeFile(uri, BinaryBuffer.fromString(content)); + this.summaries.set(filename, { label: session.title || session.id, summary, uri }); + } else { + this.summaries.set(session.id, { label: session.title || session.id, summary }); + } + return summary; + } + + protected async readFile(uri: URI): Promise { + const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined); + if (!content) { return; } + const { frontmatter, body } = this.maybeReadFrontmatter(content); + this.summaries.set(uri.path.base, { summary: body, label: frontmatter?.label || uri.path.base, uri }); + } + + protected maybeReadFrontmatter(content: string): { body: string, frontmatter: { label: string } | undefined } { + const frontmatterEnd = content.indexOf('---'); + if (frontmatterEnd !== -1) { + try { + const frontmatter = 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 { label: string } { + return !!candidate && typeof candidate === 'object' && !Array.isArray(candidate) && 'label' in candidate && typeof candidate.label === 'string'; + } + + + getLabel(id: string): string | undefined { + return this.summaries.get(id)?.label; + } +} diff --git a/packages/ai-chat/src/common/chat-model.ts b/packages/ai-chat/src/common/chat-model.ts index f2c4f040aa7c0..f1d98afd675ca 100644 --- a/packages/ai-chat/src/common/chat-model.ts +++ b/packages/ai-chat/src/common/chat-model.ts @@ -37,6 +37,7 @@ export type ChatChangeEvent = | ChatRemoveVariableEvent | ChatRemoveRequestEvent | ChatSetChangeSetEvent + | ChatSuggestionsChangedEvent | ChatUpdateChangeSetEvent | ChatRemoveChangeSetEvent; @@ -74,6 +75,11 @@ export interface ChatRemoveVariableEvent { kind: 'removeVariable'; } +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'; @@ -107,6 +113,8 @@ export interface ChangeSet extends Disposable { dispose(): void; } +export type ChatSuggestion = | string | MarkdownString | CommandChatResponseContent; + export interface ChatContextManager { onDidChange: Event; getVariables(): readonly AIVariableResolutionRequest[] @@ -539,6 +547,7 @@ export class MutableChatModel implements ChatModel, Disposable { protected _requests: MutableChatRequestModel[]; protected _id: string; protected _changeSet?: ChangeSetImpl; + protected _suggestions: readonly ChatSuggestion[] = []; protected readonly _contextManager = new ChatContextManagerImpl(); protected _settings: { [key: string]: unknown }; @@ -565,6 +574,10 @@ export class MutableChatModel implements ChatModel, Disposable { return this._changeSet; } + get suggestions(): ChatSuggestion[] { + return this.suggestions; + } + get context(): ChatContextManager { return this._contextManager; } @@ -619,6 +632,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._requests.length === 0; } @@ -1061,8 +1082,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 ?? []; From 86080b2c41ffbddfc2bc6776a18d044308cd97c0 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Thu, 17 Apr 2025 12:38:46 -0600 Subject: [PATCH 03/28] More service, more rendering, more handling --- .../browser/chat-input-agent-suggestions.tsx | 58 ++++++++++-- .../src/browser/chat-input-widget.tsx | 6 +- .../markdown-part-renderer.tsx | 21 ++++- .../session-summary-variable-contribution.ts | 2 +- ...session-summary-variable-label-provider.ts | 4 +- .../src/browser/task-context-service.ts | 89 ++++++++++++++----- packages/ai-chat/src/common/chat-agents.ts | 6 +- packages/ai-chat/src/common/chat-model.ts | 18 +++- packages/ai-chat/src/common/chat-service.ts | 19 ++-- packages/ai-ide/src/browser/coder-agent.ts | 29 ++++-- 10 files changed, 193 insertions(+), 59 deletions(-) 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 index d5c8c1eb4bc0c..7dabae0bf12d4 100644 --- a/packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx +++ b/packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx @@ -15,27 +15,71 @@ // ***************************************************************************** import * as React from '@theia/core/shared/react'; -import { useMarkdownRendering } from './chat-response-renderer'; +import { DeclaredEventsEventListenerObject, useMarkdownRendering } from './chat-response-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: (string | MarkdownString)[]; + suggestions: readonly ChatSuggestion[]; opener: OpenerService; } +function getKey(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.map(suggestion => )} + !!suggestions?.length &&
+ {suggestions.map(suggestion => )}
); interface ChatInputAgestSuggestionProps { - suggestion: string | MarkdownString; + suggestion: ChatSuggestion; opener: OpenerService; + handler?: DeclaredEventsEventListenerObject; } -const ChatInputAgentSuggestion: React.FC = ({suggestion, opener}) => { - const ref = useMarkdownRendering(suggestion, opener, true); +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 9e3b2adfa2626..d3e5252ab526b 100644 --- a/packages/ai-chat-ui/src/browser/chat-input-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx @@ -13,7 +13,7 @@ // // 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, ChatSuggestion } from '@theia/ai-chat'; import { Disposable, DisposableCollection, InMemoryResources, URI, nls } from '@theia/core'; import { ContextMenuRenderer, LabelProvider, Message, OpenerService, ReactWidget } from '@theia/core/lib/browser'; import { Deferred } from '@theia/core/lib/common/promise-util'; @@ -168,6 +168,7 @@ export class AIChatInputWidget extends ReactWidget { labelProvider={this.labelProvider} actionService={this.changeSetActionService} openerService={this.openerService} + suggestions={this._chatModel.suggestions} /> ); } @@ -262,6 +263,7 @@ interface ChatInputProperties { labelProvider: LabelProvider; actionService: ChangeSetActionService; openerService: OpenerService; + suggestions: readonly ChatSuggestion[] } const ChatInput: React.FunctionComponent = (props: ChatInputProperties) => { @@ -531,7 +533,7 @@ const ChatInput: React.FunctionComponent = (props: ChatInpu const contextUI = buildContextUI(props.context, props.labelProvider, props.onDeleteContextElement, props.onOpenContextElement); return
- {!!props.pinnedAgent?.suggestions?.length && } + {} {changeSetUI?.elements && } 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/src/browser/session-summary-variable-contribution.ts b/packages/ai-chat/src/browser/session-summary-variable-contribution.ts index 3c79523824ea5..3ce10ac3a5817 100644 --- a/packages/ai-chat/src/browser/session-summary-variable-contribution.ts +++ b/packages/ai-chat/src/browser/session-summary-variable-contribution.ts @@ -85,7 +85,7 @@ export class SessionSumaryVariableContribution implements FrontendVariableContri return request.variable.id === SESSION_SUMMARY_VARIABLE.id ? 10000 : -5; } - async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + async resolve(request: AIVariableResolutionRequest, _context: AIVariableContext): Promise { if (request.variable.id !== SESSION_SUMMARY_VARIABLE.id || !request.arg) { return; } const value = await this.taskContextService.getSummary(request.arg).catch(() => undefined); return value ? { ...request, value, contextValue: value } : undefined; diff --git a/packages/ai-chat/src/browser/session-summary-variable-label-provider.ts b/packages/ai-chat/src/browser/session-summary-variable-label-provider.ts index e902f198dfc56..dbfe42d9464c0 100644 --- a/packages/ai-chat/src/browser/session-summary-variable-label-provider.ts +++ b/packages/ai-chat/src/browser/session-summary-variable-label-provider.ts @@ -20,11 +20,13 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { codicon, LabelProviderContribution } from '@theia/core/lib/browser'; import { SessionSumaryVariableContribution, SESSION_SUMMARY_VARIABLE } from './session-summary-variable-contribution'; import { ChatService } from '../common'; +import { TaskContextService } from './task-context-service'; @injectable() export class SessionSummaryVariableLabelProvider implements LabelProviderContribution { @inject(ChatService) protected readonly chatService: ChatService; @inject(SessionSumaryVariableContribution) protected readonly chatVariableContribution: SessionSumaryVariableContribution; + @inject(TaskContextService) protected readonly taskContextService: TaskContextService; protected isMine(element: object): element is AIVariableResolutionRequest & { arg: string } { return AIVariableResolutionRequest.is(element) && element.variable.id === SESSION_SUMMARY_VARIABLE.id && !!element.arg; } @@ -41,7 +43,7 @@ export class SessionSummaryVariableLabelProvider implements LabelProviderContrib getName(element: object): string | undefined { if (!this.isMine(element)) { return undefined; } const session = this.chatService.getSession(element.arg); - return session?.title ?? this.chatVariableContribution.getLabel(element.arg) ?? session?.id ?? element.arg; + return session?.title ?? this.taskContextService.getLabel(element.arg) ?? session?.id ?? element.arg; } getLongName(element: object): string | undefined { diff --git a/packages/ai-chat/src/browser/task-context-service.ts b/packages/ai-chat/src/browser/task-context-service.ts index abe3e3e9eec95..7731e6ad1ec21 100644 --- a/packages/ai-chat/src/browser/task-context-service.ts +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -22,13 +22,21 @@ import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { TASK_CONTEXT_STORAGE_DIRECTORY_PREF } from './ai-chat-preferences'; -import { load, dump } from 'js-yaml'; +import * as yaml from 'js-yaml'; import { BinaryBuffer } from '@theia/core/lib/common/buffer'; import { FileChange, FileChangeType } from '@theia/filesystem/lib/common/files'; +import { Deferred } from '@theia/core/lib/common/promise-util'; + +interface Summary { + label: string; + summary: string; + uri?: URI; +} @injectable() export class TaskContextService { - protected summaries = new Map(); + protected summaries = new Map(); + protected pendingSummaries = new Map>(); @inject(PreferenceService) protected readonly preferenceService: PreferenceService; @inject(ChatService) protected readonly chatService: ChatService; @inject(ChatSessionSummaryAgent) protected readonly summaryAgent: ChatSessionSummaryAgent; @@ -105,6 +113,10 @@ export class TaskContextService { async getSummary(sessionIdOrFilePath: string): Promise { const existing = this.summaries.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.summarizeAndStore(session); @@ -113,38 +125,68 @@ export class TaskContextService { } protected async summarizeAndStore(session: ChatSession): Promise { - const summary = await this.summaryAgent.generateChatSessionSummary(session); - const storageLocation = this.getStorageLocation(); - if (storageLocation) { - const frontmatter = { - session: session.id, - date: new Date().toISOString(), - label: session.title || undefined, + const storageId = this.idForSession(session); + const pending = this.pendingSummaries.get(storageId); + if (pending) { return pending.then(({ summary }) => summary); } + const summaryDeferred = new Deferred(); + this.pendingSummaries.set(storageId, summaryDeferred.promise); + try { + const newSummary: Summary = { + summary: await this.summaryAgent.generateChatSessionSummary(session), + label: session.title || session.id, }; - const content = dump(frontmatter) + `${EOL}---${EOL}` + summary; - const derivedName = (session.title || session.id).replace(/\W/g, '-').replace(/-+/g, '-'); - const filename = (derivedName.length > 32 ? derivedName.slice(0, derivedName.indexOf('-', 32)) : derivedName) + '.md'; - const uri = storageLocation.resolve(filename); - await this.fileService.writeFile(uri, BinaryBuffer.fromString(content)); - this.summaries.set(filename, { label: session.title || session.id, summary, uri }); - } else { - this.summaries.set(session.id, { label: session.title || session.id, summary }); + const storageLocation = this.getStorageLocation(); + if (storageLocation) { + const frontmatter = { + session: session.id, + date: new Date().toISOString(), + label: session.title || undefined, + }; + const content = yaml.dump(frontmatter) + `${EOL}---${EOL}` + newSummary.summary; + const uri = storageLocation.resolve(storageId); + newSummary.uri = uri; + await this.fileService.writeFile(uri, BinaryBuffer.fromString(content)); + } + this.summaries.set(storageId, newSummary); + return newSummary.summary; + } catch (err) { + summaryDeferred.reject(err); + throw err; + } finally { + this.pendingSummaries.delete(storageId); } - return summary; + } + + protected idForSession(session: ChatSession): string { + if (!this.getStorageLocation()) { return session.id; } + const derivedName = (session.title || session.id).replace(/\W/g, '-').replace(/-+/g, '-'); + const filename = (derivedName.length > 32 ? derivedName.slice(0, derivedName.indexOf('-', 32)) : derivedName) + '.md'; + return filename; } protected async readFile(uri: URI): Promise { - const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined); - if (!content) { return; } - const { frontmatter, body } = this.maybeReadFrontmatter(content); - this.summaries.set(uri.path.base, { summary: body, label: frontmatter?.label || uri.path.base, uri }); + if (this.pendingSummaries.has(uri.path.base)) { return; } + const summaryDeferred = new Deferred(); + this.pendingSummaries.set(uri.path.base, summaryDeferred.promise); + try { + const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined); + if (!content) { return; } + const { frontmatter, body } = this.maybeReadFrontmatter(content); + const summary = { summary: body, label: frontmatter?.label || uri.path.base, uri }; + this.summaries.set(uri.path.base, summary); + summaryDeferred.resolve(summary); + } catch (err) { + summaryDeferred.reject(err); + } finally { + this.pendingSummaries.delete(uri.path.base); + } } protected maybeReadFrontmatter(content: string): { body: string, frontmatter: { label: string } | undefined } { const frontmatterEnd = content.indexOf('---'); if (frontmatterEnd !== -1) { try { - const frontmatter = load(content.slice(0, frontmatterEnd)); + const frontmatter = yaml.load(content.slice(0, frontmatterEnd)); if (this.hasLabel(frontmatter)) { return { frontmatter, body: content.slice(frontmatterEnd + 3).trim() }; } @@ -157,7 +199,6 @@ export class TaskContextService { return !!candidate && typeof candidate === 'object' && !Array.isArray(candidate) && 'label' in candidate && typeof candidate.label === 'string'; } - getLabel(id: string): string | undefined { return this.summaries.get(id)?.label; } diff --git a/packages/ai-chat/src/common/chat-agents.ts b/packages/ai-chat/src/common/chat-agents.ts index c944a579140d9..035f8805e0cab 100644 --- a/packages/ai-chat/src/common/chat-agents.ts +++ b/packages/ai-chat/src/common/chat-agents.ts @@ -59,12 +59,12 @@ import { MarkdownChatResponseContentImpl, ToolCallChatResponseContentImpl, ChatRequestModel, - ThinkingChatResponseContentImpl + ThinkingChatResponseContentImpl, } from './chat-model'; import { parseContents } from './parse-contents'; import { DefaultResponseContentFactory, ResponseContentMatcher, ResponseContentMatcherProvider } from './response-content-matcher'; import { ChatToolRequest, ChatToolRequestService } from './chat-tool-request-service'; -import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; +import { ChatSession } from './chat-service'; /** * System message content, enriched with function descriptions. @@ -127,7 +127,7 @@ export interface ChatAgent extends Agent { locations: ChatAgentLocation[]; iconClass?: string; invoke(request: MutableChatRequestModel, chatAgentService?: ChatAgentService): Promise; - readonly suggestions?: (string | MarkdownString)[]; + suggest?(context: ChatRequestModel | ChatSession): Promise; } @injectable() diff --git a/packages/ai-chat/src/common/chat-model.ts b/packages/ai-chat/src/common/chat-model.ts index f1d98afd675ca..64c911b169f36 100644 --- a/packages/ai-chat/src/common/chat-model.ts +++ b/packages/ai-chat/src/common/chat-model.ts @@ -101,6 +101,7 @@ export interface ChatModel { readonly location: ChatAgentLocation; readonly changeSet?: ChangeSet; readonly context: ChatContextManager; + readonly suggestions: readonly ChatSuggestion[]; readonly settings?: { [key: string]: unknown }; getRequests(): ChatRequestModel[]; isEmpty(): boolean; @@ -113,7 +114,18 @@ export interface ChangeSet extends Disposable { dispose(): void; } -export type ChatSuggestion = | string | MarkdownString | CommandChatResponseContent; +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 type ChatSuggestion = | string | MarkdownString | ChatSuggestionCallback; export interface ChatContextManager { onDidChange: Event; @@ -574,8 +586,8 @@ export class MutableChatModel implements ChatModel, Disposable { return this._changeSet; } - get suggestions(): ChatSuggestion[] { - return this.suggestions; + get suggestions(): readonly ChatSuggestion[] { + return this._suggestions; } get context(): ChatContextManager { diff --git a/packages/ai-chat/src/common/chat-service.ts b/packages/ai-chat/src/common/chat-service.ts index edc2e99b0100b..822053b0ea588 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 { /** @@ -244,25 +245,21 @@ export class ChatServiceImpl implements ChatService { this.updateSessionMetadata(session, requestModel); resolutionContext.request = requestModel; - let resolveResponseCreated: (responseModel: ChatResponseModel) => void; - let resolveResponseCompleted: (responseModel: ChatResponseModel) => void; + const responseCreationDeferred = new Deferred(); + const responseCompletionDeferred = new Deferred(); const invocation: ChatRequestInvocation = { requestCompleted: Promise.resolve(requestModel), - responseCreated: new Promise(resolve => { - resolveResponseCreated = resolve; - }), - responseCompleted: new Promise(resolve => { - resolveResponseCompleted = resolve; - }), + responseCreated: responseCreationDeferred.promise, + responseCompleted: responseCompletionDeferred.promise, }; - resolveResponseCreated!(requestModel.response); + responseCreationDeferred.resolve(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); } }); diff --git a/packages/ai-ide/src/browser/coder-agent.ts b/packages/ai-ide/src/browser/coder-agent.ts index 09da650058b34..38d5ba02420e3 100644 --- a/packages/ai-ide/src/browser/coder-agent.ts +++ b/packages/ai-ide/src/browser/coder-agent.ts @@ -13,8 +13,8 @@ // // 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 } from '../common/coder-replace-prompt-template'; import { WriteChangeToFileProvider } from './file-changeset-functions'; @@ -25,6 +25,7 @@ import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_NEW_WITH_MEMORY } from '@theia @injectable() export class CoderAgent extends AbstractStreamParsingChatAgent { + @inject(ChatService) protected readonly chatService: ChatService; id: string = 'Coder'; name = 'Coder'; languageModelRequirements: LanguageModelRequirement[] = [{ @@ -32,8 +33,6 @@ export class CoderAgent extends AbstractStreamParsingChatAgent { identifier: 'openai/gpt-4o', }]; protected defaultLanguageModelPurpose: string = 'chat'; - readonly suggestions = [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:${AI_CHAT_NEW_WITH_MEMORY.id}).`)]; override description = nls.localize('theia/ai/workspace/coderAgent/description', 'An AI assistant integrated into Theia IDE, designed to assist software developers. This agent can access the users workspace, it can get a list of all available files \ @@ -42,5 +41,25 @@ export class CoderAgent extends AbstractStreamParsingChatAgent { override promptTemplates = [getCoderReplacePromptTemplate(true), getCoderReplacePromptTemplate(false)]; 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 model = ChatRequestModel.is(context) ? context.session : context.model; + const session = this.chatService.getSessions().find(candidate => candidate.model.id === model.id); + 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 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:${AI_CHAT_NEW_WITH_MEMORY.id}).`)]); + } + } } From a228f4e8f45fb5a214a41937a46e9b1c3718c267 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Fri, 18 Apr 2025 07:41:15 -0600 Subject: [PATCH 04/28] Prompt customization available --- .../ai-chat-ui/src/browser/style/index.css | 1 + .../src/browser/task-context-service.ts | 31 +++++- .../ai-chat/src/common/chat-request-parser.ts | 4 +- packages/ai-chat/src/common/chat-service.ts | 15 +-- .../src/common/chat-session-naming-service.ts | 2 +- .../src/common/chat-session-summary-agent.ts | 95 +++---------------- 6 files changed, 47 insertions(+), 101 deletions(-) diff --git a/packages/ai-chat-ui/src/browser/style/index.css b/packages/ai-chat-ui/src/browser/style/index.css index b2daf77cca331..8cc8711a8d6d0 100644 --- a/packages/ai-chat-ui/src/browser/style/index.css +++ b/packages/ai-chat-ui/src/browser/style/index.css @@ -707,4 +707,5 @@ details[open].collapsible-arguments .collapsible-arguments-summary { .chat-agent-suggestions { padding-inline: 16px; + padding-block-end: 8px; } diff --git a/packages/ai-chat/src/browser/task-context-service.ts b/packages/ai-chat/src/browser/task-context-service.ts index 7731e6ad1ec21..ad819edb6fe09 100644 --- a/packages/ai-chat/src/browser/task-context-service.ts +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -16,9 +16,9 @@ import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { DisposableCollection, EOL, Path, URI, unreachable } from '@theia/core'; -import { ChatService, ChatSession } from '../common'; +import { ChatAgent, ChatAgentLocation, ChatRequestParser, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel, ParsedChatRequestTextPart } from '../common'; import { PreferenceService } from '@theia/core/lib/browser'; -import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; +import { CHAT_SESSION_SUMMARY_PROMPT, ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { TASK_CONTEXT_STORAGE_DIRECTORY_PREF } from './ai-chat-preferences'; @@ -26,6 +26,7 @@ import * as yaml from 'js-yaml'; import { BinaryBuffer } from '@theia/core/lib/common/buffer'; import { FileChange, FileChangeType } from '@theia/filesystem/lib/common/files'; import { Deferred } from '@theia/core/lib/common/promise-util'; +import { AIVariableService, PromptService } from '@theia/ai-core'; interface Summary { label: string; @@ -42,6 +43,9 @@ export class TaskContextService { @inject(ChatSessionSummaryAgent) protected readonly summaryAgent: ChatSessionSummaryAgent; @inject(FileService) protected readonly fileService: FileService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; + @inject(AIVariableService) protected readonly variableService: AIVariableService; + @inject(ChatRequestParser) protected readonly chatRequestParser: ChatRequestParser; + @inject(PromptService) protected readonly promptService: PromptService; @postConstruct() protected init(): void { @@ -78,7 +82,8 @@ export class TaskContextService { protected getStorageLocation(): URI | undefined { if (!this.workspaceService.opened) { return; } - const configuredPath = this.preferenceService.inspect(TASK_CONTEXT_STORAGE_DIRECTORY_PREF)?.globalValue; + 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); @@ -124,7 +129,7 @@ export class TaskContextService { throw new Error('Unable to resolve summary request.'); } - protected async summarizeAndStore(session: ChatSession): Promise { + protected async summarizeAndStore(session: ChatSession, promptId?: string, agent?: ChatAgent): Promise { const storageId = this.idForSession(session); const pending = this.pendingSummaries.get(storageId); if (pending) { return pending.then(({ summary }) => summary); } @@ -132,7 +137,7 @@ export class TaskContextService { this.pendingSummaries.set(storageId, summaryDeferred.promise); try { const newSummary: Summary = { - summary: await this.summaryAgent.generateChatSessionSummary(session), + summary: await this.summarize(session, promptId, agent), label: session.title || session.id, }; const storageLocation = this.getStorageLocation(); @@ -157,6 +162,22 @@ export class TaskContextService { } } + protected async summarize(session: ChatSession, promptId: string = CHAT_SESSION_SUMMARY_PROMPT.id, agent: ChatAgent = this.summaryAgent): Promise { + const model = new MutableChatModel(ChatAgentLocation.Panel); + const prompt = await this.promptService.getPrompt(promptId || CHAT_SESSION_SUMMARY_PROMPT.id); + if (!prompt) { return ''; } + const messages = session.model.getRequests().filter((candidate): candidate is MutableChatRequestModel => candidate instanceof MutableChatRequestModel); + model['_requests'] = messages; + 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(); + } + protected idForSession(session: ChatSession): string { if (!this.getStorageLocation()) { return session.id; } const derivedName = (session.title || session.id).replace(/\W/g, '-').replace(/-+/g, '-'); 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 822053b0ea588..9fe08c937b2c8 100644 --- a/packages/ai-chat/src/common/chat-service.ts +++ b/packages/ai-chat/src/common/chat-service.ts @@ -245,15 +245,13 @@ export class ChatServiceImpl implements ChatService { this.updateSessionMetadata(session, requestModel); resolutionContext.request = requestModel; - const responseCreationDeferred = new Deferred(); const responseCompletionDeferred = new Deferred(); const invocation: ChatRequestInvocation = { requestCompleted: Promise.resolve(requestModel), - responseCreated: responseCreationDeferred.promise, + responseCreated: Promise.resolve(requestModel.response), responseCompleted: responseCompletionDeferred.promise, }; - responseCreationDeferred.resolve(requestModel.response); requestModel.response.onDidChange(() => { if (requestModel.response.isComplete) { responseCompletionDeferred.resolve(requestModel.response); @@ -297,15 +295,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 8da14931f741b..b662351ed1a43 100644 --- a/packages/ai-chat/src/common/chat-session-naming-service.ts +++ b/packages/ai-chat/src/common/chat-session-naming-service.ts @@ -93,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(', '); diff --git a/packages/ai-chat/src/common/chat-session-summary-agent.ts b/packages/ai-chat/src/common/chat-session-summary-agent.ts index 57a45528308ab..21059d34dfeee 100644 --- a/packages/ai-chat/src/common/chat-session-summary-agent.ts +++ b/packages/ai-chat/src/common/chat-session-summary-agent.ts @@ -15,104 +15,37 @@ // ***************************************************************************** import { - Agent, - CommunicationRecordingService, - CommunicationRequestEntryParam, - getTextOfResponse, - LanguageModelRegistry, LanguageModelRequirement, - PromptService, - PromptTemplate, - UserRequest + PromptTemplate } from '@theia/ai-core'; -import { inject, injectable } from '@theia/core/shared/inversify'; -import { ChatSession } from './chat-service'; -import { generateUuid } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { AbstractStreamParsingChatAgent, ChatAgent } from './chat-agents'; -const CHAT_SESSION_SUMMARY_PROMPT = { +export const CHAT_SESSION_SUMMARY_PROMPT = { id: 'chat-session-summary-prompt', template: '{{!-- 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 below and generate a concise summary that captures every crucial detail, \ +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. \ -\ -Conversation:\n{{conversation}}', +response for use by AI agents.', }; @injectable() -export class ChatSessionSummaryAgent implements Agent { +export class ChatSessionSummaryAgent extends AbstractStreamParsingChatAgent implements ChatAgent { static ID = 'chat-session-summary-agent'; id = ChatSessionSummaryAgent.ID; name = 'Chat Session Summary'; - description = 'Agent for generating chat session summaries.'; - variables = []; - promptTemplates: PromptTemplate[] = [CHAT_SESSION_SUMMARY_PROMPT]; + 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', }]; - agentSpecificVariables = [ - { name: 'conversation', usedInPrompt: true, description: 'The content of the chat conversation.' }, - ]; - functions = []; - - @inject(LanguageModelRegistry) - protected readonly lmRegistry: LanguageModelRegistry; - - @inject(CommunicationRecordingService) - protected recordingService: CommunicationRecordingService; - - @inject(PromptService) - protected promptService: PromptService; - - async generateChatSessionSummary(chatSession: ChatSession): Promise { - const lm = await this.lmRegistry.selectLanguageModel({ agent: this.id, ...this.languageModelRequirements[0] }); - if (!lm) { - throw new Error('No language model found for chat session summary.'); - } - if (chatSession.model.getRequests().length < 1) { - throw new Error('No chat request available to generate chat session summary.'); - } - - const conversation = chatSession.model.getRequests() - .map(req => `${req.request.text}` + - (req.response.response ? `${req.response.response.asString()}` : '')) - .join('\n\n'); - - const prompt = await this.promptService.getPrompt(CHAT_SESSION_SUMMARY_PROMPT.id, { conversation }); - const message = prompt?.text; - if (!message) { - throw new Error('Unable to create prompt message for generating chat session summary.'); - } - - const sessionId = generateUuid(); - const requestId = generateUuid(); - const request = { - messages: [{ - actor: 'user', - text: message, - type: 'text' - }], - sessionId, - requestId, - agentId: this.id - } satisfies UserRequest; - - this.recordingService.recordRequest({ ...request, request: request.messages } satisfies CommunicationRequestEntryParam); - - const result = await lm.request(request); - const response = await getTextOfResponse(result); - this.recordingService.recordResponse({ - agentId: this.id, - sessionId, - requestId, - response: [{ actor: 'ai', text: response, type: 'text' }] - }); - - return response; - } - + override agentSpecificVariables = []; + override functions = []; + override locations = []; } From 27f04ea07b72a6acccf0b655afa47e6530dcbc1e Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Fri, 18 Apr 2025 12:28:45 -0600 Subject: [PATCH 05/28] Style cleanup, etc. --- .../browser/chat-input-agent-suggestions.tsx | 18 +++++++++--------- .../ai-chat-ui/src/browser/style/index.css | 1 + packages/ai-chat/src/common/chat-model.ts | 5 +++++ packages/ai-ide/src/browser/coder-agent.ts | 2 +- 4 files changed, 16 insertions(+), 10 deletions(-) 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 index 7dabae0bf12d4..f57a7cbb04e15 100644 --- a/packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx +++ b/packages/ai-chat-ui/src/browser/chat-input-agent-suggestions.tsx @@ -25,23 +25,23 @@ interface ChatInputAgentSuggestionsProps { opener: OpenerService; } -function getKey(suggestion: ChatSuggestion): string { - if (typeof suggestion === 'string') {return suggestion;} - if ('value' in suggestion) {return suggestion.value;} - if (typeof suggestion.content === 'string') {return suggestion.content;} +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;} + 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 => = ({suggestion, opener, handler}) => { const ref = useMarkdownRendering(getContent(suggestion), opener, true, handler); - return
; + 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;} + if (event.type !== 'click' || !(target instanceof Element)) {return false; } const link = target.closest('a[href^="_callback"]'); if (link) { this.suggestion.callback(); diff --git a/packages/ai-chat-ui/src/browser/style/index.css b/packages/ai-chat-ui/src/browser/style/index.css index 8cc8711a8d6d0..c4151001c5d9c 100644 --- a/packages/ai-chat-ui/src/browser/style/index.css +++ b/packages/ai-chat-ui/src/browser/style/index.css @@ -708,4 +708,5 @@ details[open].collapsible-arguments .collapsible-arguments-summary { .chat-agent-suggestions { padding-inline: 16px; padding-block-end: 8px; + user-select: none; } diff --git a/packages/ai-chat/src/common/chat-model.ts b/packages/ai-chat/src/common/chat-model.ts index 64c911b169f36..efc3aefa4e6c5 100644 --- a/packages/ai-chat/src/common/chat-model.ts +++ b/packages/ai-chat/src/common/chat-model.ts @@ -123,6 +123,11 @@ 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; diff --git a/packages/ai-ide/src/browser/coder-agent.ts b/packages/ai-ide/src/browser/coder-agent.ts index 38d5ba02420e3..c2784b212c3bc 100644 --- a/packages/ai-ide/src/browser/coder-agent.ts +++ b/packages/ai-ide/src/browser/coder-agent.ts @@ -54,7 +54,7 @@ export class CoderAgent extends AbstractStreamParsingChatAgent { { kind: 'callback', callback: () => this.chatService.sendRequest(session.id, { text: '@Coder please look at #_f and fix any problems.' }), - content: 'Fix problems in the current file.' + content: '[Fix problems](_callback) in the current file.' }, ]); } else { From 1d15f4b1c89627c52be6ecbd618ee549f0fa7752 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Fri, 18 Apr 2025 15:50:43 -0600 Subject: [PATCH 06/28] Clean up output in case of no summaries --- packages/ai-core/src/common/prompt-service.ts | 4 ++-- packages/ai-core/src/common/variable-service.ts | 4 ++-- .../ai-ide/src/browser/context-session-summary-variable.ts | 3 ++- packages/ai-ide/src/common/coder-replace-prompt-template.ts | 2 -- packages/filesystem/src/browser/file-service.ts | 4 ++-- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/ai-core/src/common/prompt-service.ts b/packages/ai-core/src/common/prompt-service.ts index 9d9fc7f00cdb7..c73135e4e6db8 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( @@ -331,7 +331,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 06195360525f0..22b6433cfe53d 100644 --- a/packages/ai-core/src/common/variable-service.ts +++ b/packages/ai-core/src/common/variable-service.ts @@ -191,7 +191,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(); @@ -354,7 +354,7 @@ export class DefaultAIVariableService implements AIVariableService { 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) { diff --git a/packages/ai-ide/src/browser/context-session-summary-variable.ts b/packages/ai-ide/src/browser/context-session-summary-variable.ts index b6ad6a57c0a6c..1bf4698ac6ebf 100644 --- a/packages/ai-ide/src/browser/context-session-summary-variable.ts +++ b/packages/ai-ide/src/browser/context-session-summary-variable.ts @@ -53,8 +53,9 @@ export class ContextSessionSummaryVariable implements AIVariableContribution, AI ): Promise { if (!resolveDependency || !ChatSessionContext.is(context) || request.variable.name !== CONTEXT_SESSION_MEMORY_VARIABLE.name) { return undefined; } const allSummaryRequests = context.model.context.getVariables().filter(candidate => candidate.variable.id === SESSION_SUMMARY_VARIABLE.id); + if (!allSummaryRequests.length) { return; } const allSummaries = await Promise.all(allSummaryRequests.map(summaryRequest => resolveDependency(summaryRequest).then(resolved => resolved?.value))); - const value = allSummaries.map((content, index) => `# Context Memory ${index + 1}\n\n${content}`).join('\n\n'); + const value = `# Task Planning background\n\n${allSummaries.map((content, index) => `## Context Memory ${index + 1}\n\n${content}`).join('\n\n')}`; return { ...request, value 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 71327d5f4a7eb..af730858c8b21 100644 --- a/packages/ai-ide/src/common/coder-replace-prompt-template.ts +++ b/packages/ai-ide/src/common/coder-replace-prompt-template.ts @@ -52,8 +52,6 @@ ${withSearchAndReplace ? ' If ~{changeSet_replaceContentInFile} continously fail 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.' : ''} -## Previous Interactions and Background - {{${CONTEXT_SESSION_MEMORY_VARIABLE_ID}}} ## Additional Context 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}. From 8236ecb22918be1aaf27b78a834d8c4bd98f83c6 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Fri, 18 Apr 2025 15:53:32 -0600 Subject: [PATCH 07/28] Request agent suggestions if created with pinned agent --- packages/ai-chat/src/common/chat-service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai-chat/src/common/chat-service.ts b/packages/ai-chat/src/common/chat-service.ts index 9fe08c937b2c8..33b9b08945794 100644 --- a/packages/ai-chat/src/common/chat-service.ts +++ b/packages/ai-chat/src/common/chat-service.ts @@ -190,6 +190,7 @@ export class ChatServiceImpl implements ChatService { isActive: true, pinnedAgent }; + if (pinnedAgent) { pinnedAgent.suggest?.(session); } this._sessions.push(session); this.setActiveSession(session.id, options); this.onSessionEventEmitter.fire({ type: 'created', sessionId: session.id }); From 5d241880cdda56d6a8a9d329c65c0b679124af08 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Fri, 18 Apr 2025 16:51:08 -0600 Subject: [PATCH 08/28] Bunch o renamings --- .../src/browser/ai-chat-ui-contribution.ts | 13 +++++--- .../src/browser/chat-view-commands.ts | 4 +-- .../src/browser/ai-chat-frontend-module.ts | 12 +++---- .../src/browser/task-context-service.ts | 33 ++++++++++++------- ... => task-context-variable-contribution.ts} | 32 +++++++++--------- ...> task-context-variable-label-provider.ts} | 10 +++--- packages/ai-ide/src/browser/coder-agent.ts | 4 +-- .../ai-ide/src/browser/frontend-module.ts | 6 ++-- ...ts => task-background-summary-variable.ts} | 27 ++++++++------- .../common/coder-replace-prompt-template.ts | 22 ++++++------- .../ai-ide/src/common/context-variables.ts | 2 +- 11 files changed, 92 insertions(+), 73 deletions(-) rename packages/ai-chat/src/browser/{session-summary-variable-contribution.ts => task-context-variable-contribution.ts} (71%) rename packages/ai-chat/src/browser/{session-summary-variable-label-provider.ts => task-context-variable-label-provider.ts} (85%) rename packages/ai-ide/src/browser/{context-session-summary-variable.ts => task-background-summary-variable.ts} (65%) 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 88f5f2160abff..6e81109de983d 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 @@ -17,7 +17,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { CommandRegistry, isOSX, nls, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core'; import { Widget } from '@theia/core/lib/browser'; -import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_NEW_WITH_MEMORY, AI_CHAT_SHOW_CHATS_COMMAND, ChatCommands } from './chat-view-commands'; +import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_NEW_WITH_TASK_CONTEXT, 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'; @@ -28,7 +28,8 @@ import { formatDistance } from 'date-fns'; import * as locales from 'date-fns/locale'; 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 { SESSION_SUMMARY_VARIABLE } from '@theia/ai-chat/lib/browser/session-summary-variable-contribution'; +import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable-contribution'; +import { TaskContextService } from '@theia/ai-chat/lib/browser/task-context-service'; export const AI_CHAT_TOGGLE_COMMAND_ID = 'aiChat:toggle'; @@ -39,6 +40,8 @@ export class AIChatContribution extends AbstractViewContribution protected readonly chatService: ChatService; @inject(QuickInputService) protected readonly quickInputService: QuickInputService; + @inject(TaskContextService) + protected readonly taskContextService: TaskContextService; protected static readonly RENAME_CHAT_BUTTON: QuickInputButton = { iconClass: 'codicon-edit', @@ -87,15 +90,17 @@ export class AIChatContribution extends AbstractViewContribution execute: () => this.openView().then(() => this.chatService.createSession(ChatAgentLocation.Panel, { focus: true })), isVisible: widget => this.withWidget(widget, () => true), }); - registry.registerCommand(AI_CHAT_NEW_WITH_MEMORY, { + registry.registerCommand(AI_CHAT_NEW_WITH_TASK_CONTEXT, { execute: () => { const activeSessions = this.chatService.getSessions().filter(candidate => candidate.isActive); if (activeSessions.length !== 1) { return; } const activeSession = activeSessions[0]; + const summaryVariable = { variable: TASK_CONTEXT_VARIABLE, arg: activeSession.id }; + this.taskContextService.summarize(activeSession); const newSession = this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }, activeSession.pinnedAgent); - newSession.model.context.addVariables({ variable: SESSION_SUMMARY_VARIABLE, arg: activeSession.id }); + newSession.model.context.addVariables(summaryVariable); }, isVisible: () => false }); 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 de29a5b53867b..65f28643cc6c2 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-commands.ts +++ b/packages/ai-chat-ui/src/browser/chat-view-commands.ts @@ -45,8 +45,8 @@ export const AI_CHAT_NEW_CHAT_WINDOW_COMMAND: Command = { iconClass: codicon('add') }; -export const AI_CHAT_NEW_WITH_MEMORY: Command = { - id: 'ai-chat.new-with-memory', +export const AI_CHAT_NEW_WITH_TASK_CONTEXT: Command = { + id: 'ai-chat.new-with-task-context', }; export const AI_CHAT_SHOW_CHATS_COMMAND: Command = { 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 1bc69e695fb91..3aab3ac518b87 100644 --- a/packages/ai-chat/src/browser/ai-chat-frontend-module.ts +++ b/packages/ai-chat/src/browser/ai-chat-frontend-module.ts @@ -47,8 +47,8 @@ import { ContextDetailsVariableContribution } from '../common/context-details-va import { ChangeSetVariableContribution } from './change-set-variable'; import { ChatSessionNamingAgent, ChatSessionNamingService } from '../common/chat-session-naming-service'; import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; -import { SessionSumaryVariableContribution } from './session-summary-variable-contribution'; -import { SessionSummaryVariableLabelProvider } from './session-summary-variable-label-provider'; +import { TaskContextVariableContribution } from './task-context-variable-contribution'; +import { TaskContextVariableLabelProvider } from './task-context-variable-label-provider'; import { TaskContextService } from './task-context-service'; export default new ContainerModule(bind => { @@ -120,10 +120,10 @@ export default new ContainerModule(bind => { bind(ChatSessionSummaryAgent).toSelf().inSingletonScope(); bind(Agent).toService(ChatSessionSummaryAgent); - bind(SessionSumaryVariableContribution).toSelf().inSingletonScope(); - bind(AIVariableContribution).toService(SessionSumaryVariableContribution); - bind(SessionSummaryVariableLabelProvider).toSelf().inSingletonScope(); - bind(LabelProviderContribution).toService(SessionSummaryVariableLabelProvider); + bind(TaskContextVariableContribution).toSelf().inSingletonScope(); + bind(AIVariableContribution).toService(TaskContextVariableContribution); + bind(TaskContextVariableLabelProvider).toSelf().inSingletonScope(); + bind(LabelProviderContribution).toService(TaskContextVariableLabelProvider); bind(TaskContextService).toSelf().inSingletonScope(); }); diff --git a/packages/ai-chat/src/browser/task-context-service.ts b/packages/ai-chat/src/browser/task-context-service.ts index ad819edb6fe09..5b40e81d72363 100644 --- a/packages/ai-chat/src/browser/task-context-service.ts +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -28,10 +28,14 @@ import { FileChange, FileChangeType } from '@theia/filesystem/lib/common/files'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { AIVariableService, PromptService } from '@theia/ai-core'; -interface Summary { +interface SummaryMetadata { label: string; - summary: string; uri?: URI; + sessionId?: string +} + +interface Summary extends SummaryMetadata { + summary: string; } @injectable() @@ -107,12 +111,16 @@ export class TaskContextService { })); } - getSummaries(): Array<{ id: string, label: string, summary: string }> { - return Array.from(this.summaries.entries(), ([id, { label, summary }]) => ({ id, label, summary })); + getSummaries(): Array<{ id: string, label: string, summary: string, sessionId?: string }> { + return Array.from(this.summaries.entries(), ([id, { label, summary, sessionId }]) => ({ id, label, summary, sessionId })); } hasSummary(id: string): boolean { - return this.summaries.has(id); + if (this.summaries.has(id)) { return true; } + for (const summary of this.summaries.values()) { + if (summary.sessionId === id) { return true; } + } + return false; } async getSummary(sessionIdOrFilePath: string): Promise { @@ -139,15 +147,16 @@ export class TaskContextService { const newSummary: Summary = { summary: await this.summarize(session, promptId, agent), label: session.title || session.id, + sessionId: session.id }; const storageLocation = this.getStorageLocation(); if (storageLocation) { const frontmatter = { - session: session.id, + sessionId: session.id, date: new Date().toISOString(), label: session.title || undefined, }; - const content = yaml.dump(frontmatter) + `${EOL}---${EOL}` + newSummary.summary; + const content = yaml.dump(frontmatter).trim() + `${EOL}---${EOL}` + newSummary.summary; const uri = storageLocation.resolve(storageId); newSummary.uri = uri; await this.fileService.writeFile(uri, BinaryBuffer.fromString(content)); @@ -162,7 +171,7 @@ export class TaskContextService { } } - protected async summarize(session: ChatSession, promptId: string = CHAT_SESSION_SUMMARY_PROMPT.id, agent: ChatAgent = this.summaryAgent): Promise { + async summarize(session: ChatSession, promptId: string = CHAT_SESSION_SUMMARY_PROMPT.id, agent: ChatAgent = this.summaryAgent): Promise { const model = new MutableChatModel(ChatAgentLocation.Panel); const prompt = await this.promptService.getPrompt(promptId || CHAT_SESSION_SUMMARY_PROMPT.id); if (!prompt) { return ''; } @@ -181,7 +190,7 @@ export class TaskContextService { protected idForSession(session: ChatSession): string { if (!this.getStorageLocation()) { return session.id; } const derivedName = (session.title || session.id).replace(/\W/g, '-').replace(/-+/g, '-'); - const filename = (derivedName.length > 32 ? derivedName.slice(0, derivedName.indexOf('-', 32)) : derivedName) + '.md'; + const filename = (derivedName.length > 32 ? derivedName.slice(0, derivedName.indexOf('-', 32) - 1) : derivedName) + '.md'; return filename; } @@ -193,7 +202,7 @@ export class TaskContextService { const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined); if (!content) { return; } const { frontmatter, body } = this.maybeReadFrontmatter(content); - const summary = { summary: body, label: frontmatter?.label || uri.path.base, uri }; + const summary = { ...frontmatter, summary: body, label: frontmatter?.label || uri.path.base, uri }; this.summaries.set(uri.path.base, summary); summaryDeferred.resolve(summary); } catch (err) { @@ -203,7 +212,7 @@ export class TaskContextService { } } - protected maybeReadFrontmatter(content: string): { body: string, frontmatter: { label: string } | undefined } { + protected maybeReadFrontmatter(content: string): { body: string, frontmatter: SummaryMetadata | undefined } { const frontmatterEnd = content.indexOf('---'); if (frontmatterEnd !== -1) { try { @@ -216,7 +225,7 @@ export class TaskContextService { return { body: content, frontmatter: undefined }; } - protected hasLabel(candidate: unknown): candidate is { label: string } { + protected hasLabel(candidate: unknown): candidate is SummaryMetadata { return !!candidate && typeof candidate === 'object' && !Array.isArray(candidate) && 'label' in candidate && typeof candidate.label === 'string'; } diff --git a/packages/ai-chat/src/browser/session-summary-variable-contribution.ts b/packages/ai-chat/src/browser/task-context-variable-contribution.ts similarity index 71% rename from packages/ai-chat/src/browser/session-summary-variable-contribution.ts rename to packages/ai-chat/src/browser/task-context-variable-contribution.ts index 3ce10ac3a5817..e02a342db6596 100644 --- a/packages/ai-chat/src/browser/session-summary-variable-contribution.ts +++ b/packages/ai-chat/src/browser/task-context-variable-contribution.ts @@ -23,26 +23,26 @@ import { codiconArray } from '@theia/core/lib/browser'; import * as monaco from '@theia/monaco-editor-core'; import { TaskContextService } from './task-context-service'; -export const SESSION_SUMMARY_VARIABLE: AIVariable = { - id: 'session-summary', - description: 'Resolves to a summary of the session with the given ID.', - name: 'session-summary', - label: 'Session Summary', +export const TASK_CONTEXT_VARIABLE: AIVariable = { + id: 'task-context', + description: 'Provides background information on task planning, particularly summaries of chat sessions.', + name: 'task-context', + label: 'Task Context', iconClasses: codiconArray('clippy'), isContextVariable: true, - args: [{ name: 'session-id', description: 'The ID of the session to summarize.' }] + args: [{ name: 'context-id', description: 'The ID of the task context to retrieve, or a chat session to summarize.' }] }; @injectable() -export class SessionSumaryVariableContribution implements FrontendVariableContribution, AIVariableResolver { +export class TaskContextVariableContribution implements FrontendVariableContribution, AIVariableResolver { @inject(QuickInputService) protected readonly quickInputService: QuickInputService; @inject(ChatService) protected readonly chatService: ChatService; @inject(TaskContextService) protected readonly taskContextService: TaskContextService; registerVariables(service: FrontendVariableService): void { - service.registerResolver(SESSION_SUMMARY_VARIABLE, this); - service.registerArgumentPicker(SESSION_SUMMARY_VARIABLE, this.pickSession.bind(this)); - service.registerArgumentCompletionProvider(SESSION_SUMMARY_VARIABLE, this.provideCompletionItems.bind(this)); + service.registerResolver(TASK_CONTEXT_VARIABLE, this); + service.registerArgumentPicker(TASK_CONTEXT_VARIABLE, this.pickSession.bind(this)); + service.registerArgumentCompletionProvider(TASK_CONTEXT_VARIABLE, this.provideCompletionItems.bind(this)); } protected async pickSession(): Promise { @@ -56,7 +56,7 @@ export class SessionSumaryVariableContribution implements FrontendVariableContri position: monaco.Position, matchString?: string ): Promise { - const context = AIVariableCompletionContext.get(SESSION_SUMMARY_VARIABLE.name, model, position, matchString); + 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) => ({ @@ -70,23 +70,25 @@ export class SessionSumaryVariableContribution implements FrontendVariableContri } protected getItems(): QuickPickItemOrSeparator[] { - const existingSummaries = this.taskContextService.getSummaries(); + const currentSession = this.chatService.getSessions().find(candidate => candidate.isActive); + const existingSummaries = this.taskContextService.getSummaries().filter(candidate => !currentSession || currentSession.id !== candidate.sessionId); + const knownSessions = new Set(existingSummaries.filter(candidate => candidate.sessionId).map(({ sessionId }) => sessionId)); return [ ...(existingSummaries.length ? [{ type: 'separator', label: 'Saved Tasks' }] satisfies QuickPickSeparator[] : []), ...existingSummaries satisfies QuickPickItem[], ...(existingSummaries.length ? [{ type: 'separator', label: 'Other Sessions' }] satisfies QuickPickSeparator[] : []), ...this.chatService.getSessions() - .filter(candidate => !this.taskContextService.hasSummary(candidate.id) && candidate.model.getRequests().length) + .filter(candidate => (!currentSession || currentSession.id !== candidate.id) && !knownSessions.has(candidate.id) && candidate.model.getRequests().length) .map(session => ({ type: 'item', label: session.title || session.id, id: session.id })) ]; } canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { - return request.variable.id === SESSION_SUMMARY_VARIABLE.id ? 10000 : -5; + return request.variable.id === TASK_CONTEXT_VARIABLE.id ? 10000 : -5; } async resolve(request: AIVariableResolutionRequest, _context: AIVariableContext): Promise { - if (request.variable.id !== SESSION_SUMMARY_VARIABLE.id || !request.arg) { return; } + 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; } diff --git a/packages/ai-chat/src/browser/session-summary-variable-label-provider.ts b/packages/ai-chat/src/browser/task-context-variable-label-provider.ts similarity index 85% rename from packages/ai-chat/src/browser/session-summary-variable-label-provider.ts rename to packages/ai-chat/src/browser/task-context-variable-label-provider.ts index dbfe42d9464c0..62da5653f6334 100644 --- a/packages/ai-chat/src/browser/session-summary-variable-label-provider.ts +++ b/packages/ai-chat/src/browser/task-context-variable-label-provider.ts @@ -18,17 +18,17 @@ 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 { SessionSumaryVariableContribution, SESSION_SUMMARY_VARIABLE } from './session-summary-variable-contribution'; +import { TaskContextVariableContribution, TASK_CONTEXT_VARIABLE } from './task-context-variable-contribution'; import { ChatService } from '../common'; import { TaskContextService } from './task-context-service'; @injectable() -export class SessionSummaryVariableLabelProvider implements LabelProviderContribution { +export class TaskContextVariableLabelProvider implements LabelProviderContribution { @inject(ChatService) protected readonly chatService: ChatService; - @inject(SessionSumaryVariableContribution) protected readonly chatVariableContribution: SessionSumaryVariableContribution; + @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 === SESSION_SUMMARY_VARIABLE.id && !!element.arg; + return AIVariableResolutionRequest.is(element) && element.variable.id === TASK_CONTEXT_VARIABLE.id && !!element.arg; } canHandle(element: object): number { @@ -49,7 +49,7 @@ export class SessionSummaryVariableLabelProvider implements LabelProviderContrib getLongName(element: object): string | undefined { const short = this.getName(element); const details = this.getDetails(element); - return `Summary of '${short}' (${details})`; + return `'${short}' (${details})`; } getDetails(element: object): string | undefined { diff --git a/packages/ai-ide/src/browser/coder-agent.ts b/packages/ai-ide/src/browser/coder-agent.ts index c2784b212c3bc..9c22ebee2989b 100644 --- a/packages/ai-ide/src/browser/coder-agent.ts +++ b/packages/ai-ide/src/browser/coder-agent.ts @@ -21,7 +21,7 @@ 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, AI_CHAT_NEW_WITH_MEMORY } from '@theia/ai-chat-ui/lib/browser/chat-view-commands'; +import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_NEW_WITH_TASK_CONTEXT } from '@theia/ai-chat-ui/lib/browser/chat-view-commands'; @injectable() export class CoderAgent extends AbstractStreamParsingChatAgent { @@ -59,7 +59,7 @@ export class CoderAgent extends AbstractStreamParsingChatAgent { ]); } 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:${AI_CHAT_NEW_WITH_MEMORY.id}).`)]); + + ` or [start a new chat with a summary of this one](command:${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 d77ef7e4df82f..5c187913718a6 100644 --- a/packages/ai-ide/src/browser/frontend-module.ts +++ b/packages/ai-ide/src/browser/frontend-module.ts @@ -45,7 +45,7 @@ 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 { ContextSessionSummaryVariable } from './context-session-summary-variable'; +import { TaskContextSummaryVariableContribution } from './task-background-summary-variable'; export default new ContainerModule(bind => { bind(PreferenceContribution).toConstantValue({ schema: WorkspacePreferencesSchema }); @@ -137,6 +137,6 @@ export default new ContainerModule(bind => { })) .inSingletonScope(); - bind(ContextSessionSummaryVariable).toSelf().inSingletonScope(); - bind(AIVariableContribution).toService(ContextSessionSummaryVariable); + bind(TaskContextSummaryVariableContribution).toSelf().inSingletonScope(); + bind(AIVariableContribution).toService(TaskContextSummaryVariableContribution); }); diff --git a/packages/ai-ide/src/browser/context-session-summary-variable.ts b/packages/ai-ide/src/browser/task-background-summary-variable.ts similarity index 65% rename from packages/ai-ide/src/browser/context-session-summary-variable.ts rename to packages/ai-ide/src/browser/task-background-summary-variable.ts index 1bf4698ac6ebf..db655ecc30fd6 100644 --- a/packages/ai-ide/src/browser/context-session-summary-variable.ts +++ b/packages/ai-ide/src/browser/task-background-summary-variable.ts @@ -27,23 +27,26 @@ import { AIVariableArg } from '@theia/ai-core'; import { ChatSessionContext } from '@theia/ai-chat'; -import { CONTEXT_SESSION_MEMORY_VARIABLE_ID } from '../common/context-variables'; -import { SESSION_SUMMARY_VARIABLE } from '@theia/ai-chat/lib/browser/session-summary-variable-contribution'; +import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable-contribution'; +import { TASK_CONTEXT_SUMMARY_VARIABLE_ID } from '../common/context-variables'; -export const CONTEXT_SESSION_MEMORY_VARIABLE: AIVariable = { - id: CONTEXT_SESSION_MEMORY_VARIABLE_ID, - description: nls.localize('theia/ai/core/contextSummaryVariable/description', 'Resolves any summaries present in the context.'), - name: CONTEXT_SESSION_MEMORY_VARIABLE_ID, +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() -export class ContextSessionSummaryVariable implements AIVariableContribution, AIVariableResolverWithVariableDependencies { +/** + * @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(CONTEXT_SESSION_MEMORY_VARIABLE, this); + service.registerResolver(TASK_CONTEXT_SUMMARY_VARIABLE, this); } canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { - return request.variable.name === CONTEXT_SESSION_MEMORY_VARIABLE.name ? 50 : 0; + return request.variable.name === TASK_CONTEXT_SUMMARY_VARIABLE.name ? 50 : 0; } async resolve( @@ -51,11 +54,11 @@ export class ContextSessionSummaryVariable implements AIVariableContribution, AI context: AIVariableContext, resolveDependency?: (variable: AIVariableArg) => Promise ): Promise { - if (!resolveDependency || !ChatSessionContext.is(context) || request.variable.name !== CONTEXT_SESSION_MEMORY_VARIABLE.name) { return undefined; } - const allSummaryRequests = context.model.context.getVariables().filter(candidate => candidate.variable.id === SESSION_SUMMARY_VARIABLE.id); + 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; } const allSummaries = await Promise.all(allSummaryRequests.map(summaryRequest => resolveDependency(summaryRequest).then(resolved => resolved?.value))); - const value = `# Task Planning background\n\n${allSummaries.map((content, index) => `## Context Memory ${index + 1}\n\n${content}`).join('\n\n')}`; + const value = `# Task Planning background\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/common/coder-replace-prompt-template.ts b/packages/ai-ide/src/common/coder-replace-prompt-template.ts index af730858c8b21..0759dc115644c 100644 --- a/packages/ai-ide/src/common/coder-replace-prompt-template.ts +++ b/packages/ai-ide/src/common/coder-replace-prompt-template.ts @@ -12,21 +12,21 @@ 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 + GET_WORKSPACE_FILE_LIST_FUNCTION_ID, + FILE_CONTENT_FUNCTION_ID, + GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID, + GET_FILE_DIAGNOSTICS_ID } from './workspace-functions'; -import { CONTEXT_FILES_VARIABLE_ID, CONTEXT_SESSION_MEMORY_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'; export const CODER_REPLACE_PROMPT_TEMPLATE_ID = 'coder-search-replace'; 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. @@ -52,7 +52,7 @@ ${withSearchAndReplace ? ' If ~{changeSet_replaceContentInFile} continously fail 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.' : ''} -{{${CONTEXT_SESSION_MEMORY_VARIABLE_ID}}} +{{${TASK_CONTEXT_SUMMARY_VARIABLE_ID}}} ## Additional Context @@ -66,6 +66,6 @@ You have previously proposed changes for the following files. Some suggestions m {{prompt:project-info}} `, - ...(!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 3043303078813..d2b91ff20ddcd 100644 --- a/packages/ai-ide/src/common/context-variables.ts +++ b/packages/ai-ide/src/common/context-variables.ts @@ -15,4 +15,4 @@ // ***************************************************************************** export const CONTEXT_FILES_VARIABLE_ID = 'contextFiles'; -export const CONTEXT_SESSION_MEMORY_VARIABLE_ID = 'contextMemories'; +export const TASK_CONTEXT_SUMMARY_VARIABLE_ID = 'taskContextSummary'; From 26fc311488ee6009934c3c42ccecc13015951fdd Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Mon, 21 Apr 2025 09:15:50 -0700 Subject: [PATCH 09/28] Break out file storage into separate service --- package-lock.json | 2 +- packages/ai-chat/package.json | 1 - .../src/browser/ai-chat-frontend-module.ts | 5 +- .../src/browser/ai-chat-preferences.ts | 9 - .../src/browser/task-context-service.ts | 189 +++--------------- .../browser/task-context-storage-service.ts | 43 ++++ .../task-context-variable-contribution.ts | 6 +- .../src/common/chat-session-naming-service.ts | 2 +- packages/ai-ide/package.json | 1 + .../ai-ide/src/browser/frontend-module.ts | 6 +- .../task-context-file-storage-service.ts | 162 +++++++++++++++ .../src/browser/workspace-preferences.ts | 9 + .../common/coder-replace-prompt-template.ts | 4 +- 13 files changed, 264 insertions(+), 175 deletions(-) create mode 100644 packages/ai-chat/src/browser/task-context-storage-service.ts create mode 100644 packages/ai-ide/src/browser/task-context-file-storage-service.ts diff --git a/package-lock.json b/package-lock.json index 05e7b69cabfea..c5007b3e75d13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30409,7 +30409,6 @@ "@theia/monaco": "1.60.0", "@theia/monaco-editor-core": "1.96.302", "@theia/workspace": "1.60.0", - "js-yaml": "^4.1.0", "minimatch": "^5.1.0", "tslib": "^2.6.2" }, @@ -30543,6 +30542,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/packages/ai-chat/package.json b/packages/ai-chat/package.json index 89638a2218bd6..fccd2421293f2 100644 --- a/packages/ai-chat/package.json +++ b/packages/ai-chat/package.json @@ -12,7 +12,6 @@ "@theia/monaco": "1.60.0", "@theia/monaco-editor-core": "1.96.302", "@theia/workspace": "1.60.0", - "js-yaml": "^4.1.0", "minimatch": "^5.1.0", "tslib": "^2.6.2" }, 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 3aab3ac518b87..6e83844f0fad4 100644 --- a/packages/ai-chat/src/browser/ai-chat-frontend-module.ts +++ b/packages/ai-chat/src/browser/ai-chat-frontend-module.ts @@ -49,7 +49,8 @@ import { ChatSessionNamingAgent, ChatSessionNamingService } from '../common/chat 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 } from './task-context-service'; +import { TaskContextService, TaskContextStorageService } from './task-context-service'; +import { InMemoryTaskContextStorage } from './task-context-storage-service'; export default new ContainerModule(bind => { bindContributionProvider(bind, Agent); @@ -126,4 +127,6 @@ export default new ContainerModule(bind => { bind(LabelProviderContribution).toService(TaskContextVariableLabelProvider); bind(TaskContextService).toSelf().inSingletonScope(); + bind(InMemoryTaskContextStorage).toSelf().inSingletonScope(); + bind(TaskContextStorageService).toService(InMemoryTaskContextStorage); }); diff --git a/packages/ai-chat/src/browser/ai-chat-preferences.ts b/packages/ai-chat/src/browser/ai-chat-preferences.ts index 1877a7c7cc2ba..c4995c5cece2a 100644 --- a/packages/ai-chat/src/browser/ai-chat-preferences.ts +++ b/packages/ai-chat/src/browser/ai-chat-preferences.ts @@ -20,7 +20,6 @@ import { PreferenceSchema } from '@theia/core/lib/browser/preferences/preference export const DEFAULT_CHAT_AGENT_PREF = 'ai-features.chat.defaultChatAgent'; export const PIN_CHAT_AGENT_PREF = 'ai-features.chat.pinChatAgent'; -export const TASK_CONTEXT_STORAGE_DIRECTORY_PREF = 'ai-features.chat.taskContextStorageDirectory'; export const aiChatPreferences: PreferenceSchema = { type: 'object', @@ -39,14 +38,6 @@ If no Default Agent is configured, Theia´s defaults will be applied.'), You can manually unpin or switch agents anytime.'), default: true, title: AI_CORE_PREFERENCES_TITLE, - }, - [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-chat/src/browser/task-context-service.ts b/packages/ai-chat/src/browser/task-context-service.ts index 5b40e81d72363..96a35c43c1476 100644 --- a/packages/ai-chat/src/browser/task-context-service.ts +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -14,117 +14,48 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; -import { DisposableCollection, EOL, Path, URI, unreachable } from '@theia/core'; -import { ChatAgent, ChatAgentLocation, ChatRequestParser, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel, ParsedChatRequestTextPart } from '../common'; -import { PreferenceService } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { MaybePromise, URI } from '@theia/core'; +import { ChatAgent, ChatAgentLocation, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel, ParsedChatRequestTextPart } from '../common'; import { CHAT_SESSION_SUMMARY_PROMPT, ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; -import { FileService } from '@theia/filesystem/lib/browser/file-service'; -import { WorkspaceService } from '@theia/workspace/lib/browser'; -import { TASK_CONTEXT_STORAGE_DIRECTORY_PREF } from './ai-chat-preferences'; -import * as yaml from 'js-yaml'; -import { BinaryBuffer } from '@theia/core/lib/common/buffer'; -import { FileChange, FileChangeType } from '@theia/filesystem/lib/common/files'; import { Deferred } from '@theia/core/lib/common/promise-util'; -import { AIVariableService, PromptService } from '@theia/ai-core'; +import { PromptService } from '@theia/ai-core'; -interface SummaryMetadata { +export interface SummaryMetadata { label: string; uri?: URI; - sessionId?: string + sessionId?: string; } -interface Summary extends SummaryMetadata { +export interface Summary extends SummaryMetadata { summary: string; + id: string; +} + +export const TaskContextStorageService = Symbol('TextContextStorageService'); +export interface TaskContextStorageService { + store(summary: Summary): MaybePromise; + getAll(): Summary[]; + get(identifier: string): Summary | undefined; + delete(identifier: string): MaybePromise; } @injectable() export class TaskContextService { - protected summaries = new Map(); + protected pendingSummaries = new Map>(); - @inject(PreferenceService) protected readonly preferenceService: PreferenceService; + @inject(ChatService) protected readonly chatService: ChatService; @inject(ChatSessionSummaryAgent) protected readonly summaryAgent: ChatSessionSummaryAgent; - @inject(FileService) protected readonly fileService: FileService; - @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; - @inject(AIVariableService) protected readonly variableService: AIVariableService; - @inject(ChatRequestParser) protected readonly chatRequestParser: ChatRequestParser; @inject(PromptService) protected readonly promptService: PromptService; + @inject(TaskContextStorageService) protected readonly storageService: TaskContextStorageService; - @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), - this.fileService.onDidFilesChange(event => { - const relevantChanges = event.changes.filter(candidate => newStorage.isEqualOrParent(candidate.resource)); - this.handleChanges(relevantChanges); - }), - { dispose: () => this.clearFileReferences() }, - ); - await this.cacheNewTasks(newStorage); - } - - protected clearFileReferences(): void { - for (const [key, value] of this.summaries.entries()) { - if (value.uri) { - this.summaries.delete(key); - } - } - } - - 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); - } - - 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))); - } - - protected async handleChanges(changes: FileChange[]): Promise { - await Promise.all(changes.map(change => { - switch (change.type) { - case FileChangeType.DELETED: return this.summaries.delete(change.resource.path.base); - case FileChangeType.ADDED: - case FileChangeType.UPDATED: - return this.readFile(change.resource); - default: return unreachable(change.type); - } - })); - } - - getSummaries(): Array<{ id: string, label: string, summary: string, sessionId?: string }> { - return Array.from(this.summaries.entries(), ([id, { label, summary, sessionId }]) => ({ id, label, summary, sessionId })); - } - - hasSummary(id: string): boolean { - if (this.summaries.has(id)) { return true; } - for (const summary of this.summaries.values()) { - if (summary.sessionId === id) { return true; } - } - return false; + getAll(): Array { + return this.storageService.getAll(); } async getSummary(sessionIdOrFilePath: string): Promise { - const existing = this.summaries.get(sessionIdOrFilePath); + const existing = this.storageService.get(sessionIdOrFilePath); if (existing) { return existing.summary; } const pending = this.pendingSummaries.get(sessionIdOrFilePath); if (pending) { @@ -132,46 +63,34 @@ export class TaskContextService { } const session = this.chatService.getSession(sessionIdOrFilePath); if (session) { - return this.summarizeAndStore(session); + return this.summarize(session); } throw new Error('Unable to resolve summary request.'); } - protected async summarizeAndStore(session: ChatSession, promptId?: string, agent?: ChatAgent): Promise { - const storageId = this.idForSession(session); - const pending = this.pendingSummaries.get(storageId); + async summarize(session: ChatSession, promptId?: string, agent?: ChatAgent): Promise { + const pending = this.pendingSummaries.get(session.id); if (pending) { return pending.then(({ summary }) => summary); } const summaryDeferred = new Deferred(); - this.pendingSummaries.set(storageId, summaryDeferred.promise); + this.pendingSummaries.set(session.id, summaryDeferred.promise); try { const newSummary: Summary = { - summary: await this.summarize(session, promptId, agent), + summary: await this.getLlmSummary(session, promptId, agent), label: session.title || session.id, - sessionId: session.id + sessionId: session.id, + id: session.id }; - const storageLocation = this.getStorageLocation(); - if (storageLocation) { - const frontmatter = { - sessionId: session.id, - date: new Date().toISOString(), - label: session.title || undefined, - }; - const content = yaml.dump(frontmatter).trim() + `${EOL}---${EOL}` + newSummary.summary; - const uri = storageLocation.resolve(storageId); - newSummary.uri = uri; - await this.fileService.writeFile(uri, BinaryBuffer.fromString(content)); - } - this.summaries.set(storageId, newSummary); + await this.storageService.store(newSummary); return newSummary.summary; } catch (err) { summaryDeferred.reject(err); throw err; } finally { - this.pendingSummaries.delete(storageId); + this.pendingSummaries.delete(session.id); } } - async summarize(session: ChatSession, promptId: string = CHAT_SESSION_SUMMARY_PROMPT.id, agent: ChatAgent = this.summaryAgent): Promise { + protected async getLlmSummary(session: ChatSession, promptId: string = CHAT_SESSION_SUMMARY_PROMPT.id, agent: ChatAgent = this.summaryAgent): Promise { const model = new MutableChatModel(ChatAgentLocation.Panel); const prompt = await this.promptService.getPrompt(promptId || CHAT_SESSION_SUMMARY_PROMPT.id); if (!prompt) { return ''; } @@ -187,49 +106,7 @@ export class TaskContextService { return summaryRequest.response.response.asDisplayString(); } - protected idForSession(session: ChatSession): string { - if (!this.getStorageLocation()) { return session.id; } - const derivedName = (session.title || session.id).replace(/\W/g, '-').replace(/-+/g, '-'); - const filename = (derivedName.length > 32 ? derivedName.slice(0, derivedName.indexOf('-', 32) - 1) : derivedName) + '.md'; - return filename; - } - - protected async readFile(uri: URI): Promise { - if (this.pendingSummaries.has(uri.path.base)) { return; } - const summaryDeferred = new Deferred(); - this.pendingSummaries.set(uri.path.base, summaryDeferred.promise); - try { - const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined); - if (!content) { return; } - const { frontmatter, body } = this.maybeReadFrontmatter(content); - const summary = { ...frontmatter, summary: body, label: frontmatter?.label || uri.path.base, uri }; - this.summaries.set(uri.path.base, summary); - summaryDeferred.resolve(summary); - } catch (err) { - summaryDeferred.reject(err); - } finally { - this.pendingSummaries.delete(uri.path.base); - } - } - - 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'; - } - getLabel(id: string): string | undefined { - return this.summaries.get(id)?.label; + return this.storageService.get(id)?.label; } } 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..6e37588525e52 --- /dev/null +++ b/packages/ai-chat/src/browser/task-context-storage-service.ts @@ -0,0 +1,43 @@ +// ***************************************************************************** +// 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 { injectable } from '@theia/core/shared/inversify'; +import { Summary, TaskContextStorageService } from './task-context-service'; + +@injectable() +export class InMemoryTaskContextStorage implements TaskContextStorageService { + protected summaries = new Map(); + + store(summary: Summary): void { + this.summaries.set(summary.id, summary); + } + + getAll(): Summary[] { + return Array.from(this.summaries.entries(), ([id, { label, summary, sessionId }]) => ({ id, label, summary, sessionId })); + } + + get(identifier: string): Summary | undefined { + return this.summaries.get(identifier); + } + + delete(identifier: string): boolean { + return this.summaries.delete(identifier); + } + + clear(): void { + this.summaries.clear(); + } +} diff --git a/packages/ai-chat/src/browser/task-context-variable-contribution.ts b/packages/ai-chat/src/browser/task-context-variable-contribution.ts index e02a342db6596..a0cb468cf7e4b 100644 --- a/packages/ai-chat/src/browser/task-context-variable-contribution.ts +++ b/packages/ai-chat/src/browser/task-context-variable-contribution.ts @@ -24,9 +24,9 @@ import * as monaco from '@theia/monaco-editor-core'; import { TaskContextService } from './task-context-service'; export const TASK_CONTEXT_VARIABLE: AIVariable = { - id: 'task-context', + id: 'taskContext', description: 'Provides background information on task planning, particularly summaries of chat sessions.', - name: 'task-context', + name: 'taskContext', label: 'Task Context', iconClasses: codiconArray('clippy'), isContextVariable: true, @@ -71,7 +71,7 @@ export class TaskContextVariableContribution implements FrontendVariableContribu protected getItems(): QuickPickItemOrSeparator[] { const currentSession = this.chatService.getSessions().find(candidate => candidate.isActive); - const existingSummaries = this.taskContextService.getSummaries().filter(candidate => !currentSession || currentSession.id !== candidate.sessionId); + const existingSummaries = this.taskContextService.getAll().filter(candidate => !currentSession || currentSession.id !== candidate.sessionId); const knownSessions = new Set(existingSummaries.filter(candidate => candidate.sessionId).map(({ sessionId }) => sessionId)); return [ ...(existingSummaries.length ? [{ type: 'separator', label: 'Saved Tasks' }] satisfies QuickPickSeparator[] : []), 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 b662351ed1a43..35f3e055baa3c 100644 --- a/packages/ai-chat/src/common/chat-session-naming-service.ts +++ b/packages/ai-chat/src/common/chat-session-naming-service.ts @@ -39,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}}', }; diff --git a/packages/ai-ide/package.json b/packages/ai-ide/package.json index 07684e87e69a3..d6e4aa163fd2d 100644 --- a/packages/ai-ide/package.json +++ b/packages/ai-ide/package.json @@ -27,6 +27,7 @@ "@theia/workspace": "1.60.0", "@theia/ai-mcp": "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/frontend-module.ts b/packages/ai-ide/src/browser/frontend-module.ts index 5c187913718a6..a8ec68b5966af 100644 --- a/packages/ai-ide/src/browser/frontend-module.ts +++ b/packages/ai-ide/src/browser/frontend-module.ts @@ -46,8 +46,10 @@ import { ChatWelcomeMessageProvider } from '@theia/ai-chat-ui/lib/browser/chat-t 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(); @@ -139,4 +141,6 @@ export default new ContainerModule(bind => { 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-context-file-storage-service.ts b/packages/ai-ide/src/browser/task-context-file-storage-service.ts new file mode 100644 index 0000000000000..dd791b21c6555 --- /dev/null +++ b/packages/ai-ide/src/browser/task-context-file-storage-service.ts @@ -0,0 +1,162 @@ +// ***************************************************************************** +// 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, Path, URI, unreachable } from '@theia/core'; +import { PreferenceService } 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; + + 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), + 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))); + } + + protected async readFile(uri: URI): Promise { + const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined); + if (!content) { return; } + const { frontmatter, body } = this.maybeReadFrontmatter(content); + const summary = { ...frontmatter, summary: body, label: frontmatter?.label || uri.path.base, uri, id: frontmatter?.sessionId || uri.path.base }; + 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(/\W+/g, '-').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); + } + + 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); + } + return this.inMemoryStorage.delete(identifier); + } + + 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'; + } +} 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 0759dc115644c..e594774e4baab 100644 --- a/packages/ai-ide/src/common/coder-replace-prompt-template.ts +++ b/packages/ai-ide/src/common/coder-replace-prompt-template.ts @@ -52,8 +52,6 @@ ${withSearchAndReplace ? ' If ~{changeSet_replaceContentInFile} continously fail 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.' : ''} -{{${TASK_CONTEXT_SUMMARY_VARIABLE_ID}}} - ## Additional Context The following files have been provided for additional context. Some of them may also be referred to by the user. \ @@ -65,6 +63,8 @@ You have previously proposed changes for the following files. Some suggestions m {{${CHANGE_SET_SUMMARY_VARIABLE_ID}}} {{prompt:project-info}} + +{{${TASK_CONTEXT_SUMMARY_VARIABLE_ID}}} `, ...(!withSearchAndReplace ? { variantOf: CODER_REPLACE_PROMPT_TEMPLATE_ID } : {}), }; From 6efee06f5381c6cd62fd1b1e92613df086d26403 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Mon, 21 Apr 2025 09:42:58 -0700 Subject: [PATCH 10/28] Add progress --- packages/ai-chat-ui/src/browser/chat-view-widget.tsx | 5 +++++ packages/ai-chat/src/browser/task-context-service.ts | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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 c456418d732e7..2f4261a9c99d1 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,7 @@ 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, AIVariableResourceResolver } from '@theia/ai-core'; +import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory'; export namespace ChatViewWidget { export interface State { @@ -56,6 +57,9 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta @inject(OpenerService) protected readonly openerService: OpenerService; + @inject(ProgressBarFactory) + protected readonly progressBarFactory: ProgressBarFactory; + protected chatSession: ChatSession; protected _state: ChatViewWidget.State = { locked: false }; @@ -117,6 +121,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/src/browser/task-context-service.ts b/packages/ai-chat/src/browser/task-context-service.ts index 96a35c43c1476..967876b61e462 100644 --- a/packages/ai-chat/src/browser/task-context-service.ts +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { inject, injectable } from '@theia/core/shared/inversify'; -import { MaybePromise, URI } from '@theia/core'; +import { MaybePromise, ProgressService, URI } from '@theia/core'; import { ChatAgent, ChatAgentLocation, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel, ParsedChatRequestTextPart } from '../common'; import { CHAT_SESSION_SUMMARY_PROMPT, ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; import { Deferred } from '@theia/core/lib/common/promise-util'; @@ -49,6 +49,7 @@ export class TaskContextService { @inject(ChatSessionSummaryAgent) protected readonly summaryAgent: ChatSessionSummaryAgent; @inject(PromptService) protected readonly promptService: PromptService; @inject(TaskContextStorageService) protected readonly storageService: TaskContextStorageService; + @inject(ProgressService) protected readonly progressService: ProgressService; getAll(): Array { return this.storageService.getAll(); @@ -72,6 +73,7 @@ export class TaskContextService { const pending = this.pendingSummaries.get(session.id); if (pending) { return pending.then(({ summary }) => summary); } 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 = { @@ -86,6 +88,7 @@ export class TaskContextService { summaryDeferred.reject(err); throw err; } finally { + progress.cancel(); this.pendingSummaries.delete(session.id); } } From c81189f7d2b87aef6b96caa4b7a1d75dde171b6a Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Mon, 21 Apr 2025 13:13:57 -0700 Subject: [PATCH 11/28] Only offer existing summaries --- .../src/browser/ai-chat-ui-contribution.ts | 55 ++++++++++++++----- .../src/browser/chat-view-commands.ts | 5 ++ .../src/browser/task-context-service.ts | 25 +++++++-- .../browser/task-context-storage-service.ts | 18 +++++- .../task-context-variable-contribution.ts | 14 +---- .../task-context-file-storage-service.ts | 16 +++++- 6 files changed, 100 insertions(+), 33 deletions(-) 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 6e81109de983d..342ef1b67f81f 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,10 +15,10 @@ // ***************************************************************************** import { inject, injectable } from '@theia/core/shared/inversify'; -import { CommandRegistry, isOSX, nls, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core'; +import { CommandRegistry, Emitter, isOSX, nls, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core'; import { Widget } from '@theia/core/lib/browser'; -import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_NEW_WITH_TASK_CONTEXT, AI_CHAT_SHOW_CHATS_COMMAND, ChatCommands } from './chat-view-commands'; -import { ChatAgentLocation, ChatService } from '@theia/ai-chat'; +import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_NEW_WITH_TASK_CONTEXT, AI_CHAT_SHOW_CHATS_COMMAND, AI_CHAT_SUMMARIZE_CURRENT_SESSION, ChatCommands } from './chat-view-commands'; +import { ChatAgentLocation, ChatService, ChatSession } 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'; import { ChatViewWidget } from './chat-view-widget'; @@ -91,23 +91,30 @@ export class AIChatContribution extends AbstractViewContribution isVisible: widget => this.withWidget(widget, () => true), }); registry.registerCommand(AI_CHAT_NEW_WITH_TASK_CONTEXT, { - execute: () => { - const activeSessions = this.chatService.getSessions().filter(candidate => candidate.isActive); - if (activeSessions.length !== 1) { - return; - } - const activeSession = activeSessions[0]; - const summaryVariable = { variable: TASK_CONTEXT_VARIABLE, arg: activeSession.id }; - this.taskContextService.summarize(activeSession); + execute: async () => { + const activeSession = this.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(AI_CHAT_SUMMARIZE_CURRENT_SESSION, { + execute: async () => this.summarizeActiveSession(), + isVisible: widget => { + if (widget && !this.withWidget(widget)) { return false; } + const activeSession = this.getActiveSession(); + return !!activeSession?.model.getRequests().length + && activeSession?.model.location === ChatAgentLocation.Panel + && !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) }); } @@ -140,6 +147,14 @@ 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.' + AI_CHAT_SUMMARIZE_CURRENT_SESSION.id, + command: AI_CHAT_SUMMARIZE_CURRENT_SESSION.id, + onDidChange: sessionSummarizibilityChangedEmitter.event + }); } protected async selectChat(sessionId?: string): Promise { @@ -228,6 +243,20 @@ export class AIChatContribution extends AbstractViewContribution canExtractChatView(chatView: ChatViewWidget): boolean { return !chatView.secondaryWindow; } + + protected getActiveSession(): ChatSession | undefined { + const activeSessions = this.chatService.getSessions().filter(candidate => candidate.isActive); + if (activeSessions.length !== 1) { + return; + } + return activeSessions[0]; + } + + protected async summarizeActiveSession(): Promise { + const activeSession = this.getActiveSession(); + if (!activeSession) { return; } + return await this.taskContextService.summarize(activeSession); + } } function getDateFnsLocale(): locales.Locale { 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 65f28643cc6c2..40f3f8fbc3153 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-commands.ts +++ b/packages/ai-chat-ui/src/browser/chat-view-commands.ts @@ -49,6 +49,11 @@ export const AI_CHAT_NEW_WITH_TASK_CONTEXT: Command = { id: 'ai-chat.new-with-task-context', }; +export const AI_CHAT_SUMMARIZE_CURRENT_SESSION: Command = { + id: 'ai-chat-summary-current-session', + iconClass: codicon('note') +}; + export const AI_CHAT_SHOW_CHATS_COMMAND: Command = { id: 'ai-chat-ui.show-chats', iconClass: codicon('history') diff --git a/packages/ai-chat/src/browser/task-context-service.ts b/packages/ai-chat/src/browser/task-context-service.ts index 967876b61e462..dbfb89af1baac 100644 --- a/packages/ai-chat/src/browser/task-context-service.ts +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -15,7 +15,7 @@ // ***************************************************************************** import { inject, injectable } from '@theia/core/shared/inversify'; -import { MaybePromise, ProgressService, URI } from '@theia/core'; +import { MaybePromise, ProgressService, URI, generateUuid, Event } from '@theia/core'; import { ChatAgent, ChatAgentLocation, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel, ParsedChatRequestTextPart } from '../common'; import { CHAT_SESSION_SUMMARY_PROMPT, ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; import { Deferred } from '@theia/core/lib/common/promise-util'; @@ -34,6 +34,7 @@ export interface Summary extends SummaryMetadata { export const TaskContextStorageService = Symbol('TextContextStorageService'); export interface TaskContextStorageService { + onDidChange: Event; store(summary: Summary): MaybePromise; getAll(): Summary[]; get(identifier: string): Summary | undefined; @@ -51,6 +52,10 @@ export class TaskContextService { @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(); } @@ -69,9 +74,13 @@ export class TaskContextService { 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(({ summary }) => summary); } + 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); @@ -80,10 +89,10 @@ export class TaskContextService { summary: await this.getLlmSummary(session, promptId, agent), label: session.title || session.id, sessionId: session.id, - id: session.id + id: summaryId }; await this.storageService.store(newSummary); - return newSummary.summary; + return summaryId; } catch (err) { summaryDeferred.reject(err); throw err; @@ -109,6 +118,14 @@ export class TaskContextService { 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; } diff --git a/packages/ai-chat/src/browser/task-context-storage-service.ts b/packages/ai-chat/src/browser/task-context-storage-service.ts index 6e37588525e52..2743005c7a12d 100644 --- a/packages/ai-chat/src/browser/task-context-storage-service.ts +++ b/packages/ai-chat/src/browser/task-context-storage-service.ts @@ -16,17 +16,22 @@ import { injectable } from '@theia/core/shared/inversify'; import { Summary, TaskContextStorageService } from './task-context-service'; +import { Emitter } from '@theia/core'; @injectable() export class InMemoryTaskContextStorage implements TaskContextStorageService { protected summaries = new Map(); + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + store(summary: Summary): void { this.summaries.set(summary.id, summary); + this.onDidChangeEmitter.fire(); } getAll(): Summary[] { - return Array.from(this.summaries.entries(), ([id, { label, summary, sessionId }]) => ({ id, label, summary, sessionId })); + return Array.from(this.summaries.values()); } get(identifier: string): Summary | undefined { @@ -34,10 +39,17 @@ export class InMemoryTaskContextStorage implements TaskContextStorageService { } delete(identifier: string): boolean { - return this.summaries.delete(identifier); + const didDelete = this.summaries.delete(identifier); + if (didDelete) { + this.onDidChangeEmitter.fire(); + } + return didDelete; } clear(): void { - this.summaries.clear(); + if (this.summaries.size) { + this.summaries.clear(); + this.onDidChangeEmitter.fire(); + } } } diff --git a/packages/ai-chat/src/browser/task-context-variable-contribution.ts b/packages/ai-chat/src/browser/task-context-variable-contribution.ts index a0cb468cf7e4b..4efe4125f4562 100644 --- a/packages/ai-chat/src/browser/task-context-variable-contribution.ts +++ b/packages/ai-chat/src/browser/task-context-variable-contribution.ts @@ -17,7 +17,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { AIVariable, AIVariableContext, AIVariableResolutionRequest, AIVariableResolver, ResolvedAIContextVariable } from '@theia/ai-core'; import { AIVariableCompletionContext, FrontendVariableContribution, FrontendVariableService } from '@theia/ai-core/lib/browser'; -import { MaybePromise, QuickInputService, QuickPickItem, QuickPickItemOrSeparator, QuickPickSeparator } from '@theia/core'; +import { MaybePromise, QuickInputService, QuickPickItem } from '@theia/core'; import { ChatService } from '../common'; import { codiconArray } from '@theia/core/lib/browser'; import * as monaco from '@theia/monaco-editor-core'; @@ -69,18 +69,10 @@ export class TaskContextVariableContribution implements FrontendVariableContribu })); } - protected getItems(): QuickPickItemOrSeparator[] { + protected getItems(): QuickPickItem[] { const currentSession = this.chatService.getSessions().find(candidate => candidate.isActive); const existingSummaries = this.taskContextService.getAll().filter(candidate => !currentSession || currentSession.id !== candidate.sessionId); - const knownSessions = new Set(existingSummaries.filter(candidate => candidate.sessionId).map(({ sessionId }) => sessionId)); - return [ - ...(existingSummaries.length ? [{ type: 'separator', label: 'Saved Tasks' }] satisfies QuickPickSeparator[] : []), - ...existingSummaries satisfies QuickPickItem[], - ...(existingSummaries.length ? [{ type: 'separator', label: 'Other Sessions' }] satisfies QuickPickSeparator[] : []), - ...this.chatService.getSessions() - .filter(candidate => (!currentSession || currentSession.id !== candidate.id) && !knownSessions.has(candidate.id) && candidate.model.getRequests().length) - .map(session => ({ type: 'item', label: session.title || session.id, id: session.id })) - ]; + return existingSummaries; } canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { 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 index dd791b21c6555..4a7e6f34b4aed 100644 --- a/packages/ai-ide/src/browser/task-context-file-storage-service.ts +++ b/packages/ai-ide/src/browser/task-context-file-storage-service.ts @@ -17,7 +17,7 @@ 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, Path, URI, unreachable } from '@theia/core'; +import { DisposableCollection, EOL, Emitter, Path, URI, unreachable } from '@theia/core'; import { PreferenceService } from '@theia/core/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { WorkspaceService } from '@theia/workspace/lib/browser'; @@ -32,6 +32,8 @@ export class TaskContextFileStorageService implements TaskContextStorageService @inject(PreferenceService) protected readonly preferenceService: PreferenceService; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(FileService) protected readonly fileService: FileService; + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange = this.onDidChangeEmitter.event; protected getStorageLocation(): URI | undefined { if (!this.workspaceService.opened) { return; } @@ -99,6 +101,7 @@ export class TaskContextFileStorageService implements TaskContextStorageService 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 { @@ -106,6 +109,10 @@ export class TaskContextFileStorageService implements TaskContextStorageService if (!content) { return; } const { frontmatter, body } = this.maybeReadFrontmatter(content); const summary = { ...frontmatter, summary: body, label: frontmatter?.label || uri.path.base, uri, id: frontmatter?.sessionId || uri.path.base }; + const existingSummary = this.getAll().find(candidate => candidate.sessionId === summary.sessionId) + if (existingSummary) { + summary.id = existingSummary.id; + } this.inMemoryStorage.store(summary); } @@ -125,6 +132,7 @@ export class TaskContextFileStorageService implements TaskContextStorageService await this.fileService.writeFile(uri, BinaryBuffer.fromString(content)); } this.inMemoryStorage.store(summary); + this.onDidChangeEmitter.fire(); } getAll(): Summary[] { @@ -140,7 +148,11 @@ export class TaskContextFileStorageService implements TaskContextStorageService if (summary?.uri) { await this.fileService.delete(summary.uri); } - return this.inMemoryStorage.delete(identifier); + this.inMemoryStorage.delete(identifier); + if (summary) { + this.onDidChangeEmitter.fire(); + } + return !!summary; } protected maybeReadFrontmatter(content: string): { body: string, frontmatter: SummaryMetadata | undefined } { From df731e1367a52c79c38d0598395f7d5d5f79061d Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Mon, 21 Apr 2025 13:31:21 -0700 Subject: [PATCH 12/28] Support non-ASCII labels --- .../ai-ide/src/browser/task-context-file-storage-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4a7e6f34b4aed..ed7482aeaf8b7 100644 --- a/packages/ai-ide/src/browser/task-context-file-storage-service.ts +++ b/packages/ai-ide/src/browser/task-context-file-storage-service.ts @@ -124,7 +124,7 @@ export class TaskContextFileStorageService implements TaskContextStorageService date: new Date().toISOString(), label: summary.label, }; - const derivedName = summary.label.trim().replace(/\W+/g, '-').replace(/^-|-$/g, ''); + 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); From c340df97264c84ad84ea63c7981d7ea3fb66a02c Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 22 Apr 2025 07:00:26 -0700 Subject: [PATCH 13/28] Explicit empty string --- packages/ai-ide/src/browser/task-background-summary-variable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-ide/src/browser/task-background-summary-variable.ts b/packages/ai-ide/src/browser/task-background-summary-variable.ts index db655ecc30fd6..8005aa7d1217f 100644 --- a/packages/ai-ide/src/browser/task-background-summary-variable.ts +++ b/packages/ai-ide/src/browser/task-background-summary-variable.ts @@ -56,7 +56,7 @@ export class TaskContextSummaryVariableContribution implements AIVariableContrib ): 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; } + if (!allSummaryRequests.length) { return { ...request, value: '' }; } const allSummaries = await Promise.all(allSummaryRequests.map(summaryRequest => resolveDependency(summaryRequest).then(resolved => resolved?.value))); const value = `# Task Planning background\n\n${allSummaries.map((content, index) => `## Task ${index + 1}\n\n${content}`).join('\n\n')}`; return { From 9ebf3a5e3078000fd159d23f1ae5edc3e250f105 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 22 Apr 2025 07:11:27 -0700 Subject: [PATCH 14/28] No replacement if no session Id --- .../browser/task-context-file-storage-service.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 index ed7482aeaf8b7..370b84c2b9208 100644 --- a/packages/ai-ide/src/browser/task-context-file-storage-service.ts +++ b/packages/ai-ide/src/browser/task-context-file-storage-service.ts @@ -59,7 +59,7 @@ export class TaskContextFileStorageService implements TaskContextStorageService const newStorage = this.getStorageLocation(); if (!newStorage) { return; } this.toDisposeOnStorageChange = new DisposableCollection( - this.fileService.watch(newStorage), + this.fileService.watch(newStorage, { recursive: true, excludes: [] }), this.fileService.onDidFilesChange(event => { const relevantChanges = event.changes.filter(candidate => newStorage.isEqualOrParent(candidate.resource)); this.handleChanges(relevantChanges); @@ -106,10 +106,16 @@ export class TaskContextFileStorageService implements TaskContextStorageService protected async readFile(uri: URI): Promise { const content = await this.fileService.read(uri).then(read => read.value).catch(() => undefined); - if (!content) { return; } + if (content === undefined) { return; } const { frontmatter, body } = this.maybeReadFrontmatter(content); - const summary = { ...frontmatter, summary: body, label: frontmatter?.label || uri.path.base, uri, id: frontmatter?.sessionId || uri.path.base }; - const existingSummary = this.getAll().find(candidate => candidate.sessionId === summary.sessionId) + 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; } From 1291873958b5e6157ba3affe484e77908a012c95 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 22 Apr 2025 14:15:36 -0700 Subject: [PATCH 15/28] Add opening to variable system --- .../src/browser/ai-chat-ui-contribution.ts | 2 +- .../src/browser/chat-view-widget.tsx | 18 +- .../src/browser/ai-chat-frontend-module.ts | 5 +- .../src/browser/change-set-file-element.ts | 28 +-- .../src/browser/change-set-file-resource.ts | 49 +---- .../src/browser/task-context-service.ts | 7 +- .../browser/task-context-storage-service.ts | 22 ++- .../task-context-variable-contribution.ts | 26 +-- .../task-context-variable-label-provider.ts | 3 +- .../src/browser/task-context-variable.ts | 28 +++ .../browser/ai-variable-uri-label-provider.ts | 9 +- .../src/browser/file-variable-contribution.ts | 48 +++-- .../src/browser/frontend-variable-service.ts | 83 +++++++- .../src/common/ai-variable-resource.ts | 182 +++--------------- .../ai-core/src/common/variable-service.ts | 91 +++++---- .../task-background-summary-variable.ts | 2 +- .../task-context-file-storage-service.ts | 13 +- .../core/src/browser/icon-theme-service.ts | 2 +- packages/core/src/common/resource.ts | 69 +++++-- 19 files changed, 356 insertions(+), 331 deletions(-) create mode 100644 packages/ai-chat/src/browser/task-context-variable.ts 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 342ef1b67f81f..6510ee3ae4762 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 @@ -28,7 +28,7 @@ import { formatDistance } from 'date-fns'; import * as locales from 'date-fns/locale'; 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 { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable-contribution'; +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'; 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 2f4261a9c99d1..35b0240861f10 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx @@ -15,14 +15,15 @@ // ***************************************************************************** import { CommandService, deepClone, Emitter, Event, MessageService } from '@theia/core'; import { ChatRequest, ChatRequestModel, ChatService, ChatSession, isActiveSessionChangedEvent, MutableChatModel } from '@theia/ai-chat'; -import { BaseWidget, codicon, ExtractableWidget, Message, open, OpenerService, PanelLayout, PreferenceService, StatefulWidget } from '@theia/core/lib/browser'; +import { BaseWidget, codicon, ExtractableWidget, Message, PanelLayout, PreferenceService, StatefulWidget } from '@theia/core/lib/browser'; import { nls } from '@theia/core/lib/common/nls'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; 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, AIVariableResourceResolver } from '@theia/ai-core'; +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 { @@ -51,11 +52,8 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta @inject(AIActivationService) protected readonly activationService: AIActivationService; - @inject(AIVariableResourceResolver) - protected readonly variableResourceResolver: AIVariableResourceResolver; - - @inject(OpenerService) - protected readonly openerService: OpenerService; + @inject(FrontendVariableService) + protected readonly variableService: FrontendVariableService; @inject(ProgressBarFactory) protected readonly progressBarFactory: ProgressBarFactory; @@ -214,10 +212,8 @@ export class ChatViewWidget extends BaseWidget implements ExtractableWidget, Sta } protected async onOpenContextElement(request: AIVariableResolutionRequest): Promise { - const context = {session: this.chatSession}; - const resource = this.variableResourceResolver.get(request, context); - await open(this.openerService, resource.uri); - resource.dispose(); + const context = { session: this.chatSession }; + await this.variableService.open(request, context); } lock(): void { 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 6e83844f0fad4..f31cf9991bec1 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 } 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'; @@ -111,8 +110,6 @@ export default new ContainerModule(bind => { container.bind(ChangeSetFileElement).toSelf().inSingletonScope(); return container.get(ChangeSetFileElement); }); - bind(ChangeSetFileResourceResolver).toSelf().inSingletonScope(); - bind(ResourceResolver).toService(ChangeSetFileResourceResolver); bind(ToolCallChatResponseContentFactory).toSelf().inSingletonScope(); bind(AIVariableContribution).to(FileChatVariableContribution).inSingletonScope(); bind(AIVariableContribution).to(ContextSummaryVariableContribution).inSingletonScope(); 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 3d6d1b69c7653..607121cd1086f 100644 --- a/packages/ai-chat/src/browser/change-set-file-element.ts +++ b/packages/ai-chat/src/browser/change-set-file-element.ts @@ -14,11 +14,11 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { DisposableCollection, Emitter, URI } from '@theia/core'; +import { DisposableCollection, Emitter, InMemoryResources, ReferenceMutableResource, URI } from '@theia/core'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { Replacement } from '@theia/core/lib/common/content-replacer'; 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'; @@ -63,8 +63,8 @@ export class ChangeSetFileElement implements ChangeSetElement { @inject(FileService) protected readonly fileService: FileService; - @inject(ChangeSetFileResourceResolver) - protected readonly resourceResolver: ChangeSetFileResourceResolver; + @inject(InMemoryResources) + protected readonly inMemoryResources: InMemoryResources; protected readonly toDispose = new DisposableCollection(); protected _state: ChangeSetElementState; @@ -73,8 +73,8 @@ export class ChangeSetFileElement implements ChangeSetElement { protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange = this.onDidChangeEmitter.event; - protected readOnlyResource: UpdatableReferenceResource; - protected changeResource: UpdatableReferenceResource; + protected readOnlyResource: ReferenceMutableResource; + protected changeResource: ReferenceMutableResource; @postConstruct() init(): void { @@ -93,17 +93,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): ReferenceMutableResource { + try { return this.inMemoryResources.resolve(uri); } catch { return this.inMemoryResources.add(uri, ''); } + } + 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 13aa3ea793ba9..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,57 +14,10 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { injectable } from '@theia/core/shared/inversify'; -import { ResourceResolver, URI } from '@theia/core'; -import {DisposableMutableResource, DisposableRefCounter, ResourceInitializationOptions, UpdatableReferenceResource} from '@theia/ai-core'; -export {DisposableMutableResource, DisposableRefCounter, ResourceInitializationOptions, UpdatableReferenceResource}; +import { URI } from '@theia/core'; export const CHANGE_SET_FILE_RESOURCE_SCHEME = 'changeset-file'; export function createChangeSetFileUri(chatSessionId: string, elementUri: URI): URI { return elementUri.withScheme(CHANGE_SET_FILE_RESOURCE_SCHEME).withAuthority(chatSessionId); } - -@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/task-context-service.ts b/packages/ai-chat/src/browser/task-context-service.ts index dbfb89af1baac..1d06eb1f2fb46 100644 --- a/packages/ai-chat/src/browser/task-context-service.ts +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -39,6 +39,7 @@ export interface TaskContextStorageService { getAll(): Summary[]; get(identifier: string): Summary | undefined; delete(identifier: string): MaybePromise; + open(identifier: string): Promise; } @injectable() @@ -123,10 +124,14 @@ export class TaskContextService { } protected getSummaryForSession(chatSession: ChatSession): Summary | undefined { - return this.storageService.getAll().find(candidate => candidate.sessionId === chatSession.id) + 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 index 2743005c7a12d..a08073a2466a4 100644 --- a/packages/ai-chat/src/browser/task-context-storage-service.ts +++ b/packages/ai-chat/src/browser/task-context-storage-service.ts @@ -14,9 +14,12 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { injectable } from '@theia/core/shared/inversify'; +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 { @@ -25,6 +28,12 @@ export class InMemoryTaskContextStorage implements TaskContextStorageService { 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(); @@ -52,4 +61,15 @@ export class InMemoryTaskContextStorage implements TaskContextStorageService { 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 index 4efe4125f4562..a22e64cc0325d 100644 --- a/packages/ai-chat/src/browser/task-context-variable-contribution.ts +++ b/packages/ai-chat/src/browser/task-context-variable-contribution.ts @@ -15,26 +15,16 @@ // ***************************************************************************** import { inject, injectable } from '@theia/core/shared/inversify'; -import { AIVariable, AIVariableContext, AIVariableResolutionRequest, AIVariableResolver, ResolvedAIContextVariable } from '@theia/ai-core'; +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 { codiconArray } from '@theia/core/lib/browser'; import * as monaco from '@theia/monaco-editor-core'; import { TaskContextService } from './task-context-service'; - -export const TASK_CONTEXT_VARIABLE: AIVariable = { - id: 'taskContext', - description: 'Provides background information on task planning, particularly summaries of chat 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.' }] -}; +import { TASK_CONTEXT_VARIABLE } from './task-context-variable'; @injectable() -export class TaskContextVariableContribution implements FrontendVariableContribution, AIVariableResolver { +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; @@ -43,6 +33,7 @@ export class TaskContextVariableContribution implements FrontendVariableContribu 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 { @@ -84,4 +75,13 @@ export class TaskContextVariableContribution implements FrontendVariableContribu 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 index 62da5653f6334..11d634b1dda5b 100644 --- a/packages/ai-chat/src/browser/task-context-variable-label-provider.ts +++ b/packages/ai-chat/src/browser/task-context-variable-label-provider.ts @@ -18,9 +18,10 @@ 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, TASK_CONTEXT_VARIABLE } from './task-context-variable-contribution'; +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 { 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..f96cf4895f4db --- /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 background information on task planning, particularly summaries of chat 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-core/src/browser/ai-variable-uri-label-provider.ts b/packages/ai-core/src/browser/ai-variable-uri-label-provider.ts index e4bea57615ff2..d9c3b41a44f38 100644 --- a/packages/ai-core/src/browser/ai-variable-uri-label-provider.ts +++ b/packages/ai-core/src/browser/ai-variable-uri-label-provider.ts @@ -18,13 +18,14 @@ 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 } from '../common/variable-service'; +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; @@ -56,6 +57,10 @@ export class AIVariableUriLabelProvider implements LabelProviderContribution { protected getResolutionRequest(element: object): AIVariableResolutionRequest | undefined { if (!this.isMine(element)) { return undefined; } - return this.variableResourceResolver.fromUri(element); + 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 f4583680e6c1f..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,20 @@ // 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, PromptText } 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; @@ -68,6 +78,11 @@ 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 { @@ -75,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(); } @@ -105,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 index 0a7543c53b605..df7d3ee48f125 100644 --- a/packages/ai-core/src/common/ai-variable-resource.ts +++ b/packages/ai-core/src/common/ai-variable-resource.ts @@ -15,168 +15,37 @@ // ***************************************************************************** import * as deepEqual from 'fast-deep-equal'; -import { injectable, inject } from '@theia/core/shared/inversify'; -import { Resource, ResourceResolver, Reference, URI, Emitter, Event, generateUuid } from '@theia/core'; -import { AIVariableContext, AIVariableResolutionRequest, AIVariableService, ResolvedAIContextVariable } from './variable-service'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { Resource, URI, generateUuid, ReferenceMutableResource, InMemoryResources } from '@theia/core'; +import { AIVariableContext, AIVariableResolutionRequest } from './variable-service'; import stableJsonStringify = require('fast-json-stable-stringify'); export const AI_VARIABLE_RESOURCE_SCHEME = 'ai-variable'; export const NO_CONTEXT_AUTHORITY = 'context-free'; -export type ResourceInitializationOptions = Pick - & { contents?: string | Promise, onSave?: Resource['saveContents'] }; -export type ResourceUpdateOptions = Pick; - -export class UpdatableReferenceResource implements Resource { - static acquire(resource: UpdatableReferenceResource): UpdatableReferenceResource { - DisposableRefCounter.acquire(resource.reference); - return resource; - } - - constructor(protected reference: DisposableRefCounter) { } - - 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: 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 implements Resource { - protected onSave: Resource['saveContents'] | undefined; - protected contents: string | Promise; - protected readonly onDidChangeContentsEmitter = new Emitter(); - readonly onDidChangeContents = this.onDidChangeContentsEmitter.event; - - constructor(readonly uri: URI, protected readonly options?: ResourceInitializationOptions) { - 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; - } - - readContents(): Promise { - return Promise.resolve(this.contents); - } - - 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.onDidChangeContentsEmitter.fire(); - } - if ('onSave' in options && options.onSave !== this.onSave) { - this.onSave = options.onSave; - } - } - - 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 AIVariableResourceResolver implements ResourceResolver { - protected readonly cache = new Map(); - @inject(AIVariableService) protected readonly variableService: AIVariableService; +export class AIVariableResourceResolver { + @inject(InMemoryResources) protected readonly inMemoryResources: InMemoryResources; - resolve(uri: URI): Resource { - const existing = this.tryGet(uri); - if (!existing) { - throw new Error('Unknown URI'); - } - return existing; + @postConstruct() + protected init(): void { + this.inMemoryResources.onWillDispose(resource => this.cache.delete(resource.uri.toString())); } - protected tryGet(uri: URI): UpdatableReferenceResource | undefined { - const existing = this.cache.get(uri.toString()); - if (existing) { - return UpdatableReferenceResource.acquire(existing[0]); - } - } + protected readonly cache = new Map(); - get(request: AIVariableResolutionRequest, context: AIVariableContext): Resource { + getOrCreate(request: AIVariableResolutionRequest, context: AIVariableContext, value: string): ReferenceMutableResource { const uri = this.toUri(request, context); - const existing = this.tryGet(uri); - if (existing) { return existing; } + try { + const existing = this.inMemoryResources.resolve(uri); + existing.update({ contents: value }); + return existing; + } catch { /* No-op */ } + const fresh = this.inMemoryResources.add(uri, value); + fresh.update({ readOnly: true, initiallyDirty: false }); const key = uri.toString(); - const underlying = new DisposableMutableResource(uri, { readOnly: true }); - const ref = DisposableRefCounter.create(underlying, () => { - underlying.dispose(); - this.cache.delete(key); - }); - const refResource = new UpdatableReferenceResource(ref); - this.cache.set(key, [refResource, context]); - this.variableService.resolveVariable(request, context) - .then((value: ResolvedAIContextVariable) => value && refResource.update({ contents: value.contextValue || value.value })); - return refResource; + this.cache.set(key, [fresh, context]); + return fresh; } protected toUri(request: AIVariableResolutionRequest, context: AIVariableContext): URI { @@ -204,17 +73,14 @@ export class AIVariableResourceResolver implements ResourceResolver { return generateUuid(); } - fromUri(uri: URI): AIVariableResolutionRequest | undefined { + fromUri(uri: URI): { variableName: string, arg: string | undefined } | undefined { if (uri.scheme !== AI_VARIABLE_RESOURCE_SCHEME) { return undefined; } try { - const { name, arg } = JSON.parse(uri.query); - if (!name) { return undefined; } - const variable = this.variableService.getVariable(name); - if (!variable) { return undefined; } - return { - variable, + const { name: variableName, arg } = JSON.parse(uri.query); + return variableName ? { + variableName, arg, - }; + } : undefined; } catch { return undefined; } } } diff --git a/packages/ai-core/src/common/variable-service.ts b/packages/ai-core/src/common/variable-service.ts index 22b6433cfe53d..32573a9bc830f 100644 --- a/packages/ai-core/src/common/variable-service.ts +++ b/packages/ai-core/src/common/variable-service.ts @@ -136,6 +136,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; /** @@ -166,6 +171,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; @@ -174,8 +180,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`. */ @@ -216,6 +220,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(); @@ -225,8 +230,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)); @@ -339,18 +343,23 @@ 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. @@ -364,40 +373,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/src/browser/task-background-summary-variable.ts b/packages/ai-ide/src/browser/task-background-summary-variable.ts index 8005aa7d1217f..04cc884f537a8 100644 --- a/packages/ai-ide/src/browser/task-background-summary-variable.ts +++ b/packages/ai-ide/src/browser/task-background-summary-variable.ts @@ -27,7 +27,7 @@ import { AIVariableArg } from '@theia/ai-core'; import { ChatSessionContext } from '@theia/ai-chat'; -import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable-contribution'; +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 = { 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 index 370b84c2b9208..56aecd911fce3 100644 --- a/packages/ai-ide/src/browser/task-context-file-storage-service.ts +++ b/packages/ai-ide/src/browser/task-context-file-storage-service.ts @@ -18,7 +18,7 @@ import { Summary, SummaryMetadata, TaskContextStorageService } from '@theia/ai-c 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 } from '@theia/core/lib/browser'; +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'; @@ -32,6 +32,7 @@ export class TaskContextFileStorageService implements TaskContextStorageService @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; @@ -115,7 +116,7 @@ export class TaskContextFileStorageService implements TaskContextStorageService uri, id: frontmatter?.sessionId || uri.path.base }; - const existingSummary = summary.sessionId && this.getAll().find(candidate => candidate.sessionId === summary.sessionId) + const existingSummary = summary.sessionId && this.getAll().find(candidate => candidate.sessionId === summary.sessionId); if (existingSummary) { summary.id = existingSummary.id; } @@ -177,4 +178,12 @@ export class TaskContextFileStorageService implements TaskContextStorageService 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?.uri) { + throw new Error('Unable to open requested task context: none found with specified identifier.'); + } + await open(this.openerService, summary.uri); + } } diff --git a/packages/core/src/browser/icon-theme-service.ts b/packages/core/src/browser/icon-theme-service.ts index ecf237d2b31b8..9e7f9edf4e49d 100644 --- a/packages/core/src/browser/icon-theme-service.ts +++ b/packages/core/src/browser/icon-theme-service.ts @@ -95,7 +95,7 @@ export class IconThemeService { return this._iconThemes.get(id); } - @inject(NoneIconTheme) protected readonly noneIconTheme: NoneIconTheme; + @inject(NoneIconTheme) protected readonly noneIconTheme: NoneIconTheme; // @CJG - something here! @inject(PreferenceService) protected readonly preferences: PreferenceService; @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider; diff --git a/packages/core/src/common/resource.ts b/packages/core/src/common/resource.ts index 79fe26ad2ca84..5817aeb260bbe 100644 --- a/packages/core/src/common/resource.ts +++ b/packages/core/src/common/resource.ts @@ -224,29 +224,54 @@ export class DefaultResourceProvider { } +export type ResourceInitializationOptions = Pick + & { contents?: string | Promise, onSave?: Resource['saveContents'] }; + export class MutableResource implements Resource { - protected contents: string = ''; + protected contents: string | Promise; + + constructor(readonly uri: URI, protected options?: ResourceInitializationOptions) { } - constructor(readonly uri: URI) { + get readOnly(): Resource['readOnly'] { + return this.options?.readOnly; } - dispose(): void { } + get autosaveable(): boolean { + return this.options?.autosaveable !== false; + } - async readContents(): Promise { - return this.contents; + get initiallyDirty(): boolean { + return !!this.options?.initiallyDirty; + } + + readContents(): Promise { + return Promise.resolve(this.contents); } async saveContents(contents: string): Promise { - this.contents = contents; - this.fireDidChangeContents(); + await this.options?.onSave?.(contents); + this.update({ contents }); + } + + update(options: ResourceInitializationOptions): void { + if (options.contents !== undefined && options.contents !== this.contents) { + this.contents = options.contents; + this.onDidChangeContentsEmitter.fire(); + } + this.options = { ...this.options, ...options }; + } + + dispose(): void { + this.onDidChangeContentsEmitter.dispose(); } protected readonly onDidChangeContentsEmitter = new Emitter(); readonly onDidChangeContents = this.onDidChangeContentsEmitter.event; protected fireDidChangeContents(): void { - this.onDidChangeContentsEmitter.fire(undefined); + this.onDidChangeContentsEmitter.fire(); } } + export class ReferenceMutableResource implements Resource { constructor(protected reference: Reference) { } @@ -269,6 +294,22 @@ export class ReferenceMutableResource implements Resource { 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; + } } @injectable() @@ -276,13 +317,17 @@ export class InMemoryResources implements ResourceResolver { protected readonly resources = new SyncReferenceCollection(uri => new MutableResource(new URI(uri))); - add(uri: URI, contents: string): Resource { + get onWillDispose(): Event { + return this.resources.onWillDispose; + } + + add(uri: URI, contents: string): ReferenceMutableResource { 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.saveContents(contents); + resource.update({ readOnly: false, contents }); return resource; } @@ -292,11 +337,11 @@ export class InMemoryResources implements ResourceResolver { if (!resource) { throw new Error(`Cannot update non-existent in-memory resource '${resourceUri}'`); } - resource.saveContents(contents); + resource.update({ contents }); return resource; } - resolve(uri: URI): Resource { + resolve(uri: URI): ReferenceMutableResource { const uriString = uri.toString(); if (!this.resources.has(uriString)) { throw new Error(`In memory '${uriString}' resource does not exist.`); From cc2ad6878f71ea0802f46f5a64a0abd03e6bb66d Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Tue, 22 Apr 2025 16:20:40 -0700 Subject: [PATCH 16/28] The in-memory one can open it without a URI --- .../ai-ide/src/browser/task-context-file-storage-service.ts | 4 ++-- packages/core/src/browser/icon-theme-service.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 56aecd911fce3..d3bec464a7995 100644 --- a/packages/ai-ide/src/browser/task-context-file-storage-service.ts +++ b/packages/ai-ide/src/browser/task-context-file-storage-service.ts @@ -181,9 +181,9 @@ export class TaskContextFileStorageService implements TaskContextStorageService async open(identifier: string): Promise { const summary = this.get(identifier); - if (!summary?.uri) { + if (!summary) { throw new Error('Unable to open requested task context: none found with specified identifier.'); } - await open(this.openerService, summary.uri); + await (summary.uri ? open(this.openerService, summary.uri) : this.inMemoryStorage.open(identifier)); } } diff --git a/packages/core/src/browser/icon-theme-service.ts b/packages/core/src/browser/icon-theme-service.ts index 9e7f9edf4e49d..ecf237d2b31b8 100644 --- a/packages/core/src/browser/icon-theme-service.ts +++ b/packages/core/src/browser/icon-theme-service.ts @@ -95,7 +95,7 @@ export class IconThemeService { return this._iconThemes.get(id); } - @inject(NoneIconTheme) protected readonly noneIconTheme: NoneIconTheme; // @CJG - something here! + @inject(NoneIconTheme) protected readonly noneIconTheme: NoneIconTheme; @inject(PreferenceService) protected readonly preferences: PreferenceService; @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider; From b87e3a1ce9716962fdf98f295c1369f5517b2038 Mon Sep 17 00:00:00 2001 From: colin-grant-work Date: Wed, 23 Apr 2025 08:00:45 -0700 Subject: [PATCH 17/28] Update packages/ai-chat/src/browser/task-context-variable.ts Co-authored-by: Jonas Helming --- packages/ai-chat/src/browser/task-context-variable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-chat/src/browser/task-context-variable.ts b/packages/ai-chat/src/browser/task-context-variable.ts index f96cf4895f4db..6704141e491ed 100644 --- a/packages/ai-chat/src/browser/task-context-variable.ts +++ b/packages/ai-chat/src/browser/task-context-variable.ts @@ -19,7 +19,7 @@ import { codiconArray } from '@theia/core/lib/browser'; export const TASK_CONTEXT_VARIABLE: AIVariable = { id: 'taskContext', - description: 'Provides background information on task planning, particularly summaries of chat sessions.', + 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'), From 3e135efca8348b69dd45a03ecfa3b3626b799d31 Mon Sep 17 00:00:00 2001 From: colin-grant-work Date: Wed, 23 Apr 2025 08:00:59 -0700 Subject: [PATCH 18/28] Update packages/ai-ide/src/browser/task-background-summary-variable.ts Co-authored-by: Jonas Helming --- packages/ai-ide/src/browser/task-background-summary-variable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-ide/src/browser/task-background-summary-variable.ts b/packages/ai-ide/src/browser/task-background-summary-variable.ts index 04cc884f537a8..c2735b5a7b2a4 100644 --- a/packages/ai-ide/src/browser/task-background-summary-variable.ts +++ b/packages/ai-ide/src/browser/task-background-summary-variable.ts @@ -58,7 +58,7 @@ export class TaskContextSummaryVariableContribution implements AIVariableContrib 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 = `# Task Planning background\n\n${allSummaries.map((content, index) => `## Task ${index + 1}\n\n${content}`).join('\n\n')}`; + const value = `# Current Task Context\n\n${allSummaries.map((content, index) => `## Task ${index + 1}\n\n${content}`).join('\n\n')}`; return { ...request, value From b54913b446545355df237d8df95a529ba298306a Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 23 Apr 2025 08:05:16 -0700 Subject: [PATCH 19/28] getActiveSession to chat service --- .../src/browser/ai-chat-ui-contribution.ts | 16 ++++------------ packages/ai-chat/src/common/chat-service.ts | 7 +++++++ 2 files changed, 11 insertions(+), 12 deletions(-) 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 6510ee3ae4762..6cfdafe56a801 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 @@ -18,7 +18,7 @@ import { inject, injectable } from '@theia/core/shared/inversify'; import { CommandRegistry, Emitter, isOSX, nls, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core'; import { Widget } from '@theia/core/lib/browser'; import { AI_CHAT_NEW_CHAT_WINDOW_COMMAND, AI_CHAT_NEW_WITH_TASK_CONTEXT, AI_CHAT_SHOW_CHATS_COMMAND, AI_CHAT_SUMMARIZE_CURRENT_SESSION, ChatCommands } from './chat-view-commands'; -import { ChatAgentLocation, ChatService, ChatSession } from '@theia/ai-chat'; +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'; import { ChatViewWidget } from './chat-view-widget'; @@ -92,7 +92,7 @@ export class AIChatContribution extends AbstractViewContribution }); registry.registerCommand(AI_CHAT_NEW_WITH_TASK_CONTEXT, { execute: async () => { - const activeSession = this.getActiveSession(); + 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); @@ -105,7 +105,7 @@ export class AIChatContribution extends AbstractViewContribution execute: async () => this.summarizeActiveSession(), isVisible: widget => { if (widget && !this.withWidget(widget)) { return false; } - const activeSession = this.getActiveSession(); + const activeSession = this.chatService.getActiveSession(); return !!activeSession?.model.getRequests().length && activeSession?.model.location === ChatAgentLocation.Panel && !this.taskContextService.hasSummary(activeSession); @@ -244,16 +244,8 @@ export class AIChatContribution extends AbstractViewContribution return !chatView.secondaryWindow; } - protected getActiveSession(): ChatSession | undefined { - const activeSessions = this.chatService.getSessions().filter(candidate => candidate.isActive); - if (activeSessions.length !== 1) { - return; - } - return activeSessions[0]; - } - protected async summarizeActiveSession(): Promise { - const activeSession = this.getActiveSession(); + const activeSession = this.chatService.getActiveSession(); if (!activeSession) { return; } return await this.taskContextService.summarize(activeSession); } diff --git a/packages/ai-chat/src/common/chat-service.ts b/packages/ai-chat/src/common/chat-service.ts index 33b9b08945794..07cdcf11af816 100644 --- a/packages/ai-chat/src/common/chat-service.ts +++ b/packages/ai-chat/src/common/chat-service.ts @@ -126,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( @@ -210,6 +211,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; From 46f9225a9b9cd9853d9eddfc53a0fdeb02c6bc1d Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 23 Apr 2025 09:27:58 -0700 Subject: [PATCH 20/28] reorganize context adding on completion --- .../src/browser/ai-chat-ui-contribution.ts | 1 + .../src/browser/chat-view-contribution.ts | 1 - .../chat-view-language-contribution.ts | 34 ++++--------- .../browser/ai-chat-frontend-contribution.ts | 49 +++++++++++++++++++ .../src/browser/ai-chat-frontend-module.ts | 5 +- .../file-chat-variable-contribution.ts | 31 +++++++----- .../task-context-variable-contribution.ts | 6 +++ 7 files changed, 90 insertions(+), 37 deletions(-) create mode 100644 packages/ai-chat/src/browser/ai-chat-frontend-contribution.ts 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 6cfdafe56a801..f444e2b6cb25b 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 @@ -43,6 +43,7 @@ export class AIChatContribution extends AbstractViewContribution @inject(TaskContextService) protected readonly taskContextService: TaskContextService; + protected static readonly RENAME_CHAT_BUTTON: QuickInputButton = { iconClass: 'codicon-edit', tooltip: nls.localize('theia/ai/chat-ui/renameChat', 'Rename Chat'), 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 99a494be8faaf..0968303c34035 100644 --- a/packages/ai-chat-ui/src/browser/chat-view-contribution.ts +++ b/packages/ai-chat-ui/src/browser/chat-view-contribution.ts @@ -142,7 +142,6 @@ export class ChatViewMenuContribution implements MenuContribution, CommandContri commandId: CommonCommands.PASTE.id }); } - } function hasEditorAsFirstArg(args: unknown[]): args is [MonacoEditor, ...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/src/browser/ai-chat-frontend-contribution.ts b/packages/ai-chat/src/browser/ai-chat-frontend-contribution.ts new file mode 100644 index 0000000000000..03232395cb151 --- /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 f31cf9991bec1..483e04101f819 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 } 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 { @@ -50,6 +50,7 @@ import { TaskContextVariableContribution } from './task-context-variable-contrib 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); @@ -126,4 +127,6 @@ export default new ContainerModule(bind => { 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/file-chat-variable-contribution.ts b/packages/ai-chat/src/browser/file-chat-variable-contribution.ts index f25d650227c71..6bbcf1a6f2a02 100644 --- a/packages/ai-chat/src/browser/file-chat-variable-contribution.ts +++ b/packages/ai-chat/src/browser/file-chat-variable-contribution.ts @@ -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 { @@ -83,17 +84,25 @@ export class FileChatVariableContribution implements FrontendVariableContributio .filter(FileQuickPickItem.is) // only show files with highlights, if the user started typing to filter down the results .filter(p => !userInput || 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: 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}`, - })) + .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-variable-contribution.ts b/packages/ai-chat/src/browser/task-context-variable-contribution.ts index a22e64cc0325d..f0ae5e8449799 100644 --- a/packages/ai-chat/src/browser/task-context-variable-contribution.ts +++ b/packages/ai-chat/src/browser/task-context-variable-contribution.ts @@ -22,6 +22,7 @@ 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 { @@ -57,6 +58,11 @@ export class TaskContextVariableContribution implements FrontendVariableContribu 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] + } })); } From 36f3b5efbcb29e91605c01b121fb60a7488542c0 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 23 Apr 2025 10:29:04 -0700 Subject: [PATCH 21/28] Update prompt and move to MIT --- .../browser/ai-chat-frontend-contribution.ts | 8 +++--- .../src/browser/change-set-variable.ts | 12 +++++---- .../src/browser/task-context-service.ts | 5 ++-- .../chat-session-summary-agent-prompt.ts | 27 +++++++++++++++++++ .../src/common/chat-session-summary-agent.ts | 12 +-------- .../common/coder-replace-prompt-template.ts | 2 -- 6 files changed, 42 insertions(+), 24 deletions(-) create mode 100644 packages/ai-chat/src/common/chat-session-summary-agent-prompt.ts diff --git a/packages/ai-chat/src/browser/ai-chat-frontend-contribution.ts b/packages/ai-chat/src/browser/ai-chat-frontend-contribution.ts index 03232395cb151..d5ec8d07f1068 100644 --- a/packages/ai-chat/src/browser/ai-chat-frontend-contribution.ts +++ b/packages/ai-chat/src/browser/ai-chat-frontend-contribution.ts @@ -14,10 +14,10 @@ // 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"; +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', 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/task-context-service.ts b/packages/ai-chat/src/browser/task-context-service.ts index 1d06eb1f2fb46..a0280e23aad4c 100644 --- a/packages/ai-chat/src/browser/task-context-service.ts +++ b/packages/ai-chat/src/browser/task-context-service.ts @@ -17,9 +17,10 @@ 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 { CHAT_SESSION_SUMMARY_PROMPT, ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; +import { ChatSessionSummaryAgent } from '../common/chat-session-summary-agent'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { PromptService } from '@theia/ai-core'; +import { CHAT_SESSION_SUMMARY_PROMPT } from '../common/chat-session-summary-agent-prompt'; export interface SummaryMetadata { label: string; @@ -105,7 +106,7 @@ export class TaskContextService { protected async getLlmSummary(session: ChatSession, promptId: string = CHAT_SESSION_SUMMARY_PROMPT.id, agent: ChatAgent = this.summaryAgent): Promise { const model = new MutableChatModel(ChatAgentLocation.Panel); - const prompt = await this.promptService.getPrompt(promptId || CHAT_SESSION_SUMMARY_PROMPT.id); + 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); model['_requests'] = messages; 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..0baa4dd7c1f5c --- /dev/null +++ b/packages/ai-chat/src/common/chat-session-summary-agent-prompt.ts @@ -0,0 +1,27 @@ +/* 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: `{{!-- 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 index 21059d34dfeee..3825a033a7e2b 100644 --- a/packages/ai-chat/src/common/chat-session-summary-agent.ts +++ b/packages/ai-chat/src/common/chat-session-summary-agent.ts @@ -20,17 +20,7 @@ import { } from '@theia/ai-core'; import { injectable } from '@theia/core/shared/inversify'; import { AbstractStreamParsingChatAgent, ChatAgent } from './chat-agents'; - -export const CHAT_SESSION_SUMMARY_PROMPT = { - id: 'chat-session-summary-prompt', - template: '{{!-- 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.', -}; +import { CHAT_SESSION_SUMMARY_PROMPT } from './chat-session-summary-agent-prompt'; @injectable() export class ChatSessionSummaryAgent extends AbstractStreamParsingChatAgent implements ChatAgent { 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 e594774e4baab..0d584f7075aef 100644 --- a/packages/ai-ide/src/common/coder-replace-prompt-template.ts +++ b/packages/ai-ide/src/common/coder-replace-prompt-template.ts @@ -58,8 +58,6 @@ The following files have been provided for additional context. Some of them may 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}} From a2528e219459da8801f4bb74d3e30b6fb4879ede Mon Sep 17 00:00:00 2001 From: colin-grant-work Date: Wed, 23 Apr 2025 18:12:24 -0700 Subject: [PATCH 22/28] Update packages/ai-chat/src/common/chat-session-summary-agent-prompt.ts Co-authored-by: Jonas Helming --- .../ai-chat/src/common/chat-session-summary-agent-prompt.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 0baa4dd7c1f5c..c97a663094084 100644 --- a/packages/ai-chat/src/common/chat-session-summary-agent-prompt.ts +++ b/packages/ai-chat/src/common/chat-session-summary-agent-prompt.ts @@ -12,7 +12,8 @@ import { CHANGE_SET_SUMMARY_VARIABLE_ID } from './context-variables'; export const CHAT_SESSION_SUMMARY_PROMPT = { id: 'chat-session-summary-prompt', - template: `{{!-- Made improvements or adaptations to this prompt template? We\'d love for you to share it with the community! Contribute back here: ' + + 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, \ From 3c173166770cefce995d850ee88e9b08f5bfdff7 Mon Sep 17 00:00:00 2001 From: Colin Grant Date: Wed, 23 Apr 2025 18:29:24 -0700 Subject: [PATCH 23/28] Ensure current session available when suggestions requested --- packages/ai-chat/src/common/chat-service.ts | 2 +- packages/ai-ide/src/browser/coder-agent.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ai-chat/src/common/chat-service.ts b/packages/ai-chat/src/common/chat-service.ts index 07cdcf11af816..e1f791a49cdc8 100644 --- a/packages/ai-chat/src/common/chat-service.ts +++ b/packages/ai-chat/src/common/chat-service.ts @@ -191,10 +191,10 @@ export class ChatServiceImpl implements ChatService { isActive: true, pinnedAgent }; - if (pinnedAgent) { pinnedAgent.suggest?.(session); } this._sessions.push(session); this.setActiveSession(session.id, options); this.onSessionEventEmitter.fire({ type: 'created', sessionId: session.id }); + if (pinnedAgent) { pinnedAgent.suggest?.(session); } return session; } diff --git a/packages/ai-ide/src/browser/coder-agent.ts b/packages/ai-ide/src/browser/coder-agent.ts index 9c22ebee2989b..13ce527253fac 100644 --- a/packages/ai-ide/src/browser/coder-agent.ts +++ b/packages/ai-ide/src/browser/coder-agent.ts @@ -46,8 +46,9 @@ export class CoderAgent extends AbstractStreamParsingChatAgent { this.suggest(request); } async suggest(context: ChatSession | ChatRequestModel): Promise { - const model = ChatRequestModel.is(context) ? context.session : context.model; - const session = this.chatService.getSessions().find(candidate => candidate.model.id === model.id); + 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([ From 3a54a202a3e486713107df0b01db9b239bbd1be8 Mon Sep 17 00:00:00 2001 From: Jonas Helming Date: Thu, 24 Apr 2025 15:13:48 +0200 Subject: [PATCH 24/28] Allow summarizing Architect sessions for Coder fixed #15511 --- .../ai-ide/src/browser/architect-agent.ts | 24 ++++++-- .../ai-ide/src/browser/frontend-module.ts | 4 ++ .../summarize-session-command-contribution.ts | 57 +++++++++++++++++++ .../src/common/architect-prompt-template.ts | 31 +++++++++- .../src/common/summarize-session-commands.ts | 22 +++++++ 5 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 packages/ai-ide/src/browser/summarize-session-command-contribution.ts create mode 100644 packages/ai-ide/src/common/summarize-session-commands.ts diff --git a/packages/ai-ide/src/browser/architect-agent.ts b/packages/ai-ide/src/browser/architect-agent.ts index e28bee68c64b0..8d006dff6dc06 100644 --- a/packages/ai-ide/src/browser/architect-agent.ts +++ b/packages/ai-ide/src/browser/architect-agent.ts @@ -13,15 +13,18 @@ // // 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 { AbstractStreamParsingChatAgent, ChatRequestModel, ChatService, ChatSession, MutableChatModel, MutableChatRequestModel } from '@theia/ai-chat/lib/common'; import { LanguageModelRequirement } from '@theia/ai-core'; -import { injectable } from '@theia/core/shared/inversify'; -import { architectPromptTemplate } from '../common/architect-prompt-template'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { architectPromptTemplate, architectTaskSummaryPromptTemplate } from '../common/architect-prompt-template'; import { FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID } from '../common/workspace-functions'; import { nls } from '@theia/core'; +import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering'; +import { AI_SUMMARIZE_SESSION_AS_TASK_FOR_CODER } from '../common/summarize-session-commands'; @injectable() export class ArchitectAgent extends AbstractStreamParsingChatAgent { + @inject(ChatService) protected readonly chatService: ChatService; name = 'Architect'; id = 'Architect'; @@ -35,8 +38,21 @@ export class ArchitectAgent extends AbstractStreamParsingChatAgent { 'An AI assistant integrated into Theia IDE, designed to assist software developers. This agent can access the users workspace, it can get a list of all available files \ and folders and retrieve their content. It cannot modify files. It can therefore answer questions about the current project, project files and source code in the \ workspace, such as how to build the project, where to put source code, where to find specific code or configurations, etc.'); - override promptTemplates = [architectPromptTemplate]; + override promptTemplates = [architectPromptTemplate, architectTaskSummaryPromptTemplate]; override functions = [GET_WORKSPACE_FILE_LIST_FUNCTION_ID, FILE_CONTENT_FUNCTION_ID]; protected override systemPromptId: string | undefined = architectPromptTemplate.id; + override async invoke(request: MutableChatRequestModel): Promise { + await super.invoke(request); + this.suggest(request); + } + + async suggest(context: ChatSession | ChatRequestModel): Promise { + const model = ChatRequestModel.is(context) ? context.session : context.model; + const session = this.chatService.getSessions().find(candidate => candidate.model.id === model.id); + if (!(model instanceof MutableChatModel) || !session) { return; } + if (!model.isEmpty()) { + model.setSuggestions([new MarkdownStringImpl(`[Summarize this session as a task for Coder](command:${AI_SUMMARIZE_SESSION_AS_TASK_FOR_CODER.id}).`)]); + } + } } diff --git a/packages/ai-ide/src/browser/frontend-module.ts b/packages/ai-ide/src/browser/frontend-module.ts index a8ec68b5966af..280d1f03d61a2 100644 --- a/packages/ai-ide/src/browser/frontend-module.ts +++ b/packages/ai-ide/src/browser/frontend-module.ts @@ -19,6 +19,7 @@ import { ChatAgent, DefaultChatAgentId, FallbackChatAgentId } from '@theia/ai-ch import { Agent, AIVariableContribution, bindToolProvider } from '@theia/ai-core/lib/common'; import { ArchitectAgent } from './architect-agent'; import { CoderAgent } from './coder-agent'; +import { SummarizeSessionCommandContribution } from './summarize-session-command-contribution'; import { FileContentFunction, FileDiagonsticProvider, GetWorkspaceDirectoryStructure, GetWorkspaceFileList, WorkspaceFunctionScope } from './workspace-functions'; import { FrontendApplicationContribution, PreferenceContribution, WidgetFactory, bindViewContribution } from '@theia/core/lib/browser'; import { WorkspacePreferencesSchema } from './workspace-preferences'; @@ -48,6 +49,7 @@ import { AITokenUsageConfigurationWidget } from './ai-configuration/token-usage- 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'; +import { CommandContribution } from '@theia/core'; export default new ContainerModule((bind, _unbind, _isBound, rebind) => { bind(PreferenceContribution).toConstantValue({ schema: WorkspacePreferencesSchema }); @@ -143,4 +145,6 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => { bind(AIVariableContribution).toService(TaskContextSummaryVariableContribution); bind(TaskContextFileStorageService).toSelf().inSingletonScope(); rebind(TaskContextStorageService).toService(TaskContextFileStorageService); + + bind(CommandContribution).to(SummarizeSessionCommandContribution); }); diff --git a/packages/ai-ide/src/browser/summarize-session-command-contribution.ts b/packages/ai-ide/src/browser/summarize-session-command-contribution.ts new file mode 100644 index 0000000000000..057869be4367b --- /dev/null +++ b/packages/ai-ide/src/browser/summarize-session-command-contribution.ts @@ -0,0 +1,57 @@ +// ***************************************************************************** +// Copyright (C) 2025 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { ChatAgentLocation, ChatService } from '@theia/ai-chat/lib/common'; +import { CommandContribution, CommandRegistry, CommandService } from '@theia/core'; +import { injectable, inject } from '@theia/core/shared/inversify'; +import { AI_SUMMARIZE_SESSION_AS_TASK_FOR_CODER } from '../common/summarize-session-commands'; +import { TaskContextService } from '@theia/ai-chat/lib/browser/task-context-service'; +import { CoderAgent } from './coder-agent'; +import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable'; +import { ARCHITECT_TASK_SUMMARY_PROMPT_TEMPLATE_ID } from '../common/architect-prompt-template'; + +@injectable() +export class SummarizeSessionCommandContribution implements CommandContribution { + @inject(ChatService) + protected readonly chatService: ChatService; + + @inject(TaskContextService) + protected readonly taskContextService: TaskContextService; + + @inject(CommandService) + protected readonly commandService: CommandService; + + @inject(CoderAgent) + protected readonly coderAgent: CoderAgent; + + registerCommands(registry: CommandRegistry): void { + registry.registerCommand(AI_SUMMARIZE_SESSION_AS_TASK_FOR_CODER, { + execute: async () => { + const activeSession = this.chatService.getActiveSession(); + + if (!activeSession) { + return; + } + + const summaryId = await this.taskContextService.summarize(activeSession, ARCHITECT_TASK_SUMMARY_PROMPT_TEMPLATE_ID); + + const newSession = this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }, this.coderAgent); + const summaryVariable = { variable: TASK_CONTEXT_VARIABLE, arg: summaryId }; + newSession.model.context.addVariables(summaryVariable); + } + }); + } +} diff --git a/packages/ai-ide/src/common/architect-prompt-template.ts b/packages/ai-ide/src/common/architect-prompt-template.ts index b83dd48fccd08..951f20fa36619 100644 --- a/packages/ai-ide/src/common/architect-prompt-template.ts +++ b/packages/ai-ide/src/common/architect-prompt-template.ts @@ -12,10 +12,12 @@ import { PromptTemplate } from '@theia/ai-core/lib/common'; import { GET_WORKSPACE_FILE_LIST_FUNCTION_ID, FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_DIRECTORY_STRUCTURE_FUNCTION_ID } from './workspace-functions'; import { CONTEXT_FILES_VARIABLE_ID } from './context-variables'; +export const ARCHITECT_TASK_SUMMARY_PROMPT_TEMPLATE_ID = 'architect-task-summary'; + export const architectPromptTemplate = { - id: 'architect-system', - 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: + id: 'architect-system', + template: `{{!-- This prompt is licensed under the MIT License (https://opensource.org/license/mit). +Made improvements or adaptations to this prompt template? Wed love for you to share it with the community! Contribute back here: https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}} # Instructions @@ -44,3 +46,26 @@ Always look at the relevant files to understand your task using the function ~{$ {{prompt:project-info}} ` }; + +export const architectTaskSummaryPromptTemplate: PromptTemplate = { + id: ARCHITECT_TASK_SUMMARY_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 --}} + +Your task is to analyze the current chat session and summarize it to prepare completing the coding task. +Your instructions should be complete, they are used by a coding agent. +Include all necessary information. +Use unique identifiers such as file paths or URIs to artifacts. +Skip irrelevant information, e.g. for discussions, only sum up the final result. + +## Instructions +1. Analyze the conversation carefully. +2. Identify the main coding objective and requirements. +3. Propose a clear approach to implement the requested functionality in task steps. +4. Ask clarifying questions if any part of the task is ambiguous. + +Focus on providing actionable steps and implementation guidance. The coding agent needs practical help with this specific coding task. +`, + variantOf: 'architect-system' +}; diff --git a/packages/ai-ide/src/common/summarize-session-commands.ts b/packages/ai-ide/src/common/summarize-session-commands.ts new file mode 100644 index 0000000000000..e8c919b9bb3d8 --- /dev/null +++ b/packages/ai-ide/src/common/summarize-session-commands.ts @@ -0,0 +1,22 @@ +// ***************************************************************************** +// Copyright (C) 2025 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Command } from '@theia/core'; + +export const AI_SUMMARIZE_SESSION_AS_TASK_FOR_CODER: Command = { + id: 'ai-chat:summarize-session-as-task-for-coder', + label: 'Summarize Session as Task for Coder' +}; From 70690c0ddc83c9412716b3c232e6c1e0f5a85435 Mon Sep 17 00:00:00 2001 From: Jonas Helming Date: Thu, 24 Apr 2025 16:12:57 +0200 Subject: [PATCH 25/28] Update packages/ai-ide/src/common/architect-prompt-template.ts Co-authored-by: colin-grant-work --- packages/ai-ide/src/common/architect-prompt-template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-ide/src/common/architect-prompt-template.ts b/packages/ai-ide/src/common/architect-prompt-template.ts index 951f20fa36619..a500491731088 100644 --- a/packages/ai-ide/src/common/architect-prompt-template.ts +++ b/packages/ai-ide/src/common/architect-prompt-template.ts @@ -17,7 +17,7 @@ export const ARCHITECT_TASK_SUMMARY_PROMPT_TEMPLATE_ID = 'architect-task-summary export const architectPromptTemplate = { id: 'architect-system', template: `{{!-- This prompt is licensed under the MIT License (https://opensource.org/license/mit). -Made improvements or adaptations to this prompt template? Wed love for you to share it with the community! Contribute back here: +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 --}} # Instructions From 98a1eefbb7253e58ee4740dfdaf60fa72b9e6028 Mon Sep 17 00:00:00 2001 From: Jonas Helming Date: Thu, 24 Apr 2025 16:13:27 +0200 Subject: [PATCH 26/28] Update packages/ai-ide/src/common/architect-prompt-template.ts Co-authored-by: colin-grant-work --- packages/ai-ide/src/common/architect-prompt-template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-ide/src/common/architect-prompt-template.ts b/packages/ai-ide/src/common/architect-prompt-template.ts index a500491731088..d344e1fa67fb2 100644 --- a/packages/ai-ide/src/common/architect-prompt-template.ts +++ b/packages/ai-ide/src/common/architect-prompt-template.ts @@ -53,7 +53,7 @@ export const architectTaskSummaryPromptTemplate: PromptTemplate = { 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 --}} -Your task is to analyze the current chat session and summarize it to prepare completing the coding task. +Your task is to analyze the current chat session and summarize it to prepare to complete the coding task. Your instructions should be complete, they are used by a coding agent. Include all necessary information. Use unique identifiers such as file paths or URIs to artifacts. From a50df419613d24f9bfbfcb723b7044e912c12a3d Mon Sep 17 00:00:00 2001 From: Jonas Helming Date: Thu, 24 Apr 2025 16:13:38 +0200 Subject: [PATCH 27/28] Update packages/ai-ide/src/common/architect-prompt-template.ts Co-authored-by: colin-grant-work --- packages/ai-ide/src/common/architect-prompt-template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-ide/src/common/architect-prompt-template.ts b/packages/ai-ide/src/common/architect-prompt-template.ts index d344e1fa67fb2..bad6249fd4ca7 100644 --- a/packages/ai-ide/src/common/architect-prompt-template.ts +++ b/packages/ai-ide/src/common/architect-prompt-template.ts @@ -54,7 +54,7 @@ Made improvements or adaptations to this prompt template? We'd love for you to s https://github.com/eclipse-theia/theia/discussions/new?category=prompt-template-contribution --}} Your task is to analyze the current chat session and summarize it to prepare to complete the coding task. -Your instructions should be complete, they are used by a coding agent. +Your instructions should be complete. They are used by a coding agent. Include all necessary information. Use unique identifiers such as file paths or URIs to artifacts. Skip irrelevant information, e.g. for discussions, only sum up the final result. From 80208a5089ab14386f7471f1a8d06dcbbdde84f2 Mon Sep 17 00:00:00 2001 From: Jonas Helming Date: Thu, 24 Apr 2025 16:13:50 +0200 Subject: [PATCH 28/28] Update packages/ai-ide/src/common/architect-prompt-template.ts Co-authored-by: colin-grant-work --- packages/ai-ide/src/common/architect-prompt-template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai-ide/src/common/architect-prompt-template.ts b/packages/ai-ide/src/common/architect-prompt-template.ts index bad6249fd4ca7..14fd867122322 100644 --- a/packages/ai-ide/src/common/architect-prompt-template.ts +++ b/packages/ai-ide/src/common/architect-prompt-template.ts @@ -63,7 +63,7 @@ Skip irrelevant information, e.g. for discussions, only sum up the final result. 1. Analyze the conversation carefully. 2. Identify the main coding objective and requirements. 3. Propose a clear approach to implement the requested functionality in task steps. -4. Ask clarifying questions if any part of the task is ambiguous. +4. If any part of the task is ambiguous, note the ambiguity so that it can be clarified later. Focus on providing actionable steps and implementation guidance. The coding agent needs practical help with this specific coding task. `,