From 4c33b820df57202aab850106b8ecc964b4333dd5 Mon Sep 17 00:00:00 2001 From: Stefan Dirix Date: Tue, 13 Aug 2024 16:57:47 +0200 Subject: [PATCH] Theia AI LLM Support [Experimental] Implements AI LLM support via optionally consumable Theia extensions. The base functionality is provided by the following extensions: - @theia/ai-core - @theia/ai-chat - @theia/ai-chat-ui 'ai-core' contains the basic LLM integration and defines the core concepts for interacting with LLM via agents, prompts and variables. 'ai-chat' builts on top to define a model for chat like conversations. 'ai-chat-ui' provides the actual Chat UI. The AI integration was built from the ground up to be flexible, inspectible, customizable and configurable. This feature is still highly experimental. Therefore, even when the AI extensions are included in a Theia based application, they are turned off by default and need to be enabled in the preferences. The preferences include a convenient: turn all AI features off setting. Additional features and integrations are offered by the remaining extensions: - @theia/ai-history - @theia/ai-code-completion - @theia/ai-terminal - @theia/ai-workspace-agent - @theia/ai-openai 'ai-history' offers a service to record requests and responses. The recordings can be inspected via the 'AI History View'. 'ai-code-completion' offers AI based code completion via completion items and inline suggestions. 'ai-terminal' offers a specialized AI for the Theia terminal which will suggest commands to execute. 'ai-workspace-agent' is a specialized agent which is able to inspect the current workspace content for context specific questions. 'ai-openai' integrates the LLM offerings of Open AI into Theia. Co-authored-by: Alexandra Muntean Co-authored-by: Camille Letavernier Co-authored-by: Christian W. Damus Co-authored-by: Eugen Neufeld Co-authored-by: Haydar Metin Co-authored-by: Johannes Faltermeier Co-authored-by: Jonas Helming Co-authored-by: Lucas Koehler Co-authored-by: Martin Fleck Co-authored-by: Maximilian Koegel Co-authored-by: Nina Doschek Co-authored-by: Olaf Lessenich Co-authored-by: Philip Langer Co-authored-by: Remi Schnekenburger Co-authored-by: Simon Graband Co-authored-by: Tobias Ortmayr --- examples/browser-only/package.json | 6 + examples/browser-only/tsconfig.json | 18 + examples/browser/package.json | 8 + examples/browser/tsconfig.json | 24 + examples/electron/package.json | 8 + examples/electron/tsconfig.json | 24 + packages/ai-chat-ui/.eslintrc.js | 10 + packages/ai-chat-ui/README.md | 32 + packages/ai-chat-ui/package.json | 58 ++ .../browser/ai-chat-command-contribution.ts | 41 + .../src/browser/aichat-ui-contribution.ts | 182 +++++ .../src/browser/aichat-ui-frontend-module.ts | 106 +++ .../src/browser/chat-input-widget.tsx | 234 ++++++ .../ai-editor-manager.ts | 183 +++++ .../ai-monaco-editor-provider.ts | 56 ++ .../code-part-renderer.tsx | 209 +++++ .../command-part-renderer.tsx | 59 ++ .../error-part-renderer.tsx | 35 + .../horizontal-layout-part-renderer.tsx | 61 ++ .../browser/chat-response-renderer/index.ts | 24 + .../markdown-part-renderer.tsx | 73 ++ .../text-part-renderer.spec.ts | 50 ++ .../text-part-renderer.tsx | 35 + .../toolcall-part-renderer.tsx | 49 ++ .../chat-view-tree-container.ts | 32 + .../chat-tree-view/chat-view-tree-widget.tsx | 356 +++++++++ .../src/browser/chat-tree-view/index.ts | 18 + .../src/browser/chat-view-commands.ts | 57 ++ .../src/browser/chat-view-contribution.ts | 158 ++++ .../chat-view-language-contribution.ts | 141 ++++ .../chat-view-widget-toolbar-contribution.tsx | 60 ++ .../src/browser/chat-view-widget.tsx | 184 +++++ .../ai-chat-ui/src/browser/style/index.css | 294 +++++++ packages/ai-chat-ui/src/browser/types.ts | 25 + packages/ai-chat-ui/tsconfig.json | 37 + packages/ai-chat/.eslintrc.js | 10 + packages/ai-chat/README.md | 30 + packages/ai-chat/package.json | 56 ++ .../src/browser/agent-frontend-module.ts | 57 ++ .../ai-chat/src/common/chat-agent-service.ts | 74 ++ packages/ai-chat/src/common/chat-agents.ts | 384 ++++++++++ packages/ai-chat/src/common/chat-model.ts | 718 ++++++++++++++++++ .../ai-chat/src/common/chat-parsed-request.ts | 135 ++++ .../src/common/chat-request-parser.spec.ts | 120 +++ .../ai-chat/src/common/chat-request-parser.ts | 214 ++++++ packages/ai-chat/src/common/chat-service.ts | 230 ++++++ packages/ai-chat/src/common/chat-variables.ts | 34 + .../ai-chat/src/common/command-chat-agents.ts | 351 +++++++++ .../ai-chat/src/common/default-chat-agent.ts | 100 +++ .../src/common/delegating-chat-agent.ts | 117 +++ packages/ai-chat/src/common/index.ts | 25 + .../ai-chat/src/node/agent-backend-module.ts | 47 ++ packages/ai-chat/tsconfig.json | 28 + packages/ai-code-completion/.eslintrc.js | 10 + packages/ai-code-completion/README.md | 30 + packages/ai-code-completion/package.json | 54 ++ .../ai-code-completion-frontend-module.ts | 34 + .../browser/ai-code-completion-preference.ts | 46 ++ .../browser/ai-code-completion-provider.ts | 84 ++ ...-code-frontend-application-contribution.ts | 73 ++ .../ai-code-inline-completion-provider.ts | 43 ++ .../ai-code-completion/src/browser/index.ts | 18 + .../src/common/code-completion-agent.ts | 154 ++++ .../ai-code-completion/src/package.spec.ts | 28 + packages/ai-code-completion/tsconfig.json | 28 + packages/ai-core/.eslintrc.js | 10 + packages/ai-core/README.md | 30 + .../data/prompttemplate.tmLanguage.json | 52 ++ packages/ai-core/package.json | 58 ++ .../src/browser/ai-activation-service.ts | 55 ++ .../agent-configuration-widget.tsx | 154 ++++ .../ai-configuration-service.ts | 43 ++ .../ai-configuration-view-contribution.ts | 54 ++ .../ai-configuration-widget.tsx | 80 ++ .../language-model-renderer.tsx | 113 +++ .../template-settings-renderer.tsx | 39 + .../variable-configuration-widget.tsx | 110 +++ ...-core-frontend-application-contribution.ts | 40 + .../src/browser/ai-core-frontend-module.ts | 159 ++++ .../src/browser/ai-core-preferences.ts | 98 +++ .../src/browser/ai-settings-service.ts | 56 ++ .../src/browser/ai-view-contribution.ts | 76 ++ .../frontend-language-model-registry.ts | 415 ++++++++++ .../frontend-prompt-customization-service.ts | 178 +++++ .../src/browser/frontend-variable-service.ts | 26 + packages/ai-core/src/browser/index.ts | 26 + .../browser/prompttemplate-contribution.ts | 250 ++++++ packages/ai-core/src/browser/style/index.css | 80 ++ .../browser/theia-variable-contribution.ts | 58 ++ packages/ai-core/src/common/agent-service.ts | 83 ++ packages/ai-core/src/common/agent.ts | 39 + .../common/agents-variable-contribution.ts | 68 ++ .../common/communication-recording-service.ts | 44 ++ .../src/common/function-call-registry.ts | 79 ++ packages/ai-core/src/common/index.ts | 28 + .../src/common/language-model-delegate.ts | 44 ++ .../ai-core/src/common/language-model-util.ts | 67 ++ .../ai-core/src/common/language-model.spec.ts | 86 +++ packages/ai-core/src/common/language-model.ts | 239 ++++++ .../ai-core/src/common/prompt-service.spec.ts | 87 +++ packages/ai-core/src/common/prompt-service.ts | 213 ++++++ packages/ai-core/src/common/protocol.ts | 23 + .../src/common/today-variable-contribution.ts | 67 ++ .../common/tomorrow-variable-contribution.ts | 66 ++ .../ai-core/src/common/variable-service.ts | 177 +++++ .../src/node/ai-core-backend-module.ts | 83 ++ .../node/backend-language-model-registry.ts | 60 ++ .../node/language-model-frontend-delegate.ts | 115 +++ packages/ai-core/tsconfig.json | 34 + packages/ai-history/.eslintrc.js | 10 + packages/ai-history/README.md | 31 + packages/ai-history/package.json | 53 ++ .../browser/ai-history-communication-card.tsx | 48 ++ .../src/browser/ai-history-contribution.ts | 52 ++ .../src/browser/ai-history-frontend-module.ts | 35 + .../src/browser/ai-history-widget.tsx | 96 +++ .../src/browser/style/ai-history.css | 74 ++ .../communication-recording-service.spec.ts | 34 + .../common/communication-recording-service.ts | 60 ++ packages/ai-history/src/common/index.ts | 17 + packages/ai-history/tsconfig.json | 28 + packages/ai-openai/.eslintrc.js | 10 + packages/ai-openai/README.md | 31 + packages/ai-openai/package.json | 53 ++ ...penai-frontend-application-contribution.ts | 60 ++ .../src/browser/openai-frontend-module.ts | 31 + .../src/browser/openai-preferences.ts | 40 + packages/ai-openai/src/common/index.ts | 16 + .../common/openai-language-models-manager.ts | 23 + .../src/node/openai-backend-module.ts | 30 + .../src/node/openai-language-model.ts | 173 +++++ .../openai-language-models-manager-impl.ts | 58 ++ packages/ai-openai/src/package.spec.ts | 28 + packages/ai-openai/tsconfig.json | 25 + packages/ai-terminal/.eslintrc.js | 10 + packages/ai-terminal/README.md | 31 + packages/ai-terminal/package.json | 51 ++ .../src/browser/ai-terminal-agent.ts | 177 +++++ .../src/browser/ai-terminal-contribution.ts | 191 +++++ .../browser/ai-terminal-frontend-module.ts | 34 + .../src/browser/style/ai-terminal.css | 94 +++ packages/ai-terminal/src/package.spec.ts | 28 + packages/ai-terminal/tsconfig.json | 25 + packages/ai-workspace-agent/.eslintrc.js | 10 + packages/ai-workspace-agent/README.md | 30 + packages/ai-workspace-agent/package.json | 53 ++ .../src/browser/frontend-module.ts | 28 + .../src/browser/functions.ts | 134 ++++ .../src/browser/workspace-agent.ts | 46 ++ .../src/common/functions.ts | 17 + .../ai-workspace-agent/src/common/template.ts | 29 + .../ai-workspace-agent/src/package.spec.ts | 28 + packages/ai-workspace-agent/tsconfig.json | 40 + packages/editor/src/browser/editor-manager.ts | 2 +- .../browser/editor-variable-contribution.ts | 10 +- .../src/browser/getting-started-widget.tsx | 96 ++- .../src/browser/style/index.css | 20 + .../src/browser/monaco-editor-provider.ts | 6 +- .../src/browser/terminal-link-provider.ts | 2 +- tsconfig.json | 24 + yarn.lock | 91 ++- 161 files changed, 12645 insertions(+), 19 deletions(-) create mode 100644 packages/ai-chat-ui/.eslintrc.js create mode 100644 packages/ai-chat-ui/README.md create mode 100644 packages/ai-chat-ui/package.json create mode 100644 packages/ai-chat-ui/src/browser/ai-chat-command-contribution.ts create mode 100644 packages/ai-chat-ui/src/browser/aichat-ui-contribution.ts create mode 100644 packages/ai-chat-ui/src/browser/aichat-ui-frontend-module.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-input-widget.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/ai-editor-manager.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/ai-monaco-editor-provider.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/code-part-renderer.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/command-part-renderer.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/error-part-renderer.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/horizontal-layout-part-renderer.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/index.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/markdown-part-renderer.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/text-part-renderer.spec.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/text-part-renderer.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-response-renderer/toolcall-part-renderer.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-container.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-tree-view/index.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-view-commands.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-view-contribution.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-view-language-contribution.ts create mode 100644 packages/ai-chat-ui/src/browser/chat-view-widget-toolbar-contribution.tsx create mode 100644 packages/ai-chat-ui/src/browser/chat-view-widget.tsx create mode 100644 packages/ai-chat-ui/src/browser/style/index.css create mode 100644 packages/ai-chat-ui/src/browser/types.ts create mode 100644 packages/ai-chat-ui/tsconfig.json create mode 100644 packages/ai-chat/.eslintrc.js create mode 100644 packages/ai-chat/README.md create mode 100644 packages/ai-chat/package.json create mode 100644 packages/ai-chat/src/browser/agent-frontend-module.ts create mode 100644 packages/ai-chat/src/common/chat-agent-service.ts create mode 100644 packages/ai-chat/src/common/chat-agents.ts create mode 100644 packages/ai-chat/src/common/chat-model.ts create mode 100644 packages/ai-chat/src/common/chat-parsed-request.ts create mode 100644 packages/ai-chat/src/common/chat-request-parser.spec.ts create mode 100644 packages/ai-chat/src/common/chat-request-parser.ts create mode 100644 packages/ai-chat/src/common/chat-service.ts create mode 100644 packages/ai-chat/src/common/chat-variables.ts create mode 100644 packages/ai-chat/src/common/command-chat-agents.ts create mode 100644 packages/ai-chat/src/common/default-chat-agent.ts create mode 100644 packages/ai-chat/src/common/delegating-chat-agent.ts create mode 100644 packages/ai-chat/src/common/index.ts create mode 100644 packages/ai-chat/src/node/agent-backend-module.ts create mode 100644 packages/ai-chat/tsconfig.json create mode 100644 packages/ai-code-completion/.eslintrc.js create mode 100644 packages/ai-code-completion/README.md create mode 100644 packages/ai-code-completion/package.json create mode 100644 packages/ai-code-completion/src/browser/ai-code-completion-frontend-module.ts create mode 100644 packages/ai-code-completion/src/browser/ai-code-completion-preference.ts create mode 100644 packages/ai-code-completion/src/browser/ai-code-completion-provider.ts create mode 100644 packages/ai-code-completion/src/browser/ai-code-frontend-application-contribution.ts create mode 100644 packages/ai-code-completion/src/browser/ai-code-inline-completion-provider.ts create mode 100644 packages/ai-code-completion/src/browser/index.ts create mode 100644 packages/ai-code-completion/src/common/code-completion-agent.ts create mode 100644 packages/ai-code-completion/src/package.spec.ts create mode 100644 packages/ai-code-completion/tsconfig.json create mode 100644 packages/ai-core/.eslintrc.js create mode 100644 packages/ai-core/README.md create mode 100644 packages/ai-core/data/prompttemplate.tmLanguage.json create mode 100644 packages/ai-core/package.json create mode 100644 packages/ai-core/src/browser/ai-activation-service.ts create mode 100644 packages/ai-core/src/browser/ai-configuration/agent-configuration-widget.tsx create mode 100644 packages/ai-core/src/browser/ai-configuration/ai-configuration-service.ts create mode 100644 packages/ai-core/src/browser/ai-configuration/ai-configuration-view-contribution.ts create mode 100644 packages/ai-core/src/browser/ai-configuration/ai-configuration-widget.tsx create mode 100644 packages/ai-core/src/browser/ai-configuration/language-model-renderer.tsx create mode 100644 packages/ai-core/src/browser/ai-configuration/template-settings-renderer.tsx create mode 100644 packages/ai-core/src/browser/ai-configuration/variable-configuration-widget.tsx create mode 100644 packages/ai-core/src/browser/ai-core-frontend-application-contribution.ts create mode 100644 packages/ai-core/src/browser/ai-core-frontend-module.ts create mode 100644 packages/ai-core/src/browser/ai-core-preferences.ts create mode 100644 packages/ai-core/src/browser/ai-settings-service.ts create mode 100644 packages/ai-core/src/browser/ai-view-contribution.ts create mode 100644 packages/ai-core/src/browser/frontend-language-model-registry.ts create mode 100644 packages/ai-core/src/browser/frontend-prompt-customization-service.ts create mode 100644 packages/ai-core/src/browser/frontend-variable-service.ts create mode 100644 packages/ai-core/src/browser/index.ts create mode 100644 packages/ai-core/src/browser/prompttemplate-contribution.ts create mode 100644 packages/ai-core/src/browser/style/index.css create mode 100644 packages/ai-core/src/browser/theia-variable-contribution.ts create mode 100644 packages/ai-core/src/common/agent-service.ts create mode 100644 packages/ai-core/src/common/agent.ts create mode 100644 packages/ai-core/src/common/agents-variable-contribution.ts create mode 100644 packages/ai-core/src/common/communication-recording-service.ts create mode 100644 packages/ai-core/src/common/function-call-registry.ts create mode 100644 packages/ai-core/src/common/index.ts create mode 100644 packages/ai-core/src/common/language-model-delegate.ts create mode 100644 packages/ai-core/src/common/language-model-util.ts create mode 100644 packages/ai-core/src/common/language-model.spec.ts create mode 100644 packages/ai-core/src/common/language-model.ts create mode 100644 packages/ai-core/src/common/prompt-service.spec.ts create mode 100644 packages/ai-core/src/common/prompt-service.ts create mode 100644 packages/ai-core/src/common/protocol.ts create mode 100644 packages/ai-core/src/common/today-variable-contribution.ts create mode 100644 packages/ai-core/src/common/tomorrow-variable-contribution.ts create mode 100644 packages/ai-core/src/common/variable-service.ts create mode 100644 packages/ai-core/src/node/ai-core-backend-module.ts create mode 100644 packages/ai-core/src/node/backend-language-model-registry.ts create mode 100644 packages/ai-core/src/node/language-model-frontend-delegate.ts create mode 100644 packages/ai-core/tsconfig.json create mode 100644 packages/ai-history/.eslintrc.js create mode 100644 packages/ai-history/README.md create mode 100644 packages/ai-history/package.json create mode 100644 packages/ai-history/src/browser/ai-history-communication-card.tsx create mode 100644 packages/ai-history/src/browser/ai-history-contribution.ts create mode 100644 packages/ai-history/src/browser/ai-history-frontend-module.ts create mode 100644 packages/ai-history/src/browser/ai-history-widget.tsx create mode 100644 packages/ai-history/src/browser/style/ai-history.css create mode 100644 packages/ai-history/src/common/communication-recording-service.spec.ts create mode 100644 packages/ai-history/src/common/communication-recording-service.ts create mode 100644 packages/ai-history/src/common/index.ts create mode 100644 packages/ai-history/tsconfig.json create mode 100644 packages/ai-openai/.eslintrc.js create mode 100644 packages/ai-openai/README.md create mode 100644 packages/ai-openai/package.json create mode 100644 packages/ai-openai/src/browser/openai-frontend-application-contribution.ts create mode 100644 packages/ai-openai/src/browser/openai-frontend-module.ts create mode 100644 packages/ai-openai/src/browser/openai-preferences.ts create mode 100644 packages/ai-openai/src/common/index.ts create mode 100644 packages/ai-openai/src/common/openai-language-models-manager.ts create mode 100644 packages/ai-openai/src/node/openai-backend-module.ts create mode 100644 packages/ai-openai/src/node/openai-language-model.ts create mode 100644 packages/ai-openai/src/node/openai-language-models-manager-impl.ts create mode 100644 packages/ai-openai/src/package.spec.ts create mode 100644 packages/ai-openai/tsconfig.json create mode 100644 packages/ai-terminal/.eslintrc.js create mode 100644 packages/ai-terminal/README.md create mode 100644 packages/ai-terminal/package.json create mode 100644 packages/ai-terminal/src/browser/ai-terminal-agent.ts create mode 100644 packages/ai-terminal/src/browser/ai-terminal-contribution.ts create mode 100644 packages/ai-terminal/src/browser/ai-terminal-frontend-module.ts create mode 100644 packages/ai-terminal/src/browser/style/ai-terminal.css create mode 100644 packages/ai-terminal/src/package.spec.ts create mode 100644 packages/ai-terminal/tsconfig.json create mode 100644 packages/ai-workspace-agent/.eslintrc.js create mode 100644 packages/ai-workspace-agent/README.md create mode 100644 packages/ai-workspace-agent/package.json create mode 100644 packages/ai-workspace-agent/src/browser/frontend-module.ts create mode 100644 packages/ai-workspace-agent/src/browser/functions.ts create mode 100644 packages/ai-workspace-agent/src/browser/workspace-agent.ts create mode 100644 packages/ai-workspace-agent/src/common/functions.ts create mode 100644 packages/ai-workspace-agent/src/common/template.ts create mode 100644 packages/ai-workspace-agent/src/package.spec.ts create mode 100644 packages/ai-workspace-agent/tsconfig.json diff --git a/examples/browser-only/package.json b/examples/browser-only/package.json index 087fd5c6548a3..3fca029a5260a 100644 --- a/examples/browser-only/package.json +++ b/examples/browser-only/package.json @@ -15,6 +15,12 @@ } }, "dependencies": { + "@theia/ai-chat": "1.52.0", + "@theia/ai-chat-ui": "1.52.0", + "@theia/ai-code-completion": "1.52.0", + "@theia/ai-core": "1.52.0", + "@theia/ai-history": "1.52.0", + "@theia/ai-openai": "1.52.0", "@theia/api-samples": "1.52.0", "@theia/bulk-edit": "1.52.0", "@theia/callhierarchy": "1.52.0", diff --git a/examples/browser-only/tsconfig.json b/examples/browser-only/tsconfig.json index d4bcfc14426b9..1036273d24792 100644 --- a/examples/browser-only/tsconfig.json +++ b/examples/browser-only/tsconfig.json @@ -8,6 +8,24 @@ { "path": "../../dev-packages/cli" }, + { + "path": "../../packages/ai-chat" + }, + { + "path": "../../packages/ai-chat-ui" + }, + { + "path": "../../packages/ai-code-completion" + }, + { + "path": "../../packages/ai-core" + }, + { + "path": "../../packages/ai-history" + }, + { + "path": "../../packages/ai-openai" + }, { "path": "../../packages/bulk-edit" }, diff --git a/examples/browser/package.json b/examples/browser/package.json index 5a938e845cb07..ffde1e2618129 100644 --- a/examples/browser/package.json +++ b/examples/browser/package.json @@ -20,6 +20,14 @@ } }, "dependencies": { + "@theia/ai-chat": "1.52.0", + "@theia/ai-chat-ui": "1.52.0", + "@theia/ai-code-completion": "1.52.0", + "@theia/ai-core": "1.52.0", + "@theia/ai-history": "1.52.0", + "@theia/ai-openai": "1.52.0", + "@theia/ai-terminal": "1.52.0", + "@theia/ai-workspace-agent": "1.52.0", "@theia/api-provider-sample": "1.52.0", "@theia/api-samples": "1.52.0", "@theia/bulk-edit": "1.52.0", diff --git a/examples/browser/tsconfig.json b/examples/browser/tsconfig.json index c04673f8d70a7..050c1c74b25fd 100644 --- a/examples/browser/tsconfig.json +++ b/examples/browser/tsconfig.json @@ -8,6 +8,30 @@ { "path": "../../dev-packages/cli" }, + { + "path": "../../packages/ai-chat" + }, + { + "path": "../../packages/ai-chat-ui" + }, + { + "path": "../../packages/ai-code-completion" + }, + { + "path": "../../packages/ai-core" + }, + { + "path": "../../packages/ai-history" + }, + { + "path": "../../packages/ai-openai" + }, + { + "path": "../../packages/ai-terminal" + }, + { + "path": "../../packages/ai-workspace-agent" + }, { "path": "../../packages/bulk-edit" }, diff --git a/examples/electron/package.json b/examples/electron/package.json index 44c941c225780..05d7a65664324 100644 --- a/examples/electron/package.json +++ b/examples/electron/package.json @@ -26,6 +26,14 @@ } }, "dependencies": { + "@theia/ai-chat": "1.52.0", + "@theia/ai-chat-ui": "1.52.0", + "@theia/ai-code-completion": "1.52.0", + "@theia/ai-core": "1.52.0", + "@theia/ai-history": "1.52.0", + "@theia/ai-openai": "1.52.0", + "@theia/ai-terminal": "1.52.0", + "@theia/ai-workspace-agent": "1.52.0", "@theia/api-provider-sample": "1.52.0", "@theia/api-samples": "1.52.0", "@theia/bulk-edit": "1.52.0", diff --git a/examples/electron/tsconfig.json b/examples/electron/tsconfig.json index 91edb2ac8dc55..4b30d5f367b37 100644 --- a/examples/electron/tsconfig.json +++ b/examples/electron/tsconfig.json @@ -11,6 +11,30 @@ { "path": "../../dev-packages/cli" }, + { + "path": "../../packages/ai-chat" + }, + { + "path": "../../packages/ai-chat-ui" + }, + { + "path": "../../packages/ai-code-completion" + }, + { + "path": "../../packages/ai-core" + }, + { + "path": "../../packages/ai-history" + }, + { + "path": "../../packages/ai-openai" + }, + { + "path": "../../packages/ai-terminal" + }, + { + "path": "../../packages/ai-workspace-agent" + }, { "path": "../../packages/bulk-edit" }, diff --git a/packages/ai-chat-ui/.eslintrc.js b/packages/ai-chat-ui/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-chat-ui/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-chat-ui/README.md b/packages/ai-chat-ui/README.md new file mode 100644 index 0000000000000..3638d69df5491 --- /dev/null +++ b/packages/ai-chat-ui/README.md @@ -0,0 +1,32 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Chat UI EXTENSION

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

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

{response.result}

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

; + + } + +} + +const Spinner = () => ( + +); diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-container.ts b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-container.ts new file mode 100644 index 0000000000000..9550de109ec0a --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-container.ts @@ -0,0 +1,32 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { createTreeContainer, TreeProps } from '@theia/core/lib/browser'; +import { interfaces } from '@theia/core/shared/inversify'; +import { ChatViewTreeWidget } from './chat-view-tree-widget'; + +const CHAT_VIEW_TREE_PROPS = { + multiSelect: false, + search: false, +} as TreeProps; + +export function createChatViewTreeWidget(parent: interfaces.Container): ChatViewTreeWidget { + const child = createTreeContainer(parent, { + props: CHAT_VIEW_TREE_PROPS, + widget: ChatViewTreeWidget, + }); + return child.get(ChatViewTreeWidget); +} diff --git a/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx new file mode 100644 index 0000000000000..ef25a70038558 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx @@ -0,0 +1,356 @@ +// ***************************************************************************** +// 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, + ChatRequestModel, + ChatResponseContent, + ChatResponseModel, +} from '@theia/ai-chat'; +import { CommandRegistry, ContributionProvider } from '@theia/core'; +import { + codicon, + CommonCommands, + CompositeTreeNode, + ContextMenuRenderer, + Key, + KeyCode, + NodeProps, + TreeModel, + TreeNode, + TreeProps, + TreeWidget, +} from '@theia/core/lib/browser'; +import { MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering/markdown-string'; +import { + inject, + injectable, + named, + postConstruct, +} from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; + +import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer'; +import { MarkdownWrapper } from '../chat-response-renderer/markdown-part-renderer'; +import { ChatResponsePartRenderer } from '../types'; + +// TODO Instead of directly operating on the ChatRequestModel we could use an intermediate view model +export interface RequestNode extends TreeNode { + request: ChatRequestModel +} +export const isRequestNode = (node: TreeNode): node is RequestNode => 'request' in node; + +// TODO Instead of directly operating on the ChatResponseModel we could use an intermediate view model +export interface ResponseNode extends TreeNode { + response: ChatResponseModel +} +export const isResponseNode = (node: TreeNode): node is ResponseNode => 'response' in node; + +@injectable() +export class ChatViewTreeWidget extends TreeWidget { + static readonly ID = 'chat-tree-widget'; + static readonly CONTEXT_MENU = ['chat-tree-context-menu']; + + @inject(ContributionProvider) @named(ChatResponsePartRenderer) + protected readonly chatResponsePartRenderers: ContributionProvider>; + + @inject(MarkdownRenderer) + private renderer: MarkdownRenderer; + + @inject(ChatAgentService) + protected chatAgentService: ChatAgentService; + + @inject(CommandRegistry) + private commandRegistry: CommandRegistry; + + protected _shouldScrollToEnd = true; + + protected isEnabled = false; + + set shouldScrollToEnd(shouldScrollToEnd: boolean) { + this._shouldScrollToEnd = shouldScrollToEnd; + this.shouldScrollToRow = this._shouldScrollToEnd; + } + + get shouldScrollToEnd(): boolean { + return this._shouldScrollToEnd; + } + + constructor( + @inject(TreeProps) props: TreeProps, + @inject(TreeModel) model: TreeModel, + @inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer + ) { + super(props, model, contextMenuRenderer); + + this.id = ChatViewTreeWidget.ID; + this.title.closable = false; + + model.root = { + id: 'ChatTree', + name: 'ChatRootNode', + parent: undefined, + visible: false, + children: [], + } as CompositeTreeNode; + } + + @postConstruct() + protected override init(): void { + super.init(); + + this.id = ChatViewTreeWidget.ID + '-treeContainer'; + this.addClass('treeContainer'); + } + + public setEnabled(enabled: boolean): void { + this.isEnabled = enabled; + this.update(); + } + + protected override renderTree(model: TreeModel): React.ReactNode { + if (this.isEnabled) { + return super.renderTree(model); + } + return this.renderDisabledMessage(); + } + + private renderDisabledMessage(): React.ReactNode { + return
+
+
+ πŸš€ Experimental AI Feature Available! +
+

