Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts
5240 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Codicon } from '../../../../../base/common/codicons.js';
7
import { Emitter, Event } from '../../../../../base/common/event.js';
8
import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
9
import { Disposable, dispose } from '../../../../../base/common/lifecycle.js';
10
import { IObservable } from '../../../../../base/common/observable.js';
11
import { ThemeIcon } from '../../../../../base/common/themables.js';
12
import { URI } from '../../../../../base/common/uri.js';
13
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
14
import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js';
15
import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js';
16
import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js';
17
import { IParsedChatRequest } from '../requestParser/chatParserTypes.js';
18
import { CodeBlockModelCollection } from '../widget/codeBlockModelCollection.js';
19
import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js';
20
import { ChatStreamStatsTracker, IChatStreamStats } from './chatStreamStats.js';
21
import { countWords } from './chatWordCounter.js';
22
23
export function isRequestVM(item: unknown): item is IChatRequestViewModel {
24
return !!item && typeof item === 'object' && 'message' in item;
25
}
26
27
export function isResponseVM(item: unknown): item is IChatResponseViewModel {
28
return !!item && typeof (item as IChatResponseViewModel).setVote !== 'undefined';
29
}
30
31
export function isPendingDividerVM(item: unknown): item is IChatPendingDividerViewModel {
32
return !!item && typeof item === 'object' && (item as IChatPendingDividerViewModel).kind === 'pendingDivider';
33
}
34
35
export function isChatTreeItem(item: unknown): item is IChatRequestViewModel | IChatResponseViewModel {
36
return isRequestVM(item) || isResponseVM(item);
37
}
38
39
export function assertIsResponseVM(item: unknown): asserts item is IChatResponseViewModel {
40
if (!isResponseVM(item)) {
41
throw new Error('Expected item to be IChatResponseViewModel');
42
}
43
}
44
45
export type IChatViewModelChangeEvent = IChatAddRequestEvent | IChangePlaceholderEvent | IChatSessionInitEvent | IChatSetHiddenEvent | null;
46
47
export interface IChatAddRequestEvent {
48
kind: 'addRequest';
49
}
50
51
export interface IChangePlaceholderEvent {
52
kind: 'changePlaceholder';
53
}
54
55
export interface IChatSessionInitEvent {
56
kind: 'initialize';
57
}
58
59
export interface IChatSetHiddenEvent {
60
kind: 'setHidden';
61
}
62
63
export interface IChatViewModel {
64
readonly model: IChatModel;
65
readonly sessionResource: URI;
66
readonly onDidDisposeModel: Event<void>;
67
readonly onDidChange: Event<IChatViewModelChangeEvent>;
68
readonly inputPlaceholder?: string;
69
getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[];
70
setInputPlaceholder(text: string): void;
71
resetInputPlaceholder(): void;
72
editing?: IChatRequestViewModel;
73
setEditing(editing: IChatRequestViewModel): void;
74
}
75
76
export interface IChatRequestViewModel {
77
readonly id: string;
78
readonly sessionResource: URI;
79
/** This ID updates every time the underlying data changes */
80
readonly dataId: string;
81
readonly username: string;
82
readonly avatarIcon?: URI | ThemeIcon;
83
readonly message: IParsedChatRequest | IChatFollowup;
84
readonly messageText: string;
85
readonly attempt: number;
86
readonly variables: readonly IChatRequestVariableEntry[];
87
currentRenderedHeight: number | undefined;
88
readonly contentReferences?: ReadonlyArray<IChatContentReference>;
89
readonly confirmation?: string;
90
readonly shouldBeRemovedOnSend: IChatRequestDisablement | undefined;
91
readonly isComplete: boolean;
92
readonly isCompleteAddedRequest: boolean;
93
readonly slashCommand: IChatAgentCommand | undefined;
94
readonly agentOrSlashCommandDetected: boolean;
95
readonly shouldBeBlocked: IObservable<boolean>;
96
readonly modelId?: string;
97
readonly timestamp: number;
98
/** The kind of pending request, or undefined if not pending */
99
readonly pendingKind?: ChatRequestQueueKind;
100
}
101
102
export interface IChatResponseMarkdownRenderData {
103
renderedWordCount: number;
104
lastRenderTime: number;
105
isFullyRendered: boolean;
106
originalMarkdown: IMarkdownString;
107
}
108
109
export interface IChatResponseMarkdownRenderData2 {
110
renderedWordCount: number;
111
lastRenderTime: number;
112
isFullyRendered: boolean;
113
originalMarkdown: IMarkdownString;
114
}
115
116
export interface IChatProgressMessageRenderData {
117
progressMessage: IChatProgressMessage;
118
119
/**
120
* Indicates whether this is part of a group of progress messages that are at the end of the response.
121
* (Not whether this particular item is the very last one in the response).
122
* Need to re-render and add to partsToRender when this changes.
123
*/
124
isAtEndOfResponse: boolean;
125
126
/**
127
* Whether this progress message the very last item in the response.
128
* Need to re-render to update spinner vs check when this changes.
129
*/
130
isLast: boolean;
131
}
132
133
export interface IChatTaskRenderData {
134
task: IChatTask;
135
isSettled: boolean;
136
progressLength: number;
137
}
138
139
export interface IChatResponseRenderData {
140
renderedParts: IChatRendererContent[];
141
142
renderedWordCount: number;
143
lastRenderTime: number;
144
}
145
146
/**
147
* Content type for references used during rendering, not in the model
148
*/
149
export interface IChatReferences {
150
references: ReadonlyArray<IChatContentReference>;
151
kind: 'references';
152
}
153
154
/**
155
* Content type for the "Working" progress message
156
*/
157
export interface IChatWorkingProgress {
158
kind: 'working';
159
}
160
161
162
/**
163
* Content type for citations used during rendering, not in the model
164
*/
165
export interface IChatCodeCitations {
166
citations: ReadonlyArray<IChatCodeCitation>;
167
kind: 'codeCitations';
168
}
169
170
export interface IChatErrorDetailsPart {
171
kind: 'errorDetails';
172
errorDetails: IChatResponseErrorDetails;
173
isLast: boolean;
174
}
175
176
export interface IChatChangesSummaryPart {
177
readonly kind: 'changesSummary';
178
readonly requestId: string;
179
readonly sessionResource: URI;
180
}
181
182
/**
183
* Type for content parts rendered by IChatListRenderer (not necessarily in the model)
184
*/
185
export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations | IChatErrorDetailsPart | IChatChangesSummaryPart | IChatWorkingProgress | IChatMcpServersStarting | IChatQuestionCarousel;
186
187
export interface IChatResponseViewModel {
188
readonly model: IChatResponseModel;
189
readonly id: string;
190
readonly session: IChatViewModel;
191
readonly sessionResource: URI;
192
/** This ID updates every time the underlying data changes */
193
readonly dataId: string;
194
/** The ID of the associated IChatRequestViewModel */
195
readonly requestId: string;
196
readonly username: string;
197
readonly agent?: IChatAgentData;
198
readonly slashCommand?: IChatAgentCommand;
199
readonly agentOrSlashCommandDetected: boolean;
200
readonly response: IResponse;
201
readonly usedContext: IChatUsedContext | undefined;
202
readonly contentReferences: ReadonlyArray<IChatContentReference>;
203
readonly codeCitations: ReadonlyArray<IChatCodeCitation>;
204
readonly progressMessages: ReadonlyArray<IChatProgressMessage>;
205
readonly isComplete: boolean;
206
readonly isCanceled: boolean;
207
readonly isStale: boolean;
208
readonly vote: ChatAgentVoteDirection | undefined;
209
readonly voteDownReason: ChatAgentVoteDownReason | undefined;
210
readonly replyFollowups?: IChatFollowup[];
211
readonly errorDetails?: IChatResponseErrorDetails;
212
readonly result?: IChatAgentResult;
213
readonly contentUpdateTimings?: IChatStreamStats;
214
readonly shouldBeRemovedOnSend: IChatRequestDisablement | undefined;
215
readonly isCompleteAddedRequest: boolean;
216
renderData?: IChatResponseRenderData;
217
currentRenderedHeight: number | undefined;
218
setVote(vote: ChatAgentVoteDirection): void;
219
setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void;
220
usedReferencesExpanded?: boolean;
221
vulnerabilitiesListExpanded: boolean;
222
setEditApplied(edit: IChatTextEditGroup, editCount: number): void;
223
readonly shouldBeBlocked: IObservable<boolean>;
224
}
225
226
export interface IChatPendingDividerViewModel {
227
readonly kind: 'pendingDivider';
228
readonly id: string; // e.g., 'pending-divider-steering' or 'pending-divider-queued'
229
readonly sessionResource: URI;
230
readonly isComplete: true;
231
readonly dividerKind: ChatRequestQueueKind;
232
currentRenderedHeight: number | undefined;
233
}
234
235
export interface IChatViewModelOptions {
236
/**
237
* Maximum number of items to return from getItems().
238
* When set, only the last N items are returned (most recent request/response pairs).
239
*/
240
readonly maxVisibleItems?: number;
241
}
242
243
export class ChatViewModel extends Disposable implements IChatViewModel {
244
245
private readonly _onDidDisposeModel = this._register(new Emitter<void>());
246
readonly onDidDisposeModel = this._onDidDisposeModel.event;
247
248
private readonly _onDidChange = this._register(new Emitter<IChatViewModelChangeEvent>());
249
readonly onDidChange = this._onDidChange.event;
250
251
private readonly _items: (ChatRequestViewModel | ChatResponseViewModel)[] = [];
252
253
private _inputPlaceholder: string | undefined = undefined;
254
get inputPlaceholder(): string | undefined {
255
return this._inputPlaceholder;
256
}
257
258
get model(): IChatModel {
259
return this._model;
260
}
261
262
setInputPlaceholder(text: string): void {
263
this._inputPlaceholder = text;
264
this._onDidChange.fire({ kind: 'changePlaceholder' });
265
}
266
267
resetInputPlaceholder(): void {
268
this._inputPlaceholder = undefined;
269
this._onDidChange.fire({ kind: 'changePlaceholder' });
270
}
271
272
get sessionResource(): URI {
273
return this._model.sessionResource;
274
}
275
276
constructor(
277
private readonly _model: IChatModel,
278
public readonly codeBlockModelCollection: CodeBlockModelCollection,
279
private readonly _options: IChatViewModelOptions | undefined,
280
@IInstantiationService private readonly instantiationService: IInstantiationService,
281
) {
282
super();
283
284
_model.getRequests().forEach((request, i) => {
285
const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, request);
286
this._items.push(requestModel);
287
288
if (request.response) {
289
this.onAddResponse(request.response);
290
}
291
});
292
293
this._register(_model.onDidDispose(() => this._onDidDisposeModel.fire()));
294
this._register(_model.onDidChangePendingRequests(() => this._onDidChange.fire(null)));
295
this._register(_model.onDidChange(e => {
296
if (e.kind === 'addRequest') {
297
const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request);
298
this._items.push(requestModel);
299
300
if (e.request.response) {
301
this.onAddResponse(e.request.response);
302
}
303
} else if (e.kind === 'addResponse') {
304
this.onAddResponse(e.response);
305
} else if (e.kind === 'removeRequest') {
306
const requestIdx = this._items.findIndex(item => isRequestVM(item) && item.id === e.requestId);
307
if (requestIdx >= 0) {
308
this._items.splice(requestIdx, 1);
309
}
310
311
const responseIdx = e.responseId && this._items.findIndex(item => isResponseVM(item) && item.id === e.responseId);
312
if (typeof responseIdx === 'number' && responseIdx >= 0) {
313
const items = this._items.splice(responseIdx, 1);
314
const item = items[0];
315
if (item instanceof ChatResponseViewModel) {
316
item.dispose();
317
}
318
}
319
}
320
321
const modelEventToVmEvent: IChatViewModelChangeEvent =
322
e.kind === 'addRequest' ? { kind: 'addRequest' }
323
: e.kind === 'initialize' ? { kind: 'initialize' }
324
: e.kind === 'setHidden' ? { kind: 'setHidden' }
325
: null;
326
this._onDidChange.fire(modelEventToVmEvent);
327
}));
328
}
329
330
private onAddResponse(responseModel: IChatResponseModel) {
331
const response = this.instantiationService.createInstance(ChatResponseViewModel, responseModel, this);
332
this._register(response.onDidChange(() => {
333
return this._onDidChange.fire(null);
334
}));
335
this._items.push(response);
336
}
337
338
getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] {
339
let items: (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] = this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop);
340
if (this._options?.maxVisibleItems !== undefined && items.length > this._options.maxVisibleItems) {
341
items = items.slice(-this._options.maxVisibleItems);
342
}
343
344
const pendingRequests = this._model.getPendingRequests();
345
if (pendingRequests.length > 0) {
346
// Separate steering and queued requests
347
const steeringRequests = pendingRequests.filter(p => p.kind === ChatRequestQueueKind.Steering);
348
const queuedRequests = pendingRequests.filter(p => p.kind === ChatRequestQueueKind.Queued);
349
350
// Add steering requests with their divider first
351
if (steeringRequests.length > 0) {
352
items.push({ kind: 'pendingDivider', id: 'pending-divider-steering', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Steering, currentRenderedHeight: undefined });
353
for (const pending of steeringRequests) {
354
const requestVM = this.instantiationService.createInstance(ChatRequestViewModel, pending.request, pending.kind);
355
items.push(requestVM);
356
}
357
}
358
359
// Add queued requests with their divider
360
if (queuedRequests.length > 0) {
361
items.push({ kind: 'pendingDivider', id: 'pending-divider-queued', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Queued, currentRenderedHeight: undefined });
362
for (const pending of queuedRequests) {
363
const requestVM = this.instantiationService.createInstance(ChatRequestViewModel, pending.request, pending.kind);
364
items.push(requestVM);
365
}
366
}
367
}
368
369
return items;
370
}
371
372
373
private _editing: IChatRequestViewModel | undefined = undefined;
374
get editing(): IChatRequestViewModel | undefined {
375
return this._editing;
376
}
377
378
setEditing(editing: IChatRequestViewModel | undefined): void {
379
if (this.editing && editing && this.editing.id === editing.id) {
380
return; // already editing this request
381
}
382
383
this._editing = editing;
384
}
385
386
override dispose() {
387
super.dispose();
388
dispose(this._items.filter((item): item is ChatResponseViewModel => item instanceof ChatResponseViewModel));
389
}
390
}
391
392
export class ChatRequestViewModel implements IChatRequestViewModel {
393
get id() {
394
return this._model.id;
395
}
396
397
/**
398
* An ID that changes when the request should be re-rendered.
399
*/
400
get dataId() {
401
return `${this.id}_${this._model.version + (this._model.response?.isComplete ? 1 : 0)}`;
402
}
403
404
get sessionResource() {
405
return this._model.session.sessionResource;
406
}
407
408
get username() {
409
return 'User';
410
}
411
412
get avatarIcon(): ThemeIcon {
413
return Codicon.account;
414
}
415
416
get message() {
417
return this._model.message;
418
}
419
420
get messageText() {
421
return this.message.text;
422
}
423
424
get attempt() {
425
return this._model.attempt;
426
}
427
428
get variables() {
429
return this._model.variableData.variables;
430
}
431
432
get contentReferences() {
433
return this._model.response?.contentReferences;
434
}
435
436
get confirmation() {
437
return this._model.confirmation;
438
}
439
440
get isComplete() {
441
return this._model.response?.isComplete ?? false;
442
}
443
444
get isCompleteAddedRequest() {
445
return this._model.isCompleteAddedRequest;
446
}
447
448
get shouldBeRemovedOnSend() {
449
return this._model.shouldBeRemovedOnSend;
450
}
451
452
get shouldBeBlocked() {
453
return this._model.shouldBeBlocked;
454
}
455
456
get slashCommand(): IChatAgentCommand | undefined {
457
return this._model.response?.slashCommand;
458
}
459
460
get agentOrSlashCommandDetected(): boolean {
461
return this._model.response?.agentOrSlashCommandDetected ?? false;
462
}
463
464
currentRenderedHeight: number | undefined;
465
466
get modelId() {
467
return this._model.modelId;
468
}
469
470
get timestamp() {
471
return this._model.timestamp;
472
}
473
474
get pendingKind() {
475
return this._pendingKind;
476
}
477
478
constructor(
479
private readonly _model: IChatRequestModel,
480
private readonly _pendingKind?: ChatRequestQueueKind,
481
) { }
482
}
483
484
export class ChatResponseViewModel extends Disposable implements IChatResponseViewModel {
485
private _modelChangeCount = 0;
486
487
private readonly _onDidChange = this._register(new Emitter<void>());
488
readonly onDidChange = this._onDidChange.event;
489
490
get model() {
491
return this._model;
492
}
493
494
get id() {
495
return this._model.id;
496
}
497
498
get dataId() {
499
return this._model.id +
500
`_${this._modelChangeCount}` +
501
(this.isLast ? '_last' : '');
502
}
503
504
get sessionResource(): URI {
505
return this._model.session.sessionResource;
506
}
507
508
get username() {
509
if (this.agent) {
510
const isAllowed = this.chatAgentNameService.getAgentNameRestriction(this.agent);
511
if (isAllowed) {
512
return this.agent.fullName || this.agent.name;
513
} else {
514
return getFullyQualifiedId(this.agent);
515
}
516
}
517
518
return this._model.username;
519
}
520
521
get agent() {
522
return this._model.agent;
523
}
524
525
get slashCommand() {
526
return this._model.slashCommand;
527
}
528
529
get agentOrSlashCommandDetected() {
530
return this._model.agentOrSlashCommandDetected;
531
}
532
533
get response(): IResponse {
534
return this._model.response;
535
}
536
537
get usedContext(): IChatUsedContext | undefined {
538
return this._model.usedContext;
539
}
540
541
get contentReferences(): ReadonlyArray<IChatContentReference> {
542
return this._model.contentReferences;
543
}
544
545
get codeCitations(): ReadonlyArray<IChatCodeCitation> {
546
return this._model.codeCitations;
547
}
548
549
get progressMessages(): ReadonlyArray<IChatProgressMessage> {
550
return this._model.progressMessages;
551
}
552
553
get isComplete() {
554
return this._model.isComplete;
555
}
556
557
get isCanceled() {
558
return this._model.isCanceled;
559
}
560
561
get shouldBeBlocked() {
562
return this._model.shouldBeBlocked;
563
}
564
565
get shouldBeRemovedOnSend() {
566
return this._model.shouldBeRemovedOnSend;
567
}
568
569
get isCompleteAddedRequest() {
570
return this._model.isCompleteAddedRequest;
571
}
572
573
get replyFollowups() {
574
return this._model.followups?.filter((f): f is IChatFollowup => f.kind === 'reply');
575
}
576
577
get result() {
578
return this._model.result;
579
}
580
581
get errorDetails(): IChatResponseErrorDetails | undefined {
582
return this.result?.errorDetails;
583
}
584
585
get vote() {
586
return this._model.vote;
587
}
588
589
get voteDownReason() {
590
return this._model.voteDownReason;
591
}
592
593
get requestId() {
594
return this._model.requestId;
595
}
596
597
get isStale() {
598
return this._model.isStale;
599
}
600
601
get isLast(): boolean {
602
return this.session.getItems().at(-1) === this;
603
}
604
605
renderData: IChatResponseRenderData | undefined = undefined;
606
currentRenderedHeight: number | undefined;
607
608
private _usedReferencesExpanded: boolean | undefined;
609
get usedReferencesExpanded(): boolean | undefined {
610
if (typeof this._usedReferencesExpanded === 'boolean') {
611
return this._usedReferencesExpanded;
612
}
613
614
return undefined;
615
}
616
617
set usedReferencesExpanded(v: boolean) {
618
this._usedReferencesExpanded = v;
619
}
620
621
private _vulnerabilitiesListExpanded: boolean = false;
622
get vulnerabilitiesListExpanded(): boolean {
623
return this._vulnerabilitiesListExpanded;
624
}
625
626
set vulnerabilitiesListExpanded(v: boolean) {
627
this._vulnerabilitiesListExpanded = v;
628
}
629
630
private readonly liveUpdateTracker: ChatStreamStatsTracker | undefined;
631
632
get contentUpdateTimings(): IChatStreamStats | undefined {
633
return this.liveUpdateTracker?.data;
634
}
635
636
constructor(
637
private readonly _model: IChatResponseModel,
638
public readonly session: IChatViewModel,
639
@IInstantiationService private readonly instantiationService: IInstantiationService,
640
@IChatAgentNameService private readonly chatAgentNameService: IChatAgentNameService,
641
) {
642
super();
643
644
if (!_model.isComplete) {
645
this.liveUpdateTracker = this.instantiationService.createInstance(ChatStreamStatsTracker);
646
}
647
648
this._register(_model.onDidChange(() => {
649
if (this.liveUpdateTracker) {
650
const wordCount = countWords(_model.entireResponse.getMarkdown());
651
this.liveUpdateTracker.update({ totalWordCount: wordCount });
652
}
653
654
// new data -> new id, new content to render
655
this._modelChangeCount++;
656
657
this._onDidChange.fire();
658
}));
659
}
660
661
setVote(vote: ChatAgentVoteDirection): void {
662
this._modelChangeCount++;
663
this._model.setVote(vote);
664
}
665
666
setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void {
667
this._modelChangeCount++;
668
this._model.setVoteDownReason(reason);
669
}
670
671
setEditApplied(edit: IChatTextEditGroup, editCount: number) {
672
this._modelChangeCount++;
673
this._model.setEditApplied(edit, editCount);
674
}
675
}
676
677