Skip to content

Commit f27c099

Browse files
authored
use image uploader in chat (#499)
* image uploader in chat extension * fix comment * cleanup * use capi client + extract into own service * capi version bump + injected service * cleanup * add service in promptrenderer * add null image service for tets * add null service in vscode node too * trying to remove unsued service in prompt renderer * cleanup spacing * use urlREquestMetaData instead * remove vendor * remove whitespace * some more cleanup * more robust mimetype check * add exp setting
1 parent cb365f3 commit f27c099

File tree

12 files changed

+180
-24
lines changed

12 files changed

+180
-24
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3685,7 +3685,7 @@
36853685
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
36863686
"@microsoft/tiktokenizer": "^1.0.10",
36873687
"@roamhq/mac-ca": "^1.0.7",
3688-
"@vscode/copilot-api": "^0.1.3",
3688+
"@vscode/copilot-api": "^0.1.4",
36893689
"@vscode/extension-telemetry": "^1.0.0",
36903690
"@vscode/l10n": "^0.0.18",
36913691
"@vscode/prompt-tsx": "^0.4.0-alpha.5",

src/extension/conversation/vscode-node/languageModelAccessPrompt.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export type Props = PromptElementProps<{
2020
}>;
2121

2222
export class LanguageModelAccessPrompt extends PromptElement<Props> {
23-
render() {
23+
async render() {
2424

2525
const systemMessages: string[] = [];
2626
const chatMessages: (UserMessage | AssistantMessage)[] = [];
@@ -44,19 +44,20 @@ export class LanguageModelAccessPrompt extends PromptElement<Props> {
4444
const statefulMarkerElement = statefulMarker && <StatefulMarkerContainer statefulMarker={statefulMarker} />;
4545
chatMessages.push(<AssistantMessage name={message.name} toolCalls={toolCalls.map(tc => ({ id: tc.callId, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.input) } }))}>{statefulMarkerElement}{content?.value}</AssistantMessage>);
4646
} else if (message.role === vscode.LanguageModelChatMessageRole.User) {
47-
message.content.forEach(part => {
47+
for (const part of message.content) {
4848
if (part instanceof vscode.LanguageModelToolResultPart2 || part instanceof vscode.LanguageModelToolResultPart) {
4949
chatMessages.push(
5050
<ToolMessage toolCallId={part.callId}>
5151
<ToolResult content={part.content} />
5252
</ToolMessage>
5353
);
5454
} else if (isImageDataPart(part)) {
55-
chatMessages.push(<UserMessage priority={0}>{imageDataPartToTSX(part)}</UserMessage>);
55+
const imageElement = await imageDataPartToTSX(part);
56+
chatMessages.push(<UserMessage priority={0}>{imageElement}</UserMessage>);
5657
} else if (part instanceof vscode.LanguageModelTextPart) {
5758
chatMessages.push(<UserMessage name={message.name}>{part.value}</UserMessage>);
5859
}
59-
});
60+
}
6061
}
6162
}
6263

src/extension/extension/vscode-node/services.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { IGithubRepositoryService } from '../../../platform/github/common/github
2828
import { GithubRepositoryService } from '../../../platform/github/node/githubRepositoryService';
2929
import { IIgnoreService } from '../../../platform/ignore/common/ignoreService';
3030
import { VsCodeIgnoreService } from '../../../platform/ignore/vscode-node/ignoreService';
31+
import { IImageService } from '../../../platform/image/common/imageService';
32+
import { ImageServiceImpl } from '../../../platform/image/node/imageServiceImpl';
3133
import { ILanguageContextService } from '../../../platform/languageServer/common/languageContextService';
3234
import { ICompletionsFetchService } from '../../../platform/nesFetch/common/completionsFetchService';
3335
import { CompletionsFetchService } from '../../../platform/nesFetch/node/completionsFetchServiceImpl';
@@ -121,6 +123,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio
121123
builder.define(IFetcherService, new SyncDescriptor(FetcherService, [undefined]));
122124
builder.define(IDomainService, new SyncDescriptor(DomainService));
123125
builder.define(ICAPIClientService, new SyncDescriptor(CAPIClientImpl));
126+
builder.define(IImageService, new SyncDescriptor(ImageServiceImpl));
124127

125128
builder.define(ITelemetryUserConfig, new SyncDescriptor(TelemetryUserConfigImpl, [undefined, undefined]));
126129
const internalAIKey = extensionContext.extension.packageJSON.internalAIKey ?? '';

src/extension/prompts/node/panel/image.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { RequestType } from '@vscode/copilot-api';
67
import * as l10n from '@vscode/l10n';
7-
import { BasePromptElementProps, ChatResponseReferencePartStatusKind, PromptElement, PromptReference, PromptSizing, UserMessage, Image as BaseImage } from '@vscode/prompt-tsx';
8+
import { Image as BaseImage, BasePromptElementProps, ChatResponseReferencePartStatusKind, PromptElement, PromptReference, PromptSizing, UserMessage } from '@vscode/prompt-tsx';
9+
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
10+
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
11+
import { IImageService } from '../../../../platform/image/common/imageService';
12+
import { ILogService } from '../../../../platform/log/common/logService';
13+
import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';
14+
import { getMimeType } from '../../../../util/common/imageUtils';
815
import { Uri } from '../../../../vscodeTypes';
916
import { IPromptEndpoint } from '../base/promptRenderer';
1017

@@ -18,7 +25,12 @@ export interface ImageProps extends BasePromptElementProps {
1825
export class Image extends PromptElement<ImageProps, unknown> {
1926
constructor(
2027
props: ImageProps,
21-
@IPromptEndpoint private readonly promptEndpoint: IPromptEndpoint
28+
@IPromptEndpoint private readonly promptEndpoint: IPromptEndpoint,
29+
@IAuthenticationService private readonly authService: IAuthenticationService,
30+
@ILogService private readonly logService: ILogService,
31+
@IImageService private readonly imageService: IImageService,
32+
@IConfigurationService private readonly configurationService: IConfigurationService,
33+
@IExperimentationService private readonly experimentationService: IExperimentationService
2234
) {
2335
super(props);
2436
}
@@ -41,16 +53,24 @@ export class Image extends PromptElement<ImageProps, unknown> {
4153
);
4254
}
4355
const variable = await this.props.variableValue;
44-
let decoded = Buffer.from(variable).toString('base64');
45-
const decoder = new TextDecoder();
46-
const decodedString = decoder.decode(variable);
47-
if (/^https?:\/\/.+/.test(decodedString)) {
48-
decoded = decodedString;
56+
let imageSource = Buffer.from(variable).toString('base64');
57+
const isChatCompletions = typeof this.promptEndpoint.urlOrRequestMetadata !== 'string' && this.promptEndpoint.urlOrRequestMetadata.type === RequestType.ChatCompletions;
58+
const enabled = this.configurationService.getExperimentBasedConfig(ConfigKey.Internal.EnableChatImageUpload, this.experimentationService);
59+
if (isChatCompletions && enabled) {
60+
try {
61+
const githubToken = (await this.authService.getAnyGitHubSession())?.accessToken;
62+
const uri = await this.imageService.uploadChatImageAttachment(variable, this.props.variableName, getMimeType(imageSource) ?? 'image/png', githubToken);
63+
if (uri) {
64+
imageSource = uri.toString();
65+
}
66+
} catch (error) {
67+
this.logService.warn(`Image upload failed, using base64 fallback: ${error}`);
68+
}
4969
}
5070

5171
return (
5272
<UserMessage priority={0}>
53-
<BaseImage src={decoded} detail='high' />
73+
<BaseImage src={imageSource} detail='high' />
5474
{this.props.reference && (
5575
<references value={[new PromptReference(this.props.variableName ? { variableName: this.props.variableName, value: fillerUri } : fillerUri, undefined)]} />
5676
)}

src/extension/prompts/node/panel/toolCalling.tsx

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { RequestMetadata, RequestType } from '@vscode/copilot-api';
67
import { AssistantMessage, BasePromptElementProps, PromptRenderer as BasePromptRenderer, Chunk, IfEmpty, Image, JSONTree, PromptElement, PromptElementProps, PromptMetadata, PromptPiece, PromptSizing, TokenLimit, ToolCall, ToolMessage, useKeepWith, UserMessage } from '@vscode/prompt-tsx';
78
import type { ChatParticipantToolToken, LanguageModelToolResult2, LanguageModelToolTokenizationOptions } from 'vscode';
9+
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
10+
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
811
import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';
912
import { CacheType } from '../../../../platform/endpoint/common/endpointTypes';
1013
import { StatefulMarkerContainer } from '../../../../platform/endpoint/common/statefulMarkerContainer';
14+
import { IImageService } from '../../../../platform/image/common/imageService';
1115
import { ILogService } from '../../../../platform/log/common/logService';
16+
import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';
1217
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
1318
import { ITokenizer } from '../../../../util/common/tokenizer';
1419
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
@@ -297,10 +302,25 @@ enum ToolInvocationOutcome {
297302
Cancelled = 'cancelled',
298303
}
299304

300-
export function imageDataPartToTSX(part: LanguageModelDataPart) {
305+
export async function imageDataPartToTSX(part: LanguageModelDataPart, githubToken?: string, urlOrRequestMetadata?: string | RequestMetadata, logService?: ILogService, imageService?: IImageService) {
301306
if (isImageDataPart(part)) {
302307
const base64 = Buffer.from(part.data).toString('base64');
303-
return <Image src={`data:${part.mimeType};base64,${base64}`} />;
308+
let imageSource = `data:${part.mimeType};base64,${base64}`;
309+
const isChatCompletions = typeof urlOrRequestMetadata !== 'string' && urlOrRequestMetadata?.type === RequestType.ChatCompletions;
310+
if (githubToken && isChatCompletions && imageService) {
311+
try {
312+
const uri = await imageService.uploadChatImageAttachment(part.data, 'tool-result-image', part.mimeType ?? 'image/png', githubToken);
313+
if (uri) {
314+
imageSource = uri.toString();
315+
}
316+
} catch (error) {
317+
if (logService) {
318+
logService.warn(`Image upload failed, using base64 fallback: ${error}`);
319+
}
320+
}
321+
}
322+
323+
return <Image src={imageSource} />;
304324
}
305325
}
306326

@@ -340,6 +360,19 @@ interface IPrimitiveToolResultProps extends BasePromptElementProps {
340360
}
341361

342362
class PrimitiveToolResult<T extends IPrimitiveToolResultProps> extends PromptElement<T> {
363+
364+
constructor(
365+
props: T,
366+
@IPromptEndpoint protected readonly endpoint: IPromptEndpoint,
367+
@IAuthenticationService private readonly authService: IAuthenticationService,
368+
@ILogService private readonly logService?: ILogService,
369+
@IImageService private readonly imageService?: IImageService,
370+
@IConfigurationService private readonly configurationService?: IConfigurationService,
371+
@IExperimentationService private readonly experimentationService?: IExperimentationService
372+
) {
373+
super(props);
374+
}
375+
343376
async render(): Promise<PromptPiece | undefined> {
344377
return (
345378
<>
@@ -368,8 +401,13 @@ class PrimitiveToolResult<T extends IPrimitiveToolResultProps> extends PromptEle
368401
return part.audience.includes(LanguageModelPartAudience.Assistant);
369402
}
370403

371-
protected onData(part: LanguageModelDataPart) {
372-
return Promise.resolve(imageDataPartToTSX(part));
404+
protected async onData(part: LanguageModelDataPart) {
405+
const githubToken = (await this.authService.getAnyGitHubSession())?.accessToken;
406+
const uploadsEnabled = this.configurationService && this.experimentationService
407+
? this.configurationService.getExperimentBasedConfig(ConfigKey.Internal.EnableChatImageUpload, this.experimentationService)
408+
: false;
409+
const effectiveToken = uploadsEnabled ? githubToken : undefined;
410+
return Promise.resolve(imageDataPartToTSX(part, effectiveToken, this.endpoint.urlOrRequestMetadata, this.logService, this.imageService));
373411
}
374412

375413
protected onTSX(part: JSONTree.PromptElementJSON) {
@@ -396,9 +434,14 @@ export interface IToolResultProps extends IPrimitiveToolResultProps {
396434
export class ToolResult extends PrimitiveToolResult<IToolResultProps> {
397435
constructor(
398436
props: PromptElementProps<IToolResultProps>,
399-
@IPromptEndpoint private readonly endpoint: IPromptEndpoint,
437+
@IPromptEndpoint endpoint: IPromptEndpoint,
438+
@IAuthenticationService authService: IAuthenticationService,
439+
@ILogService logService: ILogService,
440+
@IImageService imageService: IImageService,
441+
@IConfigurationService configurationService: IConfigurationService,
442+
@IExperimentationService experimentationService: IExperimentationService
400443
) {
401-
super(props);
444+
super(props, endpoint, authService, logService, imageService, configurationService, experimentationService);
402445
}
403446

404447
protected override async onTSX(part: JSONTree.PromptElementJSON): Promise<any> {

src/extension/test/vscode-node/services.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import { IToolsService, NullToolsService } from '../../tools/common/toolsService
102102
import { ToolGroupingService } from '../../tools/common/virtualTools/toolGroupingService';
103103
import { ToolGroupingCache } from '../../tools/common/virtualTools/virtualToolGroupCache';
104104
import { IToolGroupingCache, IToolGroupingService } from '../../tools/common/virtualTools/virtualToolTypes';
105+
import { IImageService, nullImageService } from '../../../platform/image/common/imageService';
105106

106107
/**
107108
* A default context for VSCode extension testing, building on general one in `lib`.
@@ -127,6 +128,7 @@ export function createExtensionTestingServices(): TestingServiceCollection {
127128
testingServiceCollection.define(IWorkspaceService, new SyncDescriptor(ExtensionTextDocumentManager));
128129
testingServiceCollection.define(IExtensionsService, new SyncDescriptor(VSCodeExtensionsService));
129130
testingServiceCollection.define(IChatMLFetcher, new SyncDescriptor(ChatMLFetcherImpl));
131+
testingServiceCollection.define(IImageService, nullImageService);
130132
testingServiceCollection.define(ITabsAndEditorsService, new SyncDescriptor(TabsAndEditorsServiceImpl));
131133
testingServiceCollection.define(IEmbeddingsComputer, new SyncDescriptor(RemoteEmbeddingsComputer));
132134
testingServiceCollection.define(ITelemetryService, new SyncDescriptor(NullTelemetryService));

src/platform/configuration/common/configurationService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,8 @@ export namespace ConfigKey {
704704
export const AgentHistorySummarizationForceGpt41 = defineExpSetting<boolean | undefined>('chat.advanced.agentHistorySummarizationForceGpt41', false, INTERNAL_RESTRICTED);
705705
export const UseResponsesApiTruncation = defineSetting<boolean | undefined>('chat.advanced.useResponsesApiTruncation', false, INTERNAL_RESTRICTED);
706706

707+
export const EnableChatImageUpload = defineExpSetting<boolean>('chat.advanced.imageUpload', false, INTERNAL);
708+
707709
export const EnableReadFileV2 = defineExpSetting<boolean>('chat.advanced.enableReadFileV2', isPreRelease, INTERNAL_RESTRICTED);
708710
export const AskAgent = defineExpSetting<boolean>('chat.advanced.enableAskAgent', { defaultValue: false, teamDefaultValue: true, internalDefaultValue: true }, INTERNAL_RESTRICTED);
709711
export const VerifyTextDocumentChanges = defineExpSetting<boolean>('chat.advanced.inlineEdits.verifyTextDocumentChanges', true, INTERNAL_RESTRICTED);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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 { createServiceIdentifier } from '../../../util/common/services';
7+
import { URI } from '../../../util/vs/base/common/uri';
8+
9+
export const IImageService = createServiceIdentifier<IImageService>('IImageService');
10+
11+
export interface IImageService {
12+
readonly _serviceBrand: undefined;
13+
14+
/**
15+
* Upload image data to GitHub Copilot chat attachments endpoint
16+
* @param binaryData The image binary data as Uint8Array
17+
* @param name The name for the uploaded file
18+
* @param mimeType The MIME type of the image
19+
* @param token The authentication token for GitHub API
20+
* @returns Promise<URI> The URI of the uploaded image
21+
*/
22+
uploadChatImageAttachment(binaryData: Uint8Array, name: string, mimeType: string | undefined, token: string | undefined): Promise<URI>;
23+
}
24+
25+
export const nullImageService: IImageService = {
26+
_serviceBrand: undefined,
27+
async uploadChatImageAttachment(): Promise<URI> {
28+
throw new Error('Image service not implemented');
29+
}
30+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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 { RequestType } from '@vscode/copilot-api';
7+
import { URI } from '../../../util/vs/base/common/uri';
8+
import { ICAPIClientService } from '../../endpoint/common/capiClient';
9+
import { IImageService } from '../common/imageService';
10+
11+
export class ImageServiceImpl implements IImageService {
12+
declare readonly _serviceBrand: undefined;
13+
14+
constructor(
15+
@ICAPIClientService private readonly capiClient: ICAPIClientService,
16+
) { }
17+
18+
async uploadChatImageAttachment(binaryData: Uint8Array, name: string, mimeType: string | undefined, token: string | undefined): Promise<URI> {
19+
if (!mimeType || !token) {
20+
throw new Error('Missing required mimeType or token for image upload');
21+
}
22+
23+
const sanitizedName = name.replace(/[^a-zA-Z0-9._-]/g, '');
24+
let uploadName = sanitizedName;
25+
26+
// can catch unexpected types like "IMAGE/JPEG", "image/svg+xml", or "image/png; charset=UTF-8"
27+
const subtypeMatch = mimeType.toLowerCase().match(/^[^\/]+\/([^+;]+)/);
28+
const subtype = subtypeMatch?.[1];
29+
30+
// add the extension if it is missing.
31+
if (subtype && !uploadName.toLowerCase().endsWith(`.${subtype}`)) {
32+
uploadName = `${uploadName}.${subtype}`;
33+
}
34+
35+
try {
36+
const response = await this.capiClient.makeRequest<Response>({
37+
method: 'POST',
38+
body: binaryData,
39+
headers: {
40+
'Content-Type': 'application/octet-stream',
41+
Authorization: `Bearer ${token}`,
42+
}
43+
}, { type: RequestType.ChatAttachmentUpload, uploadName, mimeType });
44+
if (!response.ok) {
45+
throw new Error(`Image upload failed: ${response.status} ${response.statusText}`);
46+
}
47+
const result = await response.json() as { url: string };
48+
return URI.parse(result.url);
49+
} catch (error) {
50+
throw new Error(`Error uploading image: ${error}`);
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)