Path: blob/main/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts
3296 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*--------------------------------------------------------------------------------------------*/4import { CancellationToken } from '../../../../base/common/cancellation.js';5import { Codicon } from '../../../../base/common/codicons.js';6import { createStringDataTransferItem, IDataTransferItem, IReadonlyVSDataTransfer, VSDataTransfer } from '../../../../base/common/dataTransfer.js';7import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js';8import { Disposable } from '../../../../base/common/lifecycle.js';9import { revive } from '../../../../base/common/marshalling.js';10import { Mimes } from '../../../../base/common/mime.js';11import { Schemas } from '../../../../base/common/network.js';12import { basename, joinPath } from '../../../../base/common/resources.js';13import { URI, UriComponents } from '../../../../base/common/uri.js';14import { IRange } from '../../../../editor/common/core/range.js';15import { DocumentPasteContext, DocumentPasteEdit, DocumentPasteEditProvider, DocumentPasteEditsSession } from '../../../../editor/common/languages.js';16import { ITextModel } from '../../../../editor/common/model.js';17import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';18import { IModelService } from '../../../../editor/common/services/model.js';19import { localize } from '../../../../nls.js';20import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';21import { IFileService } from '../../../../platform/files/common/files.js';22import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';23import { ILogService } from '../../../../platform/log/common/log.js';24import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';25import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry } from '../common/chatVariableEntries.js';26import { IChatVariablesService, IDynamicVariable } from '../common/chatVariables.js';27import { IChatWidgetService } from './chat.js';28import { ChatDynamicVariableModel } from './contrib/chatDynamicVariables.js';29import { cleanupOldImages, createFileForMedia, resizeImage } from './imageUtils.js';3031const COPY_MIME_TYPES = 'application/vnd.code.additional-editor-data';3233interface SerializedCopyData {34readonly uri: UriComponents;35readonly range: IRange;36}3738export class PasteImageProvider implements DocumentPasteEditProvider {39private readonly imagesFolder: URI;4041public readonly kind = new HierarchicalKind('chat.attach.image');42public readonly providedPasteEditKinds = [this.kind];4344public readonly copyMimeTypes = [];45public readonly pasteMimeTypes = ['image/*'];4647constructor(48private readonly chatWidgetService: IChatWidgetService,49private readonly extensionService: IExtensionService,50@IFileService private readonly fileService: IFileService,51@IEnvironmentService private readonly environmentService: IEnvironmentService,52@ILogService private readonly logService: ILogService,53) {54this.imagesFolder = joinPath(this.environmentService.workspaceStorageHome, 'vscode-chat-images');55cleanupOldImages(this.fileService, this.logService, this.imagesFolder,);56}5758async provideDocumentPasteEdits(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, context: DocumentPasteContext, token: CancellationToken): Promise<DocumentPasteEditsSession | undefined> {59if (!this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData'))) {60return;61}6263const supportedMimeTypes = [64'image/png',65'image/jpeg',66'image/jpg',67'image/bmp',68'image/gif',69'image/tiff'70];7172let mimeType: string | undefined;73let imageItem: IDataTransferItem | undefined;7475// Find the first matching image type in the dataTransfer76for (const type of supportedMimeTypes) {77imageItem = dataTransfer.get(type);78if (imageItem) {79mimeType = type;80break;81}82}8384if (!imageItem || !mimeType) {85return;86}87const currClipboard = await imageItem.asFile()?.data();88if (token.isCancellationRequested || !currClipboard) {89return;90}9192const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);93if (!widget) {94return;95}9697const attachedVariables = widget.attachmentModel.attachments;98const displayName = localize('pastedImageName', 'Pasted Image');99let tempDisplayName = displayName;100101for (let appendValue = 2; attachedVariables.some(attachment => attachment.name === tempDisplayName); appendValue++) {102tempDisplayName = `${displayName} ${appendValue}`;103}104105const fileReference = await createFileForMedia(this.fileService, this.imagesFolder, currClipboard, mimeType);106if (token.isCancellationRequested || !fileReference) {107return;108}109110const scaledImageData = await resizeImage(currClipboard);111if (token.isCancellationRequested || !scaledImageData) {112return;113}114115const scaledImageContext = await getImageAttachContext(scaledImageData, mimeType, token, tempDisplayName, fileReference);116if (token.isCancellationRequested || !scaledImageContext) {117return;118}119120widget.attachmentModel.addContext(scaledImageContext);121122// Make sure to attach only new contexts123const currentContextIds = widget.attachmentModel.getAttachmentIDs();124if (currentContextIds.has(scaledImageContext.id)) {125return;126}127128const edit = createCustomPasteEdit(model, [scaledImageContext], mimeType, this.kind, localize('pastedImageAttachment', 'Pasted Image Attachment'), this.chatWidgetService);129return createEditSession(edit);130}131}132133async function getImageAttachContext(data: Uint8Array, mimeType: string, token: CancellationToken, displayName: string, resource: URI): Promise<IChatRequestVariableEntry | undefined> {134const imageHash = await imageToHash(data);135if (token.isCancellationRequested) {136return undefined;137}138139return {140kind: 'image',141value: data,142id: imageHash,143name: displayName,144icon: Codicon.fileMedia,145mimeType,146isPasted: true,147references: [{ reference: resource, kind: 'reference' }]148};149}150151export async function imageToHash(data: Uint8Array): Promise<string> {152const hashBuffer = await crypto.subtle.digest('SHA-256', data);153const hashArray = Array.from(new Uint8Array(hashBuffer));154return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');155}156157export function isImage(array: Uint8Array): boolean {158if (array.length < 4) {159return false;160}161162// Magic numbers (identification bytes) for various image formats163const identifier: { [key: string]: number[] } = {164png: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],165jpeg: [0xFF, 0xD8, 0xFF],166bmp: [0x42, 0x4D],167gif: [0x47, 0x49, 0x46, 0x38],168tiff: [0x49, 0x49, 0x2A, 0x00]169};170171return Object.values(identifier).some((signature) =>172signature.every((byte, index) => array[index] === byte)173);174}175176export class CopyTextProvider implements DocumentPasteEditProvider {177public readonly providedPasteEditKinds = [];178public readonly copyMimeTypes = [COPY_MIME_TYPES];179public readonly pasteMimeTypes = [];180181async prepareDocumentPaste(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise<undefined | IReadonlyVSDataTransfer> {182if (model.uri.scheme === Schemas.vscodeChatInput) {183return;184}185186const customDataTransfer = new VSDataTransfer();187const data: SerializedCopyData = { range: ranges[0], uri: model.uri.toJSON() };188customDataTransfer.append(COPY_MIME_TYPES, createStringDataTransferItem(JSON.stringify(data)));189return customDataTransfer;190}191}192193class CopyAttachmentsProvider implements DocumentPasteEditProvider {194195static ATTACHMENT_MIME_TYPE = 'application/vnd.chat.attachment+json';196197public readonly kind = new HierarchicalKind('chat.attach.attachments');198public readonly providedPasteEditKinds = [this.kind];199200public readonly copyMimeTypes = [CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE];201public readonly pasteMimeTypes = [CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE];202203constructor(204@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,205@IChatVariablesService private readonly chatVariableService: IChatVariablesService206) { }207208async prepareDocumentPaste(model: ITextModel, _ranges: readonly IRange[], _dataTransfer: IReadonlyVSDataTransfer, _token: CancellationToken): Promise<undefined | IReadonlyVSDataTransfer> {209210const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);211if (!widget || !widget.viewModel) {212return undefined;213}214215const attachments = widget.attachmentModel.attachments;216const dynamicVariables = this.chatVariableService.getDynamicVariables(widget.viewModel.sessionId);217218if (attachments.length === 0 && dynamicVariables.length === 0) {219return undefined;220}221222const result = new VSDataTransfer();223result.append(CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE, createStringDataTransferItem(JSON.stringify({ attachments, dynamicVariables })));224return result;225}226227async provideDocumentPasteEdits(model: ITextModel, _ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, _context: DocumentPasteContext, token: CancellationToken): Promise<DocumentPasteEditsSession | undefined> {228229const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);230if (!widget || !widget.viewModel) {231return undefined;232}233234const chatDynamicVariable = widget.getContrib<ChatDynamicVariableModel>(ChatDynamicVariableModel.ID);235if (!chatDynamicVariable) {236return undefined;237}238239const text = dataTransfer.get(Mimes.text);240const data = dataTransfer.get(CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE);241const rawData = await data?.asString();242const textdata = await text?.asString();243244if (textdata === undefined || rawData === undefined) {245return;246}247248if (token.isCancellationRequested) {249return;250}251252let pastedData: { attachments: IChatRequestVariableEntry[]; dynamicVariables: IDynamicVariable[] } | undefined;253try {254pastedData = revive(JSON.parse(rawData));255} catch {256//257}258259if (!Array.isArray(pastedData?.attachments) && !Array.isArray(pastedData?.dynamicVariables)) {260return;261}262263const edit: DocumentPasteEdit = {264insertText: textdata,265title: localize('pastedChatAttachments', 'Insert Prompt & Attachments'),266kind: this.kind,267handledMimeType: CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE,268additionalEdit: {269edits: []270}271};272273edit.additionalEdit?.edits.push({274resource: model.uri,275redo: () => {276widget.attachmentModel.addContext(...pastedData.attachments);277for (const dynamicVariable of pastedData.dynamicVariables) {278chatDynamicVariable?.addReference(dynamicVariable);279}280widget.refreshParsedInput();281},282undo: () => {283widget.attachmentModel.delete(...pastedData.attachments.map(c => c.id));284widget.refreshParsedInput();285}286});287288return createEditSession(edit);289}290}291292export class PasteTextProvider implements DocumentPasteEditProvider {293294public readonly kind = new HierarchicalKind('chat.attach.text');295public readonly providedPasteEditKinds = [this.kind];296297public readonly copyMimeTypes = [];298public readonly pasteMimeTypes = [COPY_MIME_TYPES];299300constructor(301private readonly chatWidgetService: IChatWidgetService,302private readonly modelService: IModelService303) { }304305async provideDocumentPasteEdits(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, _context: DocumentPasteContext, token: CancellationToken): Promise<DocumentPasteEditsSession | undefined> {306if (model.uri.scheme !== Schemas.vscodeChatInput) {307return;308}309const text = dataTransfer.get(Mimes.text);310const editorData = dataTransfer.get('vscode-editor-data');311const additionalEditorData = dataTransfer.get(COPY_MIME_TYPES);312313if (!editorData || !text || !additionalEditorData) {314return;315}316317const textdata = await text.asString();318const metadata = JSON.parse(await editorData.asString());319const additionalData: SerializedCopyData = JSON.parse(await additionalEditorData.asString());320321const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);322if (!widget) {323return;324}325326const start = additionalData.range.startLineNumber;327const end = additionalData.range.endLineNumber;328if (start === end) {329const textModel = this.modelService.getModel(URI.revive(additionalData.uri));330if (!textModel) {331return;332}333334// If copied line text data is the entire line content, then we can paste it as a code attachment. Otherwise, we ignore and use default paste provider.335const lineContent = textModel.getLineContent(start);336if (lineContent !== textdata) {337return;338}339}340341const copiedContext = getCopiedContext(textdata, URI.revive(additionalData.uri), metadata.mode, additionalData.range);342343if (token.isCancellationRequested || !copiedContext) {344return;345}346347const currentContextIds = widget.attachmentModel.getAttachmentIDs();348if (currentContextIds.has(copiedContext.id)) {349return;350}351352const edit = createCustomPasteEdit(model, [copiedContext], Mimes.text, this.kind, localize('pastedCodeAttachment', 'Pasted Code Attachment'), this.chatWidgetService);353edit.yieldTo = [{ kind: HierarchicalKind.Empty.append('text', 'plain') }];354return createEditSession(edit);355}356}357358function getCopiedContext(code: string, file: URI, language: string, range: IRange): IChatRequestPasteVariableEntry {359const fileName = basename(file);360const start = range.startLineNumber;361const end = range.endLineNumber;362const resultText = `Copied Selection of Code: \n\n\n From the file: ${fileName} From lines ${start} to ${end} \n \`\`\`${code}\`\`\``;363const pastedLines = start === end ? localize('pastedAttachment.oneLine', '1 line') : localize('pastedAttachment.multipleLines', '{0} lines', end + 1 - start);364return {365kind: 'paste',366value: resultText,367id: `${fileName}${start}${end}${range.startColumn}${range.endColumn}`,368name: `${fileName} ${pastedLines}`,369icon: Codicon.code,370pastedLines,371language,372fileName: file.toString(),373copiedFrom: {374uri: file,375range376},377code,378references: [{379reference: file,380kind: 'reference'381}]382};383}384385function createCustomPasteEdit(model: ITextModel, context: IChatRequestVariableEntry[], handledMimeType: string, kind: HierarchicalKind, title: string, chatWidgetService: IChatWidgetService): DocumentPasteEdit {386387const label = context.length === 1388? context[0].name389: localize('pastedAttachment.multiple', '{0} and {1} more', context[0].name, context.length - 1);390391const customEdit = {392resource: model.uri,393variable: context,394undo: () => {395const widget = chatWidgetService.getWidgetByInputUri(model.uri);396if (!widget) {397throw new Error('No widget found for undo');398}399widget.attachmentModel.delete(...context.map(c => c.id));400},401redo: () => {402const widget = chatWidgetService.getWidgetByInputUri(model.uri);403if (!widget) {404throw new Error('No widget found for redo');405}406widget.attachmentModel.addContext(...context);407},408metadata: {409needsConfirmation: false,410label411}412};413414return {415insertText: '',416title,417kind,418handledMimeType,419additionalEdit: {420edits: [customEdit],421}422};423}424425function createEditSession(edit: DocumentPasteEdit): DocumentPasteEditsSession {426return {427edits: [edit],428dispose: () => { },429};430}431432export class ChatPasteProvidersFeature extends Disposable {433constructor(434@IInstantiationService instaService: IInstantiationService,435@ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService,436@IChatWidgetService chatWidgetService: IChatWidgetService,437@IExtensionService extensionService: IExtensionService,438@IFileService fileService: IFileService,439@IModelService modelService: IModelService,440@IEnvironmentService environmentService: IEnvironmentService,441@ILogService logService: ILogService,442) {443super();444this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, instaService.createInstance(CopyAttachmentsProvider)));445this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, new PasteImageProvider(chatWidgetService, extensionService, fileService, environmentService, logService)));446this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, new PasteTextProvider(chatWidgetService, modelService)));447this._register(languageFeaturesService.documentPasteEditProvider.register('*', new CopyTextProvider()));448this._register(languageFeaturesService.documentPasteEditProvider.register('*', new CopyTextProvider()));449}450}451452453