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 c2231c64734aa..396bcb7331a06 100644 --- a/packages/ai-chat/src/browser/ai-chat-frontend-module.ts +++ b/packages/ai-chat/src/browser/ai-chat-frontend-module.ts @@ -14,9 +14,9 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Agent, AIVariableContribution } from '@theia/ai-core/lib/common'; +import { Agent, AgentService, AIVariableContribution } from '@theia/ai-core/lib/common'; import { bindContributionProvider } from '@theia/core'; -import { PreferenceContribution } from '@theia/core/lib/browser'; +import { FrontendApplicationContribution, PreferenceContribution } from '@theia/core/lib/browser'; import { ContainerModule } from '@theia/core/shared/inversify'; import { ChatAgent, @@ -27,13 +27,16 @@ import { ChatService, DefaultChatAgentId } from '../common'; +import { ChatAgentsVariableContribution } from '../common/chat-agents-variable-contribution'; import { CommandChatAgent } from '../common/command-chat-agents'; +import { CustomChatAgent } from '../common/custom-chat-agent'; import { OrchestratorChatAgent, OrchestratorChatAgentId } from '../common/orchestrator-chat-agent'; +import { DefaultResponseContentFactory, DefaultResponseContentMatcherProvider, ResponseContentMatcherProvider } from '../common/response-content-matcher'; import { UniversalChatAgent } from '../common/universal-chat-agent'; import { aiChatPreferences } from './ai-chat-preferences'; -import { ChatAgentsVariableContribution } from '../common/chat-agents-variable-contribution'; +import { AICustomAgentsFrontendApplicationContribution } from './custom-agent-frontend-application-contribution'; import { FrontendChatServiceImpl } from './frontend-chat-service'; -import { DefaultResponseContentMatcherProvider, DefaultResponseContentFactory, ResponseContentMatcherProvider } from '../common/response-content-matcher'; +import { CustomAgentFactory } from './custom-agent-factory'; export default new ContainerModule(bind => { bindContributionProvider(bind, Agent); @@ -69,4 +72,22 @@ export default new ContainerModule(bind => { bind(ChatAgent).toService(CommandChatAgent); bind(PreferenceContribution).toConstantValue({ schema: aiChatPreferences }); + + bind(CustomChatAgent).toSelf(); + bind(CustomAgentFactory).toFactory( + ctx => (id: string, name: string, description: string, prompt: string, defaultLLM: string) => { + const agent = ctx.container.get(CustomChatAgent); + agent.id = id; + agent.name = name; + agent.description = description; + agent.prompt = prompt; + agent.languageModelRequirements = [{ + purpose: 'chat', + identifier: defaultLLM, + }]; + ctx.container.get(ChatAgentService).registerChatAgent(agent); + ctx.container.get(AgentService).registerAgent(agent); + return agent; + }); + bind(FrontendApplicationContribution).to(AICustomAgentsFrontendApplicationContribution).inSingletonScope(); }); diff --git a/packages/ai-chat/src/browser/custom-agent-factory.ts b/packages/ai-chat/src/browser/custom-agent-factory.ts new file mode 100644 index 0000000000000..9fe67f88e723e --- /dev/null +++ b/packages/ai-chat/src/browser/custom-agent-factory.ts @@ -0,0 +1,20 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { CustomChatAgent } from '../common'; + +export const CustomAgentFactory = Symbol('CustomAgentFactory'); +export type CustomAgentFactory = (id: string, name: string, description: string, prompt: string, defaultLLM: string) => CustomChatAgent; diff --git a/packages/ai-chat/src/browser/custom-agent-frontend-application-contribution.ts b/packages/ai-chat/src/browser/custom-agent-frontend-application-contribution.ts new file mode 100644 index 0000000000000..4c67a14ab508c --- /dev/null +++ b/packages/ai-chat/src/browser/custom-agent-frontend-application-contribution.ts @@ -0,0 +1,73 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { AgentService, CustomAgentDescription, PromptCustomizationService } from '@theia/ai-core'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { ChatAgentService } from '../common'; +import { CustomAgentFactory } from './custom-agent-factory'; + +@injectable() +export class AICustomAgentsFrontendApplicationContribution implements FrontendApplicationContribution { + @inject(CustomAgentFactory) + protected readonly customAgentFactory: CustomAgentFactory; + + @inject(PromptCustomizationService) + protected readonly customizationService: PromptCustomizationService; + + @inject(AgentService) + private readonly agentService: AgentService; + + @inject(ChatAgentService) + private readonly chatAgentService: ChatAgentService; + + private knownCustomAgents: Map = new Map(); + onStart(): void { + this.customizationService?.getCustomAgents().then(customAgents => { + customAgents.forEach(agent => { + this.customAgentFactory(agent.id, agent.name, agent.description, agent.prompt, agent.defaultLLM); + this.knownCustomAgents.set(agent.id, agent); + }); + }).catch(e => { + console.error('Failed to load custom agents', e); + }); + this.customizationService?.onDidChangeCustomAgents(() => { + this.customizationService?.getCustomAgents().then(customAgents => { + const customAgentsToAdd = customAgents.filter(agent => + !this.knownCustomAgents.has(agent.id) || !CustomAgentDescription.equals(this.knownCustomAgents.get(agent.id)!, agent)); + const customAgentIdsToRemove = [...this.knownCustomAgents.values()].filter(agent => + !customAgents.find(a => CustomAgentDescription.equals(a, agent))).map(a => a.id); + + // delete first so we don't have to deal with the case where we add and remove the same agentId + customAgentIdsToRemove.forEach(id => { + this.chatAgentService.unregisterChatAgent(id); + this.agentService.unregisterAgent(id); + this.knownCustomAgents.delete(id); + }); + customAgentsToAdd + .forEach(agent => { + this.customAgentFactory(agent.id, agent.name, agent.description, agent.prompt, agent.defaultLLM); + this.knownCustomAgents.set(agent.id, agent); + }); + }).catch(e => { + console.error('Failed to load custom agents', e); + }); + }); + } + + onStop(): void { + } +} diff --git a/packages/ai-chat/src/common/chat-agent-service.ts b/packages/ai-chat/src/common/chat-agent-service.ts index 53704d427cfd0..7c01541d44be1 100644 --- a/packages/ai-chat/src/common/chat-agent-service.ts +++ b/packages/ai-chat/src/common/chat-agent-service.ts @@ -41,6 +41,18 @@ export interface ChatAgentService { * Returns all agents, including disabled ones. */ getAllAgents(): ChatAgent[]; + + /** + * Allows to register a chat agent programmatically. + * @param agent the agent to register + */ + registerChatAgent(agent: ChatAgent): void; + + /** + * Allows to unregister a chat agent programmatically. + * @param agentId the agent id to unregister + */ + unregisterChatAgent(agentId: string): void; } @injectable() export class ChatAgentServiceImpl implements ChatAgentService { @@ -65,6 +77,9 @@ export class ChatAgentServiceImpl implements ChatAgentService { registerChatAgent(agent: ChatAgent): void { this._agents.push(agent); } + unregisterChatAgent(agentId: string): void { + this._agents = this._agents.filter(a => a.id !== agentId); + } getAgent(id: string): ChatAgent | undefined { if (!this._agentIsEnabled(id)) { diff --git a/packages/ai-chat/src/common/custom-chat-agent.ts b/packages/ai-chat/src/common/custom-chat-agent.ts new file mode 100644 index 0000000000000..52743d654dba7 --- /dev/null +++ b/packages/ai-chat/src/common/custom-chat-agent.ts @@ -0,0 +1,44 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { AgentSpecificVariables, PromptTemplate } from '@theia/ai-core'; +import { AbstractStreamParsingChatAgent, ChatAgent, SystemMessageDescription } from './chat-agents'; +import { injectable } from '@theia/core/shared/inversify'; + +@injectable() +export class CustomChatAgent + extends AbstractStreamParsingChatAgent + implements ChatAgent { + name: string; + description: string; + readonly variables: string[] = []; + readonly functions: string[] = []; + readonly promptTemplates: PromptTemplate[] = []; + readonly agentSpecificVariables: AgentSpecificVariables[] = []; + + constructor( + ) { + super('CustomChatAgent', [{ purpose: 'chat' }], 'chat'); + } + protected override async getSystemMessageDescription(): Promise { + const resolvedPrompt = await this.promptService.getPrompt(`${this.name}_prompt`); + return resolvedPrompt ? SystemMessageDescription.fromResolvedPromptTemplate(resolvedPrompt) : undefined; + } + + set prompt(prompt: string) { + this.promptTemplates.push({ id: `${this.name}_prompt`, template: prompt }); + } +} diff --git a/packages/ai-chat/src/common/index.ts b/packages/ai-chat/src/common/index.ts index 7fdd58621cc6b..cf160ddcadf10 100644 --- a/packages/ai-chat/src/common/index.ts +++ b/packages/ai-chat/src/common/index.ts @@ -13,12 +13,13 @@ // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -export * from './chat-agent-service'; export * from './chat-agents'; +export * from './chat-agent-service'; export * from './chat-model'; -export * from './parsed-chat-request'; export * from './chat-request-parser'; export * from './chat-service'; export * from './command-chat-agents'; -export * from './universal-chat-agent'; +export * from './custom-chat-agent'; +export * from './parsed-chat-request'; export * from './orchestrator-chat-agent'; +export * from './universal-chat-agent'; diff --git a/packages/ai-core/package.json b/packages/ai-core/package.json index e40cc0906d1a3..88a834aa8ab06 100644 --- a/packages/ai-core/package.json +++ b/packages/ai-core/package.json @@ -11,6 +11,8 @@ "@theia/output": "1.54.0", "@theia/variable-resolver": "1.54.0", "@theia/workspace": "1.54.0", + "@types/js-yaml": "^4.0.9", + "js-yaml": "^4.1.0", "minimatch": "^5.1.0", "tslib": "^2.6.2" }, diff --git a/packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx b/packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx index 43b23b8c9060a..9a8798e584986 100644 --- a/packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx +++ b/packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx @@ -87,6 +87,7 @@ export class AIAgentConfigurationWidget extends ReactWidget { this.aiSettingsService.onDidChange(() => this.update()); this.aiConfigurationSelectionService.onDidAgentChange(() => this.update()); + this.agentService.onDidChangeAgents(() => this.update()); this.update(); } @@ -100,6 +101,9 @@ export class AIAgentConfigurationWidget extends ReactWidget { )} +
+ +
{this.renderAgentDetails()} @@ -205,6 +209,10 @@ export class AIAgentConfigurationWidget extends ReactWidget { this.aiConfigurationSelectionService.selectConfigurationTab(AIVariableConfigurationWidget.ID); } + protected addCustomAgent(): void { + this.promptCustomizationService.openCustomAgentYaml(); + } + protected setActiveAgent(agent: Agent): void { this.aiConfigurationSelectionService.setActiveAgent(agent); this.update(); diff --git a/packages/ai-core/src/browser/frontend-prompt-customization-service.ts b/packages/ai-core/src/browser/frontend-prompt-customization-service.ts index aff47785cb315..11be74b482834 100644 --- a/packages/ai-core/src/browser/frontend-prompt-customization-service.ts +++ b/packages/ai-core/src/browser/frontend-prompt-customization-service.ts @@ -17,13 +17,22 @@ import { DisposableCollection, URI, Event, Emitter } from '@theia/core'; import { OpenerService } from '@theia/core/lib/browser'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; -import { PromptCustomizationService, PromptTemplate } from '../common'; +import { PromptCustomizationService, PromptTemplate, CustomAgentDescription } from '../common'; import { BinaryBuffer } from '@theia/core/lib/common/buffer'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileChangesEvent } from '@theia/filesystem/lib/common/files'; import { AICorePreferences, PREFERENCE_NAME_PROMPT_TEMPLATES } from './ai-core-preferences'; import { AgentService } from '../common/agent-service'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import { load, dump } from 'js-yaml'; + +const templateEntry = { + id: 'my_agent', + name: 'My Agent', + description: 'This is an example agent. Please adapt the properties to fit your needs.', + prompt: 'You are an example agent. Be nice and helpful to the user.', + defaultLLM: 'openai/gpt-4o' +}; @injectable() export class FrontendPromptCustomizationServiceImpl implements PromptCustomizationService { @@ -51,6 +60,9 @@ export class FrontendPromptCustomizationServiceImpl implements PromptCustomizati private readonly onDidChangePromptEmitter = new Emitter(); readonly onDidChangePrompt: Event = this.onDidChangePromptEmitter.event; + private readonly onDidChangeCustomAgentsEmitter = new Emitter(); + readonly onDidChangeCustomAgents = this.onDidChangeCustomAgentsEmitter.event; + @postConstruct() protected init(): void { this.preferences.onPreferenceChanged(event => { @@ -72,6 +84,9 @@ export class FrontendPromptCustomizationServiceImpl implements PromptCustomizati this.toDispose.push(this.fileService.watch(templateURI, { recursive: true, excludes: [] })); this.toDispose.push(this.fileService.onDidFilesChange(async (event: FileChangesEvent) => { + if (event.changes.some(change => change.resource.toString().endsWith('customAgents.yml'))) { + this.onDidChangeCustomAgentsEmitter.fire(); + } // check deleted templates for (const deletedFile of event.getDeleted()) { if (this.trackedTemplateURIs.has(deletedFile.resource.toString())) { @@ -103,6 +118,7 @@ export class FrontendPromptCustomizationServiceImpl implements PromptCustomizati })); + this.onDidChangeCustomAgentsEmitter.fire(); const stat = await this.fileService.resolve(templateURI); if (stat.children === undefined) { return; @@ -194,4 +210,47 @@ export class FrontendPromptCustomizationServiceImpl implements PromptCustomizati return undefined; } + async getCustomAgents(): Promise { + const customAgentYamlUri = (await this.getTemplatesDirectoryURI()).resolve('customAgents.yml'); + const yamlExists = await this.fileService.exists(customAgentYamlUri); + if (!yamlExists) { + return []; + } + const filecontent = await this.fileService.read(customAgentYamlUri, { encoding: 'utf-8' }); + try { + const doc = load(filecontent.value); + if (!Array.isArray(doc) || !doc.every(entry => CustomAgentDescription.is(entry))) { + console.debug('Invalid customAgents.yml file content'); + return []; + } + const readAgents = doc as CustomAgentDescription[]; + // make sure all agents are unique (id and name) + const uniqueAgentIds = new Set(); + const uniqueAgens: CustomAgentDescription[] = []; + readAgents.forEach(agent => { + if (uniqueAgentIds.has(agent.id)) { + return; + } + uniqueAgentIds.add(agent.id); + uniqueAgens.push(agent); + }); + return uniqueAgens; + } catch (e) { + console.debug(e.message, e); + return []; + } + } + + async openCustomAgentYaml(): Promise { + const customAgentYamlUri = (await this.getTemplatesDirectoryURI()).resolve('customAgents.yml'); + const content = dump([templateEntry]); + if (! await this.fileService.exists(customAgentYamlUri)) { + await this.fileService.createFile(customAgentYamlUri, BinaryBuffer.fromString(content)); + } else { + const fileContent = (await this.fileService.readFile(customAgentYamlUri)).value; + await this.fileService.writeFile(customAgentYamlUri, BinaryBuffer.concat([fileContent, BinaryBuffer.fromString(content)])); + } + const openHandler = await this.openerService.getOpener(customAgentYamlUri); + openHandler.open(customAgentYamlUri); + } } diff --git a/packages/ai-core/src/browser/style/index.css b/packages/ai-core/src/browser/style/index.css index b325058b20541..36cdad9c19221 100644 --- a/packages/ai-core/src/browser/style/index.css +++ b/packages/ai-core/src/browser/style/index.css @@ -88,3 +88,8 @@ border-radius: calc(var(--theia-ui-padding) * 2 / 3); background: hsla(0, 0%, 68%, 0.31); } + +.configuration-agents-add { + margin-top: 3em; + margin-left: 0; +} diff --git a/packages/ai-core/src/common/agent-service.ts b/packages/ai-core/src/common/agent-service.ts index 1038864bf81e3..7bb5b0f01a57a 100644 --- a/packages/ai-core/src/common/agent-service.ts +++ b/packages/ai-core/src/common/agent-service.ts @@ -14,7 +14,7 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { inject, injectable, named, optional, postConstruct } from '@theia/core/shared/inversify'; -import { ContributionProvider } from '@theia/core'; +import { ContributionProvider, Emitter, Event } from '@theia/core'; import { Agent } from './agent'; import { AISettingsService } from './settings-service'; @@ -48,6 +48,24 @@ export interface AgentService { * @return true if the agent is enabled, false otherwise. */ isEnabled(agentId: string): boolean; + + /** + * Allows to register an agent programmatically. + * @param agent the agent to register + */ + registerAgent(agent: Agent): void; + + /** + * Allows to unregister an agent programmatically. + * @param agentId the agent id to unregister + */ + unregisterAgent(agentId: string): void; + + /** + * Emitted when the list of agents changes. + * This can be used to update the UI when agents are added or removed. + */ + onDidChangeAgents: Event; } @injectable() @@ -63,6 +81,9 @@ export class AgentServiceImpl implements AgentService { protected _agents: Agent[] = []; + private readonly onDidChangeAgentsEmitter = new Emitter(); + readonly onDidChangeAgents = this.onDidChangeAgentsEmitter.event; + @postConstruct() protected init(): void { this.aiSettingsService?.getSettings().then(settings => { @@ -82,6 +103,12 @@ export class AgentServiceImpl implements AgentService { registerAgent(agent: Agent): void { this._agents.push(agent); + this.onDidChangeAgentsEmitter.fire(); + } + + unregisterAgent(agentId: string): void { + this._agents = this._agents.filter(a => a.id !== agentId); + this.onDidChangeAgentsEmitter.fire(); } getAgents(): Agent[] { diff --git a/packages/ai-core/src/common/prompt-service.ts b/packages/ai-core/src/common/prompt-service.ts index 8bc55bedebd8b..44d8fb0622d66 100644 --- a/packages/ai-core/src/common/prompt-service.ts +++ b/packages/ai-core/src/common/prompt-service.ts @@ -69,6 +69,30 @@ export interface PromptService { getAllPrompts(): PromptMap; } +export interface CustomAgentDescription { + id: string; + name: string; + description: string; + prompt: string; + defaultLLM: string; +} +export namespace CustomAgentDescription { + export function is(entry: unknown): entry is CustomAgentDescription { + // eslint-disable-next-line no-null/no-null + return typeof entry === 'object' && entry !== null + && 'id' in entry && typeof entry.id === 'string' + && 'name' in entry && typeof entry.name === 'string' + && 'description' in entry && typeof entry.description === 'string' + && 'prompt' in entry + && typeof entry.prompt === 'string' + && 'defaultLLM' in entry + && typeof entry.defaultLLM === 'string'; + } + export function equals(a: CustomAgentDescription, b: CustomAgentDescription): boolean { + return a.id === b.id && a.name === b.name && a.description === b.description && a.prompt === b.prompt && a.defaultLLM === b.defaultLLM; + } +} + export const PromptCustomizationService = Symbol('PromptCustomizationService'); export interface PromptCustomizationService { /** @@ -109,6 +133,22 @@ export interface PromptCustomizationService { * Event which is fired when the prompt template is changed. */ readonly onDidChangePrompt: Event; + + /** + * Return all custom agents. + * @returns all custom agents + */ + getCustomAgents(): Promise; + + /** + * Event which is fired when custom agents are modified. + */ + readonly onDidChangeCustomAgents: Event; + + /** + * Open the custom agent yaml file. + */ + openCustomAgentYaml(): void; } @injectable() diff --git a/yarn.lock b/yarn.lock index fa8459e47d8b4..e3ca193777616 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2083,6 +2083,11 @@ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== +"@types/js-yaml@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + "@types/jsdom@^21.1.7": version "21.1.7" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.7.tgz#9edcb09e0b07ce876e7833922d3274149c898cfa"