diff --git a/examples/browser-only/package.json b/examples/browser-only/package.json index 087fd5c6548a3..3fca029a5260a 100644 --- a/examples/browser-only/package.json +++ b/examples/browser-only/package.json @@ -15,6 +15,12 @@ } }, "dependencies": { + "@theia/ai-chat": "1.52.0", + "@theia/ai-chat-ui": "1.52.0", + "@theia/ai-code-completion": "1.52.0", + "@theia/ai-core": "1.52.0", + "@theia/ai-history": "1.52.0", + "@theia/ai-openai": "1.52.0", "@theia/api-samples": "1.52.0", "@theia/bulk-edit": "1.52.0", "@theia/callhierarchy": "1.52.0", diff --git a/examples/browser-only/tsconfig.json b/examples/browser-only/tsconfig.json index d4bcfc14426b9..1036273d24792 100644 --- a/examples/browser-only/tsconfig.json +++ b/examples/browser-only/tsconfig.json @@ -8,6 +8,24 @@ { "path": "../../dev-packages/cli" }, + { + "path": "../../packages/ai-chat" + }, + { + "path": "../../packages/ai-chat-ui" + }, + { + "path": "../../packages/ai-code-completion" + }, + { + "path": "../../packages/ai-core" + }, + { + "path": "../../packages/ai-history" + }, + { + "path": "../../packages/ai-openai" + }, { "path": "../../packages/bulk-edit" }, diff --git a/examples/browser/package.json b/examples/browser/package.json index 5a938e845cb07..ffde1e2618129 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -20,6 +20,14 @@ } }, "dependencies": { + "@theia/ai-chat": "1.52.0", + "@theia/ai-chat-ui": "1.52.0", + "@theia/ai-code-completion": "1.52.0", + "@theia/ai-core": "1.52.0", + "@theia/ai-history": "1.52.0", + "@theia/ai-openai": "1.52.0", + "@theia/ai-terminal": "1.52.0", + "@theia/ai-workspace-agent": "1.52.0", "@theia/api-provider-sample": "1.52.0", "@theia/api-samples": "1.52.0", "@theia/bulk-edit": "1.52.0", diff --git a/examples/browser/tsconfig.json b/examples/browser/tsconfig.json index c04673f8d70a7..050c1c74b25fd 100644 --- a/examples/browser/tsconfig.json +++ b/examples/browser/tsconfig.json @@ -8,6 +8,30 @@ { "path": "../../dev-packages/cli" }, + { + "path": "../../packages/ai-chat" + }, + { + "path": "../../packages/ai-chat-ui" + }, + { + "path": "../../packages/ai-code-completion" + }, + { + "path": "../../packages/ai-core" + }, + { + "path": "../../packages/ai-history" + }, + { + "path": "../../packages/ai-openai" + }, + { + "path": "../../packages/ai-terminal" + }, + { + "path": "../../packages/ai-workspace-agent" + }, { "path": "../../packages/bulk-edit" }, diff --git a/examples/electron/package.json b/examples/electron/package.json index 44c941c225780..05d7a65664324 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -26,6 +26,14 @@ } }, "dependencies": { + "@theia/ai-chat": "1.52.0", + "@theia/ai-chat-ui": "1.52.0", + "@theia/ai-code-completion": "1.52.0", + "@theia/ai-core": "1.52.0", + "@theia/ai-history": "1.52.0", + "@theia/ai-openai": "1.52.0", + "@theia/ai-terminal": "1.52.0", + "@theia/ai-workspace-agent": "1.52.0", "@theia/api-provider-sample": "1.52.0", "@theia/api-samples": "1.52.0", "@theia/bulk-edit": "1.52.0", diff --git a/examples/electron/tsconfig.json b/examples/electron/tsconfig.json index 91edb2ac8dc55..4b30d5f367b37 100644 --- a/examples/electron/tsconfig.json +++ b/examples/electron/tsconfig.json @@ -11,6 +11,30 @@ { "path": "../../dev-packages/cli" }, + { + "path": "../../packages/ai-chat" + }, + { + "path": "../../packages/ai-chat-ui" + }, + { + "path": "../../packages/ai-code-completion" + }, + { + "path": "../../packages/ai-core" + }, + { + "path": "../../packages/ai-history" + }, + { + "path": "../../packages/ai-openai" + }, + { + "path": "../../packages/ai-terminal" + }, + { + "path": "../../packages/ai-workspace-agent" + }, { "path": "../../packages/bulk-edit" }, diff --git a/packages/ai-chat-ui/.eslintrc.js b/packages/ai-chat-ui/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-chat-ui/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-chat-ui/README.md b/packages/ai-chat-ui/README.md new file mode 100644 index 0000000000000..3638d69df5491 --- /dev/null +++ b/packages/ai-chat-ui/README.md @@ -0,0 +1,32 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Chat UI EXTENSION

+ +
+ +
+ +## Description + +The `@theia/ai-chat-ui` extension contributes the `AI Chat` view.\ +The `AI Chat view` can be used to easily communicate with a language model. + +It is based on `@theia/ai-chat`. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-chat-ui/package.json b/packages/ai-chat-ui/package.json new file mode 100644 index 0000000000000..e1ff19241244e --- /dev/null +++ b/packages/ai-chat-ui/package.json @@ -0,0 +1,58 @@ +{ + "name": "@theia/ai-chat-ui", + "version": "1.52.0", + "description": "Theia - AI Chat UI Extension", + "dependencies": { + "@theia/ai-core": "1.52.0", + "@theia/ai-chat": "1.52.0", + "@theia/core": "1.52.0", + "@theia/editor": "1.52.0", + "@theia/filesystem": "1.52.0", + "@theia/monaco": "1.52.0", + "@theia/monaco-editor-core": "1.83.101", + "@theia/editor-preview": "1.52.0", + "@theia/workspace": "1.52.0", + "minimatch": "^5.1.0", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/aichat-ui-frontend-module", + "secondaryWindow": "lib/browser/aichat-ui-frontend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.52.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} \ No newline at end of file diff --git a/packages/ai-chat-ui/src/browser/ai-chat-command-contribution.ts b/packages/ai-chat-ui/src/browser/ai-chat-command-contribution.ts new file mode 100644 index 0000000000000..4f8f81ef4829b --- /dev/null +++ b/packages/ai-chat-ui/src/browser/ai-chat-command-contribution.ts @@ -0,0 +1,41 @@ +// ***************************************************************************** +// 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 { COMMAND_CHAT_RESPONSE_COMMAND } from '@theia/ai-chat/lib/common'; +import { Command, CommandContribution, CommandRegistry } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; + +export interface AIChatCommandArguments { + command: Command; + handler?: (...commandArgs: unknown[]) => Promise; + arguments?: unknown[]; +} + +@injectable() +export class AIChatCommandContribution implements CommandContribution { + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(COMMAND_CHAT_RESPONSE_COMMAND, { + execute: async (arg: AIChatCommandArguments) => { + if (arg.handler) { + arg.handler(); + } else { + console.error(`No handle available which is necessary when using the default command '${COMMAND_CHAT_RESPONSE_COMMAND.id}'.`); + } + } + }); + } +} diff --git a/packages/ai-chat-ui/src/browser/aichat-ui-contribution.ts b/packages/ai-chat-ui/src/browser/aichat-ui-contribution.ts new file mode 100644 index 0000000000000..eac286660b79b --- /dev/null +++ b/packages/ai-chat-ui/src/browser/aichat-ui-contribution.ts @@ -0,0 +1,182 @@ +// ***************************************************************************** +// 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { CommandRegistry, 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 { 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'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { SecondaryWindowHandler } from '@theia/core/lib/browser/secondary-window-handler'; + +export const AI_CHAT_TOGGLE_COMMAND_ID = 'aiChat:toggle'; + +@injectable() +export class AIChatContribution extends AbstractViewContribution implements TabBarToolbarContribution { + + @inject(ChatService) + protected readonly chatService: ChatService; + @inject(QuickInputService) + protected readonly quickInputService: QuickInputService; + + protected readonly removeChatButton: QuickInputButton = { + iconClass: 'codicon-remove-close', + tooltip: 'Remove Chat', + }; + + @inject(SecondaryWindowHandler) + protected readonly secondaryWindowHandler: SecondaryWindowHandler; + + constructor() { + super({ + widgetId: ChatViewWidget.ID, + widgetName: ChatViewWidget.LABEL, + defaultWidgetOptions: { + area: 'left', + rank: 100 + }, + toggleCommandId: AI_CHAT_TOGGLE_COMMAND_ID, + toggleKeybinding: 'ctrlcmd+shift+e' + }); + } + + override registerCommands(registry: CommandRegistry): void { + super.registerCommands(registry); + registry.registerCommand(ChatCommands.LOCK__WIDGET, { + isEnabled: widget => this.withWidget(widget, chatWidget => !chatWidget.isLocked), + isVisible: widget => this.withWidget(widget, chatWidget => !chatWidget.isLocked), + execute: widget => this.withWidget(widget, chatWidget => { + chatWidget.lock(); + return true; + }) + }); + registry.registerCommand(ChatCommands.UNLOCK__WIDGET, { + isEnabled: widget => this.withWidget(widget, chatWidget => chatWidget.isLocked), + isVisible: widget => this.withWidget(widget, chatWidget => chatWidget.isLocked), + execute: widget => this.withWidget(widget, chatWidget => { + chatWidget.unlock(); + return true; + }) + }); + registry.registerCommand(ChatCommands.OPEN_AICHAT_VIEW, { + execute: () => this.openView({ activate: true }), + }); + registry.registerCommand(AI_CHAT_NEW_CHAT_WINDOW_COMMAND, { + execute: () => this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }), + isEnabled: widget => this.withWidget(widget, () => true), + isVisible: widget => this.withWidget(widget, () => true), + }); + 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) + }); + registry.registerCommand(ChatCommands.EXTRACT_CHAT_VIEW, { + isEnabled: widget => this.withWidget(widget, this.canExtractChatView.bind(this)), + isVisible: widget => this.withWidget(widget, this.canExtractChatView.bind(this)), + execute: widget => this.withWidget(widget, chatWidget => { + this.extractChatView(chatWidget); + return true; + }) + }); + } + + registerToolbarItems(registry: TabBarToolbarRegistry): void { + registry.registerItem({ + id: AI_CHAT_NEW_CHAT_WINDOW_COMMAND.id, + command: AI_CHAT_NEW_CHAT_WINDOW_COMMAND.id, + tooltip: 'New Chat', + isVisible: widget => this.isChatViewWidget(widget) + }); + registry.registerItem({ + id: AI_CHAT_SHOW_CHATS_COMMAND.id, + command: AI_CHAT_SHOW_CHATS_COMMAND.id, + tooltip: 'Show Chats...', + isVisible: widget => this.isChatViewWidget(widget), + }); + } + + protected isChatViewWidget(widget?: Widget): boolean { + return !!widget && ChatViewWidget.ID === widget.id; + } + + protected async selectChat(sessionId?: string): Promise { + let activeSessionId = sessionId; + + if (!activeSessionId) { + const item = await this.askForChatSession(); + if (item === undefined) { + return; + } + activeSessionId = item.id; + } + + this.chatService.setActiveSession(activeSessionId!, { focus: true }); + } + + protected askForChatSession(): Promise { + const getItems = () => + this.chatService.getSessions().filter(session => !session.isActive).map(session => ({ + label: session.title ?? 'New Chat', + id: session.id, + buttons: [this.removeChatButton] + })).reverse(); + + const defer = new Deferred(); + const quickPick = this.quickInputService.createQuickPick(); + quickPick.placeholder = 'Select chat'; + quickPick.canSelectMany = false; + quickPick.items = getItems(); + + quickPick.onDidTriggerItemButton(async context => { + this.chatService.removeSession(context.item.id!); + quickPick.items = getItems(); + if (this.chatService.getSessions().length <= 1) { + quickPick.hide(); + } + }); + + quickPick.onDidAccept(() => { + const selectedItem = quickPick.selectedItems[0]; + defer.resolve(selectedItem); + quickPick.hide(); + }); + + quickPick.onDidHide(() => defer.resolve(undefined)); + + quickPick.show(); + + return defer.promise; + } + + protected withWidget( + widget: Widget | undefined = this.tryGetWidget(), + predicate: (output: ChatViewWidget) => boolean = () => true + ): boolean | false { + return widget instanceof ChatViewWidget ? predicate(widget) : false; + } + + protected extractChatView(chatView: ChatViewWidget): void { + this.secondaryWindowHandler.moveWidgetToSecondaryWindow(chatView); + } + + canExtractChatView(chatView: ChatViewWidget): boolean { + return !chatView.secondaryWindow; + } +} diff --git a/packages/ai-chat-ui/src/browser/aichat-ui-frontend-module.ts b/packages/ai-chat-ui/src/browser/aichat-ui-frontend-module.ts new file mode 100644 index 0000000000000..60ae50b751371 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/aichat-ui-frontend-module.ts @@ -0,0 +1,106 @@ +// ***************************************************************************** +// 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 { bindContributionProvider, CommandContribution, MenuContribution } from '@theia/core'; +import { bindViewContribution, FrontendApplicationContribution, WidgetFactory, } from '@theia/core/lib/browser'; +import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { ContainerModule, interfaces } from '@theia/core/shared/inversify'; +import { EditorManager } from '@theia/editor/lib/browser'; +import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider'; +import '../../src/browser/style/index.css'; +import { AIChatCommandContribution } from './ai-chat-command-contribution'; +import { AIChatContribution } from './aichat-ui-contribution'; +import { ChatInputWidget } from './chat-input-widget'; +import { CodePartRenderer, CommandPartRenderer, HorizontalLayoutPartRenderer, MarkdownPartRenderer, ErrorPartRenderer, ToolCallPartRenderer } from './chat-response-renderer'; +import { + AIEditorManager, AIEditorSelectionResolver, + GitHubSelectionResolver, TextFragmentSelectionResolver, TypeDocSymbolSelectionResolver +} from './chat-response-renderer/ai-editor-manager'; +import { AIMonacoEditorProvider } from './chat-response-renderer/ai-monaco-editor-provider'; +import { createChatViewTreeWidget } from './chat-tree-view'; +import { ChatViewTreeWidget } from './chat-tree-view/chat-view-tree-widget'; +import { ChatViewLanguageContribution } from './chat-view-language-contribution'; +import { ChatViewMenuContribution } from './chat-view-contribution'; +import { ChatViewWidget } from './chat-view-widget'; +import { ChatViewWidgetToolbarContribution } from './chat-view-widget-toolbar-contribution'; +import { ChatResponsePartRenderer } from './types'; + +export default new ContainerModule((bind, _ubind, _isBound, rebind) => { + bindViewContribution(bind, AIChatContribution); + bind(TabBarToolbarContribution).toService(AIChatContribution); + + bindContributionProvider(bind, ChatResponsePartRenderer); + + bindChatViewWidget(bind); + + bind(ChatInputWidget).toSelf(); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ + id: ChatInputWidget.ID, + createWidget: () => container.get(ChatInputWidget) + })).inSingletonScope(); + + bind(ChatViewTreeWidget).toDynamicValue(ctx => + createChatViewTreeWidget(ctx.container) + ); + bind(WidgetFactory).toDynamicValue(({ container }) => ({ + id: ChatViewTreeWidget.ID, + createWidget: () => container.get(ChatViewTreeWidget) + })).inSingletonScope(); + + bind(ChatResponsePartRenderer).to(HorizontalLayoutPartRenderer).inSingletonScope(); + bind(ChatResponsePartRenderer).to(ErrorPartRenderer).inSingletonScope(); + bind(ChatResponsePartRenderer).to(MarkdownPartRenderer).inSingletonScope(); + bind(ChatResponsePartRenderer).to(CodePartRenderer).inSingletonScope(); + bind(ChatResponsePartRenderer).to(CommandPartRenderer).inSingletonScope(); + bind(ChatResponsePartRenderer).to(ToolCallPartRenderer).inSingletonScope(); + bind(ChatResponsePartRenderer).to(ErrorPartRenderer).inSingletonScope(); + bind(CommandContribution).to(AIChatCommandContribution); + [CommandContribution, MenuContribution].forEach(serviceIdentifier => + bind(serviceIdentifier).to(ChatViewMenuContribution).inSingletonScope() + ); + + bind(AIEditorManager).toSelf().inSingletonScope(); + rebind(EditorManager).toService(AIEditorManager); + + bindContributionProvider(bind, AIEditorSelectionResolver); + bind(AIEditorSelectionResolver).to(GitHubSelectionResolver).inSingletonScope(); + bind(AIEditorSelectionResolver).to(TypeDocSymbolSelectionResolver).inSingletonScope(); + bind(AIEditorSelectionResolver).to(TextFragmentSelectionResolver).inSingletonScope(); + + bind(ChatViewWidgetToolbarContribution).toSelf().inSingletonScope(); + bind(TabBarToolbarContribution).toService(ChatViewWidgetToolbarContribution); + + bind(AIMonacoEditorProvider).toSelf().inSingletonScope(); + rebind(MonacoEditorProvider).toService(AIMonacoEditorProvider); + + bind(FrontendApplicationContribution).to(ChatViewLanguageContribution).inSingletonScope(); + +}); + +function bindChatViewWidget(bind: interfaces.Bind): void { + let chatViewWidget: ChatViewWidget | undefined; + bind(ChatViewWidget).toSelf(); + + bind(WidgetFactory).toDynamicValue(context => ({ + id: ChatViewWidget.ID, + createWidget: () => { + if (chatViewWidget?.isDisposed !== false) { + chatViewWidget = context.container.get(ChatViewWidget); + } + return chatViewWidget; + } + })).inSingletonScope(); +} diff --git a/packages/ai-chat-ui/src/browser/chat-input-widget.tsx b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx new file mode 100644 index 0000000000000..6f26d6ea49ebf --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx @@ -0,0 +1,235 @@ +// ***************************************************************************** +// 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 { ChatAgent, ChatAgentService, ChatModel } from '@theia/ai-chat'; +import { UntitledResourceResolver } from '@theia/core'; +import { ContextMenuRenderer, Message, ReactWidget } from '@theia/core/lib/browser'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; +import * as React from '@theia/core/shared/react'; +import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider'; +import { CHAT_VIEW_LANGUAGE_EXTENSION } from './chat-view-language-contribution'; +import { IMouseEvent } from '@theia/monaco-editor-core'; + +type Query = (query: string) => Promise; + +@injectable() +export class ChatInputWidget extends ReactWidget { + public static ID = 'chat-input-widget'; + static readonly CONTEXT_MENU = ['chat-input-context-menu']; + + @inject(ChatAgentService) + protected readonly agentService: ChatAgentService; + + @inject(MonacoEditorProvider) + protected readonly editorProvider: MonacoEditorProvider; + + @inject(UntitledResourceResolver) + protected readonly untitledResourceResolver: UntitledResourceResolver; + + @inject(ContextMenuRenderer) + protected readonly contextMenuRenderer: ContextMenuRenderer; + + protected isEnabled = false; + + private _onQuery: Query; + set onQuery(query: Query) { + this._onQuery = query; + } + private _chatModel: ChatModel; + set chatModel(chatModel: ChatModel) { + this._chatModel = chatModel; + this.update(); + } + + @postConstruct() + protected init(): void { + this.id = ChatInputWidget.ID; + this.title.closable = false; + this.update(); + } + protected override onActivateRequest(msg: Message): void { + super.onActivateRequest(msg); + this.node.focus({ preventScroll: true }); + } + + protected getChatAgents(): ChatAgent[] { + return this.agentService.getAgents(); + } + + protected render(): React.ReactNode { + return ( + + ); + } + + public setEnabled(enabled: boolean): void { + this.isEnabled = enabled; + this.update(); + } + + protected handleContextMenu(event: IMouseEvent): void { + this.contextMenuRenderer.render({ + menuPath: ChatInputWidget.CONTEXT_MENU, + anchor: { x: event.posx, y: event.posy }, + }); + event.preventDefault(); + } + +} + +interface ChatInputProperties { + onQuery: (query: string) => void; + isEnabled?: boolean; + chatModel: ChatModel; + getChatAgents: () => ChatAgent[]; + editorProvider: MonacoEditorProvider; + untitledResourceResolver: UntitledResourceResolver; + contextMenuCallback: (event: IMouseEvent) => void; +} +const ChatInput: React.FunctionComponent = (props: ChatInputProperties) => { + + const [inProgress, setInProgress] = React.useState(false); + // eslint-disable-next-line no-null/no-null + const editorContainerRef = React.useRef(null); + // eslint-disable-next-line no-null/no-null + const placeholderRef = React.useRef(null); + const editorRef = React.useRef(undefined); + const allRequests = props.chatModel.getRequests(); + const lastRequest = allRequests.length === 0 ? undefined : allRequests[allRequests.length - 1]; + const lastResponse = lastRequest?.response; + + const createInputElement = async () => { + const resource = await props.untitledResourceResolver.createUntitledResource('', CHAT_VIEW_LANGUAGE_EXTENSION); + const editor = await props.editorProvider.createInline(resource.uri, editorContainerRef.current!, { + language: CHAT_VIEW_LANGUAGE_EXTENSION, + // Disable code lens, inlay hints and hover support to avoid console errors from other contributions + codeLens: false, + inlayHints: { enabled: 'off' }, + hover: { enabled: false }, + autoSizing: true, + scrollBeyondLastLine: false, + scrollBeyondLastColumn: 0, + minHeight: 1, + fontFamily: 'var(--theia-ui-font-family)', + fontSize: 13, + cursorWidth: 1, + maxHeight: -1, + scrollbar: { horizontal: 'hidden' }, + automaticLayout: true, + lineNumbers: 'off', + lineHeight: 20, + padding: { top: 8 }, + suggest: { + showIcons: true, + showSnippets: false, + showWords: false, + showStatusBar: false, + insertMode: 'replace', + }, + bracketPairColorization: { enabled: false }, + wrappingStrategy: 'advanced', + stickyScroll: { enabled: false }, + }); + + editor.getControl().onDidChangeModelContent(() => { + layout(); + }); + + editor.getControl().onContextMenu(e => + props.contextMenuCallback(e.event) + ); + + editorRef.current = editor; + }; + + React.useEffect(() => { + createInputElement(); + return () => { + if (editorRef.current) { + editorRef.current.dispose(); + } + }; + }, []); + + React.useEffect(() => { + const listener = lastRequest?.response.onDidChange(() => { + if (lastRequest.response.isCanceled || lastRequest.response.isComplete || lastRequest.response.isError) { + setInProgress(false); + } + }); + return () => listener?.dispose(); + }, [lastRequest]); + + function submit(value: string): void { + setInProgress(true); + props.onQuery(value); + if (editorRef.current) { + editorRef.current.document.textEditorModel.setValue(''); + } + }; + + function layout(): void { + if (editorRef.current === undefined) { + return; + } + const hiddenClass = 'hidden'; + const editor = editorRef.current; + if (editor.document.textEditorModel.getValue().length > 0) { + placeholderRef.current?.classList.add(hiddenClass); + } else { + placeholderRef.current?.classList.remove(hiddenClass); + } + } + + const onKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + submit(editorRef.current?.document.textEditorModel.getValue() || ''); + } + }, []); + + return
+
+
+
Enter your question
+
+
+
+ { + inProgress ? { + lastResponse?.cancel(); + setInProgress(false); + }} /> : + submit(editorRef.current?.document.textEditorModel.getValue() || '') : undefined} + style={{ cursor: !props.isEnabled ? 'default' : 'pointer', opacity: !props.isEnabled ? 0.5 : 1 }} + /> + } +
+
; +}; diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/ai-editor-manager.ts b/packages/ai-chat-ui/src/browser/chat-response-renderer/ai-editor-manager.ts new file mode 100644 index 0000000000000..85f0fbeb95824 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/ai-editor-manager.ts @@ -0,0 +1,183 @@ +// ***************************************************************************** +// 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 { CancellationToken, ContributionProvider, Prioritizeable, RecursivePartial, URI } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { EditorOpenerOptions, EditorWidget, Range } from '@theia/editor/lib/browser'; + +import { EditorPreviewManager } from '@theia/editor-preview/lib/browser/editor-preview-manager'; +import { DocumentSymbol } from '@theia/monaco-editor-core/esm/vs/editor/common/languages'; +import { TextModel } from '@theia/monaco-editor-core/esm/vs/editor/common/model/textModel'; +import { ILanguageFeaturesService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures'; +import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices'; +import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; +import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter'; + +/** Regex to match GitHub-style position and range declaration with line (L) and column (C) */ +export const LOCATION_REGEX = /#L(\d+)?(?:C(\d+))?(?:-L(\d+)?(?:C(\d+))?)?$/; + +export const AIEditorSelectionResolver = Symbol('AIEditorSelectionResolver'); +export interface AIEditorSelectionResolver { + /** + * The priority of the resolver. A higher value resolver will be called before others. + */ + priority?: number; + resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise | undefined> +} + +@injectable() +export class GitHubSelectionResolver implements AIEditorSelectionResolver { + priority = 100; + + async resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise | undefined> { + if (!uri) { + return; + } + // We allow the GitHub syntax of selecting a range in markdown 'L1', 'L1-L2' 'L1-C1_L2-C2' (starting at line 1 and column 1) + const match = uri?.toString().match(LOCATION_REGEX); + if (!match) { + return; + } + // we need to adapt the position information from one-based (in GitHub) to zero-based (in Theia) + const startLine = match[1] ? parseInt(match[1], 10) - 1 : undefined; + // if no start column is given, we assume the start of the line + const startColumn = match[2] ? parseInt(match[2], 10) - 1 : 0; + const endLine = match[3] ? parseInt(match[3], 10) - 1 : undefined; + // if no end column is given, we assume the end of the line + const endColumn = match[4] ? parseInt(match[4], 10) - 1 : endLine ? widget.editor.document.getLineMaxColumn(endLine) : undefined; + + return { + start: { line: startLine, character: startColumn }, + end: { line: endLine, character: endColumn } + }; + } +} + +@injectable() +export class TypeDocSymbolSelectionResolver implements AIEditorSelectionResolver { + priority = 50; + + @inject(MonacoToProtocolConverter) protected readonly m2p: MonacoToProtocolConverter; + + async resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise | undefined> { + if (!uri) { + return; + } + const editor = MonacoEditor.get(widget); + const monacoEditor = editor?.getControl(); + if (!monacoEditor) { + return; + } + const symbolPath = this.findSymbolPath(uri); + if (!symbolPath) { + return; + } + const textModel = monacoEditor.getModel() as unknown as TextModel; + if (!textModel) { + return; + } + + // try to find the symbol through the document symbol provider + // support referencing nested symbols by separating a dot path similar to TypeDoc + for (const provider of StandaloneServices.get(ILanguageFeaturesService).documentSymbolProvider.ordered(textModel)) { + const symbols = await provider.provideDocumentSymbols(textModel, CancellationToken.None); + const match = this.findSymbolByPath(symbols ?? [], symbolPath); + if (match) { + return this.m2p.asRange(match.selectionRange); + } + } + } + + protected findSymbolPath(uri: URI): string[] | undefined { + return uri.fragment.split('.'); + } + + protected findSymbolByPath(symbols: DocumentSymbol[], symbolPath: string[]): DocumentSymbol | undefined { + if (!symbols || symbolPath.length === 0) { + return undefined; + } + let matchedSymbol: DocumentSymbol | undefined = undefined; + let currentSymbols = symbols; + for (const part of symbolPath) { + matchedSymbol = currentSymbols.find(symbol => symbol.name === part); + if (!matchedSymbol) { + return undefined; + } + currentSymbols = matchedSymbol.children || []; + } + return matchedSymbol; + } +} + +@injectable() +export class TextFragmentSelectionResolver implements AIEditorSelectionResolver { + async resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise | undefined> { + if (!uri) { + return; + } + const fragment = this.findFragment(uri); + if (!fragment) { + return; + } + const matches = widget.editor.document.findMatches?.({ isRegex: false, matchCase: false, matchWholeWord: false, searchString: fragment }) ?? []; + if (matches.length > 0) { + return { + start: { + line: matches[0].range.start.line - 1, + character: matches[0].range.start.character - 1 + }, + end: { + line: matches[0].range.end.line - 1, + character: matches[0].range.end.character - 1 + } + }; + } + } + + protected findFragment(uri: URI): string | undefined { + return uri.fragment; + } +} + +@injectable() +export class AIEditorManager extends EditorPreviewManager { + @inject(ContributionProvider) @named(AIEditorSelectionResolver) + protected readonly resolvers: ContributionProvider; + + protected override async revealSelection(widget: EditorWidget, options: EditorOpenerOptions = {}, uri?: URI): Promise { + if (!options.selection) { + options.selection = await this.resolveSelection(options, widget, uri); + } + super.revealSelection(widget, options, uri); + } + + protected async resolveSelection(options: EditorOpenerOptions, widget: EditorWidget, uri: URI | undefined): Promise | undefined> { + if (!options.selection) { + const orderedResolvers = Prioritizeable.prioritizeAllSync(this.resolvers.getContributions(), resolver => resolver.priority ?? 1); + for (const linkResolver of orderedResolvers) { + try { + const selection = await linkResolver.value.resolveSelection(widget, options, uri); + if (selection) { + return selection; + } + } catch (error) { + console.error(error); + } + } + } + return undefined; + } +} diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/ai-monaco-editor-provider.ts b/packages/ai-chat-ui/src/browser/chat-response-renderer/ai-monaco-editor-provider.ts new file mode 100644 index 0000000000000..4d4eea2e8c43d --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/ai-monaco-editor-provider.ts @@ -0,0 +1,56 @@ +// ***************************************************************************** +// 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 { MessageService, URI, } from '@theia/core'; +import { WidgetOpenerOptions, open } from '@theia/core/lib/browser'; +import { HttpOpenHandlerOptions } from '@theia/core/lib/browser/http-open-handler'; +import { inject } from '@theia/core/shared/inversify'; +import { Uri } from '@theia/monaco-editor-core'; +import { OpenExternalOptions, OpenInternalOptions } from '@theia/monaco-editor-core/esm/vs/platform/opener/common/opener'; +import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider'; + +export class AIMonacoEditorProvider extends MonacoEditorProvider { + @inject(MessageService) protected readonly messageService: MessageService; + + protected override async interceptOpen(monacoUri: Uri | string, monacoOptions?: OpenInternalOptions | OpenExternalOptions): Promise { + // customized so we can actually inform the user about not being able to open a file + let options = undefined; + if (monacoOptions) { + if ('openToSide' in monacoOptions && monacoOptions.openToSide) { + options = Object.assign(options || {}, { + widgetOptions: { + mode: 'split-right' + } + }); + } + if ('openExternal' in monacoOptions && monacoOptions.openExternal) { + options = Object.assign(options || {}, { + openExternal: true + }); + } + } + const uri = new URI(monacoUri.toString()); + try { + await open(this.openerService, uri, options); + return true; + } catch (error) { + // customization: not only log the error to the console but show to user + const details = error instanceof Error ? ': ' + error.message : ''; + this.messageService.error(`Failed to open the editor for '${uri.toString()}'${details}`, { timeout: 10_000 }); + return false; + } + } +} diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/code-part-renderer.tsx b/packages/ai-chat-ui/src/browser/chat-response-renderer/code-part-renderer.tsx new file mode 100644 index 0000000000000..c8dc2981a78eb --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/code-part-renderer.tsx @@ -0,0 +1,209 @@ +// ***************************************************************************** +// 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 { + ChatResponseContent, + CodeChatResponseContent, + isCodeChatResponseContent, +} from '@theia/ai-chat/lib/common'; +import { UntitledResourceResolver, URI } from '@theia/core'; +import { ContextMenuRenderer, TreeNode } from '@theia/core/lib/browser'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { ReactNode } from '@theia/core/shared/react'; +import { Position } from '@theia/core/shared/vscode-languageserver-protocol'; +import { EditorManager, EditorWidget } from '@theia/editor/lib/browser'; +import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor'; +import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider'; +import { MonacoLanguages } from '@theia/monaco/lib/browser/monaco-languages'; +import { ChatResponsePartRenderer } from '../types'; +import { ChatViewTreeWidget, ResponseNode } from '../chat-tree-view/chat-view-tree-widget'; +import { IMouseEvent } from '@theia/monaco-editor-core'; + +@injectable() +export class CodePartRenderer + implements ChatResponsePartRenderer { + + @inject(ClipboardService) + protected readonly clipboardService: ClipboardService; + @inject(EditorManager) + protected readonly editorManager: EditorManager; + @inject(UntitledResourceResolver) + protected readonly untitledResourceResolver: UntitledResourceResolver; + @inject(MonacoEditorProvider) + protected readonly editorProvider: MonacoEditorProvider; + @inject(MonacoLanguages) + protected readonly languageService: MonacoLanguages; + @inject(ContextMenuRenderer) + protected readonly contextMenuRenderer: ContextMenuRenderer; + + canHandle(response: ChatResponseContent): number { + if (isCodeChatResponseContent(response)) { + return 10; + } + return -1; + } + + render(response: CodeChatResponseContent, parentNode: ResponseNode): ReactNode { + const language = response.language ? this.languageService.getExtension(response.language) : undefined; + + return ( +
+
+
{this.renderTitle(response)}
+
+ + +
+
+
+
+ this.handleContextMenuEvent(parentNode, e, response.code)}> +
+
+ ); + } + + protected renderTitle(response: CodeChatResponseContent): ReactNode { + const uri = response.location?.uri; + const position = response.location?.position; + if (uri && position) { + return {this.getTitle(response.location?.uri, response.language)}; + } + return this.getTitle(response.location?.uri, response.language); + } + + private getTitle(uri: URI | undefined, language: string | undefined): string { + // If there is a URI, use the file name as the title. Otherwise, use the language as the title. + // If there is no language, use a generic fallback title. + return uri?.path?.toString().split('/').pop() ?? language ?? 'Generated Code'; + } + + /** + * Opens a file and moves the cursor to the specified position. + * + * @param uri - The URI of the file to open. + * @param position - The position to move the cursor to, specified as {line, character}. + */ + async openFileAtPosition(uri: URI, position: Position): Promise { + const editorWidget = await this.editorManager.open(uri) as EditorWidget; + if (editorWidget) { + const editor = editorWidget.editor; + editor.revealPosition(position); + editor.focus(); + editor.cursor = position; + } + } + + protected handleContextMenuEvent(node: TreeNode | undefined, event: IMouseEvent, code: string): void { + this.contextMenuRenderer.render({ + menuPath: ChatViewTreeWidget.CONTEXT_MENU, + anchor: { x: event.posx, y: event.posy }, + args: [node, { code }] + }); + event.preventDefault(); + } +} + +const CopyToClipboardButton = (props: { code: string, clipboardService: ClipboardService }) => { + const { code, clipboardService } = props; + const copyCodeToClipboard = React.useCallback(() => { + clipboardService.writeText(code); + }, [code, clipboardService]); + return ; +}; + +const InsertCodeAtCursorButton = (props: { code: string, editorManager: EditorManager }) => { + const { code, editorManager } = props; + const insertCode = React.useCallback(() => { + const editor = editorManager.currentEditor; + if (editor) { + const currentEditor = editor.editor; + const selection = currentEditor.selection; + + // Insert the text at the current cursor position + // If there is a selection, replace the selection with the text + currentEditor.executeEdits([{ + range: { + start: selection.start, + end: selection.end + }, + newText: code + }]); + } + }, [code, editorManager]); + return ; +}; + +/** + * Renders the given code within a Monaco Editor + */ +export const CodeWrapper = (props: { + content: string, + language?: string, + untitledResourceResolver: UntitledResourceResolver, + editorProvider: MonacoEditorProvider, + contextMenuCallback: (e: IMouseEvent) => void +}) => { + // eslint-disable-next-line no-null/no-null + const ref = React.useRef(null); + const editorRef = React.useRef(undefined); + + const createInputElement = async () => { + const resource = await props.untitledResourceResolver.createUntitledResource(undefined, props.language); + const editor = await props.editorProvider.createInline(resource.uri, ref.current!, { + readOnly: true, + autoSizing: true, + scrollBeyondLastLine: false, + scrollBeyondLastColumn: 0, + renderFinalNewline: 'on', + maxHeight: -1, + scrollbar: { vertical: 'hidden', horizontal: 'hidden' }, + codeLens: false, + inlayHints: { enabled: 'off' }, + hover: { enabled: false } + }); + editor.document.textEditorModel.setValue(props.content); + editor.getControl().onContextMenu(e => props.contextMenuCallback(e.event)); + editorRef.current = editor; + }; + + React.useEffect(() => { + createInputElement(); + return () => { + if (editorRef.current) { + editorRef.current.dispose(); + } + }; + }, []); + + React.useEffect(() => { + if (editorRef.current) { + editorRef.current.document.textEditorModel.setValue(props.content); + } + }, [props.content]); + + editorRef.current?.resizeToFit(); + + return
; +}; + diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/command-part-renderer.tsx b/packages/ai-chat-ui/src/browser/chat-response-renderer/command-part-renderer.tsx new file mode 100644 index 0000000000000..320c5bf8fa146 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/command-part-renderer.tsx @@ -0,0 +1,59 @@ +// ***************************************************************************** +// 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 { ChatResponsePartRenderer } from '../types'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { ChatResponseContent, isCommandChatResponseContent, CommandChatResponseContent } from '@theia/ai-chat/lib/common'; +import { ReactNode } from '@theia/core/shared/react'; +import * as React from '@theia/core/shared/react'; +import { CommandRegistry, CommandService } from '@theia/core'; +import { AIChatCommandArguments } from '../ai-chat-command-contribution'; + +@injectable() +export class CommandPartRenderer implements ChatResponsePartRenderer { + @inject(CommandService) private commandService: CommandService; + @inject(CommandRegistry) private commandRegistry: CommandRegistry; + canHandle(response: ChatResponseContent): number { + if (isCommandChatResponseContent(response)) { + return 10; + } + return -1; + } + render(response: CommandChatResponseContent): ReactNode { + const label = + response.command.label ?? + response.command.id + .split('-') + .map(s => s[0].toUpperCase() + s.substring(1)) + .join(' '); + const arg: AIChatCommandArguments = { + command: response.command, + handler: response.commandHandler, + arguments: response.arguments + }; + const isCommandEnabled = this.commandRegistry.isEnabled(arg.command.id); + return ( + isCommandEnabled ? ( + + ) : ( +
The command has the id "{arg.command.id}" but it is not executable globally from the Chat window.
+ ) + ); + } + private onCommand(arg: AIChatCommandArguments): void { + this.commandService.executeCommand(arg.command.id, ...(arg.arguments ?? [])).catch(e => { console.error(e); }); + } +} diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/error-part-renderer.tsx b/packages/ai-chat-ui/src/browser/chat-response-renderer/error-part-renderer.tsx new file mode 100644 index 0000000000000..4ef09cd1377f3 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/error-part-renderer.tsx @@ -0,0 +1,35 @@ +// ***************************************************************************** +// 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 { ChatResponsePartRenderer } from '../types'; +import { injectable } from '@theia/core/shared/inversify'; +import { ChatResponseContent, ErrorResponseContent, isErrorChatResponseContent } from '@theia/ai-chat/lib/common'; +import { ReactNode } from '@theia/core/shared/react'; +import * as React from '@theia/core/shared/react'; + +@injectable() +export class ErrorPartRenderer implements ChatResponsePartRenderer { + canHandle(response: ChatResponseContent): number { + if (isErrorChatResponseContent(response)) { + return 10; + } + return -1; + } + render(response: ErrorResponseContent): ReactNode { + return
{response.error.message}
; + } + +} diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/horizontal-layout-part-renderer.tsx b/packages/ai-chat-ui/src/browser/chat-response-renderer/horizontal-layout-part-renderer.tsx new file mode 100644 index 0000000000000..42d9d3c936f54 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/horizontal-layout-part-renderer.tsx @@ -0,0 +1,61 @@ +// ***************************************************************************** +// 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 { ChatResponsePartRenderer } from '../types'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { + BaseChatResponseContent, + ChatResponseContent, + HorizontalLayoutChatResponseContent, + isHorizontalLayoutChatResponseContent, +} from '@theia/ai-chat/lib/common'; +import { ReactNode } from '@theia/core/shared/react'; +import * as React from '@theia/core/shared/react'; +import { ContributionProvider } from '@theia/core'; +import { ResponseNode } from '../chat-tree-view/chat-view-tree-widget'; + +@injectable() +export class HorizontalLayoutPartRenderer + implements ChatResponsePartRenderer { + @inject(ContributionProvider) + @named(ChatResponsePartRenderer) + protected readonly chatResponsePartRenderers: ContributionProvider< + ChatResponsePartRenderer + >; + + canHandle(response: ChatResponseContent): number { + if (isHorizontalLayoutChatResponseContent(response)) { + return 10; + } + return -1; + } + render(response: HorizontalLayoutChatResponseContent, parentNode: ResponseNode): ReactNode { + const contributions = this.chatResponsePartRenderers.getContributions(); + return ( +
+ {response.content.map(content => { + const renderer = contributions + .map(c => ({ + prio: c.canHandle(content), + renderer: c, + })) + .sort((a, b) => b.prio - a.prio)[0].renderer; + return renderer.render(content, parentNode); + })} +
+ ); + } +} diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/index.ts b/packages/ai-chat-ui/src/browser/chat-response-renderer/index.ts new file mode 100644 index 0000000000000..e3515242a6598 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/index.ts @@ -0,0 +1,24 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +export * from './ai-editor-manager'; +export * from './ai-monaco-editor-provider'; +export * from './code-part-renderer'; +export * from './command-part-renderer'; +export * from './error-part-renderer'; +export * from './horizontal-layout-part-renderer'; +export * from './markdown-part-renderer'; +export * from './text-part-renderer'; +export * from './toolcall-part-renderer'; 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 new file mode 100644 index 0000000000000..060d970abf75a --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/markdown-part-renderer.tsx @@ -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 { ChatResponsePartRenderer } from '../types'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { + ChatResponseContent, + InformationalChatResponseContent, + isInformationalChatResponseContent, + isMarkdownChatResponseContent, + MarkdownChatResponseContent +} from '@theia/ai-chat/lib/common'; +import { ReactNode, useEffect, useRef } from '@theia/core/shared/react'; +import * as React from '@theia/core/shared/react'; +import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; +import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer'; + +@injectable() +export class MarkdownPartRenderer implements ChatResponsePartRenderer { + @inject(MarkdownRenderer) private renderer: MarkdownRenderer; + canHandle(response: ChatResponseContent): number { + if (isMarkdownChatResponseContent(response)) { + return 10; + } + if (isInformationalChatResponseContent(response)) { + return 10; + } + return -1; + } + private renderMarkdown(md: MarkdownString): HTMLElement { + return this.renderer.render(md).element; + } + render(response: MarkdownChatResponseContent | InformationalChatResponseContent): ReactNode { + // TODO let the user configure whether they want to see informational content + if (isInformationalChatResponseContent(response)) { + // null is valid in React + // eslint-disable-next-line no-null/no-null + return null; + } + return ; + } + +} + +export const MarkdownWrapper = (props: { data: MarkdownString, renderCallback: (md: MarkdownString) => HTMLElement }) => { + // eslint-disable-next-line no-null/no-null + const ref: React.MutableRefObject = useRef(null); + + useEffect(() => { + const myDomElement = props.renderCallback(props.data); + + while (ref?.current?.firstChild) { + ref.current.removeChild(ref.current.firstChild); + } + + ref?.current?.appendChild(myDomElement); + }, [props.data.value]); + + return
; +}; diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/text-part-renderer.spec.ts b/packages/ai-chat-ui/src/browser/chat-response-renderer/text-part-renderer.spec.ts new file mode 100644 index 0000000000000..e67b0fe0b122a --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/text-part-renderer.spec.ts @@ -0,0 +1,50 @@ +// ***************************************************************************** +// 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 { TextPartRenderer } from './text-part-renderer'; +import { expect } from 'chai'; +import { ChatResponseContent } from '@theia/ai-chat'; + +describe('TextPartRenderer', () => { + + it('accepts all parts', () => { + const renderer = new TextPartRenderer(); + expect(renderer.canHandle({ kind: 'text' })).to.be.greaterThan(0); + expect(renderer.canHandle({ kind: 'code' })).to.be.greaterThan(0); + expect(renderer.canHandle({ kind: 'command' })).to.be.greaterThan(0); + expect(renderer.canHandle({ kind: 'error' })).to.be.greaterThan(0); + expect(renderer.canHandle({ kind: 'horizontal' })).to.be.greaterThan(0); + expect(renderer.canHandle({ kind: 'informational' })).to.be.greaterThan(0); + expect(renderer.canHandle({ kind: 'markdownContent' })).to.be.greaterThan(0); + expect(renderer.canHandle({ kind: 'toolCall' })).to.be.greaterThan(0); + expect(renderer.canHandle(undefined as unknown as ChatResponseContent)).to.be.greaterThan(0); + }); + + it('renders text correctly', () => { + const renderer = new TextPartRenderer(); + const part = { kind: 'text', asString: () => 'Hello, World!' }; + const node = renderer.render(part); + expect(JSON.stringify(node)).to.contain('Hello, World!'); + }); + + it('handles undefined content gracefully', () => { + const renderer = new TextPartRenderer(); + const part = undefined as unknown as ChatResponseContent; + const node = renderer.render(part); + expect(node).to.exist; + }); + +}); diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/text-part-renderer.tsx b/packages/ai-chat-ui/src/browser/chat-response-renderer/text-part-renderer.tsx new file mode 100644 index 0000000000000..6e5ae361d6079 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/text-part-renderer.tsx @@ -0,0 +1,35 @@ +// ***************************************************************************** +// 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 { ChatResponsePartRenderer } from '../types'; +import { injectable } from '@theia/core/shared/inversify'; +import { ChatResponseContent, hasAsString } from '@theia/ai-chat/lib/common'; +import { ReactNode } from '@theia/core/shared/react'; +import * as React from '@theia/core/shared/react'; + +@injectable() +export class TextPartRenderer implements ChatResponsePartRenderer { + canHandle(_reponse: ChatResponseContent): number { + // this is the fallback renderer + return 1; + } + render(response: ChatResponseContent): ReactNode { + if (response && hasAsString(response)) { + return {response.asString()}; + } + return Can't display response, please check your ChatResponsePartRenderers! {JSON.stringify(response)}; + } +} diff --git a/packages/ai-chat-ui/src/browser/chat-response-renderer/toolcall-part-renderer.tsx b/packages/ai-chat-ui/src/browser/chat-response-renderer/toolcall-part-renderer.tsx new file mode 100644 index 0000000000000..65bbcfdbbdf02 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-response-renderer/toolcall-part-renderer.tsx @@ -0,0 +1,49 @@ +// ***************************************************************************** +// 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 { ChatResponsePartRenderer } from '../types'; +import { injectable } from '@theia/core/shared/inversify'; +import { ChatResponseContent, isToolCallChatResponseContent, ToolCallResponseContent } from '@theia/ai-chat/lib/common'; +import { ReactNode } from '@theia/core/shared/react'; +import * as React from '@theia/core/shared/react'; + +@injectable() +export class ToolCallPartRenderer implements ChatResponsePartRenderer { + + canHandle(response: ChatResponseContent): number { + if (isToolCallChatResponseContent(response)) { + return 10; + } + return -1; + } + render(response: ToolCallResponseContent): ReactNode { + return

+ {response.finished ? +
+ Ran {response.name} +

{response.result}

+
+ : Running [{response.name}] + } +

; + + } + +} + +const Spinner = () => ( + +); diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-container.ts b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-container.ts new file mode 100644 index 0000000000000..9550de109ec0a --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-container.ts @@ -0,0 +1,32 @@ +// ***************************************************************************** +// 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 { createTreeContainer, TreeProps } from '@theia/core/lib/browser'; +import { interfaces } from '@theia/core/shared/inversify'; +import { ChatViewTreeWidget } from './chat-view-tree-widget'; + +const CHAT_VIEW_TREE_PROPS = { + multiSelect: false, + search: false, +} as TreeProps; + +export function createChatViewTreeWidget(parent: interfaces.Container): ChatViewTreeWidget { + const child = createTreeContainer(parent, { + props: CHAT_VIEW_TREE_PROPS, + widget: ChatViewTreeWidget, + }); + return child.get(ChatViewTreeWidget); +} diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx new file mode 100644 index 0000000000000..f6158bca7c5ed --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx @@ -0,0 +1,382 @@ +// ***************************************************************************** +// 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 { + BaseChatResponseContent, + ChatAgentService, + ChatModel, + ChatProgressMessage, + ChatRequestModel, + ChatResponseContent, + ChatResponseModel, +} from '@theia/ai-chat'; +import { CommandRegistry, ContributionProvider } from '@theia/core'; +import { + codicon, + CommonCommands, + CompositeTreeNode, + ContextMenuRenderer, + Key, + KeyCode, + NodeProps, + TreeModel, + TreeNode, + TreeProps, + TreeWidget, +} from '@theia/core/lib/browser'; +import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string'; +import { + inject, + injectable, + named, + postConstruct, +} from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; + +import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer'; +import { MarkdownWrapper } from '../chat-response-renderer/markdown-part-renderer'; +import { ChatResponsePartRenderer } from '../types'; + +// TODO Instead of directly operating on the ChatRequestModel we could use an intermediate view model +export interface RequestNode extends TreeNode { + request: ChatRequestModel +} +export const isRequestNode = (node: TreeNode): node is RequestNode => 'request' in node; + +// TODO Instead of directly operating on the ChatResponseModel we could use an intermediate view model +export interface ResponseNode extends TreeNode { + response: ChatResponseModel +} +export const isResponseNode = (node: TreeNode): node is ResponseNode => 'response' in node; + +@injectable() +export class ChatViewTreeWidget extends TreeWidget { + static readonly ID = 'chat-tree-widget'; + static readonly CONTEXT_MENU = ['chat-tree-context-menu']; + + @inject(ContributionProvider) @named(ChatResponsePartRenderer) + protected readonly chatResponsePartRenderers: ContributionProvider>; + + @inject(MarkdownRenderer) + private renderer: MarkdownRenderer; + + @inject(ChatAgentService) + protected chatAgentService: ChatAgentService; + + @inject(CommandRegistry) + private commandRegistry: CommandRegistry; + + protected _shouldScrollToEnd = true; + + protected isEnabled = false; + + set shouldScrollToEnd(shouldScrollToEnd: boolean) { + this._shouldScrollToEnd = shouldScrollToEnd; + this.shouldScrollToRow = this._shouldScrollToEnd; + } + + get shouldScrollToEnd(): boolean { + return this._shouldScrollToEnd; + } + + constructor( + @inject(TreeProps) props: TreeProps, + @inject(TreeModel) model: TreeModel, + @inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer + ) { + super(props, model, contextMenuRenderer); + + this.id = ChatViewTreeWidget.ID; + this.title.closable = false; + + model.root = { + id: 'ChatTree', + name: 'ChatRootNode', + parent: undefined, + visible: false, + children: [], + } as CompositeTreeNode; + } + + @postConstruct() + protected override init(): void { + super.init(); + + this.id = ChatViewTreeWidget.ID + '-treeContainer'; + this.addClass('treeContainer'); + } + + public setEnabled(enabled: boolean): void { + this.isEnabled = enabled; + this.update(); + } + + protected override renderTree(model: TreeModel): React.ReactNode { + if (this.isEnabled) { + return super.renderTree(model); + } + return this.renderDisabledMessage(); + } + + private renderDisabledMessage(): React.ReactNode { + return
+
+
+ πŸš€ Experimental AI Feature Available! +
+

Currently, all AI Features are disabled!

+
+
+

How to Enable Experimental AI Features:

+
+
+

To enable the experimental AI features, please go to   + {this.renderLinkButton('the settings menu', this.doOpenPreferences, this.doOpenPreferencesEnter)} +  and locate the Extensions > ✨ AI Features [Experimental] section.

+
    +
  1. Toggle the switch for 'Ai-features: Enable'.
  2. +
  3. Provide an OpenAI API Key through the 'OpenAI: API Key' setting or by + setting the OPENAI_API_KEY environment variable.
  4. +
+

This will activate the new AI capabilities in the app. Please remember, these features are still in development, so they may change or be unstable. 🚧

+
+ +
+

Currently Supported Views and Features:

+
+
+

Once the experimental AI features are enabled, you can access the following views and features:

+
    +
  • Code Completion
  • +
  • Quick Fixes
  • +
  • Terminal Assistance
  • +
  • {this.renderLinkButton('AI History View', this.doOpenAIHistory, this.doOpenAIHistoryEnter)}
  • +
  • {this.renderLinkButton('AI Configuration View', this.doOpenAIConfiguration, this.doOpenAIConfigurationEnter)}
  • +
+
+
+
+
; + } + + protected doOpenPreferences = () => this.commandRegistry.executeCommand(CommonCommands.OPEN_PREFERENCES.id); + protected doOpenPreferencesEnter = (e: React.KeyboardEvent) => { + if (this.isEnterKey(e)) { + this.doOpenPreferences(); + } + }; + + protected doOpenAIHistory = () => this.commandRegistry.executeCommand('aiHistory:open'); + protected doOpenAIHistoryEnter = (e: React.KeyboardEvent) => { + if (this.isEnterKey(e)) { + this.doOpenAIHistory(); + } + }; + + protected doOpenAIConfiguration = () => this.commandRegistry.executeCommand('aiConfiguration:open'); + protected doOpenAIConfigurationEnter = (e: React.KeyboardEvent) => { + if (this.isEnterKey(e)) { + this.doOpenAIConfiguration(); + } + }; + + private renderLinkButton(title: string, onClickHandler: () => Promise, onKeyDownHandler: (e: React.KeyboardEvent) => void): React.ReactNode { + return onKeyDownHandler(e)}> + {title} + ; + } + + protected isEnterKey(e: React.KeyboardEvent): boolean { + return Key.ENTER.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode; + } + + private mapRequestToNode(request: ChatRequestModel): RequestNode { + return { + id: request.id, + parent: this.model.root as CompositeTreeNode, + request + }; + } + + private mapResponseToNode(response: ChatResponseModel): ResponseNode { + return { + id: response.id, + parent: this.model.root as CompositeTreeNode, + response + }; + } + + /** + * Tracks the handed over ChatModel. + * Tracking multiple chat models will result in a weird UI + */ + public trackChatModel(chatModel: ChatModel): void { + this.recreateModelTree(chatModel); + chatModel.getRequests().forEach(request => { + if (!request.response.isComplete) { + request.response.onDidChange(() => this.scheduleUpdateScrollToRow()); + } + }); + this.toDispose.push( + chatModel.onDidChange(event => { + if (event.kind === 'addRequest') { + this.recreateModelTree(chatModel); + if (!event.request.response.isComplete) { + event.request.response.onDidChange(() => this.scheduleUpdateScrollToRow()); + } + } + }) + ); + } + + protected override getScrollToRow(): number | undefined { + if (this.shouldScrollToEnd) { + return this.rows.size; + } + return super.getScrollToRow(); + } + + private async recreateModelTree(chatModel: ChatModel): Promise { + if (CompositeTreeNode.is(this.model.root)) { + const nodes: TreeNode[] = []; + chatModel.getRequests().forEach(request => { + nodes.push(this.mapRequestToNode(request)); + nodes.push(this.mapResponseToNode(request.response)); + }); + this.model.root.children = nodes; + this.model.refresh(); + } + } + + protected override renderNode( + node: TreeNode, + props: NodeProps + ): React.ReactNode { + if (!TreeNode.isVisible(node)) { + return undefined; + } + if (!(isRequestNode(node) || isResponseNode(node))) { + return super.renderNode(node, props); + } + return +
this.handleContextMenu(node, e)}> + {this.renderAgent(node)} + {this.renderDetail(node)} +
+
; + } + private renderAgent(node: RequestNode | ResponseNode): React.ReactNode { + const inProgress = isResponseNode(node) && !node.response.isComplete && !node.response.isCanceled && !node.response.isError; + return +
+
+

{this.getAgentLabel(node)}

+ {inProgress && Generating} +
+
; + } + private getAgentLabel(node: RequestNode | ResponseNode): string { + if (isRequestNode(node)) { + // TODO find user name + return 'You'; + } + const agent = node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined; + return agent?.name ?? 'AI'; + } + private getAgentIconClassName(node: RequestNode | ResponseNode): string | undefined { + if (isRequestNode(node)) { + return codicon('account'); + } + + const agent = node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined; + return agent?.iconClass ?? codicon('copilot'); + } + + private renderDetail(node: RequestNode | ResponseNode): React.ReactNode { + if (isRequestNode(node)) { + return this.renderChatRequest(node); + } + if (isResponseNode(node)) { + return this.renderChatResponse(node); + }; + } + + private renderChatRequest(node: RequestNode): React.ReactNode { + const text = node.request.request.displayText ?? node.request.request.text; + const markdownString = new MarkdownStringImpl(text, { supportHtml: true, isTrusted: true }); + return ( +
+ { this.renderer.render(markdownString).element} + >} +
+ ); + } + + private renderChatResponse(node: ResponseNode): React.ReactNode { + return ( +
+ {!node.response.isComplete + && node.response.response.content.length === 0 + && node.response.progressMessages.map((c, i) => + + )} + {node.response.response.content.map((c, i) => +
{this.getChatResponsePartRenderer(c, node)}
+ )} +
+ ); + } + + private getChatResponsePartRenderer(content: ChatResponseContent, node: ResponseNode): React.ReactNode { + const contributions = this.chatResponsePartRenderers.getContributions(); + const renderer = contributions.map(c => ({ prio: c.canHandle(content), renderer: c })).sort((a, b) => b.prio - a.prio)[0].renderer; + return renderer.render(content, node); + } + + protected handleContextMenu(node: TreeNode | undefined, event: React.MouseEvent): void { + this.contextMenuRenderer.render({ + menuPath: ChatViewTreeWidget.CONTEXT_MENU, + anchor: { x: event.clientX, y: event.clientY }, + args: [node] + }); + event.preventDefault(); + } +} + +const ProgressMessage = (c: ChatProgressMessage) => ( +
+ {c.content} +
+); + +const Indicator = (progressMessage: ChatProgressMessage) => ( + + {progressMessage.status === 'inProgress' && + + } + {progressMessage.status === 'completed' && + + } + {progressMessage.status === 'failed' && + + } + +); diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/index.ts b/packages/ai-chat-ui/src/browser/chat-tree-view/index.ts new file mode 100644 index 0000000000000..b3a2fd606e01a --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/index.ts @@ -0,0 +1,18 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** + +export * from './chat-view-tree-container'; +export * from './chat-view-tree-widget'; diff --git a/packages/ai-chat-ui/src/browser/chat-view-commands.ts b/packages/ai-chat-ui/src/browser/chat-view-commands.ts new file mode 100644 index 0000000000000..d3e0fdfb337a0 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-view-commands.ts @@ -0,0 +1,57 @@ +// ***************************************************************************** +// 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 { Command, nls } from '@theia/core'; +import { codicon } from '@theia/core/lib/browser'; + +export namespace ChatCommands { + const CHAT_CATEGORY = 'Chat'; + const CHAT_CATEGORY_KEY = nls.getDefaultKey(CHAT_CATEGORY); + + export const LOCK__WIDGET = Command.toLocalizedCommand({ + id: 'chat:widget:lock', + category: CHAT_CATEGORY, + iconClass: codicon('unlock') + }, '', CHAT_CATEGORY_KEY); + + export const UNLOCK__WIDGET = Command.toLocalizedCommand({ + id: 'chat:widget:unlock', + category: CHAT_CATEGORY, + iconClass: codicon('lock') + }, '', CHAT_CATEGORY_KEY); + + export const OPEN_AICHAT_VIEW = Command.toLocalizedCommand({ + id: 'ai-chat:open', + category: CHAT_CATEGORY, + label: 'Open AI Chat view (UI)', + }, '', CHAT_CATEGORY_KEY); + export const EXTRACT_CHAT_VIEW: Command = { + id: 'theia-ai:extract-chat-view', + label: 'Move Chat view into a separate window', + iconClass: codicon('window') + }; + +} + +export const AI_CHAT_NEW_CHAT_WINDOW_COMMAND: Command = { + id: 'ai-chat-ui.new-chat', + iconClass: codicon('add') +}; + +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-contribution.ts b/packages/ai-chat-ui/src/browser/chat-view-contribution.ts new file mode 100644 index 0000000000000..ee861705752b2 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-view-contribution.ts @@ -0,0 +1,158 @@ +// ***************************************************************************** +// 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 { Command, CommandContribution, CommandRegistry, CommandService, isObject, MenuContribution, MenuModelRegistry } from '@theia/core'; +import { CommonCommands, TreeNode } from '@theia/core/lib/browser'; +import { ClipboardService } from '@theia/core/lib/browser/clipboard-service'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { ChatViewTreeWidget, isRequestNode, isResponseNode, RequestNode, ResponseNode } from './chat-tree-view/chat-view-tree-widget'; +import { ChatInputWidget } from './chat-input-widget'; + +export namespace ChatViewCommands { + export const COPY = Command.toDefaultLocalizedCommand({ + id: 'chat.copy', + label: 'Copy' + }); + export const COPY_MESSAGE = Command.toDefaultLocalizedCommand({ + id: 'chat.copy.message', + label: 'Copy Message' + }); + export const COPY_ALL = Command.toDefaultLocalizedCommand({ + id: 'chat.copy.all', + label: 'Copy All' + }); + export const COPY_CODE = Command.toDefaultLocalizedCommand({ + id: 'chat.copy.code', + label: 'Copy Code Block' + }); +} + +@injectable() +export class ChatViewMenuContribution implements MenuContribution, CommandContribution { + + @inject(ClipboardService) + protected readonly clipboardService: ClipboardService; + + @inject(CommandService) + protected readonly commandService: CommandService; + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(ChatViewCommands.COPY, { + execute: (...args: unknown[]) => { + if (window.getSelection()?.type !== 'Range' && containsRequestOrResponseNode(args)) { + this.copyMessage(extractRequestOrResponseNodes(args)); + } else { + this.commandService.executeCommand(CommonCommands.COPY.id); + } + }, + isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args) + }); + commands.registerCommand(ChatViewCommands.COPY_MESSAGE, { + execute: (...args: unknown[]) => { + if (containsRequestOrResponseNode(args)) { + this.copyMessage(extractRequestOrResponseNodes(args)); + } + }, + isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args) + }); + commands.registerCommand(ChatViewCommands.COPY_ALL, { + execute: (...args: unknown[]) => { + if (containsRequestOrResponseNode(args)) { + const parent = extractRequestOrResponseNodes(args).find(arg => arg.parent)?.parent; + const text = parent?.children + .filter(isRequestOrResponseNode) + .map(child => this.getText(child)) + .join('\n\n---\n\n'); + if (text) { + this.clipboardService.writeText(text); + } + } + }, + isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args) + }); + commands.registerCommand(ChatViewCommands.COPY_CODE, { + execute: (...args: unknown[]) => { + if (containsCode(args)) { + const code = args + .filter(isCodeArg) + .map(arg => arg.code) + .join(); + this.clipboardService.writeText(code); + } + }, + isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args) && containsCode(args) + }); + } + + protected copyMessage(args: (RequestNode | ResponseNode)[]): void { + const text = this.getTextAndJoin(args); + this.clipboardService.writeText(text); + } + + protected getTextAndJoin(args: (RequestNode | ResponseNode)[] | undefined): string { + return args !== undefined ? args.map(arg => this.getText(arg)).join() : ''; + } + + protected getText(arg: RequestNode | ResponseNode): string { + if (isRequestNode(arg)) { + return arg.request.request.text; + } else if (isResponseNode(arg)) { + return arg.response.response.asString(); + } + return ''; + } + + registerMenus(menus: MenuModelRegistry): void { + menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], { + commandId: ChatViewCommands.COPY.id + }); + menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], { + commandId: ChatViewCommands.COPY_MESSAGE.id + }); + menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], { + commandId: ChatViewCommands.COPY_ALL.id + }); + menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], { + commandId: ChatViewCommands.COPY_CODE.id + }); + menus.registerMenuAction([...ChatInputWidget.CONTEXT_MENU, '_1'], { + commandId: CommonCommands.COPY.id + }); + menus.registerMenuAction([...ChatInputWidget.CONTEXT_MENU, '_1'], { + commandId: CommonCommands.PASTE.id + }); + } + +} + +function extractRequestOrResponseNodes(args: unknown[]): (RequestNode | ResponseNode)[] { + return args.filter(arg => isRequestOrResponseNode(arg)) as (RequestNode | ResponseNode)[]; +} + +function containsRequestOrResponseNode(args: unknown[]): args is (unknown | RequestNode | ResponseNode)[] { + return extractRequestOrResponseNodes(args).length > 0; +} + +function isRequestOrResponseNode(arg: unknown): arg is RequestNode | ResponseNode { + return TreeNode.is(arg) && (isRequestNode(arg) || isResponseNode(arg)); +} + +function containsCode(args: unknown[]): args is (unknown | { code: string })[] { + return args.filter(arg => isCodeArg(arg)).length > 0; +} + +function isCodeArg(arg: unknown): arg is { code: string } { + return isObject(arg) && 'code' in arg; +} 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 new file mode 100644 index 0000000000000..b6ec0d5e73f6c --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-view-language-contribution.ts @@ -0,0 +1,141 @@ +// ***************************************************************************** +// 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 { inject, injectable, named } from '@theia/core/shared/inversify'; +import { FrontendApplication, FrontendApplicationContribution } from '@theia/core/lib/browser'; +import * as monaco from '@theia/monaco-editor-core'; +import { ContributionProvider, MaybePromise } from '@theia/core'; +import { ProviderResult } from '@theia/monaco-editor-core/esm/vs/editor/common/languages'; +import { ChatAgentService } from '@theia/ai-chat'; +import { AIVariableService } from '@theia/ai-core/lib/common'; +import { ToolProvider } from '@theia/ai-core/lib/common/function-call-registry'; + +export const CHAT_VIEW_LANGUAGE_ID = 'ai-chat-view-language'; +export const CHAT_VIEW_LANGUAGE_EXTENSION = 'aichatviewlanguage'; + +@injectable() +export class ChatViewLanguageContribution implements FrontendApplicationContribution { + + @inject(ChatAgentService) + protected readonly agentService: ChatAgentService; + + @inject(AIVariableService) + protected readonly variableService: AIVariableService; + + @inject(ContributionProvider) + @named(ToolProvider) + private providers: ContributionProvider; + + onStart(_app: FrontendApplication): MaybePromise { + console.log('ChatViewLanguageContribution started'); + monaco.languages.register({ id: CHAT_VIEW_LANGUAGE_ID, extensions: [CHAT_VIEW_LANGUAGE_EXTENSION] }); + + monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, { + triggerCharacters: ['@'], + provideCompletionItems: (model, position, _context, _token): ProviderResult => this.provideAgentCompletions(model, position), + }); + monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, { + triggerCharacters: ['#'], + provideCompletionItems: (model, position, _context, _token): ProviderResult => this.provideVariableCompletions(model, position), + }); + monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, { + triggerCharacters: ['~'], + provideCompletionItems: (model, position, _context, _token): ProviderResult => this.provideToolCompletions(model, position), + }); + } + + getCompletionRange(model: monaco.editor.ITextModel, position: monaco.Position, triggerCharacter: string): monaco.Range | undefined { + // Check if the character before the current position is the trigger character + const lineContent = model.getLineContent(position.lineNumber); + const characterBefore = lineContent[position.column - 2]; // Get the character before the current position + + if (characterBefore !== triggerCharacter) { + // Do not return agent suggestions if the user didn't just type the trigger character + return undefined; + } + + // Calculate the range from the position of the '@' character + const wordInfo = model.getWordUntilPosition(position); + return new monaco.Range( + position.lineNumber, + wordInfo.startColumn, + position.lineNumber, + position.column + ); + } + + private getSuggestions( + model: monaco.editor.ITextModel, + position: monaco.Position, + triggerChar: string, + items: T[], + kind: monaco.languages.CompletionItemKind, + getId: (item: T) => string, + getName: (item: T) => string, + getDescription: (item: T) => string + ): ProviderResult { + const completionRange = this.getCompletionRange(model, position, triggerChar); + if (completionRange === undefined) { + return { suggestions: [] }; + } + const suggestions = items.map(item => ({ + insertText: getId(item), + kind: kind, + label: getName(item), + range: completionRange, + detail: getDescription(item), + })); + return { suggestions }; + } + + provideAgentCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult { + return this.getSuggestions( + model, + position, + '@', + this.agentService.getAgents(), + monaco.languages.CompletionItemKind.Value, + agent => agent.id, + agent => agent.name, + agent => agent.description + ); + } + + provideVariableCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult { + return this.getSuggestions( + model, + position, + '#', + this.variableService.getVariables(), + monaco.languages.CompletionItemKind.Variable, + variable => variable.name, + variable => variable.name, + variable => variable.description + ); + } + + provideToolCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult { + return this.getSuggestions( + model, + position, + '~', + this.providers.getContributions().map(provider => provider.getTool()), + monaco.languages.CompletionItemKind.Function, + tool => tool.id, + tool => tool.name, + tool => tool.description ?? '' + ); + } +} diff --git a/packages/ai-chat-ui/src/browser/chat-view-widget-toolbar-contribution.tsx b/packages/ai-chat-ui/src/browser/chat-view-widget-toolbar-contribution.tsx new file mode 100644 index 0000000000000..e3ac977de9ac5 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-view-widget-toolbar-contribution.tsx @@ -0,0 +1,60 @@ +// ***************************************************************************** +// 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 { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { AIChatContribution } from './aichat-ui-contribution'; +import { Emitter, nls } from '@theia/core'; +import { ChatCommands } from './chat-view-commands'; + +@injectable() +export class ChatViewWidgetToolbarContribution implements TabBarToolbarContribution { + @inject(AIChatContribution) + protected readonly chatContribution: AIChatContribution; + + protected readonly onChatWidgetStateChangedEmitter = new Emitter(); + protected readonly onChatWidgetStateChanged = this.onChatWidgetStateChangedEmitter.event; + + @postConstruct() + protected init(): void { + this.chatContribution.widget.then(widget => { + widget.onStateChanged(() => this.onChatWidgetStateChangedEmitter.fire()); + }); + } + + registerToolbarItems(registry: TabBarToolbarRegistry): void { + registry.registerItem({ + id: ChatCommands.LOCK__WIDGET.id, + command: ChatCommands.LOCK__WIDGET.id, + tooltip: nls.localizeByDefault('Turn Auto Scrolling Off'), + onDidChange: this.onChatWidgetStateChanged, + priority: 2 + }); + registry.registerItem({ + id: ChatCommands.UNLOCK__WIDGET.id, + command: ChatCommands.UNLOCK__WIDGET.id, + tooltip: nls.localizeByDefault('Turn Auto Scrolling On'), + onDidChange: this.onChatWidgetStateChanged, + priority: 2 + }); + registry.registerItem({ + id: ChatCommands.EXTRACT_CHAT_VIEW.id, + command: ChatCommands.EXTRACT_CHAT_VIEW.id, + tooltip: ChatCommands.EXTRACT_CHAT_VIEW.label, + priority: 2 + }); + } +} diff --git a/packages/ai-chat-ui/src/browser/chat-view-widget.tsx b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx new file mode 100644 index 0000000000000..7d54ca4c21121 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx @@ -0,0 +1,185 @@ +// ***************************************************************************** +// 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 { CommandService, deepClone, Emitter, Event, MessageService } from '@theia/core'; +import { ChatRequest, ChatService, ChatSession } from '@theia/ai-chat'; +import { BaseWidget, codicon, ExtractableWidget, 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 { ChatInputWidget } 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'; + +export namespace ChatViewWidget { + export interface State { + locked?: boolean; + } +} + +@injectable() +export class ChatViewWidget extends BaseWidget implements ExtractableWidget, StatefulWidget { + + public static ID = 'chat-view-widget'; + static LABEL = `✨ ${nls.localizeByDefault('Chat')} [Experimental]`; + + @inject(ChatService) + protected chatService: ChatService; + + @inject(MessageService) + protected messageService: MessageService; + + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + @inject(CommandService) + protected readonly commandService: CommandService; + + @inject(AIActivationService) + protected readonly activationService: AIActivationService; + + protected chatSession: ChatSession; + + protected _state: ChatViewWidget.State = { locked: false }; + protected readonly onStateChangedEmitter = new Emitter(); + + secondaryWindow: Window | undefined; + + constructor( + @inject(ChatViewTreeWidget) + readonly treeWidget: ChatViewTreeWidget, + @inject(ChatInputWidget) + readonly inputWidget: ChatInputWidget + ) { + super(); + this.id = ChatViewWidget.ID; + this.title.label = ChatViewWidget.LABEL; + this.title.caption = ChatViewWidget.LABEL; + this.title.iconClass = codicon('comment-discussion'); + this.title.closable = true; + this.node.classList.add('chat-view-widget'); + this.update(); + } + + @postConstruct() + protected init(): void { + this.toDispose.pushAll([ + this.treeWidget, + this.inputWidget, + this.onStateChanged(newState => { + this.treeWidget.shouldScrollToEnd = !newState.locked; + this.update(); + }) + ]); + const layout = this.layout = new PanelLayout(); + + this.treeWidget.node.classList.add('chat-tree-view-widget'); + layout.addWidget(this.treeWidget); + this.inputWidget.node.classList.add('chat-input-widget'); + layout.addWidget(this.inputWidget); + this.chatSession = this.chatService.createSession(); + + this.inputWidget.onQuery = this.onQuery.bind(this); + this.inputWidget.chatModel = this.chatSession.model; + this.treeWidget.trackChatModel(this.chatSession.model); + + this.initListeners(); + + this.inputWidget.setEnabled(this.activationService.isActive); + this.activationService.onDidChangeActiveStatus(change => { + this.treeWidget.setEnabled(change); + this.inputWidget.setEnabled(change); + this.update(); + }); + } + + protected initListeners(): void { + this.toDispose.push( + this.chatService.onActiveSessionChanged(event => { + const session = this.chatService.getSession(event.sessionId); + if (session) { + this.chatSession = session; + this.treeWidget.trackChatModel(this.chatSession.model); + this.inputWidget.chatModel = this.chatSession.model; + if (event.focus) { + this.show(); + } + } else { + console.warn(`Session with ${event.sessionId} not found.`); + } + }) + ); + } + + storeState(): object { + return this.state; + } + + restoreState(oldState: object & Partial): void { + const copy = deepClone(this.state); + if (oldState.locked) { + copy.locked = oldState.locked; + } + this.state = copy; + } + + protected get state(): ChatViewWidget.State { + return this._state; + } + + protected set state(state: ChatViewWidget.State) { + this._state = state; + this.onStateChangedEmitter.fire(this._state); + } + + get onStateChanged(): Event { + return this.onStateChangedEmitter.event; + } + + protected async onQuery(query: string): Promise { + if (query.length === 0) { return; } + + const chatRequest: ChatRequest = { + text: query + }; + + const requestProgress = await this.chatService.sendRequest(this.chatSession.id, chatRequest); + requestProgress?.responseCompleted.then(responseModel => { + if (responseModel.isError) { + this.messageService.error(responseModel.errorObject?.message ?? 'An error occurred druring chat service invocation.'); + } + }); + if (!requestProgress) { + this.messageService.error(`Was not able to send request "${chatRequest.text}" to session ${this.chatSession.id}`); + return; + } + // Tree Widget currently tracks the ChatModel itself. Therefore no notification necessary. + } + + lock(): void { + this.state = { ...deepClone(this.state), locked: true }; + } + + unlock(): void { + this.state = { ...deepClone(this.state), locked: false }; + } + + get isLocked(): boolean { + return !!this.state.locked; + } + + get isExtractable(): boolean { + return true; + } +} diff --git a/packages/ai-chat-ui/src/browser/style/index.css b/packages/ai-chat-ui/src/browser/style/index.css new file mode 100644 index 0000000000000..3f014cb6e9a26 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/style/index.css @@ -0,0 +1,309 @@ +.chat-view-widget { + display: flex; + flex-direction: column; +} + +.chat-tree-view-widget { + flex: 1; +} + +.chat-input-widget > .ps__rail-x, +.chat-input-widget > .ps__rail-y { + display: none !important; +} + +.theia-ChatNode { + cursor: default; + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 20px; + user-select: text; + -webkit-user-select: text; + border-bottom: 1px solid var(--theia-sideBarSectionHeader-border); + overflow-wrap: break-word; +} + +div:last-child > .theia-ChatNode { + border: none; +} + +.theia-ChatNodeHeader { + align-items: center; + display: flex; + gap: 8px; + width: 100%; +} + +.theia-ChatNodeHeader .theia-AgentAvatar { + display: flex; + pointer-events: none; + user-select: none; + font-size: 20px; +} + +.theia-ChatNodeHeader .theia-AgentLabel { + font-size: 13px; + font-weight: 600; + margin: 0; +} + +.theia-ChatNodeHeader .theia-ChatContentInProgress { + color: var(--theia-disabledForeground); +} + +.theia-ChatNodeHeader .theia-ChatContentInProgress-Cancel { + position: absolute; + z-index: 999; + right: 20px; +} + +@keyframes dots { + 0%, + 20% { + content: ""; + } + + 40% { + content: "."; + } + + 60% { + content: ".."; + } + + 80%, + 100% { + content: "..."; + } +} + +.theia-ChatNodeHeader .theia-ChatContentInProgress::after { + content: ""; + animation: dots 1s steps(1, end) infinite; +} + +.theia-ChatNode .codicon { + text-align: left; +} + +.theia-AgentLabel { + font-weight: 600; +} + +.theia-ChatNode .rendered-markdown p { + margin: 0 0 16px; +} + +.theia-ChatNode:last-child .rendered-markdown > :last-child { + margin-bottom: 0; +} + +.theia-ChatNode .rendered-markdown { + line-height: 1.3rem; +} + +.chat-input-widget { + align-items: flex-end; + display: flex; + flex-direction: column; +} + +.theia-ChatInput { + position: relative; + width: 100%; + box-sizing: border-box; + gap: 4px; +} + +.theia-ChatInput-Editor-Box { + margin-bottom: 2px; + padding: 10px; + height: auto; + display: flex; + flex-direction: column; + justify-content: flex-end; + overflow: hidden; +} + +.theia-ChatInput-Editor { + width: 100%; + height: auto; + border: var(--theia-border-width) solid var(--theia-dropdown-border); + border-radius: 4px; + display: flex; + flex-direction: column-reverse; + overflow: hidden; +} + +.theia-ChatInput-Editor:has(.monaco-editor.focused) { + border-color: var(--theia-focusBorder); +} + +.theia-ChatInput-Editor .monaco-editor { + display: flex; + width: 100%; + height: 100%; + overflow: hidden; + position: relative; +} + +.theia-ChatInput-Editor-Placeholder { + position: absolute; + top: -3px; + left: 19px; + right: 0; + bottom: 0; + display: flex; + align-items: center; + color: var(--theia-descriptionForeground); + pointer-events: none; + z-index: 10; + text-align: left; +} +.theia-ChatInput-Editor-Placeholder.hidden { + display: none; +} + +.theia-ChatInput-Editor .monaco-editor .margin, +.theia-ChatInput-Editor .monaco-editor .monaco-editor-background, +.theia-ChatInput-Editor .monaco-editor .inputarea.ime-input { + padding-left: 8px !important; +} + +.theia-ChatInputOptions { + position: absolute; + bottom: 31px; + right: 26px; + width: 10px; + height: 10px; +} + +.theia-ChatInputOptions .option { + width: 21px; + height: 21px; + margin-top: 2px; + display: inline-block; + box-sizing: border-box; + user-select: none; + background-repeat: no-repeat; + background-position: center; + border: var(--theia-border-width) solid transparent; + opacity: 0.7; + cursor: pointer; +} + +.theia-ChatInputOptions .option:hover { + opacity: 1; +} + +.theia-CodePartRenderer-root { + display: flex; + flex-direction: column; + gap: 4px; + border: 1px solid var(--theia-input-border); + border-radius: 4px; +} + +.theia-CodePartRenderer-left { + flex-grow: 1; +} + +.theia-CodePartRenderer-top { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 4px; +} + +.theia-CodePartRenderer-right button { + margin-left: 4px; +} + +.theia-CodePartRenderer-separator { + width: 100%; + height: 1px; + background-color: var(--theia-input-border); +} + +.theia-toolCall { + font-weight: normal; + color: var(--theia-descriptionForeground); + line-height: 20px; + margin-bottom: 6px; + cursor: pointer; +} + +.theia-toolCall .fa, +.theia-toolCall details summary::marker { + color: var(--theia-button-background); +} + +.theia-ResponseNode-ProgressMessage { + font-weight: normal; + color: var(--theia-descriptionForeground); + line-height: 20px; + margin-bottom: 6px; +} + +.theia-ResponseNode-ProgressMessage .inProgress { + color: var(--theia-progressBar-background); +} +.theia-ResponseNode-ProgressMessage .completed { + color: var(--theia-successBackground); +} +.theia-ResponseNode-ProgressMessage .failed { + color: var(--theia-errorForeground); +} + +.spinner { + display: inline-block; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.theia-ChatPart-Error { + display: flex; + flex-direction: row; + gap: 0.5em; + color: var(--theia-errorForeground); +} + +.section-header { + font-weight: bold; + font-size: 16px; + margin-bottom: 10px; +} + +.section-title { + font-weight: bold; + font-size: 14px; + margin: 20px 0px; +} + +.disable-message { + font-size: 12px; + line-height: 1.6; + padding: 15px; +} + +.section-content p { + margin: 10px 0; +} + +.section-content a { + cursor: pointer; +} + +.section-content strong { + font-weight: bold; +} diff --git a/packages/ai-chat-ui/src/browser/types.ts b/packages/ai-chat-ui/src/browser/types.ts new file mode 100644 index 0000000000000..80260e7c6ba87 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/types.ts @@ -0,0 +1,25 @@ +// ***************************************************************************** +// 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 { BaseChatResponseContent, ChatResponseContent } from '@theia/ai-chat/lib/common'; +import { ReactNode } from '@theia/core/shared/react'; +import { ResponseNode } from './chat-tree-view/chat-view-tree-widget'; + +export const ChatResponsePartRenderer = Symbol('ChatResponsePartRenderer'); +export interface ChatResponsePartRenderer { + canHandle(response: ChatResponseContent): number; + render(response: T, parentNode: ResponseNode): ReactNode; +} diff --git a/packages/ai-chat-ui/tsconfig.json b/packages/ai-chat-ui/tsconfig.json new file mode 100644 index 0000000000000..13d585dc94ad5 --- /dev/null +++ b/packages/ai-chat-ui/tsconfig.json @@ -0,0 +1,37 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-chat" + }, + { + "path": "../ai-core" + }, + { + "path": "../core" + }, + { + "path": "../editor" + }, + { + "path": "../editor-preview" + }, + { + "path": "../filesystem" + }, + { + "path": "../monaco" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-chat/.eslintrc.js b/packages/ai-chat/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-chat/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-chat/README.md b/packages/ai-chat/README.md new file mode 100644 index 0000000000000..6f394ce95cc55 --- /dev/null +++ b/packages/ai-chat/README.md @@ -0,0 +1,30 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Chat EXTENSION

+ +
+ +
+ +## Description + +The `@theia/ai-chat` extension provides the concept of a language model chat to Theia. +It serves as the basis for `@theia/ai-chat-ui` to provide the Chat UI. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-chat/package.json b/packages/ai-chat/package.json new file mode 100644 index 0000000000000..8b20eca4c6604 --- /dev/null +++ b/packages/ai-chat/package.json @@ -0,0 +1,56 @@ +{ + "name": "@theia/ai-chat", + "version": "1.52.0", + "description": "Theia - AI Chat Extension", + "dependencies": { + "@theia/ai-core": "1.52.0", + "@theia/ai-history": "1.52.0", + "@theia/core": "1.52.0", + "@theia/filesystem": "1.52.0", + "@theia/workspace": "1.52.0", + "minimatch": "^5.1.0", + "tslib": "^2.6.2" + }, + "publishConfig": { + "access": "public" + }, + "main": "lib/common", + "theiaExtensions": [ + { + "frontend": "lib/browser/agent-frontend-module" + }, + { + "backend": "lib/node/agent-backend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.52.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-chat/src/browser/agent-frontend-module.ts b/packages/ai-chat/src/browser/agent-frontend-module.ts new file mode 100644 index 0000000000000..b8e9c8e3c310d --- /dev/null +++ b/packages/ai-chat/src/browser/agent-frontend-module.ts @@ -0,0 +1,57 @@ +// ***************************************************************************** +// 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 { Agent } from '@theia/ai-core/lib/common'; +import { bindContributionProvider } from '@theia/core'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { + ChatAgent, + ChatAgentService, + ChatAgentServiceImpl, + ChatRequestParser, + ChatRequestParserImpl, + ChatService, + ChatServiceImpl +} from '../common'; +import { CommandChatAgent } from '../common/command-chat-agents'; +import { DelegatingChatAgent } from '../common/delegating-chat-agent'; +import { DefaultChatAgent } from '../common/default-chat-agent'; + +export default new ContainerModule(bind => { + bindContributionProvider(bind, Agent); + bindContributionProvider(bind, ChatAgent); + + bind(ChatAgentServiceImpl).toSelf().inSingletonScope(); + bind(ChatAgentService).toService(ChatAgentServiceImpl); + + bind(ChatRequestParserImpl).toSelf().inSingletonScope(); + bind(ChatRequestParser).toService(ChatRequestParserImpl); + + bind(ChatServiceImpl).toSelf().inSingletonScope(); + bind(ChatService).toService(ChatServiceImpl); + + bind(DelegatingChatAgent).toSelf().inSingletonScope(); + bind(Agent).toService(DelegatingChatAgent); + bind(ChatAgent).toService(DelegatingChatAgent); + + bind(DefaultChatAgent).toSelf().inSingletonScope(); + bind(Agent).toService(DefaultChatAgent); + bind(ChatAgent).toService(DefaultChatAgent); + + bind(CommandChatAgent).toSelf().inSingletonScope(); + bind(Agent).toService(CommandChatAgent); + bind(ChatAgent).toService(CommandChatAgent); +}); diff --git a/packages/ai-chat/src/common/chat-agent-service.ts b/packages/ai-chat/src/common/chat-agent-service.ts new file mode 100644 index 0000000000000..5544711effdda --- /dev/null +++ b/packages/ai-chat/src/common/chat-agent-service.ts @@ -0,0 +1,74 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatAgents.ts + +import { ContributionProvider, ILogger } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { ChatAgent } from './chat-agents'; +import { ChatRequestModel, ChatRequestModelImpl } from './chat-model'; +import { AgentService } from '@theia/ai-core'; + +export const ChatAgentService = Symbol('ChatAgentService'); +/** + * The ChatAgentService provides access to the available chat agents. + */ +export interface ChatAgentService { + getAgents(includeDisabledAgent?: boolean): ChatAgent[]; + getAgent(id: string, includeDisabledAgent?: boolean): ChatAgent | undefined; + getAgentsByName(name: string, includeDisabledAgent?: boolean): ChatAgent[]; + invokeAgent(agentId: string, request: ChatRequestModel): Promise; +} +@injectable() +export class ChatAgentServiceImpl implements ChatAgentService { + + @inject(ContributionProvider) @named(ChatAgent) + protected readonly agents: ContributionProvider; + + @inject(ILogger) + protected logger: ILogger; + + @inject(AgentService) + protected agentService: AgentService; + + getAgent(id: string, includeDisabledAgent = false): ChatAgent | undefined { + if (!includeDisabledAgent && !this._agentIsEnabled(id)) { + return; + } + return this.getAgents(includeDisabledAgent).find(agent => agent.id === id); + } + getAgents(includeDisabledAgent = false): ChatAgent[] { + return this.agents.getContributions() + .filter(a => includeDisabledAgent || this._agentIsEnabled(a.id)); + } + getAgentsByName(name: string, includeDisabledAgent = false): ChatAgent[] { + return this.getAgents(includeDisabledAgent).filter(a => a.name === name); + } + + private _agentIsEnabled(id: string): boolean { + return this.agentService.isEnabled(id); + } + invokeAgent(agentId: string, request: ChatRequestModelImpl): Promise { + const agent = this.getAgent(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + return agent.invoke(request, this); + } +} diff --git a/packages/ai-chat/src/common/chat-agents.ts b/packages/ai-chat/src/common/chat-agents.ts new file mode 100644 index 0000000000000..8a16303d76b43 --- /dev/null +++ b/packages/ai-chat/src/common/chat-agents.ts @@ -0,0 +1,384 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatAgents.ts + +import { + CommunicationRecordingService, + getTextOfResponse, + LanguageModel, + LanguageModelRequirement, + LanguageModelResponse, + PromptService, + ResolvedPromptTemplate, + ToolRequest, +} from '@theia/ai-core'; +import { + Agent, + isLanguageModelStreamResponse, + isLanguageModelTextResponse, + LanguageModelRegistry, + LanguageModelStreamResponsePart, + MessageActor, + PromptTemplate +} from '@theia/ai-core/lib/common'; +import { CancellationToken, CancellationTokenSource, ILogger, isArray } from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { ChatAgentService } from './chat-agent-service'; +import { + ChatModel, + ChatRequestModel, + ChatRequestModelImpl, + ChatResponseContent, + CodeChatResponseContentImpl, + ErrorResponseContentImpl, + MarkdownChatResponseContentImpl, + ToolCallResponseContentImpl +} from './chat-model'; + +export interface ChatMessage { + actor: MessageActor; + type: 'text'; + query: string; +} + +export interface SystemMessage { + text: string; + /** All functions references in the system message. */ + functionDescriptions?: Map>; +} +export namespace SystemMessage { + export function fromResolvedPromptTemplate(resolvedPrompt: ResolvedPromptTemplate): SystemMessage { + return { + text: resolvedPrompt.text, + functionDescriptions: resolvedPrompt.functionDescriptions + }; + } +} + +export enum ChatAgentLocation { + Panel = 'panel', + Terminal = 'terminal', + Notebook = 'notebook', + Editor = 'editor' +} + +export namespace ChatAgentLocation { + export const ALL: ChatAgentLocation[] = [ChatAgentLocation.Panel, ChatAgentLocation.Terminal, ChatAgentLocation.Notebook, ChatAgentLocation.Editor]; + + export function fromRaw(value: string): ChatAgentLocation { + switch (value) { + case 'panel': return ChatAgentLocation.Panel; + case 'terminal': return ChatAgentLocation.Terminal; + case 'notebook': return ChatAgentLocation.Notebook; + case 'editor': return ChatAgentLocation.Editor; + } + return ChatAgentLocation.Panel; + } +} + +export interface ChatAgentData extends Agent { + locations: ChatAgentLocation[]; + iconClass?: string; +} + +export const ChatAgent = Symbol('ChatAgent'); +export interface ChatAgent extends ChatAgentData { + invoke(request: ChatRequestModelImpl, chatAgentService?: ChatAgentService): Promise; +} + +@injectable() +export abstract class AbstractChatAgent implements ChatAgent { + + abstract id: string; + abstract name: string; + abstract description: string; + abstract variables: string[]; + abstract promptTemplates: PromptTemplate[]; + abstract languageModelRequirements: LanguageModelRequirement[]; + iconClass?: string | undefined = 'codicon codicon-copilot'; + locations: ChatAgentLocation[] = ChatAgentLocation.ALL; + + @inject(LanguageModelRegistry) + protected languageModelRegistry: LanguageModelRegistry; + + @inject(ILogger) + protected logger: ILogger; + + @inject(CommunicationRecordingService) + protected recordingService: CommunicationRecordingService; + + @inject(PromptService) + protected promptService: PromptService; + + protected abstract languageModelPurpose: string; + + async invoke(request: ChatRequestModelImpl): Promise { + try { + const languageModel = await this.getLanguageModel(); + if (!languageModel) { + throw new Error('Couldn\'t find a matching language model. Please check your setup!'); + } + const messages = await this.getMessages(request.session); + this.recordingService.recordRequest({ + agentId: this.id, + sessionId: request.session.id, + timestamp: Date.now(), + requestId: request.id, + request: request.request.text, + messages + }); + + const systemMessage = await this.getSystemMessage(); + const tools: Map> = new Map(); + if (systemMessage) { + const systemMsg: ChatMessage = { + actor: 'system', + type: 'text', + query: systemMessage.text + }; + // insert system message at the beginning of the request messages + messages.unshift(systemMsg); + systemMessage.functionDescriptions?.forEach((tool, id) => { + tools.set(id, tool); + }); + } + this.getTools(request)?.forEach(tool => tools.set(tool.id, tool)); + + const cancellationToken = new CancellationTokenSource(); + request.response.onDidChange(() => { + if (request.response.isCanceled) { + cancellationToken.cancel(); + } + }); + + const languageModelResponse = await this.callLlm( + languageModel, + messages, + tools.size > 0 ? Array.from(tools.values()) : undefined, + cancellationToken.token + ); + await this.addContentsToResponse(languageModelResponse, request); + request.response.complete(); + this.recordingService.recordResponse({ + agentId: this.id, + sessionId: request.session.id, + timestamp: Date.now(), + requestId: request.response.requestId, + response: request.response.response.asString() + }); + } catch (e) { + this.handleError(request, e); + } + } + + protected handleError(request: ChatRequestModelImpl, error: Error): void { + request.response.response.addContent(new ErrorResponseContentImpl(error)); + request.response.error(error); + } + + protected getLanguageModelSelector(): LanguageModelRequirement { + return this.languageModelRequirements.find(req => req.purpose === this.languageModelPurpose)!; + } + + protected async getLanguageModel(): Promise { + return this.selectLanguageModel(this.getLanguageModelSelector()); + } + + protected async selectLanguageModel(selector: LanguageModelRequirement): Promise { + const languageModel = await this.languageModelRegistry.selectLanguageModel({ agent: this.id, ...selector }); + if (!languageModel) { + throw new Error('Couldn\'t find a language model. Please check your setup!'); + } + return languageModel; + } + + protected abstract getSystemMessage(): Promise; + + protected async getMessages( + model: ChatModel, includeResponseInProgress = false + ): Promise { + const requestMessages = model.getRequests().flatMap(request => { + const messages: ChatMessage[] = []; + const query = request.message.parts.map(part => part.promptText).join(''); + messages.push({ + actor: 'user', + type: 'text', + query, + }); + if (request.response.isComplete || includeResponseInProgress) { + messages.push({ + actor: 'ai', + type: 'text', + query: request.response.response.asString(), + }); + } + return messages; + }); + + return requestMessages; + } + + /** + * @returns the list of tools used by this agent, or undefined if none is needed. + */ + protected getTools(request: ChatRequestModel): ToolRequest[] | undefined { + return request.message.toolRequests.size > 0 + ? [...request.message.toolRequests.values()] + : undefined; + } + + protected async callLlm( + languageModel: LanguageModel, + messages: ChatMessage[], + tools: ToolRequest[] | undefined, + token: CancellationToken + ): Promise { + const languageModelResponse = languageModel.request({ + messages, + tools, + cancellationToken: token, + }); + return languageModelResponse; + } + + protected abstract addContentsToResponse(languageModelResponse: LanguageModelResponse, request: ChatRequestModelImpl): Promise; +} + +@injectable() +export abstract class AbstractTextToModelParsingChatAgent extends AbstractChatAgent { + + protected async addContentsToResponse(languageModelResponse: LanguageModelResponse, request: ChatRequestModelImpl): Promise { + const responseAsText = await getTextOfResponse(languageModelResponse); + const parsedCommand = await this.parseTextResponse(responseAsText); + const content = this.createResponseContent(parsedCommand, request); + request.response.response.addContent(content); + } + + protected abstract parseTextResponse(text: string): Promise; + + protected abstract createResponseContent(parsedModel: T, request: ChatRequestModelImpl): ChatResponseContent; +} + +@injectable() +export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent { + + protected override async addContentsToResponse(languageModelResponse: LanguageModelResponse, request: ChatRequestModelImpl): Promise { + if (isLanguageModelTextResponse(languageModelResponse)) { + request.response.response.addContent( + new MarkdownChatResponseContentImpl(languageModelResponse.text) + ); + request.response.complete(); + this.recordingService.recordResponse({ + agentId: this.id, + sessionId: request.session.id, + timestamp: Date.now(), + requestId: request.response.requestId, + response: request.response.response.asString() + }); + return; + } + if (isLanguageModelStreamResponse(languageModelResponse)) { + for await (const token of languageModelResponse.stream) { + const newContents = this.parse(token, request.response.response.content); + if (isArray(newContents)) { + newContents.forEach(newContent => request.response.response.addContent(newContent)); + } else { + request.response.response.addContent(newContents); + } + + const lastContent = request.response.response.content.pop(); + if (lastContent === undefined) { + return; + } + const text = lastContent.asString?.(); + if (text === undefined) { + return; + } + let curSearchIndex = 0; + const result: ChatResponseContent[] = []; + while (curSearchIndex < text.length) { + // find start of code block: ```[language]\n[\n]``` + const codeStartIndex = text.indexOf('```', curSearchIndex); + if (codeStartIndex === -1) { + break; + } + + // find language specifier if present + const newLineIndex = text.indexOf('\n', codeStartIndex + 3); + const language = codeStartIndex + 3 < newLineIndex ? text.substring(codeStartIndex + 3, newLineIndex) : undefined; + + // find end of code block + const codeEndIndex = text.indexOf('```', codeStartIndex + 3); + if (codeEndIndex === -1) { + break; + } + + // add text before code block as markdown content + result.push(new MarkdownChatResponseContentImpl(text.substring(curSearchIndex, codeStartIndex))); + // add code block as code content + const codeText = text.substring(newLineIndex + 1, codeEndIndex).trimEnd(); + result.push(new CodeChatResponseContentImpl(codeText, language)); + curSearchIndex = codeEndIndex + 3; + } + + if (result.length > 0) { + result.forEach(r => { + request.response.response.addContent(r); + }); + } else { + request.response.response.addContent(lastContent); + } + } + request.response.complete(); + this.recordingService.recordResponse({ + agentId: this.id, + sessionId: request.session.id, + timestamp: Date.now(), + requestId: request.response.requestId, + response: request.response.response.asString() + }); + return; + } + this.logger.error( + 'Received unknown response in agent. Return response as text' + ); + request.response.response.addContent( + new MarkdownChatResponseContentImpl( + JSON.stringify(languageModelResponse) + ) + ); + } + + private parse(token: LanguageModelStreamResponsePart, previousContent: ChatResponseContent[]): ChatResponseContent | ChatResponseContent[] { + const content = token.content; + // eslint-disable-next-line no-null/no-null + if (content !== undefined && content !== null) { + return new MarkdownChatResponseContentImpl(content); + } + const toolCalls = token.tool_calls; + if (toolCalls !== undefined) { + const toolCallContents = toolCalls.map(toolCall => + new ToolCallResponseContentImpl(toolCall.id, toolCall.function?.name, toolCall.function?.arguments, toolCall.finished, toolCall.result)); + return toolCallContents; + } + return new MarkdownChatResponseContentImpl(''); + } + +} diff --git a/packages/ai-chat/src/common/chat-model.ts b/packages/ai-chat/src/common/chat-model.ts new file mode 100644 index 0000000000000..1e957591aeb4c --- /dev/null +++ b/packages/ai-chat/src/common/chat-model.ts @@ -0,0 +1,753 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatModel.ts + +import { Command, Emitter, Event, generateUuid, URI } from '@theia/core'; +import { MarkdownString, MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering'; +import { Position } from '@theia/core/shared/vscode-languageserver-protocol'; +import { ChatAgentLocation } from './chat-agents'; +import { ParsedChatRequest } from './chat-parsed-request'; + +/********************** + * INTERFACES AND TYPE GUARDS + **********************/ + +export type ChatChangeEvent = + | ChatAddRequestEvent + | ChatAddResponseEvent + | ChatRemoveRequestEvent; + +export interface ChatAddRequestEvent { + kind: 'addRequest'; + request: ChatRequestModel; +} + +export interface ChatAddResponseEvent { + kind: 'addResponse'; + response: ChatResponseModel; +} + +export type ChatRequestRemovalReason = 'removal' | 'resend' | 'adoption'; + +export interface ChatRemoveRequestEvent { + kind: 'removeRequest'; + requestId: string; + responseId?: string; + reason: ChatRequestRemovalReason; +} + +export interface ChatModel { + readonly onDidChange: Event; + readonly id: string; + readonly location: ChatAgentLocation; + getRequests(): ChatRequestModel[]; + addRequest(parsedChatRequest: ParsedChatRequest, agentId?: string): ChatRequestModel; + isEmpty(): boolean; +} + +export interface ChatRequest { + readonly text: string; + readonly displayText?: string; +} + +export interface ChatRequestModel { + readonly id: string; + readonly session: ChatModel; + readonly request: ChatRequest; + readonly response: ChatResponseModel; + readonly message: ParsedChatRequest; + readonly agentId?: string; +} + +export interface ChatProgressMessage { + kind: 'progressMessage'; + id: string; + status: 'inProgress' | 'completed' | 'failed'; + content: string; +} + +export interface BaseChatResponseContent { + kind: string; + /** + * Represents the content as a string. Returns `undefined` if the content + * is purely informational and/or visual and should not be included in the overall + * representation of the response. + */ + asString?(): string | undefined; + merge?(nextChatResponseContent: BaseChatResponseContent): boolean; +} + +export const isBaseChatResponseContent = ( + obj: unknown +): obj is BaseChatResponseContent => + !!( + obj && + typeof obj === 'object' && + 'kind' in obj && + typeof (obj as { kind: unknown }).kind === 'string' + ); + +export const hasAsString = ( + obj: BaseChatResponseContent +): obj is Required> & +BaseChatResponseContent => obj.asString !== undefined; + +export const hasMerge = ( + obj: BaseChatResponseContent +): obj is Required> & +BaseChatResponseContent => obj.merge !== undefined; + +export interface TextChatResponseContent + extends Required { + kind: 'text'; + content: string; +} +export interface ErrorResponseContent extends BaseChatResponseContent { + kind: 'error'; + error: Error; +} + +export interface MarkdownChatResponseContent + extends Required { + kind: 'markdownContent'; + content: MarkdownString; +} + +export interface CodeChatResponseContent + extends BaseChatResponseContent { + kind: 'code'; + code: string; + language?: string; + location?: Location; +} + +export interface HorizontalLayoutChatResponseContent extends Required { + kind: 'horizontal'; + content: BaseChatResponseContent[]; +} + +export interface ToolCallResponseContent extends Required { + kind: 'toolCall'; + id?: string; + name?: string; + arguments?: string; + finished: boolean; + result?: string; +} + +export interface Location { + uri: URI; + position: Position; +} +export function isLocation(obj: unknown): obj is Location { + return !!obj && typeof obj === 'object' && + 'uri' in obj && (obj as { uri: unknown }).uri instanceof URI && + 'position' in obj && Position.is((obj as { position: unknown }).position); +} + +export interface CommandChatResponseContent extends BaseChatResponseContent { + kind: 'command'; + command: Command; + commandHandler?: (...commandArgs: unknown[]) => Promise; + arguments?: unknown[]; +} + +export interface InformationalChatResponseContent extends BaseChatResponseContent { + kind: 'informational'; + content: MarkdownString; +} + +export const isTextChatResponseContent = ( + obj: unknown +): obj is TextChatResponseContent => + isBaseChatResponseContent(obj) && + obj.kind === 'text' && + 'content' in obj && + typeof (obj as { content: unknown }).content === 'string'; + +export const isMarkdownChatResponseContent = ( + obj: unknown +): obj is MarkdownChatResponseContent => + isBaseChatResponseContent(obj) && + obj.kind === 'markdownContent' && + 'content' in obj && + MarkdownString.is((obj as { content: unknown }).content); + +export const isInformationalChatResponseContent = ( + obj: unknown +): obj is InformationalChatResponseContent => + isBaseChatResponseContent(obj) && + obj.kind === 'informational' && + 'content' in obj && + MarkdownString.is((obj as { content: unknown }).content); + +export const isCommandChatResponseContent = ( + obj: unknown +): obj is CommandChatResponseContent => + isBaseChatResponseContent(obj) && + obj.kind === 'command' && + 'command' in obj && + Command.is((obj as { command: unknown }).command); + +export const isCodeChatResponseContent = ( + obj: unknown +): obj is CodeChatResponseContent => + isBaseChatResponseContent(obj) && + obj.kind === 'code' && + 'code' in obj && + typeof (obj as { code: unknown }).code === 'string'; + +export const isHorizontalLayoutChatResponseContent = (obj: unknown): obj is HorizontalLayoutChatResponseContent => + isBaseChatResponseContent(obj) && + obj.kind === 'horizontal' && + 'content' in obj && + Array.isArray((obj as { content: unknown }).content) && + (obj as { content: unknown[] }).content.every(isBaseChatResponseContent); + +export const isToolCallChatResponseContent = ( + obj: unknown +): obj is ToolCallResponseContent => + isBaseChatResponseContent(obj) && + obj.kind === 'toolCall'; + +export const isErrorChatResponseContent = ( + obj: unknown +): obj is ErrorResponseContent => + isBaseChatResponseContent(obj) && + obj.kind === 'error' && 'error' in obj && obj.error instanceof Error; + +export type ChatResponseContent = + | BaseChatResponseContent + | TextChatResponseContent + | MarkdownChatResponseContent + | CommandChatResponseContent + | CodeChatResponseContent + | HorizontalLayoutChatResponseContent + | ToolCallResponseContent + | ErrorResponseContent + | InformationalChatResponseContent; + +export interface ChatResponse { + readonly content: ChatResponseContent[]; + asString(): string; +} + +export interface ChatResponseModel { + readonly onDidChange: Event; + readonly id: string; + readonly requestId: string; + readonly progressMessages: ChatProgressMessage[]; + addProgressMessage(message: { content: string } & Partial>): ChatProgressMessage; + getProgressMessage(id: string): ChatProgressMessage | undefined; + updateProgressMessage(message: { id: string } & Partial>): void; + readonly response: ChatResponse; + readonly isComplete: boolean; + readonly isCanceled: boolean; + readonly isError: boolean; + readonly agentId?: string + cancel(): void; + error(error: Error): void; + readonly errorObject?: Error; + +} + +/********************** + * Implementations + **********************/ + +export class ChatModelImpl implements ChatModel { + protected readonly _onDidChangeEmitter = new Emitter(); + onDidChange: Event = this._onDidChangeEmitter.event; + + protected _requests: ChatRequestModelImpl[]; + protected _id: string; + + constructor(public readonly location = ChatAgentLocation.Panel) { + // TODO accept serialized data as a parameter to restore a previously saved ChatModel + this._requests = []; + this._id = generateUuid(); + } + + getRequests(): ChatRequestModelImpl[] { + return this._requests; + } + + get id(): string { + return this._id; + } + + addRequest(parsedChatRequest: ParsedChatRequest, agentId?: string): ChatRequestModelImpl { + const requestModel = new ChatRequestModelImpl(this, parsedChatRequest, agentId); + this._requests.push(requestModel); + this._onDidChangeEmitter.fire({ + kind: 'addRequest', + request: requestModel, + }); + return requestModel; + } + + isEmpty(): boolean { + return this._requests.length === 0; + } +} + +export class ChatRequestModelImpl implements ChatRequestModel { + protected _id: string; + protected _session: ChatModel; + protected _request: ChatRequest; + protected _response: ChatResponseModelImpl; + protected _agentId?: string; + + constructor(session: ChatModel, public readonly message: ParsedChatRequest, agentId?: string) { + // TODO accept serialized data as a parameter to restore a previously saved ChatRequestModel + this._request = message.request; + this._id = generateUuid(); + this._session = session; + this._response = new ChatResponseModelImpl(this._id, agentId); + this._agentId = agentId; + } + + get id(): string { + return this._id; + } + + get session(): ChatModel { + return this._session; + } + + get request(): ChatRequest { + return this._request; + } + + get response(): ChatResponseModelImpl { + return this._response; + } + + get agentId(): string | undefined { + return this._agentId; + } +} + +export class ErrorResponseContentImpl implements ErrorResponseContent { + kind: 'error' = 'error'; + protected _error: Error; + constructor(error: Error) { + this._error = error; + } + get error(): Error { + return this._error; + } + asString(): string | undefined { + return undefined; + } +} + +export class TextChatResponseContentImpl implements TextChatResponseContent { + kind: 'text' = 'text'; + protected _content: string; + + constructor(content: string) { + this._content = content; + } + + get content(): string { + return this._content; + } + + asString(): string { + return this._content; + } + + merge(nextChatResponseContent: TextChatResponseContent): boolean { + this._content += nextChatResponseContent.content; + return true; + } +} + +export class MarkdownChatResponseContentImpl implements MarkdownChatResponseContent { + kind: 'markdownContent' = 'markdownContent'; + protected _content: MarkdownStringImpl = new MarkdownStringImpl(); + + constructor(content: string) { + this._content.appendMarkdown(content); + } + + get content(): MarkdownString { + return this._content; + } + + asString(): string { + return this._content.value; + } + + merge(nextChatResponseContent: MarkdownChatResponseContent): boolean { + this._content.appendMarkdown(nextChatResponseContent.content.value); + return true; + } +} + +export class InformationalChatResponseContentImpl implements InformationalChatResponseContent { + kind: 'informational' = 'informational'; + protected _content: MarkdownStringImpl; + + constructor(content: string) { + this._content = new MarkdownStringImpl(content); + } + + get content(): MarkdownString { + return this._content; + } + + asString(): string | undefined { + return undefined; + } + + merge(nextChatResponseContent: InformationalChatResponseContent): boolean { + this._content.appendMarkdown(nextChatResponseContent.content.value); + return true; + } +} + +export class CodeChatResponseContentImpl implements CodeChatResponseContent { + kind: 'code' = 'code'; + protected _code: string; + protected _language?: string; + protected _location?: Location; + + constructor(code: string, language?: string, location?: Location) { + this._code = code; + this._language = language; + this._location = location; + } + + get code(): string { + return this._code; + } + + get language(): string | undefined { + return this._language; + } + + get location(): Location | undefined { + return this._location; + } + + asString(): string { + return `\`\`\`${this._language ?? ''}\n${this._code}\n\`\`\``; + } + + merge(nextChatResponseContent: CodeChatResponseContent): boolean { + this._code += `${nextChatResponseContent.code}`; + return true; + } +} + +export class ToolCallResponseContentImpl implements ToolCallResponseContent { + kind: 'toolCall' = 'toolCall'; + protected _id?: string; + protected _name?: string; + protected _arguments?: string; + protected _finished?: boolean; + protected _result?: string; + + constructor(id?: string, name?: string, arg_string?: string, finished?: boolean, result?: string) { + this._id = id; + this._name = name; + this._arguments = arg_string; + this._finished = finished; + this._result = result; + } + + get id(): string | undefined { + return this._id; + } + + get name(): string | undefined { + return this._name; + } + + get arguments(): string | undefined { + return this._arguments; + } + + get finished(): boolean { + return this._finished === undefined ? false : this._finished; + } + get result(): string | undefined { + return this._result; + } + + asString(): string { + return `Tool call: ${this._name}(${this._arguments ?? ''})`; + } + merge(nextChatResponseContent: ToolCallResponseContent): boolean { + if (nextChatResponseContent.id === this.id) { + this._finished = nextChatResponseContent.finished; + this._result = nextChatResponseContent.result; + return true; + } + if (nextChatResponseContent.name !== undefined) { + return false; + } + if (nextChatResponseContent.arguments === undefined) { + return false; + } + this._arguments += `${nextChatResponseContent.arguments}`; + return true; + } +} + +export const COMMAND_CHAT_RESPONSE_COMMAND: Command = { + id: 'ai-chat.command-chat-response.generic' +}; +export class CommandChatResponseContentImpl implements CommandChatResponseContent { + kind: 'command' = 'command'; + + arguments: unknown[] | undefined; + + protected _command: Command; + protected _commandHandler?: (...commandArgs: unknown[]) => Promise; + + constructor(command: Command = COMMAND_CHAT_RESPONSE_COMMAND, args?: unknown[], commandHandler?: (...commandArgs: unknown[]) => Promise) { + this._command = command; + this.arguments = args; + this._commandHandler = commandHandler; + } + + get command(): Command { + return this._command; + } + + get commandHandler(): ((...commandArgs: unknown[]) => Promise) | undefined { + return this._commandHandler; + } + + asString(): string { + return this._command.id; + } +} + +export class HorizontalLayoutChatResponseContentImpl implements HorizontalLayoutChatResponseContent { + kind: 'horizontal' = 'horizontal'; + protected _content: BaseChatResponseContent[]; + + constructor(content: BaseChatResponseContent[] = []) { + this._content = content; + } + + get content(): BaseChatResponseContent[] { + return this._content; + } + + asString(): string { + return this._content.map(child => child.asString && child.asString()).join(' '); + } + + merge(nextChatResponseContent: BaseChatResponseContent): boolean { + if (isHorizontalLayoutChatResponseContent(nextChatResponseContent)) { + this._content.push(...nextChatResponseContent.content); + } else { + this._content.push(nextChatResponseContent); + } + return true; + } +} + +class ChatResponseImpl implements ChatResponse { + protected readonly _onDidChangeEmitter = new Emitter(); + onDidChange: Event = this._onDidChangeEmitter.event; + protected _content: ChatResponseContent[]; + protected _responseRepresentation: string; + + constructor() { + // TODO accept serialized data as a parameter to restore a previously saved ChatResponse + this._content = []; + } + + get content(): ChatResponseContent[] { + return this._content; + } + + addContent(nextContent: ChatResponseContent): void { + // TODO: Support more complex merges affecting different content than the last, e.g. via some kind of ProcessorRegistry + // TODO: Support more of the built-in VS Code behavior, see + // https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatModel.ts#L188-L244 + if (isToolCallChatResponseContent(nextContent) && nextContent.id !== undefined) { + const fittingTool = this._content.find(c => isToolCallChatResponseContent(c) && c.id === nextContent.id); + if (fittingTool !== undefined) { + fittingTool.merge?.(nextContent); + } else { + this._content.push(nextContent); + } + } else { + const lastElement = + this._content.length > 0 + ? this._content[this._content.length - 1] + : undefined; + if (lastElement?.kind === nextContent.kind && hasMerge(lastElement)) { + const mergeSuccess = lastElement.merge(nextContent); + if (!mergeSuccess) { + this._content.push(nextContent); + } + } else { + this._content.push(nextContent); + } + } + this._updateResponseRepresentation(); + this._onDidChangeEmitter.fire(); + } + + protected _updateResponseRepresentation(): void { + this._responseRepresentation = this._content + .map(responseContent => { + if (hasAsString(responseContent)) { + return responseContent.asString(); + } + if (isTextChatResponseContent(responseContent)) { + return responseContent.content; + } + console.warn( + 'Was not able to map responseContent to a string', + responseContent + ); + return undefined; + }) + .filter(text => text !== undefined) + .join('\n\n'); + } + + asString(): string { + return this._responseRepresentation; + } +} + +class ChatResponseModelImpl implements ChatResponseModel { + protected readonly _onDidChangeEmitter = new Emitter(); + onDidChange: Event = this._onDidChangeEmitter.event; + + protected _id: string; + protected _requestId: string; + protected _progressMessages: ChatProgressMessage[]; + protected _response: ChatResponseImpl; + protected _isComplete: boolean; + protected _isCanceled: boolean; + protected _agentId?: string; + protected _isError: boolean; + protected _errorObject: Error | undefined; + + constructor(requestId: string, agentId?: string) { + // TODO accept serialized data as a parameter to restore a previously saved ChatResponseModel + this._requestId = requestId; + this._id = generateUuid(); + this._progressMessages = []; + const response = new ChatResponseImpl(); + response.onDidChange(() => this._onDidChangeEmitter.fire()); + this._response = response; + this._isComplete = false; + this._isCanceled = false; + this._agentId = agentId; + } + + get id(): string { + return this._id; + } + + get requestId(): string { + return this._requestId; + } + + get progressMessages(): ChatProgressMessage[] { + return this._progressMessages; + } + + addProgressMessage(message: { content: string } & Partial>): ChatProgressMessage { + const id = message.id ?? generateUuid(); + const existingMessage = this.getProgressMessage(id); + if (existingMessage) { + this.updateProgressMessage({ id, ...message }); + return existingMessage; + } + const newMessage: ChatProgressMessage = { + kind: 'progressMessage', + id, + status: message.status ?? 'inProgress', + ...message, + }; + this._progressMessages.push(newMessage); + this._onDidChangeEmitter.fire(); + return newMessage; + } + + getProgressMessage(id: string): ChatProgressMessage | undefined { + return this._progressMessages.find(message => message.id === id); + } + + updateProgressMessage(message: { id: string } & Partial>): void { + const progressMessage = this.getProgressMessage(message.id); + if (progressMessage) { + Object.assign(progressMessage, message); + this._onDidChangeEmitter.fire(); + } + } + + get response(): ChatResponseImpl { + return this._response; + } + + get isComplete(): boolean { + return this._isComplete; + } + + get isCanceled(): boolean { + return this._isCanceled; + } + + get agentId(): string | undefined { + return this._agentId; + } + + overrideAgentId(agentId: string): void { + this._agentId = agentId; + } + + complete(): void { + this._isComplete = true; + this._onDidChangeEmitter.fire(); + } + + cancel(): void { + this._isComplete = true; + this._isCanceled = true; + this._onDidChangeEmitter.fire(); + } + error(error: Error): void { + this._isComplete = true; + this._isCanceled = false; + this._isError = true; + this._errorObject = error; + this._onDidChangeEmitter.fire(); + } + get errorObject(): Error | undefined { + return this._errorObject; + } + get isError(): boolean { + return this._isError; + } +} diff --git a/packages/ai-chat/src/common/chat-parsed-request.ts b/packages/ai-chat/src/common/chat-parsed-request.ts new file mode 100644 index 0000000000000..641d1372b857b --- /dev/null +++ b/packages/ai-chat/src/common/chat-parsed-request.ts @@ -0,0 +1,135 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatParserTypes.ts +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/editor/common/core/offsetRange.ts + +import { AIVariable, ResolvedAIVariable, ToolRequest, toolRequestToPromptText } from '@theia/ai-core'; +import { ChatAgentData } from './chat-agents'; +import { ChatRequest } from './chat-model'; + +export const chatVariableLeader = '#'; +export const chatAgentLeader = '@'; +export const chatFunctionLeader = '~'; +export const chatSubcommandLeader = '/'; + +/********************** + * INTERFACES AND TYPE GUARDS + **********************/ + +export interface OffsetRange { + readonly start: number; + readonly endExclusive: number; +} +export class OffsetRangeImpl implements OffsetRange { + constructor(public readonly start: number, public readonly endExclusive: number) { + if (start > endExclusive) { + throw new Error(`Invalid range: ${this.toString()}`); + } + } +} + +export interface ParsedChatRequest { + readonly request: ChatRequest; + readonly parts: ParsedChatRequestPart[]; + readonly toolRequests: Map>; + readonly variables: Map; +} + +export interface ChatRequestBasePart { + readonly kind: string; + /** + * The text as represented in the ChatRequest + */ + readonly text: string; + /** + * The text as will be sent to the LLM + */ + readonly promptText: string; + + readonly range: OffsetRange; +} + +export class ChatRequestTextPart implements ChatRequestBasePart { + readonly kind: 'text'; + + constructor(readonly range: OffsetRange, readonly text: string) { } + + get promptText(): string { + return this.text; + } +} + +export class ChatRequestVariablePart implements ChatRequestBasePart { + readonly kind: 'var'; + + protected _resolution: ResolvedAIVariable; + + constructor(readonly range: OffsetRange, readonly variableName: string, readonly variableArg: string | undefined) { } + + get text(): string { + const argPart = this.variableArg ? `:${this.variableArg}` : ''; + return `${chatVariableLeader}${this.variableName}${argPart}`; + } + + get promptText(): string { + return this._resolution?.value ?? this.text; + } + + resolve(resolution: ResolvedAIVariable): void { + this._resolution = resolution; + } + + get resolution(): ResolvedAIVariable | undefined { + return this._resolution; + } +} + +export class ChatRequestFunctionPart implements ChatRequestBasePart { + readonly kind: 'function'; + constructor(readonly range: OffsetRange, readonly toolRequest: ToolRequest) { } + + get text(): string { + return `${chatFunctionLeader}${this.toolRequest.id}`; + } + + get promptText(): string { + return toolRequestToPromptText(this.toolRequest); + } +} + +export class ChatRequestAgentPart implements ChatRequestBasePart { + readonly kind: 'agent'; + constructor(readonly range: OffsetRange, readonly agent: ChatAgentData) { } + + get text(): string { + return `${chatAgentLeader}${this.agent.name}`; + } + + get promptText(): string { + return ''; + } +} + +export type ParsedChatRequestPart = ChatRequestBasePart | ChatRequestTextPart | ChatRequestVariablePart | ChatRequestAgentPart; + +/********************** + * Implementations + **********************/ + diff --git a/packages/ai-chat/src/common/chat-request-parser.spec.ts b/packages/ai-chat/src/common/chat-request-parser.spec.ts new file mode 100644 index 0000000000000..a04aecfc5af8a --- /dev/null +++ b/packages/ai-chat/src/common/chat-request-parser.spec.ts @@ -0,0 +1,120 @@ +// ***************************************************************************** +// 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 * as sinon from 'sinon'; +import { ChatAgentServiceImpl } from './chat-agent-service'; +import { ChatRequestParserImpl } from './chat-request-parser'; +import { ChatAgentLocation } from './chat-agents'; +import { ChatRequest } from './chat-model'; +import { expect } from 'chai'; +import { DefaultAIVariableService, FunctionCallRegistry, FunctionCallRegistryImpl } from '@theia/ai-core'; + +describe('ChatRequestParserImpl', () => { + const chatAgentService = sinon.createStubInstance(ChatAgentServiceImpl); + const variableService = sinon.createStubInstance(DefaultAIVariableService); + const functionCallRegistry: FunctionCallRegistry = sinon.createStubInstance(FunctionCallRegistryImpl); + const parser = new ChatRequestParserImpl(chatAgentService, variableService, functionCallRegistry); + + it('parses simple text', () => { + const req: ChatRequest = { + text: 'What is the best pizza topping?' + }; + const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + expect(result.parts).to.deep.contain({ + text: 'What is the best pizza topping?', + range: { start: 0, endExclusive: 31 } + }); + }); + + it('parses text with variable name', () => { + const req: ChatRequest = { + text: 'What is the #best pizza topping?' + }; + const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + expect(result).to.deep.contain({ + parts: [{ + text: 'What is the ', + range: { start: 0, endExclusive: 12 } + }, { + variableName: 'best', + variableArg: undefined, + range: { start: 12, endExclusive: 17 } + }, { + text: ' pizza topping?', + range: { start: 17, endExclusive: 32 } + }] + }); + }); + + it('parses text with variable name with argument', () => { + const req: ChatRequest = { + text: 'What is the #best:by-poll pizza topping?' + }; + const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + expect(result).to.deep.contain({ + parts: [{ + text: 'What is the ', + range: { start: 0, endExclusive: 12 } + }, { + variableName: 'best', + variableArg: 'by-poll', + range: { start: 12, endExclusive: 25 } + }, { + text: ' pizza topping?', + range: { start: 25, endExclusive: 40 } + }] + }); + }); + + it('parses text with variable name with numeric argument', () => { + const req: ChatRequest = { + text: '#size-class:2' + }; + const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + expect(result.parts[0]).to.contain( + { + variableName: 'size-class', + variableArg: '2' + } + ); + }); + + it('parses text with variable name with POSIX path argument', () => { + const req: ChatRequest = { + text: '#file:/path/to/file.ext' + }; + const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + expect(result.parts[0]).to.contain( + { + variableName: 'file', + variableArg: '/path/to/file.ext' + } + ); + }); + + it('parses text with variable name with Win32 path argument', () => { + const req: ChatRequest = { + text: '#file:c:\\path\\to\\file.ext' + }; + const result = parser.parseChatRequest(req, ChatAgentLocation.Panel); + expect(result.parts[0]).to.contain( + { + variableName: 'file', + variableArg: 'c:\\path\\to\\file.ext' + } + ); + }); +}); diff --git a/packages/ai-chat/src/common/chat-request-parser.ts b/packages/ai-chat/src/common/chat-request-parser.ts new file mode 100644 index 0000000000000..1a7508ebe55e8 --- /dev/null +++ b/packages/ai-chat/src/common/chat-request-parser.ts @@ -0,0 +1,214 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatRequestParser.ts + +import { inject, injectable } from '@theia/core/shared/inversify'; +import { ChatAgentService } from './chat-agent-service'; +import { ChatAgentLocation } from './chat-agents'; +import { ChatRequest } from './chat-model'; +import { + chatAgentLeader, + chatFunctionLeader, + ChatRequestAgentPart, + ChatRequestFunctionPart, + ChatRequestTextPart, + ChatRequestVariablePart, + chatVariableLeader, + OffsetRangeImpl, + ParsedChatRequest, + ParsedChatRequestPart, +} from './chat-parsed-request'; +import { AIVariable, AIVariableService, FunctionCallRegistry, ToolRequest } from '@theia/ai-core'; + +const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent +const functionReg = /^~([\w_\-\.]+)(?=(\s|$|\b))/i; // A ~ tool function +const variableReg = /^#([\w_\-]+)(?::([\w_\-_\/\\.:]+))?(?=(\s|$|\b))/i; // A #-variable with an optional : arg (#file:workspace/path/name.ext) + +export const ChatRequestParser = Symbol('ChatRequestParser'); +export interface ChatRequestParser { + parseChatRequest(request: ChatRequest, location: ChatAgentLocation): ParsedChatRequest; +} + +@injectable() +export class ChatRequestParserImpl { + constructor( + @inject(ChatAgentService) private readonly agentService: ChatAgentService, + @inject(AIVariableService) private readonly variableService: AIVariableService, + @inject(FunctionCallRegistry) private readonly functionCallRegistry: FunctionCallRegistry + ) { } + + parseChatRequest(request: ChatRequest, location: ChatAgentLocation): ParsedChatRequest { + const parts: ParsedChatRequestPart[] = []; + const variables = new Map(); + const toolRequests = new Map>(); + const message = request.text; + for (let i = 0; i < message.length; i++) { + const previousChar = message.charAt(i - 1); + const char = message.charAt(i); + let newPart: ParsedChatRequestPart | undefined; + + if (previousChar.match(/\s/) || i === 0) { + if (char === chatFunctionLeader) { + const functionPart = this.tryParseFunction( + message.slice(i), + i + ); + newPart = functionPart; + if (functionPart) { + toolRequests.set(functionPart.toolRequest.id, functionPart.toolRequest); + } + } else if (char === chatVariableLeader) { + const variablePart = this.tryToParseVariable( + message.slice(i), + i, + parts + ); + newPart = variablePart; + if (variablePart) { + const variable = this.variableService.getVariable(variablePart.variableName); + if (variable) { + variables.set(variable.name, variable); + } + } + } else if (char === chatAgentLeader) { + newPart = this.tryToParseAgent( + message.slice(i), + i, + parts, + location + ); + } + } + + if (newPart) { + if (i !== 0) { + // Insert a part for all the text we passed over, then insert the new parsed part + const previousPart = parts.at(-1); + const previousPartEnd = + previousPart?.range.endExclusive ?? 0; + parts.push( + new ChatRequestTextPart( + new OffsetRangeImpl(previousPartEnd, i), + message.slice(previousPartEnd, i) + ) + ); + } + + parts.push(newPart); + } + } + + const lastPart = parts.at(-1); + const lastPartEnd = lastPart?.range.endExclusive ?? 0; + if (lastPartEnd < message.length) { + parts.push( + new ChatRequestTextPart( + new OffsetRangeImpl(lastPartEnd, message.length), + message.slice(lastPartEnd, message.length) + ) + ); + } + + return { request, parts, toolRequests, variables }; + } + + private tryToParseAgent( + message: string, + offset: number, + parts: ReadonlyArray, + location: ChatAgentLocation + ): ChatRequestAgentPart | ChatRequestVariablePart | undefined { + const nextAgentMatch = message.match(agentReg); + if (!nextAgentMatch) { + return; + } + + const [full, name] = nextAgentMatch; + const agentRange = new OffsetRangeImpl(offset, offset + full.length); + + let agents = this.agentService.getAgentsByName(name); + if (!agents.length) { + const fqAgent = this.agentService.getAgent(name); + if (fqAgent) { + agents = [fqAgent]; + } + } + + // If there is more than one agent with this name, and the user picked it from the suggest widget, then the selected agent should be in the + // context and we use that one. Otherwise just pick the first. + const agent = agents[0]; + if (!agent || !agent.locations.includes(location)) { + return; + } + + if (parts.some(p => p instanceof ChatRequestAgentPart)) { + // Only one agent allowed + return; + } + + // The agent must come first + if ( + parts.some( + p => + (p instanceof ChatRequestTextPart && + p.text.trim() !== '') || + !(p instanceof ChatRequestAgentPart) + ) + ) { + return; + } + + return new ChatRequestAgentPart(agentRange, agent); + } + + private tryToParseVariable( + message: string, + offset: number, + _parts: ReadonlyArray + ): ChatRequestVariablePart | undefined { + const nextVariableMatch = message.match(variableReg); + if (!nextVariableMatch) { + return; + } + + const [full, name] = nextVariableMatch; + const variableArg = nextVariableMatch[2]; + const varRange = new OffsetRangeImpl(offset, offset + full.length); + + return new ChatRequestVariablePart(varRange, name, variableArg); + } + + private tryParseFunction(message: string, offset: number): ChatRequestFunctionPart | undefined { + const nextFunctionMatch = message.match(functionReg); + if (!nextFunctionMatch) { + return; + } + + const [full, id] = nextFunctionMatch; + + const maybeToolRequest = this.functionCallRegistry.getFunction(id); + if (!maybeToolRequest) { + return; + } + + const functionRange = new OffsetRangeImpl(offset, offset + full.length); + return new ChatRequestFunctionPart(functionRange, maybeToolRequest); + } +} diff --git a/packages/ai-chat/src/common/chat-service.ts b/packages/ai-chat/src/common/chat-service.ts new file mode 100644 index 0000000000000..f5f8c97dbcd19 --- /dev/null +++ b/packages/ai-chat/src/common/chat-service.ts @@ -0,0 +1,230 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatService.ts + +import { inject, injectable } from '@theia/core/shared/inversify'; +import { + ChatModel, + ChatModelImpl, + ChatRequest, + ChatRequestModel, + ChatResponseModel, +} from './chat-model'; +import { ChatAgentService } from './chat-agent-service'; +import { Emitter, ILogger } from '@theia/core'; +import { ChatRequestParser } from './chat-request-parser'; +import { ChatAgent, ChatAgentLocation } from './chat-agents'; +import { ChatRequestAgentPart, ChatRequestVariablePart, ParsedChatRequest } from './chat-parsed-request'; +import { AIVariableService } from '@theia/ai-core'; +import { Event } from '@theia/core/shared/vscode-languageserver-protocol'; + +export interface ChatSendRequestData { + /** + * Promise which completes once the request preprocessing is complete. + */ + requestCompleted: Promise; + /** + * Promise which completes once a response is expected to arrive. + */ + responseCreated: Promise; + /** + * Promise which completes once the response is complete. + */ + responseCompleted: Promise; +} + +export interface ChatSession { + id: string; + title?: string; + model: ChatModel; + isActive: boolean; +} + +export interface ActiveSessionChangedEvent { + sessionId: string; + focus?: boolean; +} + +export interface SessionOptions { + focus?: boolean; +} + +export const ChatService = Symbol('ChatService'); +export interface ChatService { + onActiveSessionChanged: Event + + getSession(id: string): ChatSession | undefined; + getSessions(): ChatSession[]; + getOrRestoreSession(id: string): ChatSession | undefined; + createSession(location?: ChatAgentLocation, options?: SessionOptions): ChatSession; + removeSession(sessionId: string): void; + setActiveSession(sessionId: string, options?: SessionOptions): void; + + sendRequest( + sessionId: string, + request: ChatRequest + ): Promise; +} + +@injectable() +export class ChatServiceImpl implements ChatService { + protected readonly onActiveSessionChangedEmitter = new Emitter(); + onActiveSessionChanged = this.onActiveSessionChangedEmitter.event; + + @inject(ChatAgentService) + protected chatAgentService: ChatAgentService; + + @inject(ChatRequestParser) + protected chatRequestParser: ChatRequestParser; + + @inject(AIVariableService) + protected variableService: AIVariableService; + + @inject(ILogger) + protected logger: ILogger; + + protected _sessions: ChatSession[] = []; + + getSessions(): ChatSession[] { + return [...this._sessions]; + } + + getSession(id: string): ChatSession | undefined { + return this._sessions.find(session => session.id === id); + } + + getOrRestoreSession(id: string): ChatSession | undefined { + // TODO: Implement storing and restoring sessions. + return this._sessions.find(session => session.id === id); + } + + createSession(location = ChatAgentLocation.Panel, options?: SessionOptions): ChatSession { + const model = new ChatModelImpl(location); + const session: ChatSession = { + id: model.id, + model, + isActive: true + }; + this._sessions.push(session); + this.setActiveSession(session.id, options); + return session; + } + + removeSession(sessionId: string): void { + // If the removed session is the active one, set the newest one as active + if (this.getSession(sessionId)?.isActive) { + this.setActiveSession(this._sessions[this._sessions.length - 1].id); + } + this._sessions = this._sessions.filter(item => item.id !== sessionId); + if (this._sessions.length === 0) { + this.createSession(); + } + } + + getNextId(): string { + let maxId = 0; + this._sessions.forEach(session => { + const id = parseInt(session.id); + if (id > maxId) { + maxId = id; + } + }); + return maxId.toString(); + } + + setActiveSession(sessionId: string, options?: SessionOptions): void { + this._sessions.forEach(session => { + session.isActive = session.id === sessionId; + }); + this.onActiveSessionChangedEmitter.fire({ sessionId: sessionId, ...options }); + } + + async sendRequest( + sessionId: string, + request: ChatRequest + ): Promise { + const session = this.getSession(sessionId); + if (!session) { + return undefined; + } + session.title = request.text; + let resolveRequestCompleted: (requestModel: ChatRequestModel) => void; + let resolveResponseCreated: (responseModel: ChatResponseModel) => void; + let resolveResponseCompleted: (responseModel: ChatResponseModel) => void; + const requestReturnData: ChatSendRequestData = { + requestCompleted: new Promise(resolve => { + resolveRequestCompleted = resolve; + }), + responseCreated: new Promise(resolve => { + resolveResponseCreated = resolve; + }), + responseCompleted: new Promise(resolve => { + resolveResponseCompleted = resolve; + }), + }; + const parsedRequest = this.chatRequestParser.parseChatRequest(request, session.model.location); + + const agent = this.getAgent(parsedRequest); + const requestModel = session.model.addRequest(parsedRequest, agent?.id); + + for (const part of parsedRequest.parts) { + if (part instanceof ChatRequestVariablePart) { + const resolvedVariable = await this.variableService.resolveVariable( + { variable: part.variableName, arg: part.variableArg }, + { request, model: session } + ); + if (resolvedVariable) { + part.resolve(resolvedVariable); + } else { + this.logger.warn(`Failed to resolve variable ${part.variableName} for ${session.model.location}`); + } + } + } + resolveRequestCompleted!(requestModel); + + resolveResponseCreated!(requestModel.response); + requestModel.response.onDidChange(() => { + if (requestModel.response.isComplete) { + resolveResponseCompleted!(requestModel.response); + } + if (requestModel.response.isError) { + resolveResponseCompleted!(requestModel.response); + } + }); + + if (agent) { + this.chatAgentService + .invokeAgent(agent.id, requestModel) + .catch(error => requestModel.response.error(error)); + } else { + this.logger.error('No ChatAgents available to handle request!', requestModel); + } + + return requestReturnData; + } + + protected getAgent(parsedRequest: ParsedChatRequest): ChatAgent | undefined { + const agentPart = parsedRequest.parts.find(p => p instanceof ChatRequestAgentPart) as ChatRequestAgentPart | undefined; + if (agentPart) { + return this.chatAgentService.getAgent(agentPart.agent.id); + } + return this.chatAgentService.getAgents()[0] ?? undefined; + } +} diff --git a/packages/ai-chat/src/common/chat-variables.ts b/packages/ai-chat/src/common/chat-variables.ts new file mode 100644 index 0000000000000..8742a0ae2c1e6 --- /dev/null +++ b/packages/ai-chat/src/common/chat-variables.ts @@ -0,0 +1,34 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatVariables.ts + +import { AIVariableContext } from '@theia/ai-core'; +import { ChatModel, ChatRequest } from './chat-model'; + +export interface ChatVariableContext extends AIVariableContext { + request: ChatRequest; + model: ChatModel; +} + +export namespace ChatVariableContext { + export function is(obj: unknown): obj is ChatVariableContext { + return !!obj && typeof obj === 'object' && 'request' in obj && 'model' in obj; + } +} diff --git a/packages/ai-chat/src/common/command-chat-agents.ts b/packages/ai-chat/src/common/command-chat-agents.ts new file mode 100644 index 0000000000000..63422fdd295c1 --- /dev/null +++ b/packages/ai-chat/src/common/command-chat-agents.ts @@ -0,0 +1,351 @@ +// ***************************************************************************** +// 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { AbstractTextToModelParsingChatAgent, SystemMessage } from './chat-agents'; +import { + PromptTemplate, + LanguageModelRequirement +} from '@theia/ai-core'; +import { + ChatRequestModelImpl, + ChatResponseContent, + CommandChatResponseContentImpl, + HorizontalLayoutChatResponseContentImpl, + MarkdownChatResponseContentImpl, +} from './chat-model'; +import { + Command, + CommandRegistry, + MessageService, + generateUuid, +} from '@theia/core'; + +export class CommandChatAgentSystemPromptTemplate implements PromptTemplate { + id = 'command-chat-agent-system-prompt-template'; + template = `# System Prompt + +You are a service that helps users find commands to execute in an IDE. +You reply with stringified JSON Objects that tell the user which command to execute and its arguments, if any. + +# Examples + +The examples start with a short explanation of the return object. +The response can be found within the markdown \`\`\`json and \`\`\` markers. +Please include these markers in the reply. + +Never under any circumstances may you reply with just the command-id! + +## Example 1 + +This reply is to tell the user to execute the \`theia-ai-prompt-template:show-prompts-command\` command that is available in the Theia command registry. + +\`\`\`json +{ + "type": "theia-command", + "commandId": "theia-ai-prompt-template:show-prompts-command" +} +\`\`\` + +## Example 2 + +This reply is to tell the user to execute the \`theia-ai-prompt-template:show-prompts-command\` command that is available in the theia command registry, +when the user want to pass arguments to the command. + +\`\`\`json +{ + "type": "theia-command", + "commandId": "theia-ai-prompt-template:show-prompts-command", + "arguments": ["foo"] +} +\`\`\` + +## Example 3 + +This reply is for custom commands that are not registered in the Theia command registry. +These commands always have the command id \`ai-chat.command-chat-response.generic\`. +The arguments are an array and may differ, depending on the user's instructions. + +\`\`\`json +{ + "type": "custom-handler", + "commandId": "ai-chat.command-chat-response.generic", + "arguments": ["foo", "bar"] +} +\`\`\` + +## Example 4 + +This reply of type no-command is for cases where you can't find a proper command. +You may use the message to explain the situation to the user. + +\`\`\`json +{ + "type": "no-command", + "message": "a message explaining what is wrong" +} +\`\`\` + +# Rules + +## Theia Commands + +If a user asks for a Theia command, or the context implies it is about a command in Theia, return a response with \`"type": "theia-command"\`. +You need to exchange the "commandId". +The available command ids in Theia are in the list below. The list of commands is formatted like this: + +command-id1: Label1 +command-id2: Label2 +command-id3: +command-id4: Label4 + +The Labels may be empty, but there is always a command-id. + +Suggest a command that probably fits the user's message based on the label and the command ids you know. +If you have multiple commands that fit, return the one that fits best. We only want a single command in the reply. +If the user says that the last command was not right, try to return the next best fit based on the conversation history with the user. + +If there are no more command ids that seem to fit, return a response of \`"type": "no-command"\` explaining the situation. + +Here are the known Theia commands: + +Begin List: +\${command-ids} +End List + +You may only use commands from this list when responding with \`"type": "theia-command"\`. +Do not come up with command ids that are not in this list. +If you need to do this, use the \`"type": "no-command"\`. instead + +## Custom Handlers + +If the user asks for a command that is not a Theia command, return a response with \`"type": "custom-handler"\`. + +## Other Cases + +In all other cases, return a reply of \`"type": "no-command"\`. + +# Examples of Invalid Responses + +## Invalid Response Example 1 + +This example is invalid because it returns text and two commands. +Only one command should be replied, and it must be parseable JSON. + +### The Example + +Yes, there are a few more theme-related commands. Here is another one: + +\`\`\`json +{ + "type": "theia-command", + "commandId": "workbench.action.selectIconTheme" +} +\`\`\` + +And another one: + +\`\`\`json +{ + "type": "theia-command", + "commandId": "core.close.right.tabs" +} +\`\`\` + +## Invalid Response Example 2 + +The following example is invalid because it only returns the command id and is not parseable JSON: + +### The Example + +workbench.action.selectIconTheme + +## Invalid Response Example 3 + +The following example is invalid because it returns a message with the command id. We need JSON objects based on the above rules. +Do not respond like this in any case! We need a command of \`"type": "theia-command"\`. + +The expected response would be: +\`\`\`json +{ + "type": "theia-command", + "commandId": "core.close.right.tabs" +} +\`\`\` + +### The Example + +I found this command that might help you: core.close.right.tabs + +## Invalid Response Example 4 + +The following example is invalid because it has an explanation string before the JSON. +We only want the JSON! + +### The Example + +You can toggle high contrast mode with this command: + +\`\`\`json +{ + "type": "theia-command", + "commandId": "editor.action.toggleHighContrast" +} +\`\`\` + +## Invalid Response Example 5 + +The following example is invalid because it explains that no command was found. +We want a response of \`"type": "no-command"\` and have the message there. + +### The Example + +There is no specific command available to "open the windows" in the provided Theia command list. + +## Invalid Response Example 6 + +In this example we were using the following theia id command list: + +Begin List: +container--theia-open-editors-widget: Hello +foo:toggle-visibility-explorer-view-container--files: Label 1 +foo:toggle-visibility-explorer-view-container--plugin-view: Label 2 +End List + +The problem is that workbench.action.toggleHighContrast is not in this list. +theia-command types may only use commandIds from this list. +This should have been of \`"type": "no-command"\`. + +### The Example + +\`\`\`json +{ + "type": "theia-command", + "commandId": "workbench.action.toggleHighContrast" +} +\`\`\` + +`; +} + +interface ParsedCommand { + type: 'theia-command' | 'custom-handler' | 'no-command' + commandId: string; + arguments?: string[]; + message?: string; +} + +@injectable() +export class CommandChatAgent extends AbstractTextToModelParsingChatAgent { + @inject(CommandRegistry) + protected readonly commandRegistry: CommandRegistry; + + @inject(MessageService) + private readonly messageService: MessageService; + + id: string = 'CommandChatAgent'; + name: string = 'CommandChatAgent'; + description: string = 'This agent knows everything about Theia commands you can run within the IDE.'; + variables: string[] = []; + promptTemplates: PromptTemplate[] = [new CommandChatAgentSystemPromptTemplate()]; + + languageModelRequirements: LanguageModelRequirement[] = [{ + purpose: 'command', + identifier: 'openai/gpt-4o', + }]; + + protected override languageModelPurpose = 'command'; + + protected async getSystemMessage(): Promise { + const knownCommands: string[] = []; + for (const command of this.commandRegistry.getAllCommands()) { + knownCommands.push(`${command.id}: ${command.label}`); + } + const systemPrompt = await this.promptService.getPrompt('command-chat-agent-system-prompt-template', { + 'command-ids': knownCommands.join('\n') + }); + if (systemPrompt === undefined) { + throw new Error('Couldn\'t get system prompt '); + } + return SystemMessage.fromResolvedPromptTemplate(systemPrompt); + } + + /** + * @param text the text received from the language model + * @returns the parsed command if the text contained a valid command. + * If there was no json in the text, return a no-command response. + */ + protected async parseTextResponse(text: string): Promise { + const jsonMatch = text.match(/(\{[\s\S]*\})/); + const jsonString = jsonMatch ? jsonMatch[1] : `{ + "type": "no-command", + "message": "Please try again." +}`; + const parsedCommand = JSON.parse(jsonString) as ParsedCommand; + return parsedCommand; + } + + protected createResponseContent(parsedCommand: ParsedCommand, request: ChatRequestModelImpl): ChatResponseContent { + if (parsedCommand.type === 'theia-command') { + const theiaCommand = this.commandRegistry.getCommand(parsedCommand.commandId); + if (theiaCommand === undefined) { + console.error(`No Theia Command with id ${parsedCommand.commandId}`); + request.response.cancel(); + } + const args = parsedCommand.arguments !== undefined && + parsedCommand.arguments.length > 0 + ? parsedCommand.arguments + : undefined; + + return new HorizontalLayoutChatResponseContentImpl([ + new MarkdownChatResponseContentImpl( + 'I found this command that might help you:' + ), + new CommandChatResponseContentImpl(theiaCommand, args), + ]); + } else if (parsedCommand.type === 'custom-handler') { + const id = `ai-command-${generateUuid()}`; + const command: Command = { + id, + label: 'AI Command' + }; + + const args = parsedCommand.arguments !== undefined && parsedCommand.arguments.length > 0 ? parsedCommand.arguments : undefined; + this.commandRegistry.registerCommand(command, { + execute: () => { + const fullArgs: unknown[] = [id]; + if (args !== undefined) { + fullArgs.push(...args); + } + this.commandCallback(fullArgs); + } + }); + return new HorizontalLayoutChatResponseContentImpl([ + new MarkdownChatResponseContentImpl( + 'Try executing this:' + ), + new CommandChatResponseContentImpl(command, args, this.commandCallback), + ]); + } else { + return new MarkdownChatResponseContentImpl(parsedCommand.message ?? 'Sorry, I can\'t find such a command'); + } + } + + protected async commandCallback(...commandArgs: unknown[]): Promise { + this.messageService.info(`Executing callback with args ${commandArgs.join(', ')}. The first arg is the command id registered for the dynamically registered command. + The other args are the actual args for the handler.`, 'Got it'); + } +} diff --git a/packages/ai-chat/src/common/default-chat-agent.ts b/packages/ai-chat/src/common/default-chat-agent.ts new file mode 100644 index 0000000000000..72245c1503a36 --- /dev/null +++ b/packages/ai-chat/src/common/default-chat-agent.ts @@ -0,0 +1,100 @@ +// ***************************************************************************** +// 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 { LanguageModelRequirement } from '@theia/ai-core'; +import { + PromptTemplate +} from '@theia/ai-core/lib/common'; +import { injectable } from '@theia/core/shared/inversify'; +import { AbstractStreamParsingChatAgent, SystemMessage } from './chat-agents'; + +export const defaultTemplate: PromptTemplate = { + id: 'default-template', + template: `# Instructions + +You are an AI assistant integrated into the Theia IDE, specifically designed to help software developers by +providing concise and accurate answers to programming-related questions. Your role is to enhance the +developer's productivity by offering quick solutions, explanations, and best practices. +Keep responses short and to the point, focusing on delivering valuable insights, best practices and +simple solutions. + +### Guidelines + +1. **Understand Context:** + - Assess the context of the code or issue when available. + - Tailor responses to be relevant to the programming language, framework, or tools like Eclipse Theia. + - Ask clarifying questions if necessary to provide accurate assistance. + +2. **Provide Clear Solutions:** + - Offer direct answers or code snippets that solve the problem or clarify the concept. + - Avoid lengthy explanations unless necessary for understanding. + +3. **Promote Best Practices:** + - Suggest best practices and common patterns relevant to the question. + - Provide links to official documentation for further reading when applicable. + +4. **Support Multiple Languages and Tools:** + - Be familiar with popular programming languages, frameworks, IDEs like Eclipse Theia, and command-line tools. + - Adapt advice based on the language, environment, or tools specified by the developer. + +5. **Facilitate Learning:** + - Encourage learning by explaining why a solution works or why a particular approach is recommended. + - Keep explanations concise and educational. + +6. **Maintain Professional Tone:** + - Communicate in a friendly, professional manner. + - Use technical jargon appropriately, ensuring clarity for the target audience. + +7. **Stay on Topic:** + - Limit responses strictly to topics related to software development, frameworks, Eclipse Theia, terminal usage, and relevant technologies. + - Politely decline to answer questions unrelated to these areas by saying, "I'm here to assist with programming-related questions. + For other topics, please refer to a specialized source." + +### Example Interactions + +- **Question:** "What's the difference between \`let\` and \`var\` in JavaScript?" + **Answer:** "\`let\` is block-scoped, while \`var\` is function-scoped. Prefer \`let\` to avoid scope-related bugs." + +- **Question:** "How do I handle exceptions in Java?" + **Answer:** "Use try-catch blocks: \`\`\`java try { /* code */ } catch (ExceptionType e) { /* handle exception */ }\`\`\`." + +- **Question:** "What is the capital of France?" + **Answer:** "I'm here to assist with programming-related queries. For other topics, please refer to a specialized source." +` +}; + +@injectable() +export class DefaultChatAgent extends AbstractStreamParsingChatAgent { + + id: string = 'DefaultChatAgent'; + name: string = 'DefaultChatAgent'; + description: string = 'A chat agent that is specialized in answering general programming and software development questions.'; + + languageModelPurpose = 'chat'; + languageModelRequirements: LanguageModelRequirement[] = [{ + purpose: this.languageModelPurpose, + identifier: 'openai/gpt-4o', + }]; + + variables: string[] = []; + promptTemplates: PromptTemplate[] = [defaultTemplate]; + + protected async getSystemMessage(): Promise { + const resolvedPrompt = await this.promptService.getPrompt(defaultTemplate.id); + return resolvedPrompt ? SystemMessage.fromResolvedPromptTemplate(resolvedPrompt) : undefined; + } + +} diff --git a/packages/ai-chat/src/common/delegating-chat-agent.ts b/packages/ai-chat/src/common/delegating-chat-agent.ts new file mode 100644 index 0000000000000..f8de1626cea8c --- /dev/null +++ b/packages/ai-chat/src/common/delegating-chat-agent.ts @@ -0,0 +1,128 @@ +// ***************************************************************************** +// 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 { getJsonOfResponse, LanguageModelRequirement, LanguageModelResponse } from '@theia/ai-core'; +import { + PromptTemplate +} from '@theia/ai-core/lib/common'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { ChatAgentService } from './chat-agent-service'; +import { AbstractStreamParsingChatAgent, SystemMessage } from './chat-agents'; +import { ChatRequestModelImpl, InformationalChatResponseContentImpl } from './chat-model'; + +export const delegateTemplate: PromptTemplate = { + id: 'default-delegate-template', + template: `# Instructions + +Your task is to identify which Chat Agent(s) should best reply a given user's message. +You consider all messages of the conversation to ensure consistency and avoid agent switches without a clear context change. +You should select the best Chat Agent based on the name and description of the agents, matching them to the user message. + +## Constraints + +Your response must be a JSON array containing the id(s) of the selected Chat Agent(s). + +* Do not use ids that are not provided in the list below. +* Do not include any additional information, explanations, or questions for the user. +* If there is no suitable choice, pick the \`DefaultChatAgent\`. +* If there are multiple good choices, return all of them. + +Unless there is a more specific agent available, select the \`DefaultChatAgent\`, especially for general programming-related questions. +You must only use the \`id\` attribute of the agent, never the name. + +### Example Results + +\`\`\`json +["DefaultChatAgent"] +\`\`\` + +\`\`\`json +["AnotherChatAgent", "DefaultChatAgent"] +\`\`\` + +## List of Currently Available Chat Agents + +\${agents} + +` +}; + +@injectable() +export class DelegatingChatAgent extends AbstractStreamParsingChatAgent { + id: string = 'DelegatingChatAgent'; + name: string = 'DelegatingChatAgent'; + description: string = 'A chat agent that analyzes the user request and the available chat agents' + + ' to choose and delegate to the best fitting agent for answering the user request.'; + + override iconClass = 'codicon codicon-symbol-boolean'; + + variables: string[] = ['agents']; + promptTemplates: PromptTemplate[] = [delegateTemplate]; + + languageModelPurpose = 'agent-selection'; + languageModelRequirements: LanguageModelRequirement[] = [{ + purpose: this.languageModelPurpose, + identifier: 'openai/gpt-4o', + }]; + + @inject(ChatAgentService) + protected chatAgentService: ChatAgentService; + + override invoke(request: ChatRequestModelImpl): Promise { + request.response.addProgressMessage({ content: 'Determining the most appropriate agent', status: 'inProgress' }); + return super.invoke(request); + } + + protected async getSystemMessage(): Promise { + const resolvedPrompt = await this.promptService.getPrompt(delegateTemplate.id); + return resolvedPrompt ? SystemMessage.fromResolvedPromptTemplate(resolvedPrompt) : undefined; + } + + protected override async addContentsToResponse(response: LanguageModelResponse, request: ChatRequestModelImpl): Promise { + let agentIds = []; + try { + const jsonResponse = await getJsonOfResponse(response); + if (Array.isArray(jsonResponse)) { + agentIds = jsonResponse.filter((id: string) => id !== this.id); + } + } catch (error: unknown) { + // The llm sometimes does not return a parseable result + this.logger.error('Failed to parse JSON response', error); + } + + if (agentIds.length < 1) { + this.logger.error('No agent was selected, delegating to default chat agent'); + request.response.progressMessages.forEach(progressMessage => + request.response.updateProgressMessage({ ...progressMessage, status: 'failed' }) + ); + agentIds = ['DefaultChatAgent']; + } + // TODO support delegating to more than one agent + const delegatedToAgent = agentIds[0]; + request.response.response.addContent(new InformationalChatResponseContentImpl( + `*DelegatingChatAgent*: Delegating to \`@${delegatedToAgent}\` + + --- + + ` + )); + request.response.overrideAgentId(delegatedToAgent); + request.response.progressMessages.forEach(progressMessage => + request.response.updateProgressMessage({ ...progressMessage, status: 'completed' }) + ); + await this.chatAgentService.invokeAgent(delegatedToAgent, request); + } +} diff --git a/packages/ai-chat/src/common/index.ts b/packages/ai-chat/src/common/index.ts new file mode 100644 index 0000000000000..9b04f45c55d0c --- /dev/null +++ b/packages/ai-chat/src/common/index.ts @@ -0,0 +1,25 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +export * from './chat-agent-service'; +export * from './chat-agents'; +export * from './chat-model'; +export * from './chat-parsed-request'; +export * from './chat-request-parser'; +export * from './chat-service'; +export * from './chat-variables'; +export * from './command-chat-agents'; +export * from './default-chat-agent'; +export * from './delegating-chat-agent'; diff --git a/packages/ai-chat/src/node/agent-backend-module.ts b/packages/ai-chat/src/node/agent-backend-module.ts new file mode 100644 index 0000000000000..c353e812867fd --- /dev/null +++ b/packages/ai-chat/src/node/agent-backend-module.ts @@ -0,0 +1,47 @@ +// ***************************************************************************** +// 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 { Agent } from '@theia/ai-core/lib/common'; +import { bindContributionProvider } from '@theia/core'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { + ChatAgent, + ChatAgentService, + ChatAgentServiceImpl, + ChatRequestParser, + ChatRequestParserImpl, + ChatService, + ChatServiceImpl, +} from '../common'; +import { DelegatingChatAgent } from '../common/delegating-chat-agent'; + +export default new ContainerModule(bind => { + bindContributionProvider(bind, Agent); + bindContributionProvider(bind, ChatAgent); + + bind(ChatAgentServiceImpl).toSelf().inSingletonScope(); + bind(ChatAgentService).toService(ChatAgentServiceImpl); + + bind(ChatRequestParserImpl).toSelf().inSingletonScope(); + bind(ChatRequestParser).toService(ChatRequestParserImpl); + + bind(ChatServiceImpl).toSelf().inSingletonScope(); + bind(ChatService).toService(ChatServiceImpl); + + bind(DelegatingChatAgent).toSelf().inSingletonScope(); + bind(Agent).toService(DelegatingChatAgent); + bind(ChatAgent).toService(DelegatingChatAgent); +}); diff --git a/packages/ai-chat/tsconfig.json b/packages/ai-chat/tsconfig.json new file mode 100644 index 0000000000000..e7d3cda9e5fdb --- /dev/null +++ b/packages/ai-chat/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-core" + }, + { + "path": "../ai-history" + }, + { + "path": "../core" + }, + { + "path": "../filesystem" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-code-completion/.eslintrc.js b/packages/ai-code-completion/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-code-completion/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-code-completion/README.md b/packages/ai-code-completion/README.md new file mode 100644 index 0000000000000..938ca2c78ffd9 --- /dev/null +++ b/packages/ai-code-completion/README.md @@ -0,0 +1,30 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Code Completion

+ +
+ +
+ +## Description + +The `@theia/ai-code-completion` extension contributes Ai based code completion. +The user can separately enable code completion items as well as inline code completion. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-code-completion/package.json b/packages/ai-code-completion/package.json new file mode 100644 index 0000000000000..35fc38ea657df --- /dev/null +++ b/packages/ai-code-completion/package.json @@ -0,0 +1,54 @@ +{ + "name": "@theia/ai-code-completion", + "version": "1.52.0", + "description": "Theia - AI Core", + "dependencies": { + "@theia/ai-core": "1.52.0", + "@theia/core": "1.52.0", + "@theia/filesystem": "1.52.0", + "@theia/monaco-editor-core": "1.83.101", + "@theia/output": "1.52.0", + "@theia/workspace": "1.52.0", + "minimatch": "^5.1.0", + "tslib": "^2.6.2" + }, + "main": "lib/common", + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/ai-code-completion-frontend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.52.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-code-completion/src/browser/ai-code-completion-frontend-module.ts b/packages/ai-code-completion/src/browser/ai-code-completion-frontend-module.ts new file mode 100644 index 0000000000000..69dd3b5daf7db --- /dev/null +++ b/packages/ai-code-completion/src/browser/ai-code-completion-frontend-module.ts @@ -0,0 +1,39 @@ +// ***************************************************************************** +// 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 { ILogger } from '@theia/core'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { CodeCompletionAgent, CodeCompletionAgentImpl } from '../common/code-completion-agent'; +import { AICodeCompletionProvider } from './ai-code-completion-provider'; +import { AIFrontendApplicationContribution } from './ai-code-frontend-application-contribution'; +import { FrontendApplicationContribution, PreferenceContribution } from '@theia/core/lib/browser'; +import { Agent } from '@theia/ai-core'; +import { AICodeCompletionPreferencesSchema } from './ai-code-completion-preference'; +import { AICodeInlineCompletionsProvider } from './ai-code-inline-completion-provider'; + +export default new ContainerModule(bind => { + bind(ILogger).toDynamicValue(ctx => { + const parentLogger = ctx.container.get(ILogger); + return parentLogger.child('code-completion-agent'); + }).inSingletonScope().whenTargetNamed('code-completion-agent'); + bind(CodeCompletionAgentImpl).toSelf().inSingletonScope(); + bind(CodeCompletionAgent).toService(CodeCompletionAgentImpl); + bind(Agent).toService(CodeCompletionAgentImpl); + bind(AICodeCompletionProvider).toSelf().inSingletonScope(); + bind(AICodeInlineCompletionsProvider).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).to(AIFrontendApplicationContribution).inSingletonScope(); + bind(PreferenceContribution).toConstantValue({ schema: AICodeCompletionPreferencesSchema }); +}); diff --git a/packages/ai-code-completion/src/browser/ai-code-completion-preference.ts b/packages/ai-code-completion/src/browser/ai-code-completion-preference.ts new file mode 100644 index 0000000000000..9b457a854f0ec --- /dev/null +++ b/packages/ai-code-completion/src/browser/ai-code-completion-preference.ts @@ -0,0 +1,46 @@ +// ***************************************************************************** +// 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 { PreferenceSchema } from '@theia/core/lib/browser/preferences/preference-contribution'; +import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/browser/ai-core-preferences'; + +export const PREF_AI_CODE_COMPLETION_ENABLE = 'ai-features.code-completion.enable'; +export const PREF_AI_CODE_COMPLETION_PRECOMPUTE = 'ai-features.code-completion.precompute'; +export const PREF_AI_INLINE_COMPLETION_ENABLE = 'ai-features.code-completion-inline.enable'; + +export const AICodeCompletionPreferencesSchema: PreferenceSchema = { + type: 'object', + properties: { + [PREF_AI_CODE_COMPLETION_ENABLE]: { + title: AI_CORE_PREFERENCES_TITLE, + type: 'boolean', + description: 'Enable AI completion items within any (Monaco) editor.', + default: false + }, + [PREF_AI_CODE_COMPLETION_PRECOMPUTE]: { + title: AI_CORE_PREFERENCES_TITLE, + type: 'boolean', + description: 'Precompute AI completion items. This will improve completion previews, however it will trigger many more requests and will take longer to complete.', + default: false + }, + [PREF_AI_INLINE_COMPLETION_ENABLE]: { + title: AI_CORE_PREFERENCES_TITLE, + type: 'boolean', + description: 'Enable AI completions inline within any (Monaco) editor.', + default: false + } + } +}; diff --git a/packages/ai-code-completion/src/browser/ai-code-completion-provider.ts b/packages/ai-code-completion/src/browser/ai-code-completion-provider.ts new file mode 100644 index 0000000000000..b320dcbb878e5 --- /dev/null +++ b/packages/ai-code-completion/src/browser/ai-code-completion-provider.ts @@ -0,0 +1,84 @@ +// ***************************************************************************** +// 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 * as monaco from '@theia/monaco-editor-core'; + +import { CodeCompletionAgent } from '../common/code-completion-agent'; +import { injectable, inject } from '@theia/core/shared/inversify'; +import { PreferenceService } from '@theia/core/lib/browser'; +import { CancellationTokenSource } from '@theia/core'; +import { PREF_AI_CODE_COMPLETION_PRECOMPUTE } from './ai-code-completion-preference'; + +interface WithArgs { + args: T; +} +const hasArgs = (object: {}): object is WithArgs => 'args' in object && Array.isArray(object['args']); + +@injectable() +export class AICodeCompletionProvider implements monaco.languages.CompletionItemProvider { + + @inject(CodeCompletionAgent) + protected readonly agent: CodeCompletionAgent; + + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + async provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position, + context: monaco.languages.CompletionContext, token: monaco.CancellationToken): Promise { + if (!this.preferenceService.get(PREF_AI_CODE_COMPLETION_PRECOMPUTE, false)) { + const result = { + suggestions: [{ + label: 'AI Code Completion', + detail: 'computes after trigger', + kind: monaco.languages.CompletionItemKind.Text, + insertText: '', + range: { + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column + }, + args: [] + }] + }; + (result.suggestions[0] as WithArgs).args = [...arguments]; + return result; + } + const cancellationTokenSource = new CancellationTokenSource(); + token.onCancellationRequested(() => { cancellationTokenSource.cancel(); }); + return this.agent.provideCompletionItems(model, position, context, cancellationTokenSource.token); + } + + async resolveCompletionItem(item: monaco.languages.CompletionItem, token: monaco.CancellationToken): Promise { + if (!hasArgs>(item)) { + return item; + } + const args = item.args; + const cancellationTokenSource = new CancellationTokenSource(); + token.onCancellationRequested(() => { cancellationTokenSource.cancel(); }); + const resolvedItems = await this.agent.provideCompletionItems(args[0], args[1], args[2], cancellationTokenSource.token); + item.insertText = resolvedItems?.suggestions[0].insertText ?? ''; + item.additionalTextEdits = [{ + range: { + startLineNumber: args[1].lineNumber, + startColumn: args[1].column, + endLineNumber: args[1].lineNumber, + endColumn: args[1].column + }, text: resolvedItems?.suggestions[0].insertText ?? '' + }]; + return item; + } +} diff --git a/packages/ai-code-completion/src/browser/ai-code-frontend-application-contribution.ts b/packages/ai-code-completion/src/browser/ai-code-frontend-application-contribution.ts new file mode 100644 index 0000000000000..c29fa7510113d --- /dev/null +++ b/packages/ai-code-completion/src/browser/ai-code-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 * as monaco from '@theia/monaco-editor-core'; + +import { FrontendApplicationContribution, PreferenceService } from '@theia/core/lib/browser'; +import { AICodeCompletionProvider } from './ai-code-completion-provider'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { AIActivationService } from '@theia/ai-core/lib/browser'; +import { Disposable } from '@theia/core'; +import { AICodeInlineCompletionsProvider } from './ai-code-inline-completion-provider'; +import { PREF_AI_CODE_COMPLETION_ENABLE, PREF_AI_INLINE_COMPLETION_ENABLE } from './ai-code-completion-preference'; + +@injectable() +export class AIFrontendApplicationContribution implements FrontendApplicationContribution { + @inject(AICodeCompletionProvider) + protected codeCompletionProvider: AICodeCompletionProvider; + + @inject(AICodeInlineCompletionsProvider) + private inlineCodeCompletionProvider: AICodeInlineCompletionsProvider; + + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + @inject(AIActivationService) + protected readonly activationService: AIActivationService; + + private toDispose = new Map(); + + onDidInitializeLayout(): void { + this.preferenceService.ready.then(() => { + this.handlePreference(PREF_AI_CODE_COMPLETION_ENABLE, enable => this.handleCodeCompletions(enable)); + this.handlePreference(PREF_AI_INLINE_COMPLETION_ENABLE, enable => this.handleInlineCompletions(enable)); + }); + } + + protected handlePreference(name: string, handler: (enable: boolean) => Disposable): void { + const enable = this.preferenceService.get(name, false) && this.activationService.isActive; + this.toDispose.set(name, handler(enable)); + + this.preferenceService.onPreferenceChanged(event => { + if (event.preferenceName === name) { + this.toDispose.get(name)?.dispose(); + this.toDispose.set(name, handler(event.newValue && this.activationService.isActive)); + } + }); + this.activationService.onDidChangeActiveStatus(change => { + this.toDispose.get(name)?.dispose(); + this.toDispose.set(name, handler(this.preferenceService.get(name, false) && change)); + }); + } + + protected handleCodeCompletions(enable: boolean): Disposable { + return enable ? monaco.languages.registerCompletionItemProvider({ scheme: 'file' }, this.codeCompletionProvider) : Disposable.NULL; + } + + protected handleInlineCompletions(enable: boolean): Disposable { + return enable ? monaco.languages.registerInlineCompletionsProvider({ scheme: 'file' }, this.inlineCodeCompletionProvider) : Disposable.NULL; + } +} diff --git a/packages/ai-code-completion/src/browser/ai-code-inline-completion-provider.ts b/packages/ai-code-completion/src/browser/ai-code-inline-completion-provider.ts new file mode 100644 index 0000000000000..22fb3847513e8 --- /dev/null +++ b/packages/ai-code-completion/src/browser/ai-code-inline-completion-provider.ts @@ -0,0 +1,43 @@ +// ***************************************************************************** +// 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 * as monaco from '@theia/monaco-editor-core'; + +import { inject, injectable } from '@theia/core/shared/inversify'; +import { CodeCompletionAgent } from '../common/code-completion-agent'; +import { CompletionTriggerKind } from '@theia/core/shared/vscode-languageserver-protocol'; + +@injectable() +export class AICodeInlineCompletionsProvider implements monaco.languages.InlineCompletionsProvider { + @inject(CodeCompletionAgent) + protected readonly agent: CodeCompletionAgent; + + async provideInlineCompletions(model: monaco.editor.ITextModel, position: monaco.Position, + context: monaco.languages.InlineCompletionContext, token: monaco.CancellationToken): Promise { + if (this.agent.provideInlineCompletions) { + return this.agent.provideInlineCompletions(model, position, context, token); + } + // map from regular completion items + const items = await this.agent.provideCompletionItems(model, position, { ...context, triggerKind: CompletionTriggerKind.Invoked }, token); + return { + items: items?.suggestions.map(suggestion => ({ insertText: suggestion.insertText })) ?? [] + }; + } + + freeInlineCompletions(completions: monaco.languages.InlineCompletions): void { + // nothing to do + } +} diff --git a/packages/ai-code-completion/src/browser/index.ts b/packages/ai-code-completion/src/browser/index.ts new file mode 100644 index 0000000000000..be8ba477f3df9 --- /dev/null +++ b/packages/ai-code-completion/src/browser/index.ts @@ -0,0 +1,18 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** + +export * from './ai-code-completion-provider'; +export * from '../common/code-completion-agent'; diff --git a/packages/ai-code-completion/src/common/code-completion-agent.ts b/packages/ai-code-completion/src/common/code-completion-agent.ts new file mode 100644 index 0000000000000..c45c950e4352e --- /dev/null +++ b/packages/ai-code-completion/src/common/code-completion-agent.ts @@ -0,0 +1,154 @@ +// ***************************************************************************** +// 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 { + Agent, CommunicationHistoryEntry, CommunicationRecordingService, getTextOfResponse, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequirement, PromptService, PromptTemplate +} from '@theia/ai-core/lib/common'; +import { CancellationToken, generateUuid, ILogger } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import * as monaco from '@theia/monaco-editor-core'; + +export const CodeCompletionAgent = Symbol('CodeCompletionAgent'); +export interface CodeCompletionAgent extends Agent { + provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position, + context: monaco.languages.CompletionContext, token: monaco.CancellationToken): Promise; + provideInlineCompletions?(model: monaco.editor.ITextModel, position: monaco.Position, + context: monaco.languages.InlineCompletionContext, token: monaco.CancellationToken): Promise +} + +@injectable() +export class CodeCompletionAgentImpl implements CodeCompletionAgent { + variables: string[] = []; + + @inject(ILogger) @named('code-completion-agent') + protected logger: ILogger; + + @inject(LanguageModelRegistry) + protected languageModelRegistry: LanguageModelRegistry; + + @inject(PromptService) + protected promptService: PromptService; + + @inject(CommunicationRecordingService) + protected recordingService: CommunicationRecordingService; + + async provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position, + context: monaco.languages.CompletionContext, token: CancellationToken): Promise { + + const languageModel = await this.languageModelRegistry.selectLanguageModel({ + agent: this.id, + ...this.languageModelRequirements[0] + }); + if (!languageModel) { + this.logger.error('No language model found for code-completion-agent'); + return undefined; + } + + // Get text until the given position + const textUntilPosition = model.getValueInRange({ + startLineNumber: 1, + startColumn: 1, + endLineNumber: position.lineNumber, + endColumn: position.column + }); + + // Get text after the given position + const textAfterPosition = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: model.getLineCount(), + endColumn: model.getLineMaxColumn(model.getLineCount()) + }); + + const snippet = `${textUntilPosition}{{MARKER}}${textAfterPosition}`; + const file = model.uri.toString(false); + const language = model.getLanguageId(); + + if (token.isCancellationRequested) { + return undefined; + } + const prompt = await this.promptService.getPrompt('code-completion-prompt', { snippet, file, language }).then(p => p?.text); + if (!prompt) { + this.logger.error('No prompt found for code-completion-agent'); + return undefined; + } + + // since we do not actually hold complete conversions, the request/response pair is considered a session + const sessionId = generateUuid(); + const requestId = generateUuid(); + const request: LanguageModelRequest = { messages: [{ type: 'text', actor: 'user', query: prompt }], cancellationToken: token }; + const requestEntry: CommunicationHistoryEntry = { + agentId: this.id, + sessionId, + timestamp: Date.now(), + requestId, + request: prompt + }; + if (token.isCancellationRequested) { + return undefined; + } + this.recordingService.recordRequest(requestEntry); + const response = await languageModel.request(request); + if (token.isCancellationRequested) { + return undefined; + } + const completionText = await getTextOfResponse(response); + if (token.isCancellationRequested) { + return undefined; + } + this.recordingService.recordResponse({ + agentId: this.id, + sessionId, + timestamp: Date.now(), + requestId, + response: completionText + }); + + const suggestions: monaco.languages.CompletionItem[] = []; + const completionItem: monaco.languages.CompletionItem = { + preselect: true, + label: `${completionText.substring(0, 20)}`, + detail: 'AI Generated', + documentation: `Generated via ${languageModel.id}`, + kind: monaco.languages.CompletionItemKind.Text, + insertText: completionText, + range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column) + }; + suggestions.push(completionItem); + return { suggestions }; + + }; + id: string = 'code-completion-agent'; + name: string = 'Code Completion Agent'; + description: string = 'This agent provides code completions for a given code snippet.'; + promptTemplates: PromptTemplate[] = [ + { + id: 'code-completion-prompt', + template: `You are a code completion agent. The current file you have to complete is named \${file}. +The language of the file is \${language}. Return your result as plain text without markdown formatting. +Finish the following code snippet. + +\${snippet} + +Only return the exact replacement for {{MARKER}} to complete the snippet.`, + } + ]; + languageModelRequirements: LanguageModelRequirement[] = [{ + purpose: 'code-completion', + identifier: 'openai/gpt-4o' + }]; +} diff --git a/packages/ai-code-completion/src/package.spec.ts b/packages/ai-code-completion/src/package.spec.ts new file mode 100644 index 0000000000000..fec76f95059b4 --- /dev/null +++ b/packages/ai-code-completion/src/package.spec.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2024 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 +// ***************************************************************************** + +/* note: this bogus test file is required so that + we are able to run mocha unit tests on this + package, without having any actual unit tests in it. + This way a coverage report will be generated, + showing 0% coverage, instead of no report. + This file can be removed once we have real unit + tests in place. */ + +describe('ai-code-completion package', () => { + + it('support code coverage statistics', () => true); +}); diff --git a/packages/ai-code-completion/tsconfig.json b/packages/ai-code-completion/tsconfig.json new file mode 100644 index 0000000000000..548b369565b41 --- /dev/null +++ b/packages/ai-code-completion/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-core" + }, + { + "path": "../core" + }, + { + "path": "../filesystem" + }, + { + "path": "../output" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-core/.eslintrc.js b/packages/ai-core/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-core/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-core/README.md b/packages/ai-core/README.md new file mode 100644 index 0000000000000..1cd399aaff0e1 --- /dev/null +++ b/packages/ai-core/README.md @@ -0,0 +1,30 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Core EXTENSION

+ +
+ +
+ +## Description + +The `@theia/ai-core` extension serves as the basis of all AI integration in Theia. +It manages the integration of language models and provides core concepts like agents, prompts and AI variables. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-core/data/prompttemplate.tmLanguage.json b/packages/ai-core/data/prompttemplate.tmLanguage.json new file mode 100644 index 0000000000000..e0313be58c0be --- /dev/null +++ b/packages/ai-core/data/prompttemplate.tmLanguage.json @@ -0,0 +1,52 @@ +{ + "scopeName": "source.prompttemplate", + "patterns": [ + { + "name": "variable.other.prompttemplate", + "begin": "\\${", + "beginCaptures": { + "0": { + "name": "punctuation.definition.brace.begin" + } + }, + "end": "}", + "endCaptures": { + "0": { + "name": "punctuation.definition.brace.end" + } + }, + "patterns": [ + { + "name": "keyword.control", + "match": "[a-zA-Z_][a-zA-Z0-9_]*" + } + ] + }, + { + "name": "support.function.prompttemplate", + "begin": "~{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.brace.begin" + } + }, + "end": "}", + "endCaptures": { + "0": { + "name": "punctuation.definition.brace.end" + } + }, + "patterns": [ + { + "name": "keyword.control", + "match": "[a-zA-Z_][a-zA-Z0-9_\\-]*" + } + ] + } + ], + "repository": {}, + "name": "PromptTemplate", + "fileTypes": [ + ".prompttemplate" + ] +} diff --git a/packages/ai-core/package.json b/packages/ai-core/package.json new file mode 100644 index 0000000000000..6ae34cfde88d4 --- /dev/null +++ b/packages/ai-core/package.json @@ -0,0 +1,58 @@ +{ + "name": "@theia/ai-core", + "version": "1.52.0", + "description": "Theia - AI Core", + "dependencies": { + "@theia/core": "1.52.0", + "@theia/editor": "1.52.0", + "@theia/filesystem": "1.52.0", + "@theia/monaco": "1.52.0", + "@theia/monaco-editor-core": "1.83.101", + "@theia/output": "1.52.0", + "@theia/variable-resolver": "1.52.0", + "@theia/workspace": "1.52.0", + "minimatch": "^5.1.0", + "tslib": "^2.6.2" + }, + "main": "lib/common", + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/ai-core-frontend-module", + "backend": "lib/node/ai-core-backend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "data", + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.52.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-core/src/browser/ai-activation-service.ts b/packages/ai-core/src/browser/ai-activation-service.ts new file mode 100644 index 0000000000000..75b683e711bf0 --- /dev/null +++ b/packages/ai-core/src/browser/ai-activation-service.ts @@ -0,0 +1,55 @@ +// ***************************************************************************** +// 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { FrontendApplicationContribution, PreferenceService } from '@theia/core/lib/browser'; +import { Emitter, MaybePromise, CommandHandler, Event, } from '@theia/core'; +import { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service'; +import { PREFERENCE_NAME_ENABLE_EXPERIMENTAL } from './ai-core-preferences'; + +export const EXPERIMENTAL_AI_CONTEXT_KEY = 'ai.experimental.enabled'; + +@injectable() +export class AIActivationService implements FrontendApplicationContribution { + @inject(ContextKeyService) + protected readonly contextKeyService: ContextKeyService; + + @inject(PreferenceService) + protected preferenceService: PreferenceService; + + protected isExperimentalEnabledKey: ContextKey; + + protected onDidChangeExperimentalEmitter = new Emitter(); + get onDidChangeActiveStatus(): Event { + return this.onDidChangeExperimentalEmitter.event; + } + + get isActive(): boolean { + return this.isExperimentalEnabledKey.get() ?? false; + } + + initialize(): MaybePromise { + this.isExperimentalEnabledKey = this.contextKeyService.createKey('ai.experimental.enabled', false); + this.preferenceService.onPreferenceChanged(e => { + if (e.preferenceName === PREFERENCE_NAME_ENABLE_EXPERIMENTAL) { + this.isExperimentalEnabledKey.set(e.newValue); + this.onDidChangeExperimentalEmitter.fire(e.newValue); + } + }); + } +} + +export type AICommandHandlerFactory = (handler: CommandHandler) => CommandHandler; +export const AICommandHandlerFactory = Symbol('AICommandHandlerFactory'); 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 new file mode 100644 index 0000000000000..0ebfc66a7e3d9 --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx @@ -0,0 +1,154 @@ +// ***************************************************************************** +// 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 { codicon, ReactWidget } from '@theia/core/lib/browser'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { Agent, LanguageModel, LanguageModelRegistry, PromptCustomizationService } from '../../common'; +import { AISettingsService } from '../ai-settings-service'; +import { LanguageModelRenderer } from './language-model-renderer'; +import { TemplateRenderer } from './template-settings-renderer'; +import { AIConfigurationSelectionService } from './ai-configuration-service'; +import { AIVariableConfigurationWidget } from './variable-configuration-widget'; +import { AgentService } from '../../common/agent-service'; + +@injectable() +export class AIAgentConfigurationWidget extends ReactWidget { + + static readonly ID = 'ai-agent-configuration-container-widget'; + static readonly LABEL = 'Agents'; + + @inject(AgentService) + protected readonly agentService: AgentService; + + @inject(LanguageModelRegistry) + protected readonly languageModelRegistry: LanguageModelRegistry; + + @inject(PromptCustomizationService) + protected readonly promptCustomizationService: PromptCustomizationService; + + @inject(AISettingsService) + protected readonly aiSettingsService: AISettingsService; + + @inject(AIConfigurationSelectionService) + protected readonly aiConfigurationSelectionService: AIConfigurationSelectionService; + + protected languageModels: LanguageModel[] | undefined; + + @postConstruct() + protected init(): void { + this.id = AIAgentConfigurationWidget.ID; + this.title.label = AIAgentConfigurationWidget.LABEL; + this.title.closable = false; + + this.languageModelRegistry.getLanguageModels().then(models => { + this.languageModels = models ?? []; + this.update(); + }); + this.toDispose.push(this.languageModelRegistry.onChange(({ models }) => { + this.languageModels = models; + this.update(); + })); + + this.aiSettingsService.onDidChange(() => this.update()); + this.aiConfigurationSelectionService.onDidAgentChange(() => this.update()); + this.update(); + } + + protected render(): React.ReactNode { + return
+
+
    + {this.agentService.getAgents(true).map(agent => +
  • this.setActiveAgent(agent)}>{agent.name}
  • + )} +
+
+
+ {this.renderAgentDetails()} +
+
; + } + + private renderAgentDetails(): React.ReactNode { + const agent = this.aiConfigurationSelectionService.getActiveAgent(); + if (!agent) { + return
Please select an Agent first!
; + } + + const enabled = this.agentService.isEnabled(agent.id); + + return
+
{agent.name}
+
{agent.description}
+
+ +
+
+ Variables: +
    + {agent.variables.map(variableId =>
  • +
    { this.showVariableConfigurationTab(); }} className='variable-reference'> + {variableId} + +
  • )} +
+
+
+ {agent.promptTemplates?.map(template => + )} +
+
+ +
+
; + } + + protected showVariableConfigurationTab(): void { + this.aiConfigurationSelectionService.selectConfigurationTab(AIVariableConfigurationWidget.ID); + } + + protected setActiveAgent(agent: Agent): void { + this.aiConfigurationSelectionService.setActiveAgent(agent); + this.update(); + } + + private toggleAgentEnabled = () => { + const agent = this.aiConfigurationSelectionService.getActiveAgent(); + if (!agent) { + return false; + } + const enabled = this.agentService.isEnabled(agent.id); + if (enabled) { + this.agentService.disableAgent(agent.id); + } else { + this.agentService.enableAgent(agent.id); + } + this.update(); + }; + +} diff --git a/packages/ai-core/src/browser/ai-configuration/ai-configuration-service.ts b/packages/ai-core/src/browser/ai-configuration/ai-configuration-service.ts new file mode 100644 index 0000000000000..bd364a9a1d3cd --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration/ai-configuration-service.ts @@ -0,0 +1,43 @@ +// ***************************************************************************** +// 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 { Emitter } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { Agent } from '../../common'; + +@injectable() +export class AIConfigurationSelectionService { + protected activeAgent?: Agent; + + protected readonly onDidSelectConfigurationEmitter = new Emitter(); + onDidSelectConfiguration = this.onDidSelectConfigurationEmitter.event; + + protected readonly onDidAgentChangeEmitter = new Emitter(); + onDidAgentChange = this.onDidSelectConfigurationEmitter.event; + + public getActiveAgent(): Agent | undefined { + return this.activeAgent; + } + + public setActiveAgent(agent?: Agent): void { + this.activeAgent = agent; + this.onDidAgentChangeEmitter.fire(agent); + } + + public selectConfigurationTab(widgetId: string): void { + this.onDidSelectConfigurationEmitter.fire(widgetId); + } +} diff --git a/packages/ai-core/src/browser/ai-configuration/ai-configuration-view-contribution.ts b/packages/ai-core/src/browser/ai-configuration/ai-configuration-view-contribution.ts new file mode 100644 index 0000000000000..4e6e371240f46 --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration/ai-configuration-view-contribution.ts @@ -0,0 +1,54 @@ +// ***************************************************************************** +// 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 { FrontendApplication } from '@theia/core/lib/browser'; +import { injectable } from '@theia/core/shared/inversify'; +import { AIViewContribution } from '../ai-view-contribution'; +import { AIConfigurationContainerWidget } from './ai-configuration-widget'; +import { Command, CommandRegistry } from '@theia/core'; + +export const AI_CONFIGURATION_TOGGLE_COMMAND_ID = 'aiConfiguration:toggle'; +export const OPEN_AI_CONFIG_VIEW = Command.toLocalizedCommand({ + id: 'aiConfiguration:open', + label: 'Open AI Configuration view', +}); + +@injectable() +export class AIAgentConfigurationViewContribution extends AIViewContribution { + + constructor() { + super({ + widgetId: AIConfigurationContainerWidget.ID, + widgetName: AIConfigurationContainerWidget.LABEL, + defaultWidgetOptions: { + area: 'main', + rank: 100 + }, + toggleCommandId: AI_CONFIGURATION_TOGGLE_COMMAND_ID + }); + } + + async initializeLayout(_app: FrontendApplication): Promise { + await this.openView(); + } + + override registerCommands(commands: CommandRegistry): void { + super.registerCommands(commands); + commands.registerCommand(OPEN_AI_CONFIG_VIEW, { + execute: () => this.openView({ activate: true }), + }); + } +} + diff --git a/packages/ai-core/src/browser/ai-configuration/ai-configuration-widget.tsx b/packages/ai-core/src/browser/ai-configuration/ai-configuration-widget.tsx new file mode 100644 index 0000000000000..909c822d8df47 --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration/ai-configuration-widget.tsx @@ -0,0 +1,80 @@ +// ***************************************************************************** +// 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 { BaseWidget, BoxLayout, codicon, DockPanel, WidgetManager } from '@theia/core/lib/browser'; +import { TheiaDockPanel } from '@theia/core/lib/browser/shell/theia-dock-panel'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import '../../../src/browser/style/index.css'; +import { AIAgentConfigurationWidget } from './agent-configuration-widget'; +import { AIVariableConfigurationWidget } from './variable-configuration-widget'; +import { AIConfigurationSelectionService } from './ai-configuration-service'; + +@injectable() +export class AIConfigurationContainerWidget extends BaseWidget { + + static readonly ID = 'ai-configuration'; + static readonly LABEL = '✨ AI Configuration [Experimental]'; + protected dockpanel: DockPanel; + + @inject(TheiaDockPanel.Factory) + protected readonly dockPanelFactory: TheiaDockPanel.Factory; + @inject(WidgetManager) + protected readonly widgetManager: WidgetManager; + @inject(AIConfigurationSelectionService) + protected readonly aiConfigurationSelectionService: AIConfigurationSelectionService; + + protected agentsWidget: AIAgentConfigurationWidget; + protected variablesWidget: AIVariableConfigurationWidget; + + @postConstruct() + protected init(): void { + this.id = AIConfigurationContainerWidget.ID; + this.title.label = AIConfigurationContainerWidget.LABEL; + this.title.closable = true; + this.addClass('theia-settings-container'); + this.title.iconClass = codicon('hubot'); + this.initUI(); + this.initListeners(); + } + + protected async initUI(): Promise { + const layout = (this.layout = new BoxLayout({ direction: 'top-to-bottom', spacing: 0 })); + this.dockpanel = this.dockPanelFactory({ + mode: 'multiple-document', + spacing: 0 + }); + BoxLayout.setStretch(this.dockpanel, 1); + layout.addWidget(this.dockpanel); + this.dockpanel.addClass('ai-configuration-widget'); + + this.agentsWidget = await this.widgetManager.getOrCreateWidget(AIAgentConfigurationWidget.ID); + this.variablesWidget = await this.widgetManager.getOrCreateWidget(AIVariableConfigurationWidget.ID); + this.dockpanel.addWidget(this.agentsWidget); + this.dockpanel.addWidget(this.variablesWidget); + + this.update(); + } + + protected initListeners(): void { + this.aiConfigurationSelectionService.onDidSelectConfiguration(widgetId => { + if (widgetId === AIAgentConfigurationWidget.ID) { + this.dockpanel.activateWidget(this.agentsWidget); + } else if (widgetId === AIVariableConfigurationWidget.ID) { + this.dockpanel.activateWidget(this.variablesWidget); + } + }); + } +} diff --git a/packages/ai-core/src/browser/ai-configuration/language-model-renderer.tsx b/packages/ai-core/src/browser/ai-configuration/language-model-renderer.tsx new file mode 100644 index 0000000000000..c3ba5b2e06b50 --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration/language-model-renderer.tsx @@ -0,0 +1,113 @@ +// ***************************************************************************** +// 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 * as React from '@theia/core/shared/react'; +import { Agent, LanguageModelRequirement } from '../../common'; +import { LanguageModel, LanguageModelRegistry } from '../../common/language-model'; +import { AISettingsService } from '../ai-settings-service'; +import { Mutable } from '@theia/core'; + +export interface LanguageModelSettingsProps { + agent: Agent; + languageModels?: LanguageModel[]; + aiSettingsService: AISettingsService; + languageModelRegistry: LanguageModelRegistry; +} + +export const LanguageModelRenderer: React.FC = ( + { agent, languageModels, aiSettingsService, languageModelRegistry }) => { + + const findLanguageModelRequirement = (purpose: string): LanguageModelRequirement | undefined => { + const requirementSetting = aiSettingsService.getAgentSettings(agent.id); + return requirementSetting?.languageModelRequirements.find(e => e.purpose === purpose); + }; + + const [lmRequirementMap, setLmRequirementMap] = React.useState>({}); + + React.useEffect(() => { + const computeLmRequirementMap = async () => { + const map = await agent.languageModelRequirements.reduce(async (accPromise, curr) => { + const acc = await accPromise; + // take the agents requirements and override them with the user settings if present + const lmRequirement = findLanguageModelRequirement(curr.purpose) ?? curr; + // if no llm is selected through the identifier, see what would be the default + if (!lmRequirement.identifier) { + const llm = await languageModelRegistry.selectLanguageModel({ agent: agent.id, ...lmRequirement }); + (lmRequirement as Mutable).identifier = llm?.id; + } + acc[curr.purpose] = lmRequirement; + return acc; + }, Promise.resolve({} as Record)); + setLmRequirementMap(map); + }; + computeLmRequirementMap(); + }, []); + + const renderLanguageModelMetadata = (requirement: LanguageModelRequirement, index: number) => { + const languageModel = languageModels?.find(model => model.id === requirement.identifier); + if (!languageModel) { + return
; + } + + return <> +
{requirement.purpose}
+
+ {languageModel.id &&

Identifier: {languageModel.id}

} + {languageModel.name &&

Name: {languageModel.name}

} + {languageModel.vendor &&

Vendor: {languageModel.vendor}

} + {languageModel.version &&

Version: {languageModel.version}

} + {languageModel.family &&

Family: {languageModel.family}

} + {languageModel.maxInputTokens &&

Min Input Tokens: {languageModel.maxInputTokens}

} + {languageModel.maxOutputTokens &&

Max Output Tokens: {languageModel.maxOutputTokens}

} +
+ ; + + }; + + const onSelectedModelChange = (purpose: string, event: React.ChangeEvent): void => { + const newLmRequirementMap = { ...lmRequirementMap, [purpose]: { purpose, identifier: event.target.value } }; + aiSettingsService.updateAgentSettings(agent.id, { languageModelRequirements: Object.values(newLmRequirementMap) }); + setLmRequirementMap(newLmRequirementMap); + }; + + return
+ {Object.values(lmRequirementMap).map((requirements, index) => ( + +
Purpose:
+
+ {/* language model metadata */} + {renderLanguageModelMetadata(requirements, index)} + {/* language model selector */} + <> + + + +
+
+
+ ))} + +
; +}; diff --git a/packages/ai-core/src/browser/ai-configuration/template-settings-renderer.tsx b/packages/ai-core/src/browser/ai-configuration/template-settings-renderer.tsx new file mode 100644 index 0000000000000..01125ebf58e0a --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration/template-settings-renderer.tsx @@ -0,0 +1,39 @@ +// ***************************************************************************** +// 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 * as React from '@theia/core/shared/react'; +import { PromptCustomizationService } from '../../common/prompt-service'; +import { PromptTemplate } from '../../common'; + +export interface TemplateSettingProps { + agentId: string; + template: PromptTemplate; + promptCustomizationService: PromptCustomizationService; +} + +export const TemplateRenderer: React.FC = ({ agentId, template, promptCustomizationService }) => { + const openTemplate = React.useCallback(async () => { + promptCustomizationService.editTemplate(template.id); + }, [template, promptCustomizationService]); + const resetTemplate = React.useCallback(async () => { + promptCustomizationService.resetTemplate(template.id); + }, [promptCustomizationService, template]); + + return <> + {template.id} + + + ; +}; diff --git a/packages/ai-core/src/browser/ai-configuration/variable-configuration-widget.tsx b/packages/ai-core/src/browser/ai-configuration/variable-configuration-widget.tsx new file mode 100644 index 0000000000000..64cbdfffe6122 --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration/variable-configuration-widget.tsx @@ -0,0 +1,110 @@ +// ***************************************************************************** +// 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 { codicon, ReactWidget } from '@theia/core/lib/browser'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { Agent, AIVariable, AIVariableService } from '../../common'; +import { AIAgentConfigurationWidget } from './agent-configuration-widget'; +import { AIConfigurationSelectionService } from './ai-configuration-service'; +import { AgentService } from '../../common/agent-service'; + +@injectable() +export class AIVariableConfigurationWidget extends ReactWidget { + + static readonly ID = 'ai-variable-configuration-container-widget'; + static readonly LABEL = 'Variables'; + + @inject(AIVariableService) + protected readonly variableService: AIVariableService; + + @inject(AgentService) + protected readonly agentService: AgentService; + + @inject(AIConfigurationSelectionService) + protected readonly aiConfigurationSelectionService: AIConfigurationSelectionService; + + @postConstruct() + protected init(): void { + this.id = AIVariableConfigurationWidget.ID; + this.title.label = AIVariableConfigurationWidget.LABEL; + this.title.closable = false; + this.update(); + this.toDispose.push(this.variableService.onDidChangeVariables(() => this.update())); + } + + protected render(): React.ReactNode { + return
+
    + {this.variableService.getVariables().map(variable => +
  • +
    {variable.name}
    + {variable.id} + {variable.description} + {this.renderReferencedVariables(variable)} + {this.renderArgs(variable)} +
  • + )} +
+
; + } + + protected renderReferencedVariables(variable: AIVariable): React.ReactNode | undefined { + const agents = this.getAgentsForVariable(variable); + if (agents.length === 0) { + return; + } + + return
+

Agents

+
    + {agents.map(agent =>
  • +
    { this.showAgentConfiguration(agent); }} className='variable-reference'> + {agent.name} + +
  • )} +
+
; + } + + protected renderArgs(variable: AIVariable): React.ReactNode | undefined { + if (variable.args === undefined || variable.args.length === 0) { + return; + } + + return
+

Variable Arguments

+
+ {variable.args.map(arg => + + {arg.name} + {arg.description} + + )} +
+
; + } + + protected showAgentConfiguration(agent: Agent): void { + this.aiConfigurationSelectionService.setActiveAgent(agent); + this.aiConfigurationSelectionService.selectConfigurationTab(AIAgentConfigurationWidget.ID); + } + + protected getAgentsForVariable(variable: AIVariable): Agent[] { + return this.agentService.getAgents().filter(a => a.variables?.includes(variable.id)); + } +} + diff --git a/packages/ai-core/src/browser/ai-core-frontend-application-contribution.ts b/packages/ai-core/src/browser/ai-core-frontend-application-contribution.ts new file mode 100644 index 0000000000000..2b5f4bdfbbb18 --- /dev/null +++ b/packages/ai-core/src/browser/ai-core-frontend-application-contribution.ts @@ -0,0 +1,40 @@ +// ***************************************************************************** +// 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 { FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { PromptService } from '../common'; +import { AgentService } from '../common/agent-service'; + +@injectable() +export class AICoreFrontendApplicationContribution implements FrontendApplicationContribution { + @inject(AgentService) + private readonly agentService: AgentService; + + @inject(PromptService) + private readonly promptService: PromptService; + + onStart(): void { + this.agentService.getAgents(true).forEach(a => { + a.promptTemplates.forEach(t => { + this.promptService.storePrompt(t.id, t.template); + }); + }); + } + + onStop(): void { + } +} diff --git a/packages/ai-core/src/browser/ai-core-frontend-module.ts b/packages/ai-core/src/browser/ai-core-frontend-module.ts new file mode 100644 index 0000000000000..c4dcad510c263 --- /dev/null +++ b/packages/ai-core/src/browser/ai-core-frontend-module.ts @@ -0,0 +1,159 @@ +// ***************************************************************************** +// 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 { bindContributionProvider, CommandContribution, CommandHandler } from '@theia/core'; +import { + RemoteConnectionProvider, + ServiceConnectionProvider, +} from '@theia/core/lib/browser/messaging/service-connection-provider'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { + AIVariableContribution, + AIVariableService, + FunctionCallRegistry, + FunctionCallRegistryImpl, + LanguageModelDelegateClient, + languageModelDelegatePath, + LanguageModelFrontendDelegate, + LanguageModelProvider, + LanguageModelRegistry, + LanguageModelRegistryClient, + languageModelRegistryDelegatePath, + LanguageModelRegistryFrontendDelegate, + PromptCustomizationService, + PromptService, + PromptServiceImpl, + ToolProvider +} from '../common'; +import { + FrontendLanguageModelRegistryImpl, + LanguageModelDelegateClientImpl, +} from './frontend-language-model-registry'; + +import { bindViewContribution, FrontendApplicationContribution, WidgetFactory } 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 { AIAgentConfigurationWidget } from './ai-configuration/agent-configuration-widget'; +import { AIConfigurationSelectionService } from './ai-configuration/ai-configuration-service'; +import { AIAgentConfigurationViewContribution } from './ai-configuration/ai-configuration-view-contribution'; +import { AIConfigurationContainerWidget } from './ai-configuration/ai-configuration-widget'; +import { AIVariableConfigurationWidget } from './ai-configuration/variable-configuration-widget'; +import { AICoreFrontendApplicationContribution } from './ai-core-frontend-application-contribution'; +import { bindAICorePreferences } from './ai-core-preferences'; +import { AISettingsService } from './ai-settings-service'; +import { FrontendPromptCustomizationServiceImpl } from './frontend-prompt-customization-service'; +import { FrontendVariableService } from './frontend-variable-service'; +import { PromptTemplateContribution } from './prompttemplate-contribution'; +import { TomorrowVariableContribution } from '../common/tomorrow-variable-contribution'; +import { TheiaVariableContribution } from './theia-variable-contribution'; +import { TodayVariableContribution } from '../common/today-variable-contribution'; +import { AgentsVariableContribution } from '../common/agents-variable-contribution'; +import { AIActivationService, AICommandHandlerFactory } from './ai-activation-service'; +import { AgentService, AgentServiceImpl } from '../common/agent-service'; + +export default new ContainerModule(bind => { + bindContributionProvider(bind, LanguageModelProvider); + + bind(FrontendLanguageModelRegistryImpl).toSelf().inSingletonScope(); + bind(LanguageModelRegistry).toService(FrontendLanguageModelRegistryImpl); + + bind(LanguageModelDelegateClientImpl).toSelf().inSingletonScope(); + bind(LanguageModelDelegateClient).toService(LanguageModelDelegateClientImpl); + bind(LanguageModelRegistryClient).toService(LanguageModelDelegateClient); + + bind(LanguageModelRegistryFrontendDelegate).toDynamicValue( + ctx => { + const connection = ctx.container.get(RemoteConnectionProvider); + const client = ctx.container.get(LanguageModelRegistryClient); + return connection.createProxy(languageModelRegistryDelegatePath, client); + } + ); + + bind(LanguageModelFrontendDelegate) + .toDynamicValue(ctx => { + const connection = ctx.container.get(RemoteConnectionProvider); + const client = ctx.container.get(LanguageModelDelegateClient); + return connection.createProxy(languageModelDelegatePath, client); + }) + .inSingletonScope(); + + bindAICorePreferences(bind); + + bind(FrontendPromptCustomizationServiceImpl).toSelf().inSingletonScope(); + bind(PromptCustomizationService).toService(FrontendPromptCustomizationServiceImpl); + bind(PromptServiceImpl).toSelf().inSingletonScope(); + bind(PromptService).toService(PromptServiceImpl); + + bind(PromptTemplateContribution).toSelf().inSingletonScope(); + bind(LanguageGrammarDefinitionContribution).toService(PromptTemplateContribution); + bind(CommandContribution).toService(PromptTemplateContribution); + bind(TabBarToolbarContribution).toService(PromptTemplateContribution); + + bind(AIConfigurationSelectionService).toSelf().inSingletonScope(); + bind(AIConfigurationContainerWidget).toSelf(); + bind(WidgetFactory) + .toDynamicValue(ctx => ({ + id: AIConfigurationContainerWidget.ID, + createWidget: () => ctx.container.get(AIConfigurationContainerWidget) + })) + .inSingletonScope(); + + bindViewContribution(bind, AIAgentConfigurationViewContribution); + bind(AISettingsService).toSelf().inRequestScope(); + bindContributionProvider(bind, AIVariableContribution); + bind(FrontendVariableService).toSelf().inSingletonScope(); + bind(AIVariableService).toService(FrontendVariableService); + bind(FrontendApplicationContribution).toService(FrontendVariableService); + bind(AIVariableContribution).to(TheiaVariableContribution).inSingletonScope(); + bind(AIVariableContribution).to(TodayVariableContribution).inSingletonScope(); + bind(AIVariableContribution).to(TomorrowVariableContribution).inSingletonScope(); + bind(AIVariableContribution).to(AgentsVariableContribution).inSingletonScope(); + + bind(FrontendApplicationContribution).to(AICoreFrontendApplicationContribution).inSingletonScope(); + + bind(AIVariableConfigurationWidget).toSelf(); + bind(WidgetFactory) + .toDynamicValue(ctx => ({ + id: AIVariableConfigurationWidget.ID, + createWidget: () => ctx.container.get(AIVariableConfigurationWidget) + })) + .inSingletonScope(); + + bind(AIAgentConfigurationWidget).toSelf(); + bind(WidgetFactory) + .toDynamicValue(ctx => ({ + id: AIAgentConfigurationWidget.ID, + createWidget: () => ctx.container.get(AIAgentConfigurationWidget) + })) + .inSingletonScope(); + + bind(FunctionCallRegistry).to(FunctionCallRegistryImpl).inSingletonScope(); + bindContributionProvider(bind, ToolProvider); + + bind(AIActivationService).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(AIActivationService); + bind(AgentServiceImpl).toSelf().inSingletonScope(); + bind(AgentService).toService(AgentServiceImpl); + + bind(AICommandHandlerFactory).toFactory(context => (handler: CommandHandler) => { + context.container.get(AIActivationService); + return { + execute: (...args: unknown[]) => handler.execute(...args), + isEnabled: (...args: unknown[]) => handler.isEnabled?.(...args) ?? true, + isVisible: (...args: unknown[]) => handler.isVisible?.(...args) ?? true, + isToggled: (...args: unknown[]) => handler.isToggled?.(...args) ?? false + }; + }); +}); diff --git a/packages/ai-core/src/browser/ai-core-preferences.ts b/packages/ai-core/src/browser/ai-core-preferences.ts new file mode 100644 index 0000000000000..4a003f1c95353 --- /dev/null +++ b/packages/ai-core/src/browser/ai-core-preferences.ts @@ -0,0 +1,74 @@ +// ***************************************************************************** +// 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 { PreferenceContribution, PreferenceProxy, PreferenceSchema } from '@theia/core/lib/browser'; +import { PreferenceProxyFactory } from '@theia/core/lib/browser/preferences/injectable-preference-proxy'; +import { interfaces } from '@theia/core/shared/inversify'; + +export const AI_CORE_PREFERENCES_TITLE = '✨ AI Features [Experimental]'; +export const PREFERENCE_NAME_ENABLE_EXPERIMENTAL = 'ai-features.ai-features.enable'; +export const PREFERENCE_NAME_PROMPT_TEMPLATES = 'ai-features.templates.templates-folder'; + +export const aiCorePreferenceSchema: PreferenceSchema = { + type: 'object', + properties: { + [PREFERENCE_NAME_ENABLE_EXPERIMENTAL]: { + title: AI_CORE_PREFERENCES_TITLE, + markdownDescription: '❗ This setting allows you to access and experiment with our latest AI capabilities.\ + \n\ + Please note that these features are in an experimental phase, which means they may be unstable,\ + undergo significant changes, or incur additional costs.\ + \n\ + By enabling this option, you acknowledge these risks and agree to provide feedback to help us improve.\ +  \n\ + **Please note! The settings below in this section will only take effect\n\ + once the main feature setting is enabled.**', + type: 'boolean', + default: false, + }, + [PREFERENCE_NAME_PROMPT_TEMPLATES]: { + title: AI_CORE_PREFERENCES_TITLE, + description: 'Folder for managing custom prompt templates. If not customized the user config directory is used.', + type: 'string', + default: '', + typeDetails: { + isFilepath: true, + selectionProps: { + openLabel: 'Select Folder', + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false + } + }, + + } + } +}; +export interface AICoreConfiguration { + [PREFERENCE_NAME_ENABLE_EXPERIMENTAL]: boolean | undefined; + [PREFERENCE_NAME_PROMPT_TEMPLATES]: string | undefined; +} + +export const AICorePreferences = Symbol('AICorePreferences'); +export type AICorePreferences = PreferenceProxy; + +export function bindAICorePreferences(bind: interfaces.Bind): void { + bind(AICorePreferences).toDynamicValue(ctx => { + const factory = ctx.container.get(PreferenceProxyFactory); + return factory(aiCorePreferenceSchema); + }).inSingletonScope(); + bind(PreferenceContribution).toConstantValue({ schema: aiCorePreferenceSchema }); +} diff --git a/packages/ai-core/src/browser/ai-settings-service.ts b/packages/ai-core/src/browser/ai-settings-service.ts new file mode 100644 index 0000000000000..5ea34f158791b --- /dev/null +++ b/packages/ai-core/src/browser/ai-settings-service.ts @@ -0,0 +1,56 @@ +// ***************************************************************************** +// 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 { DisposableCollection, Emitter, Event } from '@theia/core'; +import { PreferenceScope, PreferenceService } from '@theia/core/lib/browser'; +import { JSONObject } from '@theia/core/shared/@phosphor/coreutils'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { LanguageModelRequirement } from '../common'; + +@injectable() +export class AISettingsService { + @inject(PreferenceService) protected preferenceService: PreferenceService; + static readonly PREFERENCE_NAME = 'ai.settings'; + + protected toDispose = new DisposableCollection(); + + protected readonly onDidChangeEmitter = new Emitter(); + onDidChange: Event = this.onDidChangeEmitter.event; + + updateAgentSettings(agent: string, agentSettings: AgentSettings): void { + const settings = this.getSettings(); + settings.agents[agent] = agentSettings; + this.preferenceService.set(AISettingsService.PREFERENCE_NAME, settings, PreferenceScope.User); + this.onDidChangeEmitter.fire(); + } + + getAgentSettings(agent: string): AgentSettings | undefined { + const settings = this.getSettings(); + return settings.agents[agent]; + } + + getSettings(): AISettings { + const pref = this.preferenceService.inspect(AISettingsService.PREFERENCE_NAME); + return pref?.value ? pref.value : { agents: {} }; + } + +} +export interface AISettings extends JSONObject { + agents: Record +} + +interface AgentSettings extends JSONObject { + languageModelRequirements: LanguageModelRequirement[]; +} diff --git a/packages/ai-core/src/browser/ai-view-contribution.ts b/packages/ai-core/src/browser/ai-view-contribution.ts new file mode 100644 index 0000000000000..da7b731593463 --- /dev/null +++ b/packages/ai-core/src/browser/ai-view-contribution.ts @@ -0,0 +1,76 @@ +// ***************************************************************************** +// 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 { CommandRegistry, MenuModelRegistry } from '@theia/core'; +import { AbstractViewContribution, CommonMenus, KeybindingRegistry, PreferenceService, Widget } from '@theia/core/lib/browser'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import { AIActivationService, AICommandHandlerFactory, EXPERIMENTAL_AI_CONTEXT_KEY } from './ai-activation-service'; + +@injectable() +export class AIViewContribution extends AbstractViewContribution { + + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + @inject(AIActivationService) + protected readonly activationService: AIActivationService; + + @inject(AICommandHandlerFactory) + protected readonly commandHandlerFactory: AICommandHandlerFactory; + + @postConstruct() + protected init(): void { + this.activationService.onDidChangeActiveStatus(active => { + if (!active) { + this.closeView(); + } + }); + } + + override registerCommands(commands: CommandRegistry): void { + if (this.toggleCommand) { + + commands.registerCommand(this.toggleCommand, this.commandHandlerFactory({ + execute: () => this.toggleView(), + })); + } + this.quickView?.registerItem({ + label: this.viewLabel, + when: EXPERIMENTAL_AI_CONTEXT_KEY, + open: () => this.openView({ activate: true }) + }); + + } + + override registerMenus(menus: MenuModelRegistry): void { + if (this.toggleCommand) { + menus.registerMenuAction(CommonMenus.VIEW_VIEWS, { + commandId: this.toggleCommand.id, + when: EXPERIMENTAL_AI_CONTEXT_KEY, + label: this.viewLabel + }); + } + } + override registerKeybindings(keybindings: KeybindingRegistry): void { + if (this.toggleCommand && this.options.toggleKeybinding) { + keybindings.registerKeybinding({ + command: this.toggleCommand.id, + when: EXPERIMENTAL_AI_CONTEXT_KEY, + keybinding: this.options.toggleKeybinding + }); + } + } +} + diff --git a/packages/ai-core/src/browser/frontend-language-model-registry.ts b/packages/ai-core/src/browser/frontend-language-model-registry.ts new file mode 100644 index 0000000000000..42a89803627a6 --- /dev/null +++ b/packages/ai-core/src/browser/frontend-language-model-registry.ts @@ -0,0 +1,415 @@ +// ***************************************************************************** +// 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 { CancellationToken, ILogger } from '@theia/core'; +import { + inject, + injectable, + postConstruct, +} from '@theia/core/shared/inversify'; +import { + OutputChannel, + OutputChannelManager, + OutputChannelSeverity, +} from '@theia/output/lib/browser/output-channel'; +import { + DefaultLanguageModelRegistryImpl, + isLanguageModelParsedResponse, + isLanguageModelStreamResponse, + isLanguageModelStreamResponseDelegate, + isLanguageModelTextResponse, + isModelMatching, + LanguageModel, + LanguageModelDelegateClient, + LanguageModelFrontendDelegate, + LanguageModelMetaData, + LanguageModelRegistryClient, + LanguageModelRegistryFrontendDelegate, + LanguageModelRequest, + LanguageModelResponse, + LanguageModelSelector, + LanguageModelStreamResponsePart, +} from '../common'; +import { AISettingsService } from './ai-settings-service'; + +export interface TokenReceiver { + send(id: string, token: LanguageModelStreamResponsePart | undefined): void; +} +export interface ToolReceiver { + toolCall(id: string, toolId: string, arg_string: string): Promise; +} +export interface ModelReceiver { + languageModelAdded(metadata: LanguageModelMetaData): void; + languageModelRemoved(id: string): void; +} + +@injectable() +export class LanguageModelDelegateClientImpl + implements LanguageModelDelegateClient, LanguageModelRegistryClient { + protected receiver: TokenReceiver & ToolReceiver & ModelReceiver; + + setReceiver(receiver: TokenReceiver & ToolReceiver & ModelReceiver): void { + this.receiver = receiver; + } + + send(id: string, token: LanguageModelStreamResponsePart | undefined): void { + this.receiver.send(id, token); + } + + toolCall(requestId: string, toolId: string, args_string: string): Promise { + return this.receiver.toolCall(requestId, toolId, args_string); + } + + languageModelAdded(metadata: LanguageModelMetaData): void { + this.receiver.languageModelAdded(metadata); + } + + languageModelRemoved(id: string): void { + this.receiver.languageModelRemoved(id); + } +} + +interface StreamState { + id: string; + tokens: (LanguageModelStreamResponsePart | undefined)[]; + resolve?: (_: unknown) => void; +} + +@injectable() +export class FrontendLanguageModelRegistryImpl + extends DefaultLanguageModelRegistryImpl + implements TokenReceiver, ToolReceiver, ModelReceiver { + + // called by backend + languageModelAdded(metadata: LanguageModelMetaData): void { + this.addLanguageModels([metadata]); + } + // called by backend + languageModelRemoved(id: string): void { + this.removeLanguageModels([id]); + } + @inject(LanguageModelRegistryFrontendDelegate) + protected registryDelegate: LanguageModelRegistryFrontendDelegate; + + @inject(LanguageModelFrontendDelegate) + protected providerDelegate: LanguageModelFrontendDelegate; + + @inject(LanguageModelDelegateClientImpl) + protected client: LanguageModelDelegateClientImpl; + + @inject(ILogger) + protected override logger: ILogger; + + @inject(OutputChannelManager) + protected outputChannelManager: OutputChannelManager; + + @inject(AISettingsService) + protected settingsService: AISettingsService; + + private static requestCounter: number = 0; + + override addLanguageModels(models: LanguageModelMetaData[] | LanguageModel[]): void { + let modelAdded = false; + for (const model of models) { + if (this.languageModels.find(m => m.id === model.id)) { + console.warn(`Tried to add an existing model ${model.id}`); + continue; + } + if (LanguageModel.is(model)) { + this.languageModels.push( + new Proxy( + model, + languageModelOutputHandler( + this.outputChannelManager.getChannel( + model.id + ) + ) + ) + ); + modelAdded = true; + } else { + this.languageModels.push( + new Proxy( + this.createFrontendLanguageModel( + model + ), + languageModelOutputHandler( + this.outputChannelManager.getChannel( + model.id + ) + ) + ) + ); + modelAdded = true; + } + } + if (modelAdded) { + this.changeEmitter.fire({ models: this.languageModels }); + } + } + + @postConstruct() + protected override init(): void { + this.client.setReceiver(this); + + const contributions = + this.languageModelContributions.getContributions(); + const promises = contributions.map(provider => provider()); + const backendDescriptions = + this.registryDelegate.getLanguageModelDescriptions(); + + Promise.allSettled([backendDescriptions, ...promises]).then( + results => { + const backendDescriptionsResult = results[0]; + if (backendDescriptionsResult.status === 'fulfilled') { + this.addLanguageModels(backendDescriptionsResult.value); + } else { + this.logger.error( + 'Failed to add language models contributed from the backend', + backendDescriptionsResult.reason + ); + } + for (let i = 1; i < results.length; i++) { + // assert that index > 0 contains only language models + const languageModelResult = results[i] as + | PromiseRejectedResult + | PromiseFulfilledResult; + if (languageModelResult.status === 'fulfilled') { + this.addLanguageModels(languageModelResult.value); + } else { + this.logger.error( + 'Failed to add some language models:', + languageModelResult.reason + ); + } + } + this.markInitialized(); + } + ); + } + + createFrontendLanguageModel( + description: LanguageModelMetaData + ): LanguageModel { + return { + ...description, + request: async (request: LanguageModelRequest) => { + const requestId = `${FrontendLanguageModelRegistryImpl.requestCounter++}`; + this.requests.set(requestId, request); + request.cancellationToken?.onCancellationRequested(() => { + this.providerDelegate.cancel(requestId); + }); + const response = await this.providerDelegate.request( + description.id, + request, + requestId + ); + if (isLanguageModelTextResponse(response) || isLanguageModelParsedResponse(response)) { + return response; + } + if (isLanguageModelStreamResponseDelegate(response)) { + if (!this.streams.has(response.streamId)) { + const newStreamState = { + id: response.streamId, + tokens: [], + }; + this.streams.set(response.streamId, newStreamState); + } + const streamState = this.streams.get(response.streamId)!; + return { + stream: this.getIterable(streamState), + }; + } + this.logger.error( + `Received unknown response in frontend for request to language model ${description.id}. Trying to continue without touching the response.`, + response + ); + return response; + }, + }; + } + + private streams = new Map(); + private requests = new Map(); + + async *getIterable( + state: StreamState + ): AsyncIterable { + let current = -1; + while (true) { + if (current < state.tokens.length - 1) { + current++; + const token = state.tokens[current]; + if (token === undefined) { + // message is finished + break; + } + if (token !== undefined) { + yield token; + } + } else { + await new Promise(resolve => { + state.resolve = resolve; + }); + } + } + this.streams.delete(state.id); + } + + // called by backend via the "delegate client" with new tokens + send(id: string, token: LanguageModelStreamResponsePart | undefined): void { + if (!this.streams.has(id)) { + const newStreamState = { + id, + tokens: [], + }; + this.streams.set(id, newStreamState); + } + const streamState = this.streams.get(id)!; + streamState.tokens.push(token); + if (streamState.resolve) { + streamState.resolve(token); + } + } + + // called by backend once tool is invoked + toolCall(id: string, toolId: string, arg_string: string): Promise { + if (!this.requests.has(id)) { + throw new Error('Somehow we got a callback for a non existing request!'); + } + const request = this.requests.get(id)!; + const tool = request.tools?.find(t => t.id === toolId); + if (tool) { + return tool.handler(arg_string); + } + throw new Error(`Could not find a tool for ${toolId}!`); + } + + override async selectLanguageModels(request: LanguageModelSelector): Promise { + await this.initialized; + const userSettings = this.settingsService.getAgentSettings(request.agent)?.languageModelRequirements.find(req => req.purpose === request.purpose); + if (userSettings?.identifier) { + const model = await this.getLanguageModel(userSettings.identifier); + if (model) { + return [model]; + } + } + return this.languageModels.filter(model => isModelMatching(request, model)); + } + + override async selectLanguageModel(request: LanguageModelSelector): Promise { + return (await this.selectLanguageModels(request))[0]; + } +} + +const formatJsonWithIndentation = (obj: unknown): string[] => { + // eslint-disable-next-line no-null/no-null + const jsonString = JSON.stringify(obj, null, 2); + const lines = jsonString.split('\n'); + const formattedLines: string[] = []; + + lines.forEach(line => { + const subLines = line.split('\\n'); + const index = indexOfValue(subLines[0]) + 1; + formattedLines.push(subLines[0]); + const prefix = index > 0 ? ' '.repeat(index) : ''; + if (index !== -1) { + for (let i = 1; i < subLines.length; i++) { + formattedLines.push(prefix + subLines[i]); + } + } + }); + + return formattedLines; +}; + +const indexOfValue = (jsonLine: string): number => { + const pattern = /"([^"]+)"\s*:\s*/g; + const match = pattern.exec(jsonLine); + return match ? match.index + match[0].length : -1; +}; + +const languageModelOutputHandler = ( + outputChannel: OutputChannel +): ProxyHandler => ({ + get( + target: LanguageModel, + prop: K, + ): LanguageModel[K] | LanguageModel['request'] { + const original = target[prop]; + if (prop === 'request' && typeof original === 'function') { + return async function ( + ...args: Parameters + ): Promise { + outputChannel.appendLine( + 'Sending request:' + ); + const formattedRequest = formatJsonWithIndentation(args[0]); + formattedRequest.forEach(line => outputChannel.appendLine(line)); + if (args[0].cancellationToken) { + args[0].cancellationToken = new Proxy(args[0].cancellationToken, { + get( + cTarget: CancellationToken, + cProp: CK + ): CancellationToken[CK] | CancellationToken['onCancellationRequested'] { + if (cProp === 'onCancellationRequested') { + return (...cargs: Parameters) => cTarget.onCancellationRequested(() => { + outputChannel.appendLine('\nCancel requested', OutputChannelSeverity.Warning); + cargs[0](); + }, cargs[1], cargs[2]); + } + return cTarget[cProp]; + } + }); + } + try { + const result = await original.apply(target, args); + if (isLanguageModelStreamResponse(result)) { + outputChannel.appendLine('Received a response stream'); + const stream = result.stream; + const loggedStream = { + async *[Symbol.asyncIterator](): AsyncIterator { + for await (const part of stream) { + outputChannel.append(part.content || ''); + yield part; + } + outputChannel.append('\n'); + outputChannel.appendLine('End of stream'); + }, + }; + return { + ...result, + stream: loggedStream, + }; + } else { + outputChannel.appendLine('Received a response'); + outputChannel.appendLine(JSON.stringify(result)); + return result; + } + } catch (err) { + outputChannel.appendLine('An error occurred'); + if (err instanceof Error) { + outputChannel.appendLine( + err.message, + OutputChannelSeverity.Error + ); + } + throw err; + } + }; + } + return original; + }, +}); diff --git a/packages/ai-core/src/browser/frontend-prompt-customization-service.ts b/packages/ai-core/src/browser/frontend-prompt-customization-service.ts new file mode 100644 index 0000000000000..ec7ab0c7831bf --- /dev/null +++ b/packages/ai-core/src/browser/frontend-prompt-customization-service.ts @@ -0,0 +1,189 @@ +// ***************************************************************************** +// 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 { DisposableCollection, URI } 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 { 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'; + +@injectable() +export class FrontendPromptCustomizationServiceImpl implements PromptCustomizationService { + + @inject(EnvVariablesServer) + protected readonly envVariablesServer: EnvVariablesServer; + + @inject(AICorePreferences) + protected readonly preferences: AICorePreferences; + + @inject(FileService) + protected readonly fileService: FileService; + + @inject(OpenerService) + protected readonly openerService: OpenerService; + + @inject(AgentService) + protected readonly agentService: AgentService; + + protected readonly trackedTemplateURIs = new Set(); + protected readonly templates = new Map(); + + protected toDispose = new DisposableCollection(); + + @postConstruct() + protected init(): void { + this.preferences.onPreferenceChanged(event => { + if (event.preferenceName === PREFERENCE_NAME_PROMPT_TEMPLATES) { + this.update(); + } + }); + this.update(); + } + + protected async update(): Promise { + this.toDispose.dispose(); + this.templates.clear(); + this.trackedTemplateURIs.clear(); + + const templateURI = await this.getTemplatesDirectoryURI(); + + this.toDispose.push(this.fileService.watch(templateURI, { recursive: true, excludes: [] })); + this.toDispose.push(this.fileService.onDidFilesChange(async (event: FileChangesEvent) => { + + for (const child of this.trackedTemplateURIs) { + // check deletion and updates + if (event.contains(new URI(child))) { + for (const deletedFile of event.getDeleted()) { + if (this.trackedTemplateURIs.has(deletedFile.resource.toString())) { + this.trackedTemplateURIs.delete(deletedFile.resource.toString()); + this.templates.delete(deletedFile.resource.path.name); + } + } + for (const updatedFile of event.getUpdated()) { + if (this.trackedTemplateURIs.has(updatedFile.resource.toString())) { + const filecontent = await this.fileService.read(updatedFile.resource); + this.templates.set(this.removePromptTemplateSuffix(updatedFile.resource.path.name), filecontent.value); + } + } + } + } + + // check new templates + for (const addedFile of event.getAdded()) { + if (addedFile.resource.parent.toString() === templateURI.toString() && addedFile.resource.path.ext === '.prompttemplate') { + this.trackedTemplateURIs.add(addedFile.resource.toString()); + const filecontent = await this.fileService.read(addedFile.resource); + this.templates.set(this.removePromptTemplateSuffix(addedFile.resource.path.name), filecontent.value); + } + } + + })); + + const stat = await this.fileService.resolve(templateURI); + if (stat.children === undefined) { + return; + } + + for (const file of stat.children) { + if (!file.isFile) { + continue; + } + const fileURI = file.resource; + if (fileURI.path.ext === '.prompttemplate') { + this.trackedTemplateURIs.add(fileURI.toString()); + const filecontent = await this.fileService.read(fileURI); + this.templates.set(this.removePromptTemplateSuffix(file.name), filecontent.value); + } + } + } + + protected async getTemplatesDirectoryURI(): Promise { + const templatesFolder = this.preferences[PREFERENCE_NAME_PROMPT_TEMPLATES]; + if (templatesFolder && templatesFolder.trim().length > 0) { + return URI.fromFilePath(templatesFolder); + } + const theiaConfigDir = await this.envVariablesServer.getConfigDirUri(); + return new URI(theiaConfigDir).resolve('prompt-templates'); + } + + protected async getTemplateURI(templateId: string): Promise { + return (await this.getTemplatesDirectoryURI()).resolve(`${templateId}.prompttemplate`); + } + + protected removePromptTemplateSuffix(filename: string): string { + const suffix = '.prompttemplate'; + if (filename.endsWith(suffix)) { + return filename.slice(0, -suffix.length); + } + return filename; + } + + isPromptTemplateCustomized(id: string): boolean { + return this.templates.has(id); + } + + getCustomizedPromptTemplate(id: string): string | undefined { + return this.templates.get(id); + } + + async editTemplate(id: string, content?: string): Promise { + const template = this.getOriginalTemplate(id); + if (template === undefined) { + throw new Error(`Unable to edit template ${id}: template not found.`); + } + const editorUri = await this.getTemplateURI(id); + if (! await this.fileService.exists(editorUri)) { + await this.fileService.createFile(editorUri, BinaryBuffer.fromString(content ?? template.template)); + } else if (content) { + // Write content to the file before opening it + await this.fileService.writeFile(editorUri, BinaryBuffer.fromString(content)); + } + const openHandler = await this.openerService.getOpener(editorUri); + openHandler.open(editorUri); + } + + async resetTemplate(id: string): Promise { + const editorUri = await this.getTemplateURI(id); + if (await this.fileService.exists(editorUri)) { + await this.fileService.delete(editorUri); + } + } + + getOriginalTemplate(id: string): PromptTemplate | undefined { + for (const agent of this.agentService.getAgents(true)) { + for (const template of agent.promptTemplates) { + if (template.id === id) { + return template; + } + } + } + return undefined; + } + + getTemplateIDFromURI(uri: URI): string | undefined { + const id = this.removePromptTemplateSuffix(uri.path.name); + if (this.templates.has(id)) { + return id; + } + return undefined; + } + +} diff --git a/packages/ai-core/src/browser/frontend-variable-service.ts b/packages/ai-core/src/browser/frontend-variable-service.ts new file mode 100644 index 0000000000000..56ceda7e4edd8 --- /dev/null +++ b/packages/ai-core/src/browser/frontend-variable-service.ts @@ -0,0 +1,26 @@ +// ***************************************************************************** +// 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 { injectable } from '@theia/core/shared/inversify'; +import { DefaultAIVariableService } from '../common'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser'; + +@injectable() +export class FrontendVariableService extends DefaultAIVariableService implements FrontendApplicationContribution { + onStart(): void { + this.initContributions(); + } +} diff --git a/packages/ai-core/src/browser/index.ts b/packages/ai-core/src/browser/index.ts new file mode 100644 index 0000000000000..443f3894e72f4 --- /dev/null +++ b/packages/ai-core/src/browser/index.ts @@ -0,0 +1,26 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** + +export * from './ai-activation-service'; +export * from './ai-core-frontend-application-contribution'; +export * from './ai-core-frontend-module'; +export * from './ai-core-preferences'; +export * from './ai-settings-service'; +export * from './ai-view-contribution'; +export * from './frontend-language-model-registry'; +export * from './frontend-variable-service'; +export * from './prompttemplate-contribution'; +export * from './theia-variable-contribution'; diff --git a/packages/ai-core/src/browser/prompttemplate-contribution.ts b/packages/ai-core/src/browser/prompttemplate-contribution.ts new file mode 100644 index 0000000000000..d3cf4f99a6814 --- /dev/null +++ b/packages/ai-core/src/browser/prompttemplate-contribution.ts @@ -0,0 +1,250 @@ +// ***************************************************************************** +// 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 { inject, injectable, named } from '@theia/core/shared/inversify'; +import { GrammarDefinition, GrammarDefinitionProvider, LanguageGrammarDefinitionContribution, TextmateRegistry } from '@theia/monaco/lib/browser/textmate'; +import * as monaco from '@theia/monaco-editor-core'; +import { Command, CommandContribution, CommandRegistry, ContributionProvider, MessageService } from '@theia/core'; +import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; + +import { codicon, Widget } from '@theia/core/lib/browser'; +import { EditorWidget, ReplaceOperation } from '@theia/editor/lib/browser'; +import { PromptCustomizationService, PromptService, ToolProvider } from '../common'; +import { ProviderResult } from '@theia/monaco-editor-core/esm/vs/editor/common/languages'; + +const PROMPT_TEMPLATE_LANGUAGE_ID = 'theia-ai-prompt-template'; +const PROMPT_TEMPLATE_TEXTMATE_SCOPE = 'source.prompttemplate'; + +export const DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS: Command = { + id: 'theia-ai-prompt-template:discard', + iconClass: codicon('discard'), + category: 'Theia AI Prompt Templates' +}; + +// TODO this command is mainly for testing purposes +export const SHOW_ALL_PROMPTS_COMMAND: Command = { + id: 'theia-ai-prompt-template:show-prompts-command', + label: 'Show all prompts', + iconClass: codicon('beaker'), + category: 'Theia AI Prompt Templates', +}; + +@injectable() +export class PromptTemplateContribution implements LanguageGrammarDefinitionContribution, CommandContribution, TabBarToolbarContribution { + + @inject(PromptService) + private readonly promptService: PromptService; + + @inject(MessageService) + private readonly messageService: MessageService; + + @inject(PromptCustomizationService) + protected readonly customizationService: PromptCustomizationService; + + @inject(ContributionProvider) + @named(ToolProvider) + private toolProviders: ContributionProvider; + + readonly config: monaco.languages.LanguageConfiguration = + { + 'brackets': [ + ['${', '}'], + ['~{', '}'] + ], + 'autoClosingPairs': [ + { 'open': '${', 'close': '}' }, + { 'open': '~{', 'close': '}' }, + ], + 'surroundingPairs': [ + { 'open': '${', 'close': '}' }, + { 'open': '~{', 'close': '}' } + ] + }; + + registerTextmateLanguage(registry: TextmateRegistry): void { + monaco.languages.register({ + id: PROMPT_TEMPLATE_LANGUAGE_ID, + 'aliases': [ + 'Theia AI Prompt Templates' + ], + 'extensions': [ + '.prompttemplate', + ], + 'filenames': [] + }); + + monaco.languages.setLanguageConfiguration(PROMPT_TEMPLATE_LANGUAGE_ID, this.config); + + monaco.languages.registerCompletionItemProvider(PROMPT_TEMPLATE_LANGUAGE_ID, { + // Monaco only supports single character trigger characters + triggerCharacters: ['{'], + provideCompletionItems: (model, position, _context, _token): ProviderResult => this.provideFunctionCompletions(model, position), + }); + + const textmateGrammar = require('../../data/prompttemplate.tmLanguage.json'); + const grammarDefinitionProvider: GrammarDefinitionProvider = { + getGrammarDefinition: function (): Promise { + return Promise.resolve({ + format: 'json', + content: textmateGrammar + }); + } + }; + registry.registerTextmateGrammarScope(PROMPT_TEMPLATE_TEXTMATE_SCOPE, grammarDefinitionProvider); + + registry.mapLanguageIdToTextmateGrammar(PROMPT_TEMPLATE_LANGUAGE_ID, PROMPT_TEMPLATE_TEXTMATE_SCOPE); + } + + provideFunctionCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult { + return this.getSuggestions( + model, + position, + '~{', + this.toolProviders.getContributions().map(provider => provider.getTool()), + monaco.languages.CompletionItemKind.Function, + tool => tool.id, + tool => tool.name, + tool => tool.description ?? '' + ); + } + + getCompletionRange(model: monaco.editor.ITextModel, position: monaco.Position, triggerCharacters: string): monaco.Range | undefined { + // Check if the characters before the current position are the trigger characters + const lineContent = model.getLineContent(position.lineNumber); + const triggerLength = triggerCharacters.length; + const charactersBefore = lineContent.substring( + position.column - triggerLength - 1, + position.column - 1 + ); + + if (charactersBefore !== triggerCharacters) { + // Do not return agent suggestions if the user didn't just type the trigger characters + return undefined; + } + + // Calculate the range from the position of the trigger characters + const wordInfo = model.getWordUntilPosition(position); + return new monaco.Range( + position.lineNumber, + wordInfo.startColumn, + position.lineNumber, + position.column + ); + } + + private getSuggestions( + model: monaco.editor.ITextModel, + position: monaco.Position, + triggerChars: string, + items: T[], + kind: monaco.languages.CompletionItemKind, + getId: (item: T) => string, + getName: (item: T) => string, + getDescription: (item: T) => string + ): ProviderResult { + const completionRange = this.getCompletionRange(model, position, triggerChars); + if (completionRange === undefined) { + return { suggestions: [] }; + } + const suggestions = items.map(item => ({ + insertText: getId(item), + kind: kind, + label: getName(item), + range: completionRange, + detail: getDescription(item), + })); + return { suggestions }; + } + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS, { + isVisible: (widget: Widget) => this.isPromptTemplateWidget(widget), + isEnabled: (widget: EditorWidget) => this.canDiscard(widget), + execute: (widget: EditorWidget) => this.discard(widget) + }); + + commands.registerCommand(SHOW_ALL_PROMPTS_COMMAND, { + execute: () => this.showAllPrompts() + }); + } + + protected isPromptTemplateWidget(widget: Widget): boolean { + if (widget instanceof EditorWidget) { + return PROMPT_TEMPLATE_LANGUAGE_ID === widget.editor.document.languageId; + } + return false; + } + + protected canDiscard(widget: EditorWidget): boolean { + const resourceUri = widget.editor.uri; + const id = this.customizationService.getTemplateIDFromURI(resourceUri); + if (id === undefined) { + return false; + } + const rawPrompt = this.promptService.getRawPrompt(id); + const defaultPrompt = this.promptService.getDefaultRawPrompt(id); + return rawPrompt?.template !== defaultPrompt?.template; + } + + protected async discard(widget: EditorWidget): Promise { + const resourceUri = widget.editor.uri; + const id = this.customizationService.getTemplateIDFromURI(resourceUri); + if (id === undefined) { + return; + } + const defaultPrompt = this.promptService.getDefaultRawPrompt(id); + if (defaultPrompt === undefined) { + return; + } + + const source: string = widget.editor.document.getText(); + const lastLine = widget.editor.document.getLineContent(widget.editor.document.lineCount); + + const replaceOperation: ReplaceOperation = { + range: { + start: { + line: 0, + character: 0 + }, + end: { + line: widget.editor.document.lineCount, + character: lastLine.length + } + }, + text: defaultPrompt.template + }; + + await widget.editor.replaceText({ + source, + replaceOperations: [replaceOperation] + }); + } + + private showAllPrompts(): void { + const allPrompts = this.promptService.getAllPrompts(); + Object.keys(allPrompts).forEach(id => { + this.messageService.info(`Prompt Template ID: ${id}\n${allPrompts[id].template}`, 'Got it'); + }); + } + + registerToolbarItems(registry: TabBarToolbarRegistry): void { + registry.registerItem({ + id: DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS.id, + command: DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS.id, + tooltip: 'Discard Customizations' + }); + } +} diff --git a/packages/ai-core/src/browser/style/index.css b/packages/ai-core/src/browser/style/index.css new file mode 100644 index 0000000000000..cddbdb327c694 --- /dev/null +++ b/packages/ai-core/src/browser/style/index.css @@ -0,0 +1,80 @@ +.ai-configuration-widget { + padding: var(--theia-ui-padding); +} + +.theia-ai-settings-container { + padding: var(--theia-ui-padding); +} + +.language-model-container { + padding-top: calc(2 * var(--theia-ui-padding)); +} + +.language-model-container .theia-select { + margin-left: var(--theia-ui-padding); +} + +.ai-templates { + display: grid; + /** Display content in 3 columns */ + grid-template-columns: 1fr auto auto; + /** add a 3px gap between rows */ + row-gap: 3px; +} + +#ai-variable-configuration-container-widget, +#ai-agent-configuration-container-widget { + margin-top: 5px; +} + +/* Variable Settings */ +#ai-variable-configuration-container-widget ul { + list-style: none; + padding: 0; + margin: 0; +} + +#ai-variable-configuration-container-widget .variable-item { + display: flex; + flex-direction: column; + margin-bottom: 1rem; +} + +#ai-variable-configuration-container-widget .variable-args { + display: grid; + grid-template-columns: 1fr 2fr; +} + +/* Agent Settings */ +#ai-agent-configuration-container-widget ul { + list-style: none; + padding: 0; + margin: 0; +} + +.ai-agent-configuration-main { + display: flex; + flex-direction: row; +} + +.configuration-agents-list { + width: 128px; +} + +.configuration-agent-panel { + flex: 1; +} + +#ai-variable-configuration-container-widget .variable-references, +#ai-agent-configuration-container-widget .variable-references { + margin-left: 0.5rem; + padding: 0.5rem; + border-left: solid 1px var(--theia-tree-indentGuidesStroke); +} + +#ai-variable-configuration-container-widget .variable-reference, +#ai-agent-configuration-container-widget .variable-reference { + display: flex; + flex-direction: row; + align-items: center; +} diff --git a/packages/ai-core/src/browser/theia-variable-contribution.ts b/packages/ai-core/src/browser/theia-variable-contribution.ts new file mode 100644 index 0000000000000..f8353e5eecb00 --- /dev/null +++ b/packages/ai-core/src/browser/theia-variable-contribution.ts @@ -0,0 +1,58 @@ +// ***************************************************************************** +// 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 { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { VariableRegistry, VariableResolverService } from '@theia/variable-resolver/lib/browser'; +import { AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext, ResolvedAIVariable } from '../common'; + +@injectable() +export class TheiaVariableContribution implements AIVariableContribution, AIVariableResolver { + @inject(VariableResolverService) + protected readonly variableResolverService: VariableResolverService; + + @inject(VariableRegistry) + protected readonly variableRegistry: VariableRegistry; + + @inject(FrontendApplicationStateService) + protected readonly stateService: FrontendApplicationStateService; + + registerVariables(service: AIVariableService): void { + this.stateService.reachedState('initialized_layout').then(() => { + // some variable contributions in Theia are done as part of the onStart, same as our AI variable contributions + // we therefore wait for all of them to be registered before we register we map them to our own + this.variableRegistry.getVariables().forEach(variable => { + service.registerResolver({ id: `theia-${variable.name}`, name: variable.name, description: variable.description ?? 'Theia Built-in Variable' }, this); + }); + }); + } + + protected toTheiaVariable(request: AIVariableResolutionRequest): string { + return `$\{${request.variable.name}${request.arg ? ':' + request.arg : ''}}`; + } + + async canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + // some variables are not resolvable without providing a specific context + // this may be expensive but was not a problem for Theia's built-in variables + const resolved = await this.variableResolverService.resolve(this.toTheiaVariable(request), context); + return !resolved ? 0 : 1; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + const resolved = await this.variableResolverService.resolve(this.toTheiaVariable(request), context); + return resolved ? { value: resolved, variable: request.variable } : undefined; + } +} + diff --git a/packages/ai-core/src/common/agent-service.ts b/packages/ai-core/src/common/agent-service.ts new file mode 100644 index 0000000000000..122fac8d02b3e --- /dev/null +++ b/packages/ai-core/src/common/agent-service.ts @@ -0,0 +1,83 @@ +// ***************************************************************************** +// 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 { inject, injectable, named } from '@theia/core/shared/inversify'; +import { ContributionProvider } from '@theia/core'; +import { Agent } from './agent'; + +export const AgentService = Symbol('AgentService'); + +/** + * Service to access the list of known Agents. + */ +export interface AgentService { + /** + * Retrieves a list of agents. + * @param includeDisabledAgents - Optional. Specifies whether to include disabled agents in the result. + * This should usually remain false (or undefined), except when listing agents in a settings/configuration context. + * default: false + * @returns An array of Agent objects. + */ + getAgents(includeDisabledAgents?: boolean): Agent[]; + /** + * Enable the agent with the specified id. + * @param agentId the agent id. + */ + enableAgent(agentId: string): void; + /** + * disable the agent with the specified id. + * @param agentId the agent id. + */ + disableAgent(agentId: string): void; + /** + * query whether this agent is currently enabled or disabled. + * @param agentId the agent id. + * @return true if the agent is enabled, false otherwise. + */ + isEnabled(agentId: string): boolean; +} + +@injectable() +export class AgentServiceImpl implements AgentService { + + @inject(ContributionProvider) @named(Agent) + protected readonly agentsProvider: ContributionProvider; + + protected disabledAgents = new Set(); + + private get agents(): Agent[] { + return this.agentsProvider.getContributions(); + } + + getAgents(includeDisabledAgents = false): Agent[] { + if (includeDisabledAgents) { + return this.agents; + } else { + return this.agents.filter(agent => this.isEnabled(agent.id)); + } + } + + enableAgent(agentId: string): void { + this.disabledAgents.delete(agentId); + } + + disableAgent(agentId: string): void { + this.disabledAgents.add(agentId); + } + + isEnabled(agentId: string): boolean { + return !this.disabledAgents.has(agentId); + } +} diff --git a/packages/ai-core/src/common/agent.ts b/packages/ai-core/src/common/agent.ts new file mode 100644 index 0000000000000..9253351033478 --- /dev/null +++ b/packages/ai-core/src/common/agent.ts @@ -0,0 +1,39 @@ +// ***************************************************************************** +// 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 { LanguageModelRequirement } from './language-model'; +import { PromptTemplate } from './prompt-service'; + +export const Agent = Symbol('Agent'); +export interface Agent { + /** Used to identify an agent, e.g. when it is requesting language models, etc. */ + readonly id: string; + + /** Human-readable name shown to users to identify the agent. */ + readonly name: string; + + /** A markdown description of its functionality and its privacy-relevant requirements, including function call handlers that access some data autonomously. */ + readonly description: string; + + /** The list of variable identifiers this agent needs to clarify its context requirements. See #39. */ + readonly variables: string[]; + + /** The prompt templates introduced and used by this agent. */ + readonly promptTemplates: PromptTemplate[]; + + /** Required language models. This includes the purpose and optional language model selector arguments. See #47. */ + readonly languageModelRequirements: LanguageModelRequirement[]; +} diff --git a/packages/ai-core/src/common/agents-variable-contribution.ts b/packages/ai-core/src/common/agents-variable-contribution.ts new file mode 100644 index 0000000000000..b38c56c70d650 --- /dev/null +++ b/packages/ai-core/src/common/agents-variable-contribution.ts @@ -0,0 +1,68 @@ +// ***************************************************************************** +// 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { AIVariable, AIVariableContext, AIVariableContribution, AIVariableResolutionRequest, AIVariableResolver, AIVariableService, ResolvedAIVariable } from './variable-service'; +import { MaybePromise } from '@theia/core'; +import { AgentService } from './agent-service'; + +export const AGENTS_VARIABLE: AIVariable = { + id: 'agents', + name: 'agents', + description: 'Returns the list of agents available in the system' +}; + +export interface ResolvedAgentsVariable extends ResolvedAIVariable { + agents: AgentDescriptor[]; +} + +export interface AgentDescriptor { + id: string; + name: string; + description: string; +} + +@injectable() +export class AgentsVariableContribution implements AIVariableContribution, AIVariableResolver { + + @inject(AgentService) + protected readonly agentService: AgentService; + + registerVariables(service: AIVariableService): void { + service.registerResolver(AGENTS_VARIABLE, this); + } + + canResolve(request: AIVariableResolutionRequest, _context: AIVariableContext): MaybePromise { + if (request.variable.name === AGENTS_VARIABLE.name) { + return 1; + } + return -1; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + if (request.variable.name === AGENTS_VARIABLE.name) { + return this.resolveAgentsVariable(request); + } + } + + resolveAgentsVariable(_request: AIVariableResolutionRequest): ResolvedAgentsVariable { + const agents = this.agentService.getAgents().map(agent => ({ + id: agent.id, + name: agent.name, + description: agent.description + })); + return { variable: AGENTS_VARIABLE, agents, value: JSON.stringify(agents) }; + } +} diff --git a/packages/ai-core/src/common/communication-recording-service.ts b/packages/ai-core/src/common/communication-recording-service.ts new file mode 100644 index 0000000000000..491d8065173e5 --- /dev/null +++ b/packages/ai-core/src/common/communication-recording-service.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 { Event } from '@theia/core'; + +export type CommunicationHistory = CommunicationHistoryEntry[]; + +export interface CommunicationHistoryEntry { + agentId: string; + sessionId: string; + timestamp: number; + requestId: string; + request?: string; + response?: string; + responseTime?: number; + messages?: unknown[]; +} + +export type CommunicationRequestEntry = Omit; +export type CommunicationResponseEntry = Omit; + +export const CommunicationRecordingService = Symbol('CommunicationRecordingService'); +export interface CommunicationRecordingService { + recordRequest(requestEntry: CommunicationRequestEntry): void; + readonly onDidRecordRequest: Event; + + recordResponse(responseEntry: CommunicationResponseEntry): void; + readonly onDidRecordResponse: Event; + + getHistory(agentId: string): CommunicationHistory; +} diff --git a/packages/ai-core/src/common/function-call-registry.ts b/packages/ai-core/src/common/function-call-registry.ts new file mode 100644 index 0000000000000..a6cc50e7841a5 --- /dev/null +++ b/packages/ai-core/src/common/function-call-registry.ts @@ -0,0 +1,79 @@ +// ***************************************************************************** +// 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 { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; +import { ToolRequest } from './language-model'; +import { ContributionProvider } from '@theia/core'; + +export const FunctionCallRegistry = Symbol('FunctionCallRegistry'); + +/** + * Registry for all the function calls available to Agents. + */ +export interface FunctionCallRegistry { + registerFunction(tool: ToolRequest): void; + + getFunction(toolId: string): ToolRequest | undefined; + + getFunctions(...toolIds: string[]): ToolRequest[]; +} + +export const ToolProvider = Symbol('ToolProvider'); +export interface ToolProvider { + getTool(): ToolRequest; +} + +@injectable() +export class FunctionCallRegistryImpl implements FunctionCallRegistry { + + private functions: Map> = new Map>(); + + @inject(ContributionProvider) + @named(ToolProvider) + private providers: ContributionProvider; + + @postConstruct() + init(): void { + this.providers.getContributions().forEach(provider => { + this.registerFunction(provider.getTool()); + }); + } + + registerFunction(tool: ToolRequest): void { + if (this.functions.has(tool.id)) { + console.warn(`Function with id ${tool.id} is already registered.`); + } else { + this.functions.set(tool.id, tool); + } + } + + getFunction(toolId: string): ToolRequest | undefined { + return this.functions.get(toolId); + } + + getFunctions(...toolIds: string[]): ToolRequest[] { + const tools: ToolRequest[] = toolIds.map(toolId => { + const tool = this.functions.get(toolId); + if (tool) { + return tool; + } else { + throw new Error(`Function with id ${toolId} does not exist.`); + } + }); + return tools; + } +} + diff --git a/packages/ai-core/src/common/index.ts b/packages/ai-core/src/common/index.ts new file mode 100644 index 0000000000000..19fdaa8b88be3 --- /dev/null +++ b/packages/ai-core/src/common/index.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +export * from './agent-service'; +export * from './agent'; +export * from './agents-variable-contribution'; +export * from './communication-recording-service'; +export * from './function-call-registry'; +export * from './language-model-delegate'; +export * from './language-model-util'; +export * from './language-model'; +export * from './prompt-service'; +export * from './protocol'; +export * from './today-variable-contribution'; +export * from './tomorrow-variable-contribution'; +export * from './variable-service'; diff --git a/packages/ai-core/src/common/language-model-delegate.ts b/packages/ai-core/src/common/language-model-delegate.ts new file mode 100644 index 0000000000000..40404829bd182 --- /dev/null +++ b/packages/ai-core/src/common/language-model-delegate.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 { LanguageModelMetaData, LanguageModelParsedResponse, LanguageModelRequest, LanguageModelStreamResponsePart, LanguageModelTextResponse } from './language-model'; + +export const LanguageModelDelegateClient = Symbol('LanguageModelDelegateClient'); +export interface LanguageModelDelegateClient { + toolCall(requestId: string, toolId: string, args_string: string): Promise; + send(id: string, token: LanguageModelStreamResponsePart | undefined): void; +} +export const LanguageModelRegistryFrontendDelegate = Symbol('LanguageModelRegistryFrontendDelegate'); +export interface LanguageModelRegistryFrontendDelegate { + getLanguageModelDescriptions(): Promise; +} + +export interface LanguageModelStreamResponseDelegate { + streamId: string; +} +export const isLanguageModelStreamResponseDelegate = (obj: unknown): obj is LanguageModelStreamResponseDelegate => + !!(obj && typeof obj === 'object' && 'streamId' in obj && typeof (obj as { streamId: unknown }).streamId === 'string'); + +export type LanguageModelResponseDelegate = LanguageModelTextResponse | LanguageModelParsedResponse | LanguageModelStreamResponseDelegate; + +export const LanguageModelFrontendDelegate = Symbol('LanguageModelFrontendDelegate'); +export interface LanguageModelFrontendDelegate { + cancel(requestId: string): void; + request(modelId: string, request: LanguageModelRequest, requestId: string): Promise; +} + +export const languageModelRegistryDelegatePath = '/services/languageModelRegistryDelegatePath'; +export const languageModelDelegatePath = '/services/languageModelDelegatePath'; diff --git a/packages/ai-core/src/common/language-model-util.ts b/packages/ai-core/src/common/language-model-util.ts new file mode 100644 index 0000000000000..d42533cacc65a --- /dev/null +++ b/packages/ai-core/src/common/language-model-util.ts @@ -0,0 +1,67 @@ +// ***************************************************************************** +// 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 { isLanguageModelStreamResponse, isLanguageModelTextResponse, LanguageModelResponse, ToolRequest } from './language-model'; + +export const getTextOfResponse = async (response: LanguageModelResponse): Promise => { + if (isLanguageModelTextResponse(response)) { + return response.text; + } else if (isLanguageModelStreamResponse(response)) { + let result = ''; + for await (const chunk of response.stream) { + result += chunk.content ?? ''; + } + return result; + } + throw new Error(`Invalid response type ${response}`); +}; + +export const getJsonOfResponse = async (response: LanguageModelResponse): Promise => { + const text = await getTextOfResponse(response); + if (text.startsWith('```json')) { + const regex = /```json\s*([\s\S]*?)\s*```/g; + let match; + // eslint-disable-next-line no-null/no-null + while ((match = regex.exec(text)) !== null) { + try { + return JSON.parse(match[1]); + } catch (error) { + console.error('Failed to parse JSON:', error); + } + } + } else if (text.startsWith('{') || text.startsWith('[')) { + return JSON.parse(text); + } + throw new Error('Invalid response format'); +}; +export const toolRequestToPromptText = (toolRequest: ToolRequest): string => { + const parameters = toolRequest.parameters; + let paramsText = ''; + // parameters are supposed to be as a JSON schema. Thus, derive the parameters from its properties definition + if (parameters) { + const properties = parameters.properties; + paramsText = Object.keys(properties) + .map(key => { + const param = properties[key]; + return `${key}: ${param.type}`; + }) + .join(', '); + } + const descriptionText = toolRequest.description + ? `: ${toolRequest.description}` + : ''; + return `You can call function: ${toolRequest.id}(${paramsText})${descriptionText}`; +}; diff --git a/packages/ai-core/src/common/language-model.spec.ts b/packages/ai-core/src/common/language-model.spec.ts new file mode 100644 index 0000000000000..044b839531543 --- /dev/null +++ b/packages/ai-core/src/common/language-model.spec.ts @@ -0,0 +1,86 @@ +// ***************************************************************************** +// 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 { isModelMatching, LanguageModel, LanguageModelSelector } from './language-model'; +import { expect } from 'chai'; + +describe('isModelMatching', () => { + it('returns false with one of two parameter mismatches', () => { + expect( + isModelMatching( + { + name: 'XXX', + family: 'YYY', + }, + { + name: 'gpt-4o', + family: 'YYY', + } + ) + ).eql(false); + }); + it('returns false with two parameter mismatches', () => { + expect( + isModelMatching( + { + name: 'XXX', + family: 'YYY', + }, + { + name: 'gpt-4o', + family: 'ZZZ', + } + ) + ).eql(false); + }); + it('returns true with one parameter match', () => { + expect( + isModelMatching( + { + name: 'gpt-4o', + }, + { + name: 'gpt-4o', + } + ) + ).eql(true); + }); + it('returns true with two parameter matches', () => { + expect( + isModelMatching( + { + name: 'gpt-4o', + family: 'YYY', + }, + { + name: 'gpt-4o', + family: 'YYY', + } + ) + ).eql(true); + }); + it('returns true if there are no parameters in selector', () => { + expect( + isModelMatching( + {}, + { + name: 'gpt-4o', + family: 'YYY', + } + ) + ).eql(true); + }); +}); diff --git a/packages/ai-core/src/common/language-model.ts b/packages/ai-core/src/common/language-model.ts new file mode 100644 index 0000000000000..cb3a2459f6080 --- /dev/null +++ b/packages/ai-core/src/common/language-model.ts @@ -0,0 +1,239 @@ +// ***************************************************************************** +// 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 { CancellationToken, ContributionProvider, ILogger, isFunction, isObject, Event, Emitter } from '@theia/core'; +import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; + +export type MessageActor = 'user' | 'ai' | 'system'; + +export interface LanguageModelRequestMessage { + actor: MessageActor; + type: 'text'; + query: string; +} +export const isLanguageModelRequestMessage = (obj: unknown): obj is LanguageModelRequestMessage => + !!(obj && typeof obj === 'object' && + 'type' in obj && + typeof (obj as { type: unknown }).type === 'string' && + (obj as { type: unknown }).type === 'text' && + 'query' in obj && + typeof (obj as { query: unknown }).query === 'string' + ); +export interface ToolRequest { + id: string; + name: string; + parameters?: { type?: 'object', properties: Record }; + description?: string; + handler: (arg_string: string) => Promise; +} +export interface LanguageModelRequest { + messages: LanguageModelRequestMessage[], + tools?: ToolRequest[]; + response_format?: { type: 'text' } | { type: 'json_object' } | ResponseFormatJsonSchema; + cancellationToken?: CancellationToken; + settings?: { [key: string]: unknown }; +} +export interface ResponseFormatJsonSchema { + type: 'json_schema'; + json_schema: { + name: string, + description?: string, + schema?: Record, + strict?: boolean | null + }; +} + +export interface LanguageModelTextResponse { + text: string; +} +export const isLanguageModelTextResponse = (obj: unknown): obj is LanguageModelTextResponse => + !!(obj && typeof obj === 'object' && 'text' in obj && typeof (obj as { text: unknown }).text === 'string'); + +export interface LanguageModelStreamResponsePart { + content?: string | null; + tool_calls?: ToolCall[]; +} + +export interface ToolCall { + id?: string; + function?: { + arguments?: string; + name?: string; + }, + finished?: boolean; + result?: string; +} + +export interface LanguageModelStreamResponse { + stream: AsyncIterable; +} +export const isLanguageModelStreamResponse = (obj: unknown): obj is LanguageModelStreamResponse => + !!(obj && typeof obj === 'object' && 'stream' in obj); + +export interface LanguageModelParsedResponse { + parsed: unknown; + content: string; +} +export const isLanguageModelParsedResponse = (obj: unknown): obj is LanguageModelParsedResponse => + !!(obj && typeof obj === 'object' && 'parsed' in obj && 'content' in obj); + +export type LanguageModelResponse = LanguageModelTextResponse | LanguageModelStreamResponse | LanguageModelParsedResponse; + +/////////////////////////////////////////// +// Language Model Provider +/////////////////////////////////////////// + +export const LanguageModelProvider = Symbol('LanguageModelProvider'); +export type LanguageModelProvider = () => Promise; + +// See also VS Code `ILanguageModelChatMetadata` +export interface LanguageModelMetaData { + readonly id: string; + readonly providerId: string; + readonly name?: string; + readonly vendor?: string; + readonly version?: string; + readonly family?: string; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; +} + +export namespace LanguageModelMetaData { + export function is(arg: unknown): arg is LanguageModelMetaData { + return isObject(arg) && 'id' in arg && 'providerId' in arg; + } +} + +export interface LanguageModel extends LanguageModelMetaData { + request(request: LanguageModelRequest): Promise; +} + +export namespace LanguageModel { + export function is(arg: unknown): arg is LanguageModel { + return isObject(arg) && 'id' in arg && 'providerId' in arg && isFunction(arg.request); + } +} + +// See also VS Code `ILanguageModelChatSelector` +interface VsCodeLanguageModelSelector { + readonly identifier?: string; + readonly name?: string; + readonly vendor?: string; + readonly version?: string; + readonly family?: string; + readonly tokens?: number; +} + +export interface LanguageModelSelector extends VsCodeLanguageModelSelector { + readonly agent: string; + readonly purpose: string; +} + +export type LanguageModelRequirement = Omit; + +export const LanguageModelRegistry = Symbol('LanguageModelRegistry'); +export interface LanguageModelRegistry { + onChange: Event<{ models: LanguageModel[] }>; + addLanguageModels(models: LanguageModel[]): void; + getLanguageModels(): Promise; + getLanguageModel(id: string): Promise; + removeLanguageModels(id: string[]): void; + selectLanguageModel(request: LanguageModelSelector): Promise; + selectLanguageModels(request: LanguageModelSelector): Promise; +} + +@injectable() +export class DefaultLanguageModelRegistryImpl implements LanguageModelRegistry { + @inject(ILogger) + protected logger: ILogger; + @inject(ContributionProvider) @named(LanguageModelProvider) + protected readonly languageModelContributions: ContributionProvider; + + protected languageModels: LanguageModel[] = []; + + protected markInitialized: () => void; + protected initialized: Promise = new Promise(resolve => { this.markInitialized = resolve; }); + + protected changeEmitter = new Emitter<{ models: LanguageModel[] }>(); + onChange = this.changeEmitter.event; + + @postConstruct() + protected init(): void { + const contributions = this.languageModelContributions.getContributions(); + const promises = contributions.map(provider => provider()); + Promise.allSettled(promises).then(results => { + for (const result of results) { + if (result.status === 'fulfilled') { + this.languageModels.push(...result.value); + } else { + this.logger.error('Failed to add some language models:', result.reason); + } + } + this.markInitialized(); + }); + } + + addLanguageModels(models: LanguageModel[]): void { + models.forEach(model => { + if (this.languageModels.find(lm => lm.id === model.id)) { + console.warn(`Tried to add already existing language model with id ${model.id}. The new model will be ignored.`); + return; + } + this.languageModels.push(model); + this.changeEmitter.fire({ models: this.languageModels }); + }); + } + + async getLanguageModels(): Promise { + await this.initialized; + return this.languageModels; + } + + async getLanguageModel(id: string): Promise { + await this.initialized; + return this.languageModels.find(model => model.id === id); + } + + removeLanguageModels(ids: string[]): void { + ids.forEach(id => { + const index = this.languageModels.findIndex(model => model.id === id); + if (index !== -1) { + this.languageModels.splice(index, 1); + this.changeEmitter.fire({ models: this.languageModels }); + } else { + console.warn(`Language model with id ${id} was requested to be removed, however it does not exist`); + } + }); + } + + async selectLanguageModels(request: LanguageModelSelector): Promise { + await this.initialized; + // TODO check for actor and purpose against settings + return this.languageModels.filter(model => isModelMatching(request, model)); + } + + async selectLanguageModel(request: LanguageModelSelector): Promise { + return (await this.selectLanguageModels(request))[0]; + } +} + +export function isModelMatching(request: LanguageModelSelector, model: LanguageModel): boolean { + return (!request.identifier || model.id === request.identifier) && + (!request.name || model.name === request.name) && + (!request.vendor || model.vendor === request.vendor) && + (!request.version || model.version === request.version) && + (!request.family || model.family === request.family); +} diff --git a/packages/ai-core/src/common/prompt-service.spec.ts b/packages/ai-core/src/common/prompt-service.spec.ts new file mode 100644 index 0000000000000..7d752c018348d --- /dev/null +++ b/packages/ai-core/src/common/prompt-service.spec.ts @@ -0,0 +1,87 @@ +// ***************************************************************************** +// 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 'reflect-metadata'; + +import { expect } from 'chai'; +import { Container } from 'inversify'; +import { PromptService, PromptServiceImpl } from './prompt-service'; +import { DefaultAIVariableService, AIVariableService } from './variable-service'; + +describe('PromptService', () => { + let promptService: PromptService; + + beforeEach(() => { + const container = new Container(); + container.bind(PromptService).to(PromptServiceImpl).inSingletonScope(); + + const variableService = new DefaultAIVariableService({ getContributions: () => [] }); + const nameVariable = { id: 'test', name: 'name', description: 'Test name ' }; + variableService.registerResolver(nameVariable, { + canResolve: () => 100, + resolve: async () => ({ variable: nameVariable, value: 'Jane' }) + }); + container.bind(AIVariableService).toConstantValue(variableService); + + promptService = container.get(PromptService); + promptService.storePrompt('1', 'Hello, ${name}!'); + promptService.storePrompt('2', 'Goodbye, ${name}!'); + promptService.storePrompt('3', 'Ciao, ${invalid}!'); + }); + + it('should initialize prompts from PromptCollectionService', () => { + const allPrompts = promptService.getAllPrompts(); + expect(allPrompts['1'].template).to.equal('Hello, ${name}!'); + expect(allPrompts['2'].template).to.equal('Goodbye, ${name}!'); + expect(allPrompts['3'].template).to.equal('Ciao, ${invalid}!'); + }); + + it('should retrieve raw prompt by id', () => { + const rawPrompt = promptService.getRawPrompt('1'); + expect(rawPrompt?.template).to.equal('Hello, ${name}!'); + }); + + it('should format prompt with provided arguments', async () => { + const formattedPrompt = await promptService.getPrompt('1', { name: 'John' }); + expect(formattedPrompt?.text).to.equal('Hello, John!'); + }); + + it('should store a new prompt', () => { + promptService.storePrompt('3', 'Welcome, ${name}!'); + const newPrompt = promptService.getRawPrompt('3'); + expect(newPrompt?.template).to.equal('Welcome, ${name}!'); + }); + + it('should replace placeholders with provided arguments', async () => { + const prompt = await promptService.getPrompt('1', { name: 'John' }); + expect(prompt?.text).to.equal('Hello, John!'); + }); + + it('should use variable service to resolve placeholders if argument value is not provided', async () => { + const prompt = await promptService.getPrompt('1'); + expect(prompt?.text).to.equal('Hello, Jane!'); + }); + + it('should return the prompt even if there are no replacements', async () => { + const prompt = await promptService.getPrompt('3'); + expect(prompt?.text).to.equal('Ciao, ${invalid}!'); + }); + + it('should return undefined if the prompt id is not found', async () => { + const prompt = await promptService.getPrompt('4'); + expect(prompt).to.be.undefined; + }); +}); diff --git a/packages/ai-core/src/common/prompt-service.ts b/packages/ai-core/src/common/prompt-service.ts new file mode 100644 index 0000000000000..b298f3e593f62 --- /dev/null +++ b/packages/ai-core/src/common/prompt-service.ts @@ -0,0 +1,213 @@ +// ***************************************************************************** +// 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 { URI } from '@theia/core'; +import { inject, injectable, optional } from '@theia/core/shared/inversify'; +import { AIVariableService } from './variable-service'; +import { FunctionCallRegistry } from './function-call-registry'; +import { toolRequestToPromptText } from './language-model-util'; +import { ToolRequest } from './language-model'; + +export interface PromptTemplate { + id: string; + template: string; +} + +export interface PromptMap { [id: string]: PromptTemplate } + +export interface ResolvedPromptTemplate { + id: string; + /** The resolved prompt text with variables and function requests being replaced. */ + text: string; + /** All functions referenced in the prompt template. */ + functionDescriptions?: Map>; +} + +export const PromptService = Symbol('PromptService'); +export interface PromptService { + /** + * Retrieve the raw {@link PromptTemplate} object. + * @param id the id of the {@link PromptTemplate} + */ + getRawPrompt(id: string): PromptTemplate | undefined; + /** + * Retrieve the default raw {@link PromptTemplate} object. + * @param id the id of the {@link PromptTemplate} + */ + getDefaultRawPrompt(id: string): PromptTemplate | undefined; + /** + * Allows to directly replace placeholders in the prompt. The supported format is 'Hi ${name}!'. + * The placeholder is then searched inside the args object and replaced. + * Function references are also supported via format '~{functionId}'. + * @param id the id of the prompt + * @param args the object with placeholders, mapping the placeholder key to the value + */ + getPrompt(id: string, args?: { [key: string]: unknown }): Promise; + /** + * Manually add a prompt to the list of prompts. + * @param id the id of the prompt + * @param prompt the prompt template to store + */ + storePrompt(id: string, prompt: string): void; + /** + * Return all known prompts as a {@link PromptMap map}. + */ + getAllPrompts(): PromptMap; +} + +export const PromptCustomizationService = Symbol('PromptCustomizationService'); +export interface PromptCustomizationService { + /** + * Whether there is a customization for a {@link PromptTemplate} object + * @param id the id of the {@link PromptTemplate} to check + */ + isPromptTemplateCustomized(id: string): boolean; + + /** + * Returns the customization of {@link PromptTemplate} object or undefined if there is none + * @param id the id of the {@link PromptTemplate} to check + */ + getCustomizedPromptTemplate(id: string): string | undefined + + /** + * Edit the template. If the content is specified, is will be + * used to customize the template. Otherwise, the behavior depends + * on the implementation. Implementation may for example decide to + * open an editor, or request more information from the user, ... + * @param id the template id. + * @param content optional content to customize the template. + */ + editTemplate(id: string, content?: string): void; + + /** + * Reset the template to its default value. + * @param id the template id. + */ + resetTemplate(id: string): void; + + /** + * Return the template id for a given template file. + * @param uri the uri of the template file + */ + getTemplateIDFromURI(uri: URI): string | undefined; +} + +// should match the one from VariableResolverService +const PROMPT_VARIABLE_REGEX = /\$\{(.*?)\}/g; + +// Match function/tool references in the prompt. The format is ~{functionId} +const PROMPT_FUNCTION_REGEX = /\~\{(.*?)\}/g; + +@injectable() +export class PromptServiceImpl implements PromptService { + @inject(PromptCustomizationService) @optional() + protected readonly customizationService: PromptCustomizationService | undefined; + + @inject(AIVariableService) @optional() + protected readonly variableService: AIVariableService | undefined; + + @inject(FunctionCallRegistry) @optional() + protected readonly functionCallRegistry: FunctionCallRegistry | undefined; + + protected _prompts: PromptMap = {}; + + getRawPrompt(id: string): PromptTemplate | undefined { + if (this.customizationService !== undefined && this.customizationService.isPromptTemplateCustomized(id)) { + const template = this.customizationService.getCustomizedPromptTemplate(id); + if (template !== undefined) { + return { id, template }; + } + } + return this.getDefaultRawPrompt(id); + } + getDefaultRawPrompt(id: string): PromptTemplate | undefined { + return this._prompts[id]; + } + async getPrompt(id: string, args?: { [key: string]: unknown }): Promise { + const prompt = this.getRawPrompt(id); + if (prompt === undefined) { + return undefined; + } + + const matches = [...prompt.template.matchAll(PROMPT_VARIABLE_REGEX)]; + const variableAndArgReplacements = await Promise.all(matches.map(async match => { + const completeText = match[0]; + const variableAndArg = match[1]; + let variableName = variableAndArg; + let argument: string | undefined; + const parts = variableAndArg.split(':', 2); + if (parts.length > 1) { + variableName = parts[0]; + argument = parts[1]; + } + return { + placeholder: completeText, + value: String(args?.[variableAndArg] ?? (await this.variableService?.resolveVariable({ + variable: variableName, + arg: argument + }, {}))?.value ?? completeText) + }; + })); + + const functionMatches = [...prompt.template.matchAll(PROMPT_FUNCTION_REGEX)]; + const functions = new Map>(); + const functionReplacements = functionMatches.map(match => { + const completeText = match[0]; + const functionId = match[1]; + const toolRequest = this.functionCallRegistry?.getFunction(functionId); + if (toolRequest) { + functions.set(toolRequest.id, toolRequest); + } + return { + placeholder: completeText, + value: toolRequest ? toolRequestToPromptText(toolRequest) : completeText + }; + }); + + let resolvedTemplate = prompt.template; + const replacements = [...variableAndArgReplacements, ...functionReplacements]; + replacements.forEach(replacement => resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value)); + return { + id, + text: resolvedTemplate, + functionDescriptions: functions.size > 0 ? functions : undefined + }; + } + getAllPrompts(): PromptMap { + if (this.customizationService !== undefined) { + const myCustomization = this.customizationService; + const result: PromptMap = {}; + Object.keys(this._prompts).forEach(id => { + if (myCustomization.isPromptTemplateCustomized(id)) { + const template = myCustomization.getCustomizedPromptTemplate(id); + if (template !== undefined) { + result[id] = { id, template }; + } else { + result[id] = { ...this._prompts[id] }; + } + } else { + result[id] = { ...this._prompts[id] }; + } + }); + return result; + } else { + return { ...this._prompts }; + } + } + storePrompt(id: string, prompt: string): void { + this._prompts[id] = { id, template: prompt }; + } +} diff --git a/packages/ai-core/src/common/protocol.ts b/packages/ai-core/src/common/protocol.ts new file mode 100644 index 0000000000000..ec1c3dbfde4b6 --- /dev/null +++ b/packages/ai-core/src/common/protocol.ts @@ -0,0 +1,23 @@ +// ***************************************************************************** +// 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 { LanguageModelMetaData } from './language-model'; + +export const LanguageModelRegistryClient = Symbol('LanguageModelRegistryClient'); +export interface LanguageModelRegistryClient { + languageModelAdded(metadata: LanguageModelMetaData): void; + languageModelRemoved(id: string): void; +} diff --git a/packages/ai-core/src/common/today-variable-contribution.ts b/packages/ai-core/src/common/today-variable-contribution.ts new file mode 100644 index 0000000000000..a155618ffe85c --- /dev/null +++ b/packages/ai-core/src/common/today-variable-contribution.ts @@ -0,0 +1,67 @@ +// ***************************************************************************** +// 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 { MaybePromise } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { AIVariable, ResolvedAIVariable, AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext } from './variable-service'; + +export namespace TodayVariableArgs { + export const IN_UNIX_SECONDS = 'inUnixSeconds'; + export const IN_ISO_8601 = 'inIso8601'; +} + +export const TODAY_VARIABLE: AIVariable = { + id: 'today-provider', + description: 'Does something for today', + name: 'today', + args: [ + { name: TodayVariableArgs.IN_ISO_8601, description: 'Returns the current date in ISO 8601 format' }, + { name: TodayVariableArgs.IN_UNIX_SECONDS, description: 'Returns the current date in unix seconds format' } + ] +}; + +export interface ResolvedTodayVariable extends ResolvedAIVariable { + date: Date; +} + +@injectable() +export class TodayVariableContribution implements AIVariableContribution, AIVariableResolver { + registerVariables(service: AIVariableService): void { + service.registerResolver(TODAY_VARIABLE, this); + } + + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { + return 1; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + if (request.variable.name === TODAY_VARIABLE.name) { + return this.resolveTodayVariable(request); + } + return undefined; + } + + private resolveTodayVariable(request: AIVariableResolutionRequest): ResolvedTodayVariable { + const date = new Date(); + if (request.arg === TodayVariableArgs.IN_ISO_8601) { + return { variable: request.variable, value: date.toISOString(), date }; + } + if (request.arg === TodayVariableArgs.IN_UNIX_SECONDS) { + return { variable: request.variable, value: Math.round(date.getTime() / 1000).toString(), date }; + } + return { variable: request.variable, value: date.toDateString(), date }; + } +} + diff --git a/packages/ai-core/src/common/tomorrow-variable-contribution.ts b/packages/ai-core/src/common/tomorrow-variable-contribution.ts new file mode 100644 index 0000000000000..8575505cfef82 --- /dev/null +++ b/packages/ai-core/src/common/tomorrow-variable-contribution.ts @@ -0,0 +1,66 @@ +// ***************************************************************************** +// 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 { MaybePromise } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { AIVariable, AIVariableContext, AIVariableContribution, AIVariableResolutionRequest, AIVariableResolver, AIVariableService, ResolvedAIVariable } from './variable-service'; + +export namespace TomorrowVariableArgs { + export const IN_UNIX_SECONDS = 'inUnixSeconds'; + export const IN_ISO_8601 = 'inIso8601'; +} + +export const TOMORROW_VARIABLE: AIVariable = { + id: 'tomorrow-provider', + description: 'Does something for tomorrow', + name: 'tomorrow', + args: [ + { name: TomorrowVariableArgs.IN_ISO_8601, description: 'Returns the current date in ISO 8601 format' }, + { name: TomorrowVariableArgs.IN_UNIX_SECONDS, description: 'Returns the current date in unix seconds format' } + ] +}; + +export interface ResolvedTomorrowVariable extends ResolvedAIVariable { + date: Date; +} + +@injectable() +export class TomorrowVariableContribution implements AIVariableContribution, AIVariableResolver { + registerVariables(service: AIVariableService): void { + service.registerResolver(TOMORROW_VARIABLE, this); + } + + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { + return 1; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + if (request.variable.name === TOMORROW_VARIABLE.name) { + return this.resolveTomorrowVariable(request); + } + return undefined; + } + + private resolveTomorrowVariable(request: AIVariableResolutionRequest): ResolvedTomorrowVariable { + const date = new Date(+new Date() + 86400000); + if (request.arg === TomorrowVariableArgs.IN_ISO_8601) { + return { variable: request.variable, value: date.toISOString(), date }; + } + if (request.arg === TomorrowVariableArgs.IN_UNIX_SECONDS) { + return { variable: request.variable, value: Math.round(date.getTime() / 1000).toString(), date }; + } + return { variable: request.variable, value: date.toDateString(), date }; + } +} diff --git a/packages/ai-core/src/common/variable-service.ts b/packages/ai-core/src/common/variable-service.ts new file mode 100644 index 0000000000000..833d322eed48f --- /dev/null +++ b/packages/ai-core/src/common/variable-service.ts @@ -0,0 +1,177 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatVariables.ts + +import { ContributionProvider, Disposable, Emitter, ILogger, MaybePromise, Prioritizeable, Event } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; + +export interface AIVariable { + /** provider id */ + id: string; + /** variable name */ + name: string; + /** variable description */ + description: string; + args?: AIVariableDescription[]; +} + +export interface AIVariableDescription { + name: string; + description: string; +} + +export interface ResolvedAIVariable { + variable: AIVariable; + value: string; +} + +export interface AIVariableResolutionRequest { + variable: AIVariable; + arg?: string; +} + +export interface AIVariableContext { +} + +export type AIVariableArg = string | { variable: string, arg?: string } | AIVariableResolutionRequest; + +export interface AIVariableResolver { + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise, + resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise; +} + +export const AIVariableService = Symbol('AIVariableService'); +export interface AIVariableService { + hasVariable(name: string): boolean; + getVariable(name: string): Readonly | undefined; + getVariables(): Readonly[]; + unregisterVariable(name: string): void; + readonly onDidChangeVariables: Event; + + 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): Promise; +} + +export const AIVariableContribution = Symbol('AIVariableContribution'); +export interface AIVariableContribution { + registerVariables(service: AIVariableService): void; +} + +@injectable() +export class DefaultAIVariableService implements AIVariableService { + protected variables = new Map(); + protected resolvers = new Map(); + + protected readonly onDidChangeVariablesEmitter = new Emitter(); + readonly onDidChangeVariables: Event = this.onDidChangeVariablesEmitter.event; + + @inject(ILogger) protected logger: ILogger; + + constructor( + @inject(ContributionProvider) @named(AIVariableContribution) + protected readonly contributionProvider: ContributionProvider + ) { + } + + protected initContributions(): void { + this.contributionProvider.getContributions().forEach(contribution => contribution.registerVariables(this)); + } + + protected getKey(name: string): string { + return `${name.toLowerCase()}`; + } + + async getResolver(name: string, arg: string | undefined, context: AIVariableContext): Promise { + const resolvers = await this.prioritize(name, arg, context); + return resolvers[0]; + } + + protected getResolvers(name: string): AIVariableResolver[] { + return this.resolvers.get(this.getKey(name)) ?? []; + } + + protected async prioritize(name: string, arg: string | undefined, context: AIVariableContext): Promise { + const variable = this.getVariable(name); + if (!variable) { + return []; + } + const prioritized = await Prioritizeable.prioritizeAll(this.getResolvers(name), async resolver => { + try { + return await resolver.canResolve({ variable, arg }, context); + } catch { + return 0; + } + }); + return prioritized.map(p => p.value); + } + + hasVariable(name: string): boolean { + return !!this.getVariable(name); + } + + getVariable(name: string): Readonly | undefined { + return this.variables.get(this.getKey(name)); + } + + getVariables(): Readonly[] { + return [...this.variables.values()]; + } + + registerResolver(variable: AIVariable, resolver: AIVariableResolver): Disposable { + const key = this.getKey(variable.name); + if (!this.variables.get(key)) { + this.variables.set(key, variable); + this.onDidChangeVariablesEmitter.fire(); + } + const resolvers = this.resolvers.get(key) ?? []; + resolvers.push(resolver); + this.resolvers.set(key, resolvers); + return Disposable.create(() => this.unregisterResolver(variable, resolver)); + } + + unregisterResolver(variable: AIVariable, resolver: AIVariableResolver): void { + const key = this.getKey(variable.name); + const registeredResolvers = this.resolvers.get(key); + registeredResolvers?.splice(registeredResolvers.indexOf(resolver), 1); + if (registeredResolvers?.length === 0) { + this.unregisterVariable(variable.name); + } + } + + unregisterVariable(name: string): void { + this.variables.delete(this.getKey(name)); + this.resolvers.delete(this.getKey(name)); + this.onDidChangeVariablesEmitter.fire(); + } + + async resolveVariable(request: AIVariableArg, context: AIVariableContext): Promise { + const variableName = typeof request === 'string' ? request : typeof request.variable === 'string' ? request.variable : request.variable.name; + const variable = this.getVariable(variableName); + if (!variable) { + return undefined; + } + const arg = typeof request === 'string' ? undefined : request.arg; + const resolver = await this.getResolver(variableName, arg, context); + return resolver?.resolve({ variable, arg }, context); + } +} diff --git a/packages/ai-core/src/node/ai-core-backend-module.ts b/packages/ai-core/src/node/ai-core-backend-module.ts new file mode 100644 index 0000000000000..5c23c7d37f1ac --- /dev/null +++ b/packages/ai-core/src/node/ai-core-backend-module.ts @@ -0,0 +1,83 @@ +// ***************************************************************************** +// 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 { ContainerModule } from '@theia/core/shared/inversify'; +import { + LanguageModelFrontendDelegateImpl, + LanguageModelRegistryFrontendDelegateImpl, +} from './language-model-frontend-delegate'; +import { + ConnectionHandler, + RpcConnectionHandler, + bindContributionProvider, +} from '@theia/core'; +import { + LanguageModelRegistry, + LanguageModelProvider, + PromptService, + PromptServiceImpl, + LanguageModelDelegateClient, + LanguageModelFrontendDelegate, + LanguageModelRegistryFrontendDelegate, + languageModelDelegatePath, + languageModelRegistryDelegatePath, + LanguageModelRegistryClient +} from '../common'; +import { BackendLanguageModelRegistry } from './backend-language-model-registry'; + +export default new ContainerModule(bind => { + bindContributionProvider(bind, LanguageModelProvider); + bind(BackendLanguageModelRegistry).toSelf().inSingletonScope(); + bind(LanguageModelRegistry).toService(BackendLanguageModelRegistry); + + bind(LanguageModelRegistryFrontendDelegate).to(LanguageModelRegistryFrontendDelegateImpl).inSingletonScope(); + bind(ConnectionHandler) + .toDynamicValue( + ctx => + new RpcConnectionHandler( + languageModelRegistryDelegatePath, + client => { + const registryDelegate = ctx.container.get( + LanguageModelRegistryFrontendDelegate + ); + registryDelegate.setClient(client); + return registryDelegate; + } + ) + ) + .inSingletonScope(); + + bind(LanguageModelFrontendDelegateImpl).toSelf().inSingletonScope(); + bind(LanguageModelFrontendDelegate).toService(LanguageModelFrontendDelegateImpl); + bind(ConnectionHandler) + .toDynamicValue( + ({ container }) => + new RpcConnectionHandler( + languageModelDelegatePath, + client => { + const service = + container.get( + LanguageModelFrontendDelegateImpl + ); + service.setClient(client); + return service; + } + ) + ) + .inSingletonScope(); + + bind(PromptServiceImpl).toSelf().inSingletonScope(); + bind(PromptService).toService(PromptServiceImpl); +}); diff --git a/packages/ai-core/src/node/backend-language-model-registry.ts b/packages/ai-core/src/node/backend-language-model-registry.ts new file mode 100644 index 0000000000000..7bb23f9356cb2 --- /dev/null +++ b/packages/ai-core/src/node/backend-language-model-registry.ts @@ -0,0 +1,60 @@ +// ***************************************************************************** +// 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 { injectable } from '@theia/core/shared/inversify'; +import { DefaultLanguageModelRegistryImpl, LanguageModel, LanguageModelMetaData, LanguageModelRegistryClient } from '../common'; + +/** + * Notifies a client whenever a model is added or removed + */ +@injectable() +export class BackendLanguageModelRegistry extends DefaultLanguageModelRegistryImpl { + + private client: LanguageModelRegistryClient | undefined; + + setClient(client: LanguageModelRegistryClient): void { + this.client = client; + } + + override addLanguageModels(models: LanguageModel[]): void { + const modelsLength = this.languageModels.length; + super.addLanguageModels(models); + // only notify for models which were really added + for (let i = modelsLength; i < this.languageModels.length; i++) { + this.client?.languageModelAdded(this.mapToMetaData(this.languageModels[i])); + } + } + + override removeLanguageModels(ids: string[]): void { + super.removeLanguageModels(ids); + for (const id of ids) { + this.client?.languageModelRemoved(id); + } + } + + mapToMetaData(model: LanguageModel): LanguageModelMetaData { + return { + id: model.id, + providerId: model.providerId, + name: model.name, + vendor: model.vendor, + version: model.version, + family: model.family, + maxInputTokens: model.maxInputTokens, + maxOutputTokens: model.maxOutputTokens, + }; + } +} diff --git a/packages/ai-core/src/node/language-model-frontend-delegate.ts b/packages/ai-core/src/node/language-model-frontend-delegate.ts new file mode 100644 index 0000000000000..00388a86ba161 --- /dev/null +++ b/packages/ai-core/src/node/language-model-frontend-delegate.ts @@ -0,0 +1,115 @@ +// ***************************************************************************** +// 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 { inject, injectable } from '@theia/core/shared/inversify'; +import { CancellationTokenSource, ILogger, generateUuid } from '@theia/core'; +import { + LanguageModelMetaData, + LanguageModelRegistry, + LanguageModelRequest, + isLanguageModelStreamResponse, + isLanguageModelTextResponse, + LanguageModelStreamResponsePart, + LanguageModelDelegateClient, + LanguageModelFrontendDelegate, + LanguageModelRegistryFrontendDelegate, + LanguageModelResponseDelegate, + LanguageModelRegistryClient, + isLanguageModelParsedResponse, +} from '../common'; +import { BackendLanguageModelRegistry } from './backend-language-model-registry'; + +@injectable() +export class LanguageModelRegistryFrontendDelegateImpl implements LanguageModelRegistryFrontendDelegate { + + @inject(LanguageModelRegistry) + private registry: BackendLanguageModelRegistry; + + setClient(client: LanguageModelRegistryClient): void { + this.registry.setClient(client); + } + + async getLanguageModelDescriptions(): Promise { + return (await this.registry.getLanguageModels()).map(model => this.registry.mapToMetaData(model)); + } +} + +@injectable() +export class LanguageModelFrontendDelegateImpl implements LanguageModelFrontendDelegate { + + @inject(LanguageModelRegistry) + private registry: LanguageModelRegistry; + + @inject(ILogger) + private logger: ILogger; + + private frontendDelegateClient: LanguageModelDelegateClient; + private requestCancellationTokenMap: Map = new Map(); + + setClient(client: LanguageModelDelegateClient): void { + this.frontendDelegateClient = client; + } + + cancel(requestId: string): void { + this.requestCancellationTokenMap.get(requestId)?.cancel(); + } + + async request( + modelId: string, + request: LanguageModelRequest, + requestId: string + ): Promise { + const model = await this.registry.getLanguageModel(modelId); + if (!model) { + throw new Error( + `Request was sent to non-existent language model ${modelId}` + ); + } + request.tools?.forEach(tool => { + tool.handler = async args_string => this.frontendDelegateClient.toolCall(requestId, tool.id, args_string); + }); + if (request.cancellationToken) { + const tokenSource = new CancellationTokenSource(); + request.cancellationToken = tokenSource.token; + this.requestCancellationTokenMap.set(requestId, tokenSource); + } + const response = await model.request(request); + if (isLanguageModelTextResponse(response) || isLanguageModelParsedResponse(response)) { + return response; + } + if (isLanguageModelStreamResponse(response)) { + const delegate = { + streamId: generateUuid(), + }; + this.sendTokens(delegate.streamId, response.stream); + return delegate; + } + this.logger.error( + `Received unexpected response from language model ${modelId}. Trying to continue without touching the response.`, + response + ); + return response; + } + + protected sendTokens(id: string, stream: AsyncIterable): void { + (async () => { + for await (const token of stream) { + this.frontendDelegateClient.send(id, token); + } + this.frontendDelegateClient.send(id, undefined); + })(); + } +} diff --git a/packages/ai-core/tsconfig.json b/packages/ai-core/tsconfig.json new file mode 100644 index 0000000000000..4ee37e165b355 --- /dev/null +++ b/packages/ai-core/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../core" + }, + { + "path": "../editor" + }, + { + "path": "../filesystem" + }, + { + "path": "../monaco" + }, + { + "path": "../output" + }, + { + "path": "../variable-resolver" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-history/.eslintrc.js b/packages/ai-history/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-history/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-history/README.md b/packages/ai-history/README.md new file mode 100644 index 0000000000000..6992a4ae49041 --- /dev/null +++ b/packages/ai-history/README.md @@ -0,0 +1,31 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI History EXTENSION

+ +
+ +
+ +## Description + +The `@theia/ai-history` extension offers a framework for agents to record their requests and responses. +It also offers a view to inspect the history. + + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-history/package.json b/packages/ai-history/package.json new file mode 100644 index 0000000000000..9db902c946a0f --- /dev/null +++ b/packages/ai-history/package.json @@ -0,0 +1,53 @@ +{ + "name": "@theia/ai-history", + "version": "1.52.0", + "description": "Theia - AI communication history", + "dependencies": { + "@theia/ai-core": "1.52.0", + "@theia/core": "1.52.0", + "@theia/filesystem": "1.52.0", + "@theia/output": "1.52.0", + "@theia/workspace": "1.52.0", + "minimatch": "^5.1.0", + "tslib": "^2.6.2" + }, + "main": "lib/common", + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/ai-history-frontend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.52.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-history/src/browser/ai-history-communication-card.tsx b/packages/ai-history/src/browser/ai-history-communication-card.tsx new file mode 100644 index 0000000000000..3320d13692729 --- /dev/null +++ b/packages/ai-history/src/browser/ai-history-communication-card.tsx @@ -0,0 +1,48 @@ +// ***************************************************************************** +// 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 { CommunicationHistoryEntry } from '@theia/ai-core'; +import * as React from '@theia/core/shared/react'; + +export interface CommunicationCardProps { + entry: CommunicationHistoryEntry; +} + +export const CommunicationCard: React.FC = ({ entry }) => ( +
+
+ Request ID: {entry.requestId} + Session ID: {entry.sessionId} +
+
+ {entry.request && ( +
+

Request

+
{entry.request}
+
+ )} + {entry.response && ( +
+

Response

+
{entry.response}
+
+ )} +
+
+ Timestamp: {new Date(entry.timestamp).toLocaleString()} + {entry.responseTime && Response Time: {entry.responseTime}ms} +
+
+); diff --git a/packages/ai-history/src/browser/ai-history-contribution.ts b/packages/ai-history/src/browser/ai-history-contribution.ts new file mode 100644 index 0000000000000..f33d71cb6793d --- /dev/null +++ b/packages/ai-history/src/browser/ai-history-contribution.ts @@ -0,0 +1,52 @@ +// ***************************************************************************** +// 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 { FrontendApplication } from '@theia/core/lib/browser'; +import { AIViewContribution } from '@theia/ai-core/lib/browser'; +import { injectable } from '@theia/core/shared/inversify'; +import { AIHistoryView } from './ai-history-widget'; +import { Command, CommandRegistry } from '@theia/core'; + +export const AI_HISTORY_TOGGLE_COMMAND_ID = 'aiHistory:toggle'; +export const OPEN_AI_HISTORY_VIEW = Command.toLocalizedCommand({ + id: 'aiHistory:open', + label: 'Open AI History view', +}); + +@injectable() +export class AIHistoryViewContribution extends AIViewContribution { + constructor() { + super({ + widgetId: AIHistoryView.ID, + widgetName: AIHistoryView.LABEL, + defaultWidgetOptions: { + area: 'bottom', + rank: 100 + }, + toggleCommandId: AI_HISTORY_TOGGLE_COMMAND_ID, + }); + } + + async initializeLayout(_app: FrontendApplication): Promise { + await this.openView(); + } + + override registerCommands(commands: CommandRegistry): void { + super.registerCommands(commands); + commands.registerCommand(OPEN_AI_HISTORY_VIEW, { + execute: () => this.openView({ activate: true }), + }); + } +} diff --git a/packages/ai-history/src/browser/ai-history-frontend-module.ts b/packages/ai-history/src/browser/ai-history-frontend-module.ts new file mode 100644 index 0000000000000..021fc013cabdd --- /dev/null +++ b/packages/ai-history/src/browser/ai-history-frontend-module.ts @@ -0,0 +1,41 @@ +// ***************************************************************************** +// 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 { CommunicationRecordingService } from '@theia/ai-core'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { DefaultCommunicationRecordingService } from '../common/communication-recording-service'; +import { bindViewContribution, WidgetFactory } from '@theia/core/lib/browser'; +import { ILogger } from '@theia/core'; +import { AIHistoryViewContribution } from './ai-history-contribution'; +import { AIHistoryView } from './ai-history-widget'; +import '../../src/browser/style/ai-history.css'; + +export default new ContainerModule(bind => { + bind(DefaultCommunicationRecordingService).toSelf().inSingletonScope(); + bind(CommunicationRecordingService).toService(DefaultCommunicationRecordingService); + + bind(ILogger).toDynamicValue(ctx => { + const parentLogger = ctx.container.get(ILogger); + return parentLogger.child('llm-communication-recorder'); + }).inSingletonScope().whenTargetNamed('llm-communication-recorder'); + + bindViewContribution(bind, AIHistoryViewContribution); + + bind(AIHistoryView).toSelf().inSingletonScope(); + bind(WidgetFactory).toDynamicValue(context => ({ + id: AIHistoryView.ID, + createWidget: () => context.container.get(AIHistoryView) + })).inSingletonScope(); +}); diff --git a/packages/ai-history/src/browser/ai-history-widget.tsx b/packages/ai-history/src/browser/ai-history-widget.tsx new file mode 100644 index 0000000000000..b7d849e980852 --- /dev/null +++ b/packages/ai-history/src/browser/ai-history-widget.tsx @@ -0,0 +1,96 @@ +// ***************************************************************************** +// 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 { Agent, AgentService, CommunicationRecordingService, CommunicationRequestEntry, CommunicationResponseEntry } from '@theia/ai-core'; +import { codicon, ReactWidget } from '@theia/core/lib/browser'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { CommunicationCard } from './ai-history-communication-card'; +import { SelectComponent, SelectOption } from '@theia/core/lib/browser/widgets/select-component'; + +@injectable() +export class AIHistoryView extends ReactWidget { + @inject(CommunicationRecordingService) + protected recordingService: CommunicationRecordingService; + @inject(AgentService) + protected readonly agentService: AgentService; + + public static ID = 'ai-history-widget'; + static LABEL = '✨ AI Agent History [Experimental]'; + + protected selectedAgent?: Agent; + + constructor() { + super(); + this.id = AIHistoryView.ID; + this.title.label = AIHistoryView.LABEL; + this.title.caption = AIHistoryView.LABEL; + this.title.closable = true; + this.title.iconClass = codicon('history'); + } + + @postConstruct() + protected init(): void { + this.update(); + this.toDispose.push(this.recordingService.onDidRecordRequest(entry => this.historyContentUpdated(entry))); + this.toDispose.push(this.recordingService.onDidRecordResponse(entry => this.historyContentUpdated(entry))); + this.selectAgent(this.agentService.getAgents(true)[0]); + } + + protected selectAgent(agent: Agent | undefined): void { + this.selectedAgent = agent; + this.update(); + } + + protected historyContentUpdated(entry: CommunicationRequestEntry | CommunicationResponseEntry): void { + if (entry.agentId === this.selectedAgent?.id) { + this.update(); + } + } + + render(): React.ReactNode { + const selectionChange = (value: SelectOption) => { + this.selectedAgent = this.agentService.getAgents(true).find(agent => agent.id === value.value); + this.update(); + }; + return ( +
+ ({ value: agent.id, label: agent.name, description: agent.description }))} + onChange={selectionChange} + defaultValue={this.selectedAgent?.id} /> +
+ {this.renderHistory()} +
+
+ ); + } + + protected renderHistory(): React.ReactNode { + if (!this.selectedAgent) { + return
No agent selected.
; + } + const history = this.recordingService.getHistory(this.selectedAgent.id); + if (history.length === 0) { + return
No history available for the selected agent '{this.selectedAgent.name}'.
; + } + return history.map(entry => ); + } + + protected onClick(e: React.MouseEvent, agent: Agent): void { + e.stopPropagation(); + this.selectAgent(agent); + } +} diff --git a/packages/ai-history/src/browser/style/ai-history.css b/packages/ai-history/src/browser/style/ai-history.css new file mode 100644 index 0000000000000..fc72ab19494ae --- /dev/null +++ b/packages/ai-history/src/browser/style/ai-history.css @@ -0,0 +1,74 @@ +.agent-history-widget { + display: flex; + flex-direction: column; + align-items: center; +} + +.theia-select-component { + margin: 10px 0; + width: 80%; +} + +.agent-history { + width: calc(80% + 16px); + display: flex; + align-items: center; + flex-direction: column; +} + +.theia-card { + background-color: var(--theia-sideBar-background); + border: 1px solid var(--theia-sideBarSectionHeader-border); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + padding: 15px; + margin: 10px 0; + width: 100%; + box-sizing: border-box; +} + +.theia-card-meta { + display: flex; + justify-content: space-between; + font-size: 0.9em; + margin-bottom: var(--theia-ui-padding); + padding: var(--theia-ui-padding) 0; +} + +.theia-card-content { + color: var(--theia-font-color); + margin-bottom: 10px; +} + +.theia-card-content p { + margin: var(--theia-ui-padding) 0; +} + +.theia-card-request, .theia-card-response { + margin-bottom: 10px; +} + +.theia-card-request pre, +.theia-card-response pre { + font-family: monospace; + white-space: pre-wrap; + word-wrap: break-word; + background-color: var(--theia-sideBar-background); + margin: var(--theia-ui-padding) 0; +} + +.theia-card-request-id, +.theia-card-session-id, +.theia-card-timestamp, +.theia-card-response-time { + flex: 1; +} + +.theia-card-request-id, +.theia-card-timestamp { + text-align: left; +} + +.theia-card-session-id, +.theia-card-response-time { + text-align: right; +} diff --git a/packages/ai-history/src/common/communication-recording-service.spec.ts b/packages/ai-history/src/common/communication-recording-service.spec.ts new file mode 100644 index 0000000000000..662d2d58c2706 --- /dev/null +++ b/packages/ai-history/src/common/communication-recording-service.spec.ts @@ -0,0 +1,37 @@ +// ***************************************************************************** +// 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 { ILogger } from '@theia/core'; +import { MockLogger } from '@theia/core/lib/common/test/mock-logger'; +import { DefaultCommunicationRecordingService } from './communication-recording-service'; +import { expect } from 'chai'; + +describe('DefaultCommunicationRecordingService', () => { + + it('records history', () => { + const service = new DefaultCommunicationRecordingService(); + (service as unknown as { logger: ILogger }).logger = new MockLogger(); + service.recordRequest({ agentId: 'agent', requestId: '1', sessionId: '1', timestamp: 100, request: 'dummy request' }); + + const history1 = service.getHistory('agent'); + expect(history1[0].request).to.eq('dummy request'); + + service.recordResponse({ agentId: 'agent', requestId: '1', sessionId: '1', timestamp: 200, response: 'dummy response' }); + const history2 = service.getHistory('agent'); + expect(history2[0].request).to.eq('dummy request'); + expect(history2[0].response).to.eq('dummy response'); + }); + +}); diff --git a/packages/ai-history/src/common/communication-recording-service.ts b/packages/ai-history/src/common/communication-recording-service.ts new file mode 100644 index 0000000000000..9d23a6766064e --- /dev/null +++ b/packages/ai-history/src/common/communication-recording-service.ts @@ -0,0 +1,63 @@ +// ***************************************************************************** +// 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 { CommunicationHistory, CommunicationHistoryEntry, CommunicationRecordingService, CommunicationRequestEntry, CommunicationResponseEntry } from '@theia/ai-core'; +import { Emitter, Event, ILogger } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; + +@injectable() +export class DefaultCommunicationRecordingService implements CommunicationRecordingService { + + @inject(ILogger) @named('llm-communication-recorder') + protected logger: ILogger; + + protected onDidRecordRequestEmitter = new Emitter(); + readonly onDidRecordRequest: Event = this.onDidRecordRequestEmitter.event; + + protected onDidRecordResponseEmitter = new Emitter(); + readonly onDidRecordResponse: Event = this.onDidRecordResponseEmitter.event; + + protected history: Map = new Map(); + + getHistory(agentId: string): CommunicationHistory { + return this.history.get(agentId) || []; + } + + recordRequest(requestEntry: CommunicationHistoryEntry): void { + this.logger.debug('Recording request:', requestEntry.request); + if (this.history.has(requestEntry.agentId)) { + this.history.get(requestEntry.agentId)?.push(requestEntry); + } else { + this.history.set(requestEntry.agentId, [requestEntry]); + } + this.onDidRecordRequestEmitter.fire(requestEntry); + } + + recordResponse(responseEntry: CommunicationHistoryEntry): void { + this.logger.debug('Recording response:', responseEntry.response); + if (this.history.has(responseEntry.agentId)) { + const entry = this.history.get(responseEntry.agentId); + if (entry) { + const matchingRequest = entry.find(e => e.requestId === responseEntry.requestId); + if (!matchingRequest) { + throw Error('No matching request found for response'); + } + matchingRequest.response = responseEntry.response; + matchingRequest.responseTime = responseEntry.timestamp - matchingRequest.timestamp; + this.onDidRecordResponseEmitter.fire(responseEntry); + } + } + } +} diff --git a/packages/ai-history/src/common/index.ts b/packages/ai-history/src/common/index.ts new file mode 100644 index 0000000000000..52a9128e1cb3f --- /dev/null +++ b/packages/ai-history/src/common/index.ts @@ -0,0 +1,17 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** + +export * from './communication-recording-service'; diff --git a/packages/ai-history/tsconfig.json b/packages/ai-history/tsconfig.json new file mode 100644 index 0000000000000..548b369565b41 --- /dev/null +++ b/packages/ai-history/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-core" + }, + { + "path": "../core" + }, + { + "path": "../filesystem" + }, + { + "path": "../output" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-openai/.eslintrc.js b/packages/ai-openai/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-openai/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-openai/README.md b/packages/ai-openai/README.md new file mode 100644 index 0000000000000..679035fe6b435 --- /dev/null +++ b/packages/ai-openai/README.md @@ -0,0 +1,31 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - Open AI EXTENSION

+ +
+ +
+ +## Description + +The `@theia/ai-openai` integrates OpenAI's models with Theia AI. +The OpenAI API key and the models to use can be configured via preferences. +Alternatively the OpenAI API key can also be handed in via an environment variable. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-openai/package.json b/packages/ai-openai/package.json new file mode 100644 index 0000000000000..6c1cfdb350146 --- /dev/null +++ b/packages/ai-openai/package.json @@ -0,0 +1,53 @@ +{ + "name": "@theia/ai-openai", + "version": "1.52.0", + "description": "Theia - OpenAI Integration", + "dependencies": { + "@theia/core": "1.52.0", + "@theia/filesystem": "1.52.0", + "@theia/workspace": "1.52.0", + "minimatch": "^5.1.0", + "tslib": "^2.6.2", + "openai": "^4.55.7", + "@theia/ai-core": "1.52.0" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/openai-frontend-module", + "backend": "lib/node/openai-backend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.52.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-openai/src/browser/openai-frontend-application-contribution.ts b/packages/ai-openai/src/browser/openai-frontend-application-contribution.ts new file mode 100644 index 0000000000000..917e4c3b5a59a --- /dev/null +++ b/packages/ai-openai/src/browser/openai-frontend-application-contribution.ts @@ -0,0 +1,60 @@ +// ***************************************************************************** +// 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 { FrontendApplicationContribution, PreferenceService } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { OpenAiLanguageModelsManager } from '../common'; +import { API_KEY_PREF, MODELS_PREF } from './openai-preferences'; + +@injectable() +export class OpenAiFrontendApplicationContribution implements FrontendApplicationContribution { + + @inject(PreferenceService) + protected preferenceService: PreferenceService; + + @inject(OpenAiLanguageModelsManager) + protected manager: OpenAiLanguageModelsManager; + + // The preferenceChange.oldValue is always undefined for some reason + protected prevModels: string[] = []; + + onStart(): void { + this.preferenceService.ready.then(() => { + const apiKey = this.preferenceService.get(API_KEY_PREF, undefined); + this.manager.setApiKey(apiKey); + + const models = this.preferenceService.get(MODELS_PREF, []); + this.manager.createLanguageModels(...models); + this.prevModels = [...models]; + + this.preferenceService.onPreferenceChanged(event => { + if (event.preferenceName === API_KEY_PREF) { + this.manager.setApiKey(event.newValue); + } else if (event.preferenceName === MODELS_PREF) { + const oldModels = new Set(this.prevModels); + const newModels = new Set(event.newValue as string[]); + + const modelsToRemove = [...oldModels].filter(model => !newModels.has(model)); + const modelsToAdd = [...newModels].filter(model => !oldModels.has(model)); + + this.manager.removeLanguageModels(...modelsToRemove); + this.manager.createLanguageModels(...modelsToAdd); + this.prevModels = [...event.newValue]; + } + }); + }); + } +} diff --git a/packages/ai-openai/src/browser/openai-frontend-module.ts b/packages/ai-openai/src/browser/openai-frontend-module.ts new file mode 100644 index 0000000000000..21ba05b95d7cd --- /dev/null +++ b/packages/ai-openai/src/browser/openai-frontend-module.ts @@ -0,0 +1,31 @@ +// ***************************************************************************** +// 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 { ContainerModule } from '@theia/core/shared/inversify'; +import { OpenAiPreferencesSchema } from './openai-preferences'; +import { FrontendApplicationContribution, PreferenceContribution, RemoteConnectionProvider, ServiceConnectionProvider } from '@theia/core/lib/browser'; +import { OpenAiFrontendApplicationContribution } from './openai-frontend-application-contribution'; +import { OPENAI_LANGUAGE_MODELS_MANAGER_PATH, OpenAiLanguageModelsManager } from '../common'; + +export default new ContainerModule(bind => { + bind(PreferenceContribution).toConstantValue({ schema: OpenAiPreferencesSchema }); + bind(OpenAiFrontendApplicationContribution).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(OpenAiFrontendApplicationContribution); + bind(OpenAiLanguageModelsManager).toDynamicValue(ctx => { + const provider = ctx.container.get(RemoteConnectionProvider); + return provider.createProxy(OPENAI_LANGUAGE_MODELS_MANAGER_PATH); + }).inSingletonScope(); +}); diff --git a/packages/ai-openai/src/browser/openai-preferences.ts b/packages/ai-openai/src/browser/openai-preferences.ts new file mode 100644 index 0000000000000..e57915e36068f --- /dev/null +++ b/packages/ai-openai/src/browser/openai-preferences.ts @@ -0,0 +1,40 @@ +// ***************************************************************************** +// 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 { PreferenceSchema } from '@theia/core/lib/browser/preferences/preference-contribution'; +import { AI_CORE_PREFERENCES_TITLE } from '@theia/ai-core/lib/browser/ai-core-preferences'; + +export const API_KEY_PREF = 'ai-features.openai.api-key'; +export const MODELS_PREF = 'ai-features.openai.models'; + +export const OpenAiPreferencesSchema: PreferenceSchema = { + type: 'object', + properties: { + [API_KEY_PREF]: { + type: 'string', + description: 'OpenAI API Key', + title: AI_CORE_PREFERENCES_TITLE, + }, + [MODELS_PREF]: { + type: 'array', + title: AI_CORE_PREFERENCES_TITLE, + default: ['gpt-4o-2024-08-06', 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'], + items: { + type: 'string' + } + } + } +}; diff --git a/packages/ai-openai/src/common/index.ts b/packages/ai-openai/src/common/index.ts new file mode 100644 index 0000000000000..d79fbf6c3872b --- /dev/null +++ b/packages/ai-openai/src/common/index.ts @@ -0,0 +1,16 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +export * from './openai-language-models-manager'; diff --git a/packages/ai-openai/src/common/openai-language-models-manager.ts b/packages/ai-openai/src/common/openai-language-models-manager.ts new file mode 100644 index 0000000000000..07fb3f3b54714 --- /dev/null +++ b/packages/ai-openai/src/common/openai-language-models-manager.ts @@ -0,0 +1,23 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +export const OPENAI_LANGUAGE_MODELS_MANAGER_PATH = '/services/open-ai/language-model-manager'; +export const OpenAiLanguageModelsManager = Symbol('OpenAiLanguageModelsManager'); +export interface OpenAiLanguageModelsManager { + apiKey: string | undefined; + setApiKey(key: string | undefined): void; + createLanguageModels(...modelIds: string[]): Promise; + removeLanguageModels(...modelIds: string[]): void +} diff --git a/packages/ai-openai/src/node/openai-backend-module.ts b/packages/ai-openai/src/node/openai-backend-module.ts new file mode 100644 index 0000000000000..311065f402cc3 --- /dev/null +++ b/packages/ai-openai/src/node/openai-backend-module.ts @@ -0,0 +1,30 @@ +// ***************************************************************************** +// 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 { ContainerModule } from '@theia/core/shared/inversify'; +import { OPENAI_LANGUAGE_MODELS_MANAGER_PATH, OpenAiLanguageModelsManager } from '../common/openai-language-models-manager'; +import { ConnectionHandler, RpcConnectionHandler } from '@theia/core'; +import { OpenAiLanguageModelsManagerImpl } from './openai-language-models-manager-impl'; + +export const OpenAiModelFactory = Symbol('OpenAiModelFactory'); + +export default new ContainerModule(bind => { + bind(OpenAiLanguageModelsManagerImpl).toSelf().inSingletonScope(); + bind(OpenAiLanguageModelsManager).toService(OpenAiLanguageModelsManagerImpl); + bind(ConnectionHandler).toDynamicValue(ctx => + new RpcConnectionHandler(OPENAI_LANGUAGE_MODELS_MANAGER_PATH, () => ctx.container.get(OpenAiLanguageModelsManager)) + ).inSingletonScope(); +}); diff --git a/packages/ai-openai/src/node/openai-language-model.ts b/packages/ai-openai/src/node/openai-language-model.ts new file mode 100644 index 0000000000000..1d27c5c0db34c --- /dev/null +++ b/packages/ai-openai/src/node/openai-language-model.ts @@ -0,0 +1,173 @@ +// ***************************************************************************** +// 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 { + LanguageModel, + LanguageModelParsedResponse, + LanguageModelRequest, + LanguageModelRequestMessage, + LanguageModelResponse, + LanguageModelStreamResponsePart +} from '@theia/ai-core'; +import OpenAI from 'openai'; +import { ChatCompletionStream } from 'openai/lib/ChatCompletionStream'; +import { RunnableToolFunctionWithoutParse } from 'openai/lib/RunnableFunction'; +import { ChatCompletionMessageParam } from 'openai/resources'; + +export const OpenAiModelIdentifier = Symbol('OpenAiModelIdentifier'); + +export class OpenAiModel implements LanguageModel { + + readonly providerId = 'openai'; + readonly vendor: string = 'OpenAI'; + + constructor(protected readonly model: string, protected apiKey: () => string | undefined) { + } + + get id(): string { + return this.providerId + '/' + this.model; + } + + get name(): string { + return this.model; + } + + async request(request: LanguageModelRequest): Promise { + const openai = this.initializeOpenAi(); + + if (request.response_format?.type === 'json_schema') { + return this.handleStructuredOutputRequest(openai, request); + } + + let runner: ChatCompletionStream; + const tools = this.createTools(request); + if (tools) { + runner = openai.beta.chat.completions.runTools({ + model: this.model, + messages: request.messages.map(this.toOpenAIMessage), + stream: true, + tools: tools, + tool_choice: 'auto', + ...request.settings + }); + } else { + runner = openai.beta.chat.completions.stream({ + model: this.model, + messages: request.messages.map(this.toOpenAIMessage), + stream: true, + ...request.settings + }); + } + request.cancellationToken?.onCancellationRequested(() => { + runner.abort(); + }); + + let runnerEnd = false; + + let resolve: (part: LanguageModelStreamResponsePart) => void; + runner.on('error', error => { + console.error('Error in OpenAI chat completion stream:', error); + runnerEnd = true; + resolve({ content: error.message }); + }); + runner.on('message', message => { + if (message.role === 'tool') { + resolve({ tool_calls: [{ id: message.tool_call_id, finished: true, result: this.getCompletionContent(message) }] }); + } + console.debug('Received Open AI message', JSON.stringify(message)); + }); + runner.once('end', () => { + runnerEnd = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolve(runner.finalChatCompletion as any); + }); + const asyncIterator = { + async *[Symbol.asyncIterator](): AsyncIterator { + runner.on('chunk', chunk => { + if (chunk.choices[0]?.delta) { + resolve({ ...chunk.choices[0]?.delta }); + } + }); + while (!runnerEnd) { + const promise = new Promise((res, rej) => { + resolve = res; + }); + yield promise; + } + } + }; + return { stream: asyncIterator }; + } + + protected async handleStructuredOutputRequest(openai: OpenAI, request: LanguageModelRequest): Promise { + // TODO implement tool support for structured output (parse() seems to require different tool format) + const result = await openai.beta.chat.completions.parse({ + model: this.model, + messages: request.messages.map(this.toOpenAIMessage), + response_format: request.response_format, + ...request.settings + }); + const message = result.choices[0].message; + if (message.refusal || message.parsed === undefined) { + console.error('Error in OpenAI chat completion stream:', JSON.stringify(message)); + } + return { + content: message.content ?? '', + parsed: message.parsed + }; + } + + private getCompletionContent(message: OpenAI.Chat.Completions.ChatCompletionToolMessageParam): string { + if (Array.isArray(message.content)) { + return message.content.join(''); + } + return message.content; + } + + protected createTools(request: LanguageModelRequest): RunnableToolFunctionWithoutParse[] | undefined { + return request.tools?.map(tool => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + function: (args_string: string) => tool.handler(args_string) + } + } as RunnableToolFunctionWithoutParse)); + } + + protected initializeOpenAi(): OpenAI { + const key = this.apiKey(); + if (!key) { + throw new Error('Please provide OPENAI_API_KEY in preferences or via environment variable'); + } + return new OpenAI({ apiKey: key }); + } + + private toOpenAIMessage(message: LanguageModelRequestMessage): ChatCompletionMessageParam { + if (message.actor === 'ai') { + return { role: 'assistant', content: message.query || '' }; + } + if (message.actor === 'user') { + return { role: 'user', content: message.query || '' }; + } + if (message.actor === 'system') { + return { role: 'system', content: message.query || '' }; + } + return { role: 'system', content: '' }; + } + +} diff --git a/packages/ai-openai/src/node/openai-language-models-manager-impl.ts b/packages/ai-openai/src/node/openai-language-models-manager-impl.ts new file mode 100644 index 0000000000000..c81362ecc82f0 --- /dev/null +++ b/packages/ai-openai/src/node/openai-language-models-manager-impl.ts @@ -0,0 +1,58 @@ +// ***************************************************************************** +// 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 { LanguageModelRegistry } from '@theia/ai-core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { OpenAiModel } from './openai-language-model'; +import { OpenAiLanguageModelsManager } from '../common'; + +@injectable() +export class OpenAiLanguageModelsManagerImpl implements OpenAiLanguageModelsManager { + + protected _apiKey: string | undefined; + + @inject(LanguageModelRegistry) + protected readonly languageModelRegistry: LanguageModelRegistry; + + get apiKey(): string | undefined { + return this._apiKey ?? process.env.OPENAI_API_KEY; + } + + // Triggered from frontend. In case you want to use the models on the backend + // without a frontend then call this yourself + async createLanguageModels(...modelIds: string[]): Promise { + for (const id of modelIds) { + // we might be called by multiple frontends, therefore check whether a model actually needs to be created + if (!(await this.languageModelRegistry.getLanguageModel(`openai/${id}`))) { + this.languageModelRegistry.addLanguageModels([new OpenAiModel(id, () => this.apiKey)]); + } else { + console.info(`Open AI: skip creating model ${id} because it already exists`); + } + } + } + + removeLanguageModels(...modelIds: string[]): void { + this.languageModelRegistry.removeLanguageModels(modelIds.map(id => `openai/${id}`)); + } + + setApiKey(apiKey: string | undefined): void { + if (apiKey) { + this._apiKey = apiKey; + } else { + this._apiKey = undefined; + } + } +} diff --git a/packages/ai-openai/src/package.spec.ts b/packages/ai-openai/src/package.spec.ts new file mode 100644 index 0000000000000..7aa1df47bcb00 --- /dev/null +++ b/packages/ai-openai/src/package.spec.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2024 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 +// ***************************************************************************** + +/* note: this bogus test file is required so that + we are able to run mocha unit tests on this + package, without having any actual unit tests in it. + This way a coverage report will be generated, + showing 0% coverage, instead of no report. + This file can be removed once we have real unit + tests in place. */ + +describe('ai-openai package', () => { + + it('support code coverage statistics', () => true); +}); diff --git a/packages/ai-openai/tsconfig.json b/packages/ai-openai/tsconfig.json new file mode 100644 index 0000000000000..61a997fc14fd1 --- /dev/null +++ b/packages/ai-openai/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-core" + }, + { + "path": "../core" + }, + { + "path": "../filesystem" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-terminal/.eslintrc.js b/packages/ai-terminal/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-terminal/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-terminal/README.md b/packages/ai-terminal/README.md new file mode 100644 index 0000000000000..9d172389e7b14 --- /dev/null +++ b/packages/ai-terminal/README.md @@ -0,0 +1,31 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Terminal EXTENSION

+ +
+ +
+ +## Description + +The `@theia/ai-terminal` extension contributes an overlay to the terminal view.\ +The overlay can be used to ask a dedicated `TerminalAgent` for suggestions of terminal commands. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark + +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-terminal/package.json b/packages/ai-terminal/package.json new file mode 100644 index 0000000000000..02492bea14ce7 --- /dev/null +++ b/packages/ai-terminal/package.json @@ -0,0 +1,51 @@ +{ + "name": "@theia/ai-terminal", + "version": "1.52.0", + "description": "Theia - AI Terminal Extension", + "dependencies": { + "@theia/core": "1.52.0", + "@theia/ai-core": "1.52.0", + "@theia/ai-chat": "1.52.0", + "@theia/terminal": "1.52.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.2" + }, + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/ai-terminal-frontend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.52.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-terminal/src/browser/ai-terminal-agent.ts b/packages/ai-terminal/src/browser/ai-terminal-agent.ts new file mode 100644 index 0000000000000..454ac5667dbde --- /dev/null +++ b/packages/ai-terminal/src/browser/ai-terminal-agent.ts @@ -0,0 +1,177 @@ +// ***************************************************************************** +// 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 { + Agent, + isLanguageModelParsedResponse, + LanguageModelRegistry, LanguageModelRequirement, + PromptService +} from '@theia/ai-core/lib/common'; +import { ILogger } from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { z } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; + +const Commands = z.object({ + commands: z.array(z.string()), +}); +type Commands = z.infer; + +@injectable() +export class AiTerminalAgent implements Agent { + + id = 'ai-terminal'; + name = 'AI Terminal Assistant'; + description = ` + This agent provides an AI assistant in the terminal. + It accesses the terminal environment, past terminal commands of the terminal session, + and recent terminal output to provide context-aware assistance.`; + variables = []; + promptTemplates = [ + { + id: 'ai-terminal:system-prompt', + name: 'AI Terminal System Prompt', + description: 'Prompt for the AI Terminal Assistant', + template: ` +# Instructions +Generate one or more command suggestions based on the user's request, considering the shell being used, +the current working directory, and the recent terminal contents. Provide the best suggestion first, +followed by other relevant suggestions if the user asks for further options. + +Parameters: +- user-request: The user's question or request. +- shell: The shell being used, e.g., /usr/bin/zsh. +- cwd: The current working directory. +- recent-terminal-contents: The last 0 to 50 recent lines visible in the terminal. + +Return the result in the following JSON format: +{ + "commands": [ + "best_command_suggestion", + "next_best_command_suggestion", + "another_command_suggestion" + ] +} + +## Example +user-request: "How do I commit changes?" +shell: "/usr/bin/zsh" +cwd: "/home/user/project" +recent-terminal-contents: +git status +On branch main +Your branch is up to date with 'origin/main'. +nothing to commit, working tree clean + +## Expected JSON output +\`\`\`json +\{ + "commands": [ + "git commit", + "git commit --amend", + "git commit -a" + ] +} +\`\`\` +` + }, + { + id: 'ai-terminal:user-prompt', + name: 'AI Terminal User Prompt', + description: 'Prompt that contains the user request', + template: ` +user-request: \${userRequest} +shell: \${shell} +cwd: \${cwd} +recent-terminal-contents: +\${recentTerminalContents} +` + } + ]; + languageModelRequirements: LanguageModelRequirement[] = [ + { + purpose: 'suggest-terminal-commands', + identifier: 'openai/gpt-4o', + } + ]; + + @inject(LanguageModelRegistry) + protected languageModelRegistry: LanguageModelRegistry; + + @inject(PromptService) + protected promptService: PromptService; + + @inject(ILogger) + protected logger: ILogger; + + async getCommands(input: { + userRequest: string, + cwd: string, + shell: string, + recentTerminalContents: string[], + }): Promise { + const lm = await this.languageModelRegistry.selectLanguageModel({ + agent: this.id, + ...this.languageModelRequirements[0] + }); + if (!lm) { + this.logger.error('No language model available for the AI Terminal Agent.'); + return []; + } + + const systemPrompt = await this.promptService.getPrompt('ai-terminal:system-prompt', input).then(p => p?.text); + const userPrompt = await this.promptService.getPrompt('ai-terminal:user-prompt', input).then(p => p?.text); + if (!systemPrompt || !userPrompt) { + this.logger.error('The prompt service didn\'t return prompts for the AI Terminal Agent.'); + return []; + } + + try { + const result = await lm.request({ + messages: [ + { + actor: 'ai', + type: 'text', + query: systemPrompt + }, + { + actor: 'user', + type: 'text', + query: userPrompt + } + ], + response_format: { + type: 'json_schema', + json_schema: { + name: 'terminal-commands', + description: 'Suggested terminal commands based on the user request', + schema: zodToJsonSchema(Commands) + } + } + }); + if (!isLanguageModelParsedResponse(result)) { + this.logger.error('Failed to parse the response from the language model.', result); + return []; + } + const commandsObject = result.parsed as Commands; + return commandsObject.commands; + } catch (error) { + this.logger.error('Error obtaining the command suggestions.', error); + return []; + } + } + +} diff --git a/packages/ai-terminal/src/browser/ai-terminal-contribution.ts b/packages/ai-terminal/src/browser/ai-terminal-contribution.ts new file mode 100644 index 0000000000000..c63eb28e7a2d1 --- /dev/null +++ b/packages/ai-terminal/src/browser/ai-terminal-contribution.ts @@ -0,0 +1,191 @@ +// ***************************************************************************** +// 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 { AICommandHandlerFactory, EXPERIMENTAL_AI_CONTEXT_KEY } from '@theia/ai-core/lib/browser'; +import { CommandContribution, CommandRegistry, MenuContribution, MenuModelRegistry } from '@theia/core'; +import { KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { TerminalService } from '@theia/terminal/lib/browser/base/terminal-service'; +import { TerminalMenus } from '@theia/terminal/lib/browser/terminal-frontend-contribution'; +import { TerminalWidgetImpl } from '@theia/terminal/lib/browser/terminal-widget-impl'; +import { AiTerminalAgent } from './ai-terminal-agent'; + +const AI_TERMINAL_COMMAND = { + id: 'ai-terminal:open', + label: 'Ask the AI' +}; + +@injectable() +export class AiTerminalCommandContribution implements CommandContribution, MenuContribution, KeybindingContribution { + + @inject(TerminalService) + protected terminalService: TerminalService; + + @inject(AiTerminalAgent) + protected terminalAgent: AiTerminalAgent; + + @inject(AICommandHandlerFactory) + protected commandHandlerFactory: AICommandHandlerFactory; + + registerKeybindings(keybindings: KeybindingRegistry): void { + keybindings.registerKeybinding({ + command: AI_TERMINAL_COMMAND.id, + keybinding: 'ctrlcmd+i', + when: `terminalFocus && ${EXPERIMENTAL_AI_CONTEXT_KEY}` + }); + } + registerMenus(menus: MenuModelRegistry): void { + menus.registerMenuAction([...TerminalMenus.TERMINAL_CONTEXT_MENU, '_5'], { + when: EXPERIMENTAL_AI_CONTEXT_KEY, + commandId: AI_TERMINAL_COMMAND.id + }); + } + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(AI_TERMINAL_COMMAND, this.commandHandlerFactory({ + execute: () => { + if (this.terminalService.currentTerminal instanceof TerminalWidgetImpl) { + new AiTerminalChatWidget( + this.terminalService.currentTerminal, + this.terminalAgent + ); + } + } + })); + } +} + +class AiTerminalChatWidget { + + protected chatContainer: HTMLDivElement; + protected chatInput: HTMLTextAreaElement; + protected chatResultParagraph: HTMLParagraphElement; + protected chatInputContainer: HTMLDivElement; + + protected haveResult = false; + commands: string[]; + + constructor( + protected terminalWidget: TerminalWidgetImpl, + protected terminalAgent: AiTerminalAgent + ) { + this.chatContainer = document.createElement('div'); + this.chatContainer.className = 'ai-terminal-chat-container'; + + const chatCloseButton = document.createElement('span'); + chatCloseButton.className = 'closeButton codicon codicon-close'; + chatCloseButton.onclick = () => this.dispose(); + this.chatContainer.appendChild(chatCloseButton); + + const chatResultContainer = document.createElement('div'); + chatResultContainer.className = 'ai-terminal-chat-result'; + this.chatResultParagraph = document.createElement('p'); + this.chatResultParagraph.textContent = 'How can I help you?'; + chatResultContainer.appendChild(this.chatResultParagraph); + this.chatContainer.appendChild(chatResultContainer); + + this.chatInputContainer = document.createElement('div'); + this.chatInputContainer.className = 'ai-terminal-chat-input-container'; + + this.chatInput = document.createElement('textarea'); + this.chatInput.className = 'theia-input theia-ChatInput'; + this.chatInput.placeholder = 'Ask about a terminal command...'; + this.chatInput.onkeydown = event => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + if (!this.haveResult) { + this.send(); + } else { + this.terminalWidget.sendText(this.chatResultParagraph.innerText); + this.dispose(); + } + } else if (event.key === 'Escape') { + this.dispose(); + } else if (event.key === 'ArrowUp' && this.haveResult) { + this.updateChatResult(this.getNextCommandIndex(1)); + } else if (event.key === 'ArrowDown' && this.haveResult) { + this.updateChatResult(this.getNextCommandIndex(-1)); + } + }; + this.chatInputContainer.appendChild(this.chatInput); + + const chatInputOptionsContainer = document.createElement('div'); + const chatInputOptionsSpan = document.createElement('span'); + chatInputOptionsSpan.className = 'codicon codicon-send option'; + chatInputOptionsSpan.title = 'Send'; + chatInputOptionsSpan.onclick = () => this.send(); + chatInputOptionsContainer.appendChild(chatInputOptionsSpan); + this.chatInputContainer.appendChild(chatInputOptionsContainer); + + this.chatContainer.appendChild(this.chatInputContainer); + + terminalWidget.node.appendChild(this.chatContainer); + + this.chatInput.focus(); + } + + protected async send(): Promise { + const userRequest = this.chatInput.value; + if (userRequest) { + this.chatInput.value = ''; + + this.chatResultParagraph.innerText = 'Loading'; + this.chatResultParagraph.className = 'loading'; + + const cwd = (await this.terminalWidget.cwd).toString(); + const processInfo = await this.terminalWidget.processInfo; + const shell = processInfo.executable; + const recentTerminalContents = this.getRecentTerminalCommands(); + + this.commands = await this.terminalAgent.getCommands( + { userRequest, cwd, shell, recentTerminalContents } + ); + + if (this.commands.length > 0) { + this.chatResultParagraph.className = 'command'; + this.chatResultParagraph.innerText = this.commands[0]; + this.chatInput.placeholder = 'Hit enter to confirm or use β‡… to show alternatives...'; + this.haveResult = true; + } else { + this.chatResultParagraph.className = ''; + this.chatResultParagraph.innerText = 'No results'; + this.chatInput.placeholder = 'Try again...'; + } + } + } + + protected getRecentTerminalCommands(): string[] { + const maxLines = 100; + return this.terminalWidget.buffer.getLines(0, + this.terminalWidget.buffer.length > maxLines ? maxLines : this.terminalWidget.buffer.length + ); + } + + protected getNextCommandIndex(step: number): number { + const currentIndex = this.commands.indexOf(this.chatResultParagraph.innerText); + const nextIndex = (currentIndex + step + this.commands.length) % this.commands.length; + return nextIndex; + } + + protected updateChatResult(index: number): void { + this.chatResultParagraph.innerText = this.commands[index]; + } + + protected dispose(): void { + this.chatInput.value = ''; + this.terminalWidget.node.removeChild(this.chatContainer); + this.terminalWidget.getTerminal().focus(); + } +} diff --git a/packages/ai-terminal/src/browser/ai-terminal-frontend-module.ts b/packages/ai-terminal/src/browser/ai-terminal-frontend-module.ts new file mode 100644 index 0000000000000..9f8ff9c059540 --- /dev/null +++ b/packages/ai-terminal/src/browser/ai-terminal-frontend-module.ts @@ -0,0 +1,34 @@ +// ***************************************************************************** +// 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 { Agent } from '@theia/ai-core/lib/common'; +import { CommandContribution, MenuContribution } from '@theia/core'; +import { KeybindingContribution } from '@theia/core/lib/browser'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { AiTerminalAgent } from './ai-terminal-agent'; +import { AiTerminalCommandContribution } from './ai-terminal-contribution'; + +import '../../src/browser/style/ai-terminal.css'; + +export default new ContainerModule(bind => { + bind(AiTerminalCommandContribution).toSelf().inSingletonScope(); + for (const identifier of [CommandContribution, MenuContribution, KeybindingContribution]) { + bind(identifier).toService(AiTerminalCommandContribution); + } + + bind(AiTerminalAgent).toSelf().inSingletonScope(); + bind(Agent).toService(AiTerminalAgent); +}); diff --git a/packages/ai-terminal/src/browser/style/ai-terminal.css b/packages/ai-terminal/src/browser/style/ai-terminal.css new file mode 100644 index 0000000000000..acce0a411a725 --- /dev/null +++ b/packages/ai-terminal/src/browser/style/ai-terminal.css @@ -0,0 +1,94 @@ +.ai-terminal-chat-container { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 500px; + padding: 10px; + box-sizing: border-box; + background: var(--theia-menu-background); + color: var(--theia-menu-foreground); + margin-bottom: 12px; + display: flex; + flex-direction: column; + align-items: center; + border: 1px solid var(--theia-menu-border); +} + +.ai-terminal-chat-container .closeButton { + position: absolute; + top: 1em; + right: 1em; + cursor: pointer; +} + +.ai-terminal-chat-container .closeButton:hover { + color: var(--theia-menu-foreground); +} + +.ai-terminal-chat-result { + width: 100%; + margin-bottom: 10px; +} + +.ai-terminal-chat-input-container { + width: 100%; + display: flex; + align-items: center; +} + +.ai-terminal-chat-input-container textarea { + flex-grow: 1; + height: 36px; + background-color: var(--theia-input-background); + border-radius: 4px; + box-sizing: border-box; + padding: 8px; + resize: none; + overflow: hidden; + line-height: 1.3rem; + margin-right: 10px; /* Add some space between textarea and button */ +} + +.ai-terminal-chat-input-container .option { + width: 21px; + height: 21px; + display: inline-block; + box-sizing: border-box; + user-select: none; + background-repeat: no-repeat; + background-position: center; + border: var(--theia-border-width) solid transparent; + opacity: 0.7; + cursor: pointer; +} + +.ai-terminal-chat-input-container .option:hover { + opacity: 1; +} + +@keyframes dots { + 0%, + 20% { + content: ""; + } + 40% { + content: "."; + } + 60% { + content: ".."; + } + 80%, + 100% { + content: "..."; + } +} +.ai-terminal-chat-result p.loading::after { + content: ""; + animation: dots 1s steps(1, end) infinite; +} + +.ai-terminal-chat-result p.command { + font-family: "Droid Sans Mono", "monospace", monospace; +} diff --git a/packages/ai-terminal/src/package.spec.ts b/packages/ai-terminal/src/package.spec.ts new file mode 100644 index 0000000000000..7c55c63eb414f --- /dev/null +++ b/packages/ai-terminal/src/package.spec.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2024 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 +// ***************************************************************************** + +/* note: this bogus test file is required so that + we are able to run mocha unit tests on this + package, without having any actual unit tests in it. + This way a coverage report will be generated, + showing 0% coverage, instead of no report. + This file can be removed once we have real unit + tests in place. */ + +describe('ai-terminal package', () => { + + it('support code coverage statistics', () => true); +}); diff --git a/packages/ai-terminal/tsconfig.json b/packages/ai-terminal/tsconfig.json new file mode 100644 index 0000000000000..9269a0f774e34 --- /dev/null +++ b/packages/ai-terminal/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-chat" + }, + { + "path": "../ai-core" + }, + { + "path": "../core" + }, + { + "path": "../terminal" + } + ] +} diff --git a/packages/ai-workspace-agent/.eslintrc.js b/packages/ai-workspace-agent/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-workspace-agent/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-workspace-agent/README.md b/packages/ai-workspace-agent/README.md new file mode 100644 index 0000000000000..947501725540d --- /dev/null +++ b/packages/ai-workspace-agent/README.md @@ -0,0 +1,30 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Workspace Agent EXTENSION

+ +
+ +
+ +## Description + +The `@theia/ai-workspace-agent` extension contributes the `Workspace` agent to Theia AI. +The agent is able to inspect the current files of the workspace, including their content, to answer questions. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-workspace-agent/package.json b/packages/ai-workspace-agent/package.json new file mode 100644 index 0000000000000..fabb4e158a6ae --- /dev/null +++ b/packages/ai-workspace-agent/package.json @@ -0,0 +1,53 @@ +{ + "name": "@theia/ai-workspace-agent", + "version": "1.52.0", + "description": "AI Workspace Agent Extension", + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "keywords": [ + "theia-extension" + ], + "dependencies": { + "@theia/core": "1.52.0", + "@theia/filesystem": "1.52.0", + "@theia/workspace": "1.52.0", + "@theia/navigator": "1.52.0", + "@theia/terminal": "1.52.0", + "@theia/ai-core": "1.52.0", + "@theia/ai-chat": "1.52.0" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@theia/cli": "1.52.0", + "@theia/test": "1.52.0" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/frontend-module" + } + ], + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-workspace-agent/src/browser/frontend-module.ts b/packages/ai-workspace-agent/src/browser/frontend-module.ts new file mode 100644 index 0000000000000..101f3702b0cce --- /dev/null +++ b/packages/ai-workspace-agent/src/browser/frontend-module.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// 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 { ContainerModule } from '@theia/core/shared/inversify'; +import { ChatAgent } from '@theia/ai-chat/lib/common'; +import { Agent, ToolProvider } from '@theia/ai-core/lib/common'; +import { WorkspaceAgent } from './workspace-agent'; +import { FileContentFunction, GetWorkspaceFileList } from './functions'; + +export default new ContainerModule(bind => { + bind(WorkspaceAgent).toSelf().inSingletonScope(); + bind(Agent).toService(WorkspaceAgent); + bind(ChatAgent).toService(WorkspaceAgent); + bind(ToolProvider).to(GetWorkspaceFileList); + bind(ToolProvider).to(FileContentFunction); +}); diff --git a/packages/ai-workspace-agent/src/browser/functions.ts b/packages/ai-workspace-agent/src/browser/functions.ts new file mode 100644 index 0000000000000..454d6aeb8450f --- /dev/null +++ b/packages/ai-workspace-agent/src/browser/functions.ts @@ -0,0 +1,134 @@ +// ***************************************************************************** +// 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 { ToolProvider, ToolRequest } from '@theia/ai-core'; +import { URI } from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { FileStat } from '@theia/filesystem/lib/common/files'; +import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { FILE_CONTENT_FUNCTION_ID, GET_WORKSPACE_FILE_LIST_FUNCTION_ID } from '../common/functions'; + +/** + * A Function that can read the contents of a File from the Workspace. + */ +@injectable() +export class FileContentFunction implements ToolProvider { + static ID = FILE_CONTENT_FUNCTION_ID; + + getTool(): ToolRequest { + return { + id: FileContentFunction.ID, + name: FileContentFunction.ID, + description: 'Get the content of the file', + parameters: { + type: 'object', + properties: { + file: { + type: 'string', + description: 'The path of the file to retrieve content for', + } + } + }, + handler: (arg_string: string) => { + const file = this.parseArg(arg_string); + return this.getFileContent(file); + } + }; + } + + @inject(WorkspaceService) + protected workspaceService: WorkspaceService; + + @inject(FileService) + protected readonly fileService: FileService; + + private parseArg(arg_string: string): string { + const result = JSON.parse(arg_string); + return result.file; + } + + private async getFileContent(file: string): Promise { + const uri = new URI(file); + const fileContent = await this.fileService.read(uri); + return fileContent.value; + } +} + +/** + * A Function that lists all files in the workspace. + */ +@injectable() +export class GetWorkspaceFileList implements ToolProvider { + static ID = GET_WORKSPACE_FILE_LIST_FUNCTION_ID; + + getTool(): ToolRequest { + return { + id: GetWorkspaceFileList.ID, + name: GetWorkspaceFileList.ID, + description: 'List all files in the workspace', + + handler: () => this.getProjectFileList() + }; + } + + @inject(WorkspaceService) + protected workspaceService: WorkspaceService; + + @inject(FileService) + protected readonly fileService: FileService; + + async getProjectFileList(): Promise { + // Get all files from the workspace service as a flat list of qualified file names + const wsRoots = await this.workspaceService.roots; + const result: string[] = []; + for (const root of wsRoots) { + result.push(...await this.listFilesRecursively(root.resource)); + } + return result; + } + + private async listFilesRecursively(uri: URI): Promise { + const stat = await this.fileService.resolve(uri); + const result: string[] = []; + if (stat && stat.isDirectory) { + if (this.exclude(stat)) { + return result; + } + const children = await this.fileService.resolve(uri); + if (children.children) { + for (const child of children.children) { + result.push(child.resource.toString()); + result.push(...await this.listFilesRecursively(child.resource)); + } + } + } + return result; + } + + // Exclude folders which are not relevant to the AI Agent + private exclude(stat: FileStat): boolean { + if (stat.resource.path.base.startsWith('.')) { + return true; + } + if (stat.resource.path.base === 'node_modules') { + return true; + } + if (stat.resource.path.base === 'lib') { + return true; + } + return false; + } +} diff --git a/packages/ai-workspace-agent/src/browser/workspace-agent.ts b/packages/ai-workspace-agent/src/browser/workspace-agent.ts new file mode 100644 index 0000000000000..1d6a42b0bb37d --- /dev/null +++ b/packages/ai-workspace-agent/src/browser/workspace-agent.ts @@ -0,0 +1,46 @@ +// ***************************************************************************** +// 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 { AbstractStreamParsingChatAgent, SystemMessage } from '@theia/ai-chat/lib/common'; +import { FunctionCallRegistry, LanguageModelRequirement } from '@theia/ai-core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { template } from '../common/template'; + +@injectable() +export class WorkspaceAgent extends AbstractStreamParsingChatAgent { + id = 'Workspace'; + name = 'Workspace Agent'; + description = `This agent can access the workspace and thus can answer +questions about projects, project files, and source code in the workspace, such as building the project, +finding out what this project is about, or how to implement certain aspects of based on the project code. +`; + promptTemplates = [template]; + override variables = []; + + languageModelRequirements: LanguageModelRequirement[] = [{ + purpose: 'chat', + identifier: 'openai/gpt-4o', + }]; + + protected override languageModelPurpose = 'chat'; + + @inject(FunctionCallRegistry) + protected functionCallRegistry: FunctionCallRegistry; + + protected override async getSystemMessage(): Promise { + const resolvedPrompt = await this.promptService.getPrompt(template.id); + return resolvedPrompt ? SystemMessage.fromResolvedPromptTemplate(resolvedPrompt) : undefined; + } +} diff --git a/packages/ai-workspace-agent/src/common/functions.ts b/packages/ai-workspace-agent/src/common/functions.ts new file mode 100644 index 0000000000000..852a6c8f60f95 --- /dev/null +++ b/packages/ai-workspace-agent/src/common/functions.ts @@ -0,0 +1,17 @@ +// ***************************************************************************** +// 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 +// ***************************************************************************** +export const FILE_CONTENT_FUNCTION_ID = 'getFileContent'; +export const GET_WORKSPACE_FILE_LIST_FUNCTION_ID = 'getWorkspaceFileList'; diff --git a/packages/ai-workspace-agent/src/common/template.ts b/packages/ai-workspace-agent/src/common/template.ts new file mode 100644 index 0000000000000..b1c2bb4aaf27b --- /dev/null +++ b/packages/ai-workspace-agent/src/common/template.ts @@ -0,0 +1,29 @@ +// ***************************************************************************** +// 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 { PromptTemplate } from '@theia/ai-core/lib/common'; +import { GET_WORKSPACE_FILE_LIST_FUNCTION_ID, FILE_CONTENT_FUNCTION_ID } from './functions'; + +export const template = { + id: 'workspace-prompt', + template: `You are an AI Agent to help developers with coding inside of the IDE. + The user has the workspace open. + If needed, you can ask for more information. + The following functions are available to you: + - ~{${GET_WORKSPACE_FILE_LIST_FUNCTION_ID}} + - ~{${FILE_CONTENT_FUNCTION_ID}} + +Never shorten the file paths when using getFileContent.` +}; diff --git a/packages/ai-workspace-agent/src/package.spec.ts b/packages/ai-workspace-agent/src/package.spec.ts new file mode 100644 index 0000000000000..106f1490b2d7a --- /dev/null +++ b/packages/ai-workspace-agent/src/package.spec.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2024 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 +// ***************************************************************************** + +/* note: this bogus test file is required so that + we are able to run mocha unit tests on this + package, without having any actual unit tests in it. + This way a coverage report will be generated, + showing 0% coverage, instead of no report. + This file can be removed once we have real unit + tests in place. */ + +describe('ai-workspace-agent package', () => { + + it('support code coverage statistics', () => true); +}); diff --git a/packages/ai-workspace-agent/tsconfig.json b/packages/ai-workspace-agent/tsconfig.json new file mode 100644 index 0000000000000..60c1ac9586d07 --- /dev/null +++ b/packages/ai-workspace-agent/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../dev-packages/cli" + }, + { + "path": "../ai-chat" + }, + { + "path": "../ai-core" + }, + { + "path": "../core" + }, + { + "path": "../filesystem" + }, + { + "path": "../navigator" + }, + { + "path": "../terminal" + }, + { + "path": "../test" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/editor/src/browser/editor-manager.ts b/packages/editor/src/browser/editor-manager.ts index 1da41c8a31246..a855f765fafe0 100644 --- a/packages/editor/src/browser/editor-manager.ts +++ b/packages/editor/src/browser/editor-manager.ts @@ -112,7 +112,7 @@ export class EditorManager extends NavigatableWidgetOpenHandler { if (!(editorPromise instanceof Widget)) { editorPromise.then(editor => this.revealSelection(editor, options, uri)); } else { - this.revealSelection(editorPromise, options); + this.revealSelection(editorPromise, options, uri); } } return editorPromise; diff --git a/packages/editor/src/browser/editor-variable-contribution.ts b/packages/editor/src/browser/editor-variable-contribution.ts index d732f4eeaf337..4d8a3ffcbcb7c 100644 --- a/packages/editor/src/browser/editor-variable-contribution.ts +++ b/packages/editor/src/browser/editor-variable-contribution.ts @@ -39,7 +39,15 @@ export class EditorVariableContribution implements VariableContribution { description: 'The current selected text in the active file', resolve: () => { const editor = this.getCurrentEditor(); - return editor ? editor.document.getText(editor.selection) : undefined; + return editor?.document.getText(editor.selection); + } + }); + variables.registerVariable({ + name: 'currentText', + description: 'The current text in the active file', + resolve: () => { + const editor = this.getCurrentEditor(); + return editor?.document.getText(); } }); } diff --git a/packages/getting-started/src/browser/getting-started-widget.tsx b/packages/getting-started/src/browser/getting-started-widget.tsx index c40b7c95ce93c..9e64d35897705 100644 --- a/packages/getting-started/src/browser/getting-started-widget.tsx +++ b/packages/getting-started/src/browser/getting-started-widget.tsx @@ -14,18 +14,18 @@ // 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 URI from '@theia/core/lib/common/uri'; -import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; -import { CommandRegistry, isOSX, environment, Path } from '@theia/core/lib/common'; -import { WorkspaceCommands, WorkspaceService } from '@theia/workspace/lib/browser'; -import { KeymapsCommands } from '@theia/keymaps/lib/browser'; -import { Message, ReactWidget, CommonCommands, LabelProvider, Key, KeyCode, codicon, PreferenceService } from '@theia/core/lib/browser'; -import { ApplicationInfo, ApplicationServer } from '@theia/core/lib/common/application-protocol'; +import { codicon, CommonCommands, Key, KeyCode, LabelProvider, Message, PreferenceService, ReactWidget } from '@theia/core/lib/browser'; import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; -import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { WindowService } from '@theia/core/lib/browser/window/window-service'; +import { CommandRegistry, environment, isOSX, Path } from '@theia/core/lib/common'; +import { ApplicationInfo, ApplicationServer } from '@theia/core/lib/common/application-protocol'; +import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { nls } from '@theia/core/lib/common/nls'; +import URI from '@theia/core/lib/common/uri'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { KeymapsCommands } from '@theia/keymaps/lib/browser'; +import { WorkspaceCommands, WorkspaceService } from '@theia/workspace/lib/browser'; /** * Default implementation of the `GettingStartedWidget`. @@ -71,6 +71,11 @@ export class GettingStartedWidget extends ReactWidget { */ protected recentWorkspaces: string[] = []; + /** + * Indicates whether the "ai-core" extension is available. + */ + protected aiIsIncluded: boolean; + /** * Collection of useful links to display for end users. */ @@ -78,6 +83,8 @@ export class GettingStartedWidget extends ReactWidget { protected readonly compatibilityUrl = 'https://eclipse-theia.github.io/vscode-theia-comparator/status.html'; protected readonly extensionUrl = 'https://www.theia-ide.org/docs/authoring_extensions'; protected readonly pluginUrl = 'https://www.theia-ide.org/docs/authoring_plugins'; + protected readonly theiaAIDocUrl = 'https://theia-ide.org/docs/user_ai/'; + protected readonly ghProjectUrl = 'https://github.com/eclipse-theia/theia/issues/new/choose'; @inject(ApplicationServer) protected readonly appServer: ApplicationServer; @@ -114,6 +121,9 @@ export class GettingStartedWidget extends ReactWidget { this.applicationInfo = await this.appServer.getApplicationInfo(); this.recentWorkspaces = await this.workspaceService.recentWorkspaces(); this.home = new URI(await this.environments.getHomeDirUri()).path.toString(); + + const extensions = await this.appServer.getExtensionsInfos(); + this.aiIsIncluded = extensions.find(ext => ext.name === '@theia/ai-core') !== undefined; this.update(); } @@ -131,6 +141,11 @@ export class GettingStartedWidget extends ReactWidget { protected render(): React.ReactNode { return
+ {this.aiIsIncluded && +
+ {this.renderAIBanner()} +
+ } {this.renderHeader()}
@@ -387,6 +402,69 @@ export class GettingStartedWidget extends ReactWidget { return ; } + protected renderAIBanner(): React.ReactNode { + return
+
+
+

πŸš€ Theia AI [Experimental] is available! ✨

+
+
+ Theia IDE now contains the experimental "Theia AI" feature, which offers early access to cutting-edge AI capabilities within your IDE. +
+
+ Please note that these features are disabled by default, ensuring that users can opt-in at their discretion without any concerns. + For those who choose to enable Theia AI, it is important to be aware that these experimental features may generate continuous + requests to the language models (LLMs) you provide access to, potentially incurring additional costs. +
+ For more details, please visit   + this.doOpenExternalLink(this.theiaAIDocUrl)} + onKeyDown={(e: React.KeyboardEvent) => this.doOpenExternalLinkEnter(e, this.theiaAIDocUrl)}> + {'Theia AI Documentation'} + . +
+
+ We encourage feedback, contributions, and sponsorship to support the ongoing development of the Theia AI initiative use our  + this.doOpenExternalLink(this.ghProjectUrl)} + onKeyDown={(e: React.KeyboardEvent) => this.doOpenExternalLinkEnter(e, this.ghProjectUrl)}> + {'Github Project'} + . +  Thank you for being part of our community! +
+
+ Please note that this feature is currently in development and may undergo frequent changes. 🚧 +
+
+ +
+
+
+
+
; + } + + protected doOpenAIChatView = () => this.commandRegistry.executeCommand('ai-chat:open'); + protected doOpenAIChatViewEnter = (e: React.KeyboardEvent) => { + if (this.isEnterKey(e)) { + this.doOpenAIChatView(); + } + }; + /** * Build the list of workspace paths. * @param workspaces {string[]} the list of workspaces. diff --git a/packages/getting-started/src/browser/style/index.css b/packages/getting-started/src/browser/style/index.css index 17216da4df9da..274fefe468e47 100644 --- a/packages/getting-started/src/browser/style/index.css +++ b/packages/getting-started/src/browser/style/index.css @@ -107,3 +107,23 @@ body { display: flex; align-items: center; } + +.gs-float { + float: right; + width: 50%; + margin-top: 100px; +} + +.gs-container.gs-experimental-container { + border: 1px solid var(--theia-focusBorder); + padding: 15px; +} + +.shadow-pulse { + animation: shadowPulse 2s infinite ease-in-out; +} + +@keyframes shadowPulse { + 0%, 100% { box-shadow: 0 0 0 rgba(0, 0, 0, 0); } + 50% { box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); } +} diff --git a/packages/monaco/src/browser/monaco-editor-provider.ts b/packages/monaco/src/browser/monaco-editor-provider.ts index 75fa4dbcb049a..79247db06bfc3 100644 --- a/packages/monaco/src/browser/monaco-editor-provider.ts +++ b/packages/monaco/src/browser/monaco-editor-provider.ts @@ -87,6 +87,9 @@ export class MonacoEditorProvider { @inject(EditorPreferences) protected readonly editorPreferences: EditorPreferences, @inject(MonacoDiffNavigatorFactory) protected readonly diffNavigatorFactory: MonacoDiffNavigatorFactory, ) { + StandaloneServices.get(IOpenerService).registerOpener({ + open: (u, options) => this.interceptOpen(u, options) + }); } protected async getModel(uri: URI, toDispose: DisposableCollection): Promise { @@ -113,9 +116,6 @@ export class MonacoEditorProvider { ): Promise { const domNode = document.createElement('div'); const contextKeyService = StandaloneServices.get(IContextKeyService).createScoped(domNode); - StandaloneServices.get(IOpenerService).registerOpener({ - open: (u, options) => this.interceptOpen(u, options) - }); const overrides: EditorServiceOverrides = [ [IContextKeyService, contextKeyService], ]; diff --git a/packages/terminal/src/browser/terminal-link-provider.ts b/packages/terminal/src/browser/terminal-link-provider.ts index d6db9be69dfea..3d12f7522cc6f 100644 --- a/packages/terminal/src/browser/terminal-link-provider.ts +++ b/packages/terminal/src/browser/terminal-link-provider.ts @@ -25,7 +25,7 @@ import { TerminalWidgetImpl } from './terminal-widget-impl'; export const TerminalLinkProvider = Symbol('TerminalLinkProvider'); export interface TerminalLinkProvider { - provideLinks(line: string, terminal: TerminalWidget, cancelationToken?: CancellationToken): Promise; + provideLinks(line: string, terminal: TerminalWidget, cancellationToken?: CancellationToken): Promise; } export const TerminalLink = Symbol('TerminalLink'); diff --git a/tsconfig.json b/tsconfig.json index fec10e328dcec..53c2d8b64f0a7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,6 +54,30 @@ { "path": "examples/playwright" }, + { + "path": "packages/ai-chat" + }, + { + "path": "packages/ai-chat-ui" + }, + { + "path": "packages/ai-code-completion" + }, + { + "path": "packages/ai-core" + }, + { + "path": "packages/ai-history" + }, + { + "path": "packages/ai-openai" + }, + { + "path": "packages/ai-terminal" + }, + { + "path": "packages/ai-workspace-agent" + }, { "path": "packages/bulk-edit" }, diff --git a/yarn.lock b/yarn.lock index 3aabc7d1cd69a..df0f13c9de69c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2133,7 +2133,7 @@ resolved "https://registry.yarnpkg.com/@types/node-abi/-/node-abi-3.0.3.tgz#a8334d75fe45ccd4cdb2a6c1ae82540a7a76828c" integrity sha512-5oos6sivyXcDEuVC5oX3+wLwfgrGZu4NIOn826PGAjPCHsqp2zSPTGU7H1Tv+GZBOiDUY3nBXY1MdaofSEt4fw== -"@types/node-fetch@^2.5.7": +"@types/node-fetch@^2.5.7", "@types/node-fetch@^2.6.4": version "2.6.11" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== @@ -2774,6 +2774,13 @@ abbrev@^1.0.0: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.4, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -5472,6 +5479,11 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.0, eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -5867,6 +5879,11 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + form-data@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" @@ -5876,6 +5893,14 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +formdata-node@^4.3.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -8529,6 +8554,11 @@ node-api-version@^0.1.4: dependencies: semver "^7.3.5" +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-fetch@2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -9033,6 +9063,19 @@ open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openai@^4.55.7: + version "4.55.7" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.55.7.tgz#2bba4ae9224ad205c0d087d1412fe95421397dff" + integrity sha512-I2dpHTINt0Zk+Wlns6KzkKu77MmNW3VfIIQf5qYziEUI6t7WciG1zTobfKqdPzBmZi3TTM+3DtjPumxQdcvzwA== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + opener@^1.5.1: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -10966,7 +11009,7 @@ string-argv@^0.1.1: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.1.2.tgz#c5b7bc03fb2b11983ba3a72333dd0559e77e4738" integrity sha512-mBqPGEOMNJKXRo7z0keX0wlAhbBAjilUdPW13nN0PecVryZxdHIeM7TqbsSUA7VYuS00HGC6mojP7DlQzfa9ZA== -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10984,6 +11027,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -11049,7 +11101,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11070,6 +11122,13 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -12039,6 +12098,11 @@ wcwidth@^1.0.0, wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" @@ -12258,7 +12322,7 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -12276,6 +12340,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -12567,3 +12640,13 @@ zip-stream@^4.1.0: archiver-utils "^3.0.4" compress-commons "^4.1.2" readable-stream "^3.6.0" + +zod-to-json-schema@^3.23.2: + version "3.23.2" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.23.2.tgz#bc7e379c8050462538383e382964c03d8fe008f9" + integrity sha512-uSt90Gzc/tUfyNqxnjlfBs8W6WSGpNBv0rVsNxP/BVSMHMKGdthPYff4xtCHYloJGM0CFxFsb3NbC0eqPhfImw== + +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==