Add decorations for variables in rendered chat requests (#193745)

* Use parsed chat request in model and renderer

* Fix using variables with the chat parser

* Test snapshot

* Add test

* Update test snapshots
This commit is contained in:
Rob Lourens
2023-09-21 23:19:41 -07:00
committed by GitHub
parent 444399e23b
commit b1ecf6f7bc
28 changed files with 700 additions and 511 deletions

View File

@@ -62,8 +62,8 @@ import { IAccessibleViewService } from 'vs/workbench/contrib/accessibility/brows
import { IChatCodeBlockActionContext } from 'vs/workbench/contrib/chat/browser/actions/chatCodeblockActions';
import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo } from 'vs/workbench/contrib/chat/browser/chat';
import { ChatFollowups } from 'vs/workbench/contrib/chat/browser/chatFollowups';
import { convertParsedRequestToMarkdown, walkTreeAndAnnotateResourceLinks } from 'vs/workbench/contrib/chat/browser/chatMarkdownDecorationsRenderer';
import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions';
import { fixVariableReferences, walkTreeAndAnnotateResourceLinks } from 'vs/workbench/contrib/chat/browser/chatVariableReferenceRenderer';
import { CONTEXT_REQUEST, CONTEXT_RESPONSE, CONTEXT_RESPONSE_FILTERED, CONTEXT_RESPONSE_HAS_PROVIDER_ID, CONTEXT_RESPONSE_VOTE } from 'vs/workbench/contrib/chat/common/chatContextKeys';
import { IPlaceholderMarkdownString } from 'vs/workbench/contrib/chat/common/chatModel';
import { IChatReplyFollowup, IChatResponseProgressFileTreeData, IChatService, ISlashCommand, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
@@ -314,7 +314,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
} else if (isResponseVM(element)) {
this.basicRenderElement(element.response.value, element, index, templateData);
} else if (isRequestVM(element)) {
this.basicRenderElement([new MarkdownString(element.messageText)], element, index, templateData);
const markdown = 'kind' in element.message ?
element.message.message :
convertParsedRequestToMarkdown(element.message);
this.basicRenderElement([new MarkdownString(markdown)], element, index, templateData);
} else {
this.renderWelcomeMessage(element, templateData);
}
@@ -608,11 +611,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
});
const codeblocks: IChatCodeBlockInfo[] = [];
if (isRequestVM(element)) {
markdown = fixVariableReferences(markdown);
}
const result = this.renderer.render(markdown, {
fillInIncompleteTokens,
codeBlockRendererSync: (languageId, text) => {

View File

@@ -4,27 +4,33 @@
*--------------------------------------------------------------------------------------------*/
import * as dom from 'vs/base/browser/dom';
import { IMarkdownString, MarkdownString } from 'vs/base/common/htmlContent';
import { IParsedChatRequest, ChatRequestTextPart } from 'vs/workbench/contrib/chat/common/chatParserTypes';
const variableRefUrlPrefix = 'http://vscodeVar_';
const variableRefUrl = 'http://_vscodeDecoration_';
export function fixVariableReferences(markdown: IMarkdownString): IMarkdownString {
const fixedMarkdownSource = markdown.value.replace(/\]\(values:(.*)/g, `](${variableRefUrlPrefix}_$1`);
return new MarkdownString(fixedMarkdownSource, { isTrusted: markdown.isTrusted, supportThemeIcons: markdown.supportThemeIcons, supportHtml: markdown.supportHtml });
export function convertParsedRequestToMarkdown(parsedRequest: IParsedChatRequest): string {
let result = '';
for (const part of parsedRequest.parts) {
if (part instanceof ChatRequestTextPart) {
result += part.text;
} else {
result += `[${part.text}](${variableRefUrl})`;
}
}
return result;
}
export function walkTreeAndAnnotateResourceLinks(element: HTMLElement): void {
element.querySelectorAll('a').forEach(a => {
const href = a.getAttribute('data-href');
if (href) {
if (href.startsWith(variableRefUrlPrefix)) {
if (href.startsWith(variableRefUrl)) {
a.parentElement!.replaceChild(
renderResourceWidget(a.textContent!),
a);
}
}
walkTreeAndAnnotateResourceLinks(a as HTMLElement);
});
}

View File

@@ -19,6 +19,7 @@ import { IChatWidgetService, IQuickChatService } from 'vs/workbench/contrib/chat
import { IChatViewOptions } from 'vs/workbench/contrib/chat/browser/chatViewPane';
import { ChatWidget } from 'vs/workbench/contrib/chat/browser/chatWidget';
import { ChatModel } from 'vs/workbench/contrib/chat/common/chatModel';
import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChatService } from 'vs/workbench/contrib/chat/common/chatService';
export class QuickChatService extends Disposable implements IQuickChatService {
@@ -271,7 +272,7 @@ class QuickChat extends Disposable {
for (const request of this.model.getRequests()) {
if (request.response?.response.value || request.response?.errorDetails) {
this.chatService.addCompleteRequest(widget.viewModel.sessionId,
request.message as string,
request.message as IParsedChatRequest,
{
message: request.response.response.value,
errorDetails: request.response.errorDetails,

View File

@@ -26,7 +26,7 @@ import { ChatInputPart } from 'vs/workbench/contrib/chat/browser/chatInputPart';
import { ChatWidget } from 'vs/workbench/contrib/chat/browser/chatWidget';
import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { chatSlashCommandBackground, chatSlashCommandForeground } from 'vs/workbench/contrib/chat/common/chatColors';
import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestVariablePart } from '../../common/chatRequestParser';
import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestVariablePart } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { IChatService, ISlashCommand } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
@@ -127,7 +127,7 @@ class InputEditorDecorations extends Disposable {
return;
}
const parsedRequest = await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(viewModel.sessionId, inputValue);
const parsedRequest = (await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(viewModel.sessionId, inputValue)).parts;
let placeholderDecoration: IDecorationOptions[] | undefined;
const agentPart = parsedRequest.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart);
@@ -252,7 +252,7 @@ class SlashCommandCompletions extends Disposable {
return null;
}
const parsedRequest = await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionId, model.getValue());
const parsedRequest = (await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionId, model.getValue())).parts;
const usedAgent = parsedRequest.find(p => p instanceof ChatRequestAgentPart);
if (usedAgent) {
// No (classic) global slash commands when an agent is used
@@ -303,7 +303,7 @@ class AgentCompletions extends Disposable {
return null;
}
const parsedRequest = await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionId, model.getValue());
const parsedRequest = (await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionId, model.getValue())).parts;
const usedAgent = parsedRequest.find(p => p instanceof ChatRequestAgentPart);
if (usedAgent && !Range.containsPosition(usedAgent.editorRange, position)) {
// Only one agent allowed
@@ -340,7 +340,7 @@ class AgentCompletions extends Disposable {
return;
}
const parsedRequest = await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionId, model.getValue());
const parsedRequest = (await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionId, model.getValue())).parts;
const usedAgent = parsedRequest.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart);
if (!usedAgent) {
return;

View File

@@ -10,7 +10,8 @@ import { Disposable } from 'vs/base/common/lifecycle';
import { URI, UriComponents } from 'vs/base/common/uri';
import { generateUuid } from 'vs/base/common/uuid';
import { ILogService } from 'vs/platform/log/common/log';
import { IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { IChatAgentData } from 'vs/workbench/contrib/chat/common/chatAgents';
import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChat, IChatFollowup, IChatProgress, IChatReplyFollowup, IChatResponse, IChatResponseErrorDetails, IChatResponseProgressFileTreeData, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
export interface IChatRequestModel {
@@ -19,7 +20,7 @@ export interface IChatRequestModel {
readonly username: string;
readonly avatarIconUri?: URI;
readonly session: IChatModel;
readonly message: string | IChatReplyFollowup;
readonly message: IParsedChatRequest | IChatReplyFollowup;
readonly response: IChatResponseModel | undefined;
}
@@ -79,7 +80,7 @@ export class ChatRequestModel implements IChatRequestModel {
constructor(
public readonly session: ChatModel,
public readonly message: string | IChatReplyFollowup,
public readonly message: IParsedChatRequest | IChatReplyFollowup,
private _providerRequestId?: string) {
this._id = 'request_' + ChatRequestModel.nextId++;
}
@@ -457,8 +458,9 @@ export class ChatModel extends Disposable implements IChatModel {
}
get title(): string {
const firstRequestMessage = this._requests[0]?.message;
const message = typeof firstRequestMessage === 'string' ? firstRequestMessage : firstRequestMessage?.message ?? '';
// const firstRequestMessage = this._requests[0]?.message;
// const message = typeof firstRequestMessage === 'string' ? firstRequestMessage : firstRequestMessage?.message ?? '';
const message = '';
return message.split('\n')[0].substring(0, 50);
}
@@ -466,7 +468,6 @@ export class ChatModel extends Disposable implements IChatModel {
public readonly providerId: string,
private readonly initialData: ISerializableChatData | IExportableChatData | undefined,
@ILogService private readonly logService: ILogService,
@IChatAgentService private readonly chatAgentService: IChatAgentService,
) {
super();
@@ -492,14 +493,15 @@ export class ChatModel extends Disposable implements IChatModel {
this._welcomeMessage = new ChatWelcomeMessageModel(this, content);
}
return requests.map((raw: ISerializableChatRequestData) => {
const request = new ChatRequestModel(this, raw.message, raw.providerRequestId);
if (raw.response || raw.responseErrorDetails) {
const agent = raw.agent && this.chatAgentService.getAgents().find(a => a.id === raw.agent!.id); // TODO do something reasonable if this agent has disappeared since the last session
request.response = new ChatResponseModel(raw.response ?? [new MarkdownString(raw.response)], this, agent, true, raw.isCanceled, raw.vote, raw.providerRequestId, raw.responseErrorDetails, raw.followups);
}
return request;
});
return [];
// return requests.map((raw: ISerializableChatRequestData) => {
// const request = new ChatRequestModel(this, raw.message, raw.providerRequestId);
// if (raw.response || raw.responseErrorDetails) {
// const agent = raw.agent && this.chatAgentService.getAgents().find(a => a.id === raw.agent!.id); // TODO do something reasonable if this agent has disappeared since the last session
// request.response = new ChatResponseModel(raw.response ?? [new MarkdownString(raw.response)], this, agent, true, raw.isCanceled, raw.vote, raw.providerRequestId, raw.responseErrorDetails, raw.followups);
// }
// return request;
// });
}
startReinitialize(): void {
@@ -543,7 +545,7 @@ export class ChatModel extends Disposable implements IChatModel {
return this._requests;
}
addRequest(message: string | IChatReplyFollowup, chatAgent?: IChatAgentData): ChatRequestModel {
addRequest(message: IParsedChatRequest | IChatReplyFollowup, chatAgent?: IChatAgentData): ChatRequestModel {
if (!this._session) {
throw new Error('addRequest: No session');
}
@@ -649,7 +651,7 @@ export class ChatModel extends Disposable implements IChatModel {
requests: this._requests.map((r): ISerializableChatRequestData => {
return {
providerRequestId: r.providerRequestId,
message: typeof r.message === 'string' ? r.message : r.message.message,
message: typeof r.message === 'string' ? r.message : '',
response: r.response ? r.response.response.value : undefined,
responseErrorDetails: r.response?.errorDetails,
followups: r.response?.followups,

View File

@@ -0,0 +1,73 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { OffsetRange } from 'vs/editor/common/core/offsetRange';
import { IRange } from 'vs/editor/common/core/range';
import { IChatAgentData, IChatAgentCommand } from 'vs/workbench/contrib/chat/common/chatAgents';
import { ISlashCommand } from 'vs/workbench/contrib/chat/common/chatService';
// These are in a separate file to avoid circular dependencies with the dependencies of the parser
export interface IParsedChatRequest {
readonly parts: ReadonlyArray<IParsedChatRequestPart>;
readonly text: string;
}
export interface IParsedChatRequestPart {
readonly range: OffsetRange;
readonly editorRange: IRange;
readonly text: string;
}
// TODO rename to tokens
export class ChatRequestTextPart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly text: string) { }
}
/**
* An invocation of a static variable that can be resolved by the variable service
*/
export class ChatRequestVariablePart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly variableName: string, readonly variableArg: string) { }
get text(): string {
const argPart = this.variableArg ? `:${this.variableArg}` : '';
return `@${this.variableName}${argPart}`;
}
}
/**
* An invocation of an agent that can be resolved by the agent service
*/
export class ChatRequestAgentPart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly agent: IChatAgentData) { }
get text(): string {
return `@${this.agent.id}`;
}
}
/**
* An invocation of an agent's subcommand
*/
export class ChatRequestAgentSubcommandPart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly command: IChatAgentCommand) { }
get text(): string {
return `/${this.command.name}`;
}
}
/**
* An invocation of a standalone slash command
*/
export class ChatRequestSlashCommandPart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly slashCommand: ISlashCommand) { }
get text(): string {
return `/${this.slashCommand.command}`;
}
}

View File

@@ -6,13 +6,14 @@
import { CancellationToken } from 'vs/base/common/cancellation';
import { OffsetRange } from 'vs/editor/common/core/offsetRange';
import { IPosition, Position } from 'vs/editor/common/core/position';
import { IRange, Range } from 'vs/editor/common/core/range';
import { IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { IChatService, ISlashCommand } from 'vs/workbench/contrib/chat/common/chatService';
import { Range } from 'vs/editor/common/core/range';
import { IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, ChatRequestVariablePart, IParsedChatRequest, IParsedChatRequestPart } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChatService } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
const variableOrAgentReg = /^@([\w_\-]+)(:\d+)?(?=(\s|$))/i; // An @-variable with an optional numeric : arg (@response:2)
const slashReg = /\/([\w_-]+)(?=(\s|$))/i; // A / command
const variableOrAgentReg = /^@([\w_\-]+)(:\d+)?(?=(\s|$|\b))/i; // An @-variable with an optional numeric : arg (@response:2)
const slashReg = /\/([\w_-]+)(?=(\s|$|\b))/i; // A / command
export class ChatRequestParser {
constructor(
@@ -21,7 +22,7 @@ export class ChatRequestParser {
@IChatService private readonly chatService: IChatService,
) { }
async parseChatRequest(sessionId: string, message: string): Promise<IParsedChatRequestPart[]> {
async parseChatRequest(sessionId: string, message: string): Promise<IParsedChatRequest> {
const parts: IParsedChatRequestPart[] = [];
let lineNumber = 1;
@@ -68,7 +69,10 @@ export class ChatRequestParser {
new Range(lastPart?.editorRange.endLineNumber ?? 1, lastPart?.editorRange.endColumn ?? 1, lineNumber, column),
message.slice(lastPartEnd, message.length)));
return parts;
return {
parts,
text: message,
};
}
private tryToParseVariableOrAgent(message: string, offset: number, position: IPosition, parts: ReadonlyArray<IParsedChatRequestPart>): ChatRequestAgentPart | ChatRequestVariablePart | undefined {
@@ -131,40 +135,3 @@ export class ChatRequestParser {
return;
}
}
export interface IParsedChatRequestPart {
readonly range: OffsetRange;
readonly editorRange: IRange;
}
export class ChatRequestTextPart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly text: string) { }
}
/**
* An invocation of a static variable that can be resolved by the variable service
*/
export class ChatRequestVariablePart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly variableName: string, readonly variableArg: string) { }
}
/**
* An invocation of an agent that can be resolved by the agent service
*/
export class ChatRequestAgentPart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly agent: IChatAgentData) { }
}
/**
* An invocation of an agent's subcommand
*/
export class ChatRequestAgentSubcommandPart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly command: IChatAgentCommand) { }
}
/**
* An invocation of a standalone slash command
*/
export class ChatRequestSlashCommandPart implements IParsedChatRequestPart {
constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly slashCommand: ISlashCommand) { }
}

View File

@@ -11,6 +11,7 @@ import { URI } from 'vs/base/common/uri';
import { ProviderResult } from 'vs/editor/common/languages';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IChatModel, ChatModel, ISerializableChatData } from 'vs/workbench/contrib/chat/common/chatModel';
import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChatRequestVariableValue } from 'vs/workbench/contrib/chat/common/chatVariables';
export interface IChat {
@@ -227,7 +228,7 @@ export interface IChatService {
getSlashCommands(sessionId: string, token: CancellationToken): Promise<ISlashCommand[]>;
clearSession(sessionId: string): void;
addRequest(context: any): void;
addCompleteRequest(sessionId: string, message: string, response: IChatCompleteResponse): void;
addCompleteRequest(sessionId: string, message: IParsedChatRequest | string, response: IChatCompleteResponse): void;
sendRequestToProvider(sessionId: string, message: IChatDynamicRequest): void;
getHistory(): IChatDetail[];
removeHistoryEntry(sessionId: string): void;

View File

@@ -21,10 +21,12 @@ import { Progress } from 'vs/platform/progress/common/progress';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { CONTEXT_PROVIDER_EXISTS } from 'vs/workbench/contrib/chat/common/chatContextKeys';
import { ChatModel, ChatRequestModel, ChatWelcomeMessageModel, IChatModel, ISerializableChatData, ISerializableChatsData, isCompleteInteractiveProgressTreeData } from 'vs/workbench/contrib/chat/common/chatModel';
import { ChatRequestAgentPart, ChatRequestSlashCommandPart, IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { ChatMessageRole, IChatMessage } from 'vs/workbench/contrib/chat/common/chatProvider';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { IChat, IChatCompleteResponse, IChatDetail, IChatDynamicRequest, IChatFollowup, IChatProgress, IChatProvider, IChatProviderInfo, IChatReplyFollowup, IChatRequest, IChatResponse, IChatService, IChatTransferredSessionData, IChatUserActionEvent, ISlashCommand, InteractiveSessionCopyKind, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatSlashCommandService, IChatSlashFragment } from 'vs/workbench/contrib/chat/common/chatSlashCommands';
import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
@@ -431,19 +433,21 @@ export class ChatService extends Disposable implements IChatService {
}
// This method is only returning whether the request was accepted - don't block on the actual request
return { responseCompletePromise: this._sendRequestAsync(model, provider, request, usedSlashCommand) };
return { responseCompletePromise: this._sendRequestAsync(model, sessionId, provider, request, usedSlashCommand) };
}
private async _sendRequestAsync(model: ChatModel, provider: IChatProvider, message: string | IChatReplyFollowup, usedSlashCommand?: ISlashCommand): Promise<void> {
const resolvedAgent = typeof message === 'string' ? this.resolveAgent(message) : undefined;
private async _sendRequestAsync(model: ChatModel, sessionId: string, provider: IChatProvider, message: string | IChatReplyFollowup, usedSlashCommand?: ISlashCommand): Promise<void> {
const parsedRequest = typeof message === 'string' ?
await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, message) :
message; // Handle the followup type along with the response
let request: ChatRequestModel;
const resolvedCommand = typeof message === 'string' && message.startsWith('/') ? await this.handleSlashCommand(model.sessionId, message) : message;
const agentPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart);
const commandPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestSlashCommandPart => r instanceof ChatRequestSlashCommandPart);
let gotProgress = false;
const requestType = typeof message === 'string' ?
(message.startsWith('/') ? 'slashCommand' : 'string') :
commandPart ? 'slashCommand' : 'string' :
'followup';
const rawResponsePromise = createCancelablePromise<void>(async token => {
@@ -491,8 +495,8 @@ export class ChatService extends Disposable implements IChatService {
let rawResponse: IChatResponse | null | undefined;
let slashCommandFollowups: IChatFollowup[] | void = [];
if (typeof message === 'string' && resolvedAgent) {
request = model.addRequest(message);
if (typeof message === 'string' && agentPart) {
request = model.addRequest(parsedRequest);
const history: IChatMessage[] = [];
for (const request of model.getRequests()) {
if (typeof request.message !== 'string' || !request.response) {
@@ -503,15 +507,15 @@ export class ChatService extends Disposable implements IChatService {
history.push({ role: ChatMessageRole.Assistant, content: request.response.response.value.value });
}
}
const agentResult = await this.chatAgentService.invokeAgent(resolvedAgent.id, message.substring(resolvedAgent.id.length + 1).trimStart(), new Progress<IChatSlashFragment>(p => {
const agentResult = await this.chatAgentService.invokeAgent(agentPart.agent.id, message.substring(agentPart.agent.id.length + 1).trimStart(), new Progress<IChatSlashFragment>(p => {
const { content } = p;
const data = isCompleteInteractiveProgressTreeData(content) ? content : { content };
progressCallback(data);
}), history, token);
slashCommandFollowups = agentResult?.followUp;
rawResponse = { session: model.session! };
} else if ((typeof resolvedCommand === 'string' && typeof message === 'string' && this.chatSlashCommandService.hasCommand(resolvedCommand))) {
request = model.addRequest(message);
} else if (commandPart && typeof message === 'string' && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) {
request = model.addRequest(parsedRequest);
// contributed slash commands
// TODO: spell this out in the UI
const history: IChatMessage[] = [];
@@ -524,7 +528,7 @@ export class ChatService extends Disposable implements IChatService {
history.push({ role: ChatMessageRole.Assistant, content: request.response.response.value.value });
}
}
const commandResult = await this.chatSlashCommandService.executeCommand(resolvedCommand, message.substring(resolvedCommand.length + 1).trimStart(), new Progress<IChatSlashFragment>(p => {
const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress<IChatSlashFragment>(p => {
const { content } = p;
const data = isCompleteInteractiveProgressTreeData(content) ? content : { content };
progressCallback(data);
@@ -533,19 +537,18 @@ export class ChatService extends Disposable implements IChatService {
rawResponse = { session: model.session! };
} else {
request = model.addRequest(parsedRequest);
const requestProps: IChatRequest = {
session: model.session!,
message: resolvedCommand,
message,
variables: {}
};
if (typeof requestProps.message === 'string') {
const varResult = await this.chatVariablesService.resolveVariables(requestProps.message, model, token);
if ('parts' in parsedRequest) {
const varResult = await this.chatVariablesService.resolveVariables(parsedRequest, model, token);
requestProps.variables = varResult.variables;
requestProps.message = varResult.prompt;
}
request = model.addRequest(requestProps.message);
rawResponse = await provider.provideReply(requestProps, progressCallback, token);
}
@@ -613,26 +616,6 @@ export class ChatService extends Disposable implements IChatService {
provider.removeRequest?.(model.session!, requestId);
}
private async handleSlashCommand(sessionId: string, command: string): Promise<string> {
const slashCommands = await this.getSlashCommands(sessionId, CancellationToken.None);
for (const slashCommand of slashCommands ?? []) {
if (command.startsWith(`/${slashCommand.command}`) && this.chatSlashCommandService.hasCommand(slashCommand.command)) {
return slashCommand.command;
}
}
return command;
}
private resolveAgent(prompt: string): IChatAgentData | undefined {
prompt = prompt.trim();
const agents = this.chatAgentService.getAgents();
if (!prompt.startsWith('@')) {
return;
}
return agents.find(a => prompt.match(new RegExp(`@${a.id}($|\\s)`)));
}
async getSlashCommands(sessionId: string, token: CancellationToken): Promise<ISlashCommand[]> {
const model = this._sessionModels.get(sessionId);
if (!model) {
@@ -707,7 +690,7 @@ export class ChatService extends Disposable implements IChatService {
return Array.from(this._providers.keys());
}
async addCompleteRequest(sessionId: string, message: string, response: IChatCompleteResponse): Promise<void> {
async addCompleteRequest(sessionId: string, message: string | IParsedChatRequest, response: IChatCompleteResponse): Promise<void> {
this.trace('addCompleteRequest', `message: ${message}`);
const model = this._sessionModels.get(sessionId);
@@ -716,7 +699,8 @@ export class ChatService extends Disposable implements IChatService {
}
await model.waitForInitialization();
const request = model.addRequest(message, undefined);
const parsedRequest = typeof message === 'string' ? await this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(sessionId, message) : message;
const request = model.addRequest(parsedRequest);
if (typeof response.message === 'string') {
model.acceptResponseProgress(request, { content: response.message });
} else {

View File

@@ -9,6 +9,7 @@ import { Iterable } from 'vs/base/common/iterator';
import { IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { IChatModel } from 'vs/workbench/contrib/chat/common/chatModel';
import { ChatRequestVariablePart, IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
export interface IChatVariableData {
name: string;
@@ -39,7 +40,7 @@ export interface IChatVariablesService {
/**
* Resolves all variables that occur in `prompt`
*/
resolveVariables(prompt: string, model: IChatModel, token: CancellationToken): Promise<IChatVariableResolveResult>;
resolveVariables(prompt: IParsedChatRequest, model: IChatModel, token: CancellationToken): Promise<IChatVariableResolveResult>;
}
interface IChatData {
@@ -60,40 +61,29 @@ export class ChatVariablesService implements IChatVariablesService {
constructor() {
}
async resolveVariables(prompt: string, model: IChatModel, token: CancellationToken): Promise<IChatVariableResolveResult> {
async resolveVariables(prompt: IParsedChatRequest, model: IChatModel, token: CancellationToken): Promise<IChatVariableResolveResult> {
const resolvedVariables: Record<string, IChatRequestVariableValue[]> = {};
const jobs: Promise<any>[] = [];
// TODO have a separate parser that is also used for decorations
const regex = /(^|\s)@(\w+)(:\w+)?(?=\s|$|\b)/ig;
let lastMatch = 0;
const parsedPrompt: string[] = [];
let match: RegExpMatchArray | null;
while (match = regex.exec(prompt)) {
const [fullMatch, leading, varName, arg] = match;
const data = this._resolver.get(varName.toLowerCase());
prompt.parts
.forEach((varPart, i) => {
if (varPart instanceof ChatRequestVariablePart) {
const data = this._resolver.get(varPart.variableName.toLowerCase());
if (data) {
if (!arg || data.data.canTakeArgument) {
parsedPrompt.push(prompt.substring(lastMatch, match.index!));
parsedPrompt.push('');
lastMatch = match.index! + fullMatch.length;
const varIndex = parsedPrompt.length - 1;
const argWithoutColon = arg?.slice(1);
const fullVarName = varName + (arg ?? '');
jobs.push(data.resolver(prompt, argWithoutColon, model, token).then(value => {
jobs.push(data.resolver(prompt.text, varPart.variableArg, model, token).then(value => {
if (value) {
resolvedVariables[fullVarName] = value;
parsedPrompt[varIndex] = `${leading}[@${fullVarName}](values:${fullVarName})`;
resolvedVariables[varPart.variableName] = value;
parsedPrompt[i] = `[@${varPart.variableName}](values:${varPart.variableName})`;
} else {
parsedPrompt[varIndex] = fullMatch;
parsedPrompt[i] = varPart.text;
}
}).catch(onUnexpectedExternalError));
}
} else {
parsedPrompt[i] = varPart.text;
}
}
parsedPrompt.push(prompt.substring(lastMatch));
});
await Promise.allSettled(jobs);

View File

@@ -11,6 +11,7 @@ import { localize } from 'vs/nls';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { ILogService } from 'vs/platform/log/common/log';
import { IChatModel, IChatRequestModel, IChatResponseModel, IChatWelcomeMessageContent, IResponse, Response } from 'vs/workbench/contrib/chat/common/chatModel';
import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChatReplyFollowup, IChatResponseCommandFollowup, IChatResponseErrorDetails, IChatResponseProgressFileTreeData, InteractiveSessionVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { countWords } from 'vs/workbench/contrib/chat/common/chatWordCounter';
@@ -51,7 +52,7 @@ export interface IChatRequestViewModel {
readonly dataId: string;
readonly username: string;
readonly avatarIconUri?: URI;
readonly message: string | IChatReplyFollowup;
readonly message: IParsedChatRequest | IChatReplyFollowup;
readonly messageText: string;
currentRenderedHeight: number | undefined;
}
@@ -215,7 +216,7 @@ export class ChatRequestViewModel implements IChatRequestViewModel {
}
get messageText() {
return typeof this.message === 'string' ? this.message : this.message.message;
return 'kind' in this.message ? this.message.message : this.message.text;
}
currentRenderedHeight: number | undefined;

View File

@@ -1,60 +0,0 @@
[
{
range: {
start: 0,
endExclusive: 6
},
editorRange: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 7
},
agent: {
id: "agent",
metadata: {
description: "",
subCommands: [ { name: "subCommand" } ]
}
}
},
{
range: {
start: 6,
endExclusive: 18
},
editorRange: {
startLineNumber: 1,
startColumn: 7,
endLineNumber: 2,
endColumn: 4
},
text: " Please \ndo "
},
{
range: {
start: 18,
endExclusive: 29
},
editorRange: {
startLineNumber: 2,
startColumn: 4,
endLineNumber: 2,
endColumn: 15
},
command: { name: "subCommand" }
},
{
range: {
start: 29,
endExclusive: 63
},
editorRange: {
startLineNumber: 2,
startColumn: 15,
endLineNumber: 3,
endColumn: 18
},
text: " with @selection\nand @debugConsole"
}
]

View File

@@ -1,15 +0,0 @@
[
{
range: {
start: 0,
endExclusive: 21
},
editorRange: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 3,
endColumn: 7
},
text: "line 1\nline 2\r\nline 3"
}
]

View File

@@ -1,4 +1,5 @@
[
{
parts: [
{
range: {
start: 0,
@@ -70,4 +71,6 @@
},
text: " thanks"
}
]
],
text: "Hello Mr. @agent /subCommand thanks"
}

View File

@@ -0,0 +1,50 @@
{
parts: [
{
range: {
start: 0,
endExclusive: 14
},
editorRange: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 15
},
text: "Are you there "
},
{
range: {
start: 14,
endExclusive: 20
},
editorRange: {
startLineNumber: 1,
startColumn: 15,
endLineNumber: 1,
endColumn: 21
},
agent: {
id: "agent",
metadata: {
description: "",
subCommands: [ { name: "subCommand" } ]
}
}
},
{
range: {
start: 20,
endExclusive: 21
},
editorRange: {
startLineNumber: 1,
startColumn: 21,
endLineNumber: 1,
endColumn: 22
},
text: "?"
}
],
text: "Are you there @agent?"
}

View File

@@ -1,4 +1,5 @@
[
{
parts: [
{
range: {
start: 0,
@@ -57,4 +58,6 @@
},
text: " thanks"
}
]
],
text: "@agent Please do /subCommand thanks"
}

View File

@@ -0,0 +1,63 @@
{
parts: [
{
range: {
start: 0,
endExclusive: 6
},
editorRange: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 7
},
agent: {
id: "agent",
metadata: {
description: "",
subCommands: [ { name: "subCommand" } ]
}
}
},
{
range: {
start: 6,
endExclusive: 18
},
editorRange: {
startLineNumber: 1,
startColumn: 7,
endLineNumber: 2,
endColumn: 4
},
text: " Please \ndo "
},
{
range: {
start: 18,
endExclusive: 29
},
editorRange: {
startLineNumber: 2,
startColumn: 4,
endLineNumber: 2,
endColumn: 15
},
command: { name: "subCommand" }
},
{
range: {
start: 29,
endExclusive: 63
},
editorRange: {
startLineNumber: 2,
startColumn: 15,
endLineNumber: 3,
endColumn: 18
},
text: " with @selection\nand @debugConsole"
}
],
text: "@agent Please \ndo /subCommand with @selection\nand @debugConsole"
}

View File

@@ -1,4 +1,5 @@
[
{
parts: [
{
range: {
start: 0,
@@ -12,4 +13,6 @@
},
text: "/explain this"
}
]
],
text: "/explain this"
}

View File

@@ -1,4 +1,5 @@
[
{
parts: [
{
range: {
start: 0,
@@ -12,4 +13,6 @@
},
text: "What does @selection mean?"
}
]
],
text: "What does @selection mean?"
}

View File

@@ -1,4 +1,5 @@
[
{
parts: [
{
range: {
start: 0,
@@ -25,4 +26,6 @@
},
text: " /fix"
}
]
],
text: "/fix /fix"
}

View File

@@ -1,4 +1,5 @@
[
{
parts: [
{
range: {
start: 0,
@@ -12,4 +13,6 @@
},
text: "test"
}
]
],
text: "test"
}

View File

@@ -0,0 +1,18 @@
{
parts: [
{
range: {
start: 0,
endExclusive: 21
},
editorRange: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 3,
endColumn: 7
},
text: "line 1\nline 2\r\nline 3"
}
],
text: "line 1\nline 2\r\nline 3"
}

View File

@@ -1,4 +1,5 @@
[
{
parts: [
{
range: {
start: 0,
@@ -25,4 +26,6 @@
},
text: " this"
}
]
],
text: "/fix this"
}

View File

@@ -0,0 +1,45 @@
{
parts: [
{
range: {
start: 0,
endExclusive: 8
},
editorRange: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 9
},
text: "What is "
},
{
range: {
start: 8,
endExclusive: 18
},
editorRange: {
startLineNumber: 1,
startColumn: 9,
endLineNumber: 1,
endColumn: 19
},
variableName: "selection",
variableArg: ""
},
{
range: {
start: 18,
endExclusive: 19
},
editorRange: {
startLineNumber: 1,
startColumn: 19,
endLineNumber: 1,
endColumn: 20
},
text: "?"
}
],
text: "What is @selection?"
}

View File

@@ -1,4 +1,5 @@
[
{
parts: [
{
range: {
start: 0,
@@ -39,4 +40,6 @@
},
text: " mean?"
}
]
],
text: "What does @selection mean?"
}

View File

@@ -36,7 +36,7 @@ suite('ChatRequestParser', () => {
await assertSnapshot(result);
});
test('_plain text with newlines', async () => {
test('plain text with newlines', async () => {
parser = instantiationService.createInstance(ChatRequestParser);
const text = 'line 1\nline 2\r\nline 3';
const result = await parser.parseChatRequest('1', text);
@@ -87,6 +87,17 @@ suite('ChatRequestParser', () => {
await assertSnapshot(result);
});
test('variable with question mark', async () => {
const variablesService = mockObject<IChatVariablesService>()({});
variablesService.hasVariable.returns(true);
instantiationService.stub(IChatVariablesService, variablesService as any);
parser = instantiationService.createInstance(ChatRequestParser);
const text = 'What is @selection?';
const result = await parser.parseChatRequest('1', text);
await assertSnapshot(result);
});
test('invalid variables', async () => {
const variablesService = mockObject<IChatVariablesService>()({});
variablesService.hasVariable.returns(false);
@@ -108,6 +119,16 @@ suite('ChatRequestParser', () => {
await assertSnapshot(result);
});
test('agent with question mark', async () => {
const agentsService = mockObject<IChatAgentService>()({});
agentsService.getAgent.returns(<IChatAgentData>{ id: 'agent', metadata: { description: '', subCommands: [{ name: 'subCommand' }] } });
instantiationService.stub(IChatAgentService, agentsService as any);
parser = instantiationService.createInstance(ChatRequestParser);
const result = await parser.parseChatRequest('1', 'Are you there @agent?');
await assertSnapshot(result);
});
test('agent not first', async () => {
const agentsService = mockObject<IChatAgentService>()({});
agentsService.getAgent.returns(<IChatAgentData>{ id: 'agent', metadata: { description: '', subCommands: [{ name: 'subCommand' }] } });
@@ -118,7 +139,7 @@ suite('ChatRequestParser', () => {
await assertSnapshot(result);
});
test('_agents and variables and multiline', async () => {
test('agents and variables and multiline', async () => {
const agentsService = mockObject<IChatAgentService>()({});
agentsService.getAgent.returns(<IChatAgentData>{ id: 'agent', metadata: { description: '', subCommands: [{ name: 'subCommand' }] } });
instantiationService.stub(IChatAgentService, agentsService as any);

View File

@@ -94,11 +94,11 @@ suite('Chat', () => {
const session1 = testDisposables.add(testService.startSession('provider1', CancellationToken.None));
await session1.waitForInitialization();
session1!.addRequest('request 1');
session1!.addRequest({ parts: [], text: 'request 1' });
const session2 = testDisposables.add(testService.startSession('provider2', CancellationToken.None));
await session2.waitForInitialization();
session2!.addRequest('request 2');
session2!.addRequest({ parts: [], text: 'request 2' });
assert.strictEqual(provider1.lastInitialState, undefined);
assert.strictEqual(provider2.lastInitialState, undefined);

View File

@@ -6,58 +6,78 @@
import * as assert from 'assert';
import { CancellationToken } from 'vs/base/common/cancellation';
import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
import { ChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock';
import { ILogService, NullLogService } from 'vs/platform/log/common/log';
import { IStorageService } from 'vs/platform/storage/common/storage';
import { ChatAgentService, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { ChatVariablesService, IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { TestExtensionService, TestStorageService } from 'vs/workbench/test/common/workbenchTestServices';
suite('ChatVariables', function () {
let service: ChatVariablesService;
let instantiationService: TestInstantiationService;
const testDisposables = ensureNoDisposablesAreLeakedInTestSuite();
setup(function () {
service = new ChatVariablesService();
instantiationService = testDisposables.add(new TestInstantiationService());
instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService()));
instantiationService.stub(ILogService, new NullLogService());
instantiationService.stub(IExtensionService, new TestExtensionService());
instantiationService.stub(IChatVariablesService, service);
instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService)));
});
ensureNoDisposablesAreLeakedInTestSuite();
test('ChatVariables - resolveVariables', async function () {
const v1 = service.registerVariable({ name: 'foo', description: 'bar' }, async () => ([{ level: 'full', value: 'farboo' }]));
const v2 = service.registerVariable({ name: 'far', description: 'boo' }, async () => ([{ level: 'full', value: 'farboo' }]));
const parser = instantiationService.createInstance(ChatRequestParser);
const resolveVariables = async (text: string) => {
const result = await parser.parseChatRequest('1', text);
return await service.resolveVariables(result, null!, CancellationToken.None);
};
{
const data = await service.resolveVariables('Hello @foo and@far', null!, CancellationToken.None);
const data = await resolveVariables('Hello @foo and@far');
assert.strictEqual(Object.keys(data.variables).length, 1);
assert.deepEqual(Object.keys(data.variables).sort(), ['foo']);
assert.strictEqual(data.prompt, 'Hello [@foo](values:foo) and@far');
}
{
const data = await service.resolveVariables('@foo Hello', null!, CancellationToken.None);
const data = await resolveVariables('@foo Hello');
assert.strictEqual(Object.keys(data.variables).length, 1);
assert.deepEqual(Object.keys(data.variables).sort(), ['foo']);
assert.strictEqual(data.prompt, '[@foo](values:foo) Hello');
}
{
const data = await service.resolveVariables('Hello @foo', null!, CancellationToken.None);
const data = await resolveVariables('Hello @foo');
assert.strictEqual(Object.keys(data.variables).length, 1);
assert.deepEqual(Object.keys(data.variables).sort(), ['foo']);
}
{
const data = await service.resolveVariables('Hello @foo?', null!, CancellationToken.None);
const data = await resolveVariables('Hello @foo?');
assert.strictEqual(Object.keys(data.variables).length, 1);
assert.deepEqual(Object.keys(data.variables).sort(), ['foo']);
assert.strictEqual(data.prompt, 'Hello [@foo](values:foo)?');
}
{
const data = await service.resolveVariables('Hello @foo and@far @foo', null!, CancellationToken.None);
const data = await resolveVariables('Hello @foo and@far @foo');
assert.strictEqual(Object.keys(data.variables).length, 1);
assert.deepEqual(Object.keys(data.variables).sort(), ['foo']);
}
{
const data = await service.resolveVariables('Hello @foo and @far @foo', null!, CancellationToken.None);
const data = await resolveVariables('Hello @foo and @far @foo');
assert.strictEqual(Object.keys(data.variables).length, 2);
assert.deepEqual(Object.keys(data.variables).sort(), ['far', 'foo']);
}
{
const data = await service.resolveVariables('Hello @foo and @far @foo @unknown', null!, CancellationToken.None);
const data = await resolveVariables('Hello @foo and @far @foo @unknown');
assert.strictEqual(Object.keys(data.variables).length, 2);
assert.deepEqual(Object.keys(data.variables).sort(), ['far', 'foo']);
assert.strictEqual(data.prompt, 'Hello [@foo](values:foo) and [@far](values:far) [@foo](values:foo) @unknown');