llm-agent-plugin/src/references/ReferenceExpander.ts
2025-09-25 01:39:05 +03:00

249 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Расширяет ссылки на файлы и промпты, заменяя их содержимым перед отправкой на сервер.
* Обрабатывает как одинарные (@/#), так и постоянные (@@/##) ссылки.
*/
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<string> {
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<any[]> {
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<string, string>}> {
// const snapshots: Record<string, string> = {};
// 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<string> {
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<string> {
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<string | null> {
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<string> {
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, '\\$&');
}
}