diff --git a/package.json b/package.json index 8e7dbace11..4001500248 100644 --- a/package.json +++ b/package.json @@ -4173,7 +4173,8 @@ "capabilities": { "supportsFileAttachments": true, "supportsProblemAttachments": true, - "supportsToolAttachments": false + "supportsToolAttachments": false, + "supportsImageAttachments": true }, "commands": [ { diff --git a/src/extension/agents/copilotcli/node/copilotcliAgentManager.ts b/src/extension/agents/copilotcli/node/copilotcliAgentManager.ts index 5e4901b67d..702d2cc5d8 100644 --- a/src/extension/agents/copilotcli/node/copilotcliAgentManager.ts +++ b/src/extension/agents/copilotcli/node/copilotcliAgentManager.ts @@ -47,13 +47,14 @@ export class CopilotCLIAgentManager extends Disposable { context: vscode.ChatContext, stream: vscode.ChatResponseStream, modelId: ModelProvider | undefined, + imageAttachmentPaths: string[], token: vscode.CancellationToken ): Promise<{ copilotcliSessionId: string | undefined }> { const isNewSession = !copilotcliSessionId; const sessionIdForLog = copilotcliSessionId ?? 'new'; this.logService.trace(`[CopilotCLIAgentManager] Handling request for sessionId=${sessionIdForLog}.`); - const { prompt, attachments } = await this.resolvePrompt(request); + const { prompt, attachments } = await this.resolvePrompt(request, imageAttachmentPaths); // Check if we already have a session wrapper let session = copilotcliSessionId ? this.sessionService.findSessionWrapper(copilotcliSessionId) : undefined; @@ -74,7 +75,7 @@ export class CopilotCLIAgentManager extends Disposable { return { copilotcliSessionId: session.sessionId }; } - private async resolvePrompt(request: vscode.ChatRequest): Promise<{ prompt: string; attachments: Attachment[] }> { + private async resolvePrompt(request: vscode.ChatRequest, imageAttachmentPaths: string[]): Promise<{ prompt: string; attachments: Attachment[] }> { if (request.prompt.startsWith('/')) { return { prompt: request.prompt, attachments: [] }; // likely a slash command, don't modify } @@ -151,6 +152,15 @@ export class CopilotCLIAgentManager extends Disposable { if (diagnosticTexts.length > 0) { reminderParts.push(`The user provided the following diagnostics:\n${diagnosticTexts.join('\n')}`); } + if (imageAttachmentPaths.length > 0) { + const imageAttachmentsText = imageAttachmentPaths.map(p => `- ${p}`).join('\n'); + reminderParts.push(`The user provided the following image attachments:\n${imageAttachmentsText}`); + attachments.push(...imageAttachmentPaths.map(p => ({ + type: 'file', + displayName: path.basename(p), + path: p + }))); + } let prompt = request.prompt; if (reminderParts.length > 0) { diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 19ac6f7978..fd3e98992c 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -14,6 +14,7 @@ import { CopilotCLIAgentManager } from '../../agents/copilotcli/node/copilotcliA import { ICopilotCLISessionService } from '../../agents/copilotcli/node/copilotcliSessionService'; import { buildChatHistoryFromEvents } from '../../agents/copilotcli/node/copilotcliToolInvocationFormatter'; import { ChatSummarizerProvider } from '../../prompt/node/summarizer'; +import { ImageStorage } from './copilotCLIImageSupport'; import { ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; import { CopilotChatSessionsProvider } from './copilotCloudSessionsProvider'; @@ -195,6 +196,7 @@ export class CopilotCLIChatSessionContentProvider implements vscode.ChatSessionC } export class CopilotCLIChatSessionParticipant { + private readonly imageStore: ImageStorage; constructor( private readonly sessionType: string, private readonly copilotcliAgentManager: CopilotCLIAgentManager, @@ -202,18 +204,28 @@ export class CopilotCLIChatSessionParticipant { private readonly sessionItemProvider: CopilotCLIChatSessionItemProvider, private readonly cloudSessionProvider: CopilotChatSessionsProvider | undefined, private readonly summarizer: ChatSummarizerProvider, - @IGitService private readonly gitService: IGitService - ) { } + @IGitService private readonly gitService: IGitService, + @IVSCodeExtensionContext context: IVSCodeExtensionContext, + ) { + this.imageStore = new ImageStorage(context); + } createHandler(): ChatExtendedRequestHandler { return this.handleRequest.bind(this); } private async handleRequest(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise { + const imageAttachmentPaths = await Promise.all(request.references.filter(ref => ref.value instanceof vscode.ChatReferenceBinaryData).map(ref => { + const binaryData = ref.value as vscode.ChatReferenceBinaryData; + return binaryData.data().then(buffer => { + return this.imageStore.storeImage(buffer, binaryData.mimeType).then(uri => uri.fsPath); + }); + })); + const { chatSessionContext } = context; if (chatSessionContext) { if (chatSessionContext.isUntitled) { - const { copilotcliSessionId } = await this.copilotcliAgentManager.handleRequest(undefined, request, context, stream, undefined, token); + const { copilotcliSessionId } = await this.copilotcliAgentManager.handleRequest(undefined, request, context, stream, undefined, imageAttachmentPaths, token); if (!copilotcliSessionId) { stream.warning(localize('copilotcli.failedToCreateSession', "Failed to create a new CopilotCLI session.")); return {}; @@ -255,7 +267,7 @@ export class CopilotCLIChatSessionParticipant { } this.sessionService.setSessionStatus(id, vscode.ChatSessionStatus.InProgress); - await this.copilotcliAgentManager.handleRequest(id, request, context, stream, getModelProvider(_sessionModel.get(id)?.id), token); + await this.copilotcliAgentManager.handleRequest(id, request, context, stream, getModelProvider(_sessionModel.get(id)?.id), imageAttachmentPaths, token); this.sessionService.setSessionStatus(id, vscode.ChatSessionStatus.Completed); return {}; } diff --git a/src/extension/chatSessions/vscode-node/copilotCLIImageSupport.ts b/src/extension/chatSessions/vscode-node/copilotCLIImageSupport.ts new file mode 100644 index 0000000000..156d278bbd --- /dev/null +++ b/src/extension/chatSessions/vscode-node/copilotCLIImageSupport.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { URI } from '../../../util/vs/base/common/uri'; + +export class ImageStorage { + private readonly storageDir: URI; + + constructor(private readonly context: IVSCodeExtensionContext) { + this.storageDir = URI.joinPath(this.context.globalStorageUri, 'copilot-cli-images'); + this.initialize(); + } + + private async initialize(): Promise { + try { + await vscode.workspace.fs.createDirectory(this.storageDir); + await this.cleanupOldImages(); + } catch (error) { + console.error('ImageStorage: Failed to initialize', error); + } + } + + async storeImage(imageData: Uint8Array, mimeType: string): Promise { + const timestamp = Date.now(); + const randomId = Math.random().toString(36).substring(2, 10); + const extension = this.getExtension(mimeType); + const filename = `${timestamp}-${randomId}${extension}`; + const imageUri = URI.joinPath(this.storageDir, filename); + + await vscode.workspace.fs.writeFile(imageUri, imageData); + return imageUri; + } + + async getImage(uri: URI): Promise { + try { + const data = await vscode.workspace.fs.readFile(uri); + return data; + } catch { + return undefined; + } + } + + async deleteImage(uri: URI): Promise { + try { + await vscode.workspace.fs.delete(uri); + } catch { + // Already deleted + } + } + + async cleanupOldImages(maxAgeMs: number = 7 * 24 * 60 * 60 * 1000): Promise { + try { + const entries = await vscode.workspace.fs.readDirectory(this.storageDir); + const now = Date.now(); + const cutoff = now - maxAgeMs; + + for (const [filename, fileType] of entries) { + if (fileType === vscode.FileType.File) { + const fileUri = URI.joinPath(this.storageDir, filename); + try { + const stat = await vscode.workspace.fs.stat(fileUri); + if (stat.mtime < cutoff) { + await vscode.workspace.fs.delete(fileUri); + } + } catch { + // Skip files we can't access + } + } + } + } catch (error) { + console.error('ImageStorage: Failed to cleanup old images', error); + } + } + + private getExtension(mimeType: string): string { + const map: Record = { + 'image/png': '.png', + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/gif': '.gif', + 'image/webp': '.webp', + 'image/bmp': '.bmp', + }; + return map[mimeType.toLowerCase()] || '.bin'; + } +} \ No newline at end of file