Skip to content

Commit

Permalink
feat(ai): Enable Context Variables for Chat Requests (#14787)
Browse files Browse the repository at this point in the history
This PR introduces support for adding context elements (e.g., files,
symbols, or domain-specific concepts) to chat requests, allowing chat
agents to leverage additional context without embedding it
directly in user input.

- Extended the existing *variable* concept with `AIContextVariable`.
- Context variables can provide a dedicated `contextValue` that agents
can use separately from the chat request text.
- Context variables can be included in chat requests in two ways:
  1. Providing `AIVariableResolutionRequest` alongside the
`ChatRequest`.
  2. Mentioning a context variable directly in the chat request text
(e.g., `#file:abc.txt`).

- Extended the chat input widget to display and manage context
variables.
- Users can add context variables via:
  1. A `+` button, opening a quick pick list of available context
variables.
  2. Typing a context variable (`#` prefix), with auto-completion
support.
- Theia’s label provider is used to display context variables in a
user-friendly format.

- Enhanced support for variable arguments when adding context variables
via the UI.
- Introduced:
  - `AIVariableArgPicker` for UI-based argument selection.
  - `AIVariableArgCompletionProvider` for auto-completion of variable
arguments.
- Added a new context variable `#file` that accepts a file path as an
argument.
- Refactored `QuickFileSelectService` for consistent file path selection
across argument pickers and completion providers.

- `ChatService` now resolves context variables and attaches
`ResolvedAIContextVariable` objects to `ChatRequestModel`.
- Variables can both:
  - Replace occurrences in chat text (`ResolvedAIVariable.value`).
  - Provide a separate `contextValue` for the chat model.

* Make context variable completion more flexible

Don't have the chat-view-language-contribution prefix the completion item, but give the provider full control. This enables triggering the completion also on : and #.

Fixes #14839

Co-authored-by: Stefan Dirix <[email protected]>
  • Loading branch information
planger and sdirix authored Feb 18, 2025
1 parent 55ff28b commit 8cdde21
Show file tree
Hide file tree
Showing 29 changed files with 1,446 additions and 329 deletions.
7 changes: 4 additions & 3 deletions examples/api-tests/src/file-search.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,19 @@ describe('file-search', function () {

const Uri = require('@theia/core/lib/common/uri');
const { QuickFileOpenService } = require('@theia/file-search/lib/browser/quick-file-open');
const { QuickFileSelectService } = require('@theia/file-search/lib/browser/quick-file-select-service');
const { CancellationTokenSource } = require('@theia/core/lib/common/cancellation');

/** @type {import('inversify').Container} */
const container = window['theia'].container;
const quickFileOpenService = container.get(QuickFileOpenService);
const quickFileSelectService = container.get(QuickFileSelectService);

describe('quick-file-open', () => {

describe('#compareItems', () => {

/** @type import ('@theia/file-search/lib/browser/quick-file-open').QuickFileOpenService['compareItems']*/
const sortByCompareItems = (a, b) => quickFileOpenService['compareItems'](a, b);
const sortByCompareItems = (a, b) => quickFileSelectService['compareItems'](a, b, quickFileOpenService['filterAndRange'].filter);

it('should compare two quick-open-items by `label`', () => {

Expand All @@ -43,7 +44,7 @@ describe('file-search', function () {

assert.deepEqual([a, b].sort(sortByCompareItems), [a, b], 'a should be before b');
assert.deepEqual([b, a].sort(sortByCompareItems), [a, b], 'a should be before b');
assert.equal(quickFileOpenService['compareItems'](a, a), 0, 'items should be equal');
assert.equal(quickFileSelectService['compareItems'](a, a, quickFileOpenService['filterAndRange'].filter), 0, 'items should be equal');
});

it('should compare two quick-open-items by `uri`', () => {
Expand Down
11 changes: 7 additions & 4 deletions packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import { bindContributionProvider, CommandContribution, MenuContribution } from
import { bindViewContribution, FrontendApplicationContribution, WidgetFactory } from '@theia/core/lib/browser';
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { ContainerModule, interfaces } from '@theia/core/shared/inversify';
import { EditorPreviewManager } from '@theia/editor-preview/lib/browser/editor-preview-manager';
import { EditorManager } from '@theia/editor/lib/browser';
import '../../src/browser/style/index.css';
import { AIChatContribution } from './ai-chat-ui-contribution';
import { AIChatInputConfiguration, AIChatInputWidget } from './chat-input-widget';
import { ChatNodeToolbarActionContribution } from './chat-node-toolbar-action-contribution';
Expand All @@ -41,15 +43,14 @@ import {
TextFragmentSelectionResolver,
TypeDocSymbolSelectionResolver,
} from './chat-response-renderer/ai-editor-manager';
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 { EditorPreviewManager } from '@theia/editor-preview/lib/browser/editor-preview-manager';
import { QuestionPartRenderer } from './chat-response-renderer/question-part-renderer';
import '../../src/browser/style/index.css';
import { ContextVariablePicker } from './context-variable-picker';

export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
bindViewContribution(bind, AIChatContribution);
Expand All @@ -61,7 +62,7 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {

bind(AIChatInputWidget).toSelf();
bind(AIChatInputConfiguration).toConstantValue({
showContext: false,
showContext: true,
showPinnedAgent: true
});
bind(WidgetFactory).toDynamicValue(({ container }) => ({
Expand All @@ -77,6 +78,8 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
createWidget: () => container.get(ChatViewTreeWidget)
})).inSingletonScope();

bind(ContextVariablePicker).toSelf().inSingletonScope();

bind(ChatResponsePartRenderer).to(HorizontalLayoutPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(ErrorPartRenderer).inSingletonScope();
bind(ChatResponsePartRenderer).to(MarkdownPartRenderer).inSingletonScope();
Expand Down
136 changes: 131 additions & 5 deletions packages/ai-chat-ui/src/browser/chat-input-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import { IMouseEvent } from '@theia/monaco-editor-core';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
import { CHAT_VIEW_LANGUAGE_EXTENSION } from './chat-view-language-contribution';
import { AIVariableResolutionRequest, AIVariableService } from '@theia/ai-core';
import { ContextVariablePicker } from './context-variable-picker';

type Query = (query: string) => Promise<void>;
type Query = (query: string, context?: AIVariableResolutionRequest[]) => Promise<void>;
type Unpin = () => void;
type Cancel = (requestModel: ChatRequestModel) => void;
type DeleteChangeSet = (requestModel: ChatRequestModel) => void;
Expand Down Expand Up @@ -53,14 +55,22 @@ export class AIChatInputWidget extends ReactWidget {
@inject(AIChatInputConfiguration) @optional()
protected readonly configuration: AIChatInputConfiguration | undefined;

@inject(AIVariableService)
protected readonly variableService: AIVariableService;

@inject(LabelProvider)
protected readonly labelProvider: LabelProvider;

@inject(ContextVariablePicker)
protected readonly contextVariablePicker: ContextVariablePicker;

protected editorRef: MonacoEditor | undefined = undefined;
private editorReady = new Deferred<void>();

protected isEnabled = false;

protected context: AIVariableResolutionRequest[] = [];

private _onQuery: Query;
set onQuery(query: Query) {
this._onQuery = query;
Expand All @@ -81,6 +91,7 @@ export class AIChatInputWidget extends ReactWidget {
set onDeleteChangeSetElement(deleteChangeSetElement: DeleteChangeSetElement) {
this._onDeleteChangeSetElement = deleteChangeSetElement;
}

private _chatModel: ChatModel;
set chatModel(chatModel: ChatModel) {
this._chatModel = chatModel;
Expand Down Expand Up @@ -114,8 +125,13 @@ export class AIChatInputWidget extends ReactWidget {
onQuery={this._onQuery.bind(this)}
onUnpin={this._onUnpin.bind(this)}
onCancel={this._onCancel.bind(this)}
onDragOver={this.onDragOver.bind(this)}
onDrop={this.onDrop.bind(this)}
onDeleteChangeSet={this._onDeleteChangeSet.bind(this)}
onDeleteChangeSetElement={this._onDeleteChangeSetElement.bind(this)}
onAddContextElement={this.addContextElement.bind(this)}
onDeleteContextElement={this.deleteContextElement.bind(this)}
context={this.context}
chatModel={this._chatModel}
pinnedAgent={this._pinnedAgent}
editorProvider={this.editorProvider}
Expand All @@ -133,11 +149,59 @@ export class AIChatInputWidget extends ReactWidget {
);
}

protected onDragOver(event: React.DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.node.classList.add('drag-over');
if (event.dataTransfer?.types.includes('text/plain')) {
event.dataTransfer!.dropEffect = 'copy';
} else {
event.dataTransfer!.dropEffect = 'link';
}
}

protected onDrop(event: React.DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.node.classList.remove('drag-over');
const dataTransferText = event.dataTransfer?.getData('text/plain');
const position = this.editorRef?.getControl().getTargetAtClientPoint(event.clientX, event.clientY)?.position;
this.variableService.getDropResult(event.nativeEvent, { type: 'ai-chat-input-widget' }).then(result => {
result.variables.forEach(variable => this.addContext(variable));
const text = result.text ?? dataTransferText;
if (position && text) {
this.editorRef?.getControl().executeEdits('drag-and-drop', [{
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column
},
text
}]);
}
});
}

public setEnabled(enabled: boolean): void {
this.isEnabled = enabled;
this.update();
}

protected addContextElement(): void {
this.contextVariablePicker.pickContextVariable().then(contextElement => {
if (contextElement) {
this.context.push(contextElement);
this.update();
}
});
}

protected deleteContextElement(index: number): void {
this.context.splice(index, 1);
this.update();
}

protected handleContextMenu(event: IMouseEvent): void {
this.contextMenuRenderer.render({
menuPath: AIChatInputWidget.CONTEXT_MENU,
Expand All @@ -146,14 +210,23 @@ export class AIChatInputWidget extends ReactWidget {
event.preventDefault();
}

addContext(variable: AIVariableResolutionRequest): void {
this.context.push(variable);
this.update();
}
}

interface ChatInputProperties {
onCancel: (requestModel: ChatRequestModel) => void;
onQuery: (query: string) => void;
onQuery: (query: string, context?: AIVariableResolutionRequest[]) => void;
onUnpin: () => void;
onDragOver: (event: React.DragEvent) => void;
onDrop: (event: React.DragEvent) => void;
onDeleteChangeSet: (sessionId: string) => void;
onDeleteChangeSetElement: (sessionId: string, index: number) => void;
onAddContextElement: () => void;
onDeleteContextElement: (index: number) => void;
context?: AIVariableResolutionRequest[];
isEnabled?: boolean;
chatModel: ChatModel;
pinnedAgent?: ChatAgent;
Expand Down Expand Up @@ -251,6 +324,7 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
props.setEditorRef(editor);
};
createInputElement();

return () => {
props.setEditorRef(undefined);
if (editorRef.current) {
Expand Down Expand Up @@ -294,7 +368,7 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
return;
}
setInProgress(true);
props.onQuery(value);
props.onQuery(value, props.context);
if (editorRef.current) {
editorRef.current.document.textEditorModel.setValue('');
}
Expand Down Expand Up @@ -356,7 +430,7 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
...(props.showContext
? [{
title: nls.localize('theia/ai/chat-ui/attachToContext', 'Attach elements to context'),
handler: () => { /* TODO */ },
handler: () => props.onAddContextElement(),
className: 'codicon-add'
}]
: []),
Expand Down Expand Up @@ -396,14 +470,19 @@ const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInpu
disabled: isInputEmpty || !props.isEnabled
}];

return <div className='theia-ChatInput'>
const contextUI = buildContextUI(props.context, props.labelProvider, props.onDeleteContextElement);

return <div className='theia-ChatInput' onDragOver={props.onDragOver} onDrop={props.onDrop} >
{changeSetUI?.elements &&
<ChangeSetBox changeSet={changeSetUI} />
}
<div className='theia-ChatInput-Editor-Box'>
<div className='theia-ChatInput-Editor' ref={editorContainerRef} onKeyDown={onKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur}>
<div ref={placeholderRef} className='theia-ChatInput-Editor-Placeholder'>{nls.localizeByDefault('Ask a question')}</div>
</div>
{props.context && props.context.length > 0 &&
<ChatContext context={contextUI.context} />
}
<ChatInputOptions leftOptions={leftOptions} rightOptions={rightOptions} />
</div>
</div>;
Expand Down Expand Up @@ -572,3 +651,50 @@ function getLatestRequest(chatModel: ChatModel): ChatRequestModel | undefined {
const requests = chatModel.getRequests();
return requests.length > 0 ? requests[requests.length - 1] : undefined;
}

function buildContextUI(context: AIVariableResolutionRequest[] | undefined, labelProvider: LabelProvider, onDeleteContextElement: (index: number) => void): ChatContextUI {
if (!context) {
return { context: [] };
}
return {
context: context.map((element, index) => ({
name: labelProvider.getName(element),
iconClass: labelProvider.getIcon(element),
nameClass: element.variable.name,
additionalInfo: labelProvider.getDetails(element),
details: labelProvider.getLongName(element),
delete: () => onDeleteContextElement(index),
}))
};
}

interface ChatContextUI {
context: {
name: string;
iconClass: string;
nameClass: string;
additionalInfo?: string;
details?: string;
delete: () => void;
open?: () => void;
}[];
}

const ChatContext: React.FunctionComponent<ChatContextUI> = ({ context }) => (
<div className="theia-ChatInput-ChatContext">
<ul>
{context.map((element, index) => (
<li key={index} className="theia-ChatInput-ChatContext-Element" title={element.details} onClick={() => element.open?.()}>
<div className={`theia-ChatInput-ChatContext-Icon ${element.iconClass}`} />
<span className={`theia-ChatInput-ChatContext-title ${element.nameClass}`}>
{element.name}
</span>
<span className='theia-ChatInput-ChatContext-additionalInfo'>
{element.additionalInfo}
</span>
<span className="codicon codicon-close action" title={nls.localizeByDefault('Delete')} onClick={() => element.delete()} />
</li>
))}
</ul>
</div>
);
Loading

0 comments on commit 8cdde21

Please sign in to comment.