Path: blob/main/src/vs/workbench/contrib/chat/common/tools/chatArtifactsService.ts
13406 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 { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js';6import { derived, IObservable, observableFromEvent, observableSignalFromEvent, observableValueOpts } from '../../../../../base/common/observable.js';7import { URI } from '../../../../../base/common/uri.js';8import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';9import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';10import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';11import { Memento } from '../../../../common/memento.js';12import { extractArtifactsFromResponse } from '../chatArtifactExtraction.js';13import { IChatToolInvocation, IChatService } from '../chatService/chatService.js';14import { ChatConfiguration } from '../constants.js';15import { chatSessionResourceToId } from '../model/chatUri.js';1617export interface IArtifactGroupConfig {18readonly groupName: string;19readonly onlyShowGroup?: boolean;20}2122export interface IChatArtifact {23readonly label: string;24readonly uri: string;25readonly toolCallId?: string;26readonly dataPartIndex?: number;27readonly type: 'devServer' | 'screenshot' | 'plan' | undefined;28readonly groupName?: string;29readonly onlyShowGroup?: boolean;30}3132export type ArtifactSource =33| { readonly kind: 'rules' }34| { readonly kind: 'agent' }35| { readonly kind: 'subagent'; readonly invocationId: string; readonly name: string | undefined };3637export interface IArtifactSourceGroup {38readonly source: ArtifactSource;39readonly artifacts: readonly IChatArtifact[];40}4142export interface IArtifactRuleOverrides {43readonly byMimeType?: Record<string, IArtifactGroupConfig>;44readonly byFilePath?: Record<string, IArtifactGroupConfig>;45readonly byMemoryFilePath?: Record<string, IArtifactGroupConfig>;46}4748export const IChatArtifactsService = createDecorator<IChatArtifactsService>('chatArtifactsService');4950export interface IChatArtifactsService {51readonly _serviceBrand: undefined;52getArtifacts(sessionResource: URI): IChatArtifacts;53}5455export interface IChatArtifacts {56readonly artifactGroups: IObservable<readonly IArtifactSourceGroup[]>;57setAgentArtifacts(artifacts: IChatArtifact[]): void;58setSubagentArtifacts(invocationId: string, name: string | undefined, artifacts: IChatArtifact[]): void;59setRuleOverrides(rules: IArtifactRuleOverrides | undefined): void;60clearAgentArtifacts(): void;61clearSubagentArtifacts(invocationId: string): void;62migrate(target: IChatArtifacts): void;63}6465interface IResponseCache {66readonly partsLength: number;67readonly completedToolCount: number;68readonly byMimeType: Record<string, IArtifactGroupConfig>;69readonly byFilePath: Record<string, IArtifactGroupConfig>;70readonly byMemoryFilePath: Record<string, IArtifactGroupConfig>;71readonly artifacts: IChatArtifact[];72}7374class ChatArtifactsStorage {75private readonly _memento: Memento<Record<string, IChatArtifact[]>>;7677constructor(@IStorageService storageService: IStorageService) {78this._memento = new Memento('chat-artifacts', storageService);79}8081get(key: string): IChatArtifact[] {82const storage = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE);83return storage[key] || [];84}8586set(key: string, artifacts: IChatArtifact[]): void {87const storage = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE);88storage[key] = artifacts;89this._memento.saveMemento();90}9192delete(key: string): void {93const storage = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE);94delete storage[key];95this._memento.saveMemento();96}97}9899class UnifiedChatArtifacts extends Disposable implements IChatArtifacts {100101private readonly _responseCache = new Map<string, IResponseCache>();102103private readonly _ruleOverrides = observableValueOpts<IArtifactRuleOverrides | undefined>(104{ owner: this, equalsFn: () => false },105undefined,106);107108private readonly _agentArtifacts = observableValueOpts<readonly IChatArtifact[]>(109{ owner: this, equalsFn: () => false },110[],111);112113private readonly _subagentArtifacts = observableValueOpts<ReadonlyMap<string, { readonly name: string | undefined; readonly artifacts: readonly IChatArtifact[] }>>(114{ owner: this, equalsFn: () => false },115new Map(),116);117118/** Sequence counter for ordering sources by first-set time. */119private _nextSequence = 1; // 0 is reserved for rules120private readonly _sourceSequences = new Map<string, number>();121122readonly artifactGroups: IObservable<readonly IArtifactSourceGroup[]>;123124constructor(125sessionResource: URI,126private readonly _storageKey: string,127private readonly _storage: ChatArtifactsStorage,128chatService: IChatService,129configurationService: IConfigurationService,130) {131super();132133// Restore persisted agent artifacts134const restored = this._storage.get(this._storageKey);135this._agentArtifacts.set(restored, undefined);136this._sourceSequences.set('rules', 0);137if (restored.length > 0) {138this._sourceSequences.set('agent', this._nextSequence++);139}140141// Config-based rules (defaults)142const configByMimeType = observableFromEvent<Record<string, IArtifactGroupConfig>>(143this,144configurationService.onDidChangeConfiguration,145() => configurationService.getValue<Record<string, IArtifactGroupConfig>>(ChatConfiguration.ArtifactsRulesByMimeType) ?? {},146);147148const configByFilePath = observableFromEvent<Record<string, IArtifactGroupConfig>>(149this,150configurationService.onDidChangeConfiguration,151() => configurationService.getValue<Record<string, IArtifactGroupConfig>>(ChatConfiguration.ArtifactsRulesByFilePath) ?? {},152);153154const configByMemoryFilePath = observableFromEvent<Record<string, IArtifactGroupConfig>>(155this,156configurationService.onDidChangeConfiguration,157() => configurationService.getValue<Record<string, IArtifactGroupConfig>>(ChatConfiguration.ArtifactsRulesByMemoryFilePath) ?? {},158);159160const modelSignal = observableFromEvent(161this,162chatService.onDidCreateModel,163() => chatService.getSession(sessionResource),164);165166// Derived: rules-based artifacts167const rulesArtifacts = derived<readonly IChatArtifact[]>(reader => {168const overrides = this._ruleOverrides.read(reader);169const byMimeType = overrides?.byMimeType ?? configByMimeType.read(reader);170const byFilePath = overrides?.byFilePath ?? configByFilePath.read(reader);171const byMemoryFilePath = overrides?.byMemoryFilePath ?? configByMemoryFilePath.read(reader);172const model = modelSignal.read(reader);173if (!model) {174return [];175}176177const requestsSignal = observableSignalFromEvent(this, model.onDidChange);178requestsSignal.read(reader);179const requests = model.getRequests();180181const allArtifacts: IChatArtifact[] = [];182const activeResponseIds = new Set<string>();183const seenKeys = new Set<string>();184185for (const request of requests) {186const response = request.response;187if (!response) {188continue;189}190191activeResponseIds.add(response.id);192const responseValue = response.response;193const partsLength = responseValue.value.length;194195let completedToolCount = 0;196for (const part of responseValue.value) {197if ((part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') && IChatToolInvocation.resultDetails(part) !== undefined) {198completedToolCount++;199}200}201202const cached = this._responseCache.get(response.id);203let extracted: IChatArtifact[];204if (cached && cached.partsLength === partsLength && cached.completedToolCount === completedToolCount && cached.byMimeType === byMimeType && cached.byFilePath === byFilePath && cached.byMemoryFilePath === byMemoryFilePath) {205extracted = cached.artifacts;206} else {207extracted = extractArtifactsFromResponse(responseValue, sessionResource, byMimeType, byFilePath, byMemoryFilePath);208this._responseCache.set(response.id, { partsLength, completedToolCount, byMimeType, byFilePath, byMemoryFilePath, artifacts: extracted });209}210211for (const artifact of extracted) {212const key = artifact.toolCallId213? `${artifact.toolCallId}:${artifact.dataPartIndex}`214: artifact.uri;215if (seenKeys.has(key)) {216const idx = allArtifacts.findIndex(a =>217a.toolCallId ? `${a.toolCallId}:${a.dataPartIndex}` === key : a.uri === key218);219if (idx !== -1) {220allArtifacts.splice(idx, 1);221}222}223seenKeys.add(key);224allArtifacts.push(artifact);225}226}227228for (const key of this._responseCache.keys()) {229if (!activeResponseIds.has(key)) {230this._responseCache.delete(key);231}232}233234return allArtifacts;235});236237// Combined: all sources as groups, deduplicated by URI238this.artifactGroups = derived<readonly IArtifactSourceGroup[]>(reader => {239const entries: { key: string; seq: number; group: IArtifactSourceGroup }[] = [];240241const rules = rulesArtifacts.read(reader);242if (rules.length > 0) {243entries.push({ key: 'rules', seq: this._sourceSequences.get('rules') ?? 0, group: { source: { kind: 'rules' }, artifacts: rules } });244}245246const agent = this._agentArtifacts.read(reader);247if (agent.length > 0) {248entries.push({ key: 'agent', seq: this._sourceSequences.get('agent') ?? Infinity, group: { source: { kind: 'agent' }, artifacts: agent } });249}250251const subagents = this._subagentArtifacts.read(reader);252for (const [invocationId, entry] of subagents) {253if (entry.artifacts.length > 0) {254const key = `subagent:${invocationId}`;255entries.push({256key,257seq: this._sourceSequences.get(key) ?? Infinity,258group: { source: { kind: 'subagent', invocationId, name: entry.name }, artifacts: entry.artifacts },259});260}261}262263// Sort by sequence so the first source to set artifacts wins duplicates264entries.sort((a, b) => a.seq - b.seq);265266const seenKeys = new Set<string>();267const groups: IArtifactSourceGroup[] = [];268269for (const entry of entries) {270const filtered = entry.group.artifacts.filter(a => {271const k = a.toolCallId ? `${a.toolCallId}:${a.dataPartIndex}` : a.uri;272if (!k) {273return false;274}275const normalized = k.toLowerCase();276if (seenKeys.has(normalized)) {277return false;278}279seenKeys.add(normalized);280return true;281});282if (filtered.length > 0) {283groups.push({ source: entry.group.source, artifacts: filtered });284}285}286287return groups;288});289}290291setAgentArtifacts(artifacts: IChatArtifact[]): void {292if (!this._sourceSequences.has('agent')) {293this._sourceSequences.set('agent', this._nextSequence++);294}295this._agentArtifacts.set(artifacts, undefined);296this._storage.set(this._storageKey, artifacts);297}298299setSubagentArtifacts(invocationId: string, name: string | undefined, artifacts: IChatArtifact[]): void {300const key = `subagent:${invocationId}`;301if (!this._sourceSequences.has(key)) {302this._sourceSequences.set(key, this._nextSequence++);303}304const map = new Map(this._subagentArtifacts.get());305if (artifacts.length === 0) {306map.delete(invocationId);307} else {308map.set(invocationId, { name, artifacts });309}310this._subagentArtifacts.set(map, undefined);311}312313setRuleOverrides(rules: IArtifactRuleOverrides | undefined): void {314this._ruleOverrides.set(rules, undefined);315}316317clearAgentArtifacts(): void {318this._agentArtifacts.set([], undefined);319this._storage.set(this._storageKey, []);320}321322clearSubagentArtifacts(invocationId: string): void {323const map = new Map(this._subagentArtifacts.get());324map.delete(invocationId);325this._subagentArtifacts.set(map, undefined);326}327328migrate(target: IChatArtifacts): void {329const current = this._agentArtifacts.get();330if (current.length > 0) {331target.setAgentArtifacts([...current]);332}333this._agentArtifacts.set([], undefined);334this._storage.delete(this._storageKey);335}336}337338export class ChatArtifactsService extends Disposable implements IChatArtifactsService {339declare readonly _serviceBrand: undefined;340341private readonly _storage: ChatArtifactsStorage;342private readonly _instances = this._register(new DisposableMap<string, UnifiedChatArtifacts>());343344constructor(345@IStorageService storageService: IStorageService,346@IChatService private readonly _chatService: IChatService,347@IConfigurationService private readonly _configurationService: IConfigurationService,348) {349super();350this._storage = new ChatArtifactsStorage(storageService);351}352353getArtifacts(sessionResource: URI): IChatArtifacts {354const key = chatSessionResourceToId(sessionResource);355let instance = this._instances.get(key);356if (!instance) {357instance = new UnifiedChatArtifacts(sessionResource, key, this._storage, this._chatService, this._configurationService);358this._instances.set(key, instance);359}360return instance;361}362}363364365