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