Currently, all AI Features are disabled!

+
+
+

How to Enable Experimental AI Features:

+
+
+

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

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

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

+
+ +
+

Currently Supported Views and Features:

+
+
+

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

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

{this.getAgentLabel(node)}

+ {inProgress && Generating} +
+
; + } + private getAgentLabel(node: RequestNode | ResponseNode): string { + if (isRequestNode(node)) { + // TODO find user name + return 'You'; + } + const agent = node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined; + return agent?.name ?? 'AI'; + } + private getAgentIconClassName(node: RequestNode | ResponseNode): string | undefined { + if (isRequestNode(node)) { + return codicon('account'); + } + + const agent = node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined; + return agent?.iconClass ?? codicon('copilot'); + } + + private renderDetail(node: RequestNode | ResponseNode): React.ReactNode { + if (isRequestNode(node)) { + return this.renderChatRequest(node); + } + if (isResponseNode(node)) { + return this.renderChatResponse(node); + }; + } + + private renderChatRequest(node: RequestNode): React.ReactNode { + const text = node.request.request.displayText ?? node.request.request.text; + const markdownString = new MarkdownStringImpl(text, { supportHtml: true, isTrusted: true }); + return ( +
+ { this.renderer.render(markdownString).element} + >} +
+ ); + } + + private renderChatResponse(node: ResponseNode): React.ReactNode { + return ( +
+ {node.response.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(); + } +} 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..16bd03a9d993a --- /dev/null +++ b/packages/ai-chat-ui/src/browser/chat-view-widget.tsx @@ -0,0 +1,184 @@ +// ***************************************************************************** +// 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); + 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..886a414dedebd --- /dev/null +++ b/packages/ai-chat-ui/src/browser/style/index.css @@ -0,0 +1,294 @@ +.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); +} + +.spinner { + display: inline-block; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.theia-ChatPart-Error { + display: flex; + flex-direction: row; + gap: 0.5em; + color: var(--theia-errorForeground); +} + + +.section-header { + font-weight: bold; + font-size: 16px; + margin-bottom: 10px; +} + +.section-title { + font-weight: bold; + font-size: 14px; + margin: 20px 0px; +} + +.disable-message { + font-size: 12px; + line-height: 1.6; + padding: 15px; +} + +.section-content p { + margin: 10px 0; +} + +.section-content a { + cursor: pointer; +} + +.section-content strong { + font-weight: bold; +} + diff --git a/packages/ai-chat-ui/src/browser/types.ts b/packages/ai-chat-ui/src/browser/types.ts new file mode 100644 index 0000000000000..80260e7c6ba87 --- /dev/null +++ b/packages/ai-chat-ui/src/browser/types.ts @@ -0,0 +1,25 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { BaseChatResponseContent, ChatResponseContent } from '@theia/ai-chat/lib/common'; +import { ReactNode } from '@theia/core/shared/react'; +import { ResponseNode } from './chat-tree-view/chat-view-tree-widget'; + +export const ChatResponsePartRenderer = Symbol('ChatResponsePartRenderer'); +export interface ChatResponsePartRenderer { + canHandle(response: ChatResponseContent): number; + render(response: T, parentNode: ResponseNode): ReactNode; +} diff --git a/packages/ai-chat-ui/tsconfig.json b/packages/ai-chat-ui/tsconfig.json new file mode 100644 index 0000000000000..13d585dc94ad5 --- /dev/null +++ b/packages/ai-chat-ui/tsconfig.json @@ -0,0 +1,37 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-chat" + }, + { + "path": "../ai-core" + }, + { + "path": "../core" + }, + { + "path": "../editor" + }, + { + "path": "../editor-preview" + }, + { + "path": "../filesystem" + }, + { + "path": "../monaco" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-chat/.eslintrc.js b/packages/ai-chat/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-chat/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-chat/README.md b/packages/ai-chat/README.md new file mode 100644 index 0000000000000..6f394ce95cc55 --- /dev/null +++ b/packages/ai-chat/README.md @@ -0,0 +1,30 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Chat EXTENSION

+ +
+ +
+ +## Description + +The `@theia/ai-chat` extension provides the concept of a language model chat to Theia. +It serves as the basis for `@theia/ai-chat-ui` to provide the Chat UI. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-chat/package.json b/packages/ai-chat/package.json new file mode 100644 index 0000000000000..8b20eca4c6604 --- /dev/null +++ b/packages/ai-chat/package.json @@ -0,0 +1,56 @@ +{ + "name": "@theia/ai-chat", + "version": "1.52.0", + "description": "Theia - AI Chat Extension", + "dependencies": { + "@theia/ai-core": "1.52.0", + "@theia/ai-history": "1.52.0", + "@theia/core": "1.52.0", + "@theia/filesystem": "1.52.0", + "@theia/workspace": "1.52.0", + "minimatch": "^5.1.0", + "tslib": "^2.6.2" + }, + "publishConfig": { + "access": "public" + }, + "main": "lib/common", + "theiaExtensions": [ + { + "frontend": "lib/browser/agent-frontend-module" + }, + { + "backend": "lib/node/agent-backend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.52.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-chat/src/browser/agent-frontend-module.ts b/packages/ai-chat/src/browser/agent-frontend-module.ts new file mode 100644 index 0000000000000..b8e9c8e3c310d --- /dev/null +++ b/packages/ai-chat/src/browser/agent-frontend-module.ts @@ -0,0 +1,57 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Agent } from '@theia/ai-core/lib/common'; +import { bindContributionProvider } from '@theia/core'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { + ChatAgent, + ChatAgentService, + ChatAgentServiceImpl, + ChatRequestParser, + ChatRequestParserImpl, + ChatService, + ChatServiceImpl +} from '../common'; +import { CommandChatAgent } from '../common/command-chat-agents'; +import { DelegatingChatAgent } from '../common/delegating-chat-agent'; +import { DefaultChatAgent } from '../common/default-chat-agent'; + +export default new ContainerModule(bind => { + bindContributionProvider(bind, Agent); + bindContributionProvider(bind, ChatAgent); + + bind(ChatAgentServiceImpl).toSelf().inSingletonScope(); + bind(ChatAgentService).toService(ChatAgentServiceImpl); + + bind(ChatRequestParserImpl).toSelf().inSingletonScope(); + bind(ChatRequestParser).toService(ChatRequestParserImpl); + + bind(ChatServiceImpl).toSelf().inSingletonScope(); + bind(ChatService).toService(ChatServiceImpl); + + bind(DelegatingChatAgent).toSelf().inSingletonScope(); + bind(Agent).toService(DelegatingChatAgent); + bind(ChatAgent).toService(DelegatingChatAgent); + + bind(DefaultChatAgent).toSelf().inSingletonScope(); + bind(Agent).toService(DefaultChatAgent); + bind(ChatAgent).toService(DefaultChatAgent); + + bind(CommandChatAgent).toSelf().inSingletonScope(); + bind(Agent).toService(CommandChatAgent); + bind(ChatAgent).toService(CommandChatAgent); +}); diff --git a/packages/ai-chat/src/common/chat-agent-service.ts b/packages/ai-chat/src/common/chat-agent-service.ts new file mode 100644 index 0000000000000..5544711effdda --- /dev/null +++ b/packages/ai-chat/src/common/chat-agent-service.ts @@ -0,0 +1,74 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatAgents.ts + +import { ContributionProvider, ILogger } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { ChatAgent } from './chat-agents'; +import { ChatRequestModel, ChatRequestModelImpl } from './chat-model'; +import { AgentService } from '@theia/ai-core'; + +export const ChatAgentService = Symbol('ChatAgentService'); +/** + * The ChatAgentService provides access to the available chat agents. + */ +export interface ChatAgentService { + getAgents(includeDisabledAgent?: boolean): ChatAgent[]; + getAgent(id: string, includeDisabledAgent?: boolean): ChatAgent | undefined; + getAgentsByName(name: string, includeDisabledAgent?: boolean): ChatAgent[]; + invokeAgent(agentId: string, request: ChatRequestModel): Promise; +} +@injectable() +export class ChatAgentServiceImpl implements ChatAgentService { + + @inject(ContributionProvider) @named(ChatAgent) + protected readonly agents: ContributionProvider; + + @inject(ILogger) + protected logger: ILogger; + + @inject(AgentService) + protected agentService: AgentService; + + getAgent(id: string, includeDisabledAgent = false): ChatAgent | undefined { + if (!includeDisabledAgent && !this._agentIsEnabled(id)) { + return; + } + return this.getAgents(includeDisabledAgent).find(agent => agent.id === id); + } + getAgents(includeDisabledAgent = false): ChatAgent[] { + return this.agents.getContributions() + .filter(a => includeDisabledAgent || this._agentIsEnabled(a.id)); + } + getAgentsByName(name: string, includeDisabledAgent = false): ChatAgent[] { + return this.getAgents(includeDisabledAgent).filter(a => a.name === name); + } + + private _agentIsEnabled(id: string): boolean { + return this.agentService.isEnabled(id); + } + invokeAgent(agentId: string, request: ChatRequestModelImpl): Promise { + const agent = this.getAgent(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + return agent.invoke(request, this); + } +} diff --git a/packages/ai-chat/src/common/chat-agents.ts b/packages/ai-chat/src/common/chat-agents.ts new file mode 100644 index 0000000000000..8a16303d76b43 --- /dev/null +++ b/packages/ai-chat/src/common/chat-agents.ts @@ -0,0 +1,384 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatAgents.ts + +import { + CommunicationRecordingService, + getTextOfResponse, + LanguageModel, + LanguageModelRequirement, + LanguageModelResponse, + PromptService, + ResolvedPromptTemplate, + ToolRequest, +} from '@theia/ai-core'; +import { + Agent, + isLanguageModelStreamResponse, + isLanguageModelTextResponse, + LanguageModelRegistry, + LanguageModelStreamResponsePart, + MessageActor, + PromptTemplate +} from '@theia/ai-core/lib/common'; +import { CancellationToken, CancellationTokenSource, ILogger, isArray } from '@theia/core'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { ChatAgentService } from './chat-agent-service'; +import { + ChatModel, + ChatRequestModel, + ChatRequestModelImpl, + ChatResponseContent, + CodeChatResponseContentImpl, + ErrorResponseContentImpl, + MarkdownChatResponseContentImpl, + ToolCallResponseContentImpl +} from './chat-model'; + +export interface ChatMessage { + actor: MessageActor; + type: 'text'; + query: string; +} + +export interface SystemMessage { + text: string; + /** All functions references in the system message. */ + functionDescriptions?: Map>; +} +export namespace SystemMessage { + export function fromResolvedPromptTemplate(resolvedPrompt: ResolvedPromptTemplate): SystemMessage { + return { + text: resolvedPrompt.text, + functionDescriptions: resolvedPrompt.functionDescriptions + }; + } +} + +export enum ChatAgentLocation { + Panel = 'panel', + Terminal = 'terminal', + Notebook = 'notebook', + Editor = 'editor' +} + +export namespace ChatAgentLocation { + export const ALL: ChatAgentLocation[] = [ChatAgentLocation.Panel, ChatAgentLocation.Terminal, ChatAgentLocation.Notebook, ChatAgentLocation.Editor]; + + export function fromRaw(value: string): ChatAgentLocation { + switch (value) { + case 'panel': return ChatAgentLocation.Panel; + case 'terminal': return ChatAgentLocation.Terminal; + case 'notebook': return ChatAgentLocation.Notebook; + case 'editor': return ChatAgentLocation.Editor; + } + return ChatAgentLocation.Panel; + } +} + +export interface ChatAgentData extends Agent { + locations: ChatAgentLocation[]; + iconClass?: string; +} + +export const ChatAgent = Symbol('ChatAgent'); +export interface ChatAgent extends ChatAgentData { + invoke(request: ChatRequestModelImpl, chatAgentService?: ChatAgentService): Promise; +} + +@injectable() +export abstract class AbstractChatAgent implements ChatAgent { + + abstract id: string; + abstract name: string; + abstract description: string; + abstract variables: string[]; + abstract promptTemplates: PromptTemplate[]; + abstract languageModelRequirements: LanguageModelRequirement[]; + iconClass?: string | undefined = 'codicon codicon-copilot'; + locations: ChatAgentLocation[] = ChatAgentLocation.ALL; + + @inject(LanguageModelRegistry) + protected languageModelRegistry: LanguageModelRegistry; + + @inject(ILogger) + protected logger: ILogger; + + @inject(CommunicationRecordingService) + protected recordingService: CommunicationRecordingService; + + @inject(PromptService) + protected promptService: PromptService; + + protected abstract languageModelPurpose: string; + + async invoke(request: ChatRequestModelImpl): Promise { + try { + const languageModel = await this.getLanguageModel(); + if (!languageModel) { + throw new Error('Couldn\'t find a matching language model. Please check your setup!'); + } + const messages = await this.getMessages(request.session); + this.recordingService.recordRequest({ + agentId: this.id, + sessionId: request.session.id, + timestamp: Date.now(), + requestId: request.id, + request: request.request.text, + messages + }); + + const systemMessage = await this.getSystemMessage(); + const tools: Map> = new Map(); + if (systemMessage) { + const systemMsg: ChatMessage = { + actor: 'system', + type: 'text', + query: systemMessage.text + }; + // insert system message at the beginning of the request messages + messages.unshift(systemMsg); + systemMessage.functionDescriptions?.forEach((tool, id) => { + tools.set(id, tool); + }); + } + this.getTools(request)?.forEach(tool => tools.set(tool.id, tool)); + + const cancellationToken = new CancellationTokenSource(); + request.response.onDidChange(() => { + if (request.response.isCanceled) { + cancellationToken.cancel(); + } + }); + + const languageModelResponse = await this.callLlm( + languageModel, + messages, + tools.size > 0 ? Array.from(tools.values()) : undefined, + cancellationToken.token + ); + await this.addContentsToResponse(languageModelResponse, request); + request.response.complete(); + this.recordingService.recordResponse({ + agentId: this.id, + sessionId: request.session.id, + timestamp: Date.now(), + requestId: request.response.requestId, + response: request.response.response.asString() + }); + } catch (e) { + this.handleError(request, e); + } + } + + protected handleError(request: ChatRequestModelImpl, error: Error): void { + request.response.response.addContent(new ErrorResponseContentImpl(error)); + request.response.error(error); + } + + protected getLanguageModelSelector(): LanguageModelRequirement { + return this.languageModelRequirements.find(req => req.purpose === this.languageModelPurpose)!; + } + + protected async getLanguageModel(): Promise { + return this.selectLanguageModel(this.getLanguageModelSelector()); + } + + protected async selectLanguageModel(selector: LanguageModelRequirement): Promise { + const languageModel = await this.languageModelRegistry.selectLanguageModel({ agent: this.id, ...selector }); + if (!languageModel) { + throw new Error('Couldn\'t find a language model. Please check your setup!'); + } + return languageModel; + } + + protected abstract getSystemMessage(): Promise; + + protected async getMessages( + model: ChatModel, includeResponseInProgress = false + ): Promise { + const requestMessages = model.getRequests().flatMap(request => { + const messages: ChatMessage[] = []; + const query = request.message.parts.map(part => part.promptText).join(''); + messages.push({ + actor: 'user', + type: 'text', + query, + }); + if (request.response.isComplete || includeResponseInProgress) { + messages.push({ + actor: 'ai', + type: 'text', + query: request.response.response.asString(), + }); + } + return messages; + }); + + return requestMessages; + } + + /** + * @returns the list of tools used by this agent, or undefined if none is needed. + */ + protected getTools(request: ChatRequestModel): ToolRequest[] | undefined { + return request.message.toolRequests.size > 0 + ? [...request.message.toolRequests.values()] + : undefined; + } + + protected async callLlm( + languageModel: LanguageModel, + messages: ChatMessage[], + tools: ToolRequest[] | undefined, + token: CancellationToken + ): Promise { + const languageModelResponse = languageModel.request({ + messages, + tools, + cancellationToken: token, + }); + return languageModelResponse; + } + + protected abstract addContentsToResponse(languageModelResponse: LanguageModelResponse, request: ChatRequestModelImpl): Promise; +} + +@injectable() +export abstract class AbstractTextToModelParsingChatAgent extends AbstractChatAgent { + + protected async addContentsToResponse(languageModelResponse: LanguageModelResponse, request: ChatRequestModelImpl): Promise { + const responseAsText = await getTextOfResponse(languageModelResponse); + const parsedCommand = await this.parseTextResponse(responseAsText); + const content = this.createResponseContent(parsedCommand, request); + request.response.response.addContent(content); + } + + protected abstract parseTextResponse(text: string): Promise; + + protected abstract createResponseContent(parsedModel: T, request: ChatRequestModelImpl): ChatResponseContent; +} + +@injectable() +export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent { + + protected override async addContentsToResponse(languageModelResponse: LanguageModelResponse, request: ChatRequestModelImpl): Promise { + if (isLanguageModelTextResponse(languageModelResponse)) { + request.response.response.addContent( + new MarkdownChatResponseContentImpl(languageModelResponse.text) + ); + request.response.complete(); + this.recordingService.recordResponse({ + agentId: this.id, + sessionId: request.session.id, + timestamp: Date.now(), + requestId: request.response.requestId, + response: request.response.response.asString() + }); + return; + } + if (isLanguageModelStreamResponse(languageModelResponse)) { + for await (const token of languageModelResponse.stream) { + const newContents = this.parse(token, request.response.response.content); + if (isArray(newContents)) { + newContents.forEach(newContent => request.response.response.addContent(newContent)); + } else { + request.response.response.addContent(newContents); + } + + const lastContent = request.response.response.content.pop(); + if (lastContent === undefined) { + return; + } + const text = lastContent.asString?.(); + if (text === undefined) { + return; + } + let curSearchIndex = 0; + const result: ChatResponseContent[] = []; + while (curSearchIndex < text.length) { + // find start of code block: ```[language]\n[\n]``` + const codeStartIndex = text.indexOf('```', curSearchIndex); + if (codeStartIndex === -1) { + break; + } + + // find language specifier if present + const newLineIndex = text.indexOf('\n', codeStartIndex + 3); + const language = codeStartIndex + 3 < newLineIndex ? text.substring(codeStartIndex + 3, newLineIndex) : undefined; + + // find end of code block + const codeEndIndex = text.indexOf('```', codeStartIndex + 3); + if (codeEndIndex === -1) { + break; + } + + // add text before code block as markdown content + result.push(new MarkdownChatResponseContentImpl(text.substring(curSearchIndex, codeStartIndex))); + // add code block as code content + const codeText = text.substring(newLineIndex + 1, codeEndIndex).trimEnd(); + result.push(new CodeChatResponseContentImpl(codeText, language)); + curSearchIndex = codeEndIndex + 3; + } + + if (result.length > 0) { + result.forEach(r => { + request.response.response.addContent(r); + }); + } else { + request.response.response.addContent(lastContent); + } + } + request.response.complete(); + this.recordingService.recordResponse({ + agentId: this.id, + sessionId: request.session.id, + timestamp: Date.now(), + requestId: request.response.requestId, + response: request.response.response.asString() + }); + return; + } + this.logger.error( + 'Received unknown response in agent. Return response as text' + ); + request.response.response.addContent( + new MarkdownChatResponseContentImpl( + JSON.stringify(languageModelResponse) + ) + ); + } + + private parse(token: LanguageModelStreamResponsePart, previousContent: ChatResponseContent[]): ChatResponseContent | ChatResponseContent[] { + const content = token.content; + // eslint-disable-next-line no-null/no-null + if (content !== undefined && content !== null) { + return new MarkdownChatResponseContentImpl(content); + } + const toolCalls = token.tool_calls; + if (toolCalls !== undefined) { + const toolCallContents = toolCalls.map(toolCall => + new ToolCallResponseContentImpl(toolCall.id, toolCall.function?.name, toolCall.function?.arguments, toolCall.finished, toolCall.result)); + return toolCallContents; + } + return new MarkdownChatResponseContentImpl(''); + } + +} diff --git a/packages/ai-chat/src/common/chat-model.ts b/packages/ai-chat/src/common/chat-model.ts new file mode 100644 index 0000000000000..9b633af771091 --- /dev/null +++ b/packages/ai-chat/src/common/chat-model.ts @@ -0,0 +1,718 @@ +// ***************************************************************************** +// 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 { Position } from '@theia/core/shared/vscode-languageserver-protocol'; +import { MarkdownString, MarkdownStringImpl } from '@theia/core/lib/common/markdown-rendering'; +import { ParsedChatRequest } from './chat-parsed-request'; +import { ChatAgentLocation } from './chat-agents'; + +/********************** + * 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'; + 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[]; + 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; + } + + 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..41613507139ef --- /dev/null +++ b/packages/ai-chat/src/common/delegating-chat-agent.ts @@ -0,0 +1,117 @@ +// ***************************************************************************** +// 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; + + 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'); + 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); + await this.chatAgentService.invokeAgent(delegatedToAgent, request); + } +} diff --git a/packages/ai-chat/src/common/index.ts b/packages/ai-chat/src/common/index.ts new file mode 100644 index 0000000000000..9b04f45c55d0c --- /dev/null +++ b/packages/ai-chat/src/common/index.ts @@ -0,0 +1,25 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +export * from './chat-agent-service'; +export * from './chat-agents'; +export * from './chat-model'; +export * from './chat-parsed-request'; +export * from './chat-request-parser'; +export * from './chat-service'; +export * from './chat-variables'; +export * from './command-chat-agents'; +export * from './default-chat-agent'; +export * from './delegating-chat-agent'; diff --git a/packages/ai-chat/src/node/agent-backend-module.ts b/packages/ai-chat/src/node/agent-backend-module.ts new file mode 100644 index 0000000000000..c353e812867fd --- /dev/null +++ b/packages/ai-chat/src/node/agent-backend-module.ts @@ -0,0 +1,47 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Agent } from '@theia/ai-core/lib/common'; +import { bindContributionProvider } from '@theia/core'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { + ChatAgent, + ChatAgentService, + ChatAgentServiceImpl, + ChatRequestParser, + ChatRequestParserImpl, + ChatService, + ChatServiceImpl, +} from '../common'; +import { DelegatingChatAgent } from '../common/delegating-chat-agent'; + +export default new ContainerModule(bind => { + bindContributionProvider(bind, Agent); + bindContributionProvider(bind, ChatAgent); + + bind(ChatAgentServiceImpl).toSelf().inSingletonScope(); + bind(ChatAgentService).toService(ChatAgentServiceImpl); + + bind(ChatRequestParserImpl).toSelf().inSingletonScope(); + bind(ChatRequestParser).toService(ChatRequestParserImpl); + + bind(ChatServiceImpl).toSelf().inSingletonScope(); + bind(ChatService).toService(ChatServiceImpl); + + bind(DelegatingChatAgent).toSelf().inSingletonScope(); + bind(Agent).toService(DelegatingChatAgent); + bind(ChatAgent).toService(DelegatingChatAgent); +}); diff --git a/packages/ai-chat/tsconfig.json b/packages/ai-chat/tsconfig.json new file mode 100644 index 0000000000000..e7d3cda9e5fdb --- /dev/null +++ b/packages/ai-chat/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-core" + }, + { + "path": "../ai-history" + }, + { + "path": "../core" + }, + { + "path": "../filesystem" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-code-completion/.eslintrc.js b/packages/ai-code-completion/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-code-completion/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-code-completion/README.md b/packages/ai-code-completion/README.md new file mode 100644 index 0000000000000..938ca2c78ffd9 --- /dev/null +++ b/packages/ai-code-completion/README.md @@ -0,0 +1,30 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Code Completion

+ +
+ +
+ +## Description + +The `@theia/ai-code-completion` extension contributes Ai based code completion. +The user can separately enable code completion items as well as inline code completion. + +## Additional Information + +- [Theia - GitHub](https://github.com/eclipse-theia/theia) +- [Theia - Website](https://theia-ide.org/) + +## License + +- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/) +- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp) + +## Trademark +"Theia" is a trademark of the Eclipse Foundation +https://www.eclipse.org/theia diff --git a/packages/ai-code-completion/package.json b/packages/ai-code-completion/package.json new file mode 100644 index 0000000000000..35fc38ea657df --- /dev/null +++ b/packages/ai-code-completion/package.json @@ -0,0 +1,54 @@ +{ + "name": "@theia/ai-code-completion", + "version": "1.52.0", + "description": "Theia - AI Core", + "dependencies": { + "@theia/ai-core": "1.52.0", + "@theia/core": "1.52.0", + "@theia/filesystem": "1.52.0", + "@theia/monaco-editor-core": "1.83.101", + "@theia/output": "1.52.0", + "@theia/workspace": "1.52.0", + "minimatch": "^5.1.0", + "tslib": "^2.6.2" + }, + "main": "lib/common", + "publishConfig": { + "access": "public" + }, + "theiaExtensions": [ + { + "frontend": "lib/browser/ai-code-completion-frontend-module" + } + ], + "keywords": [ + "theia-extension" + ], + "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0", + "repository": { + "type": "git", + "url": "https://github.com/eclipse-theia/theia.git" + }, + "bugs": { + "url": "https://github.com/eclipse-theia/theia/issues" + }, + "homepage": "https://github.com/eclipse-theia/theia", + "files": [ + "lib", + "src" + ], + "scripts": { + "build": "theiaext build", + "clean": "theiaext clean", + "compile": "theiaext compile", + "lint": "theiaext lint", + "test": "theiaext test", + "watch": "theiaext watch" + }, + "devDependencies": { + "@theia/ext-scripts": "1.52.0" + }, + "nyc": { + "extends": "../../configs/nyc.json" + } +} diff --git a/packages/ai-code-completion/src/browser/ai-code-completion-frontend-module.ts b/packages/ai-code-completion/src/browser/ai-code-completion-frontend-module.ts new file mode 100644 index 0000000000000..9d30ba9529d70 --- /dev/null +++ b/packages/ai-code-completion/src/browser/ai-code-completion-frontend-module.ts @@ -0,0 +1,34 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { 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(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..bfb6abaaa8007 --- /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 } from '@theia/core'; +import { inject, injectable } 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(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) { + console.error('No language model found for code-completion-agent'); + return undefined; + } + console.log('Code completion agent is using language model:', languageModel.id); + + // 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) { + console.error('No prompt found for code-completion-agent'); + return undefined; + } + console.log('Code completion agent is using prompt:', prompt); + + // 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; + } + console.log('Code completion suggests', completionText); + this.recordingService.recordResponse({ + agentId: this.id, + sessionId, + timestamp: Date.now(), + requestId, + response: completionText + }); + + const suggestions: monaco.languages.CompletionItem[] = []; + const completionItem: monaco.languages.CompletionItem = { + preselect: true, + label: `${completionText.substring(0, 20)}`, + detail: 'AI Generated', + documentation: `Generated via ${languageModel.id}`, + kind: monaco.languages.CompletionItemKind.Text, + insertText: completionText, + range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column) + }; + suggestions.push(completionItem); + return { suggestions }; + + }; + id: string = 'code-completion-agent'; + name: string = 'Code Completion Agent'; + description: string = 'This agent provides code completions for a given code snippet.'; + promptTemplates: PromptTemplate[] = [ + { + id: 'code-completion-prompt', + template: `You are a code completion agent. The current file you have to complete is named \${file}. +The language of the file is \${language}. Return your result as plain text without markdown formatting. +Finish the following code snippet. + +\${snippet} + +Only return the exact replacement for {{MARKER}} to complete the snippet.`, + } + ]; + languageModelRequirements: LanguageModelRequirement[] = [{ + purpose: 'code-completion', + identifier: 'openai/gpt-4o' + }]; +} diff --git a/packages/ai-code-completion/src/package.spec.ts b/packages/ai-code-completion/src/package.spec.ts new file mode 100644 index 0000000000000..fec76f95059b4 --- /dev/null +++ b/packages/ai-code-completion/src/package.spec.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +/* note: this bogus test file is required so that + we are able to run mocha unit tests on this + package, without having any actual unit tests in it. + This way a coverage report will be generated, + showing 0% coverage, instead of no report. + This file can be removed once we have real unit + tests in place. */ + +describe('ai-code-completion package', () => { + + it('support code coverage statistics', () => true); +}); diff --git a/packages/ai-code-completion/tsconfig.json b/packages/ai-code-completion/tsconfig.json new file mode 100644 index 0000000000000..548b369565b41 --- /dev/null +++ b/packages/ai-code-completion/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-core" + }, + { + "path": "../core" + }, + { + "path": "../filesystem" + }, + { + "path": "../output" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-core/.eslintrc.js b/packages/ai-core/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-core/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-core/README.md b/packages/ai-core/README.md new file mode 100644 index 0000000000000..1cd399aaff0e1 --- /dev/null +++ b/packages/ai-core/README.md @@ -0,0 +1,30 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI Core EXTENSION

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

Identifier: {languageModel.id}

} + {languageModel.name &&

Name: {languageModel.name}

} + {languageModel.vendor &&

Vendor: {languageModel.vendor}

} + {languageModel.version &&

Version: {languageModel.version}

} + {languageModel.family &&

Family: {languageModel.family}

} + {languageModel.maxInputTokens &&

Min Input Tokens: {languageModel.maxInputTokens}

} + {languageModel.maxOutputTokens &&

Max Output Tokens: {languageModel.maxOutputTokens}

} +
+ ; + + }; + + const onSelectedModelChange = (purpose: string, event: React.ChangeEvent): void => { + const newLmRequirementMap = { ...lmRequirementMap, [purpose]: { purpose, identifier: event.target.value } }; + aiSettingsService.updateAgentSettings(agent.id, { languageModelRequirements: Object.values(newLmRequirementMap) }); + setLmRequirementMap(newLmRequirementMap); + }; + + return
+ {Object.values(lmRequirementMap).map((requirements, index) => ( + +
Purpose:
+
+ {/* language model metadata */} + {renderLanguageModelMetadata(requirements, index)} + {/* language model selector */} + <> + + + +
+
+
+ ))} + +
; +}; diff --git a/packages/ai-core/src/browser/ai-configuration/template-settings-renderer.tsx b/packages/ai-core/src/browser/ai-configuration/template-settings-renderer.tsx new file mode 100644 index 0000000000000..01125ebf58e0a --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration/template-settings-renderer.tsx @@ -0,0 +1,39 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import * as React from '@theia/core/shared/react'; +import { PromptCustomizationService } from '../../common/prompt-service'; +import { PromptTemplate } from '../../common'; + +export interface TemplateSettingProps { + agentId: string; + template: PromptTemplate; + promptCustomizationService: PromptCustomizationService; +} + +export const TemplateRenderer: React.FC = ({ agentId, template, promptCustomizationService }) => { + const openTemplate = React.useCallback(async () => { + promptCustomizationService.editTemplate(template.id); + }, [template, promptCustomizationService]); + const resetTemplate = React.useCallback(async () => { + promptCustomizationService.resetTemplate(template.id); + }, [promptCustomizationService, template]); + + return <> + {template.id} + + + ; +}; diff --git a/packages/ai-core/src/browser/ai-configuration/variable-configuration-widget.tsx b/packages/ai-core/src/browser/ai-configuration/variable-configuration-widget.tsx new file mode 100644 index 0000000000000..64cbdfffe6122 --- /dev/null +++ b/packages/ai-core/src/browser/ai-configuration/variable-configuration-widget.tsx @@ -0,0 +1,110 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { codicon, ReactWidget } from '@theia/core/lib/browser'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { Agent, AIVariable, AIVariableService } from '../../common'; +import { AIAgentConfigurationWidget } from './agent-configuration-widget'; +import { AIConfigurationSelectionService } from './ai-configuration-service'; +import { AgentService } from '../../common/agent-service'; + +@injectable() +export class AIVariableConfigurationWidget extends ReactWidget { + + static readonly ID = 'ai-variable-configuration-container-widget'; + static readonly LABEL = 'Variables'; + + @inject(AIVariableService) + protected readonly variableService: AIVariableService; + + @inject(AgentService) + protected readonly agentService: AgentService; + + @inject(AIConfigurationSelectionService) + protected readonly aiConfigurationSelectionService: AIConfigurationSelectionService; + + @postConstruct() + protected init(): void { + this.id = AIVariableConfigurationWidget.ID; + this.title.label = AIVariableConfigurationWidget.LABEL; + this.title.closable = false; + this.update(); + this.toDispose.push(this.variableService.onDidChangeVariables(() => this.update())); + } + + protected render(): React.ReactNode { + return
+
    + {this.variableService.getVariables().map(variable => +
  • +
    {variable.name}
    + {variable.id} + {variable.description} + {this.renderReferencedVariables(variable)} + {this.renderArgs(variable)} +
  • + )} +
+
; + } + + protected renderReferencedVariables(variable: AIVariable): React.ReactNode | undefined { + const agents = this.getAgentsForVariable(variable); + if (agents.length === 0) { + return; + } + + return
+

Agents

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

Variable Arguments

+
+ {variable.args.map(arg => + + {arg.name} + {arg.description} + + )} +
+
; + } + + protected showAgentConfiguration(agent: Agent): void { + this.aiConfigurationSelectionService.setActiveAgent(agent); + this.aiConfigurationSelectionService.selectConfigurationTab(AIAgentConfigurationWidget.ID); + } + + protected getAgentsForVariable(variable: AIVariable): Agent[] { + return this.agentService.getAgents().filter(a => a.variables?.includes(variable.id)); + } +} + diff --git a/packages/ai-core/src/browser/ai-core-frontend-application-contribution.ts b/packages/ai-core/src/browser/ai-core-frontend-application-contribution.ts new file mode 100644 index 0000000000000..2b5f4bdfbbb18 --- /dev/null +++ b/packages/ai-core/src/browser/ai-core-frontend-application-contribution.ts @@ -0,0 +1,40 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { FrontendApplicationContribution } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { PromptService } from '../common'; +import { AgentService } from '../common/agent-service'; + +@injectable() +export class AICoreFrontendApplicationContribution implements FrontendApplicationContribution { + @inject(AgentService) + private readonly agentService: AgentService; + + @inject(PromptService) + private readonly promptService: PromptService; + + onStart(): void { + this.agentService.getAgents(true).forEach(a => { + a.promptTemplates.forEach(t => { + this.promptService.storePrompt(t.id, t.template); + }); + }); + } + + onStop(): void { + } +} diff --git a/packages/ai-core/src/browser/ai-core-frontend-module.ts b/packages/ai-core/src/browser/ai-core-frontend-module.ts new file mode 100644 index 0000000000000..c4dcad510c263 --- /dev/null +++ b/packages/ai-core/src/browser/ai-core-frontend-module.ts @@ -0,0 +1,159 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { bindContributionProvider, CommandContribution, CommandHandler } from '@theia/core'; +import { + RemoteConnectionProvider, + ServiceConnectionProvider, +} from '@theia/core/lib/browser/messaging/service-connection-provider'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { + AIVariableContribution, + AIVariableService, + FunctionCallRegistry, + FunctionCallRegistryImpl, + LanguageModelDelegateClient, + languageModelDelegatePath, + LanguageModelFrontendDelegate, + LanguageModelProvider, + LanguageModelRegistry, + LanguageModelRegistryClient, + languageModelRegistryDelegatePath, + LanguageModelRegistryFrontendDelegate, + PromptCustomizationService, + PromptService, + PromptServiceImpl, + ToolProvider +} from '../common'; +import { + FrontendLanguageModelRegistryImpl, + LanguageModelDelegateClientImpl, +} from './frontend-language-model-registry'; + +import { bindViewContribution, FrontendApplicationContribution, WidgetFactory } from '@theia/core/lib/browser'; +import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { LanguageGrammarDefinitionContribution } from '@theia/monaco/lib/browser/textmate'; +import { AIAgentConfigurationWidget } from './ai-configuration/agent-configuration-widget'; +import { AIConfigurationSelectionService } from './ai-configuration/ai-configuration-service'; +import { AIAgentConfigurationViewContribution } from './ai-configuration/ai-configuration-view-contribution'; +import { AIConfigurationContainerWidget } from './ai-configuration/ai-configuration-widget'; +import { AIVariableConfigurationWidget } from './ai-configuration/variable-configuration-widget'; +import { AICoreFrontendApplicationContribution } from './ai-core-frontend-application-contribution'; +import { bindAICorePreferences } from './ai-core-preferences'; +import { AISettingsService } from './ai-settings-service'; +import { FrontendPromptCustomizationServiceImpl } from './frontend-prompt-customization-service'; +import { FrontendVariableService } from './frontend-variable-service'; +import { PromptTemplateContribution } from './prompttemplate-contribution'; +import { TomorrowVariableContribution } from '../common/tomorrow-variable-contribution'; +import { TheiaVariableContribution } from './theia-variable-contribution'; +import { TodayVariableContribution } from '../common/today-variable-contribution'; +import { AgentsVariableContribution } from '../common/agents-variable-contribution'; +import { AIActivationService, AICommandHandlerFactory } from './ai-activation-service'; +import { AgentService, AgentServiceImpl } from '../common/agent-service'; + +export default new ContainerModule(bind => { + bindContributionProvider(bind, LanguageModelProvider); + + bind(FrontendLanguageModelRegistryImpl).toSelf().inSingletonScope(); + bind(LanguageModelRegistry).toService(FrontendLanguageModelRegistryImpl); + + bind(LanguageModelDelegateClientImpl).toSelf().inSingletonScope(); + bind(LanguageModelDelegateClient).toService(LanguageModelDelegateClientImpl); + bind(LanguageModelRegistryClient).toService(LanguageModelDelegateClient); + + bind(LanguageModelRegistryFrontendDelegate).toDynamicValue( + ctx => { + const connection = ctx.container.get(RemoteConnectionProvider); + const client = ctx.container.get(LanguageModelRegistryClient); + return connection.createProxy(languageModelRegistryDelegatePath, client); + } + ); + + bind(LanguageModelFrontendDelegate) + .toDynamicValue(ctx => { + const connection = ctx.container.get(RemoteConnectionProvider); + const client = ctx.container.get(LanguageModelDelegateClient); + return connection.createProxy(languageModelDelegatePath, client); + }) + .inSingletonScope(); + + bindAICorePreferences(bind); + + bind(FrontendPromptCustomizationServiceImpl).toSelf().inSingletonScope(); + bind(PromptCustomizationService).toService(FrontendPromptCustomizationServiceImpl); + bind(PromptServiceImpl).toSelf().inSingletonScope(); + bind(PromptService).toService(PromptServiceImpl); + + bind(PromptTemplateContribution).toSelf().inSingletonScope(); + bind(LanguageGrammarDefinitionContribution).toService(PromptTemplateContribution); + bind(CommandContribution).toService(PromptTemplateContribution); + bind(TabBarToolbarContribution).toService(PromptTemplateContribution); + + bind(AIConfigurationSelectionService).toSelf().inSingletonScope(); + bind(AIConfigurationContainerWidget).toSelf(); + bind(WidgetFactory) + .toDynamicValue(ctx => ({ + id: AIConfigurationContainerWidget.ID, + createWidget: () => ctx.container.get(AIConfigurationContainerWidget) + })) + .inSingletonScope(); + + bindViewContribution(bind, AIAgentConfigurationViewContribution); + bind(AISettingsService).toSelf().inRequestScope(); + bindContributionProvider(bind, AIVariableContribution); + bind(FrontendVariableService).toSelf().inSingletonScope(); + bind(AIVariableService).toService(FrontendVariableService); + bind(FrontendApplicationContribution).toService(FrontendVariableService); + bind(AIVariableContribution).to(TheiaVariableContribution).inSingletonScope(); + bind(AIVariableContribution).to(TodayVariableContribution).inSingletonScope(); + bind(AIVariableContribution).to(TomorrowVariableContribution).inSingletonScope(); + bind(AIVariableContribution).to(AgentsVariableContribution).inSingletonScope(); + + bind(FrontendApplicationContribution).to(AICoreFrontendApplicationContribution).inSingletonScope(); + + bind(AIVariableConfigurationWidget).toSelf(); + bind(WidgetFactory) + .toDynamicValue(ctx => ({ + id: AIVariableConfigurationWidget.ID, + createWidget: () => ctx.container.get(AIVariableConfigurationWidget) + })) + .inSingletonScope(); + + bind(AIAgentConfigurationWidget).toSelf(); + bind(WidgetFactory) + .toDynamicValue(ctx => ({ + id: AIAgentConfigurationWidget.ID, + createWidget: () => ctx.container.get(AIAgentConfigurationWidget) + })) + .inSingletonScope(); + + bind(FunctionCallRegistry).to(FunctionCallRegistryImpl).inSingletonScope(); + bindContributionProvider(bind, ToolProvider); + + bind(AIActivationService).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(AIActivationService); + bind(AgentServiceImpl).toSelf().inSingletonScope(); + bind(AgentService).toService(AgentServiceImpl); + + bind(AICommandHandlerFactory).toFactory(context => (handler: CommandHandler) => { + context.container.get(AIActivationService); + return { + execute: (...args: unknown[]) => handler.execute(...args), + isEnabled: (...args: unknown[]) => handler.isEnabled?.(...args) ?? true, + isVisible: (...args: unknown[]) => handler.isVisible?.(...args) ?? true, + isToggled: (...args: unknown[]) => handler.isToggled?.(...args) ?? false + }; + }); +}); diff --git a/packages/ai-core/src/browser/ai-core-preferences.ts b/packages/ai-core/src/browser/ai-core-preferences.ts new file mode 100644 index 0000000000000..d4f6ca9067bd3 --- /dev/null +++ b/packages/ai-core/src/browser/ai-core-preferences.ts @@ -0,0 +1,98 @@ +// ***************************************************************************** +// 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 { FrontendApplicationContribution, PreferenceContribution, PreferenceProxy, PreferenceSchema, PreferenceScope, PreferenceService } from '@theia/core/lib/browser'; +import { PreferenceProxyFactory } from '@theia/core/lib/browser/preferences/injectable-preference-proxy'; +import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; +import { inject, injectable, 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', + 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 }); + bind(FrontendApplicationContribution).to(TheiaDirFrontendContribution).inSingletonScope(); +} + +@injectable() +export class TheiaDirFrontendContribution implements FrontendApplicationContribution { + + @inject(EnvVariablesServer) + protected readonly envVariablesServer: EnvVariablesServer; + + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + + async onDidInitializeLayout(): Promise { + if (!this.preferenceService.get(PREFERENCE_NAME_PROMPT_TEMPLATES)) { + // Initialize the templates-folder. By default, create a 'templates-folder' + // directory inside of the Theia Config dir. + const theiaConfigDir = await this.envVariablesServer.getConfigDirUri(); + const templatesDirUri = new URI(theiaConfigDir).resolve('prompt-templates'); + // FIXME: Supports Windows paths (trim leading / on windows) + this.preferenceService.set(PREFERENCE_NAME_PROMPT_TEMPLATES, templatesDirUri.path.toString(), PreferenceScope.User); + } + } +} 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..b1092ff674889 --- /dev/null +++ b/packages/ai-core/src/browser/frontend-prompt-customization-service.ts @@ -0,0 +1,178 @@ +// ***************************************************************************** +// 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'; + +@injectable() +export class FrontendPromptCustomizationServiceImpl implements PromptCustomizationService { + + @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 templateFolder = this.preferences[PREFERENCE_NAME_PROMPT_TEMPLATES]; + if (templateFolder === undefined || templateFolder.trim().length === 0) { + return; + } + const templateURI = URI.fromFilePath(templateFolder); + + 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 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.getTemplate(id); + if (template === undefined) { + throw new Error(`Unable to edit template ${id}: template not found.`); + } + const templatesFolder = this.preferences[PREFERENCE_NAME_PROMPT_TEMPLATES]; + const editorUri = new URI(`file://${templatesFolder}/${id}.prompttemplate`); + 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 templatesFolder = this.preferences[PREFERENCE_NAME_PROMPT_TEMPLATES]; + const editorUri = new URI(`file://${templatesFolder}/${id}.prompttemplate`); + if (await this.fileService.exists(editorUri)) { + await this.fileService.delete(editorUri); + } + } + + getTemplate(id: string): PromptTemplate | undefined { + for (const agent of this.agentService.getAgents(true)) { + for (const template of agent.promptTemplates) { + if (template.id === id) { + return template; + } + } + } + return undefined; + } + + getTemplateIDFromURI(uri: URI): string | undefined { + const id = this.removePromptTemplateSuffix(uri.path.name); + if (this.templates.has(id)) { + return id; + } + return undefined; + } + +} diff --git a/packages/ai-core/src/browser/frontend-variable-service.ts b/packages/ai-core/src/browser/frontend-variable-service.ts new file mode 100644 index 0000000000000..56ceda7e4edd8 --- /dev/null +++ b/packages/ai-core/src/browser/frontend-variable-service.ts @@ -0,0 +1,26 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable } from '@theia/core/shared/inversify'; +import { DefaultAIVariableService } from '../common'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser'; + +@injectable() +export class FrontendVariableService extends DefaultAIVariableService implements FrontendApplicationContribution { + onStart(): void { + this.initContributions(); + } +} diff --git a/packages/ai-core/src/browser/index.ts b/packages/ai-core/src/browser/index.ts new file mode 100644 index 0000000000000..443f3894e72f4 --- /dev/null +++ b/packages/ai-core/src/browser/index.ts @@ -0,0 +1,26 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +export * from './ai-activation-service'; +export * from './ai-core-frontend-application-contribution'; +export * from './ai-core-frontend-module'; +export * from './ai-core-preferences'; +export * from './ai-settings-service'; +export * from './ai-view-contribution'; +export * from './frontend-language-model-registry'; +export * from './frontend-variable-service'; +export * from './prompttemplate-contribution'; +export * from './theia-variable-contribution'; diff --git a/packages/ai-core/src/browser/prompttemplate-contribution.ts b/packages/ai-core/src/browser/prompttemplate-contribution.ts new file mode 100644 index 0000000000000..d3cf4f99a6814 --- /dev/null +++ b/packages/ai-core/src/browser/prompttemplate-contribution.ts @@ -0,0 +1,250 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { GrammarDefinition, GrammarDefinitionProvider, LanguageGrammarDefinitionContribution, TextmateRegistry } from '@theia/monaco/lib/browser/textmate'; +import * as monaco from '@theia/monaco-editor-core'; +import { Command, CommandContribution, CommandRegistry, ContributionProvider, MessageService } from '@theia/core'; +import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; + +import { codicon, Widget } from '@theia/core/lib/browser'; +import { EditorWidget, ReplaceOperation } from '@theia/editor/lib/browser'; +import { PromptCustomizationService, PromptService, ToolProvider } from '../common'; +import { ProviderResult } from '@theia/monaco-editor-core/esm/vs/editor/common/languages'; + +const PROMPT_TEMPLATE_LANGUAGE_ID = 'theia-ai-prompt-template'; +const PROMPT_TEMPLATE_TEXTMATE_SCOPE = 'source.prompttemplate'; + +export const DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS: Command = { + id: 'theia-ai-prompt-template:discard', + iconClass: codicon('discard'), + category: 'Theia AI Prompt Templates' +}; + +// TODO this command is mainly for testing purposes +export const SHOW_ALL_PROMPTS_COMMAND: Command = { + id: 'theia-ai-prompt-template:show-prompts-command', + label: 'Show all prompts', + iconClass: codicon('beaker'), + category: 'Theia AI Prompt Templates', +}; + +@injectable() +export class PromptTemplateContribution implements LanguageGrammarDefinitionContribution, CommandContribution, TabBarToolbarContribution { + + @inject(PromptService) + private readonly promptService: PromptService; + + @inject(MessageService) + private readonly messageService: MessageService; + + @inject(PromptCustomizationService) + protected readonly customizationService: PromptCustomizationService; + + @inject(ContributionProvider) + @named(ToolProvider) + private toolProviders: ContributionProvider; + + readonly config: monaco.languages.LanguageConfiguration = + { + 'brackets': [ + ['${', '}'], + ['~{', '}'] + ], + 'autoClosingPairs': [ + { 'open': '${', 'close': '}' }, + { 'open': '~{', 'close': '}' }, + ], + 'surroundingPairs': [ + { 'open': '${', 'close': '}' }, + { 'open': '~{', 'close': '}' } + ] + }; + + registerTextmateLanguage(registry: TextmateRegistry): void { + monaco.languages.register({ + id: PROMPT_TEMPLATE_LANGUAGE_ID, + 'aliases': [ + 'Theia AI Prompt Templates' + ], + 'extensions': [ + '.prompttemplate', + ], + 'filenames': [] + }); + + monaco.languages.setLanguageConfiguration(PROMPT_TEMPLATE_LANGUAGE_ID, this.config); + + monaco.languages.registerCompletionItemProvider(PROMPT_TEMPLATE_LANGUAGE_ID, { + // Monaco only supports single character trigger characters + triggerCharacters: ['{'], + provideCompletionItems: (model, position, _context, _token): ProviderResult => this.provideFunctionCompletions(model, position), + }); + + const textmateGrammar = require('../../data/prompttemplate.tmLanguage.json'); + const grammarDefinitionProvider: GrammarDefinitionProvider = { + getGrammarDefinition: function (): Promise { + return Promise.resolve({ + format: 'json', + content: textmateGrammar + }); + } + }; + registry.registerTextmateGrammarScope(PROMPT_TEMPLATE_TEXTMATE_SCOPE, grammarDefinitionProvider); + + registry.mapLanguageIdToTextmateGrammar(PROMPT_TEMPLATE_LANGUAGE_ID, PROMPT_TEMPLATE_TEXTMATE_SCOPE); + } + + provideFunctionCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult { + return this.getSuggestions( + model, + position, + '~{', + this.toolProviders.getContributions().map(provider => provider.getTool()), + monaco.languages.CompletionItemKind.Function, + tool => tool.id, + tool => tool.name, + tool => tool.description ?? '' + ); + } + + getCompletionRange(model: monaco.editor.ITextModel, position: monaco.Position, triggerCharacters: string): monaco.Range | undefined { + // Check if the characters before the current position are the trigger characters + const lineContent = model.getLineContent(position.lineNumber); + const triggerLength = triggerCharacters.length; + const charactersBefore = lineContent.substring( + position.column - triggerLength - 1, + position.column - 1 + ); + + if (charactersBefore !== triggerCharacters) { + // Do not return agent suggestions if the user didn't just type the trigger characters + return undefined; + } + + // Calculate the range from the position of the trigger characters + const wordInfo = model.getWordUntilPosition(position); + return new monaco.Range( + position.lineNumber, + wordInfo.startColumn, + position.lineNumber, + position.column + ); + } + + private getSuggestions( + model: monaco.editor.ITextModel, + position: monaco.Position, + triggerChars: string, + items: T[], + kind: monaco.languages.CompletionItemKind, + getId: (item: T) => string, + getName: (item: T) => string, + getDescription: (item: T) => string + ): ProviderResult { + const completionRange = this.getCompletionRange(model, position, triggerChars); + if (completionRange === undefined) { + return { suggestions: [] }; + } + const suggestions = items.map(item => ({ + insertText: getId(item), + kind: kind, + label: getName(item), + range: completionRange, + detail: getDescription(item), + })); + return { suggestions }; + } + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS, { + isVisible: (widget: Widget) => this.isPromptTemplateWidget(widget), + isEnabled: (widget: EditorWidget) => this.canDiscard(widget), + execute: (widget: EditorWidget) => this.discard(widget) + }); + + commands.registerCommand(SHOW_ALL_PROMPTS_COMMAND, { + execute: () => this.showAllPrompts() + }); + } + + protected isPromptTemplateWidget(widget: Widget): boolean { + if (widget instanceof EditorWidget) { + return PROMPT_TEMPLATE_LANGUAGE_ID === widget.editor.document.languageId; + } + return false; + } + + protected canDiscard(widget: EditorWidget): boolean { + const resourceUri = widget.editor.uri; + const id = this.customizationService.getTemplateIDFromURI(resourceUri); + if (id === undefined) { + return false; + } + const rawPrompt = this.promptService.getRawPrompt(id); + const defaultPrompt = this.promptService.getDefaultRawPrompt(id); + return rawPrompt?.template !== defaultPrompt?.template; + } + + protected async discard(widget: EditorWidget): Promise { + const resourceUri = widget.editor.uri; + const id = this.customizationService.getTemplateIDFromURI(resourceUri); + if (id === undefined) { + return; + } + const defaultPrompt = this.promptService.getDefaultRawPrompt(id); + if (defaultPrompt === undefined) { + return; + } + + const source: string = widget.editor.document.getText(); + const lastLine = widget.editor.document.getLineContent(widget.editor.document.lineCount); + + const replaceOperation: ReplaceOperation = { + range: { + start: { + line: 0, + character: 0 + }, + end: { + line: widget.editor.document.lineCount, + character: lastLine.length + } + }, + text: defaultPrompt.template + }; + + await widget.editor.replaceText({ + source, + replaceOperations: [replaceOperation] + }); + } + + private showAllPrompts(): void { + const allPrompts = this.promptService.getAllPrompts(); + Object.keys(allPrompts).forEach(id => { + this.messageService.info(`Prompt Template ID: ${id}\n${allPrompts[id].template}`, 'Got it'); + }); + } + + registerToolbarItems(registry: TabBarToolbarRegistry): void { + registry.registerItem({ + id: DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS.id, + command: DISCARD_PROMPT_TEMPLATE_CUSTOMIZATIONS.id, + tooltip: 'Discard Customizations' + }); + } +} diff --git a/packages/ai-core/src/browser/style/index.css b/packages/ai-core/src/browser/style/index.css new file mode 100644 index 0000000000000..cddbdb327c694 --- /dev/null +++ b/packages/ai-core/src/browser/style/index.css @@ -0,0 +1,80 @@ +.ai-configuration-widget { + padding: var(--theia-ui-padding); +} + +.theia-ai-settings-container { + padding: var(--theia-ui-padding); +} + +.language-model-container { + padding-top: calc(2 * var(--theia-ui-padding)); +} + +.language-model-container .theia-select { + margin-left: var(--theia-ui-padding); +} + +.ai-templates { + display: grid; + /** Display content in 3 columns */ + grid-template-columns: 1fr auto auto; + /** add a 3px gap between rows */ + row-gap: 3px; +} + +#ai-variable-configuration-container-widget, +#ai-agent-configuration-container-widget { + margin-top: 5px; +} + +/* Variable Settings */ +#ai-variable-configuration-container-widget ul { + list-style: none; + padding: 0; + margin: 0; +} + +#ai-variable-configuration-container-widget .variable-item { + display: flex; + flex-direction: column; + margin-bottom: 1rem; +} + +#ai-variable-configuration-container-widget .variable-args { + display: grid; + grid-template-columns: 1fr 2fr; +} + +/* Agent Settings */ +#ai-agent-configuration-container-widget ul { + list-style: none; + padding: 0; + margin: 0; +} + +.ai-agent-configuration-main { + display: flex; + flex-direction: row; +} + +.configuration-agents-list { + width: 128px; +} + +.configuration-agent-panel { + flex: 1; +} + +#ai-variable-configuration-container-widget .variable-references, +#ai-agent-configuration-container-widget .variable-references { + margin-left: 0.5rem; + padding: 0.5rem; + border-left: solid 1px var(--theia-tree-indentGuidesStroke); +} + +#ai-variable-configuration-container-widget .variable-reference, +#ai-agent-configuration-container-widget .variable-reference { + display: flex; + flex-direction: row; + align-items: center; +} diff --git a/packages/ai-core/src/browser/theia-variable-contribution.ts b/packages/ai-core/src/browser/theia-variable-contribution.ts new file mode 100644 index 0000000000000..f8353e5eecb00 --- /dev/null +++ b/packages/ai-core/src/browser/theia-variable-contribution.ts @@ -0,0 +1,58 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state'; +import { inject, injectable } from '@theia/core/shared/inversify'; +import { VariableRegistry, VariableResolverService } from '@theia/variable-resolver/lib/browser'; +import { AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext, ResolvedAIVariable } from '../common'; + +@injectable() +export class TheiaVariableContribution implements AIVariableContribution, AIVariableResolver { + @inject(VariableResolverService) + protected readonly variableResolverService: VariableResolverService; + + @inject(VariableRegistry) + protected readonly variableRegistry: VariableRegistry; + + @inject(FrontendApplicationStateService) + protected readonly stateService: FrontendApplicationStateService; + + registerVariables(service: AIVariableService): void { + this.stateService.reachedState('initialized_layout').then(() => { + // some variable contributions in Theia are done as part of the onStart, same as our AI variable contributions + // we therefore wait for all of them to be registered before we register we map them to our own + this.variableRegistry.getVariables().forEach(variable => { + service.registerResolver({ id: `theia-${variable.name}`, name: variable.name, description: variable.description ?? 'Theia Built-in Variable' }, this); + }); + }); + } + + protected toTheiaVariable(request: AIVariableResolutionRequest): string { + return `$\{${request.variable.name}${request.arg ? ':' + request.arg : ''}}`; + } + + async canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + // some variables are not resolvable without providing a specific context + // this may be expensive but was not a problem for Theia's built-in variables + const resolved = await this.variableResolverService.resolve(this.toTheiaVariable(request), context); + return !resolved ? 0 : 1; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + const resolved = await this.variableResolverService.resolve(this.toTheiaVariable(request), context); + return resolved ? { value: resolved, variable: request.variable } : undefined; + } +} + diff --git a/packages/ai-core/src/common/agent-service.ts b/packages/ai-core/src/common/agent-service.ts new file mode 100644 index 0000000000000..122fac8d02b3e --- /dev/null +++ b/packages/ai-core/src/common/agent-service.ts @@ -0,0 +1,83 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { inject, injectable, named } from '@theia/core/shared/inversify'; +import { ContributionProvider } from '@theia/core'; +import { Agent } from './agent'; + +export const AgentService = Symbol('AgentService'); + +/** + * Service to access the list of known Agents. + */ +export interface AgentService { + /** + * Retrieves a list of agents. + * @param includeDisabledAgents - Optional. Specifies whether to include disabled agents in the result. + * This should usually remain false (or undefined), except when listing agents in a settings/configuration context. + * default: false + * @returns An array of Agent objects. + */ + getAgents(includeDisabledAgents?: boolean): Agent[]; + /** + * Enable the agent with the specified id. + * @param agentId the agent id. + */ + enableAgent(agentId: string): void; + /** + * disable the agent with the specified id. + * @param agentId the agent id. + */ + disableAgent(agentId: string): void; + /** + * query whether this agent is currently enabled or disabled. + * @param agentId the agent id. + * @return true if the agent is enabled, false otherwise. + */ + isEnabled(agentId: string): boolean; +} + +@injectable() +export class AgentServiceImpl implements AgentService { + + @inject(ContributionProvider) @named(Agent) + protected readonly agentsProvider: ContributionProvider; + + protected disabledAgents = new Set(); + + private get agents(): Agent[] { + return this.agentsProvider.getContributions(); + } + + getAgents(includeDisabledAgents = false): Agent[] { + if (includeDisabledAgents) { + return this.agents; + } else { + return this.agents.filter(agent => this.isEnabled(agent.id)); + } + } + + enableAgent(agentId: string): void { + this.disabledAgents.delete(agentId); + } + + disableAgent(agentId: string): void { + this.disabledAgents.add(agentId); + } + + isEnabled(agentId: string): boolean { + return !this.disabledAgents.has(agentId); + } +} diff --git a/packages/ai-core/src/common/agent.ts b/packages/ai-core/src/common/agent.ts new file mode 100644 index 0000000000000..9253351033478 --- /dev/null +++ b/packages/ai-core/src/common/agent.ts @@ -0,0 +1,39 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { LanguageModelRequirement } from './language-model'; +import { PromptTemplate } from './prompt-service'; + +export const Agent = Symbol('Agent'); +export interface Agent { + /** Used to identify an agent, e.g. when it is requesting language models, etc. */ + readonly id: string; + + /** Human-readable name shown to users to identify the agent. */ + readonly name: string; + + /** A markdown description of its functionality and its privacy-relevant requirements, including function call handlers that access some data autonomously. */ + readonly description: string; + + /** The list of variable identifiers this agent needs to clarify its context requirements. See #39. */ + readonly variables: string[]; + + /** The prompt templates introduced and used by this agent. */ + readonly promptTemplates: PromptTemplate[]; + + /** Required language models. This includes the purpose and optional language model selector arguments. See #47. */ + readonly languageModelRequirements: LanguageModelRequirement[]; +} diff --git a/packages/ai-core/src/common/agents-variable-contribution.ts b/packages/ai-core/src/common/agents-variable-contribution.ts new file mode 100644 index 0000000000000..b38c56c70d650 --- /dev/null +++ b/packages/ai-core/src/common/agents-variable-contribution.ts @@ -0,0 +1,68 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { inject, injectable } from '@theia/core/shared/inversify'; +import { AIVariable, AIVariableContext, AIVariableContribution, AIVariableResolutionRequest, AIVariableResolver, AIVariableService, ResolvedAIVariable } from './variable-service'; +import { MaybePromise } from '@theia/core'; +import { AgentService } from './agent-service'; + +export const AGENTS_VARIABLE: AIVariable = { + id: 'agents', + name: 'agents', + description: 'Returns the list of agents available in the system' +}; + +export interface ResolvedAgentsVariable extends ResolvedAIVariable { + agents: AgentDescriptor[]; +} + +export interface AgentDescriptor { + id: string; + name: string; + description: string; +} + +@injectable() +export class AgentsVariableContribution implements AIVariableContribution, AIVariableResolver { + + @inject(AgentService) + protected readonly agentService: AgentService; + + registerVariables(service: AIVariableService): void { + service.registerResolver(AGENTS_VARIABLE, this); + } + + canResolve(request: AIVariableResolutionRequest, _context: AIVariableContext): MaybePromise { + if (request.variable.name === AGENTS_VARIABLE.name) { + return 1; + } + return -1; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + if (request.variable.name === AGENTS_VARIABLE.name) { + return this.resolveAgentsVariable(request); + } + } + + resolveAgentsVariable(_request: AIVariableResolutionRequest): ResolvedAgentsVariable { + const agents = this.agentService.getAgents().map(agent => ({ + id: agent.id, + name: agent.name, + description: agent.description + })); + return { variable: AGENTS_VARIABLE, agents, value: JSON.stringify(agents) }; + } +} diff --git a/packages/ai-core/src/common/communication-recording-service.ts b/packages/ai-core/src/common/communication-recording-service.ts new file mode 100644 index 0000000000000..491d8065173e5 --- /dev/null +++ b/packages/ai-core/src/common/communication-recording-service.ts @@ -0,0 +1,44 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { Event } from '@theia/core'; + +export type CommunicationHistory = CommunicationHistoryEntry[]; + +export interface CommunicationHistoryEntry { + agentId: string; + sessionId: string; + timestamp: number; + requestId: string; + request?: string; + response?: string; + responseTime?: number; + messages?: unknown[]; +} + +export type CommunicationRequestEntry = Omit; +export type CommunicationResponseEntry = Omit; + +export const CommunicationRecordingService = Symbol('CommunicationRecordingService'); +export interface CommunicationRecordingService { + recordRequest(requestEntry: CommunicationRequestEntry): void; + readonly onDidRecordRequest: Event; + + recordResponse(responseEntry: CommunicationResponseEntry): void; + readonly onDidRecordResponse: Event; + + getHistory(agentId: string): CommunicationHistory; +} diff --git a/packages/ai-core/src/common/function-call-registry.ts b/packages/ai-core/src/common/function-call-registry.ts new file mode 100644 index 0000000000000..a6cc50e7841a5 --- /dev/null +++ b/packages/ai-core/src/common/function-call-registry.ts @@ -0,0 +1,79 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; +import { ToolRequest } from './language-model'; +import { ContributionProvider } from '@theia/core'; + +export const FunctionCallRegistry = Symbol('FunctionCallRegistry'); + +/** + * Registry for all the function calls available to Agents. + */ +export interface FunctionCallRegistry { + registerFunction(tool: ToolRequest): void; + + getFunction(toolId: string): ToolRequest | undefined; + + getFunctions(...toolIds: string[]): ToolRequest[]; +} + +export const ToolProvider = Symbol('ToolProvider'); +export interface ToolProvider { + getTool(): ToolRequest; +} + +@injectable() +export class FunctionCallRegistryImpl implements FunctionCallRegistry { + + private functions: Map> = new Map>(); + + @inject(ContributionProvider) + @named(ToolProvider) + private providers: ContributionProvider; + + @postConstruct() + init(): void { + this.providers.getContributions().forEach(provider => { + this.registerFunction(provider.getTool()); + }); + } + + registerFunction(tool: ToolRequest): void { + if (this.functions.has(tool.id)) { + console.warn(`Function with id ${tool.id} is already registered.`); + } else { + this.functions.set(tool.id, tool); + } + } + + getFunction(toolId: string): ToolRequest | undefined { + return this.functions.get(toolId); + } + + getFunctions(...toolIds: string[]): ToolRequest[] { + const tools: ToolRequest[] = toolIds.map(toolId => { + const tool = this.functions.get(toolId); + if (tool) { + return tool; + } else { + throw new Error(`Function with id ${toolId} does not exist.`); + } + }); + return tools; + } +} + diff --git a/packages/ai-core/src/common/index.ts b/packages/ai-core/src/common/index.ts new file mode 100644 index 0000000000000..19fdaa8b88be3 --- /dev/null +++ b/packages/ai-core/src/common/index.ts @@ -0,0 +1,28 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +export * from './agent-service'; +export * from './agent'; +export * from './agents-variable-contribution'; +export * from './communication-recording-service'; +export * from './function-call-registry'; +export * from './language-model-delegate'; +export * from './language-model-util'; +export * from './language-model'; +export * from './prompt-service'; +export * from './protocol'; +export * from './today-variable-contribution'; +export * from './tomorrow-variable-contribution'; +export * from './variable-service'; diff --git a/packages/ai-core/src/common/language-model-delegate.ts b/packages/ai-core/src/common/language-model-delegate.ts new file mode 100644 index 0000000000000..40404829bd182 --- /dev/null +++ b/packages/ai-core/src/common/language-model-delegate.ts @@ -0,0 +1,44 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { LanguageModelMetaData, LanguageModelParsedResponse, LanguageModelRequest, LanguageModelStreamResponsePart, LanguageModelTextResponse } from './language-model'; + +export const LanguageModelDelegateClient = Symbol('LanguageModelDelegateClient'); +export interface LanguageModelDelegateClient { + toolCall(requestId: string, toolId: string, args_string: string): Promise; + send(id: string, token: LanguageModelStreamResponsePart | undefined): void; +} +export const LanguageModelRegistryFrontendDelegate = Symbol('LanguageModelRegistryFrontendDelegate'); +export interface LanguageModelRegistryFrontendDelegate { + getLanguageModelDescriptions(): Promise; +} + +export interface LanguageModelStreamResponseDelegate { + streamId: string; +} +export const isLanguageModelStreamResponseDelegate = (obj: unknown): obj is LanguageModelStreamResponseDelegate => + !!(obj && typeof obj === 'object' && 'streamId' in obj && typeof (obj as { streamId: unknown }).streamId === 'string'); + +export type LanguageModelResponseDelegate = LanguageModelTextResponse | LanguageModelParsedResponse | LanguageModelStreamResponseDelegate; + +export const LanguageModelFrontendDelegate = Symbol('LanguageModelFrontendDelegate'); +export interface LanguageModelFrontendDelegate { + cancel(requestId: string): void; + request(modelId: string, request: LanguageModelRequest, requestId: string): Promise; +} + +export const languageModelRegistryDelegatePath = '/services/languageModelRegistryDelegatePath'; +export const languageModelDelegatePath = '/services/languageModelDelegatePath'; diff --git a/packages/ai-core/src/common/language-model-util.ts b/packages/ai-core/src/common/language-model-util.ts new file mode 100644 index 0000000000000..d42533cacc65a --- /dev/null +++ b/packages/ai-core/src/common/language-model-util.ts @@ -0,0 +1,67 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { isLanguageModelStreamResponse, isLanguageModelTextResponse, LanguageModelResponse, ToolRequest } from './language-model'; + +export const getTextOfResponse = async (response: LanguageModelResponse): Promise => { + if (isLanguageModelTextResponse(response)) { + return response.text; + } else if (isLanguageModelStreamResponse(response)) { + let result = ''; + for await (const chunk of response.stream) { + result += chunk.content ?? ''; + } + return result; + } + throw new Error(`Invalid response type ${response}`); +}; + +export const getJsonOfResponse = async (response: LanguageModelResponse): Promise => { + const text = await getTextOfResponse(response); + if (text.startsWith('```json')) { + const regex = /```json\s*([\s\S]*?)\s*```/g; + let match; + // eslint-disable-next-line no-null/no-null + while ((match = regex.exec(text)) !== null) { + try { + return JSON.parse(match[1]); + } catch (error) { + console.error('Failed to parse JSON:', error); + } + } + } else if (text.startsWith('{') || text.startsWith('[')) { + return JSON.parse(text); + } + throw new Error('Invalid response format'); +}; +export const toolRequestToPromptText = (toolRequest: ToolRequest): string => { + const parameters = toolRequest.parameters; + let paramsText = ''; + // parameters are supposed to be as a JSON schema. Thus, derive the parameters from its properties definition + if (parameters) { + const properties = parameters.properties; + paramsText = Object.keys(properties) + .map(key => { + const param = properties[key]; + return `${key}: ${param.type}`; + }) + .join(', '); + } + const descriptionText = toolRequest.description + ? `: ${toolRequest.description}` + : ''; + return `You can call function: ${toolRequest.id}(${paramsText})${descriptionText}`; +}; diff --git a/packages/ai-core/src/common/language-model.spec.ts b/packages/ai-core/src/common/language-model.spec.ts new file mode 100644 index 0000000000000..044b839531543 --- /dev/null +++ b/packages/ai-core/src/common/language-model.spec.ts @@ -0,0 +1,86 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { isModelMatching, LanguageModel, LanguageModelSelector } from './language-model'; +import { expect } from 'chai'; + +describe('isModelMatching', () => { + it('returns false with one of two parameter mismatches', () => { + expect( + isModelMatching( + { + name: 'XXX', + family: 'YYY', + }, + { + name: 'gpt-4o', + family: 'YYY', + } + ) + ).eql(false); + }); + it('returns false with two parameter mismatches', () => { + expect( + isModelMatching( + { + name: 'XXX', + family: 'YYY', + }, + { + name: 'gpt-4o', + family: 'ZZZ', + } + ) + ).eql(false); + }); + it('returns true with one parameter match', () => { + expect( + isModelMatching( + { + name: 'gpt-4o', + }, + { + name: 'gpt-4o', + } + ) + ).eql(true); + }); + it('returns true with two parameter matches', () => { + expect( + isModelMatching( + { + name: 'gpt-4o', + family: 'YYY', + }, + { + name: 'gpt-4o', + family: 'YYY', + } + ) + ).eql(true); + }); + it('returns true if there are no parameters in selector', () => { + expect( + isModelMatching( + {}, + { + name: 'gpt-4o', + family: 'YYY', + } + ) + ).eql(true); + }); +}); diff --git a/packages/ai-core/src/common/language-model.ts b/packages/ai-core/src/common/language-model.ts new file mode 100644 index 0000000000000..cb3a2459f6080 --- /dev/null +++ b/packages/ai-core/src/common/language-model.ts @@ -0,0 +1,239 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { CancellationToken, ContributionProvider, ILogger, isFunction, isObject, Event, Emitter } from '@theia/core'; +import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; + +export type MessageActor = 'user' | 'ai' | 'system'; + +export interface LanguageModelRequestMessage { + actor: MessageActor; + type: 'text'; + query: string; +} +export const isLanguageModelRequestMessage = (obj: unknown): obj is LanguageModelRequestMessage => + !!(obj && typeof obj === 'object' && + 'type' in obj && + typeof (obj as { type: unknown }).type === 'string' && + (obj as { type: unknown }).type === 'text' && + 'query' in obj && + typeof (obj as { query: unknown }).query === 'string' + ); +export interface ToolRequest { + id: string; + name: string; + parameters?: { type?: 'object', properties: Record }; + description?: string; + handler: (arg_string: string) => Promise; +} +export interface LanguageModelRequest { + messages: LanguageModelRequestMessage[], + tools?: ToolRequest[]; + response_format?: { type: 'text' } | { type: 'json_object' } | ResponseFormatJsonSchema; + cancellationToken?: CancellationToken; + settings?: { [key: string]: unknown }; +} +export interface ResponseFormatJsonSchema { + type: 'json_schema'; + json_schema: { + name: string, + description?: string, + schema?: Record, + strict?: boolean | null + }; +} + +export interface LanguageModelTextResponse { + text: string; +} +export const isLanguageModelTextResponse = (obj: unknown): obj is LanguageModelTextResponse => + !!(obj && typeof obj === 'object' && 'text' in obj && typeof (obj as { text: unknown }).text === 'string'); + +export interface LanguageModelStreamResponsePart { + content?: string | null; + tool_calls?: ToolCall[]; +} + +export interface ToolCall { + id?: string; + function?: { + arguments?: string; + name?: string; + }, + finished?: boolean; + result?: string; +} + +export interface LanguageModelStreamResponse { + stream: AsyncIterable; +} +export const isLanguageModelStreamResponse = (obj: unknown): obj is LanguageModelStreamResponse => + !!(obj && typeof obj === 'object' && 'stream' in obj); + +export interface LanguageModelParsedResponse { + parsed: unknown; + content: string; +} +export const isLanguageModelParsedResponse = (obj: unknown): obj is LanguageModelParsedResponse => + !!(obj && typeof obj === 'object' && 'parsed' in obj && 'content' in obj); + +export type LanguageModelResponse = LanguageModelTextResponse | LanguageModelStreamResponse | LanguageModelParsedResponse; + +/////////////////////////////////////////// +// Language Model Provider +/////////////////////////////////////////// + +export const LanguageModelProvider = Symbol('LanguageModelProvider'); +export type LanguageModelProvider = () => Promise; + +// See also VS Code `ILanguageModelChatMetadata` +export interface LanguageModelMetaData { + readonly id: string; + readonly providerId: string; + readonly name?: string; + readonly vendor?: string; + readonly version?: string; + readonly family?: string; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; +} + +export namespace LanguageModelMetaData { + export function is(arg: unknown): arg is LanguageModelMetaData { + return isObject(arg) && 'id' in arg && 'providerId' in arg; + } +} + +export interface LanguageModel extends LanguageModelMetaData { + request(request: LanguageModelRequest): Promise; +} + +export namespace LanguageModel { + export function is(arg: unknown): arg is LanguageModel { + return isObject(arg) && 'id' in arg && 'providerId' in arg && isFunction(arg.request); + } +} + +// See also VS Code `ILanguageModelChatSelector` +interface VsCodeLanguageModelSelector { + readonly identifier?: string; + readonly name?: string; + readonly vendor?: string; + readonly version?: string; + readonly family?: string; + readonly tokens?: number; +} + +export interface LanguageModelSelector extends VsCodeLanguageModelSelector { + readonly agent: string; + readonly purpose: string; +} + +export type LanguageModelRequirement = Omit; + +export const LanguageModelRegistry = Symbol('LanguageModelRegistry'); +export interface LanguageModelRegistry { + onChange: Event<{ models: LanguageModel[] }>; + addLanguageModels(models: LanguageModel[]): void; + getLanguageModels(): Promise; + getLanguageModel(id: string): Promise; + removeLanguageModels(id: string[]): void; + selectLanguageModel(request: LanguageModelSelector): Promise; + selectLanguageModels(request: LanguageModelSelector): Promise; +} + +@injectable() +export class DefaultLanguageModelRegistryImpl implements LanguageModelRegistry { + @inject(ILogger) + protected logger: ILogger; + @inject(ContributionProvider) @named(LanguageModelProvider) + protected readonly languageModelContributions: ContributionProvider; + + protected languageModels: LanguageModel[] = []; + + protected markInitialized: () => void; + protected initialized: Promise = new Promise(resolve => { this.markInitialized = resolve; }); + + protected changeEmitter = new Emitter<{ models: LanguageModel[] }>(); + onChange = this.changeEmitter.event; + + @postConstruct() + protected init(): void { + const contributions = this.languageModelContributions.getContributions(); + const promises = contributions.map(provider => provider()); + Promise.allSettled(promises).then(results => { + for (const result of results) { + if (result.status === 'fulfilled') { + this.languageModels.push(...result.value); + } else { + this.logger.error('Failed to add some language models:', result.reason); + } + } + this.markInitialized(); + }); + } + + addLanguageModels(models: LanguageModel[]): void { + models.forEach(model => { + if (this.languageModels.find(lm => lm.id === model.id)) { + console.warn(`Tried to add already existing language model with id ${model.id}. The new model will be ignored.`); + return; + } + this.languageModels.push(model); + this.changeEmitter.fire({ models: this.languageModels }); + }); + } + + async getLanguageModels(): Promise { + await this.initialized; + return this.languageModels; + } + + async getLanguageModel(id: string): Promise { + await this.initialized; + return this.languageModels.find(model => model.id === id); + } + + removeLanguageModels(ids: string[]): void { + ids.forEach(id => { + const index = this.languageModels.findIndex(model => model.id === id); + if (index !== -1) { + this.languageModels.splice(index, 1); + this.changeEmitter.fire({ models: this.languageModels }); + } else { + console.warn(`Language model with id ${id} was requested to be removed, however it does not exist`); + } + }); + } + + async selectLanguageModels(request: LanguageModelSelector): Promise { + await this.initialized; + // TODO check for actor and purpose against settings + return this.languageModels.filter(model => isModelMatching(request, model)); + } + + async selectLanguageModel(request: LanguageModelSelector): Promise { + return (await this.selectLanguageModels(request))[0]; + } +} + +export function isModelMatching(request: LanguageModelSelector, model: LanguageModel): boolean { + return (!request.identifier || model.id === request.identifier) && + (!request.name || model.name === request.name) && + (!request.vendor || model.vendor === request.vendor) && + (!request.version || model.version === request.version) && + (!request.family || model.family === request.family); +} diff --git a/packages/ai-core/src/common/prompt-service.spec.ts b/packages/ai-core/src/common/prompt-service.spec.ts new file mode 100644 index 0000000000000..7d752c018348d --- /dev/null +++ b/packages/ai-core/src/common/prompt-service.spec.ts @@ -0,0 +1,87 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import 'reflect-metadata'; + +import { expect } from 'chai'; +import { Container } from 'inversify'; +import { PromptService, PromptServiceImpl } from './prompt-service'; +import { DefaultAIVariableService, AIVariableService } from './variable-service'; + +describe('PromptService', () => { + let promptService: PromptService; + + beforeEach(() => { + const container = new Container(); + container.bind(PromptService).to(PromptServiceImpl).inSingletonScope(); + + const variableService = new DefaultAIVariableService({ getContributions: () => [] }); + const nameVariable = { id: 'test', name: 'name', description: 'Test name ' }; + variableService.registerResolver(nameVariable, { + canResolve: () => 100, + resolve: async () => ({ variable: nameVariable, value: 'Jane' }) + }); + container.bind(AIVariableService).toConstantValue(variableService); + + promptService = container.get(PromptService); + promptService.storePrompt('1', 'Hello, ${name}!'); + promptService.storePrompt('2', 'Goodbye, ${name}!'); + promptService.storePrompt('3', 'Ciao, ${invalid}!'); + }); + + it('should initialize prompts from PromptCollectionService', () => { + const allPrompts = promptService.getAllPrompts(); + expect(allPrompts['1'].template).to.equal('Hello, ${name}!'); + expect(allPrompts['2'].template).to.equal('Goodbye, ${name}!'); + expect(allPrompts['3'].template).to.equal('Ciao, ${invalid}!'); + }); + + it('should retrieve raw prompt by id', () => { + const rawPrompt = promptService.getRawPrompt('1'); + expect(rawPrompt?.template).to.equal('Hello, ${name}!'); + }); + + it('should format prompt with provided arguments', async () => { + const formattedPrompt = await promptService.getPrompt('1', { name: 'John' }); + expect(formattedPrompt?.text).to.equal('Hello, John!'); + }); + + it('should store a new prompt', () => { + promptService.storePrompt('3', 'Welcome, ${name}!'); + const newPrompt = promptService.getRawPrompt('3'); + expect(newPrompt?.template).to.equal('Welcome, ${name}!'); + }); + + it('should replace placeholders with provided arguments', async () => { + const prompt = await promptService.getPrompt('1', { name: 'John' }); + expect(prompt?.text).to.equal('Hello, John!'); + }); + + it('should use variable service to resolve placeholders if argument value is not provided', async () => { + const prompt = await promptService.getPrompt('1'); + expect(prompt?.text).to.equal('Hello, Jane!'); + }); + + it('should return the prompt even if there are no replacements', async () => { + const prompt = await promptService.getPrompt('3'); + expect(prompt?.text).to.equal('Ciao, ${invalid}!'); + }); + + it('should return undefined if the prompt id is not found', async () => { + const prompt = await promptService.getPrompt('4'); + expect(prompt).to.be.undefined; + }); +}); diff --git a/packages/ai-core/src/common/prompt-service.ts b/packages/ai-core/src/common/prompt-service.ts new file mode 100644 index 0000000000000..b298f3e593f62 --- /dev/null +++ b/packages/ai-core/src/common/prompt-service.ts @@ -0,0 +1,213 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { URI } from '@theia/core'; +import { inject, injectable, optional } from '@theia/core/shared/inversify'; +import { AIVariableService } from './variable-service'; +import { FunctionCallRegistry } from './function-call-registry'; +import { toolRequestToPromptText } from './language-model-util'; +import { ToolRequest } from './language-model'; + +export interface PromptTemplate { + id: string; + template: string; +} + +export interface PromptMap { [id: string]: PromptTemplate } + +export interface ResolvedPromptTemplate { + id: string; + /** The resolved prompt text with variables and function requests being replaced. */ + text: string; + /** All functions referenced in the prompt template. */ + functionDescriptions?: Map>; +} + +export const PromptService = Symbol('PromptService'); +export interface PromptService { + /** + * Retrieve the raw {@link PromptTemplate} object. + * @param id the id of the {@link PromptTemplate} + */ + getRawPrompt(id: string): PromptTemplate | undefined; + /** + * Retrieve the default raw {@link PromptTemplate} object. + * @param id the id of the {@link PromptTemplate} + */ + getDefaultRawPrompt(id: string): PromptTemplate | undefined; + /** + * Allows to directly replace placeholders in the prompt. The supported format is 'Hi ${name}!'. + * The placeholder is then searched inside the args object and replaced. + * Function references are also supported via format '~{functionId}'. + * @param id the id of the prompt + * @param args the object with placeholders, mapping the placeholder key to the value + */ + getPrompt(id: string, args?: { [key: string]: unknown }): Promise; + /** + * Manually add a prompt to the list of prompts. + * @param id the id of the prompt + * @param prompt the prompt template to store + */ + storePrompt(id: string, prompt: string): void; + /** + * Return all known prompts as a {@link PromptMap map}. + */ + getAllPrompts(): PromptMap; +} + +export const PromptCustomizationService = Symbol('PromptCustomizationService'); +export interface PromptCustomizationService { + /** + * Whether there is a customization for a {@link PromptTemplate} object + * @param id the id of the {@link PromptTemplate} to check + */ + isPromptTemplateCustomized(id: string): boolean; + + /** + * Returns the customization of {@link PromptTemplate} object or undefined if there is none + * @param id the id of the {@link PromptTemplate} to check + */ + getCustomizedPromptTemplate(id: string): string | undefined + + /** + * Edit the template. If the content is specified, is will be + * used to customize the template. Otherwise, the behavior depends + * on the implementation. Implementation may for example decide to + * open an editor, or request more information from the user, ... + * @param id the template id. + * @param content optional content to customize the template. + */ + editTemplate(id: string, content?: string): void; + + /** + * Reset the template to its default value. + * @param id the template id. + */ + resetTemplate(id: string): void; + + /** + * Return the template id for a given template file. + * @param uri the uri of the template file + */ + getTemplateIDFromURI(uri: URI): string | undefined; +} + +// should match the one from VariableResolverService +const PROMPT_VARIABLE_REGEX = /\$\{(.*?)\}/g; + +// Match function/tool references in the prompt. The format is ~{functionId} +const PROMPT_FUNCTION_REGEX = /\~\{(.*?)\}/g; + +@injectable() +export class PromptServiceImpl implements PromptService { + @inject(PromptCustomizationService) @optional() + protected readonly customizationService: PromptCustomizationService | undefined; + + @inject(AIVariableService) @optional() + protected readonly variableService: AIVariableService | undefined; + + @inject(FunctionCallRegistry) @optional() + protected readonly functionCallRegistry: FunctionCallRegistry | undefined; + + protected _prompts: PromptMap = {}; + + getRawPrompt(id: string): PromptTemplate | undefined { + if (this.customizationService !== undefined && this.customizationService.isPromptTemplateCustomized(id)) { + const template = this.customizationService.getCustomizedPromptTemplate(id); + if (template !== undefined) { + return { id, template }; + } + } + return this.getDefaultRawPrompt(id); + } + getDefaultRawPrompt(id: string): PromptTemplate | undefined { + return this._prompts[id]; + } + async getPrompt(id: string, args?: { [key: string]: unknown }): Promise { + const prompt = this.getRawPrompt(id); + if (prompt === undefined) { + return undefined; + } + + const matches = [...prompt.template.matchAll(PROMPT_VARIABLE_REGEX)]; + const variableAndArgReplacements = await Promise.all(matches.map(async match => { + const completeText = match[0]; + const variableAndArg = match[1]; + let variableName = variableAndArg; + let argument: string | undefined; + const parts = variableAndArg.split(':', 2); + if (parts.length > 1) { + variableName = parts[0]; + argument = parts[1]; + } + return { + placeholder: completeText, + value: String(args?.[variableAndArg] ?? (await this.variableService?.resolveVariable({ + variable: variableName, + arg: argument + }, {}))?.value ?? completeText) + }; + })); + + const functionMatches = [...prompt.template.matchAll(PROMPT_FUNCTION_REGEX)]; + const functions = new Map>(); + const functionReplacements = functionMatches.map(match => { + const completeText = match[0]; + const functionId = match[1]; + const toolRequest = this.functionCallRegistry?.getFunction(functionId); + if (toolRequest) { + functions.set(toolRequest.id, toolRequest); + } + return { + placeholder: completeText, + value: toolRequest ? toolRequestToPromptText(toolRequest) : completeText + }; + }); + + let resolvedTemplate = prompt.template; + const replacements = [...variableAndArgReplacements, ...functionReplacements]; + replacements.forEach(replacement => resolvedTemplate = resolvedTemplate.replace(replacement.placeholder, replacement.value)); + return { + id, + text: resolvedTemplate, + functionDescriptions: functions.size > 0 ? functions : undefined + }; + } + getAllPrompts(): PromptMap { + if (this.customizationService !== undefined) { + const myCustomization = this.customizationService; + const result: PromptMap = {}; + Object.keys(this._prompts).forEach(id => { + if (myCustomization.isPromptTemplateCustomized(id)) { + const template = myCustomization.getCustomizedPromptTemplate(id); + if (template !== undefined) { + result[id] = { id, template }; + } else { + result[id] = { ...this._prompts[id] }; + } + } else { + result[id] = { ...this._prompts[id] }; + } + }); + return result; + } else { + return { ...this._prompts }; + } + } + storePrompt(id: string, prompt: string): void { + this._prompts[id] = { id, template: prompt }; + } +} diff --git a/packages/ai-core/src/common/protocol.ts b/packages/ai-core/src/common/protocol.ts new file mode 100644 index 0000000000000..ec1c3dbfde4b6 --- /dev/null +++ b/packages/ai-core/src/common/protocol.ts @@ -0,0 +1,23 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { LanguageModelMetaData } from './language-model'; + +export const LanguageModelRegistryClient = Symbol('LanguageModelRegistryClient'); +export interface LanguageModelRegistryClient { + languageModelAdded(metadata: LanguageModelMetaData): void; + languageModelRemoved(id: string): void; +} diff --git a/packages/ai-core/src/common/today-variable-contribution.ts b/packages/ai-core/src/common/today-variable-contribution.ts new file mode 100644 index 0000000000000..a155618ffe85c --- /dev/null +++ b/packages/ai-core/src/common/today-variable-contribution.ts @@ -0,0 +1,67 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { MaybePromise } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { AIVariable, ResolvedAIVariable, AIVariableContribution, AIVariableResolver, AIVariableService, AIVariableResolutionRequest, AIVariableContext } from './variable-service'; + +export namespace TodayVariableArgs { + export const IN_UNIX_SECONDS = 'inUnixSeconds'; + export const IN_ISO_8601 = 'inIso8601'; +} + +export const TODAY_VARIABLE: AIVariable = { + id: 'today-provider', + description: 'Does something for today', + name: 'today', + args: [ + { name: TodayVariableArgs.IN_ISO_8601, description: 'Returns the current date in ISO 8601 format' }, + { name: TodayVariableArgs.IN_UNIX_SECONDS, description: 'Returns the current date in unix seconds format' } + ] +}; + +export interface ResolvedTodayVariable extends ResolvedAIVariable { + date: Date; +} + +@injectable() +export class TodayVariableContribution implements AIVariableContribution, AIVariableResolver { + registerVariables(service: AIVariableService): void { + service.registerResolver(TODAY_VARIABLE, this); + } + + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { + return 1; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + if (request.variable.name === TODAY_VARIABLE.name) { + return this.resolveTodayVariable(request); + } + return undefined; + } + + private resolveTodayVariable(request: AIVariableResolutionRequest): ResolvedTodayVariable { + const date = new Date(); + if (request.arg === TodayVariableArgs.IN_ISO_8601) { + return { variable: request.variable, value: date.toISOString(), date }; + } + if (request.arg === TodayVariableArgs.IN_UNIX_SECONDS) { + return { variable: request.variable, value: Math.round(date.getTime() / 1000).toString(), date }; + } + return { variable: request.variable, value: date.toDateString(), date }; + } +} + diff --git a/packages/ai-core/src/common/tomorrow-variable-contribution.ts b/packages/ai-core/src/common/tomorrow-variable-contribution.ts new file mode 100644 index 0000000000000..8575505cfef82 --- /dev/null +++ b/packages/ai-core/src/common/tomorrow-variable-contribution.ts @@ -0,0 +1,66 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { MaybePromise } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; +import { AIVariable, AIVariableContext, AIVariableContribution, AIVariableResolutionRequest, AIVariableResolver, AIVariableService, ResolvedAIVariable } from './variable-service'; + +export namespace TomorrowVariableArgs { + export const IN_UNIX_SECONDS = 'inUnixSeconds'; + export const IN_ISO_8601 = 'inIso8601'; +} + +export const TOMORROW_VARIABLE: AIVariable = { + id: 'tomorrow-provider', + description: 'Does something for tomorrow', + name: 'tomorrow', + args: [ + { name: TomorrowVariableArgs.IN_ISO_8601, description: 'Returns the current date in ISO 8601 format' }, + { name: TomorrowVariableArgs.IN_UNIX_SECONDS, description: 'Returns the current date in unix seconds format' } + ] +}; + +export interface ResolvedTomorrowVariable extends ResolvedAIVariable { + date: Date; +} + +@injectable() +export class TomorrowVariableContribution implements AIVariableContribution, AIVariableResolver { + registerVariables(service: AIVariableService): void { + service.registerResolver(TOMORROW_VARIABLE, this); + } + + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise { + return 1; + } + + async resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise { + if (request.variable.name === TOMORROW_VARIABLE.name) { + return this.resolveTomorrowVariable(request); + } + return undefined; + } + + private resolveTomorrowVariable(request: AIVariableResolutionRequest): ResolvedTomorrowVariable { + const date = new Date(+new Date() + 86400000); + if (request.arg === TomorrowVariableArgs.IN_ISO_8601) { + return { variable: request.variable, value: date.toISOString(), date }; + } + if (request.arg === TomorrowVariableArgs.IN_UNIX_SECONDS) { + return { variable: request.variable, value: Math.round(date.getTime() / 1000).toString(), date }; + } + return { variable: request.variable, value: date.toDateString(), date }; + } +} diff --git a/packages/ai-core/src/common/variable-service.ts b/packages/ai-core/src/common/variable-service.ts new file mode 100644 index 0000000000000..833d322eed48f --- /dev/null +++ b/packages/ai-core/src/common/variable-service.ts @@ -0,0 +1,177 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Partially copied from https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatVariables.ts + +import { ContributionProvider, Disposable, Emitter, ILogger, MaybePromise, Prioritizeable, Event } from '@theia/core'; +import { inject, injectable, named } from '@theia/core/shared/inversify'; + +export interface AIVariable { + /** provider id */ + id: string; + /** variable name */ + name: string; + /** variable description */ + description: string; + args?: AIVariableDescription[]; +} + +export interface AIVariableDescription { + name: string; + description: string; +} + +export interface ResolvedAIVariable { + variable: AIVariable; + value: string; +} + +export interface AIVariableResolutionRequest { + variable: AIVariable; + arg?: string; +} + +export interface AIVariableContext { +} + +export type AIVariableArg = string | { variable: string, arg?: string } | AIVariableResolutionRequest; + +export interface AIVariableResolver { + canResolve(request: AIVariableResolutionRequest, context: AIVariableContext): MaybePromise, + resolve(request: AIVariableResolutionRequest, context: AIVariableContext): Promise; +} + +export const AIVariableService = Symbol('AIVariableService'); +export interface AIVariableService { + hasVariable(name: string): boolean; + getVariable(name: string): Readonly | undefined; + getVariables(): Readonly[]; + unregisterVariable(name: string): void; + readonly onDidChangeVariables: Event; + + registerResolver(variable: AIVariable, resolver: AIVariableResolver): Disposable; + unregisterResolver(variable: AIVariable, resolver: AIVariableResolver): void; + getResolver(name: string, arg: string | undefined, context: AIVariableContext): Promise; + + resolveVariable(variable: AIVariableArg, context: AIVariableContext): Promise; +} + +export const AIVariableContribution = Symbol('AIVariableContribution'); +export interface AIVariableContribution { + registerVariables(service: AIVariableService): void; +} + +@injectable() +export class DefaultAIVariableService implements AIVariableService { + protected variables = new Map(); + protected resolvers = new Map(); + + protected readonly onDidChangeVariablesEmitter = new Emitter(); + readonly onDidChangeVariables: Event = this.onDidChangeVariablesEmitter.event; + + @inject(ILogger) protected logger: ILogger; + + constructor( + @inject(ContributionProvider) @named(AIVariableContribution) + protected readonly contributionProvider: ContributionProvider + ) { + } + + protected initContributions(): void { + this.contributionProvider.getContributions().forEach(contribution => contribution.registerVariables(this)); + } + + protected getKey(name: string): string { + return `${name.toLowerCase()}`; + } + + async getResolver(name: string, arg: string | undefined, context: AIVariableContext): Promise { + const resolvers = await this.prioritize(name, arg, context); + return resolvers[0]; + } + + protected getResolvers(name: string): AIVariableResolver[] { + return this.resolvers.get(this.getKey(name)) ?? []; + } + + protected async prioritize(name: string, arg: string | undefined, context: AIVariableContext): Promise { + const variable = this.getVariable(name); + if (!variable) { + return []; + } + const prioritized = await Prioritizeable.prioritizeAll(this.getResolvers(name), async resolver => { + try { + return await resolver.canResolve({ variable, arg }, context); + } catch { + return 0; + } + }); + return prioritized.map(p => p.value); + } + + hasVariable(name: string): boolean { + return !!this.getVariable(name); + } + + getVariable(name: string): Readonly | undefined { + return this.variables.get(this.getKey(name)); + } + + getVariables(): Readonly[] { + return [...this.variables.values()]; + } + + registerResolver(variable: AIVariable, resolver: AIVariableResolver): Disposable { + const key = this.getKey(variable.name); + if (!this.variables.get(key)) { + this.variables.set(key, variable); + this.onDidChangeVariablesEmitter.fire(); + } + const resolvers = this.resolvers.get(key) ?? []; + resolvers.push(resolver); + this.resolvers.set(key, resolvers); + return Disposable.create(() => this.unregisterResolver(variable, resolver)); + } + + unregisterResolver(variable: AIVariable, resolver: AIVariableResolver): void { + const key = this.getKey(variable.name); + const registeredResolvers = this.resolvers.get(key); + registeredResolvers?.splice(registeredResolvers.indexOf(resolver), 1); + if (registeredResolvers?.length === 0) { + this.unregisterVariable(variable.name); + } + } + + unregisterVariable(name: string): void { + this.variables.delete(this.getKey(name)); + this.resolvers.delete(this.getKey(name)); + this.onDidChangeVariablesEmitter.fire(); + } + + async resolveVariable(request: AIVariableArg, context: AIVariableContext): Promise { + const variableName = typeof request === 'string' ? request : typeof request.variable === 'string' ? request.variable : request.variable.name; + const variable = this.getVariable(variableName); + if (!variable) { + return undefined; + } + const arg = typeof request === 'string' ? undefined : request.arg; + const resolver = await this.getResolver(variableName, arg, context); + return resolver?.resolve({ variable, arg }, context); + } +} diff --git a/packages/ai-core/src/node/ai-core-backend-module.ts b/packages/ai-core/src/node/ai-core-backend-module.ts new file mode 100644 index 0000000000000..5c23c7d37f1ac --- /dev/null +++ b/packages/ai-core/src/node/ai-core-backend-module.ts @@ -0,0 +1,83 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { ContainerModule } from '@theia/core/shared/inversify'; +import { + LanguageModelFrontendDelegateImpl, + LanguageModelRegistryFrontendDelegateImpl, +} from './language-model-frontend-delegate'; +import { + ConnectionHandler, + RpcConnectionHandler, + bindContributionProvider, +} from '@theia/core'; +import { + LanguageModelRegistry, + LanguageModelProvider, + PromptService, + PromptServiceImpl, + LanguageModelDelegateClient, + LanguageModelFrontendDelegate, + LanguageModelRegistryFrontendDelegate, + languageModelDelegatePath, + languageModelRegistryDelegatePath, + LanguageModelRegistryClient +} from '../common'; +import { BackendLanguageModelRegistry } from './backend-language-model-registry'; + +export default new ContainerModule(bind => { + bindContributionProvider(bind, LanguageModelProvider); + bind(BackendLanguageModelRegistry).toSelf().inSingletonScope(); + bind(LanguageModelRegistry).toService(BackendLanguageModelRegistry); + + bind(LanguageModelRegistryFrontendDelegate).to(LanguageModelRegistryFrontendDelegateImpl).inSingletonScope(); + bind(ConnectionHandler) + .toDynamicValue( + ctx => + new RpcConnectionHandler( + languageModelRegistryDelegatePath, + client => { + const registryDelegate = ctx.container.get( + LanguageModelRegistryFrontendDelegate + ); + registryDelegate.setClient(client); + return registryDelegate; + } + ) + ) + .inSingletonScope(); + + bind(LanguageModelFrontendDelegateImpl).toSelf().inSingletonScope(); + bind(LanguageModelFrontendDelegate).toService(LanguageModelFrontendDelegateImpl); + bind(ConnectionHandler) + .toDynamicValue( + ({ container }) => + new RpcConnectionHandler( + languageModelDelegatePath, + client => { + const service = + container.get( + LanguageModelFrontendDelegateImpl + ); + service.setClient(client); + return service; + } + ) + ) + .inSingletonScope(); + + bind(PromptServiceImpl).toSelf().inSingletonScope(); + bind(PromptService).toService(PromptServiceImpl); +}); diff --git a/packages/ai-core/src/node/backend-language-model-registry.ts b/packages/ai-core/src/node/backend-language-model-registry.ts new file mode 100644 index 0000000000000..7bb23f9356cb2 --- /dev/null +++ b/packages/ai-core/src/node/backend-language-model-registry.ts @@ -0,0 +1,60 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { injectable } from '@theia/core/shared/inversify'; +import { DefaultLanguageModelRegistryImpl, LanguageModel, LanguageModelMetaData, LanguageModelRegistryClient } from '../common'; + +/** + * Notifies a client whenever a model is added or removed + */ +@injectable() +export class BackendLanguageModelRegistry extends DefaultLanguageModelRegistryImpl { + + private client: LanguageModelRegistryClient | undefined; + + setClient(client: LanguageModelRegistryClient): void { + this.client = client; + } + + override addLanguageModels(models: LanguageModel[]): void { + const modelsLength = this.languageModels.length; + super.addLanguageModels(models); + // only notify for models which were really added + for (let i = modelsLength; i < this.languageModels.length; i++) { + this.client?.languageModelAdded(this.mapToMetaData(this.languageModels[i])); + } + } + + override removeLanguageModels(ids: string[]): void { + super.removeLanguageModels(ids); + for (const id of ids) { + this.client?.languageModelRemoved(id); + } + } + + mapToMetaData(model: LanguageModel): LanguageModelMetaData { + return { + id: model.id, + providerId: model.providerId, + name: model.name, + vendor: model.vendor, + version: model.version, + family: model.family, + maxInputTokens: model.maxInputTokens, + maxOutputTokens: model.maxOutputTokens, + }; + } +} diff --git a/packages/ai-core/src/node/language-model-frontend-delegate.ts b/packages/ai-core/src/node/language-model-frontend-delegate.ts new file mode 100644 index 0000000000000..00388a86ba161 --- /dev/null +++ b/packages/ai-core/src/node/language-model-frontend-delegate.ts @@ -0,0 +1,115 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { inject, injectable } from '@theia/core/shared/inversify'; +import { CancellationTokenSource, ILogger, generateUuid } from '@theia/core'; +import { + LanguageModelMetaData, + LanguageModelRegistry, + LanguageModelRequest, + isLanguageModelStreamResponse, + isLanguageModelTextResponse, + LanguageModelStreamResponsePart, + LanguageModelDelegateClient, + LanguageModelFrontendDelegate, + LanguageModelRegistryFrontendDelegate, + LanguageModelResponseDelegate, + LanguageModelRegistryClient, + isLanguageModelParsedResponse, +} from '../common'; +import { BackendLanguageModelRegistry } from './backend-language-model-registry'; + +@injectable() +export class LanguageModelRegistryFrontendDelegateImpl implements LanguageModelRegistryFrontendDelegate { + + @inject(LanguageModelRegistry) + private registry: BackendLanguageModelRegistry; + + setClient(client: LanguageModelRegistryClient): void { + this.registry.setClient(client); + } + + async getLanguageModelDescriptions(): Promise { + return (await this.registry.getLanguageModels()).map(model => this.registry.mapToMetaData(model)); + } +} + +@injectable() +export class LanguageModelFrontendDelegateImpl implements LanguageModelFrontendDelegate { + + @inject(LanguageModelRegistry) + private registry: LanguageModelRegistry; + + @inject(ILogger) + private logger: ILogger; + + private frontendDelegateClient: LanguageModelDelegateClient; + private requestCancellationTokenMap: Map = new Map(); + + setClient(client: LanguageModelDelegateClient): void { + this.frontendDelegateClient = client; + } + + cancel(requestId: string): void { + this.requestCancellationTokenMap.get(requestId)?.cancel(); + } + + async request( + modelId: string, + request: LanguageModelRequest, + requestId: string + ): Promise { + const model = await this.registry.getLanguageModel(modelId); + if (!model) { + throw new Error( + `Request was sent to non-existent language model ${modelId}` + ); + } + request.tools?.forEach(tool => { + tool.handler = async args_string => this.frontendDelegateClient.toolCall(requestId, tool.id, args_string); + }); + if (request.cancellationToken) { + const tokenSource = new CancellationTokenSource(); + request.cancellationToken = tokenSource.token; + this.requestCancellationTokenMap.set(requestId, tokenSource); + } + const response = await model.request(request); + if (isLanguageModelTextResponse(response) || isLanguageModelParsedResponse(response)) { + return response; + } + if (isLanguageModelStreamResponse(response)) { + const delegate = { + streamId: generateUuid(), + }; + this.sendTokens(delegate.streamId, response.stream); + return delegate; + } + this.logger.error( + `Received unexpected response from language model ${modelId}. Trying to continue without touching the response.`, + response + ); + return response; + } + + protected sendTokens(id: string, stream: AsyncIterable): void { + (async () => { + for await (const token of stream) { + this.frontendDelegateClient.send(id, token); + } + this.frontendDelegateClient.send(id, undefined); + })(); + } +} diff --git a/packages/ai-core/tsconfig.json b/packages/ai-core/tsconfig.json new file mode 100644 index 0000000000000..4ee37e165b355 --- /dev/null +++ b/packages/ai-core/tsconfig.json @@ -0,0 +1,34 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../core" + }, + { + "path": "../editor" + }, + { + "path": "../filesystem" + }, + { + "path": "../monaco" + }, + { + "path": "../output" + }, + { + "path": "../variable-resolver" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-history/.eslintrc.js b/packages/ai-history/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-history/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-history/README.md b/packages/ai-history/README.md new file mode 100644 index 0000000000000..6992a4ae49041 --- /dev/null +++ b/packages/ai-history/README.md @@ -0,0 +1,31 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - AI History EXTENSION

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

Request

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

Response

+
{entry.response}
+
+ )} +
+
+ Timestamp: {new Date(entry.timestamp).toLocaleString()} + {entry.responseTime && Response Time: {entry.responseTime}ms} +
+
+); diff --git a/packages/ai-history/src/browser/ai-history-contribution.ts b/packages/ai-history/src/browser/ai-history-contribution.ts new file mode 100644 index 0000000000000..f33d71cb6793d --- /dev/null +++ b/packages/ai-history/src/browser/ai-history-contribution.ts @@ -0,0 +1,52 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { FrontendApplication } from '@theia/core/lib/browser'; +import { AIViewContribution } from '@theia/ai-core/lib/browser'; +import { injectable } from '@theia/core/shared/inversify'; +import { AIHistoryView } from './ai-history-widget'; +import { Command, CommandRegistry } from '@theia/core'; + +export const AI_HISTORY_TOGGLE_COMMAND_ID = 'aiHistory:toggle'; +export const OPEN_AI_HISTORY_VIEW = Command.toLocalizedCommand({ + id: 'aiHistory:open', + label: 'Open AI History view', +}); + +@injectable() +export class AIHistoryViewContribution extends AIViewContribution { + constructor() { + super({ + widgetId: AIHistoryView.ID, + widgetName: AIHistoryView.LABEL, + defaultWidgetOptions: { + area: 'bottom', + rank: 100 + }, + toggleCommandId: AI_HISTORY_TOGGLE_COMMAND_ID, + }); + } + + async initializeLayout(_app: FrontendApplication): Promise { + await this.openView(); + } + + override registerCommands(commands: CommandRegistry): void { + super.registerCommands(commands); + commands.registerCommand(OPEN_AI_HISTORY_VIEW, { + execute: () => this.openView({ activate: true }), + }); + } +} diff --git a/packages/ai-history/src/browser/ai-history-frontend-module.ts b/packages/ai-history/src/browser/ai-history-frontend-module.ts new file mode 100644 index 0000000000000..97c6694fa7eef --- /dev/null +++ b/packages/ai-history/src/browser/ai-history-frontend-module.ts @@ -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 { CommunicationRecordingService } from '@theia/ai-core'; +import { ContainerModule } from '@theia/core/shared/inversify'; +import { DefaultCommunicationRecordingService } from '../common/communication-recording-service'; +import { bindViewContribution, WidgetFactory } from '@theia/core/lib/browser'; +import { AIHistoryViewContribution } from './ai-history-contribution'; +import { AIHistoryView } from './ai-history-widget'; +import '../../src/browser/style/ai-history.css'; + +export default new ContainerModule(bind => { + bind(DefaultCommunicationRecordingService).toSelf().inSingletonScope(); + bind(CommunicationRecordingService).toService(DefaultCommunicationRecordingService); + + bindViewContribution(bind, AIHistoryViewContribution); + + bind(AIHistoryView).toSelf().inSingletonScope(); + bind(WidgetFactory).toDynamicValue(context => ({ + id: AIHistoryView.ID, + createWidget: () => context.container.get(AIHistoryView) + })).inSingletonScope(); +}); diff --git a/packages/ai-history/src/browser/ai-history-widget.tsx b/packages/ai-history/src/browser/ai-history-widget.tsx new file mode 100644 index 0000000000000..b7d849e980852 --- /dev/null +++ b/packages/ai-history/src/browser/ai-history-widget.tsx @@ -0,0 +1,96 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { Agent, AgentService, CommunicationRecordingService, CommunicationRequestEntry, CommunicationResponseEntry } from '@theia/ai-core'; +import { codicon, ReactWidget } from '@theia/core/lib/browser'; +import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; +import * as React from '@theia/core/shared/react'; +import { CommunicationCard } from './ai-history-communication-card'; +import { SelectComponent, SelectOption } from '@theia/core/lib/browser/widgets/select-component'; + +@injectable() +export class AIHistoryView extends ReactWidget { + @inject(CommunicationRecordingService) + protected recordingService: CommunicationRecordingService; + @inject(AgentService) + protected readonly agentService: AgentService; + + public static ID = 'ai-history-widget'; + static LABEL = '✨ AI Agent History [Experimental]'; + + protected selectedAgent?: Agent; + + constructor() { + super(); + this.id = AIHistoryView.ID; + this.title.label = AIHistoryView.LABEL; + this.title.caption = AIHistoryView.LABEL; + this.title.closable = true; + this.title.iconClass = codicon('history'); + } + + @postConstruct() + protected init(): void { + this.update(); + this.toDispose.push(this.recordingService.onDidRecordRequest(entry => this.historyContentUpdated(entry))); + this.toDispose.push(this.recordingService.onDidRecordResponse(entry => this.historyContentUpdated(entry))); + this.selectAgent(this.agentService.getAgents(true)[0]); + } + + protected selectAgent(agent: Agent | undefined): void { + this.selectedAgent = agent; + this.update(); + } + + protected historyContentUpdated(entry: CommunicationRequestEntry | CommunicationResponseEntry): void { + if (entry.agentId === this.selectedAgent?.id) { + this.update(); + } + } + + render(): React.ReactNode { + const selectionChange = (value: SelectOption) => { + this.selectedAgent = this.agentService.getAgents(true).find(agent => agent.id === value.value); + this.update(); + }; + return ( +
+ ({ value: agent.id, label: agent.name, description: agent.description }))} + onChange={selectionChange} + defaultValue={this.selectedAgent?.id} /> +
+ {this.renderHistory()} +
+
+ ); + } + + protected renderHistory(): React.ReactNode { + if (!this.selectedAgent) { + return
No agent selected.
; + } + const history = this.recordingService.getHistory(this.selectedAgent.id); + if (history.length === 0) { + return
No history available for the selected agent '{this.selectedAgent.name}'.
; + } + return history.map(entry => ); + } + + protected onClick(e: React.MouseEvent, agent: Agent): void { + e.stopPropagation(); + this.selectAgent(agent); + } +} diff --git a/packages/ai-history/src/browser/style/ai-history.css b/packages/ai-history/src/browser/style/ai-history.css new file mode 100644 index 0000000000000..fc72ab19494ae --- /dev/null +++ b/packages/ai-history/src/browser/style/ai-history.css @@ -0,0 +1,74 @@ +.agent-history-widget { + display: flex; + flex-direction: column; + align-items: center; +} + +.theia-select-component { + margin: 10px 0; + width: 80%; +} + +.agent-history { + width: calc(80% + 16px); + display: flex; + align-items: center; + flex-direction: column; +} + +.theia-card { + background-color: var(--theia-sideBar-background); + border: 1px solid var(--theia-sideBarSectionHeader-border); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + padding: 15px; + margin: 10px 0; + width: 100%; + box-sizing: border-box; +} + +.theia-card-meta { + display: flex; + justify-content: space-between; + font-size: 0.9em; + margin-bottom: var(--theia-ui-padding); + padding: var(--theia-ui-padding) 0; +} + +.theia-card-content { + color: var(--theia-font-color); + margin-bottom: 10px; +} + +.theia-card-content p { + margin: var(--theia-ui-padding) 0; +} + +.theia-card-request, .theia-card-response { + margin-bottom: 10px; +} + +.theia-card-request pre, +.theia-card-response pre { + font-family: monospace; + white-space: pre-wrap; + word-wrap: break-word; + background-color: var(--theia-sideBar-background); + margin: var(--theia-ui-padding) 0; +} + +.theia-card-request-id, +.theia-card-session-id, +.theia-card-timestamp, +.theia-card-response-time { + flex: 1; +} + +.theia-card-request-id, +.theia-card-timestamp { + text-align: left; +} + +.theia-card-session-id, +.theia-card-response-time { + text-align: right; +} diff --git a/packages/ai-history/src/common/communication-recording-service.spec.ts b/packages/ai-history/src/common/communication-recording-service.spec.ts new file mode 100644 index 0000000000000..d384cf41a3063 --- /dev/null +++ b/packages/ai-history/src/common/communication-recording-service.spec.ts @@ -0,0 +1,34 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { DefaultCommunicationRecordingService } from './communication-recording-service'; +import { expect } from 'chai'; + +describe('DefaultCommunicationRecordingService', () => { + + it('records history', () => { + const service = new DefaultCommunicationRecordingService(); + service.recordRequest({ agentId: 'agent', requestId: '1', sessionId: '1', timestamp: 100, request: 'dummy request' }); + + const history1 = service.getHistory('agent'); + expect(history1[0].request).to.eq('dummy request'); + + service.recordResponse({ agentId: 'agent', requestId: '1', sessionId: '1', timestamp: 200, response: 'dummy response' }); + const history2 = service.getHistory('agent'); + expect(history2[0].request).to.eq('dummy request'); + expect(history2[0].response).to.eq('dummy response'); + }); + +}); diff --git a/packages/ai-history/src/common/communication-recording-service.ts b/packages/ai-history/src/common/communication-recording-service.ts new file mode 100644 index 0000000000000..579834999a67c --- /dev/null +++ b/packages/ai-history/src/common/communication-recording-service.ts @@ -0,0 +1,60 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** +import { CommunicationHistory, CommunicationHistoryEntry, CommunicationRecordingService, CommunicationRequestEntry, CommunicationResponseEntry } from '@theia/ai-core'; +import { Emitter, Event } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; + +@injectable() +export class DefaultCommunicationRecordingService implements CommunicationRecordingService { + + protected onDidRecordRequestEmitter = new Emitter(); + readonly onDidRecordRequest: Event = this.onDidRecordRequestEmitter.event; + + protected onDidRecordResponseEmitter = new Emitter(); + readonly onDidRecordResponse: Event = this.onDidRecordResponseEmitter.event; + + protected history: Map = new Map(); + + getHistory(agentId: string): CommunicationHistory { + return this.history.get(agentId) || []; + } + + recordRequest(requestEntry: CommunicationHistoryEntry): void { + console.log('Recording request:', requestEntry.request); + if (this.history.has(requestEntry.agentId)) { + this.history.get(requestEntry.agentId)?.push(requestEntry); + } else { + this.history.set(requestEntry.agentId, [requestEntry]); + } + this.onDidRecordRequestEmitter.fire(requestEntry); + } + + recordResponse(responseEntry: CommunicationHistoryEntry): void { + console.log('Recording response:', responseEntry.response); + if (this.history.has(responseEntry.agentId)) { + const entry = this.history.get(responseEntry.agentId); + if (entry) { + const matchingRequest = entry.find(e => e.requestId === responseEntry.requestId); + if (!matchingRequest) { + throw Error('No matching request found for response'); + } + matchingRequest.response = responseEntry.response; + matchingRequest.responseTime = responseEntry.timestamp - matchingRequest.timestamp; + this.onDidRecordResponseEmitter.fire(responseEntry); + } + } + } +} diff --git a/packages/ai-history/src/common/index.ts b/packages/ai-history/src/common/index.ts new file mode 100644 index 0000000000000..52a9128e1cb3f --- /dev/null +++ b/packages/ai-history/src/common/index.ts @@ -0,0 +1,17 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +export * from './communication-recording-service'; diff --git a/packages/ai-history/tsconfig.json b/packages/ai-history/tsconfig.json new file mode 100644 index 0000000000000..548b369565b41 --- /dev/null +++ b/packages/ai-history/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../configs/base.tsconfig", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "lib" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../ai-core" + }, + { + "path": "../core" + }, + { + "path": "../filesystem" + }, + { + "path": "../output" + }, + { + "path": "../workspace" + } + ] +} diff --git a/packages/ai-openai/.eslintrc.js b/packages/ai-openai/.eslintrc.js new file mode 100644 index 0000000000000..13089943582b6 --- /dev/null +++ b/packages/ai-openai/.eslintrc.js @@ -0,0 +1,10 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + extends: [ + '../../configs/build.eslintrc.json' + ], + parserOptions: { + tsconfigRootDir: __dirname, + project: 'tsconfig.json' + } +}; diff --git a/packages/ai-openai/README.md b/packages/ai-openai/README.md new file mode 100644 index 0000000000000..679035fe6b435 --- /dev/null +++ b/packages/ai-openai/README.md @@ -0,0 +1,31 @@ +
+ +
+ +theia-ext-logo + +

ECLIPSE THEIA - Open AI EXTENSION

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

ECLIPSE THEIA - AI Terminal EXTENSION

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

ECLIPSE THEIA - AI Workspace Agent EXTENSION

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

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

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