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