Path: blob/main/src/vs/workbench/contrib/chat/common/chatViewModel.ts
3296 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*--------------------------------------------------------------------------------------------*/45import { Emitter, Event } from '../../../../base/common/event.js';6import { hash } from '../../../../base/common/hash.js';7import { IMarkdownString } from '../../../../base/common/htmlContent.js';8import { Disposable, dispose } from '../../../../base/common/lifecycle.js';9import * as marked from '../../../../base/common/marked/marked.js';10import { ThemeIcon } from '../../../../base/common/themables.js';11import { URI } from '../../../../base/common/uri.js';12import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';13import { ILogService } from '../../../../platform/log/common/log.js';14import { annotateVulnerabilitiesInText } from './annotations.js';15import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from './chatAgents.js';16import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js';17import { IChatRequestVariableEntry } from './chatVariableEntries.js';18import { IParsedChatRequest } from './chatParserTypes.js';19import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatChangesSummary, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatProgressMessage, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from './chatService.js';20import { countWords } from './chatWordCounter.js';21import { CodeBlockModelCollection } from './codeBlockModelCollection.js';2223export function isRequestVM(item: unknown): item is IChatRequestViewModel {24return !!item && typeof item === 'object' && 'message' in item;25}2627export function isResponseVM(item: unknown): item is IChatResponseViewModel {28return !!item && typeof (item as IChatResponseViewModel).setVote !== 'undefined';29}3031export function isChatTreeItem(item: unknown): item is IChatRequestViewModel | IChatResponseViewModel {32return isRequestVM(item) || isResponseVM(item);33}3435export function assertIsResponseVM(item: unknown): asserts item is IChatResponseViewModel {36if (!isResponseVM(item)) {37throw new Error('Expected item to be IChatResponseViewModel');38}39}4041export type IChatViewModelChangeEvent = IChatAddRequestEvent | IChangePlaceholderEvent | IChatSessionInitEvent | IChatSetHiddenEvent | IChatSetCheckpointEvent | null;4243export interface IChatAddRequestEvent {44kind: 'addRequest';45}4647export interface IChangePlaceholderEvent {48kind: 'changePlaceholder';49}5051export interface IChatSessionInitEvent {52kind: 'initialize';53}5455export interface IChatSetHiddenEvent {56kind: 'setHidden';57}5859export interface IChatSetCheckpointEvent {60kind: 'setCheckpoint';61}6263export interface IChatViewModel {64readonly model: IChatModel;65readonly sessionId: string;66readonly onDidDisposeModel: Event<void>;67readonly onDidChange: Event<IChatViewModelChangeEvent>;68readonly requestInProgress: boolean;69readonly inputPlaceholder?: string;70getItems(): (IChatRequestViewModel | IChatResponseViewModel)[];71setInputPlaceholder(text: string): void;72resetInputPlaceholder(): void;73editing?: IChatRequestViewModel;74setEditing(editing: IChatRequestViewModel): void;75}7677export interface IChatRequestViewModel {78readonly id: string;79readonly sessionId: string;80/** This ID updates every time the underlying data changes */81readonly dataId: string;82readonly username: string;83readonly avatarIcon?: URI | ThemeIcon;84readonly message: IParsedChatRequest | IChatFollowup;85readonly messageText: string;86readonly attempt: number;87readonly variables: IChatRequestVariableEntry[];88currentRenderedHeight: number | undefined;89readonly contentReferences?: ReadonlyArray<IChatContentReference>;90readonly confirmation?: string;91readonly shouldBeRemovedOnSend: IChatRequestDisablement | undefined;92readonly isComplete: boolean;93readonly isCompleteAddedRequest: boolean;94readonly slashCommand: IChatAgentCommand | undefined;95readonly agentOrSlashCommandDetected: boolean;96readonly shouldBeBlocked?: boolean;97readonly modelId?: string;98}99100export interface IChatResponseMarkdownRenderData {101renderedWordCount: number;102lastRenderTime: number;103isFullyRendered: boolean;104originalMarkdown: IMarkdownString;105}106107export interface IChatResponseMarkdownRenderData2 {108renderedWordCount: number;109lastRenderTime: number;110isFullyRendered: boolean;111originalMarkdown: IMarkdownString;112}113114export interface IChatProgressMessageRenderData {115progressMessage: IChatProgressMessage;116117/**118* Indicates whether this is part of a group of progress messages that are at the end of the response.119* (Not whether this particular item is the very last one in the response).120* Need to re-render and add to partsToRender when this changes.121*/122isAtEndOfResponse: boolean;123124/**125* Whether this progress message the very last item in the response.126* Need to re-render to update spinner vs check when this changes.127*/128isLast: boolean;129}130131export interface IChatTaskRenderData {132task: IChatTask;133isSettled: boolean;134progressLength: number;135}136137export interface IChatResponseRenderData {138renderedParts: IChatRendererContent[];139140renderedWordCount: number;141lastRenderTime: number;142}143144/**145* Content type for references used during rendering, not in the model146*/147export interface IChatReferences {148references: ReadonlyArray<IChatContentReference>;149kind: 'references';150}151152/**153* Content type for the "Working" progress message154*/155export interface IChatWorkingProgress {156kind: 'working';157}158159160/**161* Content type for citations used during rendering, not in the model162*/163export interface IChatCodeCitations {164citations: ReadonlyArray<IChatCodeCitation>;165kind: 'codeCitations';166}167168export interface IChatErrorDetailsPart {169kind: 'errorDetails';170errorDetails: IChatResponseErrorDetails;171isLast: boolean;172}173174export interface IChatChangesSummaryPart {175readonly kind: 'changesSummary';176readonly fileChanges: ReadonlyArray<IChatChangesSummary>;177}178179/**180* Type for content parts rendered by IChatListRenderer (not necessarily in the model)181*/182export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations | IChatErrorDetailsPart | IChatChangesSummaryPart | IChatWorkingProgress;183184export interface IChatLiveUpdateData {185totalTime: number;186lastUpdateTime: number;187impliedWordLoadRate: number;188lastWordCount: number;189}190191export interface IChatResponseViewModel {192readonly model: IChatResponseModel;193readonly id: string;194readonly session: IChatViewModel;195readonly sessionId: string;196/** This ID updates every time the underlying data changes */197readonly dataId: string;198/** The ID of the associated IChatRequestViewModel */199readonly requestId: string;200readonly username: string;201readonly avatarIcon?: URI | ThemeIcon;202readonly agent?: IChatAgentData;203readonly slashCommand?: IChatAgentCommand;204readonly agentOrSlashCommandDetected: boolean;205readonly response: IResponse;206readonly usedContext: IChatUsedContext | undefined;207readonly contentReferences: ReadonlyArray<IChatContentReference>;208readonly codeCitations: ReadonlyArray<IChatCodeCitation>;209readonly progressMessages: ReadonlyArray<IChatProgressMessage>;210readonly isComplete: boolean;211readonly isCanceled: boolean;212readonly isStale: boolean;213readonly vote: ChatAgentVoteDirection | undefined;214readonly voteDownReason: ChatAgentVoteDownReason | undefined;215readonly replyFollowups?: IChatFollowup[];216readonly errorDetails?: IChatResponseErrorDetails;217readonly result?: IChatAgentResult;218readonly contentUpdateTimings?: IChatLiveUpdateData;219readonly shouldBeRemovedOnSend: IChatRequestDisablement | undefined;220readonly isCompleteAddedRequest: boolean;221renderData?: IChatResponseRenderData;222currentRenderedHeight: number | undefined;223setVote(vote: ChatAgentVoteDirection): void;224setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void;225usedReferencesExpanded?: boolean;226vulnerabilitiesListExpanded: boolean;227setEditApplied(edit: IChatTextEditGroup, editCount: number): void;228readonly shouldBeBlocked: boolean;229}230231export class ChatViewModel extends Disposable implements IChatViewModel {232233private readonly _onDidDisposeModel = this._register(new Emitter<void>());234readonly onDidDisposeModel = this._onDidDisposeModel.event;235236private readonly _onDidChange = this._register(new Emitter<IChatViewModelChangeEvent>());237readonly onDidChange = this._onDidChange.event;238239private readonly _items: (ChatRequestViewModel | ChatResponseViewModel)[] = [];240241private _inputPlaceholder: string | undefined = undefined;242get inputPlaceholder(): string | undefined {243return this._inputPlaceholder;244}245246get model(): IChatModel {247return this._model;248}249250setInputPlaceholder(text: string): void {251this._inputPlaceholder = text;252this._onDidChange.fire({ kind: 'changePlaceholder' });253}254255resetInputPlaceholder(): void {256this._inputPlaceholder = undefined;257this._onDidChange.fire({ kind: 'changePlaceholder' });258}259260get sessionId() {261return this._model.sessionId;262}263264get requestInProgress(): boolean {265return this._model.requestInProgress;266}267268constructor(269private readonly _model: IChatModel,270public readonly codeBlockModelCollection: CodeBlockModelCollection,271@IInstantiationService private readonly instantiationService: IInstantiationService,272) {273super();274275_model.getRequests().forEach((request, i) => {276const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, request);277this._items.push(requestModel);278this.updateCodeBlockTextModels(requestModel);279280if (request.response) {281this.onAddResponse(request.response);282}283});284285this._register(_model.onDidDispose(() => this._onDidDisposeModel.fire()));286this._register(_model.onDidChange(e => {287if (e.kind === 'addRequest') {288const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request);289this._items.push(requestModel);290this.updateCodeBlockTextModels(requestModel);291292if (e.request.response) {293this.onAddResponse(e.request.response);294}295} else if (e.kind === 'addResponse') {296this.onAddResponse(e.response);297} else if (e.kind === 'removeRequest') {298const requestIdx = this._items.findIndex(item => isRequestVM(item) && item.id === e.requestId);299if (requestIdx >= 0) {300this._items.splice(requestIdx, 1);301}302303const responseIdx = e.responseId && this._items.findIndex(item => isResponseVM(item) && item.id === e.responseId);304if (typeof responseIdx === 'number' && responseIdx >= 0) {305const items = this._items.splice(responseIdx, 1);306const item = items[0];307if (item instanceof ChatResponseViewModel) {308item.dispose();309}310}311}312313const modelEventToVmEvent: IChatViewModelChangeEvent =314e.kind === 'addRequest' ? { kind: 'addRequest' }315: e.kind === 'initialize' ? { kind: 'initialize' }316: e.kind === 'setHidden' ? { kind: 'setHidden' }317: null;318this._onDidChange.fire(modelEventToVmEvent);319}));320}321322private onAddResponse(responseModel: IChatResponseModel) {323const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel, this);324this._register(response.onDidChange(() => {325if (response.isComplete) {326this.updateCodeBlockTextModels(response);327}328return this._onDidChange.fire(null);329}));330this._items.push(response);331this.updateCodeBlockTextModels(response);332}333334getItems(): (IChatRequestViewModel | IChatResponseViewModel)[] {335return this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop);336}337338339private _editing: IChatRequestViewModel | undefined = undefined;340get editing(): IChatRequestViewModel | undefined {341return this._editing;342}343344setEditing(editing: IChatRequestViewModel | undefined): void {345if (this.editing && editing && this.editing.id === editing.id) {346return; // already editing this request347}348349this._editing = editing;350}351352override dispose() {353super.dispose();354dispose(this._items.filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel));355}356357updateCodeBlockTextModels(model: IChatRequestViewModel | IChatResponseViewModel) {358let content: string;359if (isRequestVM(model)) {360content = model.messageText;361} else {362content = annotateVulnerabilitiesInText(model.response.value).map(x => x.content.value).join('');363}364365let codeBlockIndex = 0;366marked.walkTokens(marked.lexer(content), token => {367if (token.type === 'code') {368const lang = token.lang || '';369const text = token.text;370this.codeBlockModelCollection.update(this._model.sessionId, model, codeBlockIndex++, { text, languageId: lang, isComplete: true });371}372});373}374}375376export class ChatRequestViewModel implements IChatRequestViewModel {377get id() {378return this._model.id;379}380381get dataId() {382return this.id + `_${hash(this.variables)}_${hash(this.isComplete)}`;383}384385get sessionId() {386return this._model.session.sessionId;387}388389get username() {390return this._model.username;391}392393get avatarIcon() {394return this._model.avatarIconUri;395}396397get message() {398return this._model.message;399}400401get messageText() {402return this.message.text;403}404405get attempt() {406return this._model.attempt;407}408409get variables() {410return this._model.variableData.variables;411}412413get contentReferences() {414return this._model.response?.contentReferences;415}416417get confirmation() {418return this._model.confirmation;419}420421get isComplete() {422return this._model.response?.isComplete ?? false;423}424425get isCompleteAddedRequest() {426return this._model.isCompleteAddedRequest;427}428429get shouldBeRemovedOnSend() {430return this._model.shouldBeRemovedOnSend;431}432433get shouldBeBlocked() {434return this._model.shouldBeBlocked;435}436437get slashCommand(): IChatAgentCommand | undefined {438return this._model.response?.slashCommand;439}440441get agentOrSlashCommandDetected(): boolean {442return this._model.response?.agentOrSlashCommandDetected ?? false;443}444445currentRenderedHeight: number | undefined;446447get modelId() {448return this._model.modelId;449}450451constructor(452private readonly _model: IChatRequestModel,453) { }454}455456export class ChatResponseViewModel extends Disposable implements IChatResponseViewModel {457private _modelChangeCount = 0;458459private readonly _onDidChange = this._register(new Emitter<void>());460readonly onDidChange = this._onDidChange.event;461462get model() {463return this._model;464}465466get id() {467return this._model.id;468}469470get dataId() {471return this._model.id +472`_${this._modelChangeCount}` +473(this.isLast ? '_last' : '');474}475476get sessionId() {477return this._model.session.sessionId;478}479480get username() {481if (this.agent) {482const isAllowed = this.chatAgentNameService.getAgentNameRestriction(this.agent);483if (isAllowed) {484return this.agent.fullName || this.agent.name;485} else {486return getFullyQualifiedId(this.agent);487}488}489490return this._model.username;491}492493get avatarIcon() {494return this._model.avatarIcon;495}496497get agent() {498return this._model.agent;499}500501get slashCommand() {502return this._model.slashCommand;503}504505get agentOrSlashCommandDetected() {506return this._model.agentOrSlashCommandDetected;507}508509get response(): IResponse {510return this._model.response;511}512513get usedContext(): IChatUsedContext | undefined {514return this._model.usedContext;515}516517get contentReferences(): ReadonlyArray<IChatContentReference> {518return this._model.contentReferences;519}520521get codeCitations(): ReadonlyArray<IChatCodeCitation> {522return this._model.codeCitations;523}524525get progressMessages(): ReadonlyArray<IChatProgressMessage> {526return this._model.progressMessages;527}528529get isComplete() {530return this._model.isComplete;531}532533get isCanceled() {534return this._model.isCanceled;535}536537get shouldBeBlocked() {538return this._model.shouldBeBlocked;539}540541get shouldBeRemovedOnSend() {542return this._model.shouldBeRemovedOnSend;543}544545get isCompleteAddedRequest() {546return this._model.isCompleteAddedRequest;547}548549get replyFollowups() {550return this._model.followups?.filter((f): f is IChatFollowup => f.kind === 'reply');551}552553get result() {554return this._model.result;555}556557get errorDetails(): IChatResponseErrorDetails | undefined {558return this.result?.errorDetails;559}560561get vote() {562return this._model.vote;563}564565get voteDownReason() {566return this._model.voteDownReason;567}568569get requestId() {570return this._model.requestId;571}572573get isStale() {574return this._model.isStale;575}576577get isLast(): boolean {578return this.session.getItems().at(-1) === this;579}580581renderData: IChatResponseRenderData | undefined = undefined;582currentRenderedHeight: number | undefined;583584private _usedReferencesExpanded: boolean | undefined;585get usedReferencesExpanded(): boolean | undefined {586if (typeof this._usedReferencesExpanded === 'boolean') {587return this._usedReferencesExpanded;588}589590return undefined;591}592593set usedReferencesExpanded(v: boolean) {594this._usedReferencesExpanded = v;595}596597private _vulnerabilitiesListExpanded: boolean = false;598get vulnerabilitiesListExpanded(): boolean {599return this._vulnerabilitiesListExpanded;600}601602set vulnerabilitiesListExpanded(v: boolean) {603this._vulnerabilitiesListExpanded = v;604}605606private _contentUpdateTimings: IChatLiveUpdateData | undefined = undefined;607get contentUpdateTimings(): IChatLiveUpdateData | undefined {608return this._contentUpdateTimings;609}610611612constructor(613private readonly _model: IChatResponseModel,614public readonly session: IChatViewModel,615@ILogService private readonly logService: ILogService,616@IChatAgentNameService private readonly chatAgentNameService: IChatAgentNameService,617) {618super();619620if (!_model.isComplete) {621this._contentUpdateTimings = {622totalTime: 0,623lastUpdateTime: Date.now(),624impliedWordLoadRate: 0,625lastWordCount: 0,626};627}628629this._register(_model.onDidChange(() => {630// This is set when the response is loading, but the model can change later for other reasons631if (this._contentUpdateTimings) {632const now = Date.now();633const wordCount = countWords(_model.entireResponse.getMarkdown());634635if (wordCount === this._contentUpdateTimings.lastWordCount) {636this.trace('onDidChange', `Update- no new words`);637} else {638if (this._contentUpdateTimings.lastWordCount === 0) {639this._contentUpdateTimings.lastUpdateTime = now;640}641642const timeDiff = Math.min(now - this._contentUpdateTimings.lastUpdateTime, 500);643const newTotalTime = Math.max(this._contentUpdateTimings.totalTime + timeDiff, 250);644const impliedWordLoadRate = wordCount / (newTotalTime / 1000);645this.trace('onDidChange', `Update- got ${wordCount} words over last ${newTotalTime}ms = ${impliedWordLoadRate} words/s`);646this._contentUpdateTimings = {647totalTime: this._contentUpdateTimings.totalTime !== 0 || this.response.value.some(v => v.kind === 'markdownContent') ?648newTotalTime :649this._contentUpdateTimings.totalTime,650lastUpdateTime: now,651impliedWordLoadRate,652lastWordCount: wordCount653};654}655}656657// new data -> new id, new content to render658this._modelChangeCount++;659660this._onDidChange.fire();661}));662}663664private trace(tag: string, message: string) {665this.logService.trace(`ChatResponseViewModel#${tag}: ${message}`);666}667668setVote(vote: ChatAgentVoteDirection): void {669this._modelChangeCount++;670this._model.setVote(vote);671}672673setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void {674this._modelChangeCount++;675this._model.setVoteDownReason(reason);676}677678setEditApplied(edit: IChatTextEditGroup, editCount: number) {679this._modelChangeCount++;680this._model.setEditApplied(edit, editCount);681}682}683684685