249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
/**
|
||
* Расширяет ссылки на файлы и промпты, заменяя их содержимым перед отправкой на сервер.
|
||
* Обрабатывает как одинарные (@/#), так и постоянные (@@/##) ссылки.
|
||
*/
|
||
|
||
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, '\\$&');
|
||
}
|
||
} |