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 @@
+
+
+
+
+
+
+
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
+
+
+ {
+ 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 Copy ;
+};
+
+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 Insert at Cursor ;
+};
+
+/**
+ * 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 ? (
+ {label}
+ ) : (
+ 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.
+
+ Toggle the switch for 'Ai-features: Enable' .
+ Provide an OpenAI API Key through the 'OpenAI: API Key' setting or by
+ setting the OPENAI_API_KEY environment variable.
+
+
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 @@
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
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}
+
+
+
+ Enable Agent
+
+
+
+
+ {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 */}
+ <>
+ Language Model:
+ onSelectedModelChange(requirements.purpose, event)}
+ >
+
+ {languageModels?.map(model => (
+ {model.name ?? model.id}
+ ))}
+
+ >
+
+
+
+ ))}
+
+
;
+};
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}
+ Edit
+ Reset
+ >;
+};
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 ;
+ }
+
+ 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