deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/ai-chat-ui/.eslintrc.js
Normal file
10
packages/ai-chat-ui/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../../configs/build.eslintrc.json'
|
||||
],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: 'tsconfig.json'
|
||||
}
|
||||
};
|
||||
67
packages/ai-chat-ui/README.md
Normal file
67
packages/ai-chat-ui/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
<div align='center'>
|
||||
|
||||
<br />
|
||||
|
||||
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
|
||||
|
||||
<h2>ECLIPSE THEIA - AI CHAT UI EXTENSION</h2>
|
||||
|
||||
<hr />
|
||||
|
||||
</div>
|
||||
|
||||
## 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`.
|
||||
|
||||
## Custom Tool Renderers
|
||||
|
||||
To create a specialized renderer for a specific tool, implement the `ChatResponsePartRenderer` interface with a higher priority than the default `ToolCallPartRenderer` (priority `10`):
|
||||
|
||||
```typescript
|
||||
@injectable()
|
||||
export class MyToolRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (ToolCallChatResponseContent.is(response) && response.name === 'my_tool_id') {
|
||||
return 15;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
|
||||
// Custom rendering logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For custom confirmation UIs, use the `ToolConfirmationActions` component to reuse the standard Allow/Deny buttons with dropdown options:
|
||||
|
||||
```typescript
|
||||
import { ToolConfirmationActions } from '@theia/ai-chat-ui/lib/browser/chat-response-renderer/tool-confirmation';
|
||||
|
||||
<ToolConfirmationActions
|
||||
toolName="my_tool"
|
||||
toolRequest={toolRequest}
|
||||
onAllow={(mode) => response.confirm()}
|
||||
onDeny={(mode) => response.deny()}
|
||||
/>
|
||||
```
|
||||
|
||||
## Additional Information
|
||||
|
||||
- [API documentation for `@theia/ai-chat-ui`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_ai-chat-ui.html)
|
||||
- [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>
|
||||
60
packages/ai-chat-ui/package.json
Normal file
60
packages/ai-chat-ui/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@theia/ai-chat-ui",
|
||||
"version": "1.68.0",
|
||||
"description": "Theia - AI Chat UI Extension",
|
||||
"dependencies": {
|
||||
"@theia/ai-chat": "1.68.0",
|
||||
"@theia/ai-core": "1.68.0",
|
||||
"@theia/core": "1.68.0",
|
||||
"@theia/editor": "1.68.0",
|
||||
"@theia/editor-preview": "1.68.0",
|
||||
"@theia/filesystem": "1.68.0",
|
||||
"@theia/monaco": "1.68.0",
|
||||
"@theia/monaco-editor-core": "1.96.302",
|
||||
"@theia/preferences": "1.68.0",
|
||||
"@theia/workspace": "1.68.0",
|
||||
"tslib": "^2.6.2",
|
||||
"uuid": "^9.0.1",
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"theiaExtensions": [
|
||||
{
|
||||
"frontend": "lib/browser/ai-chat-ui-frontend-module",
|
||||
"secondaryWindow": "lib/browser/ai-chat-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.68.0"
|
||||
},
|
||||
"nyc": {
|
||||
"extends": "../../configs/nyc.json"
|
||||
},
|
||||
"gitHead": "21358137e41342742707f660b8e222f940a27652"
|
||||
}
|
||||
605
packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts
Normal file
605
packages/ai-chat-ui/src/browser/ai-chat-ui-contribution.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 { CommandRegistry, Emitter, isOSX, MessageService, nls, PreferenceService, QuickInputButton, QuickInputService, QuickPickItem } from '@theia/core';
|
||||
import { ILogger } from '@theia/core/lib/common/logger';
|
||||
import { FrontendApplicationContribution, Widget } from '@theia/core/lib/browser';
|
||||
import {
|
||||
AI_CHAT_NEW_CHAT_WINDOW_COMMAND,
|
||||
AI_CHAT_SHOW_CHATS_COMMAND,
|
||||
ChatCommands
|
||||
} from './chat-view-commands';
|
||||
import { ChatAgent, ChatAgentLocation, ChatService, isActiveSessionChangedEvent } from '@theia/ai-chat';
|
||||
import { ChatAgentService } from '@theia/ai-chat/lib/common/chat-agent-service';
|
||||
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
|
||||
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';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import * as locales from 'date-fns/locale';
|
||||
import { AI_SHOW_SETTINGS_COMMAND, AIActivationService, ENABLE_AI_CONTEXT_KEY } from '@theia/ai-core/lib/browser';
|
||||
import { ChatNodeToolbarCommands } from './chat-node-toolbar-action-contribution';
|
||||
import { isEditableRequestNode, isResponseNode, type EditableRequestNode, type ResponseNode } from './chat-tree-view';
|
||||
import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable';
|
||||
import { TaskContextService } from '@theia/ai-chat/lib/browser/task-context-service';
|
||||
import { SESSION_STORAGE_PREF } from '@theia/ai-chat/lib/common/ai-chat-preferences';
|
||||
|
||||
export const AI_CHAT_TOGGLE_COMMAND_ID = 'aiChat:toggle';
|
||||
|
||||
@injectable()
|
||||
export class AIChatContribution extends AbstractViewContribution<ChatViewWidget> implements TabBarToolbarContribution, FrontendApplicationContribution {
|
||||
|
||||
@inject(ChatService)
|
||||
protected readonly chatService: ChatService;
|
||||
@inject(QuickInputService)
|
||||
protected readonly quickInputService: QuickInputService;
|
||||
@inject(TaskContextService)
|
||||
protected readonly taskContextService: TaskContextService;
|
||||
@inject(MessageService)
|
||||
protected readonly messageService: MessageService;
|
||||
@inject(ChatAgentService)
|
||||
protected readonly chatAgentService: ChatAgentService;
|
||||
@inject(EditorManager)
|
||||
protected readonly editorManager: EditorManager;
|
||||
@inject(AIActivationService)
|
||||
protected readonly activationService: AIActivationService;
|
||||
@inject(ILogger) @named('AIChatContribution')
|
||||
protected readonly logger: ILogger;
|
||||
@inject(PreferenceService)
|
||||
protected readonly preferenceService: PreferenceService;
|
||||
|
||||
/**
|
||||
* Store whether there are persisted sessions to make this information available in
|
||||
* command enablement checks which are synchronous.
|
||||
*/
|
||||
protected hasPersistedSessions = false;
|
||||
|
||||
protected static readonly RENAME_CHAT_BUTTON: QuickInputButton = {
|
||||
iconClass: 'codicon-edit',
|
||||
tooltip: nls.localize('theia/ai/chat-ui/renameChat', 'Rename Chat'),
|
||||
};
|
||||
protected static readonly REMOVE_CHAT_BUTTON: QuickInputButton = {
|
||||
iconClass: 'codicon-remove-close',
|
||||
tooltip: nls.localize('theia/ai/chat-ui/removeChat', 'Remove Chat'),
|
||||
};
|
||||
|
||||
@inject(SecondaryWindowHandler)
|
||||
protected readonly secondaryWindowHandler: SecondaryWindowHandler;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
widgetId: ChatViewWidget.ID,
|
||||
widgetName: ChatViewWidget.LABEL,
|
||||
defaultWidgetOptions: {
|
||||
area: 'right',
|
||||
rank: 100
|
||||
},
|
||||
toggleCommandId: AI_CHAT_TOGGLE_COMMAND_ID,
|
||||
toggleKeybinding: isOSX ? 'ctrl+cmd+i' : 'ctrl+alt+i'
|
||||
});
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
initialize(): void {
|
||||
this.chatService.onSessionEvent(event => {
|
||||
if (!isActiveSessionChangedEvent(event)) {
|
||||
return;
|
||||
}
|
||||
if (event.focus) {
|
||||
this.openView({ activate: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Re-check persisted sessions when storage preferences change
|
||||
this.preferenceService.onPreferenceChanged(event => {
|
||||
if (event.preferenceName === SESSION_STORAGE_PREF) {
|
||||
this.checkPersistedSessions();
|
||||
}
|
||||
});
|
||||
|
||||
this.checkPersistedSessions();
|
||||
}
|
||||
|
||||
async onStart(): Promise<void> {
|
||||
// Auto-open AI Chat panel on startup
|
||||
await this.openView({ activate: false, reveal: true });
|
||||
}
|
||||
|
||||
protected async checkPersistedSessions(): Promise<void> {
|
||||
try {
|
||||
this.hasPersistedSessions = await this.chatService.hasPersistedSessions();
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to check persisted AI sessions', e);
|
||||
this.hasPersistedSessions = false;
|
||||
}
|
||||
}
|
||||
|
||||
override registerCommands(registry: CommandRegistry): void {
|
||||
super.registerCommands(registry);
|
||||
registry.registerCommand(ChatCommands.SCROLL_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.SCROLL_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(AI_CHAT_NEW_CHAT_WINDOW_COMMAND, {
|
||||
execute: () => this.openView().then(() => this.chatService.createSession(ChatAgentLocation.Panel, { focus: true })),
|
||||
isVisible: widget => this.activationService.isActive,
|
||||
isEnabled: widget => this.activationService.isActive,
|
||||
});
|
||||
registry.registerCommand(ChatCommands.AI_CHAT_NEW_WITH_TASK_CONTEXT, {
|
||||
execute: async () => {
|
||||
const activeSession = this.chatService.getActiveSession();
|
||||
const id = await this.summarizeActiveSession();
|
||||
if (!id || !activeSession) { return; }
|
||||
const newSession = this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }, activeSession.pinnedAgent);
|
||||
const summaryVariable = { variable: TASK_CONTEXT_VARIABLE, arg: id };
|
||||
newSession.model.context.addVariables(summaryVariable);
|
||||
},
|
||||
isVisible: () => false
|
||||
});
|
||||
registry.registerCommand(ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION, {
|
||||
execute: async () => this.summarizeActiveSession(),
|
||||
isVisible: widget => {
|
||||
if (!this.activationService.isActive) { return false; }
|
||||
if (widget && !this.withWidget(widget)) { return false; }
|
||||
const activeSession = this.chatService.getActiveSession();
|
||||
return activeSession?.model.location === ChatAgentLocation.Panel
|
||||
&& !this.taskContextService.hasSummary(activeSession);
|
||||
},
|
||||
isEnabled: widget => {
|
||||
if (!this.activationService.isActive) { return false; }
|
||||
if (widget && !this.withWidget(widget)) { return false; }
|
||||
const activeSession = this.chatService.getActiveSession();
|
||||
return activeSession?.model.location === ChatAgentLocation.Panel
|
||||
&& !activeSession.model.isEmpty()
|
||||
&& !this.taskContextService.hasSummary(activeSession);
|
||||
}
|
||||
});
|
||||
registry.registerCommand(ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION, {
|
||||
execute: async () => {
|
||||
const id = await this.summarizeActiveSession();
|
||||
if (!id) { return; }
|
||||
await this.taskContextService.open(id);
|
||||
},
|
||||
isVisible: widget => {
|
||||
if (!this.activationService.isActive) { return false; }
|
||||
if (widget && !this.withWidget(widget)) { return false; }
|
||||
const activeSession = this.chatService.getActiveSession();
|
||||
return !!activeSession && this.taskContextService.hasSummary(activeSession);
|
||||
},
|
||||
isEnabled: widget => {
|
||||
if (!this.activationService.isActive) { return false; }
|
||||
return this.withWidget(widget, () => true);
|
||||
}
|
||||
});
|
||||
registry.registerCommand(ChatCommands.AI_CHAT_INITIATE_SESSION_WITH_TASK_CONTEXT, {
|
||||
execute: async () => {
|
||||
const selectedContextId = await this.selectTaskContextWithMarking();
|
||||
if (!selectedContextId) { return; }
|
||||
const selectedAgent = await this.selectAgent('Coder');
|
||||
if (!selectedAgent) { return; }
|
||||
const newSession = this.chatService.createSession(ChatAgentLocation.Panel, { focus: true }, selectedAgent);
|
||||
newSession.model.context.addVariables({ variable: TASK_CONTEXT_VARIABLE, arg: selectedContextId });
|
||||
},
|
||||
isVisible: () => this.activationService.isActive,
|
||||
isEnabled: () => this.activationService.isActive
|
||||
});
|
||||
registry.registerCommand(AI_CHAT_SHOW_CHATS_COMMAND, {
|
||||
execute: async () => {
|
||||
await this.openView();
|
||||
return this.selectChat();
|
||||
},
|
||||
isEnabled: () => {
|
||||
if (!this.activationService.isActive) {
|
||||
return false;
|
||||
}
|
||||
// Enable if there are active sessions with titles OR persisted sessions
|
||||
return this.chatService.getSessions().some(session => !!session.title) || this.hasPersistedSessions;
|
||||
},
|
||||
isVisible: () => this.activationService.isActive
|
||||
});
|
||||
registry.registerCommand(ChatNodeToolbarCommands.EDIT, {
|
||||
isEnabled: node => isEditableRequestNode(node) && !node.request.isEditing,
|
||||
isVisible: node => isEditableRequestNode(node) && !node.request.isEditing,
|
||||
execute: (node: EditableRequestNode) => {
|
||||
node.request.enableEdit();
|
||||
}
|
||||
});
|
||||
registry.registerCommand(ChatNodeToolbarCommands.CANCEL, {
|
||||
isEnabled: node => isEditableRequestNode(node) && node.request.isEditing,
|
||||
isVisible: node => isEditableRequestNode(node) && node.request.isEditing,
|
||||
execute: (node: EditableRequestNode) => {
|
||||
node.request.cancelEdit();
|
||||
}
|
||||
});
|
||||
registry.registerCommand(ChatNodeToolbarCommands.RETRY, {
|
||||
isEnabled: node => isResponseNode(node) && (node.response.isError || node.response.isCanceled),
|
||||
isVisible: node => isResponseNode(node) && (node.response.isError || node.response.isCanceled),
|
||||
execute: async (node: ResponseNode) => {
|
||||
try {
|
||||
// Get the session for this response node
|
||||
const session = this.chatService.getActiveSession();
|
||||
if (!session) {
|
||||
this.messageService.error(nls.localize('theia/ai/chat-ui/sessionNotFoundForRetry', 'Session not found for retry'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the request associated with this response
|
||||
const request = session.model.getRequests().find(req => req.response.id === node.response.id);
|
||||
if (!request) {
|
||||
this.messageService.error(nls.localize('theia/ai/chat-ui/requestNotFoundForRetry', 'Request not found for retry'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the same request again using the chat service
|
||||
await this.chatService.sendRequest(node.sessionId, request.request);
|
||||
} catch (error) {
|
||||
console.error('Failed to retry chat message:', error);
|
||||
this.messageService.error(nls.localize('theia/ai/chat-ui/failedToRetry', 'Failed to retry message'));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerToolbarItems(registry: TabBarToolbarRegistry): void {
|
||||
registry.registerItem({
|
||||
id: AI_CHAT_NEW_CHAT_WINDOW_COMMAND.id,
|
||||
command: AI_CHAT_NEW_CHAT_WINDOW_COMMAND.id,
|
||||
tooltip: AI_CHAT_NEW_CHAT_WINDOW_COMMAND.label,
|
||||
isVisible: widget => this.activationService.isActive && this.withWidget(widget),
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
registry.registerItem({
|
||||
id: AI_CHAT_SHOW_CHATS_COMMAND.id,
|
||||
command: AI_CHAT_SHOW_CHATS_COMMAND.id,
|
||||
tooltip: AI_CHAT_SHOW_CHATS_COMMAND.label,
|
||||
isVisible: widget => this.activationService.isActive && this.withWidget(widget),
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
registry.registerItem({
|
||||
id: 'chat-view.' + AI_SHOW_SETTINGS_COMMAND.id,
|
||||
command: AI_SHOW_SETTINGS_COMMAND.id,
|
||||
group: 'ai-settings',
|
||||
priority: 3,
|
||||
tooltip: nls.localize('theia/ai-chat-ui/open-settings-tooltip', 'Open AI settings...'),
|
||||
isVisible: widget => this.activationService.isActive && this.withWidget(widget),
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
const sessionSummarizibilityChangedEmitter = new Emitter<void>();
|
||||
this.taskContextService.onDidChange(() => sessionSummarizibilityChangedEmitter.fire());
|
||||
this.chatService.onSessionEvent(event => event.type === 'activeChange' && sessionSummarizibilityChangedEmitter.fire());
|
||||
this.activationService.onDidChangeActiveStatus(() => sessionSummarizibilityChangedEmitter.fire());
|
||||
registry.registerItem({
|
||||
id: 'chat-view.' + ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION.id,
|
||||
command: ChatCommands.AI_CHAT_SUMMARIZE_CURRENT_SESSION.id,
|
||||
onDidChange: sessionSummarizibilityChangedEmitter.event,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
registry.registerItem({
|
||||
id: 'chat-view.' + ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION.id,
|
||||
command: ChatCommands.AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION.id,
|
||||
onDidChange: sessionSummarizibilityChangedEmitter.event,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
}
|
||||
|
||||
protected async selectChat(sessionId?: string): Promise<void> {
|
||||
let activeSessionId = sessionId;
|
||||
|
||||
if (!activeSessionId) {
|
||||
const item = await this.askForChatSession();
|
||||
if (item === undefined) {
|
||||
return;
|
||||
}
|
||||
activeSessionId = item.id;
|
||||
}
|
||||
|
||||
this.chatService.setActiveSession(activeSessionId!, { focus: true });
|
||||
}
|
||||
|
||||
protected async askForChatSession(): Promise<QuickPickItem | undefined> {
|
||||
const getItems = async (): Promise<QuickPickItem[]> => {
|
||||
const activeSessions = this.chatService.getSessions()
|
||||
.filter(session => session.title)
|
||||
.map(session => ({
|
||||
session,
|
||||
isActive: true,
|
||||
lastDate: session.lastInteraction ? session.lastInteraction.getTime() : 0
|
||||
}));
|
||||
|
||||
// Try to load persisted sessions, but don't fail if it doesn't work
|
||||
let persistedSessions: Array<{ metadata: { sessionId: string; title: string; saveDate: number }; isActive: false; lastDate: number }> = [];
|
||||
try {
|
||||
const persistedIndex = await this.chatService.getPersistedSessions();
|
||||
const activeIds = new Set(activeSessions.map(s => s.session.id));
|
||||
persistedSessions = Object.values(persistedIndex)
|
||||
.filter(metadata => !activeIds.has(metadata.sessionId))
|
||||
.map(metadata => ({
|
||||
metadata,
|
||||
isActive: false,
|
||||
lastDate: metadata.saveDate
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load persisted sessions, showing only active sessions', error);
|
||||
// Continue with just active sessions
|
||||
}
|
||||
|
||||
// Combine and sort by last interaction/message date
|
||||
const allSessions = [
|
||||
...activeSessions.map(s => ({
|
||||
isActive: true,
|
||||
id: s.session.id,
|
||||
title: s.session.title!,
|
||||
lastDate: s.lastDate,
|
||||
firstRequestText: s.session.model.getRequests().at(0)?.request.text
|
||||
})),
|
||||
...persistedSessions.map(s => ({
|
||||
isActive: false,
|
||||
id: s.metadata.sessionId,
|
||||
title: s.metadata.title,
|
||||
lastDate: s.lastDate,
|
||||
firstRequestText: undefined
|
||||
}))
|
||||
].sort((a, b) => b.lastDate - a.lastDate);
|
||||
|
||||
return allSessions.map(session => {
|
||||
// Add icon for persisted sessions to visually distinguish them
|
||||
const icon = session.isActive ? '' : '$(archive) ';
|
||||
const label = `${icon}${session.title}`;
|
||||
|
||||
return <QuickPickItem>({
|
||||
label,
|
||||
description: formatDistance(new Date(session.lastDate), new Date(), { addSuffix: false, locale: getDateFnsLocale() }),
|
||||
detail: session.firstRequestText || (session.isActive ? undefined : nls.localize('theia/ai/chat-ui/persistedSession', 'Persisted session (click to restore)')),
|
||||
id: session.id,
|
||||
buttons: [AIChatContribution.RENAME_CHAT_BUTTON, AIChatContribution.REMOVE_CHAT_BUTTON]
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const defer = new Deferred<QuickPickItem | undefined>();
|
||||
const quickPick = this.quickInputService.createQuickPick();
|
||||
quickPick.placeholder = nls.localize('theia/ai/chat-ui/selectChat', 'Select chat');
|
||||
quickPick.canSelectMany = false;
|
||||
quickPick.busy = true;
|
||||
quickPick.show();
|
||||
|
||||
// Load items asynchronously
|
||||
getItems().then(items => {
|
||||
quickPick.items = items;
|
||||
quickPick.busy = false;
|
||||
}).catch(error => {
|
||||
this.logger.error('Failed to load chat sessions', error);
|
||||
quickPick.busy = false;
|
||||
quickPick.placeholder = nls.localize('theia/ai/chat-ui/failedToLoadChats', 'Failed to load chat sessions');
|
||||
});
|
||||
|
||||
quickPick.onDidTriggerItemButton(async context => {
|
||||
if (context.button === AIChatContribution.RENAME_CHAT_BUTTON) {
|
||||
quickPick.hide();
|
||||
this.quickInputService.input({
|
||||
placeHolder: nls.localize('theia/ai/chat-ui/enterChatName', 'Enter chat name')
|
||||
}).then(name => {
|
||||
if (name && name.length > 0) {
|
||||
const session = this.chatService.getSession(context.item.id!);
|
||||
if (session) {
|
||||
session.title = name;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (context.button === AIChatContribution.REMOVE_CHAT_BUTTON) {
|
||||
const activeSession = this.chatService.getActiveSession();
|
||||
|
||||
// Wait for deletion to complete before refreshing the list
|
||||
this.chatService.deleteSession(context.item.id!).then(() => getItems()).then(items => {
|
||||
quickPick.items = items;
|
||||
if (items.length === 0) {
|
||||
quickPick.hide();
|
||||
}
|
||||
// Update persisted sessions flag after deletion
|
||||
this.checkPersistedSessions();
|
||||
|
||||
if (activeSession && activeSession.id === context.item.id) {
|
||||
this.chatService.createSession(ChatAgentLocation.Panel, {
|
||||
// Auto-focus only when the quick pick is no longer visible
|
||||
focus: items.length === 0
|
||||
});
|
||||
}
|
||||
}).catch(error => {
|
||||
this.logger.error('Failed to delete chat session', error);
|
||||
this.messageService.error(nls.localize('theia/ai/chat-ui/failedToDeleteSession', 'Failed to delete chat session'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
quickPick.onDidAccept(async () => {
|
||||
const selectedItem = quickPick.selectedItems[0];
|
||||
if (selectedItem) {
|
||||
// Restore session if not already loaded
|
||||
const session = this.chatService.getSession(selectedItem.id!);
|
||||
if (!session) {
|
||||
try {
|
||||
await this.chatService.getOrRestoreSession(selectedItem.id!);
|
||||
// Update persisted sessions flag after restoration
|
||||
this.checkPersistedSessions();
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to restore chat session', error);
|
||||
this.messageService.error(nls.localize('theia/ai/chat-ui/failedToRestoreSession', 'Failed to restore chat session'));
|
||||
defer.resolve(undefined);
|
||||
quickPick.hide();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
defer.resolve(selectedItem);
|
||||
quickPick.hide();
|
||||
});
|
||||
|
||||
quickPick.onDidHide(() => defer.resolve(undefined));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
protected async summarizeActiveSession(): Promise<string | undefined> {
|
||||
const activeSession = this.chatService.getActiveSession();
|
||||
if (!activeSession) { return; }
|
||||
return this.taskContextService.summarize(activeSession).catch(err => {
|
||||
console.warn('Error while summarizing session:', err);
|
||||
this.messageService.error(nls.localize('theia/ai/chat-ui/unableToSummarizeCurrentSession',
|
||||
'Unable to summarize current session. Please confirm that the summary agent is not disabled.'));
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to select a chat agent
|
||||
* @returns The selected agent or undefined if cancelled
|
||||
*/
|
||||
/**
|
||||
* Prompts the user to select a chat agent with an optional default (pre-selected) agent.
|
||||
* @param defaultAgentId The id of the agent to pre-select, if present
|
||||
* @returns The selected agent or undefined if cancelled
|
||||
*/
|
||||
protected async selectAgent(defaultAgentId?: string): Promise<ChatAgent | undefined> {
|
||||
const agents = this.chatAgentService.getAgents();
|
||||
if (agents.length === 0) {
|
||||
this.messageService.warn(nls.localize('theia/ai/chat-ui/noChatAgentsAvailable', 'No chat agents available.'));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items: QuickPickItem[] = agents.map(agent => ({
|
||||
label: agent.name || agent.id,
|
||||
description: agent.description,
|
||||
id: agent.id
|
||||
}));
|
||||
|
||||
let preselected: QuickPickItem | undefined = undefined;
|
||||
if (defaultAgentId) {
|
||||
preselected = items.find(item => item.id === defaultAgentId);
|
||||
}
|
||||
|
||||
const selected = await this.quickInputService.showQuickPick(items, {
|
||||
placeholder: nls.localize('theia/ai/chat-ui/selectAgentQuickPickPlaceholder', 'Select an agent for the new session'),
|
||||
activeItem: preselected
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.chatAgentService.getAgent(selected.id!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to select a task context with special marking for currently opened files
|
||||
* @returns The selected task context ID or undefined if cancelled
|
||||
*/
|
||||
protected async selectTaskContextWithMarking(): Promise<string | undefined> {
|
||||
const contexts = this.taskContextService.getAll();
|
||||
const openedFilesInfo = this.getOpenedTaskContextFiles();
|
||||
|
||||
// Create items with opened files marked and prioritized
|
||||
const items: QuickPickItem[] = contexts.map(summary => {
|
||||
const isOpened = openedFilesInfo.openedIds.includes(summary.id);
|
||||
const isActive = openedFilesInfo.activeId === summary.id;
|
||||
return {
|
||||
label: isOpened ? `📄 ${summary.label} (${nls.localize('theia/ai/chat-ui/selectTaskContextQuickPickItem/currentlyOpen', 'currently open')})` : summary.label,
|
||||
description: summary.id,
|
||||
id: summary.id,
|
||||
// We'll sort active file first, then opened files, then others
|
||||
sortText: isActive ? `0-${summary.label}` : isOpened ? `1-${summary.label}` : `2-${summary.label}`
|
||||
};
|
||||
}).sort((a, b) => a.sortText!.localeCompare(b.sortText!));
|
||||
|
||||
const selected = await this.quickInputService.showQuickPick(items, {
|
||||
placeholder: nls.localize('theia/ai/chat-ui/selectTaskContextQuickPickPlaceholder', 'Select a task context to attach')
|
||||
});
|
||||
|
||||
return selected?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns information about task context files that are currently opened
|
||||
* @returns Object with arrays of opened context IDs and the active context ID
|
||||
*/
|
||||
protected getOpenedTaskContextFiles(): { openedIds: string[], activeId?: string } {
|
||||
// Get all contexts with their URIs
|
||||
const allContexts = this.taskContextService.getAll();
|
||||
const contextMap = new Map<string, string>(); // Map of URI -> ID
|
||||
// Create a map of URI string -> context ID for lookup
|
||||
for (const context of allContexts) {
|
||||
if (context.uri) {
|
||||
contextMap.set(context.uri.toString(), context.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all open editor URIs
|
||||
const openEditorUris = this.editorManager.all.map(widget => widget.editor.uri.toString());
|
||||
|
||||
// Get the currently active/focused editor URI if any
|
||||
const activeEditorUri = this.editorManager.currentEditor?.editor.uri.toString();
|
||||
let activeContextId: string | undefined;
|
||||
|
||||
if (activeEditorUri) {
|
||||
activeContextId = contextMap.get(activeEditorUri);
|
||||
}
|
||||
|
||||
// Filter to only task context files that are currently opened
|
||||
const openedContextIds: string[] = [];
|
||||
for (const uri of openEditorUris) {
|
||||
const contextId = contextMap.get(uri);
|
||||
if (contextId) {
|
||||
openedContextIds.push(contextId);
|
||||
}
|
||||
}
|
||||
|
||||
return { openedIds: openedContextIds, activeId: activeContextId };
|
||||
}
|
||||
}
|
||||
|
||||
function getDateFnsLocale(): locales.Locale {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return nls.locale ? (locales as any)[nls.locale] ?? locales.enUS : locales.enUS;
|
||||
}
|
||||
199
packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts
Normal file
199
packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 '../../src/browser/style/index.css';
|
||||
import { bindContributionProvider, CommandContribution, MenuContribution } from '@theia/core';
|
||||
import { bindViewContribution, FrontendApplicationContribution, WidgetFactory, KeybindingContribution } 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 { EditorSelectionResolver } from '@theia/editor/lib/browser/editor-manager';
|
||||
import { AIChatContribution } from './ai-chat-ui-contribution';
|
||||
import { AIChatInputConfiguration, AIChatInputWidget } from './chat-input-widget';
|
||||
import { ChatNodeToolbarActionContribution, DefaultChatNodeToolbarActionContribution } from './chat-node-toolbar-action-contribution';
|
||||
import { ChatResponsePartRenderer } from './chat-response-part-renderer';
|
||||
import {
|
||||
CodePartRenderer,
|
||||
CodePartRendererAction,
|
||||
CommandPartRenderer,
|
||||
CopyToClipboardButtonAction,
|
||||
ErrorPartRenderer,
|
||||
HorizontalLayoutPartRenderer,
|
||||
InsertCodeAtCursorButtonAction,
|
||||
MarkdownPartRenderer,
|
||||
ToolCallPartRenderer,
|
||||
NotAvailableToolCallRenderer,
|
||||
ThinkingPartRenderer,
|
||||
ProgressPartRenderer,
|
||||
DelegationResponseRenderer,
|
||||
TextPartRenderer,
|
||||
} from './chat-response-renderer';
|
||||
import { UnknownPartRenderer } from './chat-response-renderer/unknown-part-renderer';
|
||||
import {
|
||||
GitHubSelectionResolver,
|
||||
TextFragmentSelectionResolver,
|
||||
TypeDocSymbolSelectionResolver,
|
||||
} from './chat-response-renderer/ai-selection-resolver';
|
||||
import { QuestionPartRenderer } from './chat-response-renderer/question-part-renderer';
|
||||
import { createChatViewTreeWidget } from './chat-tree-view';
|
||||
import { ChatViewTreeWidget } from './chat-tree-view/chat-view-tree-widget';
|
||||
import { ChatViewMenuContribution } from './chat-view-contribution';
|
||||
import { ChatViewLanguageContribution } from './chat-view-language-contribution';
|
||||
import { ChatViewWidget } from './chat-view-widget';
|
||||
import { ChatViewWidgetToolbarContribution } from './chat-view-widget-toolbar-contribution';
|
||||
import { ContextVariablePicker } from './context-variable-picker';
|
||||
import { ChangeSetActionRenderer, ChangeSetActionService } from './change-set-actions/change-set-action-service';
|
||||
import { ChangeSetAcceptAction } from './change-set-actions/change-set-accept-action';
|
||||
import { AIChatTreeInputArgs, AIChatTreeInputConfiguration, AIChatTreeInputFactory, AIChatTreeInputWidget } from './chat-tree-view/chat-view-tree-input-widget';
|
||||
import { SubChatWidget, SubChatWidgetFactory } from './chat-tree-view/sub-chat-widget';
|
||||
import { ChatInputHistoryService } from './chat-input-history';
|
||||
import { ChatInputHistoryContribution } from './chat-input-history-contribution';
|
||||
import { ChatInputModeContribution } from './chat-input-mode-contribution';
|
||||
import { ChatFocusContribution } from './chat-focus-contribution';
|
||||
|
||||
export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
|
||||
bindViewContribution(bind, AIChatContribution);
|
||||
bind(TabBarToolbarContribution).toService(AIChatContribution);
|
||||
bind(FrontendApplicationContribution).toService(AIChatContribution);
|
||||
|
||||
bind(ChatInputHistoryService).toSelf().inSingletonScope();
|
||||
bind(ChatInputHistoryContribution).toSelf().inSingletonScope();
|
||||
bind(CommandContribution).toService(ChatInputHistoryContribution);
|
||||
bind(KeybindingContribution).toService(ChatInputHistoryContribution);
|
||||
|
||||
bind(ChatInputModeContribution).toSelf().inSingletonScope();
|
||||
bind(CommandContribution).toService(ChatInputModeContribution);
|
||||
bind(KeybindingContribution).toService(ChatInputModeContribution);
|
||||
|
||||
bind(ChatFocusContribution).toSelf().inSingletonScope();
|
||||
bind(CommandContribution).toService(ChatFocusContribution);
|
||||
bind(KeybindingContribution).toService(ChatFocusContribution);
|
||||
|
||||
bindContributionProvider(bind, ChatResponsePartRenderer);
|
||||
|
||||
bindChatViewWidget(bind);
|
||||
|
||||
bind(AIChatInputWidget).toSelf();
|
||||
bind(AIChatInputConfiguration).toConstantValue({
|
||||
showContext: true,
|
||||
showPinnedAgent: true,
|
||||
showChangeSet: true,
|
||||
enablePromptHistory: true
|
||||
} satisfies AIChatInputConfiguration);
|
||||
bind(WidgetFactory).toDynamicValue(({ container }) => ({
|
||||
id: AIChatInputWidget.ID,
|
||||
createWidget: () => container.get(AIChatInputWidget)
|
||||
})).inSingletonScope();
|
||||
|
||||
bind(ChatViewTreeWidget).toDynamicValue(ctx =>
|
||||
createChatViewTreeWidget(ctx.container)
|
||||
);
|
||||
bind(WidgetFactory).toDynamicValue(({ container }) => ({
|
||||
id: ChatViewTreeWidget.ID,
|
||||
createWidget: () => container.get(ChatViewTreeWidget)
|
||||
})).inSingletonScope();
|
||||
|
||||
bind(AIChatTreeInputFactory).toFactory(ctx => (args: AIChatTreeInputArgs) => {
|
||||
const container = ctx.container.createChild();
|
||||
container.bind(AIChatTreeInputArgs).toConstantValue(args);
|
||||
container.bind(AIChatTreeInputConfiguration).toConstantValue({
|
||||
showContext: true,
|
||||
showPinnedAgent: true,
|
||||
showChangeSet: false,
|
||||
showSuggestions: false,
|
||||
enablePromptHistory: false
|
||||
} satisfies AIChatInputConfiguration);
|
||||
container.bind(AIChatTreeInputWidget).toSelf().inSingletonScope();
|
||||
const widget = container.get(AIChatTreeInputWidget);
|
||||
const noOp = () => { };
|
||||
widget.node.classList.add('chat-input-widget');
|
||||
widget.chatModel = args.node.request.session;
|
||||
widget.initialValue = args.initialValue;
|
||||
widget.setEnabled(true);
|
||||
widget.onQuery = args.onQuery;
|
||||
// We need to set those values here, otherwise the widget will throw an error
|
||||
widget.onUnpin = args.onUnpin ?? noOp;
|
||||
widget.onCancel = args.onCancel ?? noOp;
|
||||
widget.onDeleteChangeSet = args.onDeleteChangeSet ?? noOp;
|
||||
widget.onDeleteChangeSetElement = args.onDeleteChangeSetElement ?? noOp;
|
||||
return widget;
|
||||
});
|
||||
|
||||
bind(ContextVariablePicker).toSelf().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(NotAvailableToolCallRenderer).inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).to(ErrorPartRenderer).inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).to(ThinkingPartRenderer).inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).to(QuestionPartRenderer).inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).to(ProgressPartRenderer).inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).to(TextPartRenderer).inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).to(DelegationResponseRenderer).inSingletonScope();
|
||||
bind(ChatResponsePartRenderer).to(UnknownPartRenderer).inSingletonScope();
|
||||
[CommandContribution, MenuContribution].forEach(serviceIdentifier =>
|
||||
bind(serviceIdentifier).to(ChatViewMenuContribution).inSingletonScope()
|
||||
);
|
||||
|
||||
bindContributionProvider(bind, CodePartRendererAction);
|
||||
bindContributionProvider(bind, ChangeSetActionRenderer);
|
||||
bind(CopyToClipboardButtonAction).toSelf().inSingletonScope();
|
||||
bind(CodePartRendererAction).toService(CopyToClipboardButtonAction);
|
||||
bind(InsertCodeAtCursorButtonAction).toSelf().inSingletonScope();
|
||||
bind(CodePartRendererAction).toService(InsertCodeAtCursorButtonAction);
|
||||
|
||||
bind(EditorSelectionResolver).to(GitHubSelectionResolver).inSingletonScope();
|
||||
bind(EditorSelectionResolver).to(TypeDocSymbolSelectionResolver).inSingletonScope();
|
||||
bind(EditorSelectionResolver).to(TextFragmentSelectionResolver).inSingletonScope();
|
||||
|
||||
bind(ChatViewWidgetToolbarContribution).toSelf().inSingletonScope();
|
||||
bind(TabBarToolbarContribution).toService(ChatViewWidgetToolbarContribution);
|
||||
|
||||
bind(FrontendApplicationContribution).to(ChatViewLanguageContribution).inSingletonScope();
|
||||
bind(ChangeSetActionService).toSelf().inSingletonScope();
|
||||
bind(ChangeSetAcceptAction).toSelf().inSingletonScope();
|
||||
bind(ChangeSetActionRenderer).toService(ChangeSetAcceptAction);
|
||||
|
||||
bindContributionProvider(bind, ChatNodeToolbarActionContribution);
|
||||
bind(DefaultChatNodeToolbarActionContribution).toSelf().inSingletonScope();
|
||||
bind(ChatNodeToolbarActionContribution).toService(DefaultChatNodeToolbarActionContribution);
|
||||
|
||||
bind(SubChatWidgetFactory).toFactory(ctx => () => {
|
||||
const container = ctx.container.createChild();
|
||||
container.bind(SubChatWidget).toSelf().inSingletonScope();
|
||||
const widget = container.get(SubChatWidget);
|
||||
return widget;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
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>(ChatViewWidget);
|
||||
}
|
||||
return chatViewWidget;
|
||||
}
|
||||
})).inSingletonScope();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { ChangeSetActionRenderer } from './change-set-action-service';
|
||||
import { ChangeSet, ChangeSetElement } from '@theia/ai-chat';
|
||||
import { nls } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class ChangeSetAcceptAction implements ChangeSetActionRenderer {
|
||||
readonly id = 'change-set-accept-action';
|
||||
canRender(changeSet: ChangeSet): boolean {
|
||||
return changeSet.getElements().length > 0;
|
||||
}
|
||||
|
||||
render(changeSet: ChangeSet): React.ReactNode {
|
||||
return <button
|
||||
className='theia-button'
|
||||
disabled={!hasPendingElementsToAccept(changeSet)}
|
||||
title={nls.localize('theia/ai/chat-ui/applyAllTitle', 'Apply all pending changes')}
|
||||
onClick={() => acceptAllPendingElements(changeSet)}
|
||||
>
|
||||
{nls.localize('theia/ai/chat-ui/applyAll', 'Apply All')}
|
||||
</button>;
|
||||
}
|
||||
}
|
||||
|
||||
function acceptAllPendingElements(changeSet: ChangeSet): void {
|
||||
acceptablePendingElements(changeSet).forEach(e => e.apply!());
|
||||
}
|
||||
|
||||
function hasPendingElementsToAccept(changeSet: ChangeSet): boolean | undefined {
|
||||
return acceptablePendingElements(changeSet).length > 0;
|
||||
}
|
||||
|
||||
function acceptablePendingElements(changeSet: ChangeSet): ChangeSetElement[] {
|
||||
return changeSet.getElements().filter(e => e.apply && (e.state === undefined || e.state === 'pending'));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ContributionProvider, Event, Emitter } from '@theia/core';
|
||||
import { ChangeSet } from '@theia/ai-chat';
|
||||
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
|
||||
|
||||
export const ChangeSetActionRenderer = Symbol('ChangeSetActionRenderer');
|
||||
/**
|
||||
* The CodePartRenderer offers to contribute arbitrary React nodes to the rendered code part.
|
||||
* Technically anything can be rendered, however it is intended to be used for actions, like
|
||||
* "Copy to Clipboard" or "Insert at Cursor".
|
||||
*/
|
||||
export interface ChangeSetActionRenderer {
|
||||
readonly id: string;
|
||||
onDidChange?: Event<void>;
|
||||
render(changeSet: ChangeSet): React.ReactNode;
|
||||
/**
|
||||
* Determines if the action should be rendered for the given response.
|
||||
*/
|
||||
canRender?(changeSet: ChangeSet): boolean;
|
||||
/**
|
||||
* Actions are ordered by descending priority. (Highest on left).
|
||||
*/
|
||||
readonly priority?: number;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ChangeSetActionService {
|
||||
protected readonly onDidChangeEmitter = new Emitter<void>();
|
||||
get onDidChange(): Event<void> {
|
||||
return this.onDidChangeEmitter.event;
|
||||
}
|
||||
|
||||
@inject(ContributionProvider) @named(ChangeSetActionRenderer)
|
||||
protected readonly contributions: ContributionProvider<ChangeSetActionRenderer>;
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
const actions = this.contributions.getContributions();
|
||||
actions.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||
actions.forEach(contribution => contribution.onDidChange?.(this.onDidChangeEmitter.fire, this.onDidChangeEmitter));
|
||||
}
|
||||
|
||||
getActions(): readonly ChangeSetActionRenderer[] {
|
||||
return this.contributions.getContributions();
|
||||
}
|
||||
|
||||
getActionsForChangeset(changeSet: ChangeSet): ChangeSetActionRenderer[] {
|
||||
return this.getActions().filter(candidate => !candidate.canRender || candidate.canRender(changeSet));
|
||||
}
|
||||
}
|
||||
102
packages/ai-chat-ui/src/browser/chat-focus-contribution.ts
Normal file
102
packages/ai-chat-ui/src/browser/chat-focus-contribution.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 } from '@theia/core';
|
||||
import { ApplicationShell, KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatViewWidget } from './chat-view-widget';
|
||||
import { ChatCommands } from './chat-view-commands';
|
||||
|
||||
export const CHAT_FOCUS_INPUT_COMMAND = Command.toLocalizedCommand({
|
||||
id: 'ai-chat.focus-input',
|
||||
category: ChatCommands.CHAT_CATEGORY,
|
||||
label: 'Focus Chat Input'
|
||||
}, 'theia/ai/chat-ui/focusInput', ChatCommands.CHAT_CATEGORY_KEY);
|
||||
|
||||
export const CHAT_FOCUS_RESPONSE_COMMAND = Command.toLocalizedCommand({
|
||||
id: 'ai-chat.focus-response',
|
||||
category: ChatCommands.CHAT_CATEGORY,
|
||||
label: 'Focus Chat Response'
|
||||
}, 'theia/ai/chat-ui/focusResponse', ChatCommands.CHAT_CATEGORY_KEY);
|
||||
|
||||
@injectable()
|
||||
export class ChatFocusContribution implements CommandContribution, KeybindingContribution {
|
||||
|
||||
@inject(ApplicationShell)
|
||||
protected readonly shell: ApplicationShell;
|
||||
|
||||
registerCommands(commands: CommandRegistry): void {
|
||||
commands.registerCommand(CHAT_FOCUS_INPUT_COMMAND, {
|
||||
execute: () => this.focusInput(),
|
||||
isEnabled: () => this.findActiveChatViewWidget() !== undefined
|
||||
});
|
||||
commands.registerCommand(CHAT_FOCUS_RESPONSE_COMMAND, {
|
||||
execute: () => this.focusResponse(),
|
||||
isEnabled: () => this.findActiveChatViewWidget() !== undefined
|
||||
});
|
||||
}
|
||||
|
||||
registerKeybindings(keybindings: KeybindingRegistry): void {
|
||||
keybindings.registerKeybinding({
|
||||
command: CHAT_FOCUS_RESPONSE_COMMAND.id,
|
||||
keybinding: 'ctrlcmd+up',
|
||||
when: 'chatInputFocus && !suggestWidgetVisible'
|
||||
});
|
||||
keybindings.registerKeybinding({
|
||||
command: CHAT_FOCUS_INPUT_COMMAND.id,
|
||||
keybinding: 'ctrlcmd+down',
|
||||
when: 'chatResponseFocus'
|
||||
});
|
||||
}
|
||||
|
||||
protected focusInput(): void {
|
||||
const chatViewWidget = this.findActiveChatViewWidget();
|
||||
if (chatViewWidget) {
|
||||
chatViewWidget.inputWidget.activate();
|
||||
}
|
||||
}
|
||||
|
||||
protected focusResponse(): void {
|
||||
const chatViewWidget = this.findActiveChatViewWidget();
|
||||
if (chatViewWidget) {
|
||||
chatViewWidget.treeWidget.node.focus();
|
||||
}
|
||||
}
|
||||
|
||||
protected findActiveChatViewWidget(): ChatViewWidget | undefined {
|
||||
const activeWidget = this.shell.activeWidget;
|
||||
if (activeWidget instanceof ChatViewWidget) {
|
||||
return activeWidget;
|
||||
}
|
||||
// Also check if any part of the chat view has focus
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement instanceof HTMLElement) {
|
||||
const widget = this.shell.findWidgetForElement(activeElement);
|
||||
if (widget instanceof ChatViewWidget) {
|
||||
return widget;
|
||||
}
|
||||
// Check parent widgets (e.g., when input widget has focus)
|
||||
let parent = widget?.parent;
|
||||
while (parent) {
|
||||
if (parent instanceof ChatViewWidget) {
|
||||
return parent;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { DeclaredEventsEventListenerObject, useMarkdownRendering } from './chat-response-renderer/markdown-part-renderer';
|
||||
import { OpenerService } from '@theia/core/lib/browser';
|
||||
import { ChatSuggestion, ChatSuggestionCallback } from '@theia/ai-chat';
|
||||
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
|
||||
|
||||
interface ChatInputAgentSuggestionsProps {
|
||||
suggestions: readonly ChatSuggestion[];
|
||||
opener: OpenerService;
|
||||
}
|
||||
|
||||
function getText(suggestion: ChatSuggestion): string {
|
||||
if (typeof suggestion === 'string') { return suggestion; }
|
||||
if ('value' in suggestion) { return suggestion.value; }
|
||||
if (typeof suggestion.content === 'string') { return suggestion.content; }
|
||||
return suggestion.content.value;
|
||||
}
|
||||
|
||||
function getContent(suggestion: ChatSuggestion): string | MarkdownString {
|
||||
if (typeof suggestion === 'string') { return suggestion; }
|
||||
if ('value' in suggestion) { return suggestion; }
|
||||
return suggestion.content;
|
||||
}
|
||||
|
||||
export const ChatInputAgentSuggestions: React.FC<ChatInputAgentSuggestionsProps> = ({ suggestions, opener }) => (
|
||||
!!suggestions?.length && <div className="chat-agent-suggestions">
|
||||
{suggestions.map(suggestion => <ChatInputAgentSuggestion
|
||||
key={getText(suggestion)}
|
||||
suggestion={suggestion}
|
||||
opener={opener}
|
||||
handler={ChatSuggestionCallback.is(suggestion) ? new ChatSuggestionClickHandler(suggestion) : undefined}
|
||||
/>)}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ChatInputAgestSuggestionProps {
|
||||
suggestion: ChatSuggestion;
|
||||
opener: OpenerService;
|
||||
handler?: DeclaredEventsEventListenerObject;
|
||||
}
|
||||
|
||||
const ChatInputAgentSuggestion: React.FC<ChatInputAgestSuggestionProps> = ({ suggestion, opener, handler }) => {
|
||||
const ref = useMarkdownRendering(getContent(suggestion), opener, true, handler);
|
||||
return <div className="chat-agent-suggestion" style={(!handler || ChatSuggestionCallback.containsCallbackLink(suggestion)) ? undefined : { cursor: 'pointer' }} ref={ref} />;
|
||||
};
|
||||
|
||||
class ChatSuggestionClickHandler implements DeclaredEventsEventListenerObject {
|
||||
constructor(protected readonly suggestion: ChatSuggestionCallback) { }
|
||||
handleEvent(event: Event): boolean {
|
||||
const { target, currentTarget } = event;
|
||||
if (event.type !== 'click' || !(target instanceof Element)) { return false; }
|
||||
const link = target.closest('a[href^="_callback"]');
|
||||
if (link) {
|
||||
this.suggestion.callback();
|
||||
return true;
|
||||
}
|
||||
if (!(currentTarget instanceof Element)) {
|
||||
this.suggestion.callback();
|
||||
return true;
|
||||
}
|
||||
const containedLink = currentTarget.querySelector('a[href^="_callback"]');
|
||||
// Whole body should count.
|
||||
if (!containedLink) {
|
||||
this.suggestion.callback();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 STMicroelectronics and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { Command, CommandContribution, CommandRegistry } from '@theia/core';
|
||||
import { ApplicationShell, KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { AIChatInputWidget } from './chat-input-widget';
|
||||
import { ChatInputHistoryService } from './chat-input-history';
|
||||
import { ChatCommands } from './chat-view-commands';
|
||||
|
||||
const CHAT_INPUT_PREVIOUS_PROMPT_COMMAND = Command.toLocalizedCommand({
|
||||
id: 'chat-input:previous-prompt',
|
||||
label: 'Previous Prompt'
|
||||
}, 'theia/ai/chat-ui/chatInput/previousPrompt');
|
||||
|
||||
const CHAT_INPUT_NEXT_PROMPT_COMMAND = Command.toLocalizedCommand({
|
||||
id: 'chat-input:next-prompt',
|
||||
label: 'Next Prompt'
|
||||
}, 'theia/ai/chat-ui/chatInput/nextPrompt');
|
||||
|
||||
const CHAT_INPUT_CLEAR_HISTORY_COMMAND = Command.toLocalizedCommand({
|
||||
id: 'chat-input:clear-history',
|
||||
category: ChatCommands.CHAT_CATEGORY,
|
||||
label: 'Clear Input Prompt History'
|
||||
}, 'theia/ai/chat-ui/chatInput/clearHistory', ChatCommands.CHAT_CATEGORY_KEY);
|
||||
|
||||
@injectable()
|
||||
export class ChatInputHistoryContribution implements CommandContribution, KeybindingContribution {
|
||||
|
||||
@inject(ApplicationShell)
|
||||
protected readonly shell: ApplicationShell;
|
||||
|
||||
@inject(ChatInputHistoryService)
|
||||
protected readonly historyService: ChatInputHistoryService;
|
||||
|
||||
registerCommands(commands: CommandRegistry): void {
|
||||
commands.registerCommand(CHAT_INPUT_PREVIOUS_PROMPT_COMMAND, {
|
||||
execute: () => this.executeNavigatePrevious(),
|
||||
isEnabled: () => this.isNavigationEnabled()
|
||||
});
|
||||
|
||||
commands.registerCommand(CHAT_INPUT_NEXT_PROMPT_COMMAND, {
|
||||
execute: () => this.executeNavigateNext(),
|
||||
isEnabled: () => this.isNavigationEnabled()
|
||||
});
|
||||
|
||||
commands.registerCommand(CHAT_INPUT_CLEAR_HISTORY_COMMAND, {
|
||||
execute: () => this.historyService.clearHistory(),
|
||||
isEnabled: () => this.historyService.getPrompts().length > 0
|
||||
});
|
||||
}
|
||||
|
||||
registerKeybindings(keybindings: KeybindingRegistry): void {
|
||||
keybindings.registerKeybinding({
|
||||
command: CHAT_INPUT_PREVIOUS_PROMPT_COMMAND.id,
|
||||
keybinding: 'up',
|
||||
when: 'chatInputFocus && chatInputFirstLine && !suggestWidgetVisible'
|
||||
});
|
||||
|
||||
keybindings.registerKeybinding({
|
||||
command: CHAT_INPUT_NEXT_PROMPT_COMMAND.id,
|
||||
keybinding: 'down',
|
||||
when: 'chatInputFocus && chatInputLastLine && !suggestWidgetVisible'
|
||||
});
|
||||
}
|
||||
|
||||
protected executeNavigatePrevious(): void {
|
||||
const chatInputWidget = this.findFocusedChatInput();
|
||||
if (!chatInputWidget || !chatInputWidget.editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = chatInputWidget.editor.getControl().getPosition();
|
||||
const isCursorAtBeginning = position && (position.lineNumber > 1 || position.column > 1);
|
||||
if (isCursorAtBeginning) {
|
||||
this.positionCursorAtBeginning(chatInputWidget);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentInput = chatInputWidget.editor.getControl().getValue();
|
||||
const previousPrompt = chatInputWidget.getPreviousPrompt(currentInput);
|
||||
if (previousPrompt !== undefined) {
|
||||
chatInputWidget.editor.getControl().setValue(previousPrompt);
|
||||
this.positionCursorAtBeginning(chatInputWidget);
|
||||
}
|
||||
}
|
||||
|
||||
protected executeNavigateNext(): void {
|
||||
const chatInputWidget = this.findFocusedChatInput();
|
||||
if (!chatInputWidget || !chatInputWidget.editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = chatInputWidget.editor.getControl().getPosition();
|
||||
const model = chatInputWidget.editor.getControl().getModel();
|
||||
const isCursorAtEnd = position && model && (
|
||||
position.lineNumber < model.getLineCount() || position.column < model.getLineMaxColumn(position.lineNumber)
|
||||
);
|
||||
if (isCursorAtEnd) {
|
||||
this.positionCursorAtEnd(chatInputWidget);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPrompt = chatInputWidget.getNextPrompt();
|
||||
if (nextPrompt !== undefined) {
|
||||
chatInputWidget.editor.getControl().setValue(nextPrompt);
|
||||
this.positionCursorAtEnd(chatInputWidget);
|
||||
}
|
||||
}
|
||||
|
||||
protected positionCursorAtBeginning(widget: AIChatInputWidget): void {
|
||||
const editor = widget.editor?.getControl();
|
||||
if (editor) {
|
||||
editor.setPosition({ lineNumber: 1, column: 1 });
|
||||
editor.focus();
|
||||
}
|
||||
}
|
||||
|
||||
protected positionCursorAtEnd(widget: AIChatInputWidget): void {
|
||||
const editor = widget.editor?.getControl();
|
||||
const model = editor?.getModel();
|
||||
|
||||
if (editor && model) {
|
||||
const lastLine = model.getLineCount();
|
||||
const lastColumn = model.getLineContent(lastLine).length + 1;
|
||||
editor.setPosition({ lineNumber: lastLine, column: lastColumn });
|
||||
editor.focus();
|
||||
}
|
||||
}
|
||||
|
||||
protected findFocusedChatInput(): AIChatInputWidget | undefined {
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const activeWidget = this.shell.findWidgetForElement(activeElement);
|
||||
if (!(activeWidget instanceof AIChatInputWidget)) {
|
||||
return;
|
||||
}
|
||||
if (!activeWidget.inputConfiguration?.enablePromptHistory) {
|
||||
return;
|
||||
}
|
||||
if (!activeWidget.editor?.getControl().hasWidgetFocus()) {
|
||||
return;
|
||||
}
|
||||
return activeWidget;
|
||||
}
|
||||
|
||||
protected isNavigationEnabled(): boolean {
|
||||
const chatInputWidget = this.findFocusedChatInput();
|
||||
return chatInputWidget !== undefined &&
|
||||
chatInputWidget.inputConfiguration?.enablePromptHistory !== false;
|
||||
}
|
||||
}
|
||||
138
packages/ai-chat-ui/src/browser/chat-input-history.ts
Normal file
138
packages/ai-chat-ui/src/browser/chat-input-history.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 STMicroelectronics and others.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { StorageService } from '@theia/core/lib/browser';
|
||||
|
||||
/**
|
||||
* Manages navigation state for a single chat input widget.
|
||||
* Each widget has its own independent navigation state while sharing the same history.
|
||||
*/
|
||||
export class ChatInputNavigationState {
|
||||
private currentIndex: number;
|
||||
private preservedInput?: string;
|
||||
private isNavigating = false;
|
||||
|
||||
constructor(private readonly historyService: ChatInputHistoryService) {
|
||||
this.currentIndex = historyService.getPrompts().length;
|
||||
}
|
||||
|
||||
getPreviousPrompt(currentInput: string): string | undefined {
|
||||
const history = this.historyService.getPrompts();
|
||||
|
||||
if (history.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this.isNavigating) {
|
||||
this.preservedInput = currentInput;
|
||||
this.isNavigating = true;
|
||||
this.currentIndex = history.length;
|
||||
}
|
||||
|
||||
if (this.currentIndex <= 0) {
|
||||
// Already at the oldest prompt
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.currentIndex--;
|
||||
return history[this.currentIndex];
|
||||
}
|
||||
|
||||
getNextPrompt(): string | undefined {
|
||||
const history = this.historyService.getPrompts();
|
||||
|
||||
if (!this.isNavigating || this.currentIndex >= history.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.currentIndex++;
|
||||
|
||||
if (this.currentIndex >= history.length) {
|
||||
// Reached end of history - return to preserved input
|
||||
this.isNavigating = false;
|
||||
const preserved = this.preservedInput;
|
||||
this.preservedInput = undefined;
|
||||
this.currentIndex = history.length;
|
||||
return preserved || '';
|
||||
}
|
||||
|
||||
return history[this.currentIndex];
|
||||
}
|
||||
|
||||
stopNavigation(): void {
|
||||
this.isNavigating = false;
|
||||
this.preservedInput = undefined;
|
||||
this.currentIndex = this.historyService.getPrompts().length;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const CHAT_PROMPT_HISTORY_STORAGE_KEY = 'ai-chat-prompt-history';
|
||||
const MAX_HISTORY_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Manages shared prompt history across all chat input widgets.
|
||||
* Each prompt is stored only once and shared between all chat inputs.
|
||||
*/
|
||||
@injectable()
|
||||
export class ChatInputHistoryService {
|
||||
|
||||
@inject(StorageService)
|
||||
protected readonly storageService: StorageService;
|
||||
|
||||
protected history: string[] = [];
|
||||
|
||||
async init(): Promise<void> {
|
||||
const data = await this.storageService.getData<{ prompts: string[] }>(CHAT_PROMPT_HISTORY_STORAGE_KEY, { prompts: [] });
|
||||
this.history = data.prompts || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get read-only access to the current prompt history.
|
||||
*/
|
||||
getPrompts(): readonly string[] {
|
||||
return this.history;
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
this.history = [];
|
||||
this.persistHistory();
|
||||
}
|
||||
|
||||
addToHistory(prompt: string): void {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing instance and add to end (most recent)
|
||||
this.history = this.history
|
||||
.filter(item => item !== trimmed)
|
||||
.concat(trimmed)
|
||||
.slice(-MAX_HISTORY_SIZE);
|
||||
|
||||
this.persistHistory();
|
||||
}
|
||||
|
||||
protected async persistHistory(): Promise<void> {
|
||||
try {
|
||||
await this.storageService.setData(CHAT_PROMPT_HISTORY_STORAGE_KEY, { prompts: this.history });
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist chat prompt history:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { Command, CommandContribution, CommandRegistry } from '@theia/core';
|
||||
import { ApplicationShell, KeybindingContribution, KeybindingRegistry } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { AIChatInputWidget } from './chat-input-widget';
|
||||
|
||||
const CHAT_INPUT_CYCLE_MODE_COMMAND = Command.toLocalizedCommand({
|
||||
id: 'chat-input:cycle-mode',
|
||||
label: 'Cycle Chat Mode'
|
||||
}, 'theia/ai/chat-ui/chatInput/cycleMode');
|
||||
|
||||
@injectable()
|
||||
export class ChatInputModeContribution implements CommandContribution, KeybindingContribution {
|
||||
|
||||
@inject(ApplicationShell)
|
||||
protected readonly shell: ApplicationShell;
|
||||
|
||||
registerCommands(commands: CommandRegistry): void {
|
||||
commands.registerCommand(CHAT_INPUT_CYCLE_MODE_COMMAND, {
|
||||
execute: () => this.executeCycleMode(),
|
||||
isEnabled: () => this.isCycleModeEnabled()
|
||||
});
|
||||
}
|
||||
|
||||
registerKeybindings(keybindings: KeybindingRegistry): void {
|
||||
keybindings.registerKeybinding({
|
||||
command: CHAT_INPUT_CYCLE_MODE_COMMAND.id,
|
||||
keybinding: 'shift+tab',
|
||||
when: 'chatInputFocus && chatInputHasModes && !suggestWidgetVisible'
|
||||
});
|
||||
}
|
||||
|
||||
protected executeCycleMode(): void {
|
||||
const chatInputWidget = this.findFocusedChatInput();
|
||||
if (!chatInputWidget) {
|
||||
return;
|
||||
}
|
||||
chatInputWidget.cycleMode();
|
||||
}
|
||||
|
||||
protected isCycleModeEnabled(): boolean {
|
||||
const chatInputWidget = this.findFocusedChatInput();
|
||||
return chatInputWidget !== undefined;
|
||||
}
|
||||
|
||||
protected findFocusedChatInput(): AIChatInputWidget | undefined {
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const activeWidget = this.shell.findWidgetForElement(activeElement);
|
||||
if (!(activeWidget instanceof AIChatInputWidget)) {
|
||||
return;
|
||||
}
|
||||
if (!activeWidget.editor?.getControl().hasWidgetFocus()) {
|
||||
return;
|
||||
}
|
||||
return activeWidget;
|
||||
}
|
||||
}
|
||||
1531
packages/ai-chat-ui/src/browser/chat-input-widget.tsx
Normal file
1531
packages/ai-chat-ui/src/browser/chat-input-widget.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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';
|
||||
import { isRequestNode, RequestNode, ResponseNode } from './chat-tree-view';
|
||||
import { EditableChatRequestModel } from '@theia/ai-chat';
|
||||
|
||||
export interface ChatNodeToolbarAction {
|
||||
/**
|
||||
* The command to execute when the item is selected. The handler will receive the `RequestNode` or `ResponseNode` as first argument.
|
||||
*/
|
||||
commandId: string;
|
||||
/**
|
||||
* Icon class name(s) for the item (e.g. 'codicon codicon-feedback').
|
||||
*/
|
||||
icon: string;
|
||||
/**
|
||||
* Priority among the items. Can be negative. The smaller the number the left-most the item will be placed in the toolbar. It is `0` by default.
|
||||
*/
|
||||
priority?: number;
|
||||
/**
|
||||
* Optional tooltip for the item.
|
||||
*/
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clients implement this interface if they want to contribute to the toolbar of chat nodes.
|
||||
*
|
||||
* ### Example
|
||||
* ```ts
|
||||
* bind(ChatNodeToolbarActionContribution).toDynamicValue(context => ({
|
||||
* getToolbarActions: (args: RequestNode | ResponseNode) => {
|
||||
* if (isResponseNode(args)) {
|
||||
* return [{
|
||||
* commandId: 'core.about',
|
||||
* icon: 'codicon codicon-feedback',
|
||||
* tooltip: 'Show about dialog on response nodes'
|
||||
* }];
|
||||
* } else {
|
||||
* return [];
|
||||
* }
|
||||
* }
|
||||
* }));
|
||||
* ```
|
||||
*/
|
||||
export const ChatNodeToolbarActionContribution = Symbol('ChatNodeToolbarActionContribution');
|
||||
export interface ChatNodeToolbarActionContribution {
|
||||
/**
|
||||
* Returns the toolbar actions for the given node.
|
||||
*/
|
||||
getToolbarActions(node: RequestNode | ResponseNode): ChatNodeToolbarAction[];
|
||||
}
|
||||
|
||||
export namespace ChatNodeToolbarCommands {
|
||||
const CHAT_NODE_TOOLBAR_CATEGORY = 'ChatNodeToolbar';
|
||||
const CHAT_NODE_TOOLBAR_CATEGORY_KEY = nls.getDefaultKey(CHAT_NODE_TOOLBAR_CATEGORY);
|
||||
|
||||
export const EDIT = Command.toLocalizedCommand({
|
||||
id: 'chat:node:toolbar:edit-request',
|
||||
category: CHAT_NODE_TOOLBAR_CATEGORY,
|
||||
}, '', CHAT_NODE_TOOLBAR_CATEGORY_KEY);
|
||||
|
||||
export const CANCEL = Command.toLocalizedCommand({
|
||||
id: 'chat:node:toolbar:cancel-request',
|
||||
category: CHAT_NODE_TOOLBAR_CATEGORY,
|
||||
}, '', CHAT_NODE_TOOLBAR_CATEGORY_KEY);
|
||||
|
||||
export const RETRY = Command.toLocalizedCommand({
|
||||
id: 'chat:node:toolbar:retry-message',
|
||||
category: CHAT_NODE_TOOLBAR_CATEGORY,
|
||||
}, 'Retry', CHAT_NODE_TOOLBAR_CATEGORY_KEY);
|
||||
}
|
||||
|
||||
export class DefaultChatNodeToolbarActionContribution implements ChatNodeToolbarActionContribution {
|
||||
getToolbarActions(node: RequestNode | ResponseNode): ChatNodeToolbarAction[] {
|
||||
if (isRequestNode(node)) {
|
||||
if (EditableChatRequestModel.isEditing(node.request)) {
|
||||
return [{
|
||||
commandId: ChatNodeToolbarCommands.CANCEL.id,
|
||||
icon: codicon('close'),
|
||||
tooltip: nls.localizeByDefault('Cancel'),
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
commandId: ChatNodeToolbarCommands.EDIT.id,
|
||||
icon: codicon('edit'),
|
||||
tooltip: nls.localizeByDefault('Edit'),
|
||||
}];
|
||||
} else {
|
||||
const shouldShowRetry = node.response.isError || node.response.isCanceled;
|
||||
if (shouldShowRetry) {
|
||||
return [{
|
||||
commandId: ChatNodeToolbarCommands.RETRY.id,
|
||||
icon: codicon('refresh'),
|
||||
tooltip: nls.localizeByDefault('Retry'),
|
||||
priority: -1 // Higher priority to show it first
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
40
packages/ai-chat-ui/src/browser/chat-progress-message.tsx
Normal file
40
packages/ai-chat-ui/src/browser/chat-progress-message.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ChatProgressMessage } from '@theia/ai-chat';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
|
||||
export type ProgressMessageProps = Omit<ChatProgressMessage, 'kind' | 'id' | 'show'>;
|
||||
|
||||
export const ProgressMessage = (c: ProgressMessageProps) => (
|
||||
<div className='theia-ResponseNode-ProgressMessage'>
|
||||
<Indicator {...c} /> {c.content}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Indicator = (progressMessage: ProgressMessageProps) => (
|
||||
<span className='theia-ResponseNode-ProgressMessage-Indicator'>
|
||||
{progressMessage.status === 'inProgress' &&
|
||||
<i className={'fa fa-spinner fa-spin ' + progressMessage.status}></i>
|
||||
}
|
||||
{progressMessage.status === 'completed' &&
|
||||
<i className={'fa fa-check ' + progressMessage.status}></i>
|
||||
}
|
||||
{progressMessage.status === 'failed' &&
|
||||
<i className={'fa fa-warning ' + progressMessage.status}></i>
|
||||
}
|
||||
</span>
|
||||
);
|
||||
@@ -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 { 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<T extends ChatResponseContent> {
|
||||
canHandle(response: ChatResponseContent): number;
|
||||
render(response: T, parentNode: ResponseNode): ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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, RecursivePartial, URI } from '@theia/core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { EditorOpenerOptions, EditorWidget, Range } from '@theia/editor/lib/browser';
|
||||
|
||||
import { EditorSelectionResolver } from '@theia/editor/lib/browser/editor-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+))?)?$/;
|
||||
|
||||
@injectable()
|
||||
export class GitHubSelectionResolver implements EditorSelectionResolver {
|
||||
priority = 100;
|
||||
|
||||
async resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise<RecursivePartial<Range> | 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 EditorSelectionResolver {
|
||||
priority = 50;
|
||||
|
||||
@inject(MonacoToProtocolConverter) protected readonly m2p: MonacoToProtocolConverter;
|
||||
|
||||
async resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise<RecursivePartial<Range> | 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 EditorSelectionResolver {
|
||||
async resolveSelection(widget: EditorWidget, options: EditorOpenerOptions, uri?: URI): Promise<RecursivePartial<Range> | 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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,
|
||||
} from '@theia/ai-chat/lib/common';
|
||||
import { ContributionProvider, 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, named } from '@theia/core/shared/inversify';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import { Position } from '@theia/core/shared/vscode-languageserver-protocol';
|
||||
import { EditorManager, EditorWidget } from '@theia/editor/lib/browser';
|
||||
import { SimpleMonacoEditor } from '@theia/monaco/lib/browser/simple-monaco-editor';
|
||||
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
|
||||
import { MonacoLanguages } from '@theia/monaco/lib/browser/monaco-languages';
|
||||
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
|
||||
import { ChatViewTreeWidget, ResponseNode } from '../chat-tree-view/chat-view-tree-widget';
|
||||
import { IMouseEvent } from '@theia/monaco-editor-core';
|
||||
|
||||
export const CodePartRendererAction = Symbol('CodePartRendererAction');
|
||||
/**
|
||||
* The CodePartRenderer offers to contribute arbitrary React nodes to the rendered code part.
|
||||
* Technically anything can be rendered, however it is intended to be used for actions, like
|
||||
* "Copy to Clipboard" or "Insert at Cursor".
|
||||
*/
|
||||
export interface CodePartRendererAction {
|
||||
render(response: CodeChatResponseContent, parentNode: ResponseNode): ReactNode;
|
||||
/**
|
||||
* Determines if the action should be rendered for the given response.
|
||||
*/
|
||||
canRender?(response: CodeChatResponseContent, parentNode: ResponseNode): boolean;
|
||||
/**
|
||||
* The priority determines the order in which the actions are rendered.
|
||||
* The default priorities are 10 and 20.
|
||||
*/
|
||||
priority: number;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class CodePartRenderer
|
||||
implements ChatResponsePartRenderer<CodeChatResponseContent> {
|
||||
|
||||
@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;
|
||||
@inject(ContributionProvider) @named(CodePartRendererAction)
|
||||
protected readonly codePartRendererActions: ContributionProvider<CodePartRendererAction>;
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (CodeChatResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render(response: CodeChatResponseContent, parentNode: ResponseNode): ReactNode {
|
||||
const language = response.language ? this.languageService.getExtension(response.language) : undefined;
|
||||
return (
|
||||
<div className="theia-CodePartRenderer-root">
|
||||
<div className="theia-CodePartRenderer-top">
|
||||
<div className="theia-CodePartRenderer-left">{this.renderTitle(response)}</div>
|
||||
<div className="theia-CodePartRenderer-right theia-CodePartRenderer-actions">
|
||||
{this.codePartRendererActions.getContributions()
|
||||
.filter(action => action.canRender ? action.canRender(response, parentNode) : true)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map(action => action.render(response, parentNode))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="theia-CodePartRenderer-separator"></div>
|
||||
<div className="theia-CodePartRenderer-bottom">
|
||||
<CodeWrapper
|
||||
content={response.code}
|
||||
language={language}
|
||||
editorProvider={this.editorProvider}
|
||||
untitledResourceResolver={this.untitledResourceResolver}
|
||||
contextMenuCallback={e => this.handleContextMenuEvent(parentNode, e, response.code)}></CodeWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected renderTitle(response: CodeChatResponseContent): ReactNode {
|
||||
const uri = response.location?.uri;
|
||||
const position = response.location?.position;
|
||||
if (uri && position) {
|
||||
return <a onClick={this.openFileAtPosition.bind(this, uri, position)}>{this.getTitle(response.location?.uri, response.language)}</a>;
|
||||
}
|
||||
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 ?? nls.localize('theia/ai/chat-ui/code-part-renderer/generatedCode', '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<void> {
|
||||
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 }],
|
||||
context: event.target
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class CopyToClipboardButtonAction implements CodePartRendererAction {
|
||||
@inject(ClipboardService)
|
||||
protected readonly clipboardService: ClipboardService;
|
||||
priority = 10;
|
||||
render(response: CodeChatResponseContent): ReactNode {
|
||||
return <CopyToClipboardButton key='copyToClipBoard' code={response.code} clipboardService={this.clipboardService} />;
|
||||
}
|
||||
}
|
||||
|
||||
const CopyToClipboardButton = (props: { code: string, clipboardService: ClipboardService }) => {
|
||||
const { code, clipboardService } = props;
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (timeoutRef.current !== undefined) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const copyCodeToClipboard = React.useCallback(() => {
|
||||
clipboardService.writeText(code);
|
||||
setCopied(true);
|
||||
if (timeoutRef.current !== undefined) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
timeoutRef.current = undefined;
|
||||
}, 2000);
|
||||
}, [code, clipboardService]);
|
||||
|
||||
const iconClass = copied ? 'codicon-check' : 'codicon-copy';
|
||||
const title = copied ? nls.localize('theia/ai/chat-ui/code-part-renderer/copied', 'Copied') : nls.localizeByDefault('Copy');
|
||||
return <div className={`button codicon ${iconClass}`} title={title} role='button' onClick={copyCodeToClipboard}></div>;
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class InsertCodeAtCursorButtonAction implements CodePartRendererAction {
|
||||
@inject(EditorManager)
|
||||
protected readonly editorManager: EditorManager;
|
||||
priority = 20;
|
||||
render(response: CodeChatResponseContent): ReactNode {
|
||||
return <InsertCodeAtCursorButton key='insertCodeAtCursor' code={response.code} editorManager={this.editorManager} />;
|
||||
}
|
||||
}
|
||||
|
||||
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 <div className='button codicon codicon-insert' title={nls.localizeByDefault('Insert At Cursor')} role='button' onClick={insertCode}></div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<HTMLDivElement | null>(null);
|
||||
const editorRef = React.useRef<SimpleMonacoEditor | undefined>(undefined);
|
||||
|
||||
const createInputElement = async () => {
|
||||
const resource = await props.untitledResourceResolver.createUntitledResource(undefined, props.language);
|
||||
const editor = await props.editorProvider.createSimpleInline(resource.uri, ref.current!, {
|
||||
readOnly: true,
|
||||
autoSizing: true,
|
||||
scrollBeyondLastLine: false,
|
||||
scrollBeyondLastColumn: 0,
|
||||
renderFinalNewline: 'off',
|
||||
maxHeight: -1,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
alwaysConsumeMouseWheel: false
|
||||
},
|
||||
wordWrap: 'off',
|
||||
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 <div className='theia-CodeWrapper' ref={ref}></div>;
|
||||
};
|
||||
@@ -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 '../chat-response-part-renderer';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatResponseContent, 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, nls } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class CommandPartRenderer implements ChatResponsePartRenderer<CommandChatResponseContent> {
|
||||
@inject(CommandService) private commandService: CommandService;
|
||||
@inject(CommandRegistry) private commandRegistry: CommandRegistry;
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (CommandChatResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
render(response: CommandChatResponseContent): ReactNode {
|
||||
const label =
|
||||
response.customCallback?.label ??
|
||||
response.command?.label ??
|
||||
response.command?.id
|
||||
.split('-')
|
||||
.map(s => s[0].toUpperCase() + s.substring(1))
|
||||
.join(' ') ?? nls.localizeByDefault('Execute Command');
|
||||
if (!response.customCallback && response.command) {
|
||||
const isCommandEnabled = this.commandRegistry.isEnabled(response.command.id);
|
||||
if (!isCommandEnabled) {
|
||||
return <div>{nls.localize('theia/ai/chat-ui/command-part-renderer/commandNotExecutable',
|
||||
'The command has the id "{0}" but it is not executable from the Chat window.', response.command.id)}</div>;
|
||||
|
||||
}
|
||||
}
|
||||
return <button className='theia-button main' onClick={this.onCommand.bind(this, response)}>{label}</button>;
|
||||
}
|
||||
private onCommand(arg: CommandChatResponseContent): void {
|
||||
if (arg.customCallback) {
|
||||
arg.customCallback.callback().catch(e => { console.error(e); });
|
||||
} else if (arg.command) {
|
||||
this.commandService.executeCommand(arg.command.id, ...(arg.arguments ?? [])).catch(e => { console.error(e); });
|
||||
} else {
|
||||
console.warn('No command or custom callback provided in command chat response content');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatRequestInvocation, ChatResponseContent, ChatResponseModel } from '@theia/ai-chat';
|
||||
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { DelegationResponseContent, isDelegationResponseContent } from '@theia/ai-chat/lib/browser/delegation-response-content';
|
||||
import { ResponseNode } from '../chat-tree-view';
|
||||
import { CompositeTreeNode } from '@theia/core/lib/browser';
|
||||
import { SubChatWidgetFactory } from '../chat-tree-view/sub-chat-widget';
|
||||
import { DisposableCollection, nls } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class DelegationResponseRenderer implements ChatResponsePartRenderer<DelegationResponseContent> {
|
||||
|
||||
@inject(SubChatWidgetFactory)
|
||||
subChatWidgetFactory: SubChatWidgetFactory;
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (isDelegationResponseContent(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
render(response: DelegationResponseContent, parentNode: ResponseNode): React.ReactNode {
|
||||
return this.renderExpandableNode(response, parentNode);
|
||||
}
|
||||
|
||||
private renderExpandableNode(response: DelegationResponseContent, parentNode: ResponseNode): React.ReactNode {
|
||||
return <DelegatedChat
|
||||
response={response.response}
|
||||
agentId={response.agentId}
|
||||
prompt={response.prompt}
|
||||
parentNode={parentNode}
|
||||
subChatWidgetFactory={this.subChatWidgetFactory} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface DelegatedChatProps {
|
||||
response: ChatRequestInvocation;
|
||||
agentId: string;
|
||||
prompt: string;
|
||||
parentNode: ResponseNode;
|
||||
subChatWidgetFactory: SubChatWidgetFactory;
|
||||
}
|
||||
|
||||
interface DelegatedChatState {
|
||||
node?: ResponseNode;
|
||||
}
|
||||
|
||||
class DelegatedChat extends React.Component<DelegatedChatProps, DelegatedChatState> {
|
||||
private widget: ReturnType<SubChatWidgetFactory>;
|
||||
private readonly toDispose = new DisposableCollection();
|
||||
|
||||
constructor(props: DelegatedChatProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
node: undefined
|
||||
};
|
||||
this.widget = props.subChatWidgetFactory();
|
||||
}
|
||||
|
||||
override componentDidMount(): void {
|
||||
// Start rendering as soon as the response is created (streaming mode)
|
||||
this.props.response.responseCreated.then(chatModel => {
|
||||
const node = mapResponseToNode(chatModel, this.props.parentNode);
|
||||
this.setState({ node });
|
||||
|
||||
// Listen for changes to update the rendering as the response streams in
|
||||
const changeListener = () => {
|
||||
// Force re-render when the response content changes
|
||||
this.forceUpdate();
|
||||
};
|
||||
this.toDispose.push(chatModel.onDidChange(changeListener));
|
||||
}).catch(error => {
|
||||
console.error('Failed to create delegated chat response:', error);
|
||||
// Still try to handle completion in case of partial success
|
||||
});
|
||||
|
||||
// Keep the completion handling for final cleanup if needed
|
||||
this.props.response.responseCompleted.then(() => {
|
||||
// Final update when response is complete
|
||||
this.forceUpdate();
|
||||
}).catch(error => {
|
||||
console.error('Error in delegated chat response completion:', error);
|
||||
// Force update anyway to show any partial content or error state
|
||||
this.forceUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
override componentWillUnmount(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
|
||||
override render(): React.ReactNode {
|
||||
const { agentId, prompt } = this.props;
|
||||
const hasNode = !!this.state.node;
|
||||
const isComplete = this.state.node?.response.isComplete ?? false;
|
||||
const isCanceled = this.state.node?.response.isCanceled ?? false;
|
||||
const isError = this.state.node?.response.isError ?? false;
|
||||
|
||||
let statusIcon = '';
|
||||
let statusText = '';
|
||||
if (hasNode) {
|
||||
if (isCanceled) {
|
||||
statusIcon = 'codicon-close';
|
||||
statusText = nls.localize('theia/ai/chat-ui/delegation-response-renderer/status/canceled', 'canceled');
|
||||
} else if (isComplete) {
|
||||
statusIcon = 'codicon-check';
|
||||
statusText = nls.localizeByDefault('completed');
|
||||
} else if (isError) {
|
||||
statusIcon = 'codicon-error';
|
||||
statusText = nls.localize('theia/ai/chat-ui/delegation-response-renderer/status/error', 'error');
|
||||
} else {
|
||||
statusIcon = 'codicon-loading';
|
||||
statusText = nls.localize('theia/ai/chat-ui/delegation-response-renderer/status/generating', 'generating...');
|
||||
}
|
||||
} else {
|
||||
statusIcon = 'codicon-loading';
|
||||
statusText = nls.localize('theia/ai/chat-ui/delegation-response-renderer/status/starting', 'starting...');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theia-delegation-container">
|
||||
<details className="delegation-response-details">
|
||||
<summary className="delegation-summary">
|
||||
<div className="delegation-header">
|
||||
<span className="delegation-agent">
|
||||
<span className="codicon codicon-copilot-large" /> {agentId}
|
||||
</span>
|
||||
<span className="delegation-status">
|
||||
<span className={`codicon ${statusIcon} delegation-status-icon`}></span>
|
||||
<span className="delegation-status-text">{statusText}</span>
|
||||
</span>
|
||||
</div>
|
||||
</summary>
|
||||
<div className="delegation-content">
|
||||
<div className="delegation-prompt-section">
|
||||
<strong>{nls.localize('theia/ai/chat-ui/delegation-response-renderer/prompt/label', 'Delegated prompt:')}</strong>
|
||||
<div className="delegation-prompt">{prompt}</div>
|
||||
</div>
|
||||
<div className="delegation-response-section">
|
||||
<strong>{nls.localize('theia/ai/chat-ui/delegation-response-renderer/response/label', 'Response:')}</strong>
|
||||
<div className='delegation-response-placeholder'>
|
||||
{hasNode && this.state.node ? this.widget.renderChatResponse(this.state.node) :
|
||||
<div className="theia-ChatContentInProgress">
|
||||
{nls.localize('theia/ai/chat-ui/delegation-response-renderer/starting', 'Starting delegation...')}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapResponseToNode(response: ChatResponseModel, parentNode: ResponseNode): ResponseNode {
|
||||
return {
|
||||
id: response.id,
|
||||
parent: parentNode as unknown as CompositeTreeNode,
|
||||
response,
|
||||
sessionId: parentNode.sessionId
|
||||
};
|
||||
}
|
||||
@@ -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 '../chat-response-part-renderer';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatResponseContent, ErrorChatResponseContent } 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<ErrorChatResponseContent> {
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (ErrorChatResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
render(response: ErrorChatResponseContent): ReactNode {
|
||||
return <div className='theia-ChatPart-Error'><span className='codicon codicon-error' /><span>{response.error.message}</span></div>;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 '../chat-response-part-renderer';
|
||||
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
ChatResponseContent,
|
||||
HorizontalLayoutChatResponseContent,
|
||||
} 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<ChatResponseContent> {
|
||||
@inject(ContributionProvider)
|
||||
@named(ChatResponsePartRenderer)
|
||||
protected readonly chatResponsePartRenderers: ContributionProvider<
|
||||
ChatResponsePartRenderer<ChatResponseContent>
|
||||
>;
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (HorizontalLayoutChatResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
render(response: HorizontalLayoutChatResponseContent, parentNode: ResponseNode): ReactNode {
|
||||
const contributions = this.chatResponsePartRenderers.getContributions();
|
||||
return (
|
||||
<div className="ai-chat-horizontal-layout" style={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap' }}>
|
||||
{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);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 './ai-selection-resolver';
|
||||
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';
|
||||
export * from './not-available-toolcall-renderer';
|
||||
export * from './thinking-part-renderer';
|
||||
export * from './progress-part-renderer';
|
||||
export * from './tool-confirmation';
|
||||
export * from './delegation-response-renderer';
|
||||
@@ -0,0 +1,137 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 '../chat-response-part-renderer';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
ChatResponseContent,
|
||||
InformationalChatResponseContent,
|
||||
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 * as markdownit from '@theia/core/shared/markdown-it';
|
||||
import * as markdownitemoji from '@theia/core/shared/markdown-it-emoji';
|
||||
import * as DOMPurify from '@theia/core/shared/dompurify';
|
||||
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
|
||||
import { OpenerService, open } from '@theia/core/lib/browser';
|
||||
import { URI } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class MarkdownPartRenderer implements ChatResponsePartRenderer<MarkdownChatResponseContent | InformationalChatResponseContent> {
|
||||
@inject(OpenerService) protected readonly openerService: OpenerService;
|
||||
protected readonly markdownIt = markdownit().use(markdownitemoji.full);
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (MarkdownChatResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
if (InformationalChatResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
render(response: MarkdownChatResponseContent | InformationalChatResponseContent): ReactNode {
|
||||
// TODO let the user configure whether they want to see informational content
|
||||
if (InformationalChatResponseContent.is(response)) {
|
||||
// null is valid in React
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MarkdownRender response={response} openerService={this.openerService} />;
|
||||
}
|
||||
}
|
||||
|
||||
const MarkdownRender = ({ response, openerService }: { response: MarkdownChatResponseContent | InformationalChatResponseContent; openerService: OpenerService }) => {
|
||||
const ref = useMarkdownRendering(response.content, openerService);
|
||||
|
||||
return <div ref={ref}></div>;
|
||||
};
|
||||
|
||||
export interface DeclaredEventsEventListenerObject extends EventListenerObject {
|
||||
handledEvents?: (keyof HTMLElementEventMap)[];
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook uses markdown-it directly to render markdown.
|
||||
* The reason to use markdown-it directly is that the MarkdownRenderer is
|
||||
* overridden by theia with a monaco version. This monaco version strips all html
|
||||
* tags from the markdown with empty content. This leads to unexpected behavior when
|
||||
* rendering markdown with html tags.
|
||||
*
|
||||
* Moreover, we want to intercept link clicks to use the Theia OpenerService instead of the default browser behavior.
|
||||
*
|
||||
* @param markdown the string to render as markdown
|
||||
* @param skipSurroundingParagraph whether to remove a surrounding paragraph element (default: false)
|
||||
* @param openerService the service to handle link opening
|
||||
* @param eventHandler `handleEvent` will be called by default for `click` events and additionally
|
||||
* for all events enumerated in {@link DeclaredEventsEventListenerObject.handledEvents}. If `handleEvent` returns `true`,
|
||||
* no additional handlers will be run for the event.
|
||||
* @returns the ref to use in an element to render the markdown
|
||||
*/
|
||||
export const useMarkdownRendering = (
|
||||
markdown: string | MarkdownString,
|
||||
openerService: OpenerService,
|
||||
skipSurroundingParagraph: boolean = false,
|
||||
eventHandler?: DeclaredEventsEventListenerObject
|
||||
) => {
|
||||
// null is valid in React
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const markdownString = typeof markdown === 'string' ? markdown : markdown.value;
|
||||
useEffect(() => {
|
||||
const markdownIt = markdownit().use(markdownitemoji.full);
|
||||
const host = document.createElement('div');
|
||||
|
||||
// markdownIt always puts the content in a paragraph element, so we remove it if we don't want that
|
||||
const html = skipSurroundingParagraph ? markdownIt.render(markdownString).replace(/^<p>|<\/p>|<p><\/p>$/g, '') : markdownIt.render(markdownString);
|
||||
|
||||
host.innerHTML = DOMPurify.sanitize(html, {
|
||||
// DOMPurify usually strips non http(s) links from hrefs
|
||||
// but we want to allow them (see handleClick via OpenerService below)
|
||||
ALLOW_UNKNOWN_PROTOCOLS: true
|
||||
});
|
||||
while (ref?.current?.firstChild) {
|
||||
ref.current.removeChild(ref.current.firstChild);
|
||||
}
|
||||
ref?.current?.appendChild(host);
|
||||
|
||||
// intercept link clicks to use the Theia OpenerService instead of the default browser behavior
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if ((eventHandler?.handleEvent(event) as unknown) === true) { return; }
|
||||
let target = event.target as HTMLElement;
|
||||
while (target && target.tagName !== 'A') {
|
||||
target = target.parentElement as HTMLElement;
|
||||
}
|
||||
if (target && target.tagName === 'A') {
|
||||
const href = target.getAttribute('href');
|
||||
if (href) {
|
||||
open(openerService, new URI(href));
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ref?.current?.addEventListener('click', handleClick);
|
||||
eventHandler?.handledEvents?.forEach(eventType => eventType !== 'click' && ref?.current?.addEventListener(eventType, eventHandler));
|
||||
return () => {
|
||||
ref.current?.removeEventListener('click', handleClick);
|
||||
eventHandler?.handledEvents?.forEach(eventType => eventType !== 'click' && ref?.current?.removeEventListener(eventType, eventHandler));
|
||||
};
|
||||
}, [markdownString, skipSurroundingParagraph, openerService]);
|
||||
|
||||
return ref;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 '../chat-response-part-renderer';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import { codicon } from '@theia/core/lib/browser';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ResponseNode } from '../chat-tree-view';
|
||||
|
||||
/**
|
||||
* High-priority renderer for tool calls that were not available.
|
||||
*
|
||||
* This handles cases where the LLM attempted to call a tool that was not
|
||||
* made available to it in the request. This takes priority over all other
|
||||
* tool renderers (including specialized ones like ShellExecutionToolRenderer)
|
||||
* since unavailable tools should never be processed by tool-specific renderers.
|
||||
*/
|
||||
@injectable()
|
||||
export class NotAvailableToolCallRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (ToolCallChatResponseContent.is(response) && response.finished) {
|
||||
if (ToolCallChatResponseContent.isNotAvailableResult(response.result)) {
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render(response: ToolCallChatResponseContent, _parentNode: ResponseNode): ReactNode {
|
||||
const errorMessage = ToolCallChatResponseContent.getErrorMessage(response.result);
|
||||
return (
|
||||
<div className='theia-toolCall'>
|
||||
<span className='theia-toolCall-unavailable'>
|
||||
<span className={codicon('warning')}></span>
|
||||
{' '}
|
||||
{response.name}: {errorMessage}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatResponseContent, ProgressChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ProgressMessage } from '../chat-progress-message';
|
||||
|
||||
@injectable()
|
||||
export class ProgressPartRenderer implements ChatResponsePartRenderer<ProgressChatResponseContent> {
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (ProgressChatResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render(response: ProgressChatResponseContent): ReactNode {
|
||||
return (
|
||||
<ProgressMessage content={response.message} status='inProgress' />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { ChatResponseContent, QuestionResponseContent } from '@theia/ai-chat';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
|
||||
import { ResponseNode } from '../chat-tree-view';
|
||||
|
||||
@injectable()
|
||||
export class QuestionPartRenderer
|
||||
implements ChatResponsePartRenderer<QuestionResponseContent> {
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (QuestionResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render(question: QuestionResponseContent, node: ResponseNode): ReactNode {
|
||||
const isDisabled = question.isReadOnly || question.selectedOption !== undefined || !node.response.isWaitingForInput;
|
||||
|
||||
return (
|
||||
<div className="theia-QuestionPartRenderer-root">
|
||||
<div className="theia-QuestionPartRenderer-question">{question.question}</div>
|
||||
<div className="theia-QuestionPartRenderer-options">
|
||||
{
|
||||
question.options.map((option, index) => (
|
||||
<button
|
||||
className={`theia-button theia-QuestionPartRenderer-option ${question.selectedOption?.text === option.text ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (!question.isReadOnly && question.handler) {
|
||||
question.selectedOption = option;
|
||||
question.handler(option);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
key={index}
|
||||
>
|
||||
{option.text}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 '../chat-response-part-renderer';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
|
||||
@injectable()
|
||||
export class TextPartRenderer implements ChatResponsePartRenderer<ChatResponseContent> {
|
||||
canHandle(_reponse: ChatResponseContent): number {
|
||||
// this is the fallback renderer
|
||||
return 1;
|
||||
}
|
||||
render(response: ChatResponseContent): ReactNode {
|
||||
if (response && ChatResponseContent.hasAsString(response)) {
|
||||
return <span>{response.asString()}</span>;
|
||||
}
|
||||
return <span>
|
||||
{nls.localize('theia/ai/chat-ui/text-part-renderer/cantDisplay',
|
||||
"Can't display response, please check your ChatResponsePartRenderers!")} {JSON.stringify(response)}</span>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatResponseContent, ThinkingChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
|
||||
@injectable()
|
||||
export class ThinkingPartRenderer implements ChatResponsePartRenderer<ThinkingChatResponseContent> {
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (ThinkingChatResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render(response: ThinkingChatResponseContent): ReactNode {
|
||||
return (
|
||||
<div className='theia-thinking'>
|
||||
<details>
|
||||
<summary>{nls.localize('theia/ai/chat-ui/thinking-part-renderer/thinking', 'Thinking')}</summary>
|
||||
<pre>{response.content}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2026 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 { expect } from 'chai';
|
||||
import { ContextMenuRenderer } from '@theia/core/lib/browser';
|
||||
import { ConfirmationScope, ToolConfirmationCallbacks, ToolConfirmationActionsProps, ToolConfirmationProps } from './tool-confirmation';
|
||||
|
||||
const mockContextMenuRenderer = {} as ContextMenuRenderer;
|
||||
|
||||
describe('Tool Confirmation Types', () => {
|
||||
describe('ConfirmationScope', () => {
|
||||
it('should accept valid scopes', () => {
|
||||
const scopes: ConfirmationScope[] = ['once', 'session', 'forever'];
|
||||
expect(scopes).to.have.length(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolConfirmationCallbacks', () => {
|
||||
it('should define required callback properties', () => {
|
||||
const callbacks: ToolConfirmationCallbacks = {
|
||||
onAllow: (_scope: ConfirmationScope) => { },
|
||||
onDeny: (_scope: ConfirmationScope, _reason?: string) => { }
|
||||
};
|
||||
expect(callbacks.onAllow).to.be.a('function');
|
||||
expect(callbacks.onDeny).to.be.a('function');
|
||||
});
|
||||
|
||||
it('should allow optional toolRequest', () => {
|
||||
const callbacks: ToolConfirmationCallbacks = {
|
||||
toolRequest: { id: 'test', name: 'test', handler: async () => '', parameters: { type: 'object', properties: {} } },
|
||||
onAllow: () => { },
|
||||
onDeny: () => { }
|
||||
};
|
||||
expect(callbacks.toolRequest).to.exist;
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolConfirmationActionsProps', () => {
|
||||
it('should extend ToolConfirmationCallbacks with toolName', () => {
|
||||
const props: ToolConfirmationActionsProps = {
|
||||
toolName: 'testTool',
|
||||
onAllow: () => { },
|
||||
onDeny: () => { },
|
||||
contextMenuRenderer: mockContextMenuRenderer
|
||||
};
|
||||
expect(props.toolName).to.equal('testTool');
|
||||
});
|
||||
|
||||
it('should support confirmAlwaysAllow string in toolRequest', () => {
|
||||
const props: ToolConfirmationActionsProps = {
|
||||
toolName: 'dangerousTool',
|
||||
toolRequest: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
handler: async () => '',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
confirmAlwaysAllow: 'This tool can modify system files.'
|
||||
},
|
||||
onAllow: () => { },
|
||||
onDeny: () => { },
|
||||
contextMenuRenderer: mockContextMenuRenderer
|
||||
};
|
||||
expect(props.toolRequest?.confirmAlwaysAllow).to.equal('This tool can modify system files.');
|
||||
});
|
||||
|
||||
it('should support confirmAlwaysAllow boolean in toolRequest', () => {
|
||||
const props: ToolConfirmationActionsProps = {
|
||||
toolName: 'dangerousTool',
|
||||
toolRequest: {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
handler: async () => '',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
confirmAlwaysAllow: true
|
||||
},
|
||||
onAllow: () => { },
|
||||
onDeny: () => { },
|
||||
contextMenuRenderer: mockContextMenuRenderer
|
||||
};
|
||||
expect(props.toolRequest?.confirmAlwaysAllow).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolConfirmationProps', () => {
|
||||
it('should pick toolRequest from ToolConfirmationCallbacks', () => {
|
||||
const props: ToolConfirmationProps = {
|
||||
response: { kind: 'toolCall', id: 'test', name: 'test' } as ToolConfirmationProps['response'],
|
||||
onAllow: () => { },
|
||||
onDeny: () => { },
|
||||
contextMenuRenderer: mockContextMenuRenderer
|
||||
};
|
||||
expect(props.toolRequest).to.be.undefined;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,459 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import { codicon, ContextMenuRenderer } from '@theia/core/lib/browser';
|
||||
import { ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
import { ToolRequest } from '@theia/ai-core';
|
||||
import { CommandMenu, ContextExpressionMatcher, MenuPath } from '@theia/core/lib/common/menu';
|
||||
import { GroupImpl } from '@theia/core/lib/browser/menu/composite-menu-node';
|
||||
import { ToolConfirmationMode as ToolConfirmationPreferenceMode } from '@theia/ai-chat/lib/common/chat-tool-preferences';
|
||||
import { ToolConfirmationManager } from '@theia/ai-chat/lib/browser/chat-tool-preference-bindings';
|
||||
|
||||
export type ToolConfirmationState = 'waiting' | 'allowed' | 'denied' | 'rejected';
|
||||
|
||||
export type ConfirmationScope = 'once' | 'session' | 'forever';
|
||||
|
||||
export interface ToolConfirmationCallbacks {
|
||||
toolRequest?: ToolRequest;
|
||||
onAllow: (scope: ConfirmationScope) => void;
|
||||
onDeny: (scope: ConfirmationScope, reason?: string) => void;
|
||||
}
|
||||
|
||||
export interface ToolConfirmationActionsProps extends ToolConfirmationCallbacks {
|
||||
toolName: string;
|
||||
contextMenuRenderer: ContextMenuRenderer;
|
||||
}
|
||||
|
||||
class InlineActionMenuNode implements CommandMenu {
|
||||
constructor(
|
||||
readonly id: string,
|
||||
readonly label: string,
|
||||
private readonly action: () => void,
|
||||
readonly sortString: string,
|
||||
readonly icon?: string
|
||||
) { }
|
||||
|
||||
isVisible<T>(_effectiveMenuPath: MenuPath, _contextMatcher: ContextExpressionMatcher<T>, _context: T | undefined): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
isToggled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
this.action();
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolConfirmationActions: React.FC<ToolConfirmationActionsProps> = ({
|
||||
toolName,
|
||||
toolRequest,
|
||||
onAllow,
|
||||
onDeny,
|
||||
contextMenuRenderer
|
||||
}) => {
|
||||
const [allowScope, setAllowScope] = React.useState<ConfirmationScope>('once');
|
||||
const [denyScope, setDenyScope] = React.useState<ConfirmationScope>('once');
|
||||
const [showAlwaysAllowConfirmation, setShowAlwaysAllowConfirmation] = React.useState(false);
|
||||
const [showDenyReasonInput, setShowDenyReasonInput] = React.useState(false);
|
||||
const [denyReason, setDenyReason] = React.useState('');
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const denyReasonInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAllow = React.useCallback(() => {
|
||||
if ((allowScope === 'forever' || allowScope === 'session') && toolRequest?.confirmAlwaysAllow) {
|
||||
setShowAlwaysAllowConfirmation(true);
|
||||
return;
|
||||
}
|
||||
onAllow(allowScope);
|
||||
}, [onAllow, allowScope, toolRequest]);
|
||||
|
||||
const handleConfirmAlwaysAllow = React.useCallback(() => {
|
||||
setShowAlwaysAllowConfirmation(false);
|
||||
onAllow(allowScope);
|
||||
}, [onAllow, allowScope]);
|
||||
|
||||
const handleCancelAlwaysAllow = React.useCallback(() => {
|
||||
setShowAlwaysAllowConfirmation(false);
|
||||
}, []);
|
||||
|
||||
const handleDeny = React.useCallback(() => {
|
||||
onDeny(denyScope);
|
||||
}, [onDeny, denyScope]);
|
||||
|
||||
const handleDenyWithReason = React.useCallback(() => {
|
||||
setShowDenyReasonInput(true);
|
||||
}, []);
|
||||
|
||||
const handleSubmitDenyReason = React.useCallback(() => {
|
||||
onDeny('once', denyReason.trim() || undefined);
|
||||
setShowDenyReasonInput(false);
|
||||
setDenyReason('');
|
||||
}, [onDeny, denyReason]);
|
||||
|
||||
const handleCancelDenyReason = React.useCallback(() => {
|
||||
setShowDenyReasonInput(false);
|
||||
setDenyReason('');
|
||||
}, []);
|
||||
|
||||
const handleDenyReasonKeyDown = React.useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmitDenyReason();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelDenyReason();
|
||||
}
|
||||
}, [handleSubmitDenyReason, handleCancelDenyReason]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showDenyReasonInput && denyReasonInputRef.current) {
|
||||
denyReasonInputRef.current.focus();
|
||||
}
|
||||
}, [showDenyReasonInput]);
|
||||
|
||||
const SCOPES: ConfirmationScope[] = ['once', 'session', 'forever'];
|
||||
|
||||
const scopeLabel = (type: 'allow' | 'deny', scope: ConfirmationScope): string => {
|
||||
if (type === 'allow') {
|
||||
switch (scope) {
|
||||
case 'once': return nls.localizeByDefault('Allow');
|
||||
case 'session': return nls.localize('theia/ai/chat-ui/toolconfirmation/allow-session', 'Allow for this Chat');
|
||||
case 'forever': return nls.localizeByDefault('Always Allow');
|
||||
}
|
||||
} else {
|
||||
switch (scope) {
|
||||
case 'once': return nls.localizeByDefault('Deny');
|
||||
case 'session': return nls.localize('theia/ai/chat-ui/toolconfirmation/deny-session', 'Deny for this Chat');
|
||||
case 'forever': return nls.localize('theia/ai/chat-ui/toolconfirmation/deny-forever', 'Always Deny');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getAlwaysAllowWarning = (): string => {
|
||||
if (typeof toolRequest?.confirmAlwaysAllow === 'string') {
|
||||
return toolRequest.confirmAlwaysAllow;
|
||||
}
|
||||
return nls.localize(
|
||||
'theia/ai/chat-ui/toolconfirmation/alwaysAllowGenericWarning',
|
||||
'This tool requires confirmation before auto-approval can be enabled. ' +
|
||||
'Once enabled, all future invocations will execute without confirmation. ' +
|
||||
'Only enable this if you trust this tool and understand the potential risks.'
|
||||
);
|
||||
};
|
||||
|
||||
const showDropdownMenu = React.useCallback((
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
type: 'allow' | 'deny',
|
||||
selectedScope: ConfirmationScope,
|
||||
setScope: (scope: ConfirmationScope) => void
|
||||
) => {
|
||||
const otherScopes = SCOPES.filter(s => s !== selectedScope);
|
||||
const menu = new GroupImpl('tool-confirmation-dropdown');
|
||||
|
||||
const scopesGroup = new GroupImpl('scopes', '1');
|
||||
otherScopes.forEach((scope, index) => {
|
||||
scopesGroup.addNode(new InlineActionMenuNode(
|
||||
`tool-confirmation-${type}-${scope}`,
|
||||
scopeLabel(type, scope),
|
||||
() => setScope(scope),
|
||||
String(index)
|
||||
));
|
||||
});
|
||||
menu.addNode(scopesGroup);
|
||||
|
||||
if (type === 'deny') {
|
||||
const reasonGroup = new GroupImpl('reason', '2');
|
||||
reasonGroup.addNode(new InlineActionMenuNode(
|
||||
'tool-confirmation-deny-with-reason',
|
||||
nls.localize('theia/ai/chat-ui/toolconfirmation/deny-with-reason', 'Deny with reason...'),
|
||||
handleDenyWithReason,
|
||||
'0'
|
||||
));
|
||||
menu.addNode(reasonGroup);
|
||||
}
|
||||
|
||||
const splitButtonContainer = event.currentTarget.parentElement;
|
||||
const containerRect = splitButtonContainer?.getBoundingClientRect() ?? event.currentTarget.getBoundingClientRect();
|
||||
contextMenuRenderer.render({
|
||||
menuPath: ['tool-confirmation-context-menu'],
|
||||
menu,
|
||||
anchor: { x: containerRect.left, y: containerRect.bottom },
|
||||
context: event.currentTarget,
|
||||
skipSingleRootNode: true
|
||||
});
|
||||
}, [contextMenuRenderer, handleDenyWithReason, scopeLabel]);
|
||||
|
||||
const renderSplitButton = (type: 'allow' | 'deny'): React.ReactNode => {
|
||||
const selectedScope = type === 'allow' ? allowScope : denyScope;
|
||||
const setScope = type === 'allow' ? setAllowScope : setDenyScope;
|
||||
const handleMain = type === 'allow' ? handleAllow : handleDeny;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`theia-tool-confirmation-split-button ${type}`}
|
||||
style={{ display: 'inline-flex', position: 'relative' }}
|
||||
>
|
||||
<button
|
||||
className={`theia-button ${type === 'allow' ? 'main' : 'secondary'} theia-tool-confirmation-main-btn`}
|
||||
onClick={handleMain}
|
||||
>
|
||||
{scopeLabel(type, selectedScope)}
|
||||
</button>
|
||||
<button
|
||||
className={`theia-button ${type === 'allow' ? 'main' : 'secondary'} theia-tool-confirmation-chevron-btn`}
|
||||
onClick={e => showDropdownMenu(e, type, selectedScope, setScope)}
|
||||
aria-haspopup="true"
|
||||
tabIndex={0}
|
||||
title={type === 'allow'
|
||||
? nls.localize('theia/ai/chat-ui/toolconfirmation/allow-options-dropdown-tooltip', 'More Allow Options')
|
||||
: nls.localize('theia/ai/chat-ui/toolconfirmation/deny-options-dropdown-tooltip', 'More Deny Options')}
|
||||
>
|
||||
<span className={codicon('chevron-down')}></span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (showAlwaysAllowConfirmation) {
|
||||
return (
|
||||
<div className="theia-tool-confirmation-always-allow-modal">
|
||||
<div className="theia-tool-confirmation-header">
|
||||
<span className={codicon('warning')}></span>
|
||||
{nls.localize('theia/ai/chat-ui/toolconfirmation/alwaysAllowTitle', 'Enable Auto-Approval for "{0}"?', toolName)}
|
||||
</div>
|
||||
<div className="theia-tool-confirmation-warning">
|
||||
{getAlwaysAllowWarning()}
|
||||
</div>
|
||||
<div className="theia-tool-confirmation-actions">
|
||||
<button
|
||||
className="theia-button secondary"
|
||||
onClick={handleCancelAlwaysAllow}
|
||||
>
|
||||
{nls.localizeByDefault('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="theia-button main"
|
||||
onClick={handleConfirmAlwaysAllow}
|
||||
>
|
||||
{nls.localize('theia/ai/chat-ui/toolconfirmation/alwaysAllowConfirm', 'I understand, enable auto-approval')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showDenyReasonInput) {
|
||||
return (
|
||||
<div className="theia-tool-confirmation-deny-reason">
|
||||
<input
|
||||
ref={denyReasonInputRef}
|
||||
type="text"
|
||||
className="theia-input theia-tool-confirmation-deny-reason-input"
|
||||
placeholder={nls.localize('theia/ai/chat-ui/toolconfirmation/deny-reason-placeholder', 'Enter reason for denial...')}
|
||||
value={denyReason}
|
||||
onChange={e => setDenyReason(e.target.value)}
|
||||
onKeyDown={handleDenyReasonKeyDown}
|
||||
/>
|
||||
<div className="theia-tool-confirmation-deny-reason-actions">
|
||||
<button
|
||||
className="theia-button secondary"
|
||||
onClick={handleCancelDenyReason}
|
||||
>
|
||||
{nls.localizeByDefault('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="theia-button main"
|
||||
onClick={handleSubmitDenyReason}
|
||||
>
|
||||
{nls.localizeByDefault('Deny')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theia-tool-confirmation-actions">
|
||||
{renderSplitButton('deny')}
|
||||
{renderSplitButton('allow')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ToolConfirmationProps extends Pick<ToolConfirmationCallbacks, 'toolRequest'> {
|
||||
response: ToolCallChatResponseContent;
|
||||
onAllow: (scope?: ConfirmationScope) => void;
|
||||
onDeny: (scope?: ConfirmationScope, reason?: string) => void;
|
||||
contextMenuRenderer: ContextMenuRenderer;
|
||||
}
|
||||
|
||||
export const ToolConfirmation: React.FC<ToolConfirmationProps> = ({ response, toolRequest, onAllow, onDeny, contextMenuRenderer }) => {
|
||||
const [state, setState] = React.useState<ToolConfirmationState>('waiting');
|
||||
|
||||
const handleAllow = React.useCallback((scope: ConfirmationScope) => {
|
||||
setState('allowed');
|
||||
onAllow(scope);
|
||||
}, [onAllow]);
|
||||
|
||||
const handleDeny = React.useCallback((scope: ConfirmationScope, reason?: string) => {
|
||||
setState('denied');
|
||||
onDeny(scope, reason);
|
||||
}, [onDeny]);
|
||||
|
||||
if (state === 'allowed') {
|
||||
return (
|
||||
<div className="theia-tool-confirmation-status allowed">
|
||||
<span className={codicon('check')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/allowed', 'Tool execution allowed')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'denied') {
|
||||
return (
|
||||
<div className="theia-tool-confirmation-status denied">
|
||||
<span className={codicon('close')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/denied', 'Tool execution denied')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theia-tool-confirmation">
|
||||
<div className="theia-tool-confirmation-header">
|
||||
<span className={codicon('shield')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/header', 'Confirm Tool Execution')}
|
||||
</div>
|
||||
<div className="theia-tool-confirmation-info">
|
||||
<div className="theia-tool-confirmation-name">
|
||||
<span className="label">{nls.localizeByDefault('Tool')}:</span>
|
||||
<span className="value">{response.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToolConfirmationActions
|
||||
toolName={response.name ?? 'unknown'}
|
||||
toolRequest={toolRequest}
|
||||
onAllow={handleAllow}
|
||||
onDeny={handleDeny}
|
||||
contextMenuRenderer={contextMenuRenderer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface WithToolCallConfirmationProps {
|
||||
response: ToolCallChatResponseContent;
|
||||
confirmationMode: ToolConfirmationPreferenceMode;
|
||||
toolConfirmationManager: ToolConfirmationManager;
|
||||
toolRequest?: ToolRequest;
|
||||
chatId: string;
|
||||
requestCanceled: boolean;
|
||||
contextMenuRenderer: ContextMenuRenderer;
|
||||
}
|
||||
|
||||
export function withToolCallConfirmation<P extends object>(
|
||||
WrappedComponent: React.ComponentType<P>
|
||||
): React.FC<P & WithToolCallConfirmationProps> {
|
||||
const WithConfirmation: React.FC<P & WithToolCallConfirmationProps> = props => {
|
||||
const {
|
||||
response,
|
||||
confirmationMode,
|
||||
toolConfirmationManager,
|
||||
toolRequest,
|
||||
chatId,
|
||||
requestCanceled,
|
||||
contextMenuRenderer,
|
||||
...componentProps
|
||||
} = props;
|
||||
|
||||
const [confirmationState, setConfirmationState] = React.useState<ToolConfirmationState>('waiting');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (confirmationMode === ToolConfirmationPreferenceMode.ALWAYS_ALLOW) {
|
||||
response.confirm();
|
||||
setConfirmationState('allowed');
|
||||
return;
|
||||
} else if (confirmationMode === ToolConfirmationPreferenceMode.DISABLED) {
|
||||
response.deny();
|
||||
setConfirmationState('denied');
|
||||
return;
|
||||
}
|
||||
response.confirmed
|
||||
.then(confirmed => {
|
||||
setConfirmationState(confirmed === true ? 'allowed' : 'denied');
|
||||
})
|
||||
.catch(() => {
|
||||
setConfirmationState('rejected');
|
||||
});
|
||||
}, [response, confirmationMode]);
|
||||
|
||||
const handleAllow = React.useCallback((scope: ConfirmationScope = 'once') => {
|
||||
if (scope === 'forever' && response.name) {
|
||||
toolConfirmationManager.setConfirmationMode(response.name, ToolConfirmationPreferenceMode.ALWAYS_ALLOW, toolRequest);
|
||||
} else if (scope === 'session' && response.name) {
|
||||
toolConfirmationManager.setSessionConfirmationMode(response.name, ToolConfirmationPreferenceMode.ALWAYS_ALLOW, chatId);
|
||||
}
|
||||
response.confirm();
|
||||
}, [response, toolConfirmationManager, chatId, toolRequest]);
|
||||
|
||||
const handleDeny = React.useCallback((scope: ConfirmationScope = 'once', reason?: string) => {
|
||||
if (scope === 'forever' && response.name) {
|
||||
toolConfirmationManager.setConfirmationMode(response.name, ToolConfirmationPreferenceMode.DISABLED);
|
||||
} else if (scope === 'session' && response.name) {
|
||||
toolConfirmationManager.setSessionConfirmationMode(response.name, ToolConfirmationPreferenceMode.DISABLED, chatId);
|
||||
}
|
||||
response.deny(reason);
|
||||
}, [response, toolConfirmationManager, chatId]);
|
||||
|
||||
if (confirmationState === 'rejected' || (requestCanceled && !response.finished)) {
|
||||
return (
|
||||
<div className="theia-tool-confirmation-status rejected">
|
||||
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/canceled', 'Tool execution canceled')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (confirmationState === 'denied') {
|
||||
return (
|
||||
<div className="theia-tool-confirmation-status denied">
|
||||
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolconfirmation/executionDenied', 'Tool execution denied')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (confirmationState === 'waiting' && !requestCanceled && !response.finished) {
|
||||
return (
|
||||
<ToolConfirmation
|
||||
response={response}
|
||||
toolRequest={toolRequest}
|
||||
onAllow={handleAllow}
|
||||
onDeny={handleDeny}
|
||||
contextMenuRenderer={contextMenuRenderer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <WrappedComponent {...componentProps as P} />;
|
||||
};
|
||||
|
||||
WithConfirmation.displayName = `withToolCallConfirmation(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
|
||||
return WithConfirmation;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 '../chat-response-part-renderer';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatResponseContent, ToolCallChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import { codicon, ContextMenuRenderer, OpenerService } from '@theia/core/lib/browser';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ToolConfirmation, ToolConfirmationState } from './tool-confirmation';
|
||||
import { ToolConfirmationMode } from '@theia/ai-chat/lib/common/chat-tool-preferences';
|
||||
import { ResponseNode } from '../chat-tree-view';
|
||||
import { useMarkdownRendering } from './markdown-part-renderer';
|
||||
import { ToolCallResult, ToolInvocationRegistry, ToolRequest } from '@theia/ai-core';
|
||||
import { ToolConfirmationManager } from '@theia/ai-chat/lib/browser/chat-tool-preference-bindings';
|
||||
|
||||
@injectable()
|
||||
export class ToolCallPartRenderer implements ChatResponsePartRenderer<ToolCallChatResponseContent> {
|
||||
|
||||
@inject(ToolConfirmationManager)
|
||||
protected toolConfirmationManager: ToolConfirmationManager;
|
||||
|
||||
@inject(OpenerService)
|
||||
protected openerService: OpenerService;
|
||||
|
||||
@inject(ToolInvocationRegistry)
|
||||
protected toolInvocationRegistry: ToolInvocationRegistry;
|
||||
|
||||
@inject(ContextMenuRenderer)
|
||||
protected contextMenuRenderer: ContextMenuRenderer;
|
||||
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
if (ToolCallChatResponseContent.is(response)) {
|
||||
return 10;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render(response: ToolCallChatResponseContent, parentNode: ResponseNode): ReactNode {
|
||||
const chatId = parentNode.sessionId;
|
||||
const toolRequest = response.name ? this.toolInvocationRegistry.getFunction(response.name) : undefined;
|
||||
const confirmationMode = response.name ? this.getToolConfirmationSettings(response.name, chatId, toolRequest) : ToolConfirmationMode.DISABLED;
|
||||
return <ToolCallContent
|
||||
response={response}
|
||||
confirmationMode={confirmationMode}
|
||||
toolConfirmationManager={this.toolConfirmationManager}
|
||||
toolRequest={toolRequest}
|
||||
chatId={chatId}
|
||||
renderCollapsibleArguments={this.renderCollapsibleArguments.bind(this)}
|
||||
responseRenderer={this.renderResult.bind(this)}
|
||||
requestCanceled={parentNode.response.isCanceled}
|
||||
contextMenuRenderer={this.contextMenuRenderer} />;
|
||||
}
|
||||
|
||||
protected renderResult(response: ToolCallChatResponseContent): ReactNode {
|
||||
const result = this.tryParse(response.result);
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
return <pre>{JSON.stringify(result, undefined, 2)}</pre>;
|
||||
}
|
||||
if ('content' in result) {
|
||||
return <div className='theia-toolCall-response-content'>
|
||||
{result.content.map((content, idx) => {
|
||||
switch (content.type) {
|
||||
case 'image': {
|
||||
return <div key={`content-${idx}-${content.type}`} className='theia-toolCall-image-result'>
|
||||
<img src={`data:${content.mimeType};base64,${content.base64data}`} />
|
||||
</div>;
|
||||
}
|
||||
case 'text': {
|
||||
return <div key={`content-${idx}-${content.type}`} className='theia-toolCall-text-result'>
|
||||
<MarkdownRender text={content.text} openerService={this.openerService} />
|
||||
</div>;
|
||||
}
|
||||
case 'audio':
|
||||
case 'error':
|
||||
default: {
|
||||
return <div key={`content-${idx}-${content.type}`} className='theia-toolCall-default-result'><pre>{JSON.stringify(response, undefined, 2)}</pre></div>;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
return <pre>{JSON.stringify(result, undefined, 2)}</pre>;
|
||||
}
|
||||
|
||||
private tryParse(result: ToolCallResult): ToolCallResult {
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return typeof result === 'string' ? JSON.parse(result) : result;
|
||||
} catch (error) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
protected getToolConfirmationSettings(responseId: string, chatId: string, toolRequest?: ToolRequest): ToolConfirmationMode {
|
||||
return this.toolConfirmationManager.getConfirmationMode(responseId, chatId, toolRequest);
|
||||
}
|
||||
|
||||
protected renderCollapsibleArguments(args: string | undefined): ReactNode {
|
||||
if (!args || !args.trim() || args.trim() === '{}') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="collapsible-arguments">
|
||||
<summary className="collapsible-arguments-summary">...</summary>
|
||||
<span>{this.prettyPrintArgs(args)}</span>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
private prettyPrintArgs(args: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(args), undefined, 2);
|
||||
} catch (e) {
|
||||
// fall through
|
||||
return args;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const Spinner = () => (
|
||||
<span className={`${codicon('loading')} theia-animation-spin`}></span>
|
||||
);
|
||||
|
||||
interface ToolCallContentProps {
|
||||
response: ToolCallChatResponseContent;
|
||||
confirmationMode: ToolConfirmationMode;
|
||||
toolConfirmationManager: ToolConfirmationManager;
|
||||
toolRequest?: ToolRequest;
|
||||
chatId: string;
|
||||
renderCollapsibleArguments: (args: string | undefined) => ReactNode;
|
||||
responseRenderer: (response: ToolCallChatResponseContent) => ReactNode | undefined;
|
||||
requestCanceled: boolean;
|
||||
contextMenuRenderer: ContextMenuRenderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* A function component to handle tool call rendering and confirmation
|
||||
*/
|
||||
const ToolCallContent: React.FC<ToolCallContentProps> = ({
|
||||
response,
|
||||
confirmationMode,
|
||||
toolConfirmationManager,
|
||||
toolRequest,
|
||||
chatId,
|
||||
responseRenderer,
|
||||
renderCollapsibleArguments,
|
||||
requestCanceled,
|
||||
contextMenuRenderer
|
||||
}) => {
|
||||
const [confirmationState, setConfirmationState] = React.useState<ToolConfirmationState>('waiting');
|
||||
const [rejectionReason, setRejectionReason] = React.useState<unknown>(undefined);
|
||||
|
||||
const formatReason = (reason: unknown): string => {
|
||||
if (!reason) {
|
||||
return '';
|
||||
}
|
||||
if (reason instanceof Error) {
|
||||
return reason.message;
|
||||
}
|
||||
if (typeof reason === 'string') {
|
||||
return reason;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(reason);
|
||||
} catch (e) {
|
||||
return String(reason);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (confirmationMode === ToolConfirmationMode.ALWAYS_ALLOW) {
|
||||
response.confirm();
|
||||
setConfirmationState('allowed');
|
||||
return;
|
||||
} else if (confirmationMode === ToolConfirmationMode.DISABLED) {
|
||||
response.deny();
|
||||
setConfirmationState('denied');
|
||||
return;
|
||||
}
|
||||
response.confirmed
|
||||
.then(confirmed => {
|
||||
if (confirmed === true) {
|
||||
setConfirmationState('allowed');
|
||||
} else {
|
||||
setConfirmationState('denied');
|
||||
}
|
||||
})
|
||||
.catch(reason => {
|
||||
setRejectionReason(reason);
|
||||
setConfirmationState('rejected');
|
||||
});
|
||||
}, [response, confirmationMode]);
|
||||
|
||||
const handleAllow = React.useCallback((mode: 'once' | 'session' | 'forever' = 'once') => {
|
||||
if (mode === 'forever' && response.name) {
|
||||
toolConfirmationManager.setConfirmationMode(response.name, ToolConfirmationMode.ALWAYS_ALLOW, toolRequest);
|
||||
} else if (mode === 'session' && response.name) {
|
||||
toolConfirmationManager.setSessionConfirmationMode(response.name, ToolConfirmationMode.ALWAYS_ALLOW, chatId);
|
||||
}
|
||||
response.confirm();
|
||||
}, [response, toolConfirmationManager, chatId, toolRequest]);
|
||||
|
||||
const handleDeny = React.useCallback((mode: 'once' | 'session' | 'forever' = 'once', reason?: string) => {
|
||||
if (mode === 'forever' && response.name) {
|
||||
toolConfirmationManager.setConfirmationMode(response.name, ToolConfirmationMode.DISABLED);
|
||||
} else if (mode === 'session' && response.name) {
|
||||
toolConfirmationManager.setSessionConfirmationMode(response.name, ToolConfirmationMode.DISABLED, chatId);
|
||||
}
|
||||
response.deny(reason);
|
||||
}, [response, toolConfirmationManager, chatId]);
|
||||
|
||||
const reasonText = formatReason(rejectionReason);
|
||||
|
||||
return (
|
||||
<div className='theia-toolCall'>
|
||||
{confirmationState === 'rejected' ? (
|
||||
<span className='theia-toolCall-rejected'>
|
||||
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolcall-part-renderer/rejected', 'Execution canceled')}: {response.name}
|
||||
{reasonText ? <span> — {reasonText}</span> : undefined}
|
||||
</span>
|
||||
) : requestCanceled && !response.finished ? (
|
||||
<span className='theia-toolCall-rejected'>
|
||||
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolcall-part-renderer/rejected', 'Execution canceled')}: {response.name}
|
||||
</span>
|
||||
) : confirmationState === 'denied' ? (
|
||||
<span className='theia-toolCall-denied'>
|
||||
<span className={codicon('error')}></span> {nls.localize('theia/ai/chat-ui/toolcall-part-renderer/denied', 'Execution denied')}: {response.name}
|
||||
</span>
|
||||
) : response.finished ? (
|
||||
<details className='theia-toolCall-finished'>
|
||||
<summary>
|
||||
{nls.localize('theia/ai/chat-ui/toolcall-part-renderer/finished', 'Ran')} {response.name}
|
||||
({renderCollapsibleArguments(response.arguments)})
|
||||
</summary>
|
||||
<div className='theia-toolCall-response-result'>
|
||||
{responseRenderer(response)}
|
||||
</div>
|
||||
</details>
|
||||
) : (
|
||||
confirmationState === 'allowed' && !requestCanceled && (
|
||||
<span className='theia-toolCall-allowed'>
|
||||
<Spinner /> {nls.localizeByDefault('Running')} {response.name}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
{confirmationState === 'waiting' && !requestCanceled && !response.finished && (
|
||||
<span className='theia-toolCall-waiting'>
|
||||
<ToolConfirmation
|
||||
response={response}
|
||||
toolRequest={toolRequest}
|
||||
onAllow={handleAllow}
|
||||
onDeny={handleDeny}
|
||||
contextMenuRenderer={contextMenuRenderer}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownRender = ({ text, openerService }: { text: string; openerService: OpenerService }) => {
|
||||
const ref = useMarkdownRendering(text, openerService);
|
||||
return <div ref={ref}></div>;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
|
||||
import { ChatResponseContent, UnknownChatResponseContent } from '@theia/ai-chat/lib/common';
|
||||
import { ReactNode } from '@theia/core/shared/react';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { codicon } from '@theia/core/lib/browser';
|
||||
|
||||
@injectable()
|
||||
export class UnknownPartRenderer implements ChatResponsePartRenderer<UnknownChatResponseContent> {
|
||||
canHandle(response: ChatResponseContent): number {
|
||||
return response.kind === 'unknown' ? 10 : -1;
|
||||
}
|
||||
|
||||
render(response: UnknownChatResponseContent): ReactNode {
|
||||
const fallbackMessage = response.fallbackMessage || response.asString?.() || '';
|
||||
|
||||
return (
|
||||
<div className="theia-chat-unknown-content">
|
||||
<div className="theia-chat-unknown-content-warning">
|
||||
<i className={codicon('warning')} />
|
||||
<span>
|
||||
{nls.localize(
|
||||
'theia/ai/chat-ui/unknown-part-renderer/contentNotRestoreable',
|
||||
"This content (type '{0}') could not be fully restored. It may be from an extension that is no longer available.",
|
||||
response.originalKind
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="theia-chat-unknown-content-fallback">
|
||||
{fallbackMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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,
|
||||
viewProps: {
|
||||
// Let Virtuoso handle auto-scroll natively: follow new output only when
|
||||
// the user is already at the bottom, and render items from the top
|
||||
// (prevents short conversations from anchoring to the bottom of the panel).
|
||||
followOutput: true,
|
||||
},
|
||||
} as TreeProps;
|
||||
|
||||
export function createChatViewTreeWidget(parent: interfaces.Container): ChatViewTreeWidget {
|
||||
const child = createTreeContainer(parent, {
|
||||
props: CHAT_VIEW_TREE_PROPS,
|
||||
widget: ChatViewTreeWidget,
|
||||
});
|
||||
return child.get(ChatViewTreeWidget);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { AIChatInputWidget, type AIChatInputConfiguration } from '../chat-input-widget';
|
||||
import type { EditableRequestNode } from './chat-view-tree-widget';
|
||||
import { URI } from '@theia/core';
|
||||
import { CHAT_VIEW_LANGUAGE_EXTENSION } from '../chat-view-language-contribution';
|
||||
import type { ChatRequestModel, EditableChatRequestModel, ChatHierarchyBranch } from '@theia/ai-chat';
|
||||
import type { AIVariableResolutionRequest } from '@theia/ai-core';
|
||||
import { Key } from '@theia/core/lib/browser';
|
||||
|
||||
export const AIChatTreeInputConfiguration = Symbol('AIChatTreeInputConfiguration');
|
||||
export interface AIChatTreeInputConfiguration extends AIChatInputConfiguration { }
|
||||
|
||||
export const AIChatTreeInputArgs = Symbol('AIChatTreeInputArgs');
|
||||
export interface AIChatTreeInputArgs {
|
||||
node: EditableRequestNode;
|
||||
/**
|
||||
* The branch of the chat tree for this request node (used by the input widget for state tracking).
|
||||
*/
|
||||
branch?: ChatHierarchyBranch;
|
||||
initialValue?: string;
|
||||
onQuery: (query: string) => Promise<void>;
|
||||
onUnpin?: () => void;
|
||||
onCancel?: (requestModel: ChatRequestModel) => void;
|
||||
onDeleteChangeSet?: (requestModel: ChatRequestModel) => void;
|
||||
onDeleteChangeSetElement?: (requestModel: ChatRequestModel, index: number) => void;
|
||||
}
|
||||
export const AIChatTreeInputFactory = Symbol('AIChatTreeInputFactory');
|
||||
export type AIChatTreeInputFactory = (args: AIChatTreeInputArgs) => AIChatTreeInputWidget;
|
||||
|
||||
@injectable()
|
||||
export class AIChatTreeInputWidget extends AIChatInputWidget {
|
||||
public static override ID = 'chat-tree-input-widget';
|
||||
|
||||
@inject(AIChatTreeInputArgs)
|
||||
protected readonly args: AIChatTreeInputArgs;
|
||||
|
||||
@inject(AIChatTreeInputConfiguration) @optional()
|
||||
protected override readonly configuration: AIChatTreeInputConfiguration | undefined;
|
||||
|
||||
get requestNode(): EditableRequestNode {
|
||||
return this.args.node;
|
||||
}
|
||||
|
||||
get request(): EditableChatRequestModel {
|
||||
return this.requestNode.request;
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
protected override init(): void {
|
||||
super.init();
|
||||
this.updateBranch();
|
||||
|
||||
const request = this.requestNode.request;
|
||||
this.toDispose.push(request.session.onDidChange(() => {
|
||||
this.updateBranch();
|
||||
}));
|
||||
|
||||
this.addKeyListener(this.node, Key.ESCAPE, () => {
|
||||
this.request.cancelEdit();
|
||||
});
|
||||
|
||||
this.editorReady.promise.then(() => {
|
||||
if (this.editorRef) {
|
||||
this.editorRef.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected updateBranch(): void {
|
||||
this.branch = this.args.branch ?? this.requestNode.branch;
|
||||
}
|
||||
|
||||
protected override getResourceUri(): URI {
|
||||
return new URI(`ai-chat:/${this.requestNode.id}-input.${CHAT_VIEW_LANGUAGE_EXTENSION}`);
|
||||
}
|
||||
|
||||
override addContext(variable: AIVariableResolutionRequest): void {
|
||||
this.request.editContextManager.addVariables(variable);
|
||||
}
|
||||
|
||||
protected override getContext(): readonly AIVariableResolutionRequest[] {
|
||||
return this.request.editContextManager.getVariables();
|
||||
}
|
||||
|
||||
protected override deleteContextElement(index: number): void {
|
||||
this.request.editContextManager.deleteVariables(index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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,
|
||||
ChatRequestModel,
|
||||
ChatResponseContent,
|
||||
ChatResponseModel,
|
||||
ChatService,
|
||||
EditableChatRequestModel,
|
||||
ParsedChatRequestAgentPart,
|
||||
ParsedChatRequestFunctionPart,
|
||||
ParsedChatRequestVariablePart,
|
||||
type ChatRequest,
|
||||
type ChatHierarchyBranch,
|
||||
} from '@theia/ai-chat';
|
||||
import { ImageContextVariable } from '@theia/ai-chat/lib/common/image-context-variable';
|
||||
import { AIVariableService } from '@theia/ai-core';
|
||||
import { AIActivationService } from '@theia/ai-core/lib/browser';
|
||||
import { CommandRegistry, ContributionProvider, Disposable, DisposableCollection, Emitter, Event } from '@theia/core';
|
||||
import {
|
||||
codicon,
|
||||
CompositeTreeNode,
|
||||
ContextMenuRenderer,
|
||||
HoverService,
|
||||
Key,
|
||||
KeyCode,
|
||||
NodeProps,
|
||||
OpenerService,
|
||||
TreeModel,
|
||||
TreeNode,
|
||||
TreeProps,
|
||||
TreeWidget,
|
||||
Widget,
|
||||
type ReactWidget
|
||||
} from '@theia/core/lib/browser';
|
||||
import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import {
|
||||
inject,
|
||||
injectable,
|
||||
named,
|
||||
optional,
|
||||
postConstruct
|
||||
} from '@theia/core/shared/inversify';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ChatNodeToolbarActionContribution } from '../chat-node-toolbar-action-contribution';
|
||||
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
|
||||
import { useMarkdownRendering } from '../chat-response-renderer/markdown-part-renderer';
|
||||
import { ProgressMessage } from '../chat-progress-message';
|
||||
import { AIChatTreeInputFactory, type AIChatTreeInputWidget } from './chat-view-tree-input-widget';
|
||||
import { PromptVariantBadge } from './prompt-variant-badge';
|
||||
|
||||
// TODO Instead of directly operating on the ChatRequestModel we could use an intermediate view model
|
||||
export interface RequestNode extends TreeNode {
|
||||
request: ChatRequestModel,
|
||||
branch: ChatHierarchyBranch,
|
||||
sessionId: string
|
||||
}
|
||||
export const isRequestNode = (node: TreeNode): node is RequestNode => 'request' in node;
|
||||
|
||||
export interface EditableRequestNode extends RequestNode {
|
||||
request: EditableChatRequestModel
|
||||
}
|
||||
export const isEditableRequestNode = (node: TreeNode): node is EditableRequestNode => isRequestNode(node) && EditableChatRequestModel.is(node.request);
|
||||
|
||||
// TODO Instead of directly operating on the ChatResponseModel we could use an intermediate view model
|
||||
export interface ResponseNode extends TreeNode {
|
||||
response: ChatResponseModel,
|
||||
sessionId: string
|
||||
}
|
||||
export const isResponseNode = (node: TreeNode): node is ResponseNode => 'response' in node;
|
||||
|
||||
export function isEnterKey(e: React.KeyboardEvent): boolean {
|
||||
return Key.ENTER.keyCode === KeyCode.createKeyCode(e.nativeEvent).key?.keyCode;
|
||||
}
|
||||
|
||||
export const ChatWelcomeMessageProvider = Symbol('ChatWelcomeMessageProvider');
|
||||
export interface ChatWelcomeMessageProvider {
|
||||
renderWelcomeMessage?(): React.ReactNode;
|
||||
renderDisabledMessage?(): React.ReactNode;
|
||||
readonly hasReadyModels?: boolean;
|
||||
readonly modelRequirementBypassed?: boolean;
|
||||
readonly defaultAgent?: string;
|
||||
readonly onStateChanged?: Event<void>;
|
||||
}
|
||||
|
||||
@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<ChatResponsePartRenderer<ChatResponseContent>>;
|
||||
|
||||
@inject(ContributionProvider) @named(ChatNodeToolbarActionContribution)
|
||||
protected readonly chatNodeToolbarActionContributions: ContributionProvider<ChatNodeToolbarActionContribution>;
|
||||
|
||||
@inject(ChatAgentService)
|
||||
protected chatAgentService: ChatAgentService;
|
||||
|
||||
@inject(AIVariableService)
|
||||
protected readonly variableService: AIVariableService;
|
||||
|
||||
@inject(CommandRegistry)
|
||||
protected commandRegistry: CommandRegistry;
|
||||
|
||||
@inject(OpenerService)
|
||||
protected readonly openerService: OpenerService;
|
||||
|
||||
@inject(HoverService)
|
||||
protected hoverService: HoverService;
|
||||
|
||||
@inject(ChatWelcomeMessageProvider) @optional()
|
||||
protected welcomeMessageProvider?: ChatWelcomeMessageProvider;
|
||||
|
||||
@inject(AIChatTreeInputFactory)
|
||||
protected inputWidgetFactory: AIChatTreeInputFactory;
|
||||
|
||||
@inject(AIActivationService)
|
||||
protected readonly activationService: AIActivationService;
|
||||
|
||||
@inject(ChatService)
|
||||
protected readonly chatService: ChatService;
|
||||
|
||||
@inject(ContextKeyService)
|
||||
protected readonly contextKeyService: ContextKeyService;
|
||||
|
||||
protected chatResponseFocusKey: ContextKey<boolean>;
|
||||
|
||||
protected readonly onDidSubmitEditEmitter = new Emitter<ChatRequest>();
|
||||
onDidSubmitEdit = this.onDidSubmitEditEmitter.event;
|
||||
|
||||
protected readonly chatInputs: Map<string, AIChatTreeInputWidget> = new Map();
|
||||
|
||||
protected _shouldScrollToEnd = true;
|
||||
|
||||
protected isEnabled = false;
|
||||
|
||||
protected chatModelId: string;
|
||||
|
||||
/** Tracks if we are at the bottom for showing the scroll-to-bottom button. */
|
||||
protected atBottom = true;
|
||||
/**
|
||||
* Track the visibility of the scroll button with debounce logic. Used to prevent flickering when streaming tokens.
|
||||
*/
|
||||
protected _showScrollButton = false;
|
||||
/**
|
||||
* Timer for debouncing the scroll button activation (prevents flicker on auto-scroll).
|
||||
* If user scrolls up, this delays showing the button in case auto-scroll-to-bottom kicks in.
|
||||
*/
|
||||
protected _scrollButtonDebounceTimer?: number;
|
||||
/**
|
||||
* Debounce period in ms before showing scroll-to-bottom button after scrolling up.
|
||||
* Avoids flickering of the button during LLM token streaming.
|
||||
*/
|
||||
protected static readonly SCROLL_BUTTON_GRACE_PERIOD = 100;
|
||||
|
||||
onScrollLockChange?: (temporaryLocked: boolean) => void;
|
||||
|
||||
protected lastScrollTop = 0;
|
||||
|
||||
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');
|
||||
|
||||
this.chatResponseFocusKey = this.contextKeyService.createKey<boolean>('chatResponseFocus', false);
|
||||
this.node.setAttribute('tabindex', '0');
|
||||
this.node.setAttribute('aria-label', nls.localize('theia/ai/chat-ui/chatResponses', 'Chat responses'));
|
||||
this.addEventListener(this.node, 'focusin', () => this.chatResponseFocusKey.set(true));
|
||||
this.addEventListener(this.node, 'focusout', () => this.chatResponseFocusKey.set(false));
|
||||
|
||||
this.toDispose.pushAll([
|
||||
this.toDisposeOnChatModelChange,
|
||||
this.activationService.onDidChangeActiveStatus(change => {
|
||||
this.chatInputs.forEach(widget => {
|
||||
widget.setEnabled(change);
|
||||
});
|
||||
this.update();
|
||||
}),
|
||||
this.onScroll(scrollEvent => {
|
||||
this.handleScrollEvent(scrollEvent);
|
||||
})
|
||||
]);
|
||||
|
||||
if (this.welcomeMessageProvider?.onStateChanged) {
|
||||
this.toDispose.push(
|
||||
this.welcomeMessageProvider.onStateChanged(() => {
|
||||
this.update();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize lastScrollTop with current scroll position
|
||||
this.lastScrollTop = this.getCurrentScrollTop(undefined);
|
||||
}
|
||||
|
||||
public setEnabled(enabled: boolean): void {
|
||||
this.isEnabled = enabled;
|
||||
this.update();
|
||||
}
|
||||
|
||||
protected handleScrollEvent(scrollEvent: unknown): void {
|
||||
const currentScrollTop = this.getCurrentScrollTop(scrollEvent);
|
||||
const isScrollingUp = currentScrollTop < this.lastScrollTop;
|
||||
const isScrollingDown = currentScrollTop > this.lastScrollTop;
|
||||
const isAtBottom = this.isScrolledToBottom();
|
||||
const isAtAbsoluteBottom = this.isAtAbsoluteBottom();
|
||||
|
||||
// Asymmetric threshold logic to prevent jitter:
|
||||
if (this.shouldScrollToEnd && isScrollingUp) {
|
||||
if (!isAtAbsoluteBottom) {
|
||||
this.setTemporaryScrollLock(true);
|
||||
}
|
||||
} else if (!this.shouldScrollToEnd && isAtBottom && isScrollingDown) {
|
||||
this.setTemporaryScrollLock(false);
|
||||
}
|
||||
|
||||
this.updateScrollToBottomButtonState(isAtBottom);
|
||||
|
||||
this.lastScrollTop = currentScrollTop;
|
||||
}
|
||||
|
||||
/** Updates the scroll-to-bottom button state and handles debounce. */
|
||||
protected updateScrollToBottomButtonState(isAtBottom: boolean): void {
|
||||
const atBottomNow = isAtBottom; // Use isScrolledToBottom for threshold
|
||||
if (atBottomNow !== this.atBottom) {
|
||||
this.atBottom = atBottomNow;
|
||||
if (this.atBottom) {
|
||||
// We're at the bottom, hide the button immediately and clear any debounce timer.
|
||||
this._showScrollButton = false;
|
||||
if (this._scrollButtonDebounceTimer !== undefined) {
|
||||
clearTimeout(this._scrollButtonDebounceTimer);
|
||||
this._scrollButtonDebounceTimer = undefined;
|
||||
}
|
||||
this.update();
|
||||
} else {
|
||||
// User scrolled up; delay showing the scroll-to-bottom button.
|
||||
if (this._scrollButtonDebounceTimer !== undefined) {
|
||||
clearTimeout(this._scrollButtonDebounceTimer);
|
||||
}
|
||||
this._scrollButtonDebounceTimer = window.setTimeout(() => {
|
||||
// Re-check: only show if we're still not at bottom
|
||||
if (!this.atBottom) {
|
||||
this._showScrollButton = true;
|
||||
this.update();
|
||||
}
|
||||
this._scrollButtonDebounceTimer = undefined;
|
||||
}, ChatViewTreeWidget.SCROLL_BUTTON_GRACE_PERIOD);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected setTemporaryScrollLock(enabled: boolean): void {
|
||||
// Immediately apply scroll lock changes without delay
|
||||
this.onScrollLockChange?.(enabled);
|
||||
// Update cached scrollToRow so that outdated values do not cause unwanted scrolling on update()
|
||||
this.updateScrollToRow();
|
||||
}
|
||||
|
||||
protected getCurrentScrollTop(scrollEvent: unknown): number {
|
||||
// For virtualized trees, use the virtualized view's scroll state (most reliable)
|
||||
if (this.props.virtualized !== false && this.view) {
|
||||
const scrollState = this.getVirtualizedScrollState();
|
||||
if (scrollState !== undefined) {
|
||||
return scrollState.scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract scroll position from the scroll event
|
||||
if (scrollEvent && typeof scrollEvent === 'object' && 'scrollTop' in scrollEvent) {
|
||||
const scrollEventWithScrollTop = scrollEvent as { scrollTop: unknown };
|
||||
const scrollTop = scrollEventWithScrollTop.scrollTop;
|
||||
if (typeof scrollTop === 'number' && !isNaN(scrollTop)) {
|
||||
return scrollTop;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: use DOM scroll position
|
||||
if (this.node && typeof this.node.scrollTop === 'number') {
|
||||
return this.node.scrollTop;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the scroll position is at the absolute (1px tolerance) bottom of the scroll container.
|
||||
* Handles both virtualized and non-virtualized scroll containers.
|
||||
* Allows for a tiny floating point epsilon (1px).
|
||||
*/
|
||||
protected isAtAbsoluteBottom(): boolean {
|
||||
let scrollTop: number = 0;
|
||||
let scrollHeight: number = 0;
|
||||
let clientHeight: number = 0;
|
||||
const EPSILON = 1; // px
|
||||
if (this.props.virtualized !== false && this.view) {
|
||||
const state = this.getVirtualizedScrollState();
|
||||
if (state) {
|
||||
scrollTop = state.scrollTop;
|
||||
scrollHeight = state.scrollHeight ?? 0;
|
||||
clientHeight = state.clientHeight ?? 0;
|
||||
}
|
||||
} else if (this.node) {
|
||||
scrollTop = this.node.scrollTop;
|
||||
scrollHeight = this.node.scrollHeight;
|
||||
clientHeight = this.node.clientHeight;
|
||||
}
|
||||
const diff = Math.abs(scrollTop + clientHeight - scrollHeight);
|
||||
return diff <= EPSILON;
|
||||
}
|
||||
|
||||
protected override renderTree(model: TreeModel): React.ReactNode {
|
||||
if (!this.isEnabled) {
|
||||
return this.renderDisabledMessage();
|
||||
}
|
||||
|
||||
const tree = CompositeTreeNode.is(model.root) && model.root.children?.length > 0
|
||||
? super.renderTree(model)
|
||||
: this.renderWelcomeMessage();
|
||||
|
||||
return <React.Fragment>
|
||||
{tree}
|
||||
{this.renderScrollToBottomButton()}
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
/** Shows the scroll to bottom button if not at the bottom (debounced). */
|
||||
protected renderScrollToBottomButton(): React.ReactNode {
|
||||
if (!this._showScrollButton) {
|
||||
return undefined;
|
||||
}
|
||||
// Down-arrow, Theia codicon, fixed overlay on widget
|
||||
return <button
|
||||
className="theia-ChatTree-ScrollToBottom codicon codicon-arrow-down"
|
||||
title={nls.localize('theia/ai/chat-ui/chat-view-tree-widget/scrollToBottom', 'Jump to latest message')}
|
||||
onClick={() => this.handleScrollToBottomButtonClick()}
|
||||
/>;
|
||||
}
|
||||
|
||||
/** Scrolls to the bottom row and updates atBottom state. */
|
||||
protected handleScrollToBottomButtonClick(): void {
|
||||
this.scrollToRow = this.rows.size;
|
||||
this.atBottom = true;
|
||||
this._showScrollButton = false;
|
||||
if (this._scrollButtonDebounceTimer !== undefined) {
|
||||
clearTimeout(this._scrollButtonDebounceTimer);
|
||||
this._scrollButtonDebounceTimer = undefined;
|
||||
}
|
||||
this.update();
|
||||
}
|
||||
|
||||
protected renderDisabledMessage(): React.ReactNode {
|
||||
return this.welcomeMessageProvider?.renderDisabledMessage?.() ?? <></>;
|
||||
}
|
||||
|
||||
protected renderWelcomeMessage(): React.ReactNode {
|
||||
return this.welcomeMessageProvider?.renderWelcomeMessage?.() ?? <></>;
|
||||
}
|
||||
|
||||
protected mapRequestToNode(branch: ChatHierarchyBranch): RequestNode {
|
||||
return {
|
||||
parent: this.model.root as CompositeTreeNode,
|
||||
get id(): string {
|
||||
return this.request.id;
|
||||
},
|
||||
get request(): ChatRequestModel {
|
||||
return branch.get();
|
||||
},
|
||||
branch,
|
||||
sessionId: this.chatModelId
|
||||
};
|
||||
}
|
||||
|
||||
protected mapResponseToNode(response: ChatResponseModel): ResponseNode {
|
||||
return {
|
||||
id: response.id,
|
||||
parent: this.model.root as CompositeTreeNode,
|
||||
response,
|
||||
sessionId: this.chatModelId
|
||||
};
|
||||
}
|
||||
|
||||
protected readonly toDisposeOnChatModelChange = new DisposableCollection();
|
||||
|
||||
/**
|
||||
* Tracks the ChatModel handed over.
|
||||
* Tracking multiple chat models will result in a weird UI
|
||||
*/
|
||||
public trackChatModel(chatModel: ChatModel): void {
|
||||
this.toDisposeOnChatModelChange.dispose();
|
||||
this.recreateModelTree(chatModel);
|
||||
|
||||
chatModel.getRequests().forEach(request => {
|
||||
if (!request.response.isComplete) {
|
||||
request.response.onDidChange(() => this.scheduleUpdateScrollToRow());
|
||||
}
|
||||
});
|
||||
this.toDisposeOnChatModelChange.pushAll([
|
||||
Disposable.create(() => {
|
||||
this.chatInputs.forEach(widget => widget.dispose());
|
||||
this.chatInputs.clear();
|
||||
}),
|
||||
chatModel.onDidChange(event => {
|
||||
if (event.kind === 'enableEdit') {
|
||||
this.scrollToRow = this.rows.get(event.request.id)?.index;
|
||||
this.update();
|
||||
return;
|
||||
} else if (event.kind === 'cancelEdit') {
|
||||
this.disposeChatInputWidget(event.request);
|
||||
this.scrollToRow = undefined;
|
||||
this.update();
|
||||
return;
|
||||
} else if (event.kind === 'changeHierarchyBranch') {
|
||||
this.scrollToRow = undefined;
|
||||
}
|
||||
|
||||
this.recreateModelTree(chatModel);
|
||||
|
||||
if (event.kind === 'addRequest' && !event.request.response.isComplete) {
|
||||
event.request.response.onDidChange(() => this.scheduleUpdateScrollToRow());
|
||||
} else if (event.kind === 'submitEdit') {
|
||||
event.branch.succeedingBranches().forEach(branch => {
|
||||
this.disposeChatInputWidget(branch.get());
|
||||
});
|
||||
this.onDidSubmitEditEmitter.fire(
|
||||
event.newRequest,
|
||||
);
|
||||
}
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
protected disposeChatInputWidget(request: ChatRequestModel): void {
|
||||
const widget = this.chatInputs.get(request.id);
|
||||
if (widget) {
|
||||
widget.dispose();
|
||||
this.chatInputs.delete(request.id);
|
||||
}
|
||||
}
|
||||
|
||||
protected override getScrollToRow(): number | undefined {
|
||||
// followOutput on the Virtuoso component handles auto-scrolling when the
|
||||
// user is at the bottom — returning rows.size here would force an
|
||||
// artificial anchor to the very bottom of the panel, which makes short
|
||||
// conversations appear at the bottom instead of the top (Cursor-style).
|
||||
// The scroll-to-bottom button still works via the direct this.scrollToRow
|
||||
// assignment in handleScrollToBottomButtonClick().
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected async recreateModelTree(chatModel: ChatModel): Promise<void> {
|
||||
if (CompositeTreeNode.is(this.model.root)) {
|
||||
const nodes: TreeNode[] = [];
|
||||
this.chatModelId = chatModel.id;
|
||||
chatModel.getBranches().forEach(branch => {
|
||||
const request = branch.get();
|
||||
nodes.push(this.mapRequestToNode(branch));
|
||||
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);
|
||||
}
|
||||
const ariaLabel = isRequestNode(node)
|
||||
? nls.localize('theia/ai/chat-ui/yourMessage', 'Your message')
|
||||
: nls.localize('theia/ai/chat-ui/responseFrom', 'Response from {0}', this.getAgentLabel(node));
|
||||
return <React.Fragment key={node.id}>
|
||||
<div
|
||||
className='theia-ChatNode'
|
||||
role='article'
|
||||
aria-label={ariaLabel}
|
||||
onContextMenu={e => this.handleContextMenu(node, e)}
|
||||
>
|
||||
{this.renderAgent(node)}
|
||||
{this.renderDetail(node)}
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
protected renderAgent(node: RequestNode | ResponseNode): React.ReactNode {
|
||||
const inProgress = isResponseNode(node) && !node.response.isComplete && !node.response.isCanceled && !node.response.isError;
|
||||
const waitingForInput = isResponseNode(node) && node.response.isWaitingForInput;
|
||||
const toolbarContributions = !inProgress
|
||||
? this.chatNodeToolbarActionContributions.getContributions()
|
||||
.flatMap(c => c.getToolbarActions(node))
|
||||
.filter(action => this.commandRegistry.isEnabled(action.commandId, node))
|
||||
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
|
||||
: [];
|
||||
const agentLabel = React.createRef<HTMLHeadingElement>();
|
||||
const agentDescription = this.getAgent(node)?.description;
|
||||
|
||||
const promptVariantId = isResponseNode(node) ? node.response.promptVariantId : undefined;
|
||||
const isPromptVariantEdited = isResponseNode(node) ? !!node.response.isPromptVariantEdited : false;
|
||||
|
||||
return <React.Fragment>
|
||||
<div className='theia-ChatNodeHeader'>
|
||||
<div className={`theia-AgentAvatar ${this.getAgentIconClassName(node)}`}></div>
|
||||
<h3 ref={agentLabel}
|
||||
className='theia-AgentLabel'
|
||||
onMouseEnter={() => {
|
||||
if (agentDescription) {
|
||||
this.hoverService.requestHover({
|
||||
content: agentDescription,
|
||||
target: agentLabel.current!,
|
||||
position: 'right'
|
||||
});
|
||||
}
|
||||
}}>
|
||||
{this.getAgentLabel(node)}
|
||||
</h3>
|
||||
{promptVariantId && (
|
||||
<PromptVariantBadge
|
||||
variantId={promptVariantId}
|
||||
isEdited={isPromptVariantEdited}
|
||||
hoverService={this.hoverService}
|
||||
/>
|
||||
)}
|
||||
{inProgress && !waitingForInput &&
|
||||
<span className='theia-ChatContentInProgress' role='status' aria-live='polite'>
|
||||
{nls.localize('theia/ai/chat-ui/chat-view-tree-widget/generating', 'Generating')}
|
||||
</span>}
|
||||
{inProgress && waitingForInput &&
|
||||
<span className='theia-ChatContentInProgress' role='status' aria-live='polite'>
|
||||
{nls.localize('theia/ai/chat-ui/chat-view-tree-widget/waitingForInput', 'Waiting for input')}
|
||||
</span>}
|
||||
<div className='theia-ChatNodeToolbar'>
|
||||
{!inProgress &&
|
||||
toolbarContributions.length > 0 &&
|
||||
toolbarContributions.map(action =>
|
||||
<span
|
||||
key={action.commandId}
|
||||
className={`theia-ChatNodeToolbarAction ${action.icon}`}
|
||||
title={action.tooltip}
|
||||
aria-label={action.tooltip}
|
||||
tabIndex={0}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
this.commandRegistry.executeCommand(action.commandId, node);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (isEnterKey(e)) {
|
||||
e.stopPropagation();
|
||||
this.commandRegistry.executeCommand(action.commandId, node);
|
||||
}
|
||||
}}
|
||||
role='button'
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>;
|
||||
}
|
||||
|
||||
protected getAgentLabel(node: RequestNode | ResponseNode): string {
|
||||
if (isRequestNode(node)) {
|
||||
// TODO find user name
|
||||
return nls.localize('theia/ai/chat-ui/chat-view-tree-widget/you', 'You');
|
||||
}
|
||||
return this.getAgent(node)?.name ?? nls.localize('theia/ai/chat-ui/chat-view-tree-widget/ai', 'AI');
|
||||
}
|
||||
|
||||
protected getAgent(node: RequestNode | ResponseNode): ChatAgent | undefined {
|
||||
if (isRequestNode(node)) {
|
||||
return undefined;
|
||||
}
|
||||
return node.response.agentId ? this.chatAgentService.getAgent(node.response.agentId) : undefined;
|
||||
}
|
||||
|
||||
protected 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');
|
||||
}
|
||||
|
||||
protected renderDetail(node: RequestNode | ResponseNode): React.ReactNode {
|
||||
if (isRequestNode(node)) {
|
||||
return this.renderChatRequest(node);
|
||||
}
|
||||
if (isResponseNode(node)) {
|
||||
return this.renderChatResponse(node);
|
||||
};
|
||||
}
|
||||
|
||||
protected renderChatRequest(node: RequestNode): React.ReactNode {
|
||||
return <ChatRequestRender
|
||||
node={node}
|
||||
hoverService={this.hoverService}
|
||||
chatAgentService={this.chatAgentService}
|
||||
variableService={this.variableService}
|
||||
openerService={this.openerService}
|
||||
provideChatInputWidget={() => {
|
||||
const editableNode = node;
|
||||
if (isEditableRequestNode(editableNode)) {
|
||||
let widget = this.chatInputs.get(editableNode.id);
|
||||
if (!widget) {
|
||||
widget = this.inputWidgetFactory({
|
||||
node: editableNode,
|
||||
initialValue: editableNode.request.message.request.text,
|
||||
onQuery: async query => {
|
||||
editableNode.request.submitEdit({ text: query });
|
||||
},
|
||||
branch: editableNode.branch
|
||||
});
|
||||
|
||||
this.chatInputs.set(editableNode.id, widget);
|
||||
|
||||
widget.disposed.connect(() => {
|
||||
this.chatInputs.delete(editableNode.id);
|
||||
editableNode.request.cancelEdit();
|
||||
});
|
||||
}
|
||||
|
||||
return widget;
|
||||
}
|
||||
|
||||
return;
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
protected renderChatResponse(node: ResponseNode): React.ReactNode {
|
||||
return (
|
||||
<div className={'theia-ResponseNode'}>
|
||||
{!node.response.isComplete
|
||||
&& node.response.response.content.length === 0
|
||||
&& node.response.progressMessages
|
||||
.filter(c => c.show === 'untilFirstContent')
|
||||
.map((c, i) =>
|
||||
<ProgressMessage {...c} key={`${node.id}-progress-untilFirstContent-${i}`} />
|
||||
)
|
||||
}
|
||||
{node.response.response.content.map((c, i) =>
|
||||
<div className='theia-ResponseNode-Content' key={`${node.id}-content-${i}`}>{this.getChatResponsePartRenderer(c, node)}</div>
|
||||
)}
|
||||
{!node.response.isComplete
|
||||
&& node.response.progressMessages
|
||||
.filter(c => c.show === 'whileIncomplete')
|
||||
.map((c, i) =>
|
||||
<ProgressMessage {...c} key={`${node.id}-progress-whileIncomplete-${i}`} />
|
||||
)
|
||||
}
|
||||
{node.response.progressMessages
|
||||
.filter(c => c.show === 'forever')
|
||||
.map((c, i) =>
|
||||
<ProgressMessage {...c} key={`${node.id}-progress-afterComplete-${i}`} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected getChatResponsePartRenderer(content: ChatResponseContent, node: ResponseNode): React.ReactNode {
|
||||
const renderer = this.chatResponsePartRenderers.getContributions().reduce<[number, ChatResponsePartRenderer<ChatResponseContent> | undefined]>(
|
||||
(prev, current) => {
|
||||
const prio = current.canHandle(content);
|
||||
if (prio > prev[0]) {
|
||||
return [prio, current];
|
||||
} return prev;
|
||||
},
|
||||
[-1, undefined])[1];
|
||||
if (!renderer) {
|
||||
console.error('No renderer found for content', content);
|
||||
return <div>{nls.localize('theia/ai/chat-ui/chat-view-tree-widget/noRenderer', 'Error: No renderer found')}</div>;
|
||||
}
|
||||
return renderer.render(content, node);
|
||||
}
|
||||
|
||||
protected handleContextMenu(node: TreeNode | undefined, event: React.MouseEvent<HTMLElement>): void {
|
||||
this.contextMenuRenderer.render({
|
||||
menuPath: ChatViewTreeWidget.CONTEXT_MENU,
|
||||
anchor: { x: event.clientX, y: event.clientY },
|
||||
args: [node],
|
||||
context: event.currentTarget
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
protected override handleSpace(event: KeyboardEvent): boolean {
|
||||
// We need to return false to prevent the handler within
|
||||
// packages/core/src/browser/widgets/widget.ts
|
||||
// Otherwise, the space key will never be handled by the monaco editor
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure atBottom state is correct when content grows (e.g., LLM streaming while scroll lock is enabled).
|
||||
*/
|
||||
protected override updateScrollToRow(): void {
|
||||
super.updateScrollToRow();
|
||||
const isAtBottom = this.isScrolledToBottom();
|
||||
this.updateScrollToBottomButtonState(isAtBottom);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface WidgetContainerProps {
|
||||
widget: ReactWidget;
|
||||
}
|
||||
|
||||
const WidgetContainer: React.FC<WidgetContainerProps> = ({ widget }) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (containerRef.current && !widget.isAttached) {
|
||||
Widget.attach(widget, containerRef.current);
|
||||
}
|
||||
}, [containerRef.current]);
|
||||
|
||||
// Clean up
|
||||
React.useEffect(() =>
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
// Delay clean up to allow react to finish its rendering cycle
|
||||
widget.clearFlag(Widget.Flag.IsAttached);
|
||||
widget.dispose();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
};
|
||||
|
||||
const ChatRequestRender = (
|
||||
{
|
||||
node, hoverService, chatAgentService, variableService, openerService,
|
||||
provideChatInputWidget
|
||||
}: {
|
||||
node: RequestNode,
|
||||
hoverService: HoverService,
|
||||
chatAgentService: ChatAgentService,
|
||||
variableService: AIVariableService,
|
||||
openerService: OpenerService,
|
||||
provideChatInputWidget: () => ReactWidget | undefined,
|
||||
}) => {
|
||||
const parts = node.request.message.parts;
|
||||
if (EditableChatRequestModel.isEditing(node.request)) {
|
||||
const widget = provideChatInputWidget();
|
||||
if (widget) {
|
||||
return <div className="theia-RequestNode">
|
||||
<WidgetContainer widget={widget}></WidgetContainer>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const renderFooter = () => {
|
||||
if (node.branch.items.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirst = node.branch.activeBranchIndex === 0;
|
||||
const isLast = node.branch.activeBranchIndex === node.branch.items.length - 1;
|
||||
|
||||
return (
|
||||
<div className='theia-RequestNode-Footer'>
|
||||
<div className={`item ${isFirst ? '' : 'enabled'}`}>
|
||||
<div className="codicon codicon-chevron-left action-label" title="Previous" onClick={() => {
|
||||
node.branch.enablePrevious();
|
||||
}}></div>
|
||||
</div>
|
||||
<small>
|
||||
<span>{node.branch.activeBranchIndex + 1}/</span>
|
||||
<span>{node.branch.items.length}</span>
|
||||
</small>
|
||||
<div className={`item ${isLast ? '' : 'enabled'}`}>
|
||||
<div className='codicon codicon-chevron-right action-label' title="Next" onClick={() => {
|
||||
node.branch.enableNext();
|
||||
}}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Extract image variables from the request context
|
||||
const imageVariables = node.request.context.variables
|
||||
.filter(ImageContextVariable.isResolvedImageContext)
|
||||
.map(resolved => ImageContextVariable.parseResolved(resolved))
|
||||
.filter((img): img is NonNullable<typeof img> => img !== undefined);
|
||||
|
||||
const renderImages = () => {
|
||||
if (imageVariables.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<div className="theia-RequestNode-Images">
|
||||
{imageVariables.map((img, index) => (
|
||||
<div key={index} className="theia-RequestNode-ImagePreview">
|
||||
<img
|
||||
src={`data:${img.mimeType};base64,${img.data}`}
|
||||
alt={img.name ?? img.wsRelativePath ?? 'Image'}
|
||||
title={img.name ?? img.wsRelativePath ?? 'Image'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="theia-RequestNode">
|
||||
<p>
|
||||
{parts.map((part, index) => {
|
||||
if (part instanceof ParsedChatRequestAgentPart || part instanceof ParsedChatRequestVariablePart || part instanceof ParsedChatRequestFunctionPart) {
|
||||
let description = undefined;
|
||||
let className = '';
|
||||
if (part instanceof ParsedChatRequestAgentPart) {
|
||||
description = chatAgentService.getAgent(part.agentId)?.description;
|
||||
className = 'theia-RequestNode-AgentLabel';
|
||||
} else if (part instanceof ParsedChatRequestVariablePart) {
|
||||
description = variableService.getVariable(part.variableName)?.description;
|
||||
className = 'theia-RequestNode-VariableLabel';
|
||||
} else if (part instanceof ParsedChatRequestFunctionPart) {
|
||||
description = part.toolRequest?.description;
|
||||
className = 'theia-RequestNode-FunctionLabel';
|
||||
}
|
||||
return (
|
||||
<HoverableLabel
|
||||
key={index}
|
||||
text={part.text}
|
||||
description={description}
|
||||
hoverService={hoverService}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const ref = useMarkdownRendering(
|
||||
part.text
|
||||
.replace(/^[\r\n]+|[\r\n]+$/g, '') // remove excessive new lines
|
||||
.replace(/(^ )/g, ' '), // enforce keeping space before
|
||||
openerService,
|
||||
true
|
||||
);
|
||||
return (
|
||||
<span key={index} ref={ref}></span>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</p>
|
||||
{renderImages()}
|
||||
{renderFooter()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HoverableLabel = (
|
||||
{
|
||||
text, description, hoverService, className
|
||||
}: {
|
||||
text: string,
|
||||
description?: string,
|
||||
hoverService: HoverService,
|
||||
className: string
|
||||
}) => {
|
||||
const spanRef = React.createRef<HTMLSpanElement>();
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
ref={spanRef}
|
||||
onMouseEnter={() => {
|
||||
if (description) {
|
||||
hoverService.requestHover({
|
||||
content: description,
|
||||
target: spanRef.current!,
|
||||
position: 'right'
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
18
packages/ai-chat-ui/src/browser/chat-tree-view/index.ts
Normal file
18
packages/ai-chat-ui/src/browser/chat-tree-view/index.ts
Normal file
@@ -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';
|
||||
@@ -0,0 +1,54 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { nls } from '@theia/core';
|
||||
import { HoverService } from '@theia/core/lib/browser';
|
||||
|
||||
export interface PromptVariantBadgeProps {
|
||||
variantId: string;
|
||||
isEdited: boolean;
|
||||
hoverService: HoverService;
|
||||
}
|
||||
|
||||
export const PromptVariantBadge: React.FC<PromptVariantBadgeProps> = ({ variantId, isEdited, hoverService }) => {
|
||||
// eslint-disable-next-line no-null/no-null
|
||||
const badgeRef = React.useRef<HTMLSpanElement>(null);
|
||||
const displayText = isEdited
|
||||
? `[${nls.localize('theia/ai/chat-ui/edited', 'edited')}] ${variantId}`
|
||||
: variantId;
|
||||
const baseTooltip = nls.localize('theia/ai/chat-ui/variantTooltip', 'Prompt variant: {0}', variantId);
|
||||
const tooltip = isEdited
|
||||
? baseTooltip + '. ' + nls.localize('theia/ai/chat-ui/editedTooltipHint', 'This prompt variant has been edited. You can reset it in the AI Configuration view.')
|
||||
: baseTooltip;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={badgeRef}
|
||||
className={`theia-PromptVariantBadge ${isEdited ? 'edited' : ''}`}
|
||||
onMouseEnter={() => {
|
||||
if (badgeRef.current) {
|
||||
hoverService.requestHover({
|
||||
content: tooltip,
|
||||
target: badgeRef.current,
|
||||
position: 'right'
|
||||
});
|
||||
};
|
||||
}}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { inject, injectable, named } from '@theia/core/shared/inversify';
|
||||
import { ProgressMessage } from '../chat-progress-message';
|
||||
import { ChatViewTreeWidget, ResponseNode } from './chat-view-tree-widget';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { ContributionProvider } from '@theia/core';
|
||||
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
|
||||
import { ChatNodeToolbarActionContribution } from '../chat-node-toolbar-action-contribution';
|
||||
import { ChatResponseContent } from '@theia/ai-chat';
|
||||
import { ContextMenuRenderer, TreeNode } from '@theia/core/lib/browser';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
|
||||
/**
|
||||
* Subset of the ChatViewTreeWidget used to render ResponseNodes for delegated prompts.
|
||||
*/
|
||||
@injectable()
|
||||
export class SubChatWidget {
|
||||
|
||||
@inject(ContributionProvider) @named(ChatResponsePartRenderer)
|
||||
protected readonly chatResponsePartRenderers: ContributionProvider<ChatResponsePartRenderer<ChatResponseContent>>;
|
||||
|
||||
@inject(ContributionProvider) @named(ChatNodeToolbarActionContribution)
|
||||
protected readonly chatNodeToolbarActionContributions: ContributionProvider<ChatNodeToolbarActionContribution>;
|
||||
|
||||
@inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer;
|
||||
|
||||
renderChatResponse(node: ResponseNode): React.ReactNode {
|
||||
return (
|
||||
<div className={'theia-ResponseNode'}>
|
||||
{!node.response.isComplete
|
||||
&& node.response.response.content.length === 0
|
||||
&& node.response.progressMessages
|
||||
.filter(c => c.show === 'untilFirstContent')
|
||||
.map((c, i) =>
|
||||
<ProgressMessage {...c} key={`${node.id}-progress-untilFirstContent-${i}`} />
|
||||
)
|
||||
}
|
||||
{node.response.response.content.map((c, i) =>
|
||||
<div className='theia-ResponseNode-Content' key={`${node.id}-content-${i}`}>{this.getChatResponsePartRenderer(c, node)}</div>
|
||||
)}
|
||||
{!node.response.isComplete
|
||||
&& node.response.progressMessages
|
||||
.filter(c => c.show === 'whileIncomplete')
|
||||
.map((c, i) =>
|
||||
<ProgressMessage {...c} key={`${node.id}-progress-whileIncomplete-${i}`} />
|
||||
)
|
||||
}
|
||||
{node.response.progressMessages
|
||||
.filter(c => c.show === 'forever')
|
||||
.map((c, i) =>
|
||||
<ProgressMessage {...c} key={`${node.id}-progress-afterComplete-${i}`} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
protected getChatResponsePartRenderer(content: ChatResponseContent, node: ResponseNode): React.ReactNode {
|
||||
const renderer = this.chatResponsePartRenderers.getContributions().reduce<[number, ChatResponsePartRenderer<ChatResponseContent> | undefined]>(
|
||||
(prev, current) => {
|
||||
const prio = current.canHandle(content);
|
||||
if (prio > prev[0]) {
|
||||
return [prio, current];
|
||||
} return prev;
|
||||
},
|
||||
[-1, undefined])[1];
|
||||
if (!renderer) {
|
||||
console.error('No renderer found for content', content);
|
||||
return <div>{nls.localize('theia/ai/chat-ui/chat-view-tree-widget/noRenderer', 'Error: No renderer found')}</div>;
|
||||
}
|
||||
return renderer.render(content, node);
|
||||
}
|
||||
|
||||
protected handleContextMenu(node: TreeNode | undefined, event: React.MouseEvent<HTMLElement>): void {
|
||||
this.contextMenuRenderer.render({
|
||||
menuPath: ChatViewTreeWidget.CONTEXT_MENU,
|
||||
anchor: { x: event.clientX, y: event.clientY },
|
||||
args: [node],
|
||||
context: event.currentTarget
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
export const SubChatWidgetFactory = Symbol('SubChatWidgetFactory');
|
||||
export type SubChatWidgetFactory = () => SubChatWidget;
|
||||
82
packages/ai-chat-ui/src/browser/chat-view-commands.ts
Normal file
82
packages/ai-chat-ui/src/browser/chat-view-commands.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 {
|
||||
export const CHAT_CATEGORY = 'Chat';
|
||||
export const CHAT_CATEGORY_KEY = nls.getDefaultKey(CHAT_CATEGORY);
|
||||
|
||||
export const SCROLL_LOCK_WIDGET = Command.toLocalizedCommand({
|
||||
id: 'chat:widget:lock',
|
||||
category: CHAT_CATEGORY,
|
||||
iconClass: codicon('unlock'),
|
||||
label: 'Lock Scroll'
|
||||
}, 'theia/ai-chat-ui/scroll-lock', CHAT_CATEGORY_KEY);
|
||||
|
||||
export const SCROLL_UNLOCK_WIDGET = Command.toLocalizedCommand({
|
||||
id: 'chat:widget:unlock',
|
||||
category: CHAT_CATEGORY,
|
||||
iconClass: codicon('lock'),
|
||||
label: 'Unlock Scroll'
|
||||
}, 'theia/ai-chat-ui/scroll-unlock', CHAT_CATEGORY_KEY);
|
||||
|
||||
export const EDIT_SESSION_SETTINGS = Command.toLocalizedCommand({
|
||||
id: 'chat:widget:session-settings',
|
||||
category: CHAT_CATEGORY,
|
||||
iconClass: codicon('bracket'),
|
||||
label: 'Set Session Settings'
|
||||
}, 'theia/ai-chat-ui/session-settings', CHAT_CATEGORY_KEY);
|
||||
|
||||
export const AI_CHAT_NEW_WITH_TASK_CONTEXT: Command = {
|
||||
id: 'ai-chat.new-with-task-context',
|
||||
};
|
||||
|
||||
export const AI_CHAT_INITIATE_SESSION_WITH_TASK_CONTEXT = Command.toLocalizedCommand({
|
||||
id: 'ai-chat.initiate-session-with-task-context',
|
||||
label: 'Task Context: Initiate Session',
|
||||
category: CHAT_CATEGORY
|
||||
}, 'theia/ai-chat-ui/initiate-session-task-context', CHAT_CATEGORY_KEY);
|
||||
|
||||
export const AI_CHAT_SUMMARIZE_CURRENT_SESSION = Command.toLocalizedCommand({
|
||||
id: 'ai-chat-summary-current-session',
|
||||
iconClass: codicon('go-to-editing-session'),
|
||||
label: 'Summarize Current Session',
|
||||
category: CHAT_CATEGORY
|
||||
}, 'theia/ai-chat-ui/summarize-current-session', CHAT_CATEGORY_KEY);
|
||||
|
||||
export const AI_CHAT_OPEN_SUMMARY_FOR_CURRENT_SESSION = Command.toLocalizedCommand({
|
||||
id: 'ai-chat-open-current-session-summary',
|
||||
iconClass: codicon('note'),
|
||||
label: 'Open Current Session Summary',
|
||||
category: CHAT_CATEGORY
|
||||
}, 'theia/ai-chat-ui/open-current-session-summary', CHAT_CATEGORY_KEY);
|
||||
}
|
||||
|
||||
export const AI_CHAT_NEW_CHAT_WINDOW_COMMAND = Command.toDefaultLocalizedCommand({
|
||||
id: 'ai-chat-ui.new-chat',
|
||||
iconClass: codicon('add'),
|
||||
category: ChatCommands.CHAT_CATEGORY,
|
||||
label: 'New Chat'
|
||||
});
|
||||
|
||||
export const AI_CHAT_SHOW_CHATS_COMMAND = Command.toLocalizedCommand({
|
||||
id: 'ai-chat-ui.show-chats',
|
||||
iconClass: codicon('history'),
|
||||
category: ChatCommands.CHAT_CATEGORY,
|
||||
label: 'Show Chats...'
|
||||
}, 'theia/ai-chat-ui/showChats');
|
||||
185
packages/ai-chat-ui/src/browser/chat-view-contribution.ts
Normal file
185
packages/ai-chat-ui/src/browser/chat-view-contribution.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
import { 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, isEditableRequestNode, isRequestNode,
|
||||
isResponseNode, RequestNode, ResponseNode, type EditableRequestNode
|
||||
} from './chat-tree-view/chat-view-tree-widget';
|
||||
import { AIChatInputWidget } from './chat-input-widget';
|
||||
import { AICommandHandlerFactory, ENABLE_AI_CONTEXT_KEY } from '@theia/ai-core/lib/browser';
|
||||
|
||||
export namespace ChatViewCommands {
|
||||
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.toLocalizedCommand({
|
||||
id: 'chat.copy.code',
|
||||
label: 'Copy Code Block'
|
||||
}, 'theia/ai/chat-ui/copyCodeBlock');
|
||||
export const EDIT = Command.toLocalizedCommand({
|
||||
id: 'chat.edit.request',
|
||||
label: 'Edit'
|
||||
}, 'theia/ai/chat-ui/editRequest');
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ChatViewMenuContribution implements MenuContribution, CommandContribution {
|
||||
|
||||
@inject(ClipboardService)
|
||||
protected readonly clipboardService: ClipboardService;
|
||||
|
||||
@inject(CommandService)
|
||||
protected readonly commandService: CommandService;
|
||||
|
||||
@inject(AICommandHandlerFactory)
|
||||
protected readonly commandHandlerFactory: AICommandHandlerFactory;
|
||||
|
||||
registerCommands(commands: CommandRegistry): void {
|
||||
commands.registerHandler(CommonCommands.COPY.id, this.commandHandlerFactory({
|
||||
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, this.commandHandlerFactory({
|
||||
execute: (...args: unknown[]) => {
|
||||
if (containsRequestOrResponseNode(args)) {
|
||||
this.copyMessage(extractRequestOrResponseNodes(args));
|
||||
}
|
||||
},
|
||||
isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args)
|
||||
}));
|
||||
commands.registerCommand(ChatViewCommands.COPY_ALL, this.commandHandlerFactory({
|
||||
execute: (...args: unknown[]) => {
|
||||
if (containsRequestOrResponseNode(args)) {
|
||||
const parent = extractRequestOrResponseNodes(args).find(arg => arg.parent)?.parent;
|
||||
const text = parent?.children
|
||||
.filter(isRequestOrResponseNode)
|
||||
.map(child => this.getCopyText(child))
|
||||
.join('\n\n---\n\n');
|
||||
if (text) {
|
||||
this.clipboardService.writeText(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
isEnabled: (...args: unknown[]) => containsRequestOrResponseNode(args)
|
||||
}));
|
||||
commands.registerCommand(ChatViewCommands.COPY_CODE, this.commandHandlerFactory({
|
||||
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)
|
||||
}));
|
||||
commands.registerCommand(ChatViewCommands.EDIT, this.commandHandlerFactory({
|
||||
execute: (...args: [EditableRequestNode, ...unknown[]]) => {
|
||||
args[0].request.enableEdit();
|
||||
},
|
||||
isEnabled: (...args: unknown[]) => hasAsFirstArg(args, isEditableRequestNode) && !args[0].request.isEditing,
|
||||
isVisible: (...args: unknown[]) => hasAsFirstArg(args, isEditableRequestNode) && !args[0].request.isEditing
|
||||
}));
|
||||
}
|
||||
|
||||
protected copyMessage(args: (RequestNode | ResponseNode)[]): void {
|
||||
const text = this.getCopyTextAndJoin(args);
|
||||
this.clipboardService.writeText(text);
|
||||
}
|
||||
|
||||
protected getCopyTextAndJoin(args: (RequestNode | ResponseNode)[] | undefined): string {
|
||||
return args !== undefined ? args.map(arg => this.getCopyText(arg)).join() : '';
|
||||
}
|
||||
|
||||
protected getCopyText(arg: RequestNode | ResponseNode): string {
|
||||
if (isRequestNode(arg)) {
|
||||
return arg.request.request.text ?? '';
|
||||
} else if (isResponseNode(arg)) {
|
||||
return arg.response.response.asDisplayString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
registerMenus(menus: MenuModelRegistry): void {
|
||||
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
|
||||
commandId: CommonCommands.COPY.id,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
|
||||
commandId: ChatViewCommands.COPY_MESSAGE.id,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
|
||||
commandId: ChatViewCommands.COPY_ALL.id,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
|
||||
commandId: ChatViewCommands.COPY_CODE.id,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
menus.registerMenuAction([...ChatViewTreeWidget.CONTEXT_MENU, '_1'], {
|
||||
commandId: ChatViewCommands.EDIT.id,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
menus.registerMenuAction([...AIChatInputWidget.CONTEXT_MENU, '_1'], {
|
||||
commandId: CommonCommands.COPY.id,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
menus.registerMenuAction([...AIChatInputWidget.CONTEXT_MENU, '_1'], {
|
||||
commandId: CommonCommands.PASTE.id,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hasAsFirstArg<T>(args: unknown[], guard: (arg: unknown) => arg is T): args is [T, ...unknown[]] {
|
||||
return args.length > 0 && guard(args[0]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 { ChatAgentService } from '@theia/ai-chat';
|
||||
import { AIVariableService } from '@theia/ai-core/lib/common';
|
||||
import { PromptText } from '@theia/ai-core/lib/common/prompt-text';
|
||||
import { PromptService, BasePromptFragment } from '@theia/ai-core/lib/common/prompt-service';
|
||||
import { ToolInvocationRegistry } from '@theia/ai-core/lib/common/tool-invocation-registry';
|
||||
import { MaybePromise, nls } from '@theia/core';
|
||||
import { FrontendApplication, FrontendApplicationContribution } from '@theia/core/lib/browser';
|
||||
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import * as monaco from '@theia/monaco-editor-core';
|
||||
import { ProviderResult } from '@theia/monaco-editor-core/esm/vs/editor/common/languages';
|
||||
import { AIChatFrontendContribution, VARIABLE_ADD_CONTEXT_COMMAND } from '@theia/ai-chat/lib/browser/ai-chat-frontend-contribution';
|
||||
|
||||
export const CHAT_VIEW_LANGUAGE_ID = 'theia-ai-chat-view-language';
|
||||
export const SETTINGS_LANGUAGE_ID = 'theia-ai-chat-settings-language';
|
||||
export const CHAT_VIEW_LANGUAGE_EXTENSION = 'aichatviewlanguage';
|
||||
|
||||
const VARIABLE_RESOLUTION_CONTEXT = { context: 'chat-input-autocomplete' };
|
||||
const VARIABLE_ARGUMENT_PICKER_COMMAND = 'trigger-variable-argument-picker';
|
||||
|
||||
interface CompletionSource<T> {
|
||||
triggerCharacter: string;
|
||||
getItems: () => T[];
|
||||
kind: monaco.languages.CompletionItemKind;
|
||||
getId: (item: T) => string;
|
||||
getName: (item: T) => string;
|
||||
getDescription: (item: T) => string;
|
||||
command?: monaco.languages.Command;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ChatViewLanguageContribution implements FrontendApplicationContribution {
|
||||
|
||||
@inject(ChatAgentService)
|
||||
protected readonly agentService: ChatAgentService;
|
||||
|
||||
@inject(AIVariableService)
|
||||
protected readonly variableService: AIVariableService;
|
||||
|
||||
@inject(ToolInvocationRegistry)
|
||||
protected readonly toolInvocationRegistry: ToolInvocationRegistry;
|
||||
|
||||
@inject(AIChatFrontendContribution)
|
||||
protected readonly chatFrontendContribution: AIChatFrontendContribution;
|
||||
|
||||
@inject(PromptService)
|
||||
protected readonly promptService: PromptService;
|
||||
|
||||
@inject(ContextKeyService)
|
||||
protected readonly contextKeyService: ContextKeyService;
|
||||
|
||||
onStart(_app: FrontendApplication): MaybePromise<void> {
|
||||
monaco.languages.register({ id: CHAT_VIEW_LANGUAGE_ID, extensions: [CHAT_VIEW_LANGUAGE_EXTENSION] });
|
||||
monaco.languages.register({ id: SETTINGS_LANGUAGE_ID, extensions: ['json'], filenames: ['editor'] });
|
||||
|
||||
this.registerCompletionProviders();
|
||||
|
||||
monaco.editor.registerCommand(VARIABLE_ARGUMENT_PICKER_COMMAND, this.triggerVariableArgumentPicker.bind(this));
|
||||
}
|
||||
|
||||
protected registerCompletionProviders(): void {
|
||||
this.registerStandardCompletionProvider({
|
||||
triggerCharacter: PromptText.AGENT_CHAR,
|
||||
getItems: () => this.agentService.getAgents(),
|
||||
kind: monaco.languages.CompletionItemKind.Value,
|
||||
getId: agent => `${agent.id} `,
|
||||
getName: agent => agent.name,
|
||||
getDescription: agent => agent.description
|
||||
});
|
||||
|
||||
this.registerStandardCompletionProvider({
|
||||
triggerCharacter: PromptText.VARIABLE_CHAR,
|
||||
getItems: () => this.variableService.getVariables(),
|
||||
kind: monaco.languages.CompletionItemKind.Variable,
|
||||
getId: variable => variable.args?.some(arg => !arg.isOptional) ? variable.name + PromptText.VARIABLE_SEPARATOR_CHAR : `${variable.name} `,
|
||||
getName: variable => variable.name,
|
||||
getDescription: variable => variable.description,
|
||||
command: {
|
||||
title: nls.localize('theia/ai/chat-ui/selectVariableArguments', 'Select variable arguments'),
|
||||
id: VARIABLE_ARGUMENT_PICKER_COMMAND,
|
||||
}
|
||||
});
|
||||
|
||||
this.registerStandardCompletionProvider({
|
||||
triggerCharacter: PromptText.FUNCTION_CHAR,
|
||||
getItems: () => this.toolInvocationRegistry.getAllFunctions(),
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
getId: tool => `${tool.id} `,
|
||||
getName: tool => tool.name,
|
||||
getDescription: tool => tool.description ?? ''
|
||||
});
|
||||
|
||||
// Register the variable argument completion provider (special case)
|
||||
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
|
||||
triggerCharacters: [PromptText.VARIABLE_CHAR, PromptText.VARIABLE_SEPARATOR_CHAR],
|
||||
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> =>
|
||||
this.provideVariableWithArgCompletions(model, position),
|
||||
});
|
||||
|
||||
// Register command completion provider
|
||||
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
|
||||
triggerCharacters: [PromptText.COMMAND_CHAR],
|
||||
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> =>
|
||||
this.provideCommandCompletions(model, position),
|
||||
});
|
||||
}
|
||||
|
||||
protected registerStandardCompletionProvider<T>(source: CompletionSource<T>): void {
|
||||
monaco.languages.registerCompletionItemProvider(CHAT_VIEW_LANGUAGE_ID, {
|
||||
triggerCharacters: [source.triggerCharacter],
|
||||
provideCompletionItems: (model, position, _context, _token): ProviderResult<monaco.languages.CompletionList> =>
|
||||
this.provideCompletions(model, position, source),
|
||||
});
|
||||
}
|
||||
|
||||
getCompletionRange(model: monaco.editor.ITextModel, position: monaco.Position, triggerCharacter: string): monaco.Range | undefined {
|
||||
const wordInfo = model.getWordUntilPosition(position);
|
||||
const lineContent = model.getLineContent(position.lineNumber);
|
||||
// one to the left, and -1 for 0-based index
|
||||
const characterBeforeCurrentWord = lineContent[wordInfo.startColumn - 1 - 1];
|
||||
|
||||
if (characterBeforeCurrentWord !== triggerCharacter) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// we are not at the beginning of the line
|
||||
if (wordInfo.startColumn > 2) {
|
||||
const charBeforeTrigger = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: wordInfo.startColumn - 2,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: wordInfo.startColumn - 1
|
||||
});
|
||||
// If the character before the trigger is not whitespace, don't provide completions
|
||||
if (!/\s/.test(charBeforeTrigger)) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return new monaco.Range(
|
||||
position.lineNumber,
|
||||
wordInfo.startColumn,
|
||||
position.lineNumber,
|
||||
position.column
|
||||
);
|
||||
}
|
||||
|
||||
protected provideCompletions<T>(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
source: CompletionSource<T>
|
||||
): ProviderResult<monaco.languages.CompletionList> {
|
||||
const completionRange = this.getCompletionRange(model, position, source.triggerCharacter);
|
||||
if (completionRange === undefined) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const items = source.getItems();
|
||||
const suggestions = items.map(item => ({
|
||||
insertText: source.getId(item),
|
||||
kind: source.kind,
|
||||
label: source.getName(item),
|
||||
range: completionRange,
|
||||
detail: source.getDescription(item),
|
||||
command: source.command
|
||||
}));
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
async provideVariableWithArgCompletions(model: monaco.editor.ITextModel, position: monaco.Position): Promise<monaco.languages.CompletionList> {
|
||||
// Get the text of the current line up to the cursor position
|
||||
const textUntilPosition = model.getValueInRange({
|
||||
startLineNumber: position.lineNumber,
|
||||
startColumn: 1,
|
||||
endLineNumber: position.lineNumber,
|
||||
endColumn: position.column,
|
||||
});
|
||||
|
||||
// Regex that captures the variable name in contexts like "#varname" or "#var-name:args"
|
||||
// Matches only when # is at the beginning of the string or after whitespace
|
||||
const variableRegex = /(?:^|\s)#([\w-]*)/;
|
||||
const match = textUntilPosition.match(variableRegex);
|
||||
|
||||
if (!match) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const currentVariableName = match[1];
|
||||
const hasColonSeparator = textUntilPosition.includes(`${currentVariableName}:`);
|
||||
|
||||
const variables = this.variableService.getVariables();
|
||||
const suggestions: monaco.languages.CompletionItem[] = [];
|
||||
|
||||
for (const variable of variables) {
|
||||
// If we have a variable:arg pattern, only process the matching variable
|
||||
if (hasColonSeparator && variable.name !== currentVariableName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const provider = await this.variableService.getArgumentCompletionProvider(variable.name);
|
||||
if (provider) {
|
||||
const items = await provider(model, position);
|
||||
if (items) {
|
||||
suggestions.push(...items.map(item => ({
|
||||
command: {
|
||||
title: VARIABLE_ADD_CONTEXT_COMMAND.label!,
|
||||
id: VARIABLE_ADD_CONTEXT_COMMAND.id,
|
||||
arguments: [variable.name, item.insertText]
|
||||
},
|
||||
...item,
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
protected async triggerVariableArgumentPicker(): Promise<void> {
|
||||
const inputEditor = monaco.editor.getEditors().find(editor => editor.hasTextFocus());
|
||||
if (!inputEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = inputEditor.getModel();
|
||||
const position = inputEditor.getPosition();
|
||||
if (!model || !position) {
|
||||
return;
|
||||
}
|
||||
|
||||
// // Get the word at cursor
|
||||
const wordInfo = model.getWordUntilPosition(position);
|
||||
|
||||
// account for the variable separator character if present
|
||||
let endOfWordPosition = position.column;
|
||||
if (wordInfo.word === '' && this.getCharacterBeforePosition(model, position) === PromptText.VARIABLE_SEPARATOR_CHAR) {
|
||||
endOfWordPosition = position.column - 1;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const variableName = model.getWordAtPosition({ ...position, column: endOfWordPosition })?.word;
|
||||
if (!variableName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = await this.variableService.getArgumentPicker(variableName, VARIABLE_RESOLUTION_CONTEXT);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const arg = await provider(VARIABLE_RESOLUTION_CONTEXT);
|
||||
if (!arg) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputEditor.executeEdits('variable-argument-picker', [{
|
||||
range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
|
||||
text: arg
|
||||
}]);
|
||||
|
||||
await this.chatFrontendContribution.addContextVariable(variableName, arg);
|
||||
}
|
||||
|
||||
protected getCharacterBeforePosition(model: monaco.editor.ITextModel, position: monaco.Position): string {
|
||||
return model.getLineContent(position.lineNumber)[position.column - 1 - 1];
|
||||
}
|
||||
|
||||
protected provideCommandCompletions(model: monaco.editor.ITextModel, position: monaco.Position): ProviderResult<monaco.languages.CompletionList> {
|
||||
const range = this.getCompletionRange(model, position, PromptText.COMMAND_CHAR);
|
||||
if (range === undefined) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
let currentAgentId: string | undefined;
|
||||
const allAgents = this.agentService.getAgents();
|
||||
for (const agent of allAgents) {
|
||||
if (this.contextKeyService.match(`chatInputReceivingAgent == '${agent.id}'`)) {
|
||||
currentAgentId = agent.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const commands = this.promptService.getCommands(currentAgentId);
|
||||
|
||||
const suggestions = commands.map(cmd => {
|
||||
const base = cmd as BasePromptFragment;
|
||||
const label = base.commandName || base.id;
|
||||
const description = base.commandDescription || '';
|
||||
const argHint = base.commandArgumentHint || '';
|
||||
|
||||
const detail = argHint ? `${description} — ${argHint}` : description;
|
||||
|
||||
return {
|
||||
insertText: `${label} `,
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
label,
|
||||
range,
|
||||
detail
|
||||
};
|
||||
});
|
||||
|
||||
return { suggestions };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 './ai-chat-ui-contribution';
|
||||
import { Emitter, InMemoryResources, URI, nls } from '@theia/core';
|
||||
import { ChatCommands } from './chat-view-commands';
|
||||
import { CommandRegistry } from '@theia/core/lib/common/command';
|
||||
import { SessionSettingsDialog } from './session-settings-dialog';
|
||||
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
|
||||
import { ChatViewWidget } from './chat-view-widget';
|
||||
import { AIActivationService, ENABLE_AI_CONTEXT_KEY } from '@theia/ai-core/lib/browser';
|
||||
|
||||
@injectable()
|
||||
export class ChatViewWidgetToolbarContribution implements TabBarToolbarContribution {
|
||||
@inject(AIChatContribution)
|
||||
protected readonly chatContribution: AIChatContribution;
|
||||
|
||||
@inject(CommandRegistry)
|
||||
protected readonly commandRegistry: CommandRegistry;
|
||||
|
||||
@inject(MonacoEditorProvider)
|
||||
protected readonly editorProvider: MonacoEditorProvider;
|
||||
|
||||
@inject(InMemoryResources)
|
||||
protected readonly resources: InMemoryResources;
|
||||
|
||||
@inject(AIActivationService)
|
||||
protected readonly activationService: AIActivationService;
|
||||
|
||||
protected readonly onChatWidgetStateChangedEmitter = new Emitter<void>();
|
||||
protected readonly onChatWidgetStateChanged = this.onChatWidgetStateChangedEmitter.event;
|
||||
|
||||
private readonly sessionSettingsURI = new URI('chat-view:/settings.json');
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.resources.add(this.sessionSettingsURI, '{}');
|
||||
|
||||
this.chatContribution.widget.then(widget => {
|
||||
widget.onStateChanged(() => this.onChatWidgetStateChangedEmitter.fire());
|
||||
});
|
||||
|
||||
this.commandRegistry.registerCommand(ChatCommands.EDIT_SESSION_SETTINGS, {
|
||||
execute: () => this.openJsonDataDialog(),
|
||||
isEnabled: widget => this.activationService.isActive && widget instanceof ChatViewWidget,
|
||||
isVisible: widget => this.activationService.isActive && widget instanceof ChatViewWidget
|
||||
});
|
||||
}
|
||||
|
||||
registerToolbarItems(registry: TabBarToolbarRegistry): void {
|
||||
registry.registerItem({
|
||||
id: ChatCommands.SCROLL_LOCK_WIDGET.id,
|
||||
command: ChatCommands.SCROLL_LOCK_WIDGET.id,
|
||||
tooltip: nls.localizeByDefault('Turn Auto Scrolling Off'),
|
||||
onDidChange: this.onChatWidgetStateChanged,
|
||||
priority: 2,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
registry.registerItem({
|
||||
id: ChatCommands.SCROLL_UNLOCK_WIDGET.id,
|
||||
command: ChatCommands.SCROLL_UNLOCK_WIDGET.id,
|
||||
tooltip: nls.localizeByDefault('Turn Auto Scrolling On'),
|
||||
onDidChange: this.onChatWidgetStateChanged,
|
||||
priority: 2,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
registry.registerItem({
|
||||
id: ChatCommands.EDIT_SESSION_SETTINGS.id,
|
||||
command: ChatCommands.EDIT_SESSION_SETTINGS.id,
|
||||
tooltip: nls.localize('theia/ai/session-settings-dialog/tooltip', 'Set Session Settings'),
|
||||
priority: 3,
|
||||
when: ENABLE_AI_CONTEXT_KEY
|
||||
});
|
||||
}
|
||||
|
||||
protected async openJsonDataDialog(): Promise<void> {
|
||||
const widget = await this.chatContribution.widget;
|
||||
if (!widget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = new SessionSettingsDialog(this.editorProvider, this.resources, this.sessionSettingsURI, {
|
||||
initialSettings: widget.getSettings()
|
||||
});
|
||||
|
||||
const result = await dialog.open();
|
||||
if (result) {
|
||||
widget.setSettings(result);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
302
packages/ai-chat-ui/src/browser/chat-view-widget.tsx
Normal file
302
packages/ai-chat-ui/src/browser/chat-view-widget.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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, PreferenceService, URI } from '@theia/core';
|
||||
import { ChatRequest, ChatRequestModel, ChatService, ChatSession, isActiveSessionChangedEvent, MutableChatModel } from '@theia/ai-chat';
|
||||
import { BaseWidget, codicon, ExtractableWidget, Message, PanelLayout, StatefulWidget } from '@theia/core/lib/browser';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { AIChatInputWidget } from './chat-input-widget';
|
||||
import { ChatViewTreeWidget, ChatWelcomeMessageProvider } from './chat-tree-view/chat-view-tree-widget';
|
||||
import { AIActivationService } from '@theia/ai-core/lib/browser/ai-activation-service';
|
||||
import { AIVariableResolutionRequest } from '@theia/ai-core';
|
||||
import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory';
|
||||
import { FrontendVariableService } from '@theia/ai-core/lib/browser';
|
||||
import { FrontendLanguageModelRegistry } from '@theia/ai-core/lib/common';
|
||||
|
||||
export namespace ChatViewWidget {
|
||||
export interface State {
|
||||
locked?: boolean;
|
||||
temporaryLocked?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class ChatViewWidget extends BaseWidget implements ExtractableWidget, StatefulWidget {
|
||||
|
||||
public static ID = 'chat-view-widget';
|
||||
static LABEL = nls.localize('theia/ai/chat/view/label', 'AI Chat');
|
||||
|
||||
@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;
|
||||
|
||||
@inject(FrontendVariableService)
|
||||
protected readonly variableService: FrontendVariableService;
|
||||
|
||||
@inject(ProgressBarFactory)
|
||||
protected readonly progressBarFactory: ProgressBarFactory;
|
||||
|
||||
@inject(FrontendLanguageModelRegistry)
|
||||
protected readonly languageModelRegistry: FrontendLanguageModelRegistry;
|
||||
|
||||
@inject(ChatWelcomeMessageProvider) @optional()
|
||||
protected readonly welcomeProvider?: ChatWelcomeMessageProvider;
|
||||
|
||||
protected chatSession: ChatSession;
|
||||
|
||||
protected _state: ChatViewWidget.State = { locked: false, temporaryLocked: false };
|
||||
protected readonly onStateChangedEmitter = new Emitter<ChatViewWidget.State>();
|
||||
|
||||
isExtractable = true;
|
||||
secondaryWindow: Window | undefined;
|
||||
|
||||
constructor(
|
||||
@inject(ChatViewTreeWidget)
|
||||
readonly treeWidget: ChatViewTreeWidget,
|
||||
@inject(AIChatInputWidget)
|
||||
readonly inputWidget: AIChatInputWidget
|
||||
) {
|
||||
super();
|
||||
this.id = ChatViewWidget.ID;
|
||||
this.title.label = ChatViewWidget.LABEL;
|
||||
this.title.caption = ChatViewWidget.LABEL;
|
||||
this.title.iconClass = codicon('comment-discussion');
|
||||
this.title.closable = false; // Always visible in Product OS
|
||||
this.node.classList.add('chat-view-widget');
|
||||
this.update();
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this.toDispose.pushAll([
|
||||
this.treeWidget,
|
||||
this.inputWidget,
|
||||
this.onStateChanged(newState => {
|
||||
const shouldScrollToEnd = !newState.locked && !newState.temporaryLocked;
|
||||
this.treeWidget.shouldScrollToEnd = shouldScrollToEnd;
|
||||
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.onUnpin = this.onUnpin.bind(this);
|
||||
this.inputWidget.onCancel = this.onCancel.bind(this);
|
||||
this.inputWidget.chatModel = this.chatSession.model;
|
||||
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
|
||||
this.inputWidget.onDeleteChangeSet = this.onDeleteChangeSet.bind(this);
|
||||
this.inputWidget.onDeleteChangeSetElement = this.onDeleteChangeSetElement.bind(this);
|
||||
this.treeWidget.trackChatModel(this.chatSession.model);
|
||||
this.treeWidget.onScrollLockChange = this.onScrollLockChange.bind(this);
|
||||
|
||||
this.initListeners();
|
||||
|
||||
this.updateInputEnabledState();
|
||||
|
||||
this.activationService.onDidChangeActiveStatus(change => {
|
||||
this.treeWidget.setEnabled(change);
|
||||
this.updateInputEnabledState();
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.toDispose.push(
|
||||
this.languageModelRegistry.onChange(() => {
|
||||
this.updateInputEnabledState();
|
||||
})
|
||||
);
|
||||
|
||||
if (this.welcomeProvider?.onStateChanged) {
|
||||
this.toDispose.push(this.welcomeProvider.onStateChanged(() => {
|
||||
this.updateInputEnabledState();
|
||||
this.update();
|
||||
}));
|
||||
}
|
||||
|
||||
this.toDispose.push(this.progressBarFactory({ container: this.node, insertMode: 'prepend', locationId: 'ai-chat' }));
|
||||
}
|
||||
|
||||
protected async updateInputEnabledState(): Promise<void> {
|
||||
const shouldEnable = this.activationService.isActive && await this.shouldEnableInput();
|
||||
this.inputWidget.setEnabled(shouldEnable);
|
||||
this.treeWidget.setEnabled(this.activationService.isActive);
|
||||
}
|
||||
|
||||
protected async shouldEnableInput(): Promise<boolean> {
|
||||
if (!this.welcomeProvider) {
|
||||
return true;
|
||||
}
|
||||
const hasReadyModels = await this.hasReadyLanguageModels();
|
||||
const modelRequirementBypassed = this.welcomeProvider.modelRequirementBypassed ?? false;
|
||||
return hasReadyModels || modelRequirementBypassed;
|
||||
}
|
||||
|
||||
protected async hasReadyLanguageModels(): Promise<boolean> {
|
||||
const models = await this.languageModelRegistry.getLanguageModels();
|
||||
return models.some(model => model.status.status === 'ready');
|
||||
}
|
||||
|
||||
protected initListeners(): void {
|
||||
this.toDispose.pushAll([
|
||||
this.chatService.onSessionEvent(event => {
|
||||
if (!isActiveSessionChangedEvent(event)) {
|
||||
return;
|
||||
}
|
||||
const session = event.sessionId ? this.chatService.getSession(event.sessionId) : this.chatService.createSession();
|
||||
if (session) {
|
||||
this.chatSession = session;
|
||||
this.treeWidget.trackChatModel(this.chatSession.model);
|
||||
this.inputWidget.chatModel = this.chatSession.model;
|
||||
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
|
||||
} else {
|
||||
console.warn(`Session with ${event.sessionId} not found.`);
|
||||
}
|
||||
}),
|
||||
// The chat view needs to handle the submission of the edit request
|
||||
this.treeWidget.onDidSubmitEdit(request => {
|
||||
this.onQuery(request);
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
protected override onActivateRequest(msg: Message): void {
|
||||
super.onActivateRequest(msg);
|
||||
this.inputWidget.activate();
|
||||
}
|
||||
|
||||
storeState(): object {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
restoreState(oldState: object & Partial<ChatViewWidget.State>): void {
|
||||
const copy = deepClone(this.state);
|
||||
if (oldState.locked) {
|
||||
copy.locked = oldState.locked;
|
||||
}
|
||||
// Don't restore temporary lock state as it should reset on restart
|
||||
copy.temporaryLocked = false;
|
||||
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<ChatViewWidget.State> {
|
||||
return this.onStateChangedEmitter.event;
|
||||
}
|
||||
|
||||
protected async onQuery(query?: string | ChatRequest, modeId?: string): Promise<void> {
|
||||
const chatRequest: ChatRequest = !query
|
||||
? { text: '' }
|
||||
: typeof query === 'string'
|
||||
? { text: query, modeId }
|
||||
: { ...query };
|
||||
if (chatRequest.text.length === 0) { return; }
|
||||
|
||||
const requestProgress = await this.chatService.sendRequest(this.chatSession.id, chatRequest);
|
||||
requestProgress?.responseCompleted.then(responseModel => {
|
||||
if (responseModel.isError) {
|
||||
this.messageService.error(responseModel.errorObject?.message ??
|
||||
nls.localize('theia/ai/chat-ui/errorChatInvocation', 'An error occurred during chat service invocation.'));
|
||||
}
|
||||
}).finally(() => {
|
||||
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
|
||||
});
|
||||
if (!requestProgress) {
|
||||
this.messageService.error(nls.localize('theia/ai/chat-ui/couldNotSendRequestToSession',
|
||||
'Was not able to send request "{0}" to session {1}', chatRequest.text, this.chatSession.id));
|
||||
return;
|
||||
}
|
||||
// Tree Widget currently tracks the ChatModel itself. Therefore no notification necessary.
|
||||
}
|
||||
|
||||
protected onUnpin(): void {
|
||||
this.chatSession.pinnedAgent = undefined;
|
||||
this.inputWidget.pinnedAgent = this.chatSession.pinnedAgent;
|
||||
}
|
||||
|
||||
protected onCancel(requestModel: ChatRequestModel): void {
|
||||
this.chatService.cancelRequest(requestModel.session.id, requestModel.id);
|
||||
}
|
||||
|
||||
protected onDeleteChangeSet(sessionId: string): void {
|
||||
this.chatService.deleteChangeSet(sessionId);
|
||||
}
|
||||
|
||||
protected onDeleteChangeSetElement(sessionId: string, uri: URI): void {
|
||||
this.chatService.deleteChangeSetElement(sessionId, uri);
|
||||
}
|
||||
|
||||
protected onScrollLockChange(temporaryLocked: boolean): void {
|
||||
this.setTemporaryLock(temporaryLocked);
|
||||
}
|
||||
|
||||
lock(): void {
|
||||
this.state = { ...deepClone(this.state), locked: true, temporaryLocked: false };
|
||||
}
|
||||
|
||||
unlock(): void {
|
||||
this.state = { ...deepClone(this.state), locked: false, temporaryLocked: false };
|
||||
}
|
||||
|
||||
setTemporaryLock(locked: boolean): void {
|
||||
// Only set temporary lock if not permanently locked
|
||||
if (!this.state.locked) {
|
||||
this.state = { ...deepClone(this.state), temporaryLocked: locked };
|
||||
}
|
||||
}
|
||||
|
||||
get isLocked(): boolean {
|
||||
return !!this.state.locked;
|
||||
}
|
||||
|
||||
addContext(variable: AIVariableResolutionRequest): void {
|
||||
this.inputWidget.addContext(variable);
|
||||
}
|
||||
|
||||
setSettings(settings: { [key: string]: unknown }): void {
|
||||
if (this.chatSession && this.chatSession.model) {
|
||||
const model = this.chatSession.model as MutableChatModel;
|
||||
model.setSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
getSettings(): { [key: string]: unknown } | undefined {
|
||||
return this.chatSession.model.settings;
|
||||
}
|
||||
}
|
||||
85
packages/ai-chat-ui/src/browser/context-variable-picker.ts
Normal file
85
packages/ai-chat-ui/src/browser/context-variable-picker.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// https://www.gnu.org/software/classpath/license.html.
|
||||
//
|
||||
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
||||
// *****************************************************************************
|
||||
|
||||
import { AIContextVariable, AIVariableResolutionRequest, AIVariableService, PromptText } from '@theia/ai-core';
|
||||
import { nls, QuickInputService } from '@theia/core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
|
||||
const QUERY_CONTEXT = { type: 'context-variable-picker' };
|
||||
|
||||
@injectable()
|
||||
export class ContextVariablePicker {
|
||||
|
||||
@inject(AIVariableService)
|
||||
protected readonly variableService: AIVariableService;
|
||||
|
||||
@inject(QuickInputService)
|
||||
protected readonly quickInputService: QuickInputService;
|
||||
|
||||
async pickContextVariable(): Promise<AIVariableResolutionRequest | undefined> {
|
||||
const variables = this.variableService.getContextVariables();
|
||||
const selection = await this.quickInputService.showQuickPick(
|
||||
variables.map(v => ({
|
||||
id: v.id,
|
||||
label: v.label ?? v.name,
|
||||
variable: v,
|
||||
iconClasses: v.iconClasses,
|
||||
})),
|
||||
{ placeholder: nls.localize('theia/ai/chat-ui/selectContextVariableQuickPickPlaceholder', 'Select a context variable to be attached to the message'), }
|
||||
);
|
||||
if (!selection) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const variable = selection.variable;
|
||||
if (!variable.args || variable.args.length === 0) {
|
||||
return { variable };
|
||||
}
|
||||
|
||||
const argumentPicker = await this.variableService.getArgumentPicker(variable.name, QUERY_CONTEXT);
|
||||
if (!argumentPicker) {
|
||||
return this.useGenericArgumentPicker(variable);
|
||||
}
|
||||
const arg = await argumentPicker(QUERY_CONTEXT);
|
||||
if (!arg) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { variable, arg };
|
||||
}
|
||||
|
||||
protected async useGenericArgumentPicker(variable: AIContextVariable): Promise<AIVariableResolutionRequest | undefined> {
|
||||
const args: string[] = [];
|
||||
for (const argument of variable.args ?? []) {
|
||||
const placeHolder = argument.description;
|
||||
let input: string | undefined;
|
||||
if (argument.enum) {
|
||||
const picked = await this.quickInputService.pick(
|
||||
argument.enum.map(enumItem => ({ label: enumItem })),
|
||||
{ placeHolder, canPickMany: false }
|
||||
);
|
||||
input = picked?.label;
|
||||
} else {
|
||||
input = await this.quickInputService.input({ placeHolder });
|
||||
}
|
||||
if (!input && !argument.isOptional) {
|
||||
return;
|
||||
}
|
||||
args.push(input ?? '');
|
||||
}
|
||||
return { variable, arg: args.join(PromptText.VARIABLE_SEPARATOR_CHAR) };
|
||||
}
|
||||
}
|
||||
144
packages/ai-chat-ui/src/browser/session-settings-dialog.tsx
Normal file
144
packages/ai-chat-ui/src/browser/session-settings-dialog.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 EclipseSource GmbH.
|
||||
//
|
||||
// This program and the accompanying materials are made available under the
|
||||
// terms of the Eclipse Public License v. 2.0 which is available at
|
||||
// http://www.eclipse.org/legal/epl-2.0.
|
||||
//
|
||||
// This Source Code may also be made available under the following Secondary
|
||||
// Licenses when the conditions for such availability set forth in the Eclipse
|
||||
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
||||
// with the GNU Classpath Exception which is available at
|
||||
// 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 { InMemoryResources, URI, nls } from '@theia/core';
|
||||
import { AbstractDialog } from '@theia/core/lib/browser/dialogs';
|
||||
|
||||
import { Message } from '@theia/core/lib/browser';
|
||||
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
|
||||
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
|
||||
|
||||
export interface SessionSettingsDialogProps {
|
||||
initialSettings: { [key: string]: unknown } | undefined;
|
||||
}
|
||||
|
||||
export class SessionSettingsDialog extends AbstractDialog<{ [key: string]: unknown }> {
|
||||
|
||||
protected jsonEditor: MonacoEditor | undefined;
|
||||
protected dialogContent: HTMLDivElement;
|
||||
protected errorMessageDiv: HTMLDivElement;
|
||||
protected settings: { [key: string]: unknown } = {};
|
||||
protected initialSettingsString: string;
|
||||
|
||||
constructor(
|
||||
protected readonly editorProvider: MonacoEditorProvider,
|
||||
protected readonly resources: InMemoryResources,
|
||||
protected readonly uri: URI,
|
||||
protected readonly options: SessionSettingsDialogProps
|
||||
) {
|
||||
super({
|
||||
title: nls.localize('theia/ai/session-settings-dialog/title', 'Set Session Settings')
|
||||
});
|
||||
|
||||
const initialSettings = options.initialSettings;
|
||||
this.initialSettingsString = JSON.stringify(initialSettings, undefined, 2) || '{}';
|
||||
|
||||
this.contentNode.classList.add('monaco-session-settings-dialog');
|
||||
|
||||
this.dialogContent = document.createElement('div');
|
||||
this.dialogContent.className = 'session-settings-container';
|
||||
this.contentNode.appendChild(this.dialogContent);
|
||||
|
||||
this.errorMessageDiv = document.createElement('div');
|
||||
this.errorMessageDiv.className = 'session-settings-error';
|
||||
this.contentNode.appendChild(this.errorMessageDiv);
|
||||
|
||||
this.appendCloseButton(nls.localizeByDefault('Cancel'));
|
||||
this.appendAcceptButton(nls.localizeByDefault('Apply'));
|
||||
|
||||
this.createJsonEditor();
|
||||
|
||||
this.validateJson();
|
||||
}
|
||||
|
||||
protected override onAfterAttach(msg: Message): void {
|
||||
super.onAfterAttach(msg);
|
||||
this.update();
|
||||
}
|
||||
|
||||
protected override onActivateRequest(msg: Message): void {
|
||||
super.onActivateRequest(msg);
|
||||
if (this.jsonEditor) {
|
||||
this.jsonEditor.focus();
|
||||
}
|
||||
}
|
||||
protected async createJsonEditor(): Promise<void> {
|
||||
|
||||
this.resources.update(this.uri, this.initialSettingsString);
|
||||
try {
|
||||
const editor = await this.editorProvider.createInline(this.uri, this.dialogContent, {
|
||||
language: 'json',
|
||||
automaticLayout: true,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
folding: true,
|
||||
lineNumbers: 'on',
|
||||
fontSize: 13,
|
||||
wordWrap: 'on',
|
||||
renderValidationDecorations: 'on',
|
||||
scrollbar: {
|
||||
vertical: 'auto',
|
||||
horizontal: 'auto'
|
||||
}
|
||||
});
|
||||
|
||||
editor.getControl().onDidChangeModelContent(() => {
|
||||
this.validateJson();
|
||||
});
|
||||
editor.document.textEditorModel.setValue(this.initialSettingsString);
|
||||
|
||||
this.jsonEditor = editor;
|
||||
this.validateJson();
|
||||
} catch (error) {
|
||||
console.error('Failed to create JSON editor:', error);
|
||||
}
|
||||
}
|
||||
|
||||
protected validateJson(): void {
|
||||
if (!this.jsonEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonContent = this.jsonEditor.getControl().getValue();
|
||||
|
||||
try {
|
||||
this.settings = JSON.parse(jsonContent);
|
||||
this.errorMessageDiv.textContent = '';
|
||||
this.setErrorButtonState(false);
|
||||
} catch (error) {
|
||||
this.errorMessageDiv.textContent = `${error}`;
|
||||
this.setErrorButtonState(true);
|
||||
}
|
||||
}
|
||||
|
||||
protected setErrorButtonState(isError: boolean): void {
|
||||
const acceptButton = this.acceptButton;
|
||||
if (acceptButton) {
|
||||
acceptButton.disabled = isError;
|
||||
if (isError) {
|
||||
acceptButton.classList.add('disabled');
|
||||
} else {
|
||||
acceptButton.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get value(): { [key: string]: unknown } {
|
||||
return this.settings;
|
||||
}
|
||||
}
|
||||
1347
packages/ai-chat-ui/src/browser/style/index.css
Normal file
1347
packages/ai-chat-ui/src/browser/style/index.css
Normal file
File diff suppressed because it is too large
Load Diff
40
packages/ai-chat-ui/tsconfig.json
Normal file
40
packages/ai-chat-ui/tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"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": "../preferences"
|
||||
},
|
||||
{
|
||||
"path": "../workspace"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user