Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatPasteProviders.ts
3296 views
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
import { CancellationToken } from '../../../../base/common/cancellation.js';
6
import { Codicon } from '../../../../base/common/codicons.js';
7
import { createStringDataTransferItem, IDataTransferItem, IReadonlyVSDataTransfer, VSDataTransfer } from '../../../../base/common/dataTransfer.js';
8
import { HierarchicalKind } from '../../../../base/common/hierarchicalKind.js';
9
import { Disposable } from '../../../../base/common/lifecycle.js';
10
import { revive } from '../../../../base/common/marshalling.js';
11
import { Mimes } from '../../../../base/common/mime.js';
12
import { Schemas } from '../../../../base/common/network.js';
13
import { basename, joinPath } from '../../../../base/common/resources.js';
14
import { URI, UriComponents } from '../../../../base/common/uri.js';
15
import { IRange } from '../../../../editor/common/core/range.js';
16
import { DocumentPasteContext, DocumentPasteEdit, DocumentPasteEditProvider, DocumentPasteEditsSession } from '../../../../editor/common/languages.js';
17
import { ITextModel } from '../../../../editor/common/model.js';
18
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
19
import { IModelService } from '../../../../editor/common/services/model.js';
20
import { localize } from '../../../../nls.js';
21
import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';
22
import { IFileService } from '../../../../platform/files/common/files.js';
23
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
24
import { ILogService } from '../../../../platform/log/common/log.js';
25
import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js';
26
import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry } from '../common/chatVariableEntries.js';
27
import { IChatVariablesService, IDynamicVariable } from '../common/chatVariables.js';
28
import { IChatWidgetService } from './chat.js';
29
import { ChatDynamicVariableModel } from './contrib/chatDynamicVariables.js';
30
import { cleanupOldImages, createFileForMedia, resizeImage } from './imageUtils.js';
31
32
const COPY_MIME_TYPES = 'application/vnd.code.additional-editor-data';
33
34
interface SerializedCopyData {
35
readonly uri: UriComponents;
36
readonly range: IRange;
37
}
38
39
export class PasteImageProvider implements DocumentPasteEditProvider {
40
private readonly imagesFolder: URI;
41
42
public readonly kind = new HierarchicalKind('chat.attach.image');
43
public readonly providedPasteEditKinds = [this.kind];
44
45
public readonly copyMimeTypes = [];
46
public readonly pasteMimeTypes = ['image/*'];
47
48
constructor(
49
private readonly chatWidgetService: IChatWidgetService,
50
private readonly extensionService: IExtensionService,
51
@IFileService private readonly fileService: IFileService,
52
@IEnvironmentService private readonly environmentService: IEnvironmentService,
53
@ILogService private readonly logService: ILogService,
54
) {
55
this.imagesFolder = joinPath(this.environmentService.workspaceStorageHome, 'vscode-chat-images');
56
cleanupOldImages(this.fileService, this.logService, this.imagesFolder,);
57
}
58
59
async provideDocumentPasteEdits(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, context: DocumentPasteContext, token: CancellationToken): Promise<DocumentPasteEditsSession | undefined> {
60
if (!this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData'))) {
61
return;
62
}
63
64
const supportedMimeTypes = [
65
'image/png',
66
'image/jpeg',
67
'image/jpg',
68
'image/bmp',
69
'image/gif',
70
'image/tiff'
71
];
72
73
let mimeType: string | undefined;
74
let imageItem: IDataTransferItem | undefined;
75
76
// Find the first matching image type in the dataTransfer
77
for (const type of supportedMimeTypes) {
78
imageItem = dataTransfer.get(type);
79
if (imageItem) {
80
mimeType = type;
81
break;
82
}
83
}
84
85
if (!imageItem || !mimeType) {
86
return;
87
}
88
const currClipboard = await imageItem.asFile()?.data();
89
if (token.isCancellationRequested || !currClipboard) {
90
return;
91
}
92
93
const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);
94
if (!widget) {
95
return;
96
}
97
98
const attachedVariables = widget.attachmentModel.attachments;
99
const displayName = localize('pastedImageName', 'Pasted Image');
100
let tempDisplayName = displayName;
101
102
for (let appendValue = 2; attachedVariables.some(attachment => attachment.name === tempDisplayName); appendValue++) {
103
tempDisplayName = `${displayName} ${appendValue}`;
104
}
105
106
const fileReference = await createFileForMedia(this.fileService, this.imagesFolder, currClipboard, mimeType);
107
if (token.isCancellationRequested || !fileReference) {
108
return;
109
}
110
111
const scaledImageData = await resizeImage(currClipboard);
112
if (token.isCancellationRequested || !scaledImageData) {
113
return;
114
}
115
116
const scaledImageContext = await getImageAttachContext(scaledImageData, mimeType, token, tempDisplayName, fileReference);
117
if (token.isCancellationRequested || !scaledImageContext) {
118
return;
119
}
120
121
widget.attachmentModel.addContext(scaledImageContext);
122
123
// Make sure to attach only new contexts
124
const currentContextIds = widget.attachmentModel.getAttachmentIDs();
125
if (currentContextIds.has(scaledImageContext.id)) {
126
return;
127
}
128
129
const edit = createCustomPasteEdit(model, [scaledImageContext], mimeType, this.kind, localize('pastedImageAttachment', 'Pasted Image Attachment'), this.chatWidgetService);
130
return createEditSession(edit);
131
}
132
}
133
134
async function getImageAttachContext(data: Uint8Array, mimeType: string, token: CancellationToken, displayName: string, resource: URI): Promise<IChatRequestVariableEntry | undefined> {
135
const imageHash = await imageToHash(data);
136
if (token.isCancellationRequested) {
137
return undefined;
138
}
139
140
return {
141
kind: 'image',
142
value: data,
143
id: imageHash,
144
name: displayName,
145
icon: Codicon.fileMedia,
146
mimeType,
147
isPasted: true,
148
references: [{ reference: resource, kind: 'reference' }]
149
};
150
}
151
152
export async function imageToHash(data: Uint8Array): Promise<string> {
153
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
154
const hashArray = Array.from(new Uint8Array(hashBuffer));
155
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
156
}
157
158
export function isImage(array: Uint8Array): boolean {
159
if (array.length < 4) {
160
return false;
161
}
162
163
// Magic numbers (identification bytes) for various image formats
164
const identifier: { [key: string]: number[] } = {
165
png: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
166
jpeg: [0xFF, 0xD8, 0xFF],
167
bmp: [0x42, 0x4D],
168
gif: [0x47, 0x49, 0x46, 0x38],
169
tiff: [0x49, 0x49, 0x2A, 0x00]
170
};
171
172
return Object.values(identifier).some((signature) =>
173
signature.every((byte, index) => array[index] === byte)
174
);
175
}
176
177
export class CopyTextProvider implements DocumentPasteEditProvider {
178
public readonly providedPasteEditKinds = [];
179
public readonly copyMimeTypes = [COPY_MIME_TYPES];
180
public readonly pasteMimeTypes = [];
181
182
async prepareDocumentPaste(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise<undefined | IReadonlyVSDataTransfer> {
183
if (model.uri.scheme === Schemas.vscodeChatInput) {
184
return;
185
}
186
187
const customDataTransfer = new VSDataTransfer();
188
const data: SerializedCopyData = { range: ranges[0], uri: model.uri.toJSON() };
189
customDataTransfer.append(COPY_MIME_TYPES, createStringDataTransferItem(JSON.stringify(data)));
190
return customDataTransfer;
191
}
192
}
193
194
class CopyAttachmentsProvider implements DocumentPasteEditProvider {
195
196
static ATTACHMENT_MIME_TYPE = 'application/vnd.chat.attachment+json';
197
198
public readonly kind = new HierarchicalKind('chat.attach.attachments');
199
public readonly providedPasteEditKinds = [this.kind];
200
201
public readonly copyMimeTypes = [CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE];
202
public readonly pasteMimeTypes = [CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE];
203
204
constructor(
205
@IChatWidgetService private readonly chatWidgetService: IChatWidgetService,
206
@IChatVariablesService private readonly chatVariableService: IChatVariablesService
207
) { }
208
209
async prepareDocumentPaste(model: ITextModel, _ranges: readonly IRange[], _dataTransfer: IReadonlyVSDataTransfer, _token: CancellationToken): Promise<undefined | IReadonlyVSDataTransfer> {
210
211
const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);
212
if (!widget || !widget.viewModel) {
213
return undefined;
214
}
215
216
const attachments = widget.attachmentModel.attachments;
217
const dynamicVariables = this.chatVariableService.getDynamicVariables(widget.viewModel.sessionId);
218
219
if (attachments.length === 0 && dynamicVariables.length === 0) {
220
return undefined;
221
}
222
223
const result = new VSDataTransfer();
224
result.append(CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE, createStringDataTransferItem(JSON.stringify({ attachments, dynamicVariables })));
225
return result;
226
}
227
228
async provideDocumentPasteEdits(model: ITextModel, _ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, _context: DocumentPasteContext, token: CancellationToken): Promise<DocumentPasteEditsSession | undefined> {
229
230
const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);
231
if (!widget || !widget.viewModel) {
232
return undefined;
233
}
234
235
const chatDynamicVariable = widget.getContrib<ChatDynamicVariableModel>(ChatDynamicVariableModel.ID);
236
if (!chatDynamicVariable) {
237
return undefined;
238
}
239
240
const text = dataTransfer.get(Mimes.text);
241
const data = dataTransfer.get(CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE);
242
const rawData = await data?.asString();
243
const textdata = await text?.asString();
244
245
if (textdata === undefined || rawData === undefined) {
246
return;
247
}
248
249
if (token.isCancellationRequested) {
250
return;
251
}
252
253
let pastedData: { attachments: IChatRequestVariableEntry[]; dynamicVariables: IDynamicVariable[] } | undefined;
254
try {
255
pastedData = revive(JSON.parse(rawData));
256
} catch {
257
//
258
}
259
260
if (!Array.isArray(pastedData?.attachments) && !Array.isArray(pastedData?.dynamicVariables)) {
261
return;
262
}
263
264
const edit: DocumentPasteEdit = {
265
insertText: textdata,
266
title: localize('pastedChatAttachments', 'Insert Prompt & Attachments'),
267
kind: this.kind,
268
handledMimeType: CopyAttachmentsProvider.ATTACHMENT_MIME_TYPE,
269
additionalEdit: {
270
edits: []
271
}
272
};
273
274
edit.additionalEdit?.edits.push({
275
resource: model.uri,
276
redo: () => {
277
widget.attachmentModel.addContext(...pastedData.attachments);
278
for (const dynamicVariable of pastedData.dynamicVariables) {
279
chatDynamicVariable?.addReference(dynamicVariable);
280
}
281
widget.refreshParsedInput();
282
},
283
undo: () => {
284
widget.attachmentModel.delete(...pastedData.attachments.map(c => c.id));
285
widget.refreshParsedInput();
286
}
287
});
288
289
return createEditSession(edit);
290
}
291
}
292
293
export class PasteTextProvider implements DocumentPasteEditProvider {
294
295
public readonly kind = new HierarchicalKind('chat.attach.text');
296
public readonly providedPasteEditKinds = [this.kind];
297
298
public readonly copyMimeTypes = [];
299
public readonly pasteMimeTypes = [COPY_MIME_TYPES];
300
301
constructor(
302
private readonly chatWidgetService: IChatWidgetService,
303
private readonly modelService: IModelService
304
) { }
305
306
async provideDocumentPasteEdits(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, _context: DocumentPasteContext, token: CancellationToken): Promise<DocumentPasteEditsSession | undefined> {
307
if (model.uri.scheme !== Schemas.vscodeChatInput) {
308
return;
309
}
310
const text = dataTransfer.get(Mimes.text);
311
const editorData = dataTransfer.get('vscode-editor-data');
312
const additionalEditorData = dataTransfer.get(COPY_MIME_TYPES);
313
314
if (!editorData || !text || !additionalEditorData) {
315
return;
316
}
317
318
const textdata = await text.asString();
319
const metadata = JSON.parse(await editorData.asString());
320
const additionalData: SerializedCopyData = JSON.parse(await additionalEditorData.asString());
321
322
const widget = this.chatWidgetService.getWidgetByInputUri(model.uri);
323
if (!widget) {
324
return;
325
}
326
327
const start = additionalData.range.startLineNumber;
328
const end = additionalData.range.endLineNumber;
329
if (start === end) {
330
const textModel = this.modelService.getModel(URI.revive(additionalData.uri));
331
if (!textModel) {
332
return;
333
}
334
335
// 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.
336
const lineContent = textModel.getLineContent(start);
337
if (lineContent !== textdata) {
338
return;
339
}
340
}
341
342
const copiedContext = getCopiedContext(textdata, URI.revive(additionalData.uri), metadata.mode, additionalData.range);
343
344
if (token.isCancellationRequested || !copiedContext) {
345
return;
346
}
347
348
const currentContextIds = widget.attachmentModel.getAttachmentIDs();
349
if (currentContextIds.has(copiedContext.id)) {
350
return;
351
}
352
353
const edit = createCustomPasteEdit(model, [copiedContext], Mimes.text, this.kind, localize('pastedCodeAttachment', 'Pasted Code Attachment'), this.chatWidgetService);
354
edit.yieldTo = [{ kind: HierarchicalKind.Empty.append('text', 'plain') }];
355
return createEditSession(edit);
356
}
357
}
358
359
function getCopiedContext(code: string, file: URI, language: string, range: IRange): IChatRequestPasteVariableEntry {
360
const fileName = basename(file);
361
const start = range.startLineNumber;
362
const end = range.endLineNumber;
363
const resultText = `Copied Selection of Code: \n\n\n From the file: ${fileName} From lines ${start} to ${end} \n \`\`\`${code}\`\`\``;
364
const pastedLines = start === end ? localize('pastedAttachment.oneLine', '1 line') : localize('pastedAttachment.multipleLines', '{0} lines', end + 1 - start);
365
return {
366
kind: 'paste',
367
value: resultText,
368
id: `${fileName}${start}${end}${range.startColumn}${range.endColumn}`,
369
name: `${fileName} ${pastedLines}`,
370
icon: Codicon.code,
371
pastedLines,
372
language,
373
fileName: file.toString(),
374
copiedFrom: {
375
uri: file,
376
range
377
},
378
code,
379
references: [{
380
reference: file,
381
kind: 'reference'
382
}]
383
};
384
}
385
386
function createCustomPasteEdit(model: ITextModel, context: IChatRequestVariableEntry[], handledMimeType: string, kind: HierarchicalKind, title: string, chatWidgetService: IChatWidgetService): DocumentPasteEdit {
387
388
const label = context.length === 1
389
? context[0].name
390
: localize('pastedAttachment.multiple', '{0} and {1} more', context[0].name, context.length - 1);
391
392
const customEdit = {
393
resource: model.uri,
394
variable: context,
395
undo: () => {
396
const widget = chatWidgetService.getWidgetByInputUri(model.uri);
397
if (!widget) {
398
throw new Error('No widget found for undo');
399
}
400
widget.attachmentModel.delete(...context.map(c => c.id));
401
},
402
redo: () => {
403
const widget = chatWidgetService.getWidgetByInputUri(model.uri);
404
if (!widget) {
405
throw new Error('No widget found for redo');
406
}
407
widget.attachmentModel.addContext(...context);
408
},
409
metadata: {
410
needsConfirmation: false,
411
label
412
}
413
};
414
415
return {
416
insertText: '',
417
title,
418
kind,
419
handledMimeType,
420
additionalEdit: {
421
edits: [customEdit],
422
}
423
};
424
}
425
426
function createEditSession(edit: DocumentPasteEdit): DocumentPasteEditsSession {
427
return {
428
edits: [edit],
429
dispose: () => { },
430
};
431
}
432
433
export class ChatPasteProvidersFeature extends Disposable {
434
constructor(
435
@IInstantiationService instaService: IInstantiationService,
436
@ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService,
437
@IChatWidgetService chatWidgetService: IChatWidgetService,
438
@IExtensionService extensionService: IExtensionService,
439
@IFileService fileService: IFileService,
440
@IModelService modelService: IModelService,
441
@IEnvironmentService environmentService: IEnvironmentService,
442
@ILogService logService: ILogService,
443
) {
444
super();
445
this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, instaService.createInstance(CopyAttachmentsProvider)));
446
this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, new PasteImageProvider(chatWidgetService, extensionService, fileService, environmentService, logService)));
447
this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, new PasteTextProvider(chatWidgetService, modelService)));
448
this._register(languageFeaturesService.documentPasteEditProvider.register('*', new CopyTextProvider()));
449
this._register(languageFeaturesService.documentPasteEditProvider.register('*', new CopyTextProvider()));
450
}
451
}
452
453