/** * Расширяет ссылки на файлы и промпты, заменяя их содержимым перед отправкой на сервер. * Обрабатывает как одинарные (@/#), так и постоянные (@@/##) ссылки. */ import { FileSystemAdapter, TFile } from 'obsidian'; import LLMAgentPlugin from 'main'; import { FileReference, ReferenceParser } from './ReferenceParser'; import { TemplateEngine } from './TemplateEngine'; import { Attachment, ChatHistoryItem } from 'src/components/models/ChatHistoryItem'; import { CacheManager } from './CacheManager'; import { getMimeType } from 'src/utils/fileUtils'; // ----------------------------------------------- Reference Expander --------------------------------------------------------------- export class ReferenceExpander { private plugin: LLMAgentPlugin; private templateEngine: TemplateEngine; private cacheManager: CacheManager; constructor(plugin: LLMAgentPlugin, templateEngine: TemplateEngine) { this.plugin = plugin; this.templateEngine = templateEngine; this.cacheManager = new CacheManager(plugin); } // ----------------------------------------------- Public Methods --------------------------------------------------------------- /** * Заменяет все ссылки на содержимое файлов (перед отправкой на сервер) * @param text - текст с ссылками * @param references - массив ссылок для обработки * @returns текст с развернутыми ссылками */ /*async expandReferences(text: string, references: FileReference[]): Promise { if (references.length === 0) { return text; } let expandedText = text; // Обрабатываем ссылки от конца к началу, чтобы не сбивать индексы const sortedReferences = [...references].sort((a, b) => { const aMatches = ReferenceParser.parseReferences(text).filter((m) => m.name === a.name); const bMatches = ReferenceParser.parseReferences(text).filter((m) => m.name === b.name); const aIndex = aMatches.length > 0 ? aMatches[aMatches.length - 1].startIndex : 0; const bIndex = bMatches.length > 0 ? bMatches[bMatches.length - 1].startIndex : 0; return bIndex - aIndex; }); for (const reference of sortedReferences) { expandedText = await this.expandSingleReference(expandedText, reference); } return expandedText; }*/ /** * Обрабатывает историю сообщений, раскрывая постоянные ссылки (@@/##) * @param messages - массив сообщений * @returns обработанные сообщения */ /*async expandMessageHistory(messages: any[]): Promise { const expandedMessages = []; for (const message of messages) { if (message.role === 'user') { const expandedContent = await this.expandPermanentReferences(message.content); expandedMessages.push({ ...message, content: expandedContent }); } else { expandedMessages.push(message); } } return expandedMessages; }*/ /** * Извлекает вложения из текста и кеширует файлы. * Обрабатывает как физические файлы, так и виртуальные (current-log). */ async extractAttachments( text: string, references: FileReference[] ): Promise<{ originalText: string; attachments: Attachment[]; }> { const attachments: Attachment[] = []; for (const reference of references) { try { let cachedPath: string; let uuid: string; let mimeType: string; let sourcePath: string; // --- Обработка виртуальных ссылок на логи --- if (reference.path.startsWith("virtual:current-log")) { const logsFolder = this.plugin.settings.logsFolder || "audio-logs"; // Ищем md файлы в папке логов, сортируем по дате изменения (самый свежий — первый) const logFiles = this.plugin.app.vault.getMarkdownFiles() .filter(f => f.path.startsWith(logsFolder)) .sort((a, b) => b.stat.mtime - a.stat.mtime); if (logFiles.length > 0) { const file = logFiles[0]; let content = await this.plugin.app.vault.read(file); // Если выбрана часть, отрезаем последние N символов из настроек if (reference.path.endsWith(":part")) { const limit = this.plugin.settings.voiceContextLimit; // || 30000; content = content.slice(-limit); } uuid = "log-" + Date.now(); cachedPath = `log-snapshot-${Date.now()}.md`; mimeType = "text/markdown"; sourcePath = file.path; // Сохраняем контент лога во временный файл в папку кеша, чтобы бэкенд мог его прочитать if (this.plugin.app.vault.adapter instanceof FileSystemAdapter) { const cacheFolderPath = this.cacheManager.getCacheFolderPath(); const fs = window.require('fs').promises; // Убедимся, что папка кеша существует try { await fs.mkdir(cacheFolderPath, { recursive: true }); } catch(e) {} await fs.writeFile(`${cacheFolderPath}/${cachedPath}`, content, 'utf8'); } else { throw new Error("Кеширование логов доступно только в десктопной версии"); } } else { console.warn(`Файлы логов не найдены в папке: ${logsFolder}`); continue; } } // --- Обработка внешних файлов ($ / !$) --- else if (reference.type === 'external') { const cacheResult = await this.cacheManager.cacheExternalFile(reference.path); cachedPath = cacheResult.cachedPath; uuid = cacheResult.uuid; mimeType = cacheResult.mimeType; sourcePath = reference.path; // Абсолютный } // --- Обработка файлов из Vault (@ / @@) --- else if (reference.type === 'file') { const file = this.plugin.app.vault.getAbstractFileByPath(reference.path); if (file instanceof TFile) { const cacheResult = await this.cacheManager.cacheVaultFile(file); cachedPath = cacheResult.cachedPath; uuid = cacheResult.uuid; mimeType = getMimeType(file.name); sourcePath = file.path; // Относительный } else { console.warn(`Файл не найден в vault: ${reference.path}`); continue; } } // --- Обработка промптов (# / ##) --- else { const file = this.plugin.app.vault.getAbstractFileByPath(reference.path); if (file instanceof TFile) { const cacheResult = await this.cacheManager.cacheVaultFile(file); cachedPath = cacheResult.cachedPath; uuid = cacheResult.uuid; mimeType = 'text/markdown'; sourcePath = file.path; // Относительный от promptsFolder } else { console.warn(`Промпт не найден: ${reference.path}`); continue; } } attachments.push({ type: reference.type, name: reference.name, sourcePath: sourcePath, cachedPath: cachedPath, uuid: uuid, permanent: reference.permanent, mimeType: mimeType }); } catch (error) { console.error(`Не удалось обработать вложение ${reference.name}:`, error); } } return { originalText: text, attachments: attachments }; } /** * Подготавливает сообщения для LLM с учетом вложений. * Раскрывает только постоянные (@@/##) ссылки и ссылки в последнем сообщении. */ /*async prepareMessagesForLLM(messages: ChatHistoryItem[], isLastMessage: boolean = false): Promise { const preparedMessages = []; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; const isLast = i === messages.length - 1; if (msg.role === 'user' && msg.attachments && msg.attachments.length > 0) { // Определяем, какие вложения нужно раскрыть const attachmentsToExpand = msg.attachments.filter((att) => att.permanent || (isLast && isLastMessage)); if (attachmentsToExpand.length > 0) { // Формируем сообщение с раскрытыми вложениями let expandedContent = msg.content; for (const attachment of attachmentsToExpand) { const formattedContent = this.formatContentForInsertion(attachment.content || '', attachment); expandedContent += '\n' + formattedContent; } preparedMessages.push({ role: msg.role, content: expandedContent }); } else { // Вложения есть, но раскрывать не нужно preparedMessages.push({ role: msg.role, content: msg.content }); } } else { // Сообщение без вложений preparedMessages.push({ role: msg.role, content: msg.content }); } } return preparedMessages; }*/ // TODO: При реализации снапшотов - сохранять содержимое файлов // async expandReferencesWithSnapshots(text: string, references: FileReference[], messageId: string): Promise<{expandedText: string, snapshots: Record}> { // const snapshots: Record = {}; // let expandedText = text; // // for (const reference of references) { // const content = await this.loadFileContent(reference); // if (content !== null) { // snapshots[reference.path] = content; // expandedText = await this.expandSingleReference(expandedText, reference, content); // } // } // // return { expandedText, snapshots }; // } // ----------------------------------------------- Private Methods --------------------------------------------------------------- /** * Раскрывает одну ссылку в тексте * @param text - текст для обработки * @param reference - ссылка для раскрытия * @param preloadedContent - предзагруженное содержимое (для снапшотов) * @returns текст с раскрытой ссылкой */ /*private async expandSingleReference(text: string, reference: FileReference, preloadedContent?: string): Promise { const content = preloadedContent || (await this.loadFileContent(reference)); if (content === null) { console.warn(`Не удалось загрузить содержимое для ссылки: ${reference.name}`); return text; } // Определяем паттерн для замены const prefix = reference.permanent ? (reference.type === 'file' ? '@@' : '##') : reference.type === 'file' ? '@' : '#'; // Создаем регулярное выражение для поиска ссылки const escapedName = this.escapeRegExp(reference.name); const pattern = new RegExp(`${this.escapeRegExp(prefix)}(?:"${escapedName}"|${escapedName})(?=\\s|$|[^\\w-])`, 'g'); // Форматируем содержимое для вставки const formattedContent = this.formatContentForInsertion(content, reference); return text.replace(pattern, formattedContent); }*/ /** * Раскрывает только постоянные ссылки (@@/##) в тексте * @param text - текст для обработки * @returns текст с раскрытыми постоянными ссылками */ /*private async expandPermanentReferences(text: string): Promise { const matches = ReferenceParser.parseReferences(text); const permanentMatches = matches.filter((match) => match.prefix.length === 2); // @@ или ## if (permanentMatches.length === 0) { return text; } let expandedText = text; // Обрабатываем ссылки от конца к началу const sortedMatches = [...permanentMatches].sort((a, b) => b.startIndex - a.startIndex); for (const match of sortedMatches) { const reference: FileReference = { type: match.type, name: match.name, path: await this.resolveFilePath(match.name, match.type), permanent: true }; const content = await this.loadFileContent(reference); if (content !== null) { const formattedContent = this.formatContentForInsertion(content, reference); expandedText = expandedText.substring(0, match.startIndex) + formattedContent + expandedText.substring(match.endIndex); } } return expandedText; }*/ /** * Загружает содержимое файла * @param reference - ссылка на файл * @returns содержимое файла или null если не найден */ /*private async loadFileContent(reference: FileReference): Promise { try { const file = this.findFile(reference.path, reference.type); if (!file) { return null; } const content = await this.plugin.app.vault.read(file); // Для промптов обрабатываем шаблоны if (reference.type === 'prompt') { return await this.templateEngine.expandPromptTemplate(content); } return content; } catch (error) { console.error(`Ошибка загрузки файла ${reference.path}:`, error); return null; } }*/ /** * Находит файл по пути * @param path - путь к файлу * @param type - тип файла * @returns файл или null */ /*private findFile(path: string, type: 'file' | 'prompt'): TFile | null { let file = this.plugin.app.vault.getAbstractFileByPath(path); if (!file && type === 'prompt') { // Попробуем найти в папке промптов const promptsFolder = this.plugin.settings.promptsFolder; if (promptsFolder) { const fullPath = `${promptsFolder}/${path}`; file = this.plugin.app.vault.getAbstractFileByPath(fullPath); } } return file instanceof TFile ? file : null; }*/ /** * Разрешает полный путь к файлу по имени * @param name - имя файла * @param type - тип файла * @returns полный путь к файлу */ /*private async resolveFilePath(name: string, type: 'file' | 'prompt'): Promise { if (type === 'prompt') { const promptsFolder = this.plugin.settings.promptsFolder; return promptsFolder ? `${promptsFolder}/${name}.md` : `${name}.md`; } else { // Ищем файл в хранилище const files = this.plugin.app.vault.getMarkdownFiles(); const matchingFile = files.find((f) => f.basename === name); return matchingFile ? matchingFile.path : `${name}.md`; } }*/ /** * Форматирует содержимое для вставки в сообщение * @param content - содержимое файла * @param reference - информация о ссылке * @returns отформатированное содержимое */ /*private formatContentForInsertion(content: string, reference: FileReference): string { // Добавляем заголовок с информацией о файле const header = `\n\n--- Содержимое ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n`; const footer = `\n--- Конец ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n\n`; return header + content.trim() + footer; }*/ /** * Экранирует специальные символы для регулярного выражения * @param string - строка для экранирования * @returns экранированная строка */ /*private escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }*/ }