Path: blob/main/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts
4780 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 { Codicon } from '../../../../../base/common/codicons.js';6import { Emitter, Event } from '../../../../../base/common/event.js';7import { hash } from '../../../../../base/common/hash.js';8import { IMarkdownString } from '../../../../../base/common/htmlContent.js';9import { Disposable, dispose } from '../../../../../base/common/lifecycle.js';10import * as marked from '../../../../../base/common/marked/marked.js';11import { IObservable } from '../../../../../base/common/observable.js';12import { ThemeIcon } from '../../../../../base/common/themables.js';13import { URI } from '../../../../../base/common/uri.js';14import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';15import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js';16import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js';17import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js';18import { IParsedChatRequest } from '../requestParser/chatParserTypes.js';19import { annotateVulnerabilitiesInText } from '../widget/annotations.js';20import { CodeBlockModelCollection } from '../widget/codeBlockModelCollection.js';21import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js';22import { ChatStreamStatsTracker, IChatStreamStats } from './chatStreamStats.js';23import { countWords } from './chatWordCounter.js';2425export function isRequestVM(item: unknown): item is IChatRequestViewModel {26return !!item && typeof item === 'object' && 'message' in item;27}2829export function isResponseVM(item: unknown): item is IChatResponseViewModel {30return !!item && typeof (item as IChatResponseViewModel).setVote !== 'undefined';31}3233export function isChatTreeItem(item: unknown): item is IChatRequestViewModel | IChatResponseViewModel {34return isRequestVM(item) || isResponseVM(item);35}3637export function assertIsResponseVM(item: unknown): asserts item is IChatResponseViewModel {38if (!isResponseVM(item)) {39throw new Error('Expected item to be IChatResponseViewModel');40}41}4243export type IChatViewModelChangeEvent = IChatAddRequestEvent | IChangePlaceholderEvent | IChatSessionInitEvent | IChatSetHiddenEvent | IChatSetCheckpointEvent | null;4445export interface IChatAddRequestEvent {46kind: 'addRequest';47}4849export interface IChangePlaceholderEvent {50kind: 'changePlaceholder';51}5253export interface IChatSessionInitEvent {54kind: 'initialize';55}5657export interface IChatSetHiddenEvent {58kind: 'setHidden';59}6061export interface IChatSetCheckpointEvent {62kind: 'setCheckpoint';63}6465export interface IChatViewModel {66readonly model: IChatModel;67readonly sessionResource: URI;68readonly onDidDisposeModel: Event<void>;69readonly onDidChange: Event<IChatViewModelChangeEvent>;70readonly inputPlaceholder?: string;71getItems(): (IChatRequestViewModel | IChatResponseViewModel)[];72setInputPlaceholder(text: string): void;73resetInputPlaceholder(): void;74editing?: IChatRequestViewModel;75setEditing(editing: IChatRequestViewModel): void;76}7778export interface IChatRequestViewModel {79readonly id: string;80/** @deprecated */81readonly sessionId: string;82readonly sessionResource: URI;83/** This ID updates every time the underlying data changes */84readonly dataId: string;85readonly username: string;86readonly avatarIcon?: URI | ThemeIcon;87readonly message: IParsedChatRequest | IChatFollowup;88readonly messageText: string;89readonly attempt: number;90readonly variables: readonly IChatRequestVariableEntry[];91currentRenderedHeight: number | undefined;92readonly contentReferences?: ReadonlyArray<IChatContentReference>;93readonly confirmation?: string;94readonly shouldBeRemovedOnSend: IChatRequestDisablement | undefined;95readonly isComplete: boolean;96readonly isCompleteAddedRequest: boolean;97readonly slashCommand: IChatAgentCommand | undefined;98readonly agentOrSlashCommandDetected: boolean;99readonly shouldBeBlocked: IObservable<boolean>;100readonly modelId?: string;101}102103export interface IChatResponseMarkdownRenderData {104renderedWordCount: number;105lastRenderTime: number;106isFullyRendered: boolean;107originalMarkdown: IMarkdownString;108}109110export interface IChatResponseMarkdownRenderData2 {111renderedWordCount: number;112lastRenderTime: number;113isFullyRendered: boolean;114originalMarkdown: IMarkdownString;115}116117export interface IChatProgressMessageRenderData {118progressMessage: IChatProgressMessage;119120/**121* Indicates whether this is part of a group of progress messages that are at the end of the response.122* (Not whether this particular item is the very last one in the response).123* Need to re-render and add to partsToRender when this changes.124*/125isAtEndOfResponse: boolean;126127/**128* Whether this progress message the very last item in the response.129* Need to re-render to update spinner vs check when this changes.130*/131isLast: boolean;132}133134export interface IChatTaskRenderData {135task: IChatTask;136isSettled: boolean;137progressLength: number;138}139140export interface IChatResponseRenderData {141renderedParts: IChatRendererContent[];142143renderedWordCount: number;144lastRenderTime: number;145}146147/**148* Content type for references used during rendering, not in the model149*/150export interface IChatReferences {151references: ReadonlyArray<IChatContentReference>;152kind: 'references';153}154155/**156* Content type for the "Working" progress message157*/158export interface IChatWorkingProgress {159kind: 'working';160}161162163/**164* Content type for citations used during rendering, not in the model165*/166export interface IChatCodeCitations {167citations: ReadonlyArray<IChatCodeCitation>;168kind: 'codeCitations';169}170171export interface IChatErrorDetailsPart {172kind: 'errorDetails';173errorDetails: IChatResponseErrorDetails;174isLast: boolean;175}176177export interface IChatChangesSummaryPart {178readonly kind: 'changesSummary';179readonly requestId: string;180readonly sessionResource: URI;181}182183/**184* Type for content parts rendered by IChatListRenderer (not necessarily in the model)185*/186export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations | IChatErrorDetailsPart | IChatChangesSummaryPart | IChatWorkingProgress | IChatMcpServersStarting;187188export interface IChatResponseViewModel {189readonly model: IChatResponseModel;190readonly id: string;191readonly session: IChatViewModel;192/** @deprecated */193readonly sessionId: string;194readonly sessionResource: URI;195/** This ID updates every time the underlying data changes */196readonly dataId: string;197/** The ID of the associated IChatRequestViewModel */198readonly requestId: string;199readonly username: string;200readonly avatarIcon?: URI | ThemeIcon;201readonly agent?: IChatAgentData;202readonly slashCommand?: IChatAgentCommand;203readonly agentOrSlashCommandDetected: boolean;204readonly response: IResponse;205readonly usedContext: IChatUsedContext | undefined;206readonly contentReferences: ReadonlyArray<IChatContentReference>;207readonly codeCitations: ReadonlyArray<IChatCodeCitation>;208readonly progressMessages: ReadonlyArray<IChatProgressMessage>;209readonly isComplete: boolean;210readonly isCanceled: boolean;211readonly isStale: boolean;212readonly vote: ChatAgentVoteDirection | undefined;213readonly voteDownReason: ChatAgentVoteDownReason | undefined;214readonly replyFollowups?: IChatFollowup[];215readonly errorDetails?: IChatResponseErrorDetails;216readonly result?: IChatAgentResult;217readonly contentUpdateTimings?: IChatStreamStats;218readonly shouldBeRemovedOnSend: IChatRequestDisablement | undefined;219readonly isCompleteAddedRequest: boolean;220renderData?: IChatResponseRenderData;221currentRenderedHeight: number | undefined;222setVote(vote: ChatAgentVoteDirection): void;223setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void;224usedReferencesExpanded?: boolean;225vulnerabilitiesListExpanded: boolean;226setEditApplied(edit: IChatTextEditGroup, editCount: number): void;227readonly shouldBeBlocked: IObservable<boolean>;228}229230export class ChatViewModel extends Disposable implements IChatViewModel {231232private readonly _onDidDisposeModel = this._register(new Emitter<void>());233readonly onDidDisposeModel = this._onDidDisposeModel.event;234235private readonly _onDidChange = this._register(new Emitter<IChatViewModelChangeEvent>());236readonly onDidChange = this._onDidChange.event;237238private readonly _items: (ChatRequestViewModel | ChatResponseViewModel)[] = [];239240private _inputPlaceholder: string | undefined = undefined;241get inputPlaceholder(): string | undefined {242return this._inputPlaceholder;243}244245get model(): IChatModel {246return this._model;247}248249setInputPlaceholder(text: string): void {250this._inputPlaceholder = text;251this._onDidChange.fire({ kind: 'changePlaceholder' });252}253254resetInputPlaceholder(): void {255this._inputPlaceholder = undefined;256this._onDidChange.fire({ kind: 'changePlaceholder' });257}258259get sessionResource(): URI {260return this._model.sessionResource;261}262263constructor(264private readonly _model: IChatModel,265public readonly codeBlockModelCollection: CodeBlockModelCollection,266@IInstantiationService private readonly instantiationService: IInstantiationService,267) {268super();269270_model.getRequests().forEach((request, i) => {271const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, request);272this._items.push(requestModel);273this.updateCodeBlockTextModels(requestModel);274275if (request.response) {276this.onAddResponse(request.response);277}278});279280this._register(_model.onDidDispose(() => this._onDidDisposeModel.fire()));281this._register(_model.onDidChange(e => {282if (e.kind === 'addRequest') {283const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request);284this._items.push(requestModel);285this.updateCodeBlockTextModels(requestModel);286287if (e.request.response) {288this.onAddResponse(e.request.response);289}290} else if (e.kind === 'addResponse') {291this.onAddResponse(e.response);292} else if (e.kind === 'removeRequest') {293const requestIdx = this._items.findIndex(item => isRequestVM(item) && item.id === e.requestId);294if (requestIdx >= 0) {295this._items.splice(requestIdx, 1);296}297298const responseIdx = e.responseId && this._items.findIndex(item => isResponseVM(item) && item.id === e.responseId);299if (typeof responseIdx === 'number' && responseIdx >= 0) {300const items = this._items.splice(responseIdx, 1);301const item = items[0];302if (item instanceof ChatResponseViewModel) {303item.dispose();304}305}306}307308const modelEventToVmEvent: IChatViewModelChangeEvent =309e.kind === 'addRequest' ? { kind: 'addRequest' }310: e.kind === 'initialize' ? { kind: 'initialize' }311: e.kind === 'setHidden' ? { kind: 'setHidden' }312: null;313this._onDidChange.fire(modelEventToVmEvent);314}));315}316317private onAddResponse(responseModel: IChatResponseModel) {318const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel, this);319this._register(response.onDidChange(() => {320if (response.isComplete) {321this.updateCodeBlockTextModels(response);322}323return this._onDidChange.fire(null);324}));325this._items.push(response);326this.updateCodeBlockTextModels(response);327}328329getItems(): (IChatRequestViewModel | IChatResponseViewModel)[] {330return this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop);331}332333334private _editing: IChatRequestViewModel | undefined = undefined;335get editing(): IChatRequestViewModel | undefined {336return this._editing;337}338339setEditing(editing: IChatRequestViewModel | undefined): void {340if (this.editing && editing && this.editing.id === editing.id) {341return; // already editing this request342}343344this._editing = editing;345}346347override dispose() {348super.dispose();349dispose(this._items.filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel));350}351352updateCodeBlockTextModels(model: IChatRequestViewModel | IChatResponseViewModel) {353let content: string;354if (isRequestVM(model)) {355content = model.messageText;356} else {357content = annotateVulnerabilitiesInText(model.response.value).map(x => x.content.value).join('');358}359360let codeBlockIndex = 0;361marked.walkTokens(marked.lexer(content), token => {362if (token.type === 'code') {363const lang = token.lang || '';364const text = token.text;365this.codeBlockModelCollection.update(this._model.sessionResource, model, codeBlockIndex++, { text, languageId: lang, isComplete: true });366}367});368}369}370371const variablesHash = new WeakMap<readonly IChatRequestVariableEntry[], number>();372373export class ChatRequestViewModel implements IChatRequestViewModel {374get id() {375return this._model.id;376}377378get dataId() {379let varsHash = variablesHash.get(this.variables);380if (typeof varsHash !== 'number') {381varsHash = hash(this.variables);382variablesHash.set(this.variables, varsHash);383}384385return `${this.id}_${this.isComplete ? '1' : '0'}_${varsHash}`;386}387388/** @deprecated */389get sessionId() {390return this._model.session.sessionId;391}392393get sessionResource() {394return this._model.session.sessionResource;395}396397get username() {398return 'User';399}400401get avatarIcon(): ThemeIcon {402return Codicon.account;403}404405get message() {406return this._model.message;407}408409get messageText() {410return this.message.text;411}412413get attempt() {414return this._model.attempt;415}416417get variables() {418return this._model.variableData.variables;419}420421get contentReferences() {422return this._model.response?.contentReferences;423}424425get confirmation() {426return this._model.confirmation;427}428429get isComplete() {430return this._model.response?.isComplete ?? false;431}432433get isCompleteAddedRequest() {434return this._model.isCompleteAddedRequest;435}436437get shouldBeRemovedOnSend() {438return this._model.shouldBeRemovedOnSend;439}440441get shouldBeBlocked() {442return this._model.shouldBeBlocked;443}444445get slashCommand(): IChatAgentCommand | undefined {446return this._model.response?.slashCommand;447}448449get agentOrSlashCommandDetected(): boolean {450return this._model.response?.agentOrSlashCommandDetected ?? false;451}452453currentRenderedHeight: number | undefined;454455get modelId() {456return this._model.modelId;457}458459constructor(460private readonly _model: IChatRequestModel,461) { }462}463464export class ChatResponseViewModel extends Disposable implements IChatResponseViewModel {465private _modelChangeCount = 0;466467private readonly _onDidChange = this._register(new Emitter<void>());468readonly onDidChange = this._onDidChange.event;469470get model() {471return this._model;472}473474get id() {475return this._model.id;476}477478get dataId() {479return this._model.id +480`_${this._modelChangeCount}` +481(this.isLast ? '_last' : '');482}483484/** @deprecated */485get sessionId() {486return this._model.session.sessionId;487}488489get sessionResource(): URI {490return this._model.session.sessionResource;491}492493get username() {494if (this.agent) {495const isAllowed = this.chatAgentNameService.getAgentNameRestriction(this.agent);496if (isAllowed) {497return this.agent.fullName || this.agent.name;498} else {499return getFullyQualifiedId(this.agent);500}501}502503return this._model.username;504}505506get avatarIcon() {507return this._model.avatarIcon;508}509510get agent() {511return this._model.agent;512}513514get slashCommand() {515return this._model.slashCommand;516}517518get agentOrSlashCommandDetected() {519return this._model.agentOrSlashCommandDetected;520}521522get response(): IResponse {523return this._model.response;524}525526get usedContext(): IChatUsedContext | undefined {527return this._model.usedContext;528}529530get contentReferences(): ReadonlyArray<IChatContentReference> {531return this._model.contentReferences;532}533534get codeCitations(): ReadonlyArray<IChatCodeCitation> {535return this._model.codeCitations;536}537538get progressMessages(): ReadonlyArray<IChatProgressMessage> {539return this._model.progressMessages;540}541542get isComplete() {543return this._model.isComplete;544}545546get isCanceled() {547return this._model.isCanceled;548}549550get shouldBeBlocked() {551return this._model.shouldBeBlocked;552}553554get shouldBeRemovedOnSend() {555return this._model.shouldBeRemovedOnSend;556}557558get isCompleteAddedRequest() {559return this._model.isCompleteAddedRequest;560}561562get replyFollowups() {563return this._model.followups?.filter((f): f is IChatFollowup => f.kind === 'reply');564}565566get result() {567return this._model.result;568}569570get errorDetails(): IChatResponseErrorDetails | undefined {571return this.result?.errorDetails;572}573574get vote() {575return this._model.vote;576}577578get voteDownReason() {579return this._model.voteDownReason;580}581582get requestId() {583return this._model.requestId;584}585586get isStale() {587return this._model.isStale;588}589590get isLast(): boolean {591return this.session.getItems().at(-1) === this;592}593594renderData: IChatResponseRenderData | undefined = undefined;595currentRenderedHeight: number | undefined;596597private _usedReferencesExpanded: boolean | undefined;598get usedReferencesExpanded(): boolean | undefined {599if (typeof this._usedReferencesExpanded === 'boolean') {600return this._usedReferencesExpanded;601}602603return undefined;604}605606set usedReferencesExpanded(v: boolean) {607this._usedReferencesExpanded = v;608}609610private _vulnerabilitiesListExpanded: boolean = false;611get vulnerabilitiesListExpanded(): boolean {612return this._vulnerabilitiesListExpanded;613}614615set vulnerabilitiesListExpanded(v: boolean) {616this._vulnerabilitiesListExpanded = v;617}618619private readonly liveUpdateTracker: ChatStreamStatsTracker | undefined;620621get contentUpdateTimings(): IChatStreamStats | undefined {622return this.liveUpdateTracker?.data;623}624625constructor(626private readonly _model: IChatResponseModel,627public readonly session: IChatViewModel,628@IInstantiationService private readonly instantiationService: IInstantiationService,629@IChatAgentNameService private readonly chatAgentNameService: IChatAgentNameService,630) {631super();632633if (!_model.isComplete) {634this.liveUpdateTracker = this.instantiationService.createInstance(ChatStreamStatsTracker);635}636637this._register(_model.onDidChange(() => {638if (this.liveUpdateTracker) {639const wordCount = countWords(_model.entireResponse.getMarkdown());640this.liveUpdateTracker.update({ totalWordCount: wordCount });641}642643// new data -> new id, new content to render644this._modelChangeCount++;645646this._onDidChange.fire();647}));648}649650setVote(vote: ChatAgentVoteDirection): void {651this._modelChangeCount++;652this._model.setVote(vote);653}654655setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void {656this._modelChangeCount++;657this._model.setVoteDownReason(reason);658}659660setEditApplied(edit: IChatTextEditGroup, editCount: number) {661this._modelChangeCount++;662this._model.setEditApplied(edit, editCount);663}664}665666667