Skip to content

Commit df984eb

Browse files
authored
make sure to resize images coming from tool calls (microsoft#257785)
* make sure to resize images coming from tool calls * try/catch, resize in the right areas * new image service * some cleanup * clean up service usage * no more electron main service * app.ts cleanup * address comments * wrap in try catch and revert * proper revert
1 parent 3c4789f commit df984eb

File tree

8 files changed

+227
-9
lines changed

8 files changed

+227
-9
lines changed
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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 { decodeBase64, VSBuffer } from '../../../base/common/buffer.js';
7+
import { joinPath } from '../../../base/common/resources.js';
8+
import { URI } from '../../../base/common/uri.js';
9+
import { IFileService } from '../../files/common/files.js';
10+
import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js';
11+
import { ILogService } from '../../log/common/log.js';
12+
import { IImageResizeService } from '../common/imageResizeService.js';
13+
14+
15+
export class ImageResizeService implements IImageResizeService {
16+
17+
declare readonly _serviceBrand: undefined;
18+
19+
/**
20+
* Resizes an image provided as a UInt8Array string. Resizing is based on Open AI's algorithm for tokenzing images.
21+
* https://platform.openai.com/docs/guides/vision#calculating-costs
22+
* @param data - The UInt8Array string of the image to resize.
23+
* @returns A promise that resolves to the UInt8Array string of the resized image.
24+
*/
25+
26+
async resizeImage(data: Uint8Array | string, mimeType?: string): Promise<Uint8Array> {
27+
const isGif = mimeType === 'image/gif';
28+
29+
if (typeof data === 'string') {
30+
data = this.convertStringToUInt8Array(data);
31+
}
32+
33+
return new Promise((resolve, reject) => {
34+
const blob = new Blob([data as Uint8Array<ArrayBuffer>], { type: mimeType });
35+
const img = new Image();
36+
const url = URL.createObjectURL(blob);
37+
img.src = url;
38+
39+
img.onload = () => {
40+
URL.revokeObjectURL(url);
41+
let { width, height } = img;
42+
43+
if ((width <= 768 || height <= 768) && !isGif) {
44+
resolve(data);
45+
return;
46+
}
47+
48+
// Calculate the new dimensions while maintaining the aspect ratio
49+
if (width > 2048 || height > 2048) {
50+
const scaleFactor = 2048 / Math.max(width, height);
51+
width = Math.round(width * scaleFactor);
52+
height = Math.round(height * scaleFactor);
53+
}
54+
55+
const scaleFactor = 768 / Math.min(width, height);
56+
width = Math.round(width * scaleFactor);
57+
height = Math.round(height * scaleFactor);
58+
59+
const canvas = document.createElement('canvas');
60+
canvas.width = width;
61+
canvas.height = height;
62+
const ctx = canvas.getContext('2d');
63+
if (ctx) {
64+
ctx.drawImage(img, 0, 0, width, height);
65+
canvas.toBlob((blob) => {
66+
if (blob) {
67+
const reader = new FileReader();
68+
reader.onload = () => {
69+
resolve(new Uint8Array(reader.result as ArrayBuffer));
70+
};
71+
reader.onerror = (error) => reject(error);
72+
reader.readAsArrayBuffer(blob);
73+
} else {
74+
reject(new Error('Failed to create blob from canvas'));
75+
}
76+
}, mimeType || 'image/png');
77+
} else {
78+
reject(new Error('Failed to get canvas context'));
79+
}
80+
};
81+
img.onerror = (error) => {
82+
URL.revokeObjectURL(url);
83+
reject(error);
84+
};
85+
});
86+
}
87+
88+
convertStringToUInt8Array(data: string): Uint8Array {
89+
const base64Data = data.includes(',') ? data.split(',')[1] : data;
90+
if (this.isValidBase64(base64Data)) {
91+
return decodeBase64(base64Data).buffer;
92+
}
93+
return new TextEncoder().encode(data);
94+
}
95+
96+
// Only used for URLs
97+
convertUint8ArrayToString(data: Uint8Array): string {
98+
try {
99+
const decoder = new TextDecoder();
100+
const decodedString = decoder.decode(data);
101+
return decodedString;
102+
} catch {
103+
return '';
104+
}
105+
}
106+
107+
isValidBase64(str: string): boolean {
108+
try {
109+
decodeBase64(str);
110+
return true;
111+
} catch {
112+
return false;
113+
}
114+
}
115+
116+
async createFileForMedia(fileService: IFileService, imagesFolder: URI, dataTransfer: Uint8Array, mimeType: string): Promise<URI | undefined> {
117+
const exists = await fileService.exists(imagesFolder);
118+
if (!exists) {
119+
await fileService.createFolder(imagesFolder);
120+
}
121+
122+
const ext = mimeType.split('/')[1] || 'png';
123+
const filename = `image-${Date.now()}.${ext}`;
124+
const fileUri = joinPath(imagesFolder, filename);
125+
126+
const buffer = VSBuffer.wrap(dataTransfer);
127+
await fileService.writeFile(fileUri, buffer);
128+
129+
return fileUri;
130+
}
131+
132+
async cleanupOldImages(fileService: IFileService, logService: ILogService, imagesFolder: URI): Promise<void> {
133+
const exists = await fileService.exists(imagesFolder);
134+
if (!exists) {
135+
return;
136+
}
137+
138+
const duration = 7 * 24 * 60 * 60 * 1000; // 7 days
139+
const files = await fileService.resolve(imagesFolder);
140+
if (!files.children) {
141+
return;
142+
}
143+
144+
await Promise.all(files.children.map(async (file) => {
145+
try {
146+
const timestamp = this.getTimestampFromFilename(file.name);
147+
if (timestamp && (Date.now() - timestamp > duration)) {
148+
await fileService.del(file.resource);
149+
}
150+
} catch (err) {
151+
logService.error('Failed to clean up old images', err);
152+
}
153+
}));
154+
}
155+
156+
getTimestampFromFilename(filename: string): number | undefined {
157+
const match = filename.match(/image-(\d+)\./);
158+
if (match) {
159+
return parseInt(match[1], 10);
160+
}
161+
return undefined;
162+
}
163+
164+
165+
}
166+
167+
registerSingleton(IImageResizeService, ImageResizeService, InstantiationType.Delayed);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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 { createDecorator } from '../../instantiation/common/instantiation.js';
7+
8+
export const IImageResizeService = createDecorator<IImageResizeService>('imageResizeMainService');
9+
10+
11+
export interface IImageResizeService {
12+
13+
readonly _serviceBrand: undefined;
14+
15+
/**
16+
* Resizes an image to a maximum dimension of 768px while maintaining aspect ratio.
17+
*/
18+
resizeImage(data: Uint8Array | string, mimeType?: string): Promise<Uint8Array>;
19+
}

src/vs/workbench/contrib/chat/browser/contrib/chatInputCompletions.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { ToolSet } from '../../common/languageModelToolsService.js';
5656
import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js';
5757
import { ChatSubmitAction } from '../actions/chatExecuteActions.js';
5858
import { IChatWidget, IChatWidgetService } from '../chat.js';
59+
import { resizeImage } from '../imageUtils.js';
5960
import { ChatDynamicVariableModel } from './chatDynamicVariables.js';
6061

6162
class SlashCommandCompletions extends Disposable {
@@ -647,11 +648,13 @@ class StartParameterizedPromptAction extends Action2 {
647648
});
648649
}
649650
} else if (mimeType && getAttachableImageExtension(mimeType)) {
651+
const resized = await resizeImage(contents)
652+
.catch(() => decodeBase64(contents).buffer);
650653
chatWidget.attachmentModel.addContext({
651654
id: generateUuid(),
652655
name: localize('mcp.prompt.image', 'Prompt Image'),
653656
fullName: localize('mcp.prompt.image', 'Prompt Image'),
654-
value: decodeBase64(contents).buffer,
657+
value: resized,
655658
kind: 'image',
656659
references: validURI && [{ reference: validURI, kind: 'reference' }],
657660
});

src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { basename } from '../../../../base/common/resources.js';
1515
import { URI } from '../../../../base/common/uri.js';
1616
import { localize } from '../../../../nls.js';
1717
import { IFileService } from '../../../../platform/files/common/files.js';
18+
import { IImageResizeService } from '../../../../platform/imageResize/common/imageResizeService.js';
1819
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
1920
import { IProductService } from '../../../../platform/product/common/productService.js';
2021
import { StorageScope } from '../../../../platform/storage/common/storage.js';
@@ -163,6 +164,7 @@ class McpToolImplementation implements IToolImpl {
163164
private readonly _server: IMcpServer,
164165
@IProductService private readonly _productService: IProductService,
165166
@IFileService private readonly _fileService: IFileService,
167+
@IImageResizeService private readonly _imageResizeService: IImageResizeService,
166168
) { }
167169

168170
async prepareToolInvocation(context: IToolInvocationPreparationContext): Promise<IPreparedToolInvocation> {
@@ -222,14 +224,18 @@ class McpToolImplementation implements IToolImpl {
222224
}
223225
}
224226

225-
// Rewrite image rsources to images so they are inlined nicely
226-
const addAsInlineData = (mimeType: string, value: string, uri?: URI) => {
227+
// Rewrite image resources to images so they are inlined nicely
228+
const addAsInlineData = async (mimeType: string, value: string, uri?: URI): Promise<VSBuffer | void> => {
227229
details.output.push({ type: 'embed', mimeType, value, uri });
228230
if (isForModel) {
229-
result.content.push({
230-
kind: 'data',
231-
value: { mimeType, data: decodeBase64(value) }
232-
});
231+
let finalData: VSBuffer;
232+
try {
233+
const resized = await this._imageResizeService.resizeImage(decodeBase64(value).buffer, mimeType);
234+
finalData = VSBuffer.wrap(resized);
235+
} catch {
236+
finalData = decodeBase64(value);
237+
}
238+
result.content.push({ kind: 'data', value: { mimeType, data: finalData } });
233239
}
234240
};
235241

@@ -246,7 +252,7 @@ class McpToolImplementation implements IToolImpl {
246252
}
247253
} else if (item.type === 'image' || item.type === 'audio') {
248254
// default to some image type if not given to hint
249-
addAsInlineData(item.mimeType || 'image/png', item.data);
255+
await addAsInlineData(item.mimeType || 'image/png', item.data);
250256
} else if (item.type === 'resource_link') {
251257
const uri = McpResourceURI.fromServer(this._server.definition, item.uri);
252258
details.output.push({
@@ -274,7 +280,7 @@ class McpToolImplementation implements IToolImpl {
274280
} else if (item.type === 'resource') {
275281
const uri = McpResourceURI.fromServer(this._server.definition, item.resource.uri);
276282
if (item.resource.mimeType && getAttachableImageExtension(item.resource.mimeType) && 'blob' in item.resource) {
277-
addAsInlineData(item.resource.mimeType, item.resource.blob, uri);
283+
await addAsInlineData(item.resource.mimeType, item.resource.blob, uri);
278284
} else {
279285
details.output.push({
280286
type: 'embed',
@@ -305,4 +311,5 @@ class McpToolImplementation implements IToolImpl {
305311
result.toolResultDetails = details;
306312
return result;
307313
}
314+
308315
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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 { IImageResizeService } from '../../../../platform/imageResize/common/imageResizeService.js';
7+
import { ImageResizeService } from '../../../../platform/imageResize/browser/imageResizeService.js';
8+
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
9+
10+
registerSingleton(IImageResizeService, ImageResizeService, InstantiationType.Delayed);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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 { IImageResizeService } from '../../../../platform/imageResize/common/imageResizeService.js';
7+
import { ImageResizeService } from '../../../../platform/imageResize/browser/imageResizeService.js';
8+
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
9+
10+
registerSingleton(IImageResizeService, ImageResizeService, InstantiationType.Delayed);

src/vs/workbench/workbench.desktop.main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import './services/themes/electron-browser/nativeHostColorSchemeService.js';
5555
import './services/extensionManagement/electron-browser/extensionManagementService.js';
5656
import './services/mcp/electron-browser/mcpWorkbenchManagementService.js';
5757
import './services/encryption/electron-browser/encryptionService.js';
58+
import './services/imageResize/electron-browser/imageResizeService.js';
5859
import './services/browserElements/electron-browser/browserElementsService.js';
5960
import './services/secrets/electron-browser/secretStorageService.js';
6061
import './services/localization/electron-browser/languagePackService.js';

src/vs/workbench/workbench.web.main.internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import './services/localization/browser/localeService.js';
5858
import './services/path/browser/pathService.js';
5959
import './services/themes/browser/browserHostColorSchemeService.js';
6060
import './services/encryption/browser/encryptionService.js';
61+
import './services/imageResize/browser/imageResizeService.js';
6162
import './services/secrets/browser/secretStorageService.js';
6263
import './services/workingCopy/browser/workingCopyBackupService.js';
6364
import './services/tunnel/browser/tunnelService.js';

0 commit comments

Comments
 (0)