Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/common/tools/chatArtifactsService.ts
13406 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 { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js';
7
import { derived, IObservable, observableFromEvent, observableSignalFromEvent, observableValueOpts } from '../../../../../base/common/observable.js';
8
import { URI } from '../../../../../base/common/uri.js';
9
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
10
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
11
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
12
import { Memento } from '../../../../common/memento.js';
13
import { extractArtifactsFromResponse } from '../chatArtifactExtraction.js';
14
import { IChatToolInvocation, IChatService } from '../chatService/chatService.js';
15
import { ChatConfiguration } from '../constants.js';
16
import { chatSessionResourceToId } from '../model/chatUri.js';
17
18
export interface IArtifactGroupConfig {
19
readonly groupName: string;
20
readonly onlyShowGroup?: boolean;
21
}
22
23
export interface IChatArtifact {
24
readonly label: string;
25
readonly uri: string;
26
readonly toolCallId?: string;
27
readonly dataPartIndex?: number;
28
readonly type: 'devServer' | 'screenshot' | 'plan' | undefined;
29
readonly groupName?: string;
30
readonly onlyShowGroup?: boolean;
31
}
32
33
export type ArtifactSource =
34
| { readonly kind: 'rules' }
35
| { readonly kind: 'agent' }
36
| { readonly kind: 'subagent'; readonly invocationId: string; readonly name: string | undefined };
37
38
export interface IArtifactSourceGroup {
39
readonly source: ArtifactSource;
40
readonly artifacts: readonly IChatArtifact[];
41
}
42
43
export interface IArtifactRuleOverrides {
44
readonly byMimeType?: Record<string, IArtifactGroupConfig>;
45
readonly byFilePath?: Record<string, IArtifactGroupConfig>;
46
readonly byMemoryFilePath?: Record<string, IArtifactGroupConfig>;
47
}
48
49
export const IChatArtifactsService = createDecorator<IChatArtifactsService>('chatArtifactsService');
50
51
export interface IChatArtifactsService {
52
readonly _serviceBrand: undefined;
53
getArtifacts(sessionResource: URI): IChatArtifacts;
54
}
55
56
export interface IChatArtifacts {
57
readonly artifactGroups: IObservable<readonly IArtifactSourceGroup[]>;
58
setAgentArtifacts(artifacts: IChatArtifact[]): void;
59
setSubagentArtifacts(invocationId: string, name: string | undefined, artifacts: IChatArtifact[]): void;
60
setRuleOverrides(rules: IArtifactRuleOverrides | undefined): void;
61
clearAgentArtifacts(): void;
62
clearSubagentArtifacts(invocationId: string): void;
63
migrate(target: IChatArtifacts): void;
64
}
65
66
interface IResponseCache {
67
readonly partsLength: number;
68
readonly completedToolCount: number;
69
readonly byMimeType: Record<string, IArtifactGroupConfig>;
70
readonly byFilePath: Record<string, IArtifactGroupConfig>;
71
readonly byMemoryFilePath: Record<string, IArtifactGroupConfig>;
72
readonly artifacts: IChatArtifact[];
73
}
74
75
class ChatArtifactsStorage {
76
private readonly _memento: Memento<Record<string, IChatArtifact[]>>;
77
78
constructor(@IStorageService storageService: IStorageService) {
79
this._memento = new Memento('chat-artifacts', storageService);
80
}
81
82
get(key: string): IChatArtifact[] {
83
const storage = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE);
84
return storage[key] || [];
85
}
86
87
set(key: string, artifacts: IChatArtifact[]): void {
88
const storage = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE);
89
storage[key] = artifacts;
90
this._memento.saveMemento();
91
}
92
93
delete(key: string): void {
94
const storage = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE);
95
delete storage[key];
96
this._memento.saveMemento();
97
}
98
}
99
100
class UnifiedChatArtifacts extends Disposable implements IChatArtifacts {
101
102
private readonly _responseCache = new Map<string, IResponseCache>();
103
104
private readonly _ruleOverrides = observableValueOpts<IArtifactRuleOverrides | undefined>(
105
{ owner: this, equalsFn: () => false },
106
undefined,
107
);
108
109
private readonly _agentArtifacts = observableValueOpts<readonly IChatArtifact[]>(
110
{ owner: this, equalsFn: () => false },
111
[],
112
);
113
114
private readonly _subagentArtifacts = observableValueOpts<ReadonlyMap<string, { readonly name: string | undefined; readonly artifacts: readonly IChatArtifact[] }>>(
115
{ owner: this, equalsFn: () => false },
116
new Map(),
117
);
118
119
/** Sequence counter for ordering sources by first-set time. */
120
private _nextSequence = 1; // 0 is reserved for rules
121
private readonly _sourceSequences = new Map<string, number>();
122
123
readonly artifactGroups: IObservable<readonly IArtifactSourceGroup[]>;
124
125
constructor(
126
sessionResource: URI,
127
private readonly _storageKey: string,
128
private readonly _storage: ChatArtifactsStorage,
129
chatService: IChatService,
130
configurationService: IConfigurationService,
131
) {
132
super();
133
134
// Restore persisted agent artifacts
135
const restored = this._storage.get(this._storageKey);
136
this._agentArtifacts.set(restored, undefined);
137
this._sourceSequences.set('rules', 0);
138
if (restored.length > 0) {
139
this._sourceSequences.set('agent', this._nextSequence++);
140
}
141
142
// Config-based rules (defaults)
143
const configByMimeType = observableFromEvent<Record<string, IArtifactGroupConfig>>(
144
this,
145
configurationService.onDidChangeConfiguration,
146
() => configurationService.getValue<Record<string, IArtifactGroupConfig>>(ChatConfiguration.ArtifactsRulesByMimeType) ?? {},
147
);
148
149
const configByFilePath = observableFromEvent<Record<string, IArtifactGroupConfig>>(
150
this,
151
configurationService.onDidChangeConfiguration,
152
() => configurationService.getValue<Record<string, IArtifactGroupConfig>>(ChatConfiguration.ArtifactsRulesByFilePath) ?? {},
153
);
154
155
const configByMemoryFilePath = observableFromEvent<Record<string, IArtifactGroupConfig>>(
156
this,
157
configurationService.onDidChangeConfiguration,
158
() => configurationService.getValue<Record<string, IArtifactGroupConfig>>(ChatConfiguration.ArtifactsRulesByMemoryFilePath) ?? {},
159
);
160
161
const modelSignal = observableFromEvent(
162
this,
163
chatService.onDidCreateModel,
164
() => chatService.getSession(sessionResource),
165
);
166
167
// Derived: rules-based artifacts
168
const rulesArtifacts = derived<readonly IChatArtifact[]>(reader => {
169
const overrides = this._ruleOverrides.read(reader);
170
const byMimeType = overrides?.byMimeType ?? configByMimeType.read(reader);
171
const byFilePath = overrides?.byFilePath ?? configByFilePath.read(reader);
172
const byMemoryFilePath = overrides?.byMemoryFilePath ?? configByMemoryFilePath.read(reader);
173
const model = modelSignal.read(reader);
174
if (!model) {
175
return [];
176
}
177
178
const requestsSignal = observableSignalFromEvent(this, model.onDidChange);
179
requestsSignal.read(reader);
180
const requests = model.getRequests();
181
182
const allArtifacts: IChatArtifact[] = [];
183
const activeResponseIds = new Set<string>();
184
const seenKeys = new Set<string>();
185
186
for (const request of requests) {
187
const response = request.response;
188
if (!response) {
189
continue;
190
}
191
192
activeResponseIds.add(response.id);
193
const responseValue = response.response;
194
const partsLength = responseValue.value.length;
195
196
let completedToolCount = 0;
197
for (const part of responseValue.value) {
198
if ((part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && IChatToolInvocation.resultDetails(part) !== undefined) {
199
completedToolCount++;
200
}
201
}
202
203
const cached = this._responseCache.get(response.id);
204
let extracted: IChatArtifact[];
205
if (cached && cached.partsLength === partsLength && cached.completedToolCount === completedToolCount && cached.byMimeType === byMimeType && cached.byFilePath === byFilePath && cached.byMemoryFilePath === byMemoryFilePath) {
206
extracted = cached.artifacts;
207
} else {
208
extracted = extractArtifactsFromResponse(responseValue, sessionResource, byMimeType, byFilePath, byMemoryFilePath);
209
this._responseCache.set(response.id, { partsLength, completedToolCount, byMimeType, byFilePath, byMemoryFilePath, artifacts: extracted });
210
}
211
212
for (const artifact of extracted) {
213
const key = artifact.toolCallId
214
? `${artifact.toolCallId}:${artifact.dataPartIndex}`
215
: artifact.uri;
216
if (seenKeys.has(key)) {
217
const idx = allArtifacts.findIndex(a =>
218
a.toolCallId ? `${a.toolCallId}:${a.dataPartIndex}` === key : a.uri === key
219
);
220
if (idx !== -1) {
221
allArtifacts.splice(idx, 1);
222
}
223
}
224
seenKeys.add(key);
225
allArtifacts.push(artifact);
226
}
227
}
228
229
for (const key of this._responseCache.keys()) {
230
if (!activeResponseIds.has(key)) {
231
this._responseCache.delete(key);
232
}
233
}
234
235
return allArtifacts;
236
});
237
238
// Combined: all sources as groups, deduplicated by URI
239
this.artifactGroups = derived<readonly IArtifactSourceGroup[]>(reader => {
240
const entries: { key: string; seq: number; group: IArtifactSourceGroup }[] = [];
241
242
const rules = rulesArtifacts.read(reader);
243
if (rules.length > 0) {
244
entries.push({ key: 'rules', seq: this._sourceSequences.get('rules') ?? 0, group: { source: { kind: 'rules' }, artifacts: rules } });
245
}
246
247
const agent = this._agentArtifacts.read(reader);
248
if (agent.length > 0) {
249
entries.push({ key: 'agent', seq: this._sourceSequences.get('agent') ?? Infinity, group: { source: { kind: 'agent' }, artifacts: agent } });
250
}
251
252
const subagents = this._subagentArtifacts.read(reader);
253
for (const [invocationId, entry] of subagents) {
254
if (entry.artifacts.length > 0) {
255
const key = `subagent:${invocationId}`;
256
entries.push({
257
key,
258
seq: this._sourceSequences.get(key) ?? Infinity,
259
group: { source: { kind: 'subagent', invocationId, name: entry.name }, artifacts: entry.artifacts },
260
});
261
}
262
}
263
264
// Sort by sequence so the first source to set artifacts wins duplicates
265
entries.sort((a, b) => a.seq - b.seq);
266
267
const seenKeys = new Set<string>();
268
const groups: IArtifactSourceGroup[] = [];
269
270
for (const entry of entries) {
271
const filtered = entry.group.artifacts.filter(a => {
272
const k = a.toolCallId ? `${a.toolCallId}:${a.dataPartIndex}` : a.uri;
273
if (!k) {
274
return false;
275
}
276
const normalized = k.toLowerCase();
277
if (seenKeys.has(normalized)) {
278
return false;
279
}
280
seenKeys.add(normalized);
281
return true;
282
});
283
if (filtered.length > 0) {
284
groups.push({ source: entry.group.source, artifacts: filtered });
285
}
286
}
287
288
return groups;
289
});
290
}
291
292
setAgentArtifacts(artifacts: IChatArtifact[]): void {
293
if (!this._sourceSequences.has('agent')) {
294
this._sourceSequences.set('agent', this._nextSequence++);
295
}
296
this._agentArtifacts.set(artifacts, undefined);
297
this._storage.set(this._storageKey, artifacts);
298
}
299
300
setSubagentArtifacts(invocationId: string, name: string | undefined, artifacts: IChatArtifact[]): void {
301
const key = `subagent:${invocationId}`;
302
if (!this._sourceSequences.has(key)) {
303
this._sourceSequences.set(key, this._nextSequence++);
304
}
305
const map = new Map(this._subagentArtifacts.get());
306
if (artifacts.length === 0) {
307
map.delete(invocationId);
308
} else {
309
map.set(invocationId, { name, artifacts });
310
}
311
this._subagentArtifacts.set(map, undefined);
312
}
313
314
setRuleOverrides(rules: IArtifactRuleOverrides | undefined): void {
315
this._ruleOverrides.set(rules, undefined);
316
}
317
318
clearAgentArtifacts(): void {
319
this._agentArtifacts.set([], undefined);
320
this._storage.set(this._storageKey, []);
321
}
322
323
clearSubagentArtifacts(invocationId: string): void {
324
const map = new Map(this._subagentArtifacts.get());
325
map.delete(invocationId);
326
this._subagentArtifacts.set(map, undefined);
327
}
328
329
migrate(target: IChatArtifacts): void {
330
const current = this._agentArtifacts.get();
331
if (current.length > 0) {
332
target.setAgentArtifacts([...current]);
333
}
334
this._agentArtifacts.set([], undefined);
335
this._storage.delete(this._storageKey);
336
}
337
}
338
339
export class ChatArtifactsService extends Disposable implements IChatArtifactsService {
340
declare readonly _serviceBrand: undefined;
341
342
private readonly _storage: ChatArtifactsStorage;
343
private readonly _instances = this._register(new DisposableMap<string, UnifiedChatArtifacts>());
344
345
constructor(
346
@IStorageService storageService: IStorageService,
347
@IChatService private readonly _chatService: IChatService,
348
@IConfigurationService private readonly _configurationService: IConfigurationService,
349
) {
350
super();
351
this._storage = new ChatArtifactsStorage(storageService);
352
}
353
354
getArtifacts(sessionResource: URI): IChatArtifacts {
355
const key = chatSessionResourceToId(sessionResource);
356
let instance = this._instances.get(key);
357
if (!instance) {
358
instance = new UnifiedChatArtifacts(sessionResource, key, this._storage, this._chatService, this._configurationService);
359
this._instances.set(key, instance);
360
}
361
return instance;
362
}
363
}
364
365