Path: blob/main/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts
5263 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*--------------------------------------------------------------------------------------------*/45import { VSBuffer } from '../../../../../base/common/buffer.js';6import { Codicon } from '../../../../../base/common/codicons.js';7import { basename } from '../../../../../base/common/resources.js';8import { ThemeIcon } from '../../../../../base/common/themables.js';9import { URI } from '../../../../../base/common/uri.js';10import { IRange } from '../../../../../editor/common/core/range.js';11import { SymbolKinds } from '../../../../../editor/common/languages.js';12import { ITextModelService } from '../../../../../editor/common/services/resolverService.js';13import { localize } from '../../../../../nls.js';14import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';15import { IDraggedResourceEditorInput, MarkerTransferData, DocumentSymbolTransferData, NotebookCellOutputTransferData } from '../../../../../platform/dnd/browser/dnd.js';16import { IFileService } from '../../../../../platform/files/common/files.js';17import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';18import { MarkerSeverity } from '../../../../../platform/markers/common/markers.js';19import { isUntitledResourceEditorInput } from '../../../../common/editor.js';20import { EditorInput } from '../../../../common/editor/editorInput.js';21import { IEditorService } from '../../../../services/editor/common/editorService.js';22import { IExtensionService, isProposedApiEnabled } from '../../../../services/extensions/common/extensions.js';23import { UntitledTextEditorInput } from '../../../../services/untitled/common/untitledTextEditorInput.js';24import { createNotebookOutputVariableEntry, NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT_CONST } from '../../../notebook/browser/contrib/chat/notebookChatUtils.js';25import { getOutputViewModelFromId } from '../../../notebook/browser/controller/cellOutputActions.js';26import { getNotebookEditorFromEditorPane } from '../../../notebook/browser/notebookBrowser.js';27import { SCMHistoryItemTransferData } from '../../../scm/browser/scmHistoryChatContext.js';28import { CHAT_ATTACHABLE_IMAGE_MIME_TYPES, getAttachableImageExtension } from '../../common/model/chatModel.js';29import { IChatRequestVariableEntry, OmittedState, IDiagnosticVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry, toPromptFileVariableEntry, PromptFileVariableKind, ISCMHistoryItemVariableEntry } from '../../common/attachments/chatVariableEntries.js';30import { getPromptsTypeForLanguageId, PromptsType } from '../../common/promptSyntax/promptTypes.js';31import { imageToHash } from '../widget/input/editor/chatPasteProviders.js';32import { resizeImage } from '../chatImageUtils.js';3334export const IChatAttachmentResolveService = createDecorator<IChatAttachmentResolveService>('IChatAttachmentResolveService');3536export interface IChatAttachmentResolveService {37_serviceBrand: undefined;3839resolveEditorAttachContext(editor: EditorInput | IDraggedResourceEditorInput): Promise<IChatRequestVariableEntry | undefined>;40resolveUntitledEditorAttachContext(editor: IDraggedResourceEditorInput): Promise<IChatRequestVariableEntry | undefined>;41resolveResourceAttachContext(resource: URI, isDirectory: boolean): Promise<IChatRequestVariableEntry | undefined>;4243resolveImageEditorAttachContext(resource: URI, data?: VSBuffer, mimeType?: string): Promise<IChatRequestVariableEntry | undefined>;44resolveImageAttachContext(images: ImageTransferData[]): Promise<IChatRequestVariableEntry[]>;45resolveMarkerAttachContext(markers: MarkerTransferData[]): IDiagnosticVariableEntry[];46resolveSymbolsAttachContext(symbols: DocumentSymbolTransferData[]): ISymbolVariableEntry[];47resolveNotebookOutputAttachContext(data: NotebookCellOutputTransferData): IChatRequestVariableEntry[];48resolveSourceControlHistoryItemAttachContext(data: SCMHistoryItemTransferData[]): ISCMHistoryItemVariableEntry[];49resolveDirectoryImages(directoryUri: URI): Promise<IChatRequestVariableEntry[]>;50}5152export class ChatAttachmentResolveService implements IChatAttachmentResolveService {53_serviceBrand: undefined;5455constructor(56@IFileService private fileService: IFileService,57@IEditorService private editorService: IEditorService,58@ITextModelService private textModelService: ITextModelService,59@IExtensionService private extensionService: IExtensionService,60@IDialogService private dialogService: IDialogService61) { }6263// --- EDITORS ---6465public async resolveEditorAttachContext(editor: EditorInput | IDraggedResourceEditorInput): Promise<IChatRequestVariableEntry | undefined> {66// untitled editor67if (isUntitledResourceEditorInput(editor)) {68return await this.resolveUntitledEditorAttachContext(editor);69}7071if (!editor.resource) {72return undefined;73}7475let stat;76try {77stat = await this.fileService.stat(editor.resource);78} catch {79return undefined;80}8182if (!stat.isDirectory && !stat.isFile) {83return undefined;84}8586const imageContext = await this.resolveImageEditorAttachContext(editor.resource);87if (imageContext) {88return this.extensionService.extensions.some(ext => isProposedApiEnabled(ext, 'chatReferenceBinaryData')) ? imageContext : undefined;89}9091return await this.resolveResourceAttachContext(editor.resource, stat.isDirectory);92}9394public async resolveUntitledEditorAttachContext(editor: IDraggedResourceEditorInput): Promise<IChatRequestVariableEntry | undefined> {95// If the resource is known, we can use it directly96if (editor.resource) {97return await this.resolveResourceAttachContext(editor.resource, false);98}99100// Otherwise, we need to check if the contents are already open in another editor101const openUntitledEditors = this.editorService.editors.filter(editor => editor instanceof UntitledTextEditorInput) as UntitledTextEditorInput[];102for (const canidate of openUntitledEditors) {103const model = await canidate.resolve();104const contents = model.textEditorModel?.getValue();105if (contents === editor.contents) {106return await this.resolveResourceAttachContext(canidate.resource, false);107}108}109110return undefined;111}112113public async resolveResourceAttachContext(resource: URI, isDirectory: boolean): Promise<IChatRequestVariableEntry | undefined> {114let omittedState = OmittedState.NotOmitted;115116if (!isDirectory) {117118let languageId: string | undefined;119try {120const createdModel = await this.textModelService.createModelReference(resource);121languageId = createdModel.object.getLanguageId();122createdModel.dispose();123} catch {124omittedState = OmittedState.Full;125}126127if (/\.(svg)$/i.test(resource.path)) {128omittedState = OmittedState.Full;129}130if (languageId) {131const promptsType = getPromptsTypeForLanguageId(languageId);132if (promptsType === PromptsType.prompt) {133return toPromptFileVariableEntry(resource, PromptFileVariableKind.PromptFile);134} else if (promptsType === PromptsType.instructions) {135return toPromptFileVariableEntry(resource, PromptFileVariableKind.Instruction);136}137}138}139140return {141kind: isDirectory ? 'directory' : 'file',142value: resource,143id: resource.toString(),144name: basename(resource),145omittedState146};147}148149// --- IMAGES ---150151public async resolveImageEditorAttachContext(resource: URI, data?: VSBuffer, mimeType?: string): Promise<IChatRequestVariableEntry | undefined> {152if (!resource) {153return undefined;154}155156if (mimeType) {157if (!getAttachableImageExtension(mimeType)) {158return undefined;159}160} else {161const match = SUPPORTED_IMAGE_EXTENSIONS_REGEX.exec(resource.path);162if (!match) {163return undefined;164}165166mimeType = getMimeTypeFromPath(match);167}168const fileName = basename(resource);169170let dataBuffer: VSBuffer | undefined;171if (data) {172dataBuffer = data;173} else {174175let stat;176try {177stat = await this.fileService.stat(resource);178} catch {179return undefined;180}181182const readFile = await this.fileService.readFile(resource);183184if (stat.size > 30 * 1024 * 1024) { // 30 MB185this.dialogService.error(localize('imageTooLarge', 'Image is too large'), localize('imageTooLargeMessage', 'The image {0} is too large to be attached.', fileName));186throw new Error('Image is too large');187}188189dataBuffer = readFile.value;190}191192const isPartiallyOmitted = /\.gif$/i.test(resource.path);193const imageFileContext = await this.resolveImageAttachContext([{194id: resource.toString(),195name: fileName,196data: dataBuffer.buffer,197icon: Codicon.fileMedia,198resource: resource,199mimeType: mimeType,200omittedState: isPartiallyOmitted ? OmittedState.Partial : OmittedState.NotOmitted201}]);202203return imageFileContext[0];204}205206public resolveImageAttachContext(images: ImageTransferData[]): Promise<IChatRequestVariableEntry[]> {207return Promise.all(images.map(async image => ({208id: image.id || await imageToHash(image.data),209name: image.name,210fullName: image.resource ? image.resource.path : undefined,211value: await resizeImage(image.data, image.mimeType),212icon: image.icon,213kind: 'image',214isFile: false,215isDirectory: false,216omittedState: image.omittedState || OmittedState.NotOmitted,217references: image.resource ? [{ reference: image.resource, kind: 'reference' }] : []218})));219}220221// --- MARKERS ---222223public resolveMarkerAttachContext(markers: MarkerTransferData[]): IDiagnosticVariableEntry[] {224return markers.map((marker): IDiagnosticVariableEntry => {225let filter: IDiagnosticVariableEntryFilterData;226if (!('severity' in marker)) {227filter = { filterUri: URI.revive(marker.uri), filterSeverity: MarkerSeverity.Warning };228} else {229filter = IDiagnosticVariableEntryFilterData.fromMarker(marker);230}231232return IDiagnosticVariableEntryFilterData.toEntry(filter);233});234}235236// --- SYMBOLS ---237238public resolveSymbolsAttachContext(symbols: DocumentSymbolTransferData[]): ISymbolVariableEntry[] {239return symbols.map(symbol => {240const resource = URI.file(symbol.fsPath);241return {242kind: 'symbol',243id: symbolId(resource, symbol.range),244value: { uri: resource, range: symbol.range },245symbolKind: symbol.kind,246icon: SymbolKinds.toIcon(symbol.kind),247fullName: symbol.name,248name: symbol.name,249};250});251}252253// --- NOTEBOOKS ---254255public resolveNotebookOutputAttachContext(data: NotebookCellOutputTransferData): IChatRequestVariableEntry[] {256const notebookEditor = getNotebookEditorFromEditorPane(this.editorService.activeEditorPane);257if (!notebookEditor) {258return [];259}260261const outputViewModel = getOutputViewModelFromId(data.outputId, notebookEditor);262if (!outputViewModel) {263return [];264}265266const mimeType = outputViewModel.pickedMimeType?.mimeType;267if (mimeType && NOTEBOOK_CELL_OUTPUT_MIME_TYPE_LIST_FOR_CHAT_CONST.includes(mimeType)) {268269const entry = createNotebookOutputVariableEntry(outputViewModel, mimeType, notebookEditor);270if (!entry) {271return [];272}273274return [entry];275}276277return [];278}279280// --- DIRECTORIES ---281282public async resolveDirectoryImages(directoryUri: URI): Promise<IChatRequestVariableEntry[]> {283const imageEntries: IChatRequestVariableEntry[] = [];284await this._collectDirectoryImages(directoryUri, imageEntries);285return imageEntries;286}287288private async _collectDirectoryImages(directoryUri: URI, results: IChatRequestVariableEntry[]): Promise<void> {289let stat;290try {291stat = await this.fileService.resolve(directoryUri);292} catch {293return;294}295296if (!stat.children) {297return;298}299300const childPromises: Promise<void>[] = [];301302for (const child of stat.children) {303if (child.isDirectory && !child.isSymbolicLink) {304childPromises.push(this._collectDirectoryImages(child.resource, results));305} else if (child.isFile && !child.isSymbolicLink && SUPPORTED_IMAGE_EXTENSIONS_REGEX.test(child.resource.path)) {306childPromises.push(307this.resolveImageEditorAttachContext(child.resource).then(entry => {308if (entry) {309results.push(entry);310}311}).catch(() => { /* skip unreadable images */ })312);313}314}315316await Promise.all(childPromises);317}318319// --- SOURCE CONTROL ---320321public resolveSourceControlHistoryItemAttachContext(data: SCMHistoryItemTransferData[]): ISCMHistoryItemVariableEntry[] {322return data.map(d => ({323id: d.historyItem.id,324name: d.name,325value: URI.revive(d.resource),326historyItem: {327...d.historyItem,328references: []329},330kind: 'scmHistoryItem'331} satisfies ISCMHistoryItemVariableEntry));332}333}334335function symbolId(resource: URI, range?: IRange): string {336let rangePart = '';337if (range) {338rangePart = `:${range.startLineNumber}`;339if (range.startLineNumber !== range.endLineNumber) {340rangePart += `-${range.endLineNumber}`;341}342}343return resource.fsPath + rangePart;344}345346export type ImageTransferData = {347data: Uint8Array;348name: string;349icon?: ThemeIcon;350resource?: URI;351id?: string;352mimeType?: string;353omittedState?: OmittedState;354};355const SUPPORTED_IMAGE_EXTENSIONS_REGEX = new RegExp(`\\.(${Object.keys(CHAT_ATTACHABLE_IMAGE_MIME_TYPES).join('|')})$`, 'i');356357function getMimeTypeFromPath(match: RegExpExecArray): string | undefined {358const ext = match[1].toLowerCase();359return CHAT_ATTACHABLE_IMAGE_MIME_TYPES[ext];360}361362363