Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3685,7 +3685,7 @@
"@humanwhocodes/gitignore-to-minimatch": "1.0.2",
"@microsoft/tiktokenizer": "^1.0.10",
"@roamhq/mac-ca": "^1.0.7",
"@vscode/copilot-api": "^0.1.3",
"@vscode/copilot-api": "^0.1.4",
"@vscode/extension-telemetry": "^1.0.0",
"@vscode/l10n": "^0.0.18",
"@vscode/prompt-tsx": "^0.4.0-alpha.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type Props = PromptElementProps<{
}>;

export class LanguageModelAccessPrompt extends PromptElement<Props> {
render() {
async render() {

const systemMessages: string[] = [];
const chatMessages: (UserMessage | AssistantMessage)[] = [];
Expand All @@ -44,19 +44,20 @@ export class LanguageModelAccessPrompt extends PromptElement<Props> {
const statefulMarkerElement = statefulMarker && <StatefulMarkerContainer statefulMarker={statefulMarker} />;
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>);
} else if (message.role === vscode.LanguageModelChatMessageRole.User) {
message.content.forEach(part => {
for (const part of message.content) {
if (part instanceof vscode.LanguageModelToolResultPart2 || part instanceof vscode.LanguageModelToolResultPart) {
chatMessages.push(
<ToolMessage toolCallId={part.callId}>
<ToolResult content={part.content} />
</ToolMessage>
);
} else if (isImageDataPart(part)) {
chatMessages.push(<UserMessage priority={0}>{imageDataPartToTSX(part)}</UserMessage>);
const imageElement = await imageDataPartToTSX(part);
chatMessages.push(<UserMessage priority={0}>{imageElement}</UserMessage>);
} else if (part instanceof vscode.LanguageModelTextPart) {
chatMessages.push(<UserMessage name={message.name}>{part.value}</UserMessage>);
}
});
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/extension/extension/vscode-node/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { IGithubRepositoryService } from '../../../platform/github/common/github
import { GithubRepositoryService } from '../../../platform/github/node/githubRepositoryService';
import { IIgnoreService } from '../../../platform/ignore/common/ignoreService';
import { VsCodeIgnoreService } from '../../../platform/ignore/vscode-node/ignoreService';
import { IImageService } from '../../../platform/image/common/imageService';
import { ImageServiceImpl } from '../../../platform/image/node/imageServiceImpl';
import { ILanguageContextService } from '../../../platform/languageServer/common/languageContextService';
import { ICompletionsFetchService } from '../../../platform/nesFetch/common/completionsFetchService';
import { CompletionsFetchService } from '../../../platform/nesFetch/node/completionsFetchServiceImpl';
Expand Down Expand Up @@ -121,6 +123,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio
builder.define(IFetcherService, new SyncDescriptor(FetcherService, [undefined]));
builder.define(IDomainService, new SyncDescriptor(DomainService));
builder.define(ICAPIClientService, new SyncDescriptor(CAPIClientImpl));
builder.define(IImageService, new SyncDescriptor(ImageServiceImpl));

builder.define(ITelemetryUserConfig, new SyncDescriptor(TelemetryUserConfigImpl, [undefined, undefined]));
const internalAIKey = extensionContext.extension.packageJSON.internalAIKey ?? '';
Expand Down
31 changes: 23 additions & 8 deletions src/extension/prompts/node/panel/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { RequestType } from '@vscode/copilot-api';
import * as l10n from '@vscode/l10n';
import { BasePromptElementProps, ChatResponseReferencePartStatusKind, PromptElement, PromptReference, PromptSizing, UserMessage, Image as BaseImage } from '@vscode/prompt-tsx';
import { Image as BaseImage, BasePromptElementProps, ChatResponseReferencePartStatusKind, PromptElement, PromptReference, PromptSizing, UserMessage } from '@vscode/prompt-tsx';
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
import { IImageService } from '../../../../platform/image/common/imageService';
import { ILogService } from '../../../../platform/log/common/logService';
import { getMimeType } from '../../../../util/common/imageUtils';
import { Uri } from '../../../../vscodeTypes';
import { IPromptEndpoint } from '../base/promptRenderer';

Expand All @@ -18,7 +23,10 @@ export interface ImageProps extends BasePromptElementProps {
export class Image extends PromptElement<ImageProps, unknown> {
constructor(
props: ImageProps,
@IPromptEndpoint private readonly promptEndpoint: IPromptEndpoint
@IPromptEndpoint private readonly promptEndpoint: IPromptEndpoint,
@IAuthenticationService private readonly authService: IAuthenticationService,
@ILogService private readonly logService: ILogService,
@IImageService private readonly imageService: IImageService
) {
super(props);
}
Expand All @@ -41,16 +49,23 @@ export class Image extends PromptElement<ImageProps, unknown> {
);
}
const variable = await this.props.variableValue;
let decoded = Buffer.from(variable).toString('base64');
const decoder = new TextDecoder();
const decodedString = decoder.decode(variable);
if (/^https?:\/\/.+/.test(decodedString)) {
decoded = decodedString;
let imageSource = Buffer.from(variable).toString('base64');
const isChatCompletions = typeof this.promptEndpoint.urlOrRequestMetadata !== 'string' && this.promptEndpoint.urlOrRequestMetadata.type === RequestType.ChatCompletions;
if (isChatCompletions) {
try {
const githubToken = (await this.authService.getAnyGitHubSession())?.accessToken;
const uri = await this.imageService.uploadChatImageAttachment(variable, this.props.variableName, getMimeType(imageSource) ?? 'image/png', githubToken);
if (uri) {
imageSource = uri.toString();
}
} catch (error) {
this.logService.warn(`Image upload failed, using base64 fallback: ${error}`);
}
}

return (
<UserMessage priority={0}>
<BaseImage src={decoded} detail='high' />
<BaseImage src={imageSource} detail='high' />
{this.props.reference && (
<references value={[new PromptReference(this.props.variableName ? { variableName: this.props.variableName, value: fillerUri } : fillerUri, undefined)]} />
)}
Expand Down
45 changes: 39 additions & 6 deletions src/extension/prompts/node/panel/toolCalling.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { RequestMetadata, RequestType } from '@vscode/copilot-api';
import { AssistantMessage, BasePromptElementProps, PromptRenderer as BasePromptRenderer, Chunk, IfEmpty, Image, JSONTree, PromptElement, PromptElementProps, PromptMetadata, PromptPiece, PromptSizing, TokenLimit, ToolCall, ToolMessage, useKeepWith, UserMessage } from '@vscode/prompt-tsx';
import type { ChatParticipantToolToken, LanguageModelToolResult2, LanguageModelToolTokenizationOptions } from 'vscode';
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';
import { CacheType } from '../../../../platform/endpoint/common/endpointTypes';
import { StatefulMarkerContainer } from '../../../../platform/endpoint/common/statefulMarkerContainer';
import { IImageService } from '../../../../platform/image/common/imageService';
import { ILogService } from '../../../../platform/log/common/logService';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
import { ITokenizer } from '../../../../util/common/tokenizer';
Expand Down Expand Up @@ -297,10 +300,25 @@ enum ToolInvocationOutcome {
Cancelled = 'cancelled',
}

export function imageDataPartToTSX(part: LanguageModelDataPart) {
export async function imageDataPartToTSX(part: LanguageModelDataPart, githubToken?: string, urlOrRequestMetadata?: string | RequestMetadata, logService?: ILogService, imageService?: IImageService) {
if (isImageDataPart(part)) {
const base64 = Buffer.from(part.data).toString('base64');
return <Image src={`data:${part.mimeType};base64,${base64}`} />;
let imageSource = `data:${part.mimeType};base64,${base64}`;
const isChatCompletions = typeof urlOrRequestMetadata !== 'string' && urlOrRequestMetadata?.type === RequestType.ChatCompletions;
if (githubToken && isChatCompletions && imageService) {
try {
const uri = await imageService.uploadChatImageAttachment(part.data, 'tool-result-image', part.mimeType ?? 'image/png', githubToken);
if (uri) {
imageSource = uri.toString();
}
} catch (error) {
if (logService) {
logService.warn(`Image upload failed, using base64 fallback: ${error}`);
}
}
}

return <Image src={imageSource} />;
}
}

Expand Down Expand Up @@ -340,6 +358,17 @@ interface IPrimitiveToolResultProps extends BasePromptElementProps {
}

class PrimitiveToolResult<T extends IPrimitiveToolResultProps> extends PromptElement<T> {

constructor(
props: T,
@IPromptEndpoint protected readonly endpoint: IPromptEndpoint,
@IAuthenticationService private readonly authService: IAuthenticationService,
@ILogService private readonly logService?: ILogService,
@IImageService private readonly imageService?: IImageService
) {
super(props);
}

async render(): Promise<PromptPiece | undefined> {
return (
<>
Expand Down Expand Up @@ -368,8 +397,9 @@ class PrimitiveToolResult<T extends IPrimitiveToolResultProps> extends PromptEle
return part.audience.includes(LanguageModelPartAudience.Assistant);
}

protected onData(part: LanguageModelDataPart) {
return Promise.resolve(imageDataPartToTSX(part));
protected async onData(part: LanguageModelDataPart) {
const githubToken = (await this.authService.getAnyGitHubSession())?.accessToken;
return Promise.resolve(imageDataPartToTSX(part, githubToken, this.endpoint.urlOrRequestMetadata, this.logService, this.imageService));
}

protected onTSX(part: JSONTree.PromptElementJSON) {
Expand All @@ -396,9 +426,12 @@ export interface IToolResultProps extends IPrimitiveToolResultProps {
export class ToolResult extends PrimitiveToolResult<IToolResultProps> {
constructor(
props: PromptElementProps<IToolResultProps>,
@IPromptEndpoint private readonly endpoint: IPromptEndpoint,
@IPromptEndpoint endpoint: IPromptEndpoint,
@IAuthenticationService authService: IAuthenticationService,
@ILogService logService: ILogService,
@IImageService imageService: IImageService
) {
super(props);
super(props, endpoint, authService, logService, imageService);
}

protected override async onTSX(part: JSONTree.PromptElementJSON): Promise<any> {
Expand Down
2 changes: 2 additions & 0 deletions src/extension/test/vscode-node/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import { IToolsService, NullToolsService } from '../../tools/common/toolsService
import { ToolGroupingService } from '../../tools/common/virtualTools/toolGroupingService';
import { ToolGroupingCache } from '../../tools/common/virtualTools/virtualToolGroupCache';
import { IToolGroupingCache, IToolGroupingService } from '../../tools/common/virtualTools/virtualToolTypes';
import { IImageService, nullImageService } from '../../../platform/image/common/imageService';

/**
* A default context for VSCode extension testing, building on general one in `lib`.
Expand All @@ -127,6 +128,7 @@ export function createExtensionTestingServices(): TestingServiceCollection {
testingServiceCollection.define(IWorkspaceService, new SyncDescriptor(ExtensionTextDocumentManager));
testingServiceCollection.define(IExtensionsService, new SyncDescriptor(VSCodeExtensionsService));
testingServiceCollection.define(IChatMLFetcher, new SyncDescriptor(ChatMLFetcherImpl));
testingServiceCollection.define(IImageService, nullImageService);
testingServiceCollection.define(ITabsAndEditorsService, new SyncDescriptor(TabsAndEditorsServiceImpl));
testingServiceCollection.define(IEmbeddingsComputer, new SyncDescriptor(RemoteEmbeddingsComputer));
testingServiceCollection.define(ITelemetryService, new SyncDescriptor(NullTelemetryService));
Expand Down
30 changes: 30 additions & 0 deletions src/platform/image/common/imageService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { createServiceIdentifier } from '../../../util/common/services';
import { URI } from '../../../util/vs/base/common/uri';

export const IImageService = createServiceIdentifier<IImageService>('IImageService');

export interface IImageService {
readonly _serviceBrand: undefined;

/**
* Upload image data to GitHub Copilot chat attachments endpoint
* @param binaryData The image binary data as Uint8Array
* @param name The name for the uploaded file
* @param mimeType The MIME type of the image
* @param token The authentication token for GitHub API
* @returns Promise<URI> The URI of the uploaded image
*/
uploadChatImageAttachment(binaryData: Uint8Array, name: string, mimeType: string | undefined, token: string | undefined): Promise<URI>;
}

export const nullImageService: IImageService = {
_serviceBrand: undefined,
async uploadChatImageAttachment(): Promise<URI> {
throw new Error('Image service not implemented');
}
};
48 changes: 48 additions & 0 deletions src/platform/image/node/imageServiceImpl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { RequestType } from '@vscode/copilot-api';
import { URI } from '../../../util/vs/base/common/uri';
import { ICAPIClientService } from '../../endpoint/common/capiClient';
import { IImageService } from '../common/imageService';

export class ImageServiceImpl implements IImageService {
declare readonly _serviceBrand: undefined;

constructor(
@ICAPIClientService private readonly capiClient: ICAPIClientService,
) { }

async uploadChatImageAttachment(binaryData: Uint8Array, name: string, mimeType: string | undefined, token: string | undefined): Promise<URI> {
if (!mimeType || !token) {
throw new Error('Missing required mimeType or token for image upload');
}

const sanitizedName = name.replace(/[^a-zA-Z0-9._-]/g, '');
let uploadName = sanitizedName;
const subtype = mimeType.split('/')[1].split('+')[0].toLowerCase();
if (!uploadName.toLowerCase().endsWith(`.${subtype}`)) {
uploadName = `${uploadName}.${subtype}`;
}

try {
const response = await this.capiClient.makeRequest<Response>({
method: 'POST',
body: binaryData,
headers: {
'Content-Type': 'application/octet-stream',
Authorization: `Bearer ${token}`,
}
}, { type: RequestType.ChatAttachmentUpload, uploadName, mimeType });
if (!response.ok) {
throw new Error(`Image upload failed: ${response.status} ${response.statusText}`);
}
const result = await response.json() as { url: string };
return URI.parse(result.url);
} catch (error) {
throw new Error(`Error uploading image: ${error}`);
}
}
}
2 changes: 2 additions & 0 deletions src/platform/test/node/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { TestAuthenticationService } from './testAuthenticationService';
import { TestChatAgentService } from './testChatAgentService';
import { TestWorkbenchService } from './testWorkbenchService';
import { TestWorkspaceService } from './testWorkspaceService';
import { IImageService, nullImageService } from '../../image/common/imageService';

/**
* Collects descriptors for services to use in testing.
Expand Down Expand Up @@ -255,6 +256,7 @@ export function createPlatformServices(): TestingServiceCollection {
testingServiceCollection.define(IRunCommandExecutionService, new SyncDescriptor(MockRunCommandExecutionService));
testingServiceCollection.define(INaiveChunkingService, new SyncDescriptor(NaiveChunkingService));
testingServiceCollection.define(IHeatmapService, nullHeatmapService);
testingServiceCollection.define(IImageService, nullImageService);
testingServiceCollection.define(ILanguageContextService, NullLanguageContextService);
testingServiceCollection.define(ILanguageContextProviderService, new SyncDescriptor(NullLanguageContextProviderService));
testingServiceCollection.define(ILanguageDiagnosticsService, new SyncDescriptor(TestLanguageDiagnosticsService));
Expand Down
2 changes: 1 addition & 1 deletion src/util/common/imageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function getWebPDimensions(base64String: string) {
}
}

function getMimeType(base64String: string): string | undefined {
export function getMimeType(base64String: string): string | undefined {
const mimeTypes: { [key: string]: string } = {
'/9j/': 'image/jpeg',
'iVBOR': 'image/png',
Expand Down