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 = '⚡';
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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; // Относительный путь к кешированному файлу
|
||||||
|
|
|
||||||
|
|
@ -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 сущности (например & -> &) и обрезаем
|
||||||
|
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: Кажется, не используется
|
// TODO: Кажется, не используется
|
||||||
/**
|
/**
|
||||||
* Восстанавливает кеш из исходного файла
|
* Восстанавливает кеш из исходного файла
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@ export class ReferenceBox {
|
||||||
return '📎'; // Знак доллара для внешних файлов
|
return '📎'; // Знак доллара для внешних файлов
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reference.type === 'url') {
|
||||||
|
return '🌐'; // Для текста с URL-страниц
|
||||||
|
}
|
||||||
|
|
||||||
// Для файлов из vault используем @
|
// Для файлов из vault используем @
|
||||||
return '📄';
|
return '📄';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user