From a1039661782ecc9e4ec31bb7a536f436931a0c10 Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Sun, 10 May 2026 19:46:31 +0300 Subject: [PATCH] Add support for %url and !%url references with html-to-markdown caching --- src/components/ChatPanel.ts | 4 +- src/components/models/ChatHistoryItem.ts | 2 +- src/references/AutocompleteManager.ts | 50 ++++++++++++++++++++- src/references/CacheManager.ts | 29 ++++++++++++ src/references/ReferenceAutocomplete.ts | 12 +++-- src/references/ReferenceBox.ts | 4 ++ src/references/ReferenceExpander.ts | 57 +++++++++++++++++++++++- src/references/ReferenceParser.ts | 18 ++++---- 8 files changed, 158 insertions(+), 18 deletions(-) diff --git a/src/components/ChatPanel.ts b/src/components/ChatPanel.ts index d1e7f32..f1eeafa 100644 --- a/src/components/ChatPanel.ts +++ b/src/components/ChatPanel.ts @@ -279,6 +279,8 @@ export class ChatPanel { icon.textContent = '⚡'; } else if (attachment.type === 'external') { icon.textContent = '📎'; + } else if (attachment.type === 'url') { + icon.textContent = '🌐'; } else { icon.textContent = '📄'; } @@ -445,7 +447,7 @@ export class ChatPanel { data-testid="editor-input-main" class="message-input-editor" tabindex="0" - data-placeholder="Введите сообщение, @файл, #промпт, $внешний_файл или команду (/)" + data-placeholder="Введите сообщение, @файл, #промпт, $внешний_файл, %url или команду (/)" > diff --git a/src/components/models/ChatHistoryItem.ts b/src/components/models/ChatHistoryItem.ts index 635364a..49e604c 100644 --- a/src/components/models/ChatHistoryItem.ts +++ b/src/components/models/ChatHistoryItem.ts @@ -13,7 +13,7 @@ export interface ChatHistoryItem { } export interface Attachment { - type: 'file' | 'prompt' | 'external'; + type: 'file' | 'prompt' | 'external' | 'url'; name: string; sourcePath: string; // Относительный для 'file'/'prompt', абсолютный для 'external' cachedPath: string; // Относительный путь к кешированному файлу diff --git a/src/references/AutocompleteManager.ts b/src/references/AutocompleteManager.ts index 2ca170d..3042d59 100644 --- a/src/references/AutocompleteManager.ts +++ b/src/references/AutocompleteManager.ts @@ -3,7 +3,7 @@ * Обеспечивает поиск файлов в хранилище и кеширование результатов. */ -import { Notice, TFile } from 'obsidian'; +import { Notice, TFile, requestUrl } from 'obsidian'; import LLMAgentPlugin from 'main'; // ----------------------------------------------- Interfaces --------------------------------------------------------------- @@ -11,7 +11,7 @@ import LLMAgentPlugin from 'main'; export interface AutocompleteItem { name: string; path: string; - type: 'file' | 'prompt' | 'external'; + type: 'file' | 'prompt' | 'external' | 'url'; icon: string; // Obsidian icon name file?: TFile; // Ссылка на файл Obsidian } @@ -24,6 +24,7 @@ export class AutocompleteManager { private promptCache: Map = new Map(); private lastCacheUpdate = 0; private readonly CACHE_TTL = 30000; // 30 секунд + private urlTitleCache: Map = new Map(); /** * @property {number} MAX_TOTAL_ITEMS Максимальное количество элементов для поиска и кэширования. @@ -101,6 +102,51 @@ export class AutocompleteManager { .slice(0, AutocompleteManager.MAX_TOTAL_ITEMS); } + /** + * Получает элементы для автокомплита URL. Загружает title страницы. + */ + async getUrlOptions(query: string): Promise { + if (!query.trim()) return []; + + // Проверяем, похож ли запрос на полноценный URL (начинается с http) + const isUrl = /^https?:\/\//i.test(query); + let displayTitle = query; + + if (isUrl) { + if (this.urlTitleCache.has(query)) { + displayTitle = this.urlTitleCache.get(query)!; + } else { + try { + // Скачиваем заголовок + const response = await requestUrl({ url: query }); + const match = response.text.match(/]*>(.*?)<\/title>/i); + if (match && match[1]) { + // Очищаем HTML сущности (например & -> &) и обрезаем + displayTitle = match[1].replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); + this.urlTitleCache.set(query, displayTitle); + } + } catch (e) { + // Игнорируем ошибку (вероятно URL неполный или сервер не отвечает) + console.warn(`URL autocomplete fetch error for ${query}:`, e); + } + } + } + + return [ + { + name: displayTitle, // Либо title (если распарсился), либо сам URL + path: query, // Сам URL используется как путь назначения + type: 'url', + icon: 'globe' + } + ]; + } + /** * Очищает кеш (используется при изменении настроек) */ diff --git a/src/references/CacheManager.ts b/src/references/CacheManager.ts index d94c4ab..6416d77 100644 --- a/src/references/CacheManager.ts +++ b/src/references/CacheManager.ts @@ -139,6 +139,35 @@ export class CacheManager { }; } + /** + * Кеширует произвольный текстовый контент как MD файл (используется для URL страниц). + */ + async cacheTextData(content: string, url: string): Promise<{ uuid: string; cachedPath: string; mimeType: string }> { + await this.ensureCacheFolderExists(); + + const uuid = uuidv4(); + // Делаем безопасное имя для URL: убираем спецсимволы, оставляем максимум 30 символов + const safeName = url.replace(/[^a-zA-Z0-9а-яА-ЯёЁ]/g, '_').substring(0, 30); + const cachedFileName = `web-${safeName}-${uuid}.md`; + + const cacheFolderPath = this.getCacheFolderPath(); + const cachedFilePath = `${cacheFolderPath}/${cachedFileName}`; + + if (!window.require) { + throw new Error('Кеширование доступно только в desktop версии'); + } + + const fs = window.require('fs').promises; + await fs.writeFile(cachedFilePath, content, 'utf8'); + + return { + uuid: uuid, + cachedPath: cachedFileName, + name: url, + mimeType: 'text/markdown' + }; + } + // TODO: Кажется, не используется /** * Восстанавливает кеш из исходного файла diff --git a/src/references/ReferenceAutocomplete.ts b/src/references/ReferenceAutocomplete.ts index b7b5fe8..10d56cc 100644 --- a/src/references/ReferenceAutocomplete.ts +++ b/src/references/ReferenceAutocomplete.ts @@ -58,10 +58,14 @@ export class ReferenceAutocomplete { } // Для файлов и промптов работаем как раньше - const options = - reference.type === 'file' - ? await this.autocompleteManager.getFileOptions(reference.name) - : await this.autocompleteManager.getPromptOptions(reference.name); + let options: AutocompleteItem[] = []; + if (reference.type === 'file') { + options = await this.autocompleteManager.getFileOptions(reference.name); + } else if (reference.type === 'prompt') { + options = await this.autocompleteManager.getPromptOptions(reference.name); + } else if (reference.type === 'url') { + options = await this.autocompleteManager.getUrlOptions(reference.name); + } if (options.length === 0) { this.hideAutocomplete(); diff --git a/src/references/ReferenceBox.ts b/src/references/ReferenceBox.ts index 86923ba..2f6aa2f 100644 --- a/src/references/ReferenceBox.ts +++ b/src/references/ReferenceBox.ts @@ -135,6 +135,10 @@ export class ReferenceBox { return '📎'; // Знак доллара для внешних файлов } + if (reference.type === 'url') { + return '🌐'; // Для текста с URL-страниц + } + // Для файлов из vault используем @ return '📄'; } diff --git a/src/references/ReferenceExpander.ts b/src/references/ReferenceExpander.ts index d48dded..c2ad1ef 100644 --- a/src/references/ReferenceExpander.ts +++ b/src/references/ReferenceExpander.ts @@ -3,7 +3,7 @@ * Обрабатывает как одинарные (@/#), так и постоянные (@@/##) ссылки. */ -import { FileSystemAdapter, TFile } from 'obsidian'; +import { FileSystemAdapter, TFile, requestUrl } from 'obsidian'; import LLMAgentPlugin from 'main'; import { FileReference, ReferenceParser } from './ReferenceParser'; import { TemplateEngine } from './TemplateEngine'; @@ -85,7 +85,60 @@ export class ReferenceExpander { console.warn(`Файлы логов не найдены в папке: ${logsFolder}`); continue; } - } + } + // --- Обработка URL ссылок (% / !%) --- + else if (reference.type === 'url') { + let cleanedMarkdown = ''; + try { + // Скачиваем страницу (requestUrl обходит CORS) + const response = await requestUrl({ url: reference.path }); + const htmlContent = response.text; + + // Парсим HTML через встроенный в браузер DOMParser + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlContent, "text/html"); + + // 1. Очищаем мусор: скрипты, стили, элементы навигации + const elementsToRemove = doc.querySelectorAll('script, style, noscript, iframe, link, meta, head, nav, footer, header, aside, form, svg'); + elementsToRemove.forEach(el => el.remove()); + + // 2. Достаем форматированный текст + // innerText отлично читает структуру, добавляет переносы строк между блоками + cleanedMarkdown = doc.body ? doc.body.innerText : doc.documentElement.innerText; + + // 3. Форматируем: сворачиваем множественные пустые строки (>3) в двойные + cleanedMarkdown = cleanedMarkdown + // А) Сначала схлапываем горизонтальные пробелы (пробелы и табы) + // Любые 2 и более пробельных символа (кроме переноса строки) -> 1 пробел + .replace(/[^\S\r\n]{2,}/g, ' ') + + // Б) Схлапываем "грязные" переносы строк. + // Находим последовательности, где есть перенос, а за ним могут идти пробелы, табы и другие переносы. + // Регистрируем случаи, когда таких "пустых" переходов больше двух. + // Регулярка: \n (один запуск) + любая комбинация пробелов/табов/переносов, которая в сумме дает лишние пустые строки. + .replace(/\n([ \t]*\n){2,}/g, '\n\n') + + // В) Убираем пробелы в начале и конце каждой строки + .split('\n').map(line => line.trim()).join('\n') + + // Г) Финальный trim всего документа + .trim(); + + if (!cleanedMarkdown) { + cleanedMarkdown = "Не удалось извлечь текстовый контент со страницы."; + } + } catch (e) { + console.error(`Ошибка загрузки URL: ${reference.path}`, e); + cleanedMarkdown = `Ошибка загрузки URL: ${e.message}`; + } + + // Кешируем результат как файл, чтобы бекенд прочитал его как вложение + const cacheResult = await this.cacheManager.cacheTextData(cleanedMarkdown, reference.path); + cachedPath = cacheResult.cachedPath; + uuid = cacheResult.uuid; + mimeType = cacheResult.mimeType; + sourcePath = reference.path; // Отдаем сам URL как источник + } // --- Обработка внешних файлов ($ / !$) --- else if (reference.type === 'external') { const cacheResult = await this.cacheManager.cacheExternalFile(reference.path); diff --git a/src/references/ReferenceParser.ts b/src/references/ReferenceParser.ts index f135d56..c49ade3 100644 --- a/src/references/ReferenceParser.ts +++ b/src/references/ReferenceParser.ts @@ -11,12 +11,12 @@ export interface ReferenceMatch { name: string; startIndex: number; endIndex: number; - type: 'file' | 'prompt' | 'external'; + type: 'file' | 'prompt' | 'external' | 'url'; permanent: boolean; } export interface FileReference { - type: 'file' | 'prompt' | 'external'; + type: 'file' | 'prompt' | 'external' | 'url'; name: string; path: string; permanent: boolean; @@ -34,7 +34,7 @@ export class ReferenceParser { static parseReferences(text: string): ReferenceMatch[] { const matches: ReferenceMatch[] = []; - const regex = /(![@#$]|[@#$])(?:"([^"]+)"|([^\s@#$"!]+))/g; + const regex = /(![@#$%]|[@#$%])(?:"([^"]+)"|([^\s@#$%"!]+))/g; let match; while ((match = regex.exec(text)) !== null) { @@ -45,11 +45,12 @@ export class ReferenceParser { // Определяем тип по первому символу префикса (после !) const typeChar = prefix.replace('!', ''); - let type: 'file' | 'prompt' | 'external'; + let type: 'file' | 'prompt' | 'external'| 'url'; if (typeChar === '@') type = 'file'; else if (typeChar === '#') type = 'prompt'; - else type = 'external'; // $ + else if (typeChar === '$') type = 'external'; + else type = 'url'; matches.push({ fullMatch: match[0], @@ -76,7 +77,7 @@ export class ReferenceParser { // Измененная регулярка: теперь она понимает, если после символа # идет кавычка // Группа 1: Префикс, Группа 2: Имя в кавычках, Группа 3: Имя без кавычек - const incompleteRegex = /(![@#$]|[@#$])(?:"([^"]*)|([^\s@#$"!]*?))$/; + const incompleteRegex = /(![@#$%]|[@#$%])(?:"([^"]*)|([^\s@#$%"!]*?))$/; const match = beforeCursor.match(incompleteRegex); if (match) { @@ -88,11 +89,12 @@ export class ReferenceParser { const startIndex = beforeCursor.length - match[0].length; const typeChar = prefix.replace('!', ''); - let type: 'file' | 'prompt' | 'external'; + let type: 'file' | 'prompt' | 'external' | 'url'; if (typeChar === '@') type = 'file'; else if (typeChar === '#') type = 'prompt'; - else type = 'external'; + else if (typeChar === '$') type = 'external'; + else type = 'url'; return { fullMatch: match[0],