Skip to content

Commit

Permalink
voice - support synthesize pending chat responses (#212505)
Browse files Browse the repository at this point in the history
  • Loading branch information
bpasero authored May 13, 2024
1 parent b3f2d61 commit c6ca1ff
Show file tree
Hide file tree
Showing 15 changed files with 336 additions and 188 deletions.
14 changes: 7 additions & 7 deletions src/vs/workbench/api/browser/mainThreadSpeech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { raceCancellation } from 'vs/base/common/async';
import { Emitter, Event } from 'vs/base/common/event';
import { DisposableStore, IDisposable } from 'vs/base/common/lifecycle';
import { ILogService } from 'vs/platform/log/common/log';
import { ExtHostContext, ExtHostSpeechShape, MainContext, MainThreadSpeechShape } from 'vs/workbench/api/common/extHost.protocol';
import { IKeywordRecognitionEvent, ISpeechProviderMetadata, ISpeechService, ISpeechToTextEvent, ITextToSpeechEvent } from 'vs/workbench/contrib/speech/common/speechService';
import { IKeywordRecognitionEvent, ISpeechProviderMetadata, ISpeechService, ISpeechToTextEvent, ITextToSpeechEvent, TextToSpeechStatus } from 'vs/workbench/contrib/speech/common/speechService';
import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers';

type SpeechToTextSession = {
Expand All @@ -16,7 +17,6 @@ type SpeechToTextSession = {

type TextToSpeechSession = {
readonly onDidChange: Emitter<ITextToSpeechEvent>;
synthesize(text: string): Promise<void>;
};

type KeywordRecognitionSession = {
Expand Down Expand Up @@ -86,10 +86,7 @@ export class MainThreadSpeech implements MainThreadSpeechShape {
this.proxy.$createTextToSpeechSession(handle, session, options?.language);

const onDidChange = disposables.add(new Emitter<ITextToSpeechEvent>());
this.textToSpeechSessions.set(session, {
onDidChange,
synthesize: text => this.proxy.$synthesizeSpeech(session, text)
});
this.textToSpeechSessions.set(session, { onDidChange });

disposables.add(token.onCancellationRequested(() => {
this.proxy.$cancelTextToSpeechSession(session);
Expand All @@ -99,7 +96,10 @@ export class MainThreadSpeech implements MainThreadSpeechShape {

return {
onDidChange: onDidChange.event,
synthesize: text => this.proxy.$synthesizeSpeech(session, text)
synthesize: async text => {
await this.proxy.$synthesizeSpeech(session, text);
await raceCancellation(Event.toPromise(Event.filter(onDidChange.event, e => e.status === TextToSpeechStatus.Stopped)), token);
}
};
},
createKeywordRecognitionSession: token => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Disposable } from 'vs/base/common/lifecycle';
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
import { Event } from 'vs/base/common/event';
import { isDefined } from 'vs/base/common/types';
import { IProductService } from 'vs/platform/product/common/productService';

export const accessibilityHelpIsShown = new RawContextKey<boolean>('accessibilityHelpIsShown', false, true);
export const accessibleViewIsShown = new RawContextKey<boolean>('accessibleViewIsShown', false, true);
Expand Down Expand Up @@ -631,6 +632,7 @@ export function registerAccessibilityConfiguration() {

export const enum AccessibilityVoiceSettingId {
SpeechTimeout = 'accessibility.voice.speechTimeout',
AutoSynthesize = 'accessibility.voice.autoSynthesize',
SpeechLanguage = SPEECH_LANGUAGE_CONFIG
}
export const SpeechTimeoutDefault = 1200;
Expand All @@ -640,7 +642,8 @@ export class DynamicSpeechAccessibilityConfiguration extends Disposable implemen
static readonly ID = 'workbench.contrib.dynamicSpeechAccessibilityConfiguration';

constructor(
@ISpeechService private readonly speechService: ISpeechService
@ISpeechService private readonly speechService: ISpeechService,
@IProductService private readonly productService: IProductService
) {
super();

Expand Down Expand Up @@ -676,6 +679,12 @@ export class DynamicSpeechAccessibilityConfiguration extends Disposable implemen
'tags': ['accessibility'],
'enumDescriptions': languagesSorted.map(key => languages[key].name),
'enumItemLabels': languagesSorted.map(key => languages[key].name)
},
[AccessibilityVoiceSettingId.AutoSynthesize]: {
'type': 'boolean',
'markdownDescription': localize('autoSynthesize', "Whether a textual response should automatically be read out aloud when speech was used as input. For example in a chat session, a response is automatically synthesized when voice was used as chat request."),
'default': this.productService.quality !== 'stable', // TODO@bpasero decide on a default
'tags': ['accessibility']
}
}
});
Expand Down
5 changes: 4 additions & 1 deletion src/vs/workbench/contrib/chat/browser/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { Selection } from 'vs/editor/common/core/selection';
import { localize } from 'vs/nls';
import { MenuId } from 'vs/platform/actions/common/actions';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { ChatViewPane } from 'vs/workbench/contrib/chat/browser/chatViewPane';
import { IChatWidgetContrib } from 'vs/workbench/contrib/chat/browser/chatWidget';
import { ICodeBlockActionContext } from 'vs/workbench/contrib/chat/browser/codeBlockPart';
import { ChatAgentLocation, IChatAgentCommand, IChatAgentData } from 'vs/workbench/contrib/chat/common/chatAgents';
import { IChatResponseModel } from 'vs/workbench/contrib/chat/common/chatModel';
import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { CHAT_PROVIDER_ID } from 'vs/workbench/contrib/chat/common/chatParticipantContribTypes';
import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWelcomeMessageViewModel } from 'vs/workbench/contrib/chat/common/chatViewModel';
Expand Down Expand Up @@ -135,6 +137,7 @@ export interface IChatWidget {
readonly supportsFileReferences: boolean;
readonly parsedInput: IParsedChatRequest;
lastSelectedAgent: IChatAgentData | undefined;
readonly scopedContextKeyService: IContextKeyService;

getContrib<T extends IChatWidgetContrib>(id: string): T | undefined;
reveal(item: ChatTreeItem): void;
Expand All @@ -143,7 +146,7 @@ export interface IChatWidget {
getFocus(): ChatTreeItem | undefined;
setInput(query?: string): void;
getInput(): string;
acceptInput(query?: string): void;
acceptInput(query?: string): Promise<IChatResponseModel | undefined>;
acceptInputWithPrefix(prefix: string): void;
setInputPlaceholder(placeholder: string): void;
resetInputPlaceholder(): void;
Expand Down
2 changes: 1 addition & 1 deletion src/vs/workbench/contrib/chat/browser/chatQuick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ class QuickChat extends Disposable {
}));
}

async acceptInput(): Promise<void> {
async acceptInput() {
return this.widget.acceptInput();
}

Expand Down
16 changes: 11 additions & 5 deletions src/vs/workbench/contrib/chat/browser/chatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { ChatListDelegate, ChatListItemRenderer, IChatRendererDelegate } from 'v
import { ChatEditorOptions } from 'vs/workbench/contrib/chat/browser/chatOptions';
import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { CONTEXT_CHAT_INPUT_HAS_AGENT, CONTEXT_CHAT_LOCATION, CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_IN_CHAT_SESSION, CONTEXT_RESPONSE_FILTERED } from 'vs/workbench/contrib/chat/common/chatContextKeys';
import { ChatModelInitState, IChatModel } from 'vs/workbench/contrib/chat/common/chatModel';
import { ChatModelInitState, IChatModel, IChatResponseModel } from 'vs/workbench/contrib/chat/common/chatModel';
import { ChatRequestAgentPart, IParsedChatRequest, chatAgentLeader, chatSubcommandLeader, extractAgentAndCommand } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { IChatFollowup, IChatService } from 'vs/workbench/contrib/chat/common/chatService';
Expand Down Expand Up @@ -161,6 +161,10 @@ export class ChatWidget extends Disposable implements IChatWidget {
return this.parsedChatRequest;
}

get scopedContextKeyService(): IContextKeyService {
return this.contextKeyService;
}

constructor(
readonly location: ChatAgentLocation,
readonly viewContext: IChatWidgetViewContext,
Expand Down Expand Up @@ -689,8 +693,8 @@ export class ChatWidget extends Disposable implements IChatWidget {
return this.inputPart.inputEditor.getValue();
}

async acceptInput(query?: string): Promise<void> {
this._acceptInput(query ? { query } : undefined);
async acceptInput(query?: string): Promise<IChatResponseModel | undefined> {
return this._acceptInput(query ? { query } : undefined);
}

async acceptInputWithPrefix(prefix: string): Promise<void> {
Expand All @@ -707,7 +711,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
return inputState;
}

private async _acceptInput(opts: { query: string } | { prefix: string } | undefined): Promise<void> {
private async _acceptInput(opts: { query: string } | { prefix: string } | undefined): Promise<IChatResponseModel | undefined> {
if (this.viewModel) {
this._onDidAcceptInput.fire();

Expand All @@ -723,13 +727,15 @@ export class ChatWidget extends Disposable implements IChatWidget {
const inputState = this.collectInputState();
this.inputPart.acceptInput(isUserQuery ? input : undefined, isUserQuery ? inputState : undefined);
this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand });
result.responseCompletePromise.then(async () => {
result.responseCompletePromise.then(() => {
const responses = this.viewModel?.getItems().filter(isResponseVM);
const lastResponse = responses?.[responses.length - 1];
this.chatAccessibilityService.acceptResponse(lastResponse, requestId);
});
return result.responseCreatedPromise;
}
}
return undefined;
}

getCodeBlockInfosForResponse(response: IChatResponseViewModel): IChatCodeBlockInfo[] {
Expand Down
8 changes: 6 additions & 2 deletions src/vs/workbench/contrib/chat/common/chatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Command, Location, TextEdit } from 'vs/editor/common/languages';
import { FileType } from 'vs/platform/files/common/files';
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IChatAgentResult } from 'vs/workbench/contrib/chat/common/chatAgents';
import { ChatModel, IChatModel, IChatRequestModel, IChatRequestVariableData, IExportableChatData, ISerializableChatData } from 'vs/workbench/contrib/chat/common/chatModel';
import { ChatModel, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData } from 'vs/workbench/contrib/chat/common/chatModel';
import { IParsedChatRequest } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { IChatParserContext } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { IChatRequestVariableValue } from 'vs/workbench/contrib/chat/common/chatVariables';
Expand Down Expand Up @@ -288,8 +288,12 @@ export interface IChatTransferredSessionData {
inputValue: string;
}

export interface IChatSendRequestData {
export interface IChatSendRequestResponseState {
responseCreatedPromise: Promise<IChatResponseModel>;
responseCompletePromise: Promise<void>;
}

export interface IChatSendRequestData extends IChatSendRequestResponseState {
agent: IChatAgentData;
slashCommand?: IChatAgentCommand;
}
Expand Down
29 changes: 23 additions & 6 deletions src/vs/workbench/contrib/chat/common/chatServiceImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { coalesce } from 'vs/base/common/arrays';
import { DeferredPromise } from 'vs/base/common/async';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { ErrorNoTelemetry } from 'vs/base/common/errors';
import { Emitter, Event } from 'vs/base/common/event';
Expand All @@ -22,10 +23,10 @@ import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storag
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace';
import { ChatAgentLocation, IChatAgent, IChatAgentRequest, IChatAgentResult, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents';
import { ChatModel, ChatRequestModel, ChatWelcomeMessageModel, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatRequestVariableEntry, IExportableChatData, ISerializableChatData, ISerializableChatsData, getHistoryEntriesFromModel, updateRanges } from 'vs/workbench/contrib/chat/common/chatModel';
import { ChatModel, ChatRequestModel, ChatWelcomeMessageModel, IChatModel, IChatRequestModel, IChatRequestVariableData, IChatRequestVariableEntry, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatsData, getHistoryEntriesFromModel, updateRanges } from 'vs/workbench/contrib/chat/common/chatModel';
import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, IParsedChatRequest, chatAgentLeader, chatSubcommandLeader, getPromptText } from 'vs/workbench/contrib/chat/common/chatParserTypes';
import { ChatRequestParser } from 'vs/workbench/contrib/chat/common/chatRequestParser';
import { ChatCopyKind, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatService, IChatTransferredSessionData, IChatUserActionEvent, ChatAgentVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { ChatCopyKind, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatTransferredSessionData, IChatUserActionEvent, ChatAgentVoteDirection } from 'vs/workbench/contrib/chat/common/chatService';
import { IChatSlashCommandService } from 'vs/workbench/contrib/chat/common/chatSlashCommands';
import { IChatVariablesService } from 'vs/workbench/contrib/chat/common/chatVariables';
import { ChatMessageRole, IChatMessage } from 'vs/workbench/contrib/chat/common/languageModels';
Expand Down Expand Up @@ -433,7 +434,7 @@ export class ChatService extends Disposable implements IChatService {

this.removeRequest(model.sessionId, request.id);

await this._sendRequestAsync(model, model.sessionId, request.message, attempt, enableCommandDetection, implicitVariablesEnabled, defaultAgent, location);
await this._sendRequestAsync(model, model.sessionId, request.message, attempt, enableCommandDetection, implicitVariablesEnabled, defaultAgent, location).responseCompletePromise;
}

async sendRequest(sessionId: string, request: string, options?: IChatSendRequestOptions): Promise<IChatSendRequestData | undefined> {
Expand Down Expand Up @@ -467,7 +468,7 @@ 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, sessionId, parsedRequest, attempt, !options?.noCommandDetection, implicitVariablesEnabled, defaultAgent, location, options),
...this._sendRequestAsync(model, sessionId, parsedRequest, attempt, !options?.noCommandDetection, implicitVariablesEnabled, defaultAgent, location, options),
agent,
slashCommand: agentSlashCommandPart?.command,
};
Expand Down Expand Up @@ -497,7 +498,7 @@ export class ChatService extends Disposable implements IChatService {
return newTokenSource.token;
}

private async _sendRequestAsync(model: ChatModel, sessionId: string, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, implicitVariablesEnabled: boolean, defaultAgent: IChatAgent, location: ChatAgentLocation, confirmData?: IRequestConfirmationData): Promise<void> {
private _sendRequestAsync(model: ChatModel, sessionId: string, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, implicitVariablesEnabled: boolean, defaultAgent: IChatAgent, location: ChatAgentLocation, confirmData?: IRequestConfirmationData): IChatSendRequestResponseState {
const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionId);
let request: ChatRequestModel;
const agentPart = 'kind' in parsedRequest ? undefined : parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart);
Expand All @@ -507,6 +508,15 @@ export class ChatService extends Disposable implements IChatService {
let gotProgress = false;
const requestType = commandPart ? 'slashCommand' : 'string';

const responseCreated = new DeferredPromise<IChatResponseModel>();
let responseCreatedComplete = false;
function completeResponseCreated(): void {
if (!responseCreatedComplete && request?.response) {
responseCreated.complete(request.response);
responseCreatedComplete = true;
}
}

const source = new CancellationTokenSource();
const token = source.token;
const sendRequestInternal = async () => {
Expand All @@ -524,6 +534,7 @@ export class ChatService extends Disposable implements IChatService {
}

model.acceptResponseProgress(request, progress);
completeResponseCreated();
};

const stopWatch = new StopWatch(false);
Expand Down Expand Up @@ -554,6 +565,7 @@ export class ChatService extends Disposable implements IChatService {

const initVariableData: IChatRequestVariableData = { variables: [] };
request = model.addRequest(parsedRequest, initVariableData, attempt, agent, agentSlashCommandPart?.command);
completeResponseCreated();
const variableData = await this.chatVariablesService.resolveVariables(parsedRequest, model, progressCallback, token);
request.variableData = variableData;

Expand Down Expand Up @@ -589,6 +601,7 @@ export class ChatService extends Disposable implements IChatService {
agentOrCommandFollowups = this.chatAgentService.getFollowups(agent.id, requestProps, agentResult, history, followupsCancelToken);
} else if (commandPart && this.chatSlashCommandService.hasCommand(commandPart.slashCommand.command)) {
request = model.addRequest(parsedRequest, { variables: [] }, attempt);
completeResponseCreated();
// contributed slash commands
// TODO: spell this out in the UI
const history: IChatMessage[] = [];
Expand Down Expand Up @@ -632,6 +645,7 @@ export class ChatService extends Disposable implements IChatService {
chatSessionId: model.sessionId
});
model.setResponse(request, rawResult);
completeResponseCreated();
this.trace('sendRequest', `Provider returned response for session ${model.sessionId}`);

model.completeResponse(request);
Expand All @@ -650,7 +664,10 @@ export class ChatService extends Disposable implements IChatService {
rawResponsePromise.finally(() => {
this._pendingRequests.deleteAndDispose(model.sessionId);
});
return rawResponsePromise;
return {
responseCreatedPromise: responseCreated.p,
responseCompletePromise: rawResponsePromise,
};
}

async removeRequest(sessionId: string, requestId: string): Promise<void> {
Expand Down
5 changes: 5 additions & 0 deletions src/vs/workbench/contrib/chat/common/chatViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export interface IChatLiveUpdateData {
}

export interface IChatResponseViewModel {
readonly model: IChatResponseModel;
readonly id: string;
readonly sessionId: string;
/** This ID updates every time the underlying data changes */
Expand Down Expand Up @@ -362,6 +363,10 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi
private readonly _onDidChange = this._register(new Emitter<void>());
readonly onDidChange = this._onDidChange.event;

get model() {
return this._model;
}

get id() {
return this._model.id;
}
Expand Down
Loading

0 comments on commit c6ca1ff

Please sign in to comment.