Path: blob/main/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts
13401 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 { getMediaMime } from '../../../../base/common/mime.js';6import { isEqual } from '../../../../base/common/resources.js';7import { URI } from '../../../../base/common/uri.js';8import { VSBuffer } from '../../../../base/common/buffer.js';9import { localize } from '../../../../nls.js';10import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';11import { ICommandService } from '../../../../platform/commands/common/commands.js';12import { IFileService } from '../../../../platform/files/common/files.js';13import { extractImagesFromChatRequest, extractImagesFromChatResponse, IChatExtractedImage } from '../common/chatImageExtraction.js';14import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../common/model/chatViewModel.js';15import { IChatWidgetService } from './chat.js';1617export const IChatImageCarouselService = createDecorator<IChatImageCarouselService>('chatImageCarouselService');1819export interface IChatImageCarouselService {20readonly _serviceBrand: undefined;2122/**23* Opens the image carousel for the given resource URI, collecting all images24* from the focused chat widget's responses to populate the carousel.25*26* @param resource The URI of the clicked image to start the carousel at.27* @param data Optional raw image data (e.g. for input attachment images that are Uint8Arrays).28*/29openCarouselAtResource(resource: URI, data?: Uint8Array): Promise<void>;30}3132//#region Carousel data types3334export interface ICarouselImage {35readonly id: string;36readonly name: string;37readonly mimeType: string;38readonly data: Uint8Array;39readonly caption?: string;40}4142export interface ICarouselSection {43readonly title: string;44readonly images: ICarouselImage[];45}4647export interface ICarouselCollectionArgs {48readonly collection: {49readonly id: string;50readonly title: string;51readonly sections: ICarouselSection[];52};53readonly startIndex: number;54}5556export interface ICarouselSingleImageArgs {57readonly name: string;58readonly mimeType: string;59readonly data: Uint8Array;60readonly title: string;61}6263//#endregion6465//#region Testable helper functions6667/**68* Collects all carousel image sections from chat items.69* Each request/response pair with images becomes one section containing70* user attachment images, tool invocation images, and inline reference images.71*/72export async function collectCarouselSections(73items: (IChatRequestViewModel | IChatResponseViewModel)[],74readFile: (uri: URI) => Promise<Uint8Array>,75): Promise<ICarouselSection[]> {76const sections: ICarouselSection[] = [];7778// Build a map from request id to request VM for pairing79const requestMap = new Map<string, IChatRequestViewModel>();80for (const item of items) {81if (isRequestVM(item)) {82requestMap.set(item.id, item);83}84}8586for (const item of items) {87if (!isResponseVM(item)) {88continue;89}9091const { title: extractedTitle, images: responseImages } = await extractImagesFromChatResponse(item, async uri => VSBuffer.wrap(await readFile(uri)));9293// Also collect images from the corresponding user request94const request = requestMap.get(item.requestId);95const requestImages = request ? extractImagesFromChatRequest(request) : [];9697const allImages = [...requestImages, ...responseImages];98const dedupedImages = deduplicateConsecutiveImages(allImages);99if (dedupedImages.length > 0) {100sections.push({101title: request?.messageText ?? extractedTitle,102images: dedupedImages.map(({ uri, name, mimeType, data, caption }) => ({ id: uri.toString(), name, mimeType, data: data.buffer, caption }))103});104}105}106107// Handle requests that have no response yet (e.g. pending requests with image attachments)108const respondedRequestIds = new Set(109items.filter(isResponseVM).map(r => r.requestId)110);111for (const item of items) {112if (!isRequestVM(item) || respondedRequestIds.has(item.id)) {113continue;114}115const requestImages = extractImagesFromChatRequest(item);116const dedupedImages = deduplicateConsecutiveImages(requestImages);117if (dedupedImages.length > 0) {118sections.push({119title: item.messageText,120images: dedupedImages.map(({ uri, name, mimeType, data, caption }) => ({ id: uri.toString(), name, mimeType, data: data.buffer, caption }))121});122}123}124125return sections;126}127128/**129* Removes consecutive images with the same URI, keeping only the first occurrence130* of each run of duplicates.131*/132function deduplicateConsecutiveImages(images: IChatExtractedImage[]): IChatExtractedImage[] {133return images.filter((img, index) => {134if (index === 0) {135return true;136}137return !isEqual(images[index - 1].uri, img.uri);138});139}140141/**142* Finds the global index of the clicked image across all carousel sections.143* Tries URI string match, then parsed URI equality, then data buffer equality.144*/145export function findClickedImageIndex(146sections: ICarouselSection[],147resource: URI,148data?: Uint8Array,149): number {150let globalOffset = 0;151152for (const section of sections) {153const localIndex = findImageInListByUri(section.images, resource);154if (localIndex >= 0) {155return globalOffset + localIndex;156}157globalOffset += section.images.length;158}159160if (!data) {161return -1;162}163164globalOffset = 0;165for (const section of sections) {166const localIndex = findImageInListByData(section.images, data);167if (localIndex >= 0) {168return globalOffset + localIndex;169}170globalOffset += section.images.length;171}172173return -1;174}175176function findImageInListByUri(177images: ICarouselImage[],178resource: URI,179): number {180// Try matching by URI string (for inline references and tool images with URIs)181const uriStr = resource.toString();182const byUri = images.findIndex(img => img.id === uriStr);183if (byUri >= 0) {184return byUri;185}186187// Try matching by parsed URI equality (for tool invocation images with generated URIs)188const byParsedUri = images.findIndex(img => {189try {190return isEqual(URI.parse(img.id), resource);191} catch {192return false;193}194});195if (byParsedUri >= 0) {196return byParsedUri;197}198199return -1;200}201202function findImageInListByData(images: ICarouselImage[], data: Uint8Array): number {203const wrapped = VSBuffer.wrap(data);204return images.findIndex(img => VSBuffer.wrap(img.data).equals(wrapped));205}206207/**208* Builds the collection arguments for the carousel command.209*/210export function buildCollectionArgs(211sections: ICarouselSection[],212clickedGlobalIndex: number,213sessionResource: URI,214): ICarouselCollectionArgs {215const collectionId = sessionResource.toString() + '_carousel';216const defaultTitle = localize('chatImageCarousel.allImages', "Conversation Images");217return {218collection: {219id: collectionId,220title: sections.length === 1221? (sections[0].title || defaultTitle)222: defaultTitle,223sections,224},225startIndex: clickedGlobalIndex,226};227}228229/**230* Builds the single-image arguments for the carousel command.231*/232export function buildSingleImageArgs(resource: URI, data: Uint8Array): ICarouselSingleImageArgs {233const name = resource.path.split('/').pop() ?? 'image';234const mimeType = getMediaMime(resource.path) ?? getMediaMime(name) ?? 'image/png';235return { name, mimeType, data, title: name };236}237238//#endregion239240const CAROUSEL_COMMAND = 'workbench.action.chat.openImageInCarousel';241242export class ChatImageCarouselService implements IChatImageCarouselService {243244declare readonly _serviceBrand: undefined;245246constructor(247@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,248@ICommandService private readonly commandService: ICommandService,249@IFileService private readonly fileService: IFileService,250) { }251252async openCarouselAtResource(resource: URI, data?: Uint8Array): Promise<void> {253const widget = this.chatWidgetService.lastFocusedWidget;254if (!widget?.viewModel) {255await this.openSingleImage(resource, data);256return;257}258259const items = widget.viewModel.getItems().filter(260(item): item is IChatRequestViewModel | IChatResponseViewModel => isRequestVM(item) || isResponseVM(item)261);262const readFile = async (uri: URI) => (await this.fileService.readFile(uri)).value.buffer;263const sections = await collectCarouselSections(items, readFile);264const clickedGlobalIndex = findClickedImageIndex(sections, resource, data);265266if (clickedGlobalIndex === -1 || sections.length === 0) {267await this.openSingleImage(resource, data);268return;269}270271const args = buildCollectionArgs(sections, clickedGlobalIndex, widget.viewModel.sessionResource);272await this.commandService.executeCommand(CAROUSEL_COMMAND, args);273}274275private async openSingleImage(resource: URI, data?: Uint8Array): Promise<void> {276if (!data) {277const content = await this.fileService.readFile(resource);278data = content.value.buffer;279}280281const args = buildSingleImageArgs(resource, data);282await this.commandService.executeCommand(CAROUSEL_COMMAND, args);283}284}285286287