Add support for %url and !%url references with html-to-markdown caching
This commit is contained in:
parent
a7019c8a7f
commit
a103966178
|
|
@ -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 или команду (/)"
|
||||
></div>
|
||||
<div class="attached-files-container" id="attached-files-container" style="display: none;"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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; // Относительный путь к кешированному файлу
|
||||
|
|
|
|||
|
|
@ -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<string, AutocompleteItem[]> = new Map();
|
||||
private lastCacheUpdate = 0;
|
||||
private readonly CACHE_TTL = 30000; // 30 секунд
|
||||
private urlTitleCache: Map<string, string> = 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<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 сущности (например & -> &) и обрезаем
|
||||
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'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает кеш (используется при изменении настроек)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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: Кажется, не используется
|
||||
/**
|
||||
* Восстанавливает кеш из исходного файла
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -135,6 +135,10 @@ export class ReferenceBox {
|
|||
return '📎'; // Знак доллара для внешних файлов
|
||||
}
|
||||
|
||||
if (reference.type === 'url') {
|
||||
return '🌐'; // Для текста с URL-страниц
|
||||
}
|
||||
|
||||
// Для файлов из vault используем @
|
||||
return '📄';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user