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],