/** * Расширяет ссылки на файлы и промпты, заменяя их содержимым перед отправкой на сервер. * Обрабатывает как одинарные (@/#), так и постоянные (@@/##) ссылки. */ import { TFile } from 'obsidian'; import LLMAgentPlugin from 'main'; import { FileReference, ReferenceParser } from './ReferenceParser'; import { TemplateEngine } from './TemplateEngine'; // ----------------------------------------------- Reference Expander --------------------------------------------------------------- export class ReferenceExpander { private plugin: LLMAgentPlugin; private templateEngine: TemplateEngine; constructor(plugin: LLMAgentPlugin, templateEngine: TemplateEngine) { this.plugin = plugin; this.templateEngine = templateEngine; } // ----------------------------------------------- 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; } // 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, '\\$&'); } }