Path: blob/main/extensions/copilot/src/extension/linkify/common/responseStreamWithLinkification.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import type { ChatQuestion, ChatResponseClearToPreviousToolInvocationReason, ChatResponseFileTree, ChatResponsePart, ChatResponseStream, ChatResultUsage, ChatToolInvocationStreamData, ChatVulnerability, ChatWorkspaceFileEdit, Command, Location, NotebookEdit, TextEdit, ThinkingDelta, Uri } from 'vscode';5import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';6import { FinalizableChatResponseStream } from '../../../util/common/chatResponseStreamImpl';7import { CancellationToken } from '../../../util/vs/base/common/cancellation';8import { ChatHookType, ChatResponseAnchorPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseFileTreePart, ChatResponseMarkdownPart, ChatResponseThinkingProgressPart, ChatToolInvocationPart, MarkdownString } from '../../../vscodeTypes';9import { LinkifiedText, LinkifySymbolAnchor } from './linkifiedText';10import { IContributedLinkifierFactory, ILinkifier, ILinkifyService, LinkifierContext } from './linkifyService';1112/**13* Proxy of {@linkcode ChatResponseStream} that linkifies paths and symbols in emitted Markdown.14*/15export class ResponseStreamWithLinkification implements FinalizableChatResponseStream {1617private readonly _linkifier: ILinkifier;18private readonly _progress: ChatResponseStream;19private readonly _token: CancellationToken;2021constructor(22context: LinkifierContext,23progress: ChatResponseStream,24additionalLinkifiers: readonly IContributedLinkifierFactory[],25token: CancellationToken,26@ILinkifyService linkifyService: ILinkifyService,27@IWorkspaceService private readonly workspaceService: IWorkspaceService,28) {29this._linkifier = linkifyService.createLinkifier(context, additionalLinkifiers);30this._progress = progress;31this._token = token;32}3334get totalAddedLinkCount() {35return this._linkifier.totalAddedLinkCount;36}3738clearToPreviousToolInvocation(reason: ChatResponseClearToPreviousToolInvocationReason): void {39this._pendingMarkdown = '';40this._pendingMarkdownScheduled = false;41this._linkifier.flush(CancellationToken.None);42this._progress.clearToPreviousToolInvocation(reason);43}4445//#region ChatResponseStream46markdown(value: string | MarkdownString): ChatResponseStream {47this.appendMarkdown(typeof value === 'string' ? new MarkdownString(value) : value);48return this;49}5051anchor(value: Uri | Location, title?: string | undefined): ChatResponseStream {52this.enqueue(() => this._progress.anchor(value, title), false);53return this;54}5556button(command: Command): ChatResponseStream {57this.enqueue(() => this._progress.button(command), true);58return this;59}6061filetree(value: ChatResponseFileTree[], baseUri: Uri): ChatResponseStream {62this.enqueue(() => this._progress.filetree(value, baseUri), true);63return this;64}6566progress(value: string): ChatResponseStream {67this.enqueue(() => this._progress.progress(value), false);68return this;69}7071thinkingProgress(thinkingDelta: ThinkingDelta): ChatResponseStream {72this.enqueue(() => this._progress.thinkingProgress(thinkingDelta), false);73return this;74}7576warning(value: string | MarkdownString): ChatResponseStream {77this.enqueue(() => this._progress.warning(value), false);78return this;79}8081info(value: string | MarkdownString): ChatResponseStream {82this.enqueue(() => this._progress.info(value), false);83return this;84}8586hookProgress(hookType: ChatHookType, stopReason?: string, systemMessage?: string): ChatResponseStream {87this.enqueue(() => this._progress.hookProgress(hookType, stopReason, systemMessage), false);88return this;89}909192reference(value: Uri | Location): ChatResponseStream {93this.enqueue(() => this._progress.reference(value), false);94return this;95}9697reference2(value: Uri | Location): ChatResponseStream {98this.enqueue(() => this._progress.reference(value), false);99return this;100}101102codeCitation(value: Uri, license: string, snippet: string): ChatResponseStream {103this.enqueue(() => this._progress.codeCitation(value, license, snippet), false);104return this;105}106107externalEdit(target: Uri | Uri[], callback: () => Thenable<void>): Thenable<string> {108return this.enqueue(() => this._progress.externalEdit(target, callback), true);109}110111push(part: ChatResponsePart): ChatResponseStream {112if (part instanceof ChatResponseMarkdownPart) {113this.appendMarkdown(part.value);114} else {115this.enqueue(() => this._progress.push(part), this.isBlockPart(part));116}117return this;118}119120private isBlockPart(part: ChatResponsePart): boolean {121return part instanceof ChatResponseFileTreePart122|| part instanceof ChatResponseCommandButtonPart123|| part instanceof ChatResponseConfirmationPart124|| part instanceof ChatToolInvocationPart125|| part instanceof ChatResponseThinkingProgressPart;126}127128textEdit(target: Uri, editsOrDone: TextEdit | TextEdit[] | true): ChatResponseStream {129// TS makes me do this130if (editsOrDone === true) {131this.enqueue(() => this._progress.textEdit(target, editsOrDone), false);132} else {133this.enqueue(() => this._progress.textEdit(target, editsOrDone), false);134}135136return this;137}138139notebookEdit(target: Uri, edits: NotebookEdit | NotebookEdit[]): void;140notebookEdit(target: Uri, isDone: true): void;141notebookEdit(target: Uri, editsOrDone: NotebookEdit | NotebookEdit[] | true): ChatResponseStream {142// TS makes me do this143if (editsOrDone === true) {144this.enqueue(() => this._progress.notebookEdit(target, editsOrDone), false);145} else {146this.enqueue(() => this._progress.notebookEdit(target, editsOrDone), false);147}148return this;149}150151workspaceEdit(edits: ChatWorkspaceFileEdit[]): void {152this.enqueue(() => this._progress.workspaceEdit(edits), false);153}154155markdownWithVulnerabilities(value: string | MarkdownString, vulnerabilities: ChatVulnerability[]): ChatResponseStream {156this.enqueue(() => this._progress.markdownWithVulnerabilities(value, vulnerabilities), false);157return this;158}159160codeblockUri(uri: Uri, isEdit?: boolean): void {161if ('codeblockUri' in this._progress) {162this.enqueue(() => this._progress.codeblockUri(uri, isEdit), false);163}164}165166confirmation(title: string, message: string, data: any): ChatResponseStream {167this.enqueue(() => this._progress.confirmation(title, message, data), true);168return this;169}170171beginToolInvocation(toolCallId: string, toolName: string, streamData?: ChatToolInvocationStreamData): ChatResponseStream {172this.enqueue(() => this._progress.beginToolInvocation(toolCallId, toolName, streamData), true);173return this;174}175176updateToolInvocation(toolCallId: string, streamData: { partialInput?: unknown }): ChatResponseStream {177this.enqueue(() => this._progress.updateToolInvocation(toolCallId, streamData), false);178return this;179}180181questionCarousel(questions: ChatQuestion[], allowSkip?: boolean): Thenable<Record<string, unknown> | undefined> {182return this.enqueue(() => this._progress.questionCarousel(questions, allowSkip), true);183}184185usage(usage: ChatResultUsage): ChatResponseStream {186this.enqueue(() => this._progress.usage(usage), false);187return this;188}189190//#endregion191192private sequencer: Promise<unknown> = Promise.resolve();193194private enqueue<T>(f: () => T | Thenable<T>, flush: boolean) {195if (flush) {196this.sequencer = this.sequencer.then(() => this.doFinalize());197}198this.sequencer = this.sequencer.then(f);199return this.sequencer as Promise<T>;200}201202private _pendingMarkdown = '';203private _pendingMarkdownScheduled = false;204205private async appendMarkdown(md: MarkdownString): Promise<void> {206if (!md.value) {207return;208}209210// Buffer incoming markdown and schedule a single drain when the sequencer frees up.211// This coalesces many small markdown chunks into fewer linkifier.append() calls,212// dramatically reducing queue wait when the linkifier is busy.213this._pendingMarkdown += md.value;214215if (!this._pendingMarkdownScheduled) {216this._pendingMarkdownScheduled = true;217this.enqueue(async () => {218const buf = this._pendingMarkdown;219this._pendingMarkdown = '';220this._pendingMarkdownScheduled = false;221222const output = await this._linkifier.append(buf, this._token);223if (this._token.isCancellationRequested) {224return;225}226227this.outputMarkdown(output);228}, false);229}230}231232async finalize() {233await this.enqueue(() => this.doFinalize(), false);234}235236private async doFinalize() {237const textToApply = await this._linkifier.flush(this._token);238if (this._token.isCancellationRequested) {239return;240}241242if (textToApply) {243this.outputMarkdown(textToApply);244}245}246247private outputMarkdown(textToApply: LinkifiedText) {248for (const part of textToApply.parts) {249if (typeof part === 'string') {250if (!part.length) {251continue;252}253254const content = new MarkdownString(part);255256const folder = this.workspaceService.getWorkspaceFolders()?.at(0);257if (folder) {258content.baseUri = folder.path.endsWith('/') ? folder : folder.with({ path: folder.path + '/' });259}260261this._progress.markdown(content);262} else {263if (part instanceof LinkifySymbolAnchor) {264const chatPart = new ChatResponseAnchorPart(part.symbolInformation as any);265if (part.resolve) {266(chatPart as any).resolve = () => part.resolve!(this._token);267}268this._progress.push(chatPart);269} else {270this._progress.anchor(part.value, part.title);271}272}273}274}275}276277278