Path: blob/main/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts
5240 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 { IMarkdownString } from '../../../../../base/common/htmlContent.js';8import { Disposable, dispose } from '../../../../../base/common/lifecycle.js';9import { IObservable } from '../../../../../base/common/observable.js';10import { ThemeIcon } from '../../../../../base/common/themables.js';11import { URI } from '../../../../../base/common/uri.js';12import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';13import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js';14import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js';15import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js';16import { IParsedChatRequest } from '../requestParser/chatParserTypes.js';17import { CodeBlockModelCollection } from '../widget/codeBlockModelCollection.js';18import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js';19import { ChatStreamStatsTracker, IChatStreamStats } from './chatStreamStats.js';20import { countWords } from './chatWordCounter.js';2122export function isRequestVM(item: unknown): item is IChatRequestViewModel {23return !!item && typeof item === 'object' && 'message' in item;24}2526export function isResponseVM(item: unknown): item is IChatResponseViewModel {27return !!item && typeof (item as IChatResponseViewModel).setVote !== 'undefined';28}2930export function isPendingDividerVM(item: unknown): item is IChatPendingDividerViewModel {31return !!item && typeof item === 'object' && (item as IChatPendingDividerViewModel).kind === 'pendingDivider';32}3334export function isChatTreeItem(item: unknown): item is IChatRequestViewModel | IChatResponseViewModel {35return isRequestVM(item) || isResponseVM(item);36}3738export function assertIsResponseVM(item: unknown): asserts item is IChatResponseViewModel {39if (!isResponseVM(item)) {40throw new Error('Expected item to be IChatResponseViewModel');41}42}4344export type IChatViewModelChangeEvent = IChatAddRequestEvent | IChangePlaceholderEvent | IChatSessionInitEvent | IChatSetHiddenEvent | null;4546export interface IChatAddRequestEvent {47kind: 'addRequest';48}4950export interface IChangePlaceholderEvent {51kind: 'changePlaceholder';52}5354export interface IChatSessionInitEvent {55kind: 'initialize';56}5758export interface IChatSetHiddenEvent {59kind: 'setHidden';60}6162export interface IChatViewModel {63readonly model: IChatModel;64readonly sessionResource: URI;65readonly onDidDisposeModel: Event<void>;66readonly onDidChange: Event<IChatViewModelChangeEvent>;67readonly inputPlaceholder?: string;68getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[];69setInputPlaceholder(text: string): void;70resetInputPlaceholder(): void;71editing?: IChatRequestViewModel;72setEditing(editing: IChatRequestViewModel): void;73}7475export interface IChatRequestViewModel {76readonly id: string;77readonly sessionResource: URI;78/** This ID updates every time the underlying data changes */79readonly dataId: string;80readonly username: string;81readonly avatarIcon?: URI | ThemeIcon;82readonly message: IParsedChatRequest | IChatFollowup;83readonly messageText: string;84readonly attempt: number;85readonly variables: readonly IChatRequestVariableEntry[];86currentRenderedHeight: number | undefined;87readonly contentReferences?: ReadonlyArray<IChatContentReference>;88readonly confirmation?: string;89readonly shouldBeRemovedOnSend: IChatRequestDisablement | undefined;90readonly isComplete: boolean;91readonly isCompleteAddedRequest: boolean;92readonly slashCommand: IChatAgentCommand | undefined;93readonly agentOrSlashCommandDetected: boolean;94readonly shouldBeBlocked: IObservable<boolean>;95readonly modelId?: string;96readonly timestamp: number;97/** The kind of pending request, or undefined if not pending */98readonly pendingKind?: ChatRequestQueueKind;99}100101export interface IChatResponseMarkdownRenderData {102renderedWordCount: number;103lastRenderTime: number;104isFullyRendered: boolean;105originalMarkdown: IMarkdownString;106}107108export interface IChatResponseMarkdownRenderData2 {109renderedWordCount: number;110lastRenderTime: number;111isFullyRendered: boolean;112originalMarkdown: IMarkdownString;113}114115export interface IChatProgressMessageRenderData {116progressMessage: IChatProgressMessage;117118/**119* Indicates whether this is part of a group of progress messages that are at the end of the response.120* (Not whether this particular item is the very last one in the response).121* Need to re-render and add to partsToRender when this changes.122*/123isAtEndOfResponse: boolean;124125/**126* Whether this progress message the very last item in the response.127* Need to re-render to update spinner vs check when this changes.128*/129isLast: boolean;130}131132export interface IChatTaskRenderData {133task: IChatTask;134isSettled: boolean;135progressLength: number;136}137138export interface IChatResponseRenderData {139renderedParts: IChatRendererContent[];140141renderedWordCount: number;142lastRenderTime: number;143}144145/**146* Content type for references used during rendering, not in the model147*/148export interface IChatReferences {149references: ReadonlyArray<IChatContentReference>;150kind: 'references';151}152153/**154* Content type for the "Working" progress message155*/156export interface IChatWorkingProgress {157kind: 'working';158}159160161/**162* Content type for citations used during rendering, not in the model163*/164export interface IChatCodeCitations {165citations: ReadonlyArray<IChatCodeCitation>;166kind: 'codeCitations';167}168169export interface IChatErrorDetailsPart {170kind: 'errorDetails';171errorDetails: IChatResponseErrorDetails;172isLast: boolean;173}174175export interface IChatChangesSummaryPart {176readonly kind: 'changesSummary';177readonly requestId: string;178readonly sessionResource: URI;179}180181/**182* Type for content parts rendered by IChatListRenderer (not necessarily in the model)183*/184export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations | IChatErrorDetailsPart | IChatChangesSummaryPart | IChatWorkingProgress | IChatMcpServersStarting | IChatQuestionCarousel;185186export interface IChatResponseViewModel {187readonly model: IChatResponseModel;188readonly id: string;189readonly session: IChatViewModel;190readonly sessionResource: URI;191/** This ID updates every time the underlying data changes */192readonly dataId: string;193/** The ID of the associated IChatRequestViewModel */194readonly requestId: string;195readonly username: string;196readonly agent?: IChatAgentData;197readonly slashCommand?: IChatAgentCommand;198readonly agentOrSlashCommandDetected: boolean;199readonly response: IResponse;200readonly usedContext: IChatUsedContext | undefined;201readonly contentReferences: ReadonlyArray<IChatContentReference>;202readonly codeCitations: ReadonlyArray<IChatCodeCitation>;203readonly progressMessages: ReadonlyArray<IChatProgressMessage>;204readonly isComplete: boolean;205readonly isCanceled: boolean;206readonly isStale: boolean;207readonly vote: ChatAgentVoteDirection | undefined;208readonly voteDownReason: ChatAgentVoteDownReason | undefined;209readonly replyFollowups?: IChatFollowup[];210readonly errorDetails?: IChatResponseErrorDetails;211readonly result?: IChatAgentResult;212readonly contentUpdateTimings?: IChatStreamStats;213readonly shouldBeRemovedOnSend: IChatRequestDisablement | undefined;214readonly isCompleteAddedRequest: boolean;215renderData?: IChatResponseRenderData;216currentRenderedHeight: number | undefined;217setVote(vote: ChatAgentVoteDirection): void;218setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void;219usedReferencesExpanded?: boolean;220vulnerabilitiesListExpanded: boolean;221setEditApplied(edit: IChatTextEditGroup, editCount: number): void;222readonly shouldBeBlocked: IObservable<boolean>;223}224225export interface IChatPendingDividerViewModel {226readonly kind: 'pendingDivider';227readonly id: string; // e.g., 'pending-divider-steering' or 'pending-divider-queued'228readonly sessionResource: URI;229readonly isComplete: true;230readonly dividerKind: ChatRequestQueueKind;231currentRenderedHeight: number | undefined;232}233234export interface IChatViewModelOptions {235/**236* Maximum number of items to return from getItems().237* When set, only the last N items are returned (most recent request/response pairs).238*/239readonly maxVisibleItems?: number;240}241242export class ChatViewModel extends Disposable implements IChatViewModel {243244private readonly _onDidDisposeModel = this._register(new Emitter<void>());245readonly onDidDisposeModel = this._onDidDisposeModel.event;246247private readonly _onDidChange = this._register(new Emitter<IChatViewModelChangeEvent>());248readonly onDidChange = this._onDidChange.event;249250private readonly _items: (ChatRequestViewModel | ChatResponseViewModel)[] = [];251252private _inputPlaceholder: string | undefined = undefined;253get inputPlaceholder(): string | undefined {254return this._inputPlaceholder;255}256257get model(): IChatModel {258return this._model;259}260261setInputPlaceholder(text: string): void {262this._inputPlaceholder = text;263this._onDidChange.fire({ kind: 'changePlaceholder' });264}265266resetInputPlaceholder(): void {267this._inputPlaceholder = undefined;268this._onDidChange.fire({ kind: 'changePlaceholder' });269}270271get sessionResource(): URI {272return this._model.sessionResource;273}274275constructor(276private readonly _model: IChatModel,277public readonly codeBlockModelCollection: CodeBlockModelCollection,278private readonly _options: IChatViewModelOptions | undefined,279@IInstantiationService private readonly instantiationService: IInstantiationService,280) {281super();282283_model.getRequests().forEach((request, i) => {284const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, request);285this._items.push(requestModel);286287if (request.response) {288this.onAddResponse(request.response);289}290});291292this._register(_model.onDidDispose(() => this._onDidDisposeModel.fire()));293this._register(_model.onDidChangePendingRequests(() => this._onDidChange.fire(null)));294this._register(_model.onDidChange(e => {295if (e.kind === 'addRequest') {296const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request);297this._items.push(requestModel);298299if (e.request.response) {300this.onAddResponse(e.request.response);301}302} else if (e.kind === 'addResponse') {303this.onAddResponse(e.response);304} else if (e.kind === 'removeRequest') {305const requestIdx = this._items.findIndex(item => isRequestVM(item) && item.id === e.requestId);306if (requestIdx >= 0) {307this._items.splice(requestIdx, 1);308}309310const responseIdx = e.responseId && this._items.findIndex(item => isResponseVM(item) && item.id === e.responseId);311if (typeof responseIdx === 'number' && responseIdx >= 0) {312const items = this._items.splice(responseIdx, 1);313const item = items[0];314if (item instanceof ChatResponseViewModel) {315item.dispose();316}317}318}319320const modelEventToVmEvent: IChatViewModelChangeEvent =321e.kind === 'addRequest' ? { kind: 'addRequest' }322: e.kind === 'initialize' ? { kind: 'initialize' }323: e.kind === 'setHidden' ? { kind: 'setHidden' }324: null;325this._onDidChange.fire(modelEventToVmEvent);326}));327}328329private onAddResponse(responseModel: IChatResponseModel) {330const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel, this);331this._register(response.onDidChange(() => {332return this._onDidChange.fire(null);333}));334this._items.push(response);335}336337getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] {338let items: (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] = this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop);339if (this._options?.maxVisibleItems !== undefined && items.length > this._options.maxVisibleItems) {340items = items.slice(-this._options.maxVisibleItems);341}342343const pendingRequests = this._model.getPendingRequests();344if (pendingRequests.length > 0) {345// Separate steering and queued requests346const steeringRequests = pendingRequests.filter(p => p.kind === ChatRequestQueueKind.Steering);347const queuedRequests = pendingRequests.filter(p => p.kind === ChatRequestQueueKind.Queued);348349// Add steering requests with their divider first350if (steeringRequests.length > 0) {351items.push({ kind: 'pendingDivider', id: 'pending-divider-steering', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Steering, currentRenderedHeight: undefined });352for (const pending of steeringRequests) {353const requestVM = this.instantiationService.createInstance(ChatRequestViewModel, pending.request, pending.kind);354items.push(requestVM);355}356}357358// Add queued requests with their divider359if (queuedRequests.length > 0) {360items.push({ kind: 'pendingDivider', id: 'pending-divider-queued', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Queued, currentRenderedHeight: undefined });361for (const pending of queuedRequests) {362const requestVM = this.instantiationService.createInstance(ChatRequestViewModel, pending.request, pending.kind);363items.push(requestVM);364}365}366}367368return items;369}370371372private _editing: IChatRequestViewModel | undefined = undefined;373get editing(): IChatRequestViewModel | undefined {374return this._editing;375}376377setEditing(editing: IChatRequestViewModel | undefined): void {378if (this.editing && editing && this.editing.id === editing.id) {379return; // already editing this request380}381382this._editing = editing;383}384385override dispose() {386super.dispose();387dispose(this._items.filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel));388}389}390391export class ChatRequestViewModel implements IChatRequestViewModel {392get id() {393return this._model.id;394}395396/**397* An ID that changes when the request should be re-rendered.398*/399get dataId() {400return `${this.id}_${this._model.version + (this._model.response?.isComplete ? 1 : 0)}`;401}402403get sessionResource() {404return this._model.session.sessionResource;405}406407get username() {408return 'User';409}410411get avatarIcon(): ThemeIcon {412return Codicon.account;413}414415get message() {416return this._model.message;417}418419get messageText() {420return this.message.text;421}422423get attempt() {424return this._model.attempt;425}426427get variables() {428return this._model.variableData.variables;429}430431get contentReferences() {432return this._model.response?.contentReferences;433}434435get confirmation() {436return this._model.confirmation;437}438439get isComplete() {440return this._model.response?.isComplete ?? false;441}442443get isCompleteAddedRequest() {444return this._model.isCompleteAddedRequest;445}446447get shouldBeRemovedOnSend() {448return this._model.shouldBeRemovedOnSend;449}450451get shouldBeBlocked() {452return this._model.shouldBeBlocked;453}454455get slashCommand(): IChatAgentCommand | undefined {456return this._model.response?.slashCommand;457}458459get agentOrSlashCommandDetected(): boolean {460return this._model.response?.agentOrSlashCommandDetected ?? false;461}462463currentRenderedHeight: number | undefined;464465get modelId() {466return this._model.modelId;467}468469get timestamp() {470return this._model.timestamp;471}472473get pendingKind() {474return this._pendingKind;475}476477constructor(478private readonly _model: IChatRequestModel,479private readonly _pendingKind?: ChatRequestQueueKind,480) { }481}482483export class ChatResponseViewModel extends Disposable implements IChatResponseViewModel {484private _modelChangeCount = 0;485486private readonly _onDidChange = this._register(new Emitter<void>());487readonly onDidChange = this._onDidChange.event;488489get model() {490return this._model;491}492493get id() {494return this._model.id;495}496497get dataId() {498return this._model.id +499`_${this._modelChangeCount}` +500(this.isLast ? '_last' : '');501}502503get sessionResource(): URI {504return this._model.session.sessionResource;505}506507get username() {508if (this.agent) {509const isAllowed = this.chatAgentNameService.getAgentNameRestriction(this.agent);510if (isAllowed) {511return this.agent.fullName || this.agent.name;512} else {513return getFullyQualifiedId(this.agent);514}515}516517return this._model.username;518}519520get agent() {521return this._model.agent;522}523524get slashCommand() {525return this._model.slashCommand;526}527528get agentOrSlashCommandDetected() {529return this._model.agentOrSlashCommandDetected;530}531532get response(): IResponse {533return this._model.response;534}535536get usedContext(): IChatUsedContext | undefined {537return this._model.usedContext;538}539540get contentReferences(): ReadonlyArray<IChatContentReference> {541return this._model.contentReferences;542}543544get codeCitations(): ReadonlyArray<IChatCodeCitation> {545return this._model.codeCitations;546}547548get progressMessages(): ReadonlyArray<IChatProgressMessage> {549return this._model.progressMessages;550}551552get isComplete() {553return this._model.isComplete;554}555556get isCanceled() {557return this._model.isCanceled;558}559560get shouldBeBlocked() {561return this._model.shouldBeBlocked;562}563564get shouldBeRemovedOnSend() {565return this._model.shouldBeRemovedOnSend;566}567568get isCompleteAddedRequest() {569return this._model.isCompleteAddedRequest;570}571572get replyFollowups() {573return this._model.followups?.filter((f): f is IChatFollowup => f.kind === 'reply');574}575576get result() {577return this._model.result;578}579580get errorDetails(): IChatResponseErrorDetails | undefined {581return this.result?.errorDetails;582}583584get vote() {585return this._model.vote;586}587588get voteDownReason() {589return this._model.voteDownReason;590}591592get requestId() {593return this._model.requestId;594}595596get isStale() {597return this._model.isStale;598}599600get isLast(): boolean {601return this.session.getItems().at(-1) === this;602}603604renderData: IChatResponseRenderData | undefined = undefined;605currentRenderedHeight: number | undefined;606607private _usedReferencesExpanded: boolean | undefined;608get usedReferencesExpanded(): boolean | undefined {609if (typeof this._usedReferencesExpanded === 'boolean') {610return this._usedReferencesExpanded;611}612613return undefined;614}615616set usedReferencesExpanded(v: boolean) {617this._usedReferencesExpanded = v;618}619620private _vulnerabilitiesListExpanded: boolean = false;621get vulnerabilitiesListExpanded(): boolean {622return this._vulnerabilitiesListExpanded;623}624625set vulnerabilitiesListExpanded(v: boolean) {626this._vulnerabilitiesListExpanded = v;627}628629private readonly liveUpdateTracker: ChatStreamStatsTracker | undefined;630631get contentUpdateTimings(): IChatStreamStats | undefined {632return this.liveUpdateTracker?.data;633}634635constructor(636private readonly _model: IChatResponseModel,637public readonly session: IChatViewModel,638@IInstantiationService private readonly instantiationService: IInstantiationService,639@IChatAgentNameService private readonly chatAgentNameService: IChatAgentNameService,640) {641super();642643if (!_model.isComplete) {644this.liveUpdateTracker = this.instantiationService.createInstance(ChatStreamStatsTracker);645}646647this._register(_model.onDidChange(() => {648if (this.liveUpdateTracker) {649const wordCount = countWords(_model.entireResponse.getMarkdown());650this.liveUpdateTracker.update({ totalWordCount: wordCount });651}652653// new data -> new id, new content to render654this._modelChangeCount++;655656this._onDidChange.fire();657}));658}659660setVote(vote: ChatAgentVoteDirection): void {661this._modelChangeCount++;662this._model.setVote(vote);663}664665setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void {666this._modelChangeCount++;667this._model.setVoteDownReason(reason);668}669670setEditApplied(edit: IChatTextEditGroup, editCount: number) {671this._modelChangeCount++;672this._model.setEditApplied(edit, editCount);673}674}675676677