Add support for %url and !%url references with html-to-markdown caching

This commit is contained in:
dimitrievgs 2026-05-10 19:46:31 +03:00
parent a7019c8a7f
commit a103966178
8 changed files with 158 additions and 18 deletions

View File

@ -279,6 +279,8 @@ export class ChatPanel {
icon.textContent = '⚡'; icon.textContent = '⚡';
} else if (attachment.type === 'external') { } else if (attachment.type === 'external') {
icon.textContent = '📎'; icon.textContent = '📎';
} else if (attachment.type === 'url') {
icon.textContent = '🌐';
} else { } else {
icon.textContent = '📄'; icon.textContent = '📄';
} }
@ -445,7 +447,7 @@ export class ChatPanel {
data-testid="editor-input-main" data-testid="editor-input-main"
class="message-input-editor" class="message-input-editor"
tabindex="0" tabindex="0"
data-placeholder="Введите сообщение, @файл, #промпт, $внешний_файл или команду (/)" data-placeholder="Введите сообщение, @файл, #промпт, $внешний_файл, %url или команду (/)"
></div> ></div>
<div class="attached-files-container" id="attached-files-container" style="display: none;"></div> <div class="attached-files-container" id="attached-files-container" style="display: none;"></div>
</div> </div>

View File

@ -13,7 +13,7 @@ export interface ChatHistoryItem {
} }
export interface Attachment { export interface Attachment {
type: 'file' | 'prompt' | 'external'; type: 'file' | 'prompt' | 'external' | 'url';
name: string; name: string;
sourcePath: string; // Относительный для 'file'/'prompt', абсолютный для 'external' sourcePath: string; // Относительный для 'file'/'prompt', абсолютный для 'external'
cachedPath: string; // Относительный путь к кешированному файлу cachedPath: string; // Относительный путь к кешированному файлу

View File

@ -3,7 +3,7 @@
* Обеспечивает поиск файлов в хранилище и кеширование результатов. * Обеспечивает поиск файлов в хранилище и кеширование результатов.
*/ */
import { Notice, TFile } from 'obsidian'; import { Notice, TFile, requestUrl } from 'obsidian';
import LLMAgentPlugin from 'main'; import LLMAgentPlugin from 'main';
// ----------------------------------------------- Interfaces --------------------------------------------------------------- // ----------------------------------------------- Interfaces ---------------------------------------------------------------
@ -11,7 +11,7 @@ import LLMAgentPlugin from 'main';
export interface AutocompleteItem { export interface AutocompleteItem {
name: string; name: string;
path: string; path: string;
type: 'file' | 'prompt' | 'external'; type: 'file' | 'prompt' | 'external' | 'url';
icon: string; // Obsidian icon name icon: string; // Obsidian icon name
file?: TFile; // Ссылка на файл Obsidian file?: TFile; // Ссылка на файл Obsidian
} }
@ -24,6 +24,7 @@ export class AutocompleteManager {
private promptCache: Map<string, AutocompleteItem[]> = new Map(); private promptCache: Map<string, AutocompleteItem[]> = new Map();
private lastCacheUpdate = 0; private lastCacheUpdate = 0;
private readonly CACHE_TTL = 30000; // 30 секунд private readonly CACHE_TTL = 30000; // 30 секунд
private urlTitleCache: Map<string, string> = new Map();
/** /**
* @property {number} MAX_TOTAL_ITEMS Максимальное количество элементов для поиска и кэширования. * @property {number} MAX_TOTAL_ITEMS Максимальное количество элементов для поиска и кэширования.
@ -101,6 +102,51 @@ export class AutocompleteManager {
.slice(0, AutocompleteManager.MAX_TOTAL_ITEMS); .slice(0, AutocompleteManager.MAX_TOTAL_ITEMS);
} }
/**
* Получает элементы для автокомплита URL. Загружает title страницы.
*/
async getUrlOptions(query: string): Promise<AutocompleteItem[]> {
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[^>]*>(.*?)<\/title>/i);
if (match && match[1]) {
// Очищаем HTML сущности (например &amp; -> &) и обрезаем
displayTitle = match[1].replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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'
}
];
}
/** /**
* Очищает кеш (используется при изменении настроек) * Очищает кеш (используется при изменении настроек)
*/ */

View File

@ -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: Кажется, не используется // TODO: Кажется, не используется
/** /**
* Восстанавливает кеш из исходного файла * Восстанавливает кеш из исходного файла

View File

@ -58,10 +58,14 @@ export class ReferenceAutocomplete {
} }
// Для файлов и промптов работаем как раньше // Для файлов и промптов работаем как раньше
const options = let options: AutocompleteItem[] = [];
reference.type === 'file' if (reference.type === 'file') {
? await this.autocompleteManager.getFileOptions(reference.name) options = await this.autocompleteManager.getFileOptions(reference.name);
: await this.autocompleteManager.getPromptOptions(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) { if (options.length === 0) {
this.hideAutocomplete(); this.hideAutocomplete();

View File

@ -135,6 +135,10 @@ export class ReferenceBox {
return '📎'; // Знак доллара для внешних файлов return '📎'; // Знак доллара для внешних файлов
} }
if (reference.type === 'url') {
return '🌐'; // Для текста с URL-страниц
}
// Для файлов из vault используем @ // Для файлов из vault используем @
return '📄'; return '📄';
} }

View File

@ -3,7 +3,7 @@
* Обрабатывает как одинарные (@/#), так и постоянные (@@/##) ссылки. * Обрабатывает как одинарные (@/#), так и постоянные (@@/##) ссылки.
*/ */
import { FileSystemAdapter, TFile } from 'obsidian'; import { FileSystemAdapter, TFile, requestUrl } from 'obsidian';
import LLMAgentPlugin from 'main'; import LLMAgentPlugin from 'main';
import { FileReference, ReferenceParser } from './ReferenceParser'; import { FileReference, ReferenceParser } from './ReferenceParser';
import { TemplateEngine } from './TemplateEngine'; import { TemplateEngine } from './TemplateEngine';
@ -86,6 +86,59 @@ export class ReferenceExpander {
continue; 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') { else if (reference.type === 'external') {
const cacheResult = await this.cacheManager.cacheExternalFile(reference.path); const cacheResult = await this.cacheManager.cacheExternalFile(reference.path);

View File

@ -11,12 +11,12 @@ export interface ReferenceMatch {
name: string; name: string;
startIndex: number; startIndex: number;
endIndex: number; endIndex: number;
type: 'file' | 'prompt' | 'external'; type: 'file' | 'prompt' | 'external' | 'url';
permanent: boolean; permanent: boolean;
} }
export interface FileReference { export interface FileReference {
type: 'file' | 'prompt' | 'external'; type: 'file' | 'prompt' | 'external' | 'url';
name: string; name: string;
path: string; path: string;
permanent: boolean; permanent: boolean;
@ -34,7 +34,7 @@ export class ReferenceParser {
static parseReferences(text: string): ReferenceMatch[] { static parseReferences(text: string): ReferenceMatch[] {
const matches: ReferenceMatch[] = []; const matches: ReferenceMatch[] = [];
const regex = /(![@#$]|[@#$])(?:"([^"]+)"|([^\s@#$"!]+))/g; const regex = /(![@#$%]|[@#$%])(?:"([^"]+)"|([^\s@#$%"!]+))/g;
let match; let match;
while ((match = regex.exec(text)) !== null) { while ((match = regex.exec(text)) !== null) {
@ -45,11 +45,12 @@ export class ReferenceParser {
// Определяем тип по первому символу префикса (после !) // Определяем тип по первому символу префикса (после !)
const typeChar = prefix.replace('!', ''); const typeChar = prefix.replace('!', '');
let type: 'file' | 'prompt' | 'external'; let type: 'file' | 'prompt' | 'external'| 'url';
if (typeChar === '@') type = 'file'; if (typeChar === '@') type = 'file';
else if (typeChar === '#') type = 'prompt'; else if (typeChar === '#') type = 'prompt';
else type = 'external'; // $ else if (typeChar === '$') type = 'external';
else type = 'url';
matches.push({ matches.push({
fullMatch: match[0], fullMatch: match[0],
@ -76,7 +77,7 @@ export class ReferenceParser {
// Измененная регулярка: теперь она понимает, если после символа # идет кавычка // Измененная регулярка: теперь она понимает, если после символа # идет кавычка
// Группа 1: Префикс, Группа 2: Имя в кавычках, Группа 3: Имя без кавычек // Группа 1: Префикс, Группа 2: Имя в кавычках, Группа 3: Имя без кавычек
const incompleteRegex = /(![@#$]|[@#$])(?:"([^"]*)|([^\s@#$"!]*?))$/; const incompleteRegex = /(![@#$%]|[@#$%])(?:"([^"]*)|([^\s@#$%"!]*?))$/;
const match = beforeCursor.match(incompleteRegex); const match = beforeCursor.match(incompleteRegex);
if (match) { if (match) {
@ -88,11 +89,12 @@ export class ReferenceParser {
const startIndex = beforeCursor.length - match[0].length; const startIndex = beforeCursor.length - match[0].length;
const typeChar = prefix.replace('!', ''); const typeChar = prefix.replace('!', '');
let type: 'file' | 'prompt' | 'external'; let type: 'file' | 'prompt' | 'external' | 'url';
if (typeChar === '@') type = 'file'; if (typeChar === '@') type = 'file';
else if (typeChar === '#') type = 'prompt'; else if (typeChar === '#') type = 'prompt';
else type = 'external'; else if (typeChar === '$') type = 'external';
else type = 'url';
return { return {
fullMatch: match[0], fullMatch: match[0],