llm-agent-plugin/src/references/ReferenceExpander.ts
2026-05-03 22:21:15 +03:00

416 lines
16 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 { 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<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;
}*/
/**
* Извлекает вложения из текста и кеширует файлы.
* Обрабатывает как физические файлы, так и виртуальные (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<any[]> {
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<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, '\\$&');
}*/
}