diff --git a/src/vs/workbench/api/common/extHostApiCommands.ts b/src/vs/workbench/api/common/extHostApiCommands.ts index 1d08ef21082..0dc498638b9 100644 --- a/src/vs/workbench/api/common/extHostApiCommands.ts +++ b/src/vs/workbench/api/common/extHostApiCommands.ts @@ -547,6 +547,7 @@ const newCommands: ApiCommand[] = [ initialRange: v.initialRange ? typeConverters.Range.from(v.initialRange) : undefined, initialSelection: types.Selection.isSelection(v.initialSelection) ? typeConverters.Selection.from(v.initialSelection) : undefined, message: v.message, + attachments: v.attachments, autoSend: v.autoSend, position: v.position ? typeConverters.Position.from(v.position) : undefined, }; @@ -559,6 +560,7 @@ type InlineChatEditorApiArg = { initialRange?: vscode.Range; initialSelection?: vscode.Selection; message?: string; + attachments?: vscode.Uri[]; autoSend?: boolean; position?: vscode.Position; }; @@ -567,6 +569,7 @@ type InlineChatRunOptions = { initialRange?: IRange; initialSelection?: ISelection; message?: string; + attachments?: URI[]; autoSend?: boolean; position?: IPosition; }; diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts index 7f48c8d688c..daeb19c3ab5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentModel.ts @@ -12,9 +12,11 @@ import { IChatRequestVariableEntry } from '../common/chatModel.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ChatPromptAttachmentsCollection } from './chatAttachmentModel/chatPromptAttachmentsCollection.js'; import { IFileService } from '../../../../platform/files/common/files.js'; -import { resizeImage } from './imageUtils.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { localize } from '../../../../nls.js'; +import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { resolveImageEditorAttachContext } from './chatAttachmentResolve.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; export class ChatAttachmentModel extends Disposable { /** @@ -26,6 +28,7 @@ export class ChatAttachmentModel extends Disposable { @IInstantiationService private readonly initService: IInstantiationService, @IFileService private readonly fileService: IFileService, @IDialogService private readonly dialogService: IDialogService, + @ISharedWebContentExtractorService private readonly webContentExtractorService: ISharedWebContentExtractorService, ) { super(); @@ -75,7 +78,10 @@ export class ChatAttachmentModel extends Disposable { async addFile(uri: URI, range?: IRange) { if (/\.(png|jpe?g|gif|bmp|webp)$/i.test(uri.path)) { - this.addContext(await this.asImageVariableEntry(uri)); + const context = await this.asImageVariableEntry(uri); + if (context) { + this.addContext(context); + } return; } @@ -101,23 +107,18 @@ export class ChatAttachmentModel extends Disposable { }; } - async asImageVariableEntry(uri: URI): Promise { - const fileName = basename(uri); - const readFile = await this.fileService.readFile(uri); - if (readFile.size > 30 * 1024 * 1024) { // 30 MB - this.dialogService.error(localize('imageTooLarge', 'Image is too large'), localize('imageTooLargeMessage', 'The image {0} is too large to be attached.', fileName)); - throw new Error('Image is too large'); + // Gets an image variable for a given URI, which may be a file or a web URL + async asImageVariableEntry(uri: URI): Promise { + if (uri.scheme === Schemas.file && await this.fileService.canHandleResource(uri)) { + return await resolveImageEditorAttachContext(this.fileService, this.dialogService, uri); + } else if (uri.scheme === Schemas.http || uri.scheme === Schemas.https) { + const extractedImages = await this.webContentExtractorService.readImage(uri, CancellationToken.None); + if (extractedImages) { + return await resolveImageEditorAttachContext(this.fileService, this.dialogService, uri, extractedImages); + } } - const resizedImage = await resizeImage(readFile.value.buffer); - return { - id: uri.toString(), - name: fileName, - fullName: uri.path, - value: resizedImage, - kind: 'image', - isFile: false, - references: [{ reference: uri, kind: 'reference' }] - }; + + return undefined; } addContext(...attachments: IChatRequestVariableEntry[]) { diff --git a/src/vs/workbench/contrib/chat/browser/chatAttachmentResolve.ts b/src/vs/workbench/contrib/chat/browser/chatAttachmentResolve.ts index 9ac4a35f4b2..4eede520306 100644 --- a/src/vs/workbench/contrib/chat/browser/chatAttachmentResolve.ts +++ b/src/vs/workbench/contrib/chat/browser/chatAttachmentResolve.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { VSBuffer } from '../../../../base/common/buffer.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { basename } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -47,7 +48,7 @@ export async function resolveEditorAttachContext(editor: EditorInput | IDraggedR return undefined; } - const imageContext = await resolveImageEditorAttachContext(editor, fileService, dialogService); + const imageContext = await resolveImageEditorAttachContext(fileService, dialogService, editor.resource); if (imageContext) { return extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? imageContext : undefined; } @@ -113,32 +114,48 @@ export type ImageTransferData = { }; const SUPPORTED_IMAGE_EXTENSIONS_REGEX = /\.(png|jpg|jpeg|gif|webp)$/i; -export async function resolveImageEditorAttachContext(editor: EditorInput | IDraggedResourceEditorInput, fileService: IFileService, dialogService: IDialogService): Promise { - if (!editor.resource) { +export async function resolveImageEditorAttachContext(fileService: IFileService, dialogService: IDialogService, resource: URI, data?: VSBuffer): Promise { + if (!resource) { return undefined; } - const match = SUPPORTED_IMAGE_EXTENSIONS_REGEX.exec(editor.resource.path); + const match = SUPPORTED_IMAGE_EXTENSIONS_REGEX.exec(resource.path); if (!match) { return undefined; } const mimeType = getMimeTypeFromPath(match); - const fileName = basename(editor.resource); - const readFile = await fileService.readFile(editor.resource); + const fileName = basename(resource); - if (readFile.size > 30 * 1024 * 1024) { // 30 MB - dialogService.error(localize('imageTooLarge', 'Image is too large'), localize('imageTooLargeMessage', 'The image {0} is too large to be attached.', fileName)); - throw new Error('Image is too large'); + let dataBuffer: VSBuffer | undefined; + if (data) { + dataBuffer = data; + } else { + + let stat; + try { + stat = await fileService.stat(resource); + } catch { + return undefined; + } + + const readFile = await fileService.readFile(resource); + + if (stat.size > 30 * 1024 * 1024) { // 30 MB + dialogService.error(localize('imageTooLarge', 'Image is too large'), localize('imageTooLargeMessage', 'The image {0} is too large to be attached.', fileName)); + throw new Error('Image is too large'); + } + + dataBuffer = readFile.value; } - const isPartiallyOmitted = /\.gif$/i.test(editor.resource.path); + const isPartiallyOmitted = /\.gif$/i.test(resource.path); const imageFileContext = await resolveImageAttachContext([{ - id: editor.resource.toString(), + id: resource.toString(), name: fileName, - data: readFile.value.buffer, + data: dataBuffer.buffer, icon: Codicon.fileMedia, - resource: editor.resource, + resource: resource, mimeType: mimeType, omittedState: isPartiallyOmitted ? OmittedState.Partial : OmittedState.NotOmitted }]); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 7b9f23e2552..2f86f76b182 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -11,11 +11,13 @@ import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; import { MovingAverage } from '../../../../base/common/numbers.js'; import { autorun, autorunWithStore, derived, IObservable, observableSignalFromEvent, observableValue, transaction, waitForState } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { assertType } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ICodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; @@ -41,7 +43,7 @@ import { IEditorService, SIDE_GROUP } from '../../../services/editor/common/edit import { IViewsService } from '../../../services/views/common/viewsService.js'; import { showChatView } from '../../chat/browser/chat.js'; import { IChatWidgetLocationOptions } from '../../chat/browser/chatWidget.js'; -import { ChatModel, ChatRequestRemovalReason, IChatRequestModel, IChatTextEditGroup, IChatTextEditGroupState, IResponse } from '../../chat/common/chatModel.js'; +import { ChatModel, ChatRequestRemovalReason, IChatRequestModel, IChatRequestVariableEntry, IChatTextEditGroup, IChatTextEditGroupState, IResponse } from '../../chat/common/chatModel.js'; import { IChatService } from '../../chat/common/chatService.js'; import { INotebookEditorService } from '../../notebook/browser/services/notebookEditorService.js'; import { CTX_INLINE_CHAT_EDITING, CTX_INLINE_CHAT_REQUEST_IN_PROGRESS, CTX_INLINE_CHAT_RESPONSE_TYPE, CTX_INLINE_CHAT_VISIBLE, INLINE_CHAT_ID, InlineChatConfigKeys, InlineChatResponseType } from '../common/inlineChat.js'; @@ -55,6 +57,9 @@ import { ChatAgentLocation } from '../../chat/common/constants.js'; import { ChatContextKeys } from '../../chat/common/chatContextKeys.js'; import { IChatEditingService, ModifiedFileEntryState } from '../../chat/common/chatEditingService.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { resolveImageEditorAttachContext } from '../../chat/browser/chatAttachmentResolve.js'; export const enum State { CREATE_SESSION = 'CREATE_SESSION', @@ -80,12 +85,13 @@ export abstract class InlineChatRunOptions { initialSelection?: ISelection; initialRange?: IRange; message?: string; + attachments?: URI[]; autoSend?: boolean; existingSession?: Session; position?: IPosition; static isInlineChatRunOptions(options: any): options is InlineChatRunOptions { - const { initialSelection, initialRange, message, autoSend, position, existingSession } = options; + const { initialSelection, initialRange, message, autoSend, position, existingSession, attachments: attachments } = options; if ( typeof message !== 'undefined' && typeof message !== 'string' || typeof autoSend !== 'undefined' && typeof autoSend !== 'boolean' @@ -93,6 +99,7 @@ export abstract class InlineChatRunOptions { || typeof initialSelection !== 'undefined' && !Selection.isISelection(initialSelection) || typeof position !== 'undefined' && !Position.isIPosition(position) || typeof existingSession !== 'undefined' && !(existingSession instanceof Session) + || typeof attachments !== 'undefined' && (!Array.isArray(attachments) || !attachments.every(item => item instanceof URI)) ) { return false; } @@ -200,6 +207,8 @@ export class InlineChatController1 implements IEditorContribution { @IChatService private readonly _chatService: IChatService, @IEditorService private readonly _editorService: IEditorService, @INotebookEditorService notebookEditorService: INotebookEditorService, + @ISharedWebContentExtractorService private readonly _webContentExtractorService: ISharedWebContentExtractorService, + @IFileService private readonly _fileService: IFileService, ) { this._ctxVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); this._ctxEditing = CTX_INLINE_CHAT_EDITING.bindTo(contextKeyService); @@ -575,6 +584,12 @@ export class InlineChatController1 implements IEditorContribution { barrier.open(); })); + if (options.attachments) { + await Promise.all(options.attachments.map(async attachment => { + await this._ui.value.widget.chatWidget.attachmentModel.addFile(attachment); + })); + delete options.attachments; + } if (options.autoSend) { delete options.autoSend; this._showWidget(this._session.headless, false); @@ -1167,6 +1182,21 @@ export class InlineChatController1 implements IEditorContribution { get isActive() { return Boolean(this._currentRun); } + + async createImageAttachment(attachment: URI): Promise { + if (attachment.scheme === Schemas.file) { + if (await this._fileService.canHandleResource(attachment)) { + return await resolveImageEditorAttachContext(this._fileService, this._dialogService, attachment); + } + } else if (attachment.scheme === Schemas.http || attachment.scheme === Schemas.https) { + const extractedImages = await this._webContentExtractorService.readImage(attachment, CancellationToken.None); + if (extractedImages) { + return await resolveImageEditorAttachContext(this._fileService, this._dialogService, attachment, extractedImages); + } + } + + return undefined; + } } export class InlineChatController2 implements IEditorContribution { @@ -1199,6 +1229,9 @@ export class InlineChatController2 implements IEditorContribution { @IInlineChatSessionService private readonly _inlineChatSessions: IInlineChatSessionService, @ICodeEditorService codeEditorService: ICodeEditorService, @IContextKeyService contextKeyService: IContextKeyService, + @ISharedWebContentExtractorService private readonly _webContentExtractorService: ISharedWebContentExtractorService, + @IFileService private readonly _fileService: IFileService, + @IDialogService private readonly _dialogService: IDialogService, ) { const ctxInlineChatVisible = CTX_INLINE_CHAT_VISIBLE.bindTo(contextKeyService); @@ -1394,6 +1427,12 @@ export class InlineChatController2 implements IEditorContribution { if (arg.initialSelection) { this._editor.setSelection(arg.initialSelection); } + if (arg.attachments) { + await Promise.all(arg.attachments.map(async attachment => { + await this._zone.value.widget.chatWidget.attachmentModel.addFile(attachment); + })); + delete arg.attachments; + } if (arg.message) { this._zone.value.widget.chatWidget.setInput(arg.message); if (arg.autoSend) { @@ -1412,6 +1451,24 @@ export class InlineChatController2 implements IEditorContribution { const value = this._currentSession.get(); value?.editingSession.accept(); } + + async createImageAttachment(attachment: URI): Promise { + const value = this._currentSession.get(); + if (!value) { + return undefined; + } + if (attachment.scheme === Schemas.file) { + if (await this._fileService.canHandleResource(attachment)) { + return await resolveImageEditorAttachContext(this._fileService, this._dialogService, attachment); + } + } else if (attachment.scheme === Schemas.http || attachment.scheme === Schemas.https) { + const extractedImages = await this._webContentExtractorService.readImage(attachment, CancellationToken.None); + if (extractedImages) { + return await resolveImageEditorAttachContext(this._fileService, this._dialogService, attachment, extractedImages); + } + } + return undefined; + } } export async function reviewEdits(accessor: ServicesAccessor, editor: ICodeEditor, stream: AsyncIterable, token: CancellationToken): Promise {