Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts
13401 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 { getMediaMime } from '../../../../base/common/mime.js';
7
import { isEqual } from '../../../../base/common/resources.js';
8
import { URI } from '../../../../base/common/uri.js';
9
import { VSBuffer } from '../../../../base/common/buffer.js';
10
import { localize } from '../../../../nls.js';
11
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
12
import { ICommandService } from '../../../../platform/commands/common/commands.js';
13
import { IFileService } from '../../../../platform/files/common/files.js';
14
import { extractImagesFromChatRequest, extractImagesFromChatResponse, IChatExtractedImage } from '../common/chatImageExtraction.js';
15
import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../common/model/chatViewModel.js';
16
import { IChatWidgetService } from './chat.js';
17
18
export const IChatImageCarouselService = createDecorator<IChatImageCarouselService>('chatImageCarouselService');
19
20
export interface IChatImageCarouselService {
21
readonly _serviceBrand: undefined;
22
23
/**
24
* Opens the image carousel for the given resource URI, collecting all images
25
* from the focused chat widget's responses to populate the carousel.
26
*
27
* @param resource The URI of the clicked image to start the carousel at.
28
* @param data Optional raw image data (e.g. for input attachment images that are Uint8Arrays).
29
*/
30
openCarouselAtResource(resource: URI, data?: Uint8Array): Promise<void>;
31
}
32
33
//#region Carousel data types
34
35
export interface ICarouselImage {
36
readonly id: string;
37
readonly name: string;
38
readonly mimeType: string;
39
readonly data: Uint8Array;
40
readonly caption?: string;
41
}
42
43
export interface ICarouselSection {
44
readonly title: string;
45
readonly images: ICarouselImage[];
46
}
47
48
export interface ICarouselCollectionArgs {
49
readonly collection: {
50
readonly id: string;
51
readonly title: string;
52
readonly sections: ICarouselSection[];
53
};
54
readonly startIndex: number;
55
}
56
57
export interface ICarouselSingleImageArgs {
58
readonly name: string;
59
readonly mimeType: string;
60
readonly data: Uint8Array;
61
readonly title: string;
62
}
63
64
//#endregion
65
66
//#region Testable helper functions
67
68
/**
69
* Collects all carousel image sections from chat items.
70
* Each request/response pair with images becomes one section containing
71
* user attachment images, tool invocation images, and inline reference images.
72
*/
73
export async function collectCarouselSections(
74
items: (IChatRequestViewModel | IChatResponseViewModel)[],
75
readFile: (uri: URI) => Promise<Uint8Array>,
76
): Promise<ICarouselSection[]> {
77
const sections: ICarouselSection[] = [];
78
79
// Build a map from request id to request VM for pairing
80
const requestMap = new Map<string, IChatRequestViewModel>();
81
for (const item of items) {
82
if (isRequestVM(item)) {
83
requestMap.set(item.id, item);
84
}
85
}
86
87
for (const item of items) {
88
if (!isResponseVM(item)) {
89
continue;
90
}
91
92
const { title: extractedTitle, images: responseImages } = await extractImagesFromChatResponse(item, async uri => VSBuffer.wrap(await readFile(uri)));
93
94
// Also collect images from the corresponding user request
95
const request = requestMap.get(item.requestId);
96
const requestImages = request ? extractImagesFromChatRequest(request) : [];
97
98
const allImages = [...requestImages, ...responseImages];
99
const dedupedImages = deduplicateConsecutiveImages(allImages);
100
if (dedupedImages.length > 0) {
101
sections.push({
102
title: request?.messageText ?? extractedTitle,
103
images: dedupedImages.map(({ uri, name, mimeType, data, caption }) => ({ id: uri.toString(), name, mimeType, data: data.buffer, caption }))
104
});
105
}
106
}
107
108
// Handle requests that have no response yet (e.g. pending requests with image attachments)
109
const respondedRequestIds = new Set(
110
items.filter(isResponseVM).map(r => r.requestId)
111
);
112
for (const item of items) {
113
if (!isRequestVM(item) || respondedRequestIds.has(item.id)) {
114
continue;
115
}
116
const requestImages = extractImagesFromChatRequest(item);
117
const dedupedImages = deduplicateConsecutiveImages(requestImages);
118
if (dedupedImages.length > 0) {
119
sections.push({
120
title: item.messageText,
121
images: dedupedImages.map(({ uri, name, mimeType, data, caption }) => ({ id: uri.toString(), name, mimeType, data: data.buffer, caption }))
122
});
123
}
124
}
125
126
return sections;
127
}
128
129
/**
130
* Removes consecutive images with the same URI, keeping only the first occurrence
131
* of each run of duplicates.
132
*/
133
function deduplicateConsecutiveImages(images: IChatExtractedImage[]): IChatExtractedImage[] {
134
return images.filter((img, index) => {
135
if (index === 0) {
136
return true;
137
}
138
return !isEqual(images[index - 1].uri, img.uri);
139
});
140
}
141
142
/**
143
* Finds the global index of the clicked image across all carousel sections.
144
* Tries URI string match, then parsed URI equality, then data buffer equality.
145
*/
146
export function findClickedImageIndex(
147
sections: ICarouselSection[],
148
resource: URI,
149
data?: Uint8Array,
150
): number {
151
let globalOffset = 0;
152
153
for (const section of sections) {
154
const localIndex = findImageInListByUri(section.images, resource);
155
if (localIndex >= 0) {
156
return globalOffset + localIndex;
157
}
158
globalOffset += section.images.length;
159
}
160
161
if (!data) {
162
return -1;
163
}
164
165
globalOffset = 0;
166
for (const section of sections) {
167
const localIndex = findImageInListByData(section.images, data);
168
if (localIndex >= 0) {
169
return globalOffset + localIndex;
170
}
171
globalOffset += section.images.length;
172
}
173
174
return -1;
175
}
176
177
function findImageInListByUri(
178
images: ICarouselImage[],
179
resource: URI,
180
): number {
181
// Try matching by URI string (for inline references and tool images with URIs)
182
const uriStr = resource.toString();
183
const byUri = images.findIndex(img => img.id === uriStr);
184
if (byUri >= 0) {
185
return byUri;
186
}
187
188
// Try matching by parsed URI equality (for tool invocation images with generated URIs)
189
const byParsedUri = images.findIndex(img => {
190
try {
191
return isEqual(URI.parse(img.id), resource);
192
} catch {
193
return false;
194
}
195
});
196
if (byParsedUri >= 0) {
197
return byParsedUri;
198
}
199
200
return -1;
201
}
202
203
function findImageInListByData(images: ICarouselImage[], data: Uint8Array): number {
204
const wrapped = VSBuffer.wrap(data);
205
return images.findIndex(img => VSBuffer.wrap(img.data).equals(wrapped));
206
}
207
208
/**
209
* Builds the collection arguments for the carousel command.
210
*/
211
export function buildCollectionArgs(
212
sections: ICarouselSection[],
213
clickedGlobalIndex: number,
214
sessionResource: URI,
215
): ICarouselCollectionArgs {
216
const collectionId = sessionResource.toString() + '_carousel';
217
const defaultTitle = localize('chatImageCarousel.allImages', "Conversation Images");
218
return {
219
collection: {
220
id: collectionId,
221
title: sections.length === 1
222
? (sections[0].title || defaultTitle)
223
: defaultTitle,
224
sections,
225
},
226
startIndex: clickedGlobalIndex,
227
};
228
}
229
230
/**
231
* Builds the single-image arguments for the carousel command.
232
*/
233
export function buildSingleImageArgs(resource: URI, data: Uint8Array): ICarouselSingleImageArgs {
234
const name = resource.path.split('/').pop() ?? 'image';
235
const mimeType = getMediaMime(resource.path) ?? getMediaMime(name) ?? 'image/png';
236
return { name, mimeType, data, title: name };
237
}
238
239
//#endregion
240
241
const CAROUSEL_COMMAND = 'workbench.action.chat.openImageInCarousel';
242
243
export class ChatImageCarouselService implements IChatImageCarouselService {
244
245
declare readonly _serviceBrand: undefined;
246
247
constructor(
248
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
249
@ICommandService private readonly commandService: ICommandService,
250
@IFileService private readonly fileService: IFileService,
251
) { }
252
253
async openCarouselAtResource(resource: URI, data?: Uint8Array): Promise<void> {
254
const widget = this.chatWidgetService.lastFocusedWidget;
255
if (!widget?.viewModel) {
256
await this.openSingleImage(resource, data);
257
return;
258
}
259
260
const items = widget.viewModel.getItems().filter(
261
(item): item is IChatRequestViewModel | IChatResponseViewModel => isRequestVM(item) || isResponseVM(item)
262
);
263
const readFile = async (uri: URI) => (await this.fileService.readFile(uri)).value.buffer;
264
const sections = await collectCarouselSections(items, readFile);
265
const clickedGlobalIndex = findClickedImageIndex(sections, resource, data);
266
267
if (clickedGlobalIndex === -1 || sections.length === 0) {
268
await this.openSingleImage(resource, data);
269
return;
270
}
271
272
const args = buildCollectionArgs(sections, clickedGlobalIndex, widget.viewModel.sessionResource);
273
await this.commandService.executeCommand(CAROUSEL_COMMAND, args);
274
}
275
276
private async openSingleImage(resource: URI, data?: Uint8Array): Promise<void> {
277
if (!data) {
278
const content = await this.fileService.readFile(resource);
279
data = content.value.buffer;
280
}
281
282
const args = buildSingleImageArgs(resource, data);
283
await this.commandService.executeCommand(CAROUSEL_COMMAND, args);
284
}
285
}
286
287