Add сaching of files, content of files no longer stored in db, add $ and !$ shoftcuts

This commit is contained in:
dimitrievgs 2025-11-10 03:28:10 +03:00
parent 145417cfea
commit b773fdbc40
13 changed files with 1201 additions and 597 deletions

677
main.js

File diff suppressed because one or more lines are too long

25
main.ts
View File

@ -12,10 +12,11 @@ interface LLMAgentSettings {
apiBaseUrl: string; apiBaseUrl: string;
systemPrompt: string; systemPrompt: string;
defaultModel: string; defaultModel: string;
promptsFolder: string; // Новое поле promptsFolder: string;
enableFileReferences: boolean; // Новое поле enableFileReferences: boolean;
enablePromptReferences: boolean; // Новое поле enablePromptReferences: boolean;
excludedFolders: string[]; // Новое поле: Список папок для исключения из автокомплита excludedFolders: string[];
refCacheFolder: string;
} }
const DEFAULT_SETTINGS: LLMAgentSettings = { const DEFAULT_SETTINGS: LLMAgentSettings = {
@ -25,7 +26,8 @@ const DEFAULT_SETTINGS: LLMAgentSettings = {
promptsFolder: 'prompts', // Папка с промптами по умолчанию promptsFolder: 'prompts', // Папка с промптами по умолчанию
enableFileReferences: true, enableFileReferences: true,
enablePromptReferences: true, enablePromptReferences: true,
excludedFolders: ['templates'] // Папка "templates" по умолчанию исключена excludedFolders: ['templates'], // Папка "templates" по умолчанию исключена
refCacheFolder: 'ref-cache' // По умолчанию относительно vault
}; };
// ----------------------------------------------- Main Plugin Class --------------------------------------------------------------- // ----------------------------------------------- Main Plugin Class ---------------------------------------------------------------
@ -207,6 +209,19 @@ class SampleSettingTab extends PluginSettingTab {
}) })
); );
new Setting(containerEl)
.setName('Папка кеша для вложений')
.setDesc('Абсолютный или относительный путь к папке для кеширования файлов')
.addText((text) =>
text
.setPlaceholder('ref-cache')
.setValue(this.plugin.settings.refCacheFolder)
.onChange(async (value) => {
this.plugin.settings.refCacheFolder = value;
await this.plugin.saveSettings();
})
);
// Настройки ссылок на файлы // Настройки ссылок на файлы
new Setting(containerEl) new Setting(containerEl)
.setName('Включить ссылки на файлы') .setName('Включить ссылки на файлы')

View File

@ -14,10 +14,11 @@ import { AutocompleteItem } from '../references/AutocompleteManager';
import LLMAgentPlugin from 'main'; import LLMAgentPlugin from 'main';
import { MarkdownRenderer, setIcon, Notice, TFile } from 'obsidian'; import { MarkdownRenderer, setIcon, Notice, TFile } from 'obsidian';
import { Dialog } from 'src/utils/Dialog'; import { Dialog } from 'src/utils/Dialog';
import { CacheManager } from 'src/references/CacheManager';
interface ChatPanelProps { interface ChatPanelProps {
chatHistory: ChatHistoryItem[]; chatHistory: ChatHistoryItem[];
onSendMessage: (message: string, selectedModel?: string) => void; onSendMessage: (message: string, selectedModel?: string, attachments?: Attachment[]) => void;
availableModels?: string[]; availableModels?: string[];
selectedModel?: string; selectedModel?: string;
plugin: LLMAgentPlugin; plugin: LLMAgentPlugin;
@ -42,9 +43,11 @@ export class ChatPanel {
attachFileButton: HTMLButtonElement; attachFileButton: HTMLButtonElement;
fileInput: HTMLInputElement; fileInput: HTMLInputElement;
attachedFilesContainer: HTMLDivElement; attachedFilesContainer: HTMLDivElement;
externalAttachments: Attachment[] = []; currentAttachments: Attachment[] = [];
// ----------------------------------------------- Reference System Fields --------------------------------------------------------------- // ----------------------------------------------- Reference System Fields ---------------------------------------------------------------
cacheManager: CacheManager;
private autocompleteManager: AutocompleteManager; private autocompleteManager: AutocompleteManager;
private referenceAutocomplete: ReferenceAutocomplete; private referenceAutocomplete: ReferenceAutocomplete;
private referenceExpander: ReferenceExpander; private referenceExpander: ReferenceExpander;
@ -254,13 +257,18 @@ export class ChatPanel {
*/ */
private createAttachmentBox(attachment: Attachment): HTMLElement { private createAttachmentBox(attachment: Attachment): HTMLElement {
const box = document.createElement('span'); const box = document.createElement('span');
box.className = 'reference-box attachment-box clickable';
// Определяем классы в зависимости от типа и permanent
const permanentClass = attachment.permanent ? 'permanent' : 'single';
box.className = `reference-box attachment-box ${attachment.type} ${permanentClass} clickable`;
box.setAttribute('data-type', attachment.type); box.setAttribute('data-type', attachment.type);
box.setAttribute('data-name', attachment.name); box.setAttribute('data-name', attachment.name);
box.setAttribute('data-path', attachment.path); box.setAttribute('data-source-path', attachment.sourcePath);
box.setAttribute('data-cached-path', attachment.cachedPath);
box.setAttribute('data-uuid', attachment.uuid);
box.setAttribute('data-permanent', String(attachment.permanent)); box.setAttribute('data-permanent', String(attachment.permanent));
// НОВОЕ: Добавляем обработчик клика
box.addEventListener('click', () => { box.addEventListener('click', () => {
this.handleAttachmentClick(attachment); this.handleAttachmentClick(attachment);
}); });
@ -268,7 +276,6 @@ export class ChatPanel {
const icon = document.createElement('span'); const icon = document.createElement('span');
icon.className = 'reference-icon'; icon.className = 'reference-icon';
// ИЗМЕНЕНО: Разные иконки для разных типов
if (attachment.type === 'prompt') { if (attachment.type === 'prompt') {
icon.textContent = '⚡'; icon.textContent = '⚡';
} else if (attachment.type === 'external') { } else if (attachment.type === 'external') {
@ -290,18 +297,29 @@ export class ChatPanel {
/** /**
* Обрабатывает клик по боксу вложения * Обрабатывает клик по боксу вложения
*/ */
private handleAttachmentClick(attachment: Attachment): void { private async handleAttachmentClick(attachment: Attachment): Promise<void> {
// Проверяем существование кеша
const cacheManager = new CacheManager(this.props.plugin);
const cacheExists = await cacheManager.cachedFileExists(attachment.cachedPath);
let pathToOpen: string;
if (cacheExists) {
// Открываем кешированный файл
const cacheFolderPath = this.props.plugin.settings.refCacheFolder;
pathToOpen = this.props.plugin.app.vault.adapter.getBasePath() + '/' + cacheFolderPath + '/' + attachment.cachedPath;
} else {
// Кеш не найден, открываем исходный файл
if (attachment.type === 'external') { if (attachment.type === 'external') {
// Для внешних файлов открываем по абсолютному пути pathToOpen = attachment.sourcePath; // Уже абсолютный
if (attachment.path) { } else if (attachment.type === 'file') {
this.openExternalFile(attachment.path); pathToOpen = this.props.plugin.app.vault.adapter.getBasePath() + '/' + attachment.sourcePath;
} else { } else {
new Notice(`Не указан путь к файлу: ${attachment.name}`, 3000); pathToOpen = this.props.plugin.settings.promptsFolder + '/' + attachment.sourcePath;
} }
} else {
// Для файлов из хранилища Obsidian открываем через API
this.openVaultFile(attachment.path);
} }
this.openExternalFile(pathToOpen);
} }
/** /**
@ -409,6 +427,7 @@ export class ChatPanel {
this.autocompleteManager = new AutocompleteManager(this.props.plugin); this.autocompleteManager = new AutocompleteManager(this.props.plugin);
this.templateEngine = new TemplateEngine(this.props.plugin); this.templateEngine = new TemplateEngine(this.props.plugin);
this.cacheManager = new CacheManager(this.props.plugin);
this.referenceExpander = new ReferenceExpander(this.props.plugin, this.templateEngine); this.referenceExpander = new ReferenceExpander(this.props.plugin, this.templateEngine);
} }
@ -424,24 +443,12 @@ 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="Введите сообщение, @файл, #промпт, $внешний_файл или команду (/)"
></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>
<div class="chat-controls"> <div class="chat-controls">
<div class="control-buttons-left"> <div class="control-buttons-left">
<!-- НОВОЕ: Кнопка прикрепления файла -->
<button class="attach-file-button" id="attach-file-button"
aria-label="Прикрепить файл"
data-tooltip-position="top">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
</svg>
</button>
<input type="file" id="file-input" style="display: none;" multiple />
<div class="model-select-container"> <div class="model-select-container">
<button class="model-select-button" id="model-select-button"> <button class="model-select-button" id="model-select-button">
<span class="model-name" id="selected-model-name">${this.selectedModel}</span> <span class="model-name" id="selected-model-name">${this.selectedModel}</span>
@ -478,9 +485,6 @@ export class ChatPanel {
this.customPromptIndicator = this.container.querySelector('#custom-prompt-indicator') as HTMLSpanElement; this.customPromptIndicator = this.container.querySelector('#custom-prompt-indicator') as HTMLSpanElement;
this.stopButton = this.container.querySelector('#stop-button') as HTMLButtonElement; this.stopButton = this.container.querySelector('#stop-button') as HTMLButtonElement;
// НОВОЕ: Элементы для файловых вложений
this.attachFileButton = this.container.querySelector('#attach-file-button') as HTMLButtonElement;
this.fileInput = this.container.querySelector('#file-input') as HTMLInputElement;
this.attachedFilesContainer = this.container.querySelector('#attached-files-container') as HTMLDivElement; this.attachedFilesContainer = this.container.querySelector('#attached-files-container') as HTMLDivElement;
this.setupEventListeners(); this.setupEventListeners();
@ -552,11 +556,6 @@ export class ChatPanel {
this.stopButton.addEventListener('click', () => this.handleStopGeneration()); this.stopButton.addEventListener('click', () => this.handleStopGeneration());
// НОВОЕ: Обработчики для файловых вложений
this.attachFileButton.addEventListener('click', () => {
this.openFileDialog();
});
// Close dropdown when clicking outside // Close dropdown when clicking outside
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) { if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) {
@ -884,7 +883,7 @@ export class ChatPanel {
const rawText = this.extractTextWithReferences(); const rawText = this.extractTextWithReferences();
const message = rawText.trim(); const message = rawText.trim();
if (!message && this.externalAttachments.length === 0) return; // ИЗМЕНЕНО: разрешаем отправку только с файлами if (!message && this.currentAttachments.length === 0) return;
if (!this.referenceExpander) { if (!this.referenceExpander) {
this.handleSend(); this.handleSend();
@ -892,11 +891,11 @@ export class ChatPanel {
} }
try { try {
// Извлекаем вложения из ссылок // ИЗМЕНЕНО: Извлекаем вложения из ссылок И кешируем их
const { originalText, attachments } = await this.referenceExpander.extractAttachments(rawText, this.currentReferences); const { originalText, attachments } = await this.referenceExpander.extractAttachments(rawText, this.currentReferences);
// НОВОЕ: Объединяем вложения из ссылок и внешние файлы // Объединяем вложения из ссылок и текущие (из attached-files-container)
const allAttachments = [...attachments, ...this.externalAttachments]; const allAttachments = [...attachments, ...this.currentAttachments];
// Отправляем оригинальный текст и все вложения // Отправляем оригинальный текст и все вложения
if (this.props.onSendMessage) { if (this.props.onSendMessage) {
@ -907,7 +906,7 @@ export class ChatPanel {
} catch (error) { } catch (error) {
console.error('Ошибка при обработке ссылок:', error); console.error('Ошибка при обработке ссылок:', error);
if (this.props.onSendMessage) { if (this.props.onSendMessage) {
this.props.onSendMessage(rawText, this.selectedModel, this.externalAttachments); this.props.onSendMessage(rawText, this.selectedModel, this.currentAttachments);
} }
this.clearInput(); this.clearInput();
} }
@ -919,7 +918,7 @@ export class ChatPanel {
private clearInput(): void { private clearInput(): void {
this.messageInput.innerHTML = ''; this.messageInput.innerHTML = '';
this.currentReferences = []; this.currentReferences = [];
this.externalAttachments = []; this.currentAttachments = [];
this.renderAttachedFiles(); this.renderAttachedFiles();
this.updatePlaceholder(); this.updatePlaceholder();
this.hideSuggestions(); this.hideSuggestions();
@ -932,7 +931,7 @@ export class ChatPanel {
handleSend(): void { handleSend(): void {
const message = this.messageInput.textContent?.trim() || ''; const message = this.messageInput.textContent?.trim() || '';
if (message && this.props.onSendMessage) { if (message && this.props.onSendMessage) {
this.props.onSendMessage(message, this.selectedModel); this.props.onSendMessage(message, this.selectedModel, this.currentAttachments);
this.clearInput(); this.clearInput();
} }
} }
@ -1076,104 +1075,11 @@ export class ChatPanel {
// ----------------------------------------------- File Attachments --------------------------------------------------------------- // ----------------------------------------------- File Attachments ---------------------------------------------------------------
private async openFileDialog(): Promise<void> {
if (!window.electron) {
new Notice('Прикрепление внешних файлов доступно только в desktop-версии', 3000);
return;
}
try {
const result = await window.electron.remote.dialog.showOpenDialog({
properties: ['openFile', 'multiSelections'],
title: 'Выберите файлы для прикрепления'
});
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
return;
}
for (const filePath of result.filePaths) {
await this.addExternalAttachmentByPath(filePath);
}
} catch (error) {
console.error('Ошибка при открытии диалога выбора файлов:', error);
new Notice('Не удалось открыть диалог выбора файлов', 3000);
}
}
private async addExternalAttachmentByPath(absolutePath: string): Promise<void> {
if (!window.require) {
new Notice('Функция недоступна в этой версии приложения', 3000);
return;
}
try {
// Используем Node.js модули через require
const fs = window.require('fs');
const path = window.require('path');
// Читаем файл
const content = await fs.promises.readFile(absolutePath, 'utf-8');
const fileName = path.basename(absolutePath);
// Определяем MIME-type
const mimeType = this.getMimeType(fileName);
const attachment: Attachment = {
type: 'external',
name: fileName,
path: absolutePath,
permanent: false,
content: content,
mimeType: mimeType
};
this.externalAttachments.push(attachment);
this.renderAttachedFiles();
new Notice(`Файл "${fileName}" прикреплен`, 2000);
} catch (error) {
console.error('Ошибка при чтении файла:', error);
new Notice(`Не удалось прикрепить файл: ${error.message}`, 3000);
}
}
private getMimeType(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
txt: 'text/plain',
md: 'text/markdown',
json: 'application/json',
js: 'text/javascript',
ts: 'text/typescript',
html: 'text/html',
css: 'text/css',
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
xml: 'application/xml',
csv: 'text/csv',
zip: 'application/zip',
py: 'text/x-python',
java: 'text/x-java',
cpp: 'text/x-c++src',
c: 'text/x-csrc',
h: 'text/x-chdr',
sh: 'application/x-sh',
yaml: 'text/yaml',
yml: 'text/yaml'
};
return mimeTypes[ext || ''] || 'application/octet-stream';
}
/** /**
* Отображает прикрепленные файлы * Отображает прикрепленные файлы
*/ */
private renderAttachedFiles(): void { private renderAttachedFiles(): void {
if (this.externalAttachments.length === 0) { if (this.currentAttachments.length === 0) {
this.attachedFilesContainer.style.display = 'none'; this.attachedFilesContainer.style.display = 'none';
this.attachedFilesContainer.innerHTML = ''; this.attachedFilesContainer.innerHTML = '';
return; return;
@ -1182,7 +1088,7 @@ export class ChatPanel {
this.attachedFilesContainer.style.display = 'flex'; this.attachedFilesContainer.style.display = 'flex';
this.attachedFilesContainer.innerHTML = ''; this.attachedFilesContainer.innerHTML = '';
for (const attachment of this.externalAttachments) { for (const attachment of this.currentAttachments) {
const box = this.createRemovableAttachmentBox(attachment); const box = this.createRemovableAttachmentBox(attachment);
this.attachedFilesContainer.appendChild(box); this.attachedFilesContainer.appendChild(box);
} }
@ -1208,7 +1114,7 @@ export class ChatPanel {
removeBtn.innerHTML = '×'; removeBtn.innerHTML = '×';
removeBtn.setAttribute('aria-label', 'Удалить вложение'); removeBtn.setAttribute('aria-label', 'Удалить вложение');
removeBtn.addEventListener('click', () => { removeBtn.addEventListener('click', () => {
this.removeExternalAttachment(attachment); this.removeAttachment(attachment);
}); });
box.appendChild(icon); box.appendChild(icon);
@ -1221,10 +1127,10 @@ export class ChatPanel {
/** /**
* Удаляет внешнее вложение * Удаляет внешнее вложение
*/ */
private removeExternalAttachment(attachment: Attachment): void { private removeAttachment(attachment: Attachment): void {
const index = this.externalAttachments.indexOf(attachment); const index = this.currentAttachments.indexOf(attachment);
if (index > -1) { if (index > -1) {
this.externalAttachments.splice(index, 1); this.currentAttachments.splice(index, 1);
this.renderAttachedFiles(); this.renderAttachedFiles();
} }
} }

View File

@ -15,8 +15,9 @@ export interface ChatHistoryItem {
export interface Attachment { export interface Attachment {
type: 'file' | 'prompt' | 'external'; type: 'file' | 'prompt' | 'external';
name: string; name: string;
path: string; // Относительный путь для 'file'/'prompt', абсолютный для 'external' sourcePath: string; // Относительный для 'file'/'prompt', абсолютный для 'external'
permanent: boolean; cachedPath: string; // Относительный путь к кешированному файлу
content?: string; uuid: string; // UUID для идентификации кеша
permanent: boolean; // true для !@, !#, !$
mimeType?: string; mimeType?: string;
} }

View File

@ -542,7 +542,7 @@
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
box-shadow: var(--shadow-l); box-shadow: none; /* var(--shadow-l) */
z-index: auto; z-index: auto;
position: static; position: static;
@ -572,6 +572,21 @@
font-weight: 600; font-weight: 600;
} }
/* Внешние файлы - одинарная ссылка $ */
.reference-box.external.single {
background: rgba(100, 150, 255, 0.1);
border: 1px solid rgba(100, 150, 255, 0.3);
color: var(--text-normal);
}
/* Внешние файлы - постоянная ссылка !$ */
.reference-box.external.permanent {
background: rgba(100, 150, 255, 0.2);
border: 1px solid rgba(100, 150, 255, 0.6);
color: var(--text-normal);
font-weight: 600;
}
/* Reference Autocomplete */ /* Reference Autocomplete */
.reference-autocomplete { .reference-autocomplete {
/* 200px для 5 элементов, возможно, нужно заменить на расчёт с помощью padding и размером шрифта */ /* 200px для 5 элементов, возможно, нужно заменить на расчёт с помощью padding и размером шрифта */

View File

@ -3,7 +3,7 @@
* Обеспечивает поиск файлов в хранилище и кеширование результатов. * Обеспечивает поиск файлов в хранилище и кеширование результатов.
*/ */
import { TFile } from 'obsidian'; import { Notice, TFile } from 'obsidian';
import LLMAgentPlugin from 'main'; import LLMAgentPlugin from 'main';
// ----------------------------------------------- Interfaces --------------------------------------------------------------- // ----------------------------------------------- Interfaces ---------------------------------------------------------------
@ -237,4 +237,36 @@ export class AutocompleteManager {
} }
return false; return false;
} }
async handleExternalFileSelection(): Promise<AutocompleteItem | null> {
if (!window.electron) {
new Notice('Выбор внешних файлов доступен только в desktop-версии', 3000);
return null;
}
try {
const result = await window.electron.remote.dialog.showOpenDialog({
properties: ['openFile'],
title: 'Выберите файл для прикрепления'
});
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
return null;
}
const filePath = result.filePaths[0];
const path = window.require('path');
const fileName = path.basename(filePath);
return {
name: fileName,
path: filePath,
type: 'external',
icon: 'paperclip'
};
} catch (error) {
console.error('Ошибка при выборе файла:', error);
return null;
}
}
} }

View File

@ -0,0 +1,219 @@
/**
* Управляет кешированием файлов (внутренних, внешних, промптов).
* Сохраняет файлы в указанной папке с UUID в имени.
*/
import { TFile, normalizePath } from 'obsidian';
import LLMAgentPlugin from 'main';
import { v4 as uuidv4 } from 'uuid'; // Нужно установить: npm install uuid @types/uuid
export class CacheManager {
private plugin: LLMAgentPlugin;
constructor(plugin: LLMAgentPlugin) {
this.plugin = plugin;
}
/**
* Возвращает абсолютный путь к папке кеша
*/
public getCacheFolderPath(): string {
const cacheFolder = this.plugin.settings.refCacheFolder;
// Если путь относительный, делаем его относительно vault
if (!this.isAbsolutePath(cacheFolder)) {
return this.plugin.app.vault.adapter.getBasePath() + '/' + cacheFolder;
}
return cacheFolder;
}
/**
* Проверяет, является ли путь абсолютным
*/
private isAbsolutePath(path: string): boolean {
// Windows: C:\, D:\, и т.д.
if (/^[a-zA-Z]:\\/.test(path)) return true;
// Unix: /
if (path.startsWith('/')) return true;
return false;
}
/**
* Создает папку кеша, если она не существует
*/
private async ensureCacheFolderExists(): Promise<void> {
const cachePath = this.getCacheFolderPath();
if (!window.require) return; // Web версия
const fs = window.require('fs').promises;
try {
await fs.access(cachePath);
} catch {
await fs.mkdir(cachePath, { recursive: true });
}
}
/**
* Кеширует файл из vault
*/
async cacheVaultFile(file: TFile): Promise<{ uuid: string; cachedPath: string }> {
await this.ensureCacheFolderExists();
const uuid = uuidv4();
const extension = file.extension;
const cachedFileName = `${file.basename}-${uuid}.${extension}`;
const content = await this.plugin.app.vault.readBinary(file);
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, Buffer.from(content));
return {
uuid: uuid,
cachedPath: cachedFileName // Относительный путь
};
}
/**
* Кеширует внешний файл
*/
async cacheExternalFile(absolutePath: string): Promise<{ uuid: string; cachedPath: string; name: string; mimeType: string }> {
await this.ensureCacheFolderExists();
if (!window.require) {
throw new Error('Кеширование доступно только в desktop версии');
}
const fs = window.require('fs').promises;
const path = window.require('path');
const content = await fs.readFile(absolutePath);
const fileName = path.basename(absolutePath);
const extension = path.extname(fileName).substring(1);
const baseName = path.basename(fileName, path.extname(fileName));
const uuid = uuidv4();
const cachedFileName = `${baseName}-${uuid}.${extension}`;
const cacheFolderPath = this.getCacheFolderPath();
const cachedFilePath = `${cacheFolderPath}/${cachedFileName}`;
await fs.writeFile(cachedFilePath, content);
return {
uuid: uuid,
cachedPath: cachedFileName,
name: fileName,
mimeType: this.getMimeType(fileName)
};
}
/**
* Восстанавливает кеш из исходного файла
*/
async restoreCache(sourcePath: string, type: 'file' | 'prompt' | 'external', uuid: string): Promise<string> {
if (type === 'external') {
// Для внешних файлов sourcePath - абсолютный
const result = await this.cacheExternalFile(sourcePath);
return result.cachedPath;
} else if (type === 'file') {
// Для внутренних файлов sourcePath - относительный
const file = this.plugin.app.vault.getAbstractFileByPath(sourcePath);
if (file instanceof TFile) {
const result = await this.cacheVaultFile(file);
return result.cachedPath;
}
} else if (type === 'prompt') {
// Для промптов
const promptsFolder = this.plugin.settings.promptsFolder;
const fullPath = this.isAbsolutePath(sourcePath)
? sourcePath
: `${promptsFolder}/${sourcePath}`;
const file = this.plugin.app.vault.getAbstractFileByPath(fullPath);
if (file instanceof TFile) {
const result = await this.cacheVaultFile(file);
return result.cachedPath;
}
}
throw new Error(`Не удалось восстановить кеш для ${sourcePath}`);
}
/**
* Читает содержимое из кеша
*/
async readCachedFile(cachedPath: string): Promise<ArrayBuffer> {
const cacheFolderPath = this.getCacheFolderPath();
const fullPath = `${cacheFolderPath}/${cachedPath}`;
if (!window.require) {
throw new Error('Чтение кеша доступно только в desktop версии');
}
const fs = window.require('fs').promises;
const buffer = await fs.readFile(fullPath);
return buffer.buffer;
}
/**
* Проверяет существование кешированного файла
*/
async cachedFileExists(cachedPath: string): Promise<boolean> {
const cacheFolderPath = this.getCacheFolderPath();
const fullPath = `${cacheFolderPath}/${cachedPath}`;
if (!window.require) return false;
const fs = window.require('fs').promises;
try {
await fs.access(fullPath);
return true;
} catch {
return false;
}
}
private getMimeType(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
txt: 'text/plain',
md: 'text/markdown',
json: 'application/json',
js: 'text/javascript',
ts: 'text/typescript',
html: 'text/html',
css: 'text/css',
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
webp: 'image/webp',
xml: 'application/xml',
csv: 'text/csv',
zip: 'application/zip',
py: 'text/x-python',
java: 'text/x-java',
cpp: 'text/x-c++src',
c: 'text/x-csrc',
h: 'text/x-chdr',
sh: 'application/x-sh',
yaml: 'text/yaml',
yml: 'text/yaml'
};
return mimeTypes[ext || ''] || 'application/octet-stream';
}
}

View File

@ -39,7 +39,16 @@ export class ReferenceAutocomplete {
async showAutocomplete(reference: ReferenceMatch, cursorRect: DOMRect): Promise<void> { async showAutocomplete(reference: ReferenceMatch, cursorRect: DOMRect): Promise<void> {
this.currentReference = reference; this.currentReference = reference;
// Получаем опции в зависимости от типа ссылки // Для внешних файлов ($, !$) сразу открываем диалог
if (reference.type === 'external') {
const selectedFile = await this.autocompleteManager.handleExternalFileSelection();
if (selectedFile && this.onItemSelectedCallback) {
this.onItemSelectedCallback(selectedFile, reference);
}
return;
}
// Для файлов и промптов работаем как раньше
const options = const options =
reference.type === 'file' reference.type === 'file'
? await this.autocompleteManager.getFileOptions(reference.name) ? await this.autocompleteManager.getFileOptions(reference.name)
@ -51,7 +60,6 @@ export class ReferenceAutocomplete {
} }
this.currentOptions = options; this.currentOptions = options;
// Сбрасываем selection, если новые опции не содержат предыдущий выбранный элемент
if (this.selectedIndex >= this.currentOptions.length) { if (this.selectedIndex >= this.currentOptions.length) {
this.selectedIndex = 0; this.selectedIndex = 0;
} }

View File

@ -9,7 +9,6 @@ import { setIcon } from 'obsidian';
// ----------------------------------------------- Reference Box Component --------------------------------------------------------------- // ----------------------------------------------- Reference Box Component ---------------------------------------------------------------
export class ReferenceBox { export class ReferenceBox {
/** /**
* Создаёт визуальный бокс для отображения ссылки в сообщении * Создаёт визуальный бокс для отображения ссылки в сообщении
* @param reference - информация о ссылке * @param reference - информация о ссылке
@ -17,16 +16,24 @@ export class ReferenceBox {
*/ */
static createBox(reference: FileReference): HTMLElement { static createBox(reference: FileReference): HTMLElement {
const box = document.createElement('span'); const box = document.createElement('span');
box.contentEditable = 'false'; // ДОБАВЛЕНО: Делаем бокс нередактируемым
box.className = this.getBoxClasses(reference); box.className = this.getBoxClasses(reference);
box.setAttribute('data-reference-type', reference.type); box.setAttribute('data-reference-type', reference.type);
box.setAttribute('data-reference-name', reference.name); box.setAttribute('data-reference-name', reference.name);
box.setAttribute('data-reference-path', reference.path); box.setAttribute('data-reference-path', reference.path);
box.setAttribute('data-reference-permanent', reference.permanent.toString()); box.setAttribute('data-reference-permanent', reference.permanent.toString());
// ДОБАВЛЕНО: Сохраняем UUID, если есть
if (reference.uuid) {
box.setAttribute('data-reference-uuid', reference.uuid);
}
// Создаем иконку // Создаем иконку
const icon = document.createElement('span'); const icon = document.createElement('span');
icon.className = 'reference-box-icon'; icon.className = 'reference-box-icon';
setIcon(icon, this.getIconName(reference));
// ИЗМЕНЕНО: Используем символы вместо setIcon для более предсказуемого отображения
icon.textContent = this.getIconSymbol(reference);
// Создаем текст с именем // Создаем текст с именем
const nameSpan = document.createElement('span'); const nameSpan = document.createElement('span');
@ -59,12 +66,17 @@ export class ReferenceBox {
boxElement.setAttribute('data-reference-path', reference.path); boxElement.setAttribute('data-reference-path', reference.path);
boxElement.setAttribute('data-reference-permanent', reference.permanent.toString()); boxElement.setAttribute('data-reference-permanent', reference.permanent.toString());
// ДОБАВЛЕНО: Обновляем UUID, если есть
if (reference.uuid) {
boxElement.setAttribute('data-reference-uuid', reference.uuid);
}
const iconElement = boxElement.querySelector('.reference-box-icon'); const iconElement = boxElement.querySelector('.reference-box-icon');
const nameElement = boxElement.querySelector('.reference-box-name'); const nameElement = boxElement.querySelector('.reference-box-name');
if (iconElement) { if (iconElement) {
iconElement.innerHTML = ''; // ИЗМЕНЕНО: Используем символы вместо setIcon
setIcon(iconElement as HTMLElement, this.getIconName(reference)); iconElement.textContent = this.getIconSymbol(reference);
} }
if (nameElement) { if (nameElement) {
@ -78,10 +90,11 @@ export class ReferenceBox {
* @returns информация о ссылке * @returns информация о ссылке
*/ */
static extractReference(boxElement: HTMLElement): FileReference | null { static extractReference(boxElement: HTMLElement): FileReference | null {
const type = boxElement.getAttribute('data-reference-type') as 'file' | 'prompt'; const type = boxElement.getAttribute('data-reference-type') as 'file' | 'prompt' | 'external';
const name = boxElement.getAttribute('data-reference-name'); const name = boxElement.getAttribute('data-reference-name');
const path = boxElement.getAttribute('data-reference-path'); const path = boxElement.getAttribute('data-reference-path');
const permanent = boxElement.getAttribute('data-reference-permanent') === 'true'; const permanent = boxElement.getAttribute('data-reference-permanent') === 'true';
const uuid = boxElement.getAttribute('data-reference-uuid');
if (!type || !name || !path) { if (!type || !name || !path) {
return null; return null;
@ -91,7 +104,8 @@ export class ReferenceBox {
type, type,
name, name,
path, path,
permanent permanent,
uuid: uuid || undefined
}; };
} }
@ -109,32 +123,19 @@ export class ReferenceBox {
} }
/** /**
* Возвращает имя иконки для ссылки * Возвращает символ иконки для ссылки
* ИЗМЕНЕНО: Теперь возвращает символы вместо имен иконок Obsidian
*/ */
private static getIconName(reference: FileReference): string { private static getIconSymbol(reference: FileReference): string {
if (reference.type === 'prompt') { if (reference.type === 'prompt') {
return 'zap'; return '⚡'; // Молния для промптов
} }
// Определяем иконку по расширению файла if (reference.type === 'external') {
const extension = reference.path.split('.').pop()?.toLowerCase(); return '📎'; // Знак доллара для внешних файлов
switch (extension) { }
case 'md':
return 'file-text'; // Для файлов из vault используем @
case 'js': return '📄';
case 'ts':
return 'code';
case 'json':
return 'brackets';
case 'css':
return 'palette';
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
return 'image';
default:
return 'file';
}
} }
} }

View File

@ -8,16 +8,19 @@ import LLMAgentPlugin from 'main';
import { FileReference, ReferenceParser } from './ReferenceParser'; import { FileReference, ReferenceParser } from './ReferenceParser';
import { TemplateEngine } from './TemplateEngine'; import { TemplateEngine } from './TemplateEngine';
import { Attachment, ChatHistoryItem } from 'src/components/models/ChatHistoryItem'; import { Attachment, ChatHistoryItem } from 'src/components/models/ChatHistoryItem';
import { CacheManager } from './CacheManager';
// ----------------------------------------------- Reference Expander --------------------------------------------------------------- // ----------------------------------------------- Reference Expander ---------------------------------------------------------------
export class ReferenceExpander { export class ReferenceExpander {
private plugin: LLMAgentPlugin; private plugin: LLMAgentPlugin;
private templateEngine: TemplateEngine; private templateEngine: TemplateEngine;
private cacheManager: CacheManager;
constructor(plugin: LLMAgentPlugin, templateEngine: TemplateEngine) { constructor(plugin: LLMAgentPlugin, templateEngine: TemplateEngine) {
this.plugin = plugin; this.plugin = plugin;
this.templateEngine = templateEngine; this.templateEngine = templateEngine;
this.cacheManager = new CacheManager(plugin);
} }
// ----------------------------------------------- Public Methods --------------------------------------------------------------- // ----------------------------------------------- Public Methods ---------------------------------------------------------------
@ -75,8 +78,7 @@ export class ReferenceExpander {
} }
/** /**
* Извлекает вложения из текста, НЕ раскрывая их в тексте. * Извлекает вложения из текста и кеширует файлы
* Возвращает оригинальный текст и массив вложений.
*/ */
async extractAttachments( async extractAttachments(
text: string, text: string,
@ -88,15 +90,60 @@ export class ReferenceExpander {
const attachments: Attachment[] = []; const attachments: Attachment[] = [];
for (const reference of references) { for (const reference of references) {
const content = await this.loadFileContent(reference); try {
if (content !== null) { let cachedPath: string;
let uuid: string;
let mimeType: string;
let sourcePath: string;
if (reference.type === 'external') {
// Кешируем внешний файл
const cacheResult = await this.cacheManager.cacheExternalFile(reference.path);
cachedPath = cacheResult.cachedPath;
uuid = cacheResult.uuid;
mimeType = cacheResult.mimeType;
sourcePath = reference.path; // Абсолютный
} else if (reference.type === 'file') {
// ИЗМЕНЕНО: Кешируем файл из vault
const file = this.plugin.app.vault.getAbstractFileByPath(reference.path);
if (file instanceof TFile) {
const cacheResult = await this.cacheManager.cacheVaultFile(file);
cachedPath = cacheResult.cachedPath;
uuid = cacheResult.uuid;
mimeType = this.getMimeType(file.name);
sourcePath = file.path; // Относительный
} else {
console.warn(`Файл не найден в vault: ${reference.path}`);
continue;
}
} else {
// prompt
// Кешируем промпт
const file = this.plugin.app.vault.getAbstractFileByPath(reference.path);
if (file instanceof TFile) {
const cacheResult = await this.cacheManager.cacheVaultFile(file);
cachedPath = cacheResult.cachedPath;
uuid = cacheResult.uuid;
mimeType = 'text/markdown';
sourcePath = file.path; // Относительный от promptsFolder
} else {
console.warn(`Промпт не найден: ${reference.path}`);
continue;
}
}
attachments.push({ attachments.push({
type: reference.type, type: reference.type,
name: reference.name, name: reference.name,
path: reference.path, sourcePath: sourcePath,
cachedPath: cachedPath,
uuid: uuid,
permanent: reference.permanent, permanent: reference.permanent,
content: content mimeType: mimeType
}); });
} catch (error) {
console.error(`Не удалось закешировать ${reference.name}:`, error);
} }
} }
@ -106,6 +153,38 @@ export class ReferenceExpander {
}; };
} }
private getMimeType(fileName: string): string {
// Та же логика, что в CacheManager
const ext = fileName.split('.').pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
txt: 'text/plain',
md: 'text/markdown',
json: 'application/json',
js: 'text/javascript',
ts: 'text/typescript',
html: 'text/html',
css: 'text/css',
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg+xml',
xml: 'application/xml',
csv: 'text/csv',
zip: 'application/zip',
py: 'text/x-python',
java: 'text/x-java',
cpp: 'text/x-c++src',
c: 'text/x-csrc',
h: 'text/x-chdr',
sh: 'application/x-sh',
yaml: 'text/yaml',
yml: 'text/yaml'
};
return mimeTypes[ext || ''] || 'application/octet-stream';
}
/** /**
* Подготавливает сообщения для LLM с учетом вложений. * Подготавливает сообщения для LLM с учетом вложений.
* Раскрывает только постоянные (@@/##) ссылки и ссылки в последнем сообщении. * Раскрывает только постоянные (@@/##) ссылки и ссылки в последнем сообщении.

View File

@ -6,20 +6,21 @@
// ----------------------------------------------- Interfaces --------------------------------------------------------------- // ----------------------------------------------- Interfaces ---------------------------------------------------------------
export interface ReferenceMatch { export interface ReferenceMatch {
fullMatch: string; // "@filename" или "@@filename" fullMatch: string;
prefix: string; // "@" или "@@" prefix: string; // Теперь может быть @, !@, #, !#, $, !$
name: string; // "filename" name: string;
startIndex: number; startIndex: number;
endIndex: number; endIndex: number;
type: 'file' | 'prompt'; type: 'file' | 'prompt' | 'external';
permanent: boolean;
} }
export interface FileReference { export interface FileReference {
type: 'file' | 'prompt'; type: 'file' | 'prompt' | 'external';
name: string; name: string;
path: string; path: string;
permanent: boolean; // true для @@ и ## permanent: boolean;
// TODO: добавить snapshotId при реализации снапшотов uuid?: string;
} }
// ----------------------------------------------- Reference Parser --------------------------------------------------------------- // ----------------------------------------------- Reference Parser ---------------------------------------------------------------
@ -33,9 +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 = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g;
let match; let match;
while ((match = regex.exec(text)) !== null) { while ((match = regex.exec(text)) !== null) {
@ -44,13 +43,22 @@ export class ReferenceParser {
const unquotedName = match[3]; const unquotedName = match[3];
const name = quotedName || unquotedName; const name = quotedName || unquotedName;
// Определяем тип по первому символу префикса (после !)
const typeChar = prefix.replace('!', '');
let type: 'file' | 'prompt' | 'external';
if (typeChar === '@') type = 'file';
else if (typeChar === '#') type = 'prompt';
else type = 'external'; // $
matches.push({ matches.push({
fullMatch: match[0], fullMatch: match[0],
prefix: prefix, prefix: prefix,
name: name, name: name,
startIndex: match.index, startIndex: match.index,
endIndex: match.index + match[0].length, endIndex: match.index + match[0].length,
type: prefix.startsWith('@') ? 'file' : 'prompt' type: type,
permanent: prefix.startsWith('!')
}); });
} }
@ -64,11 +72,10 @@ export class ReferenceParser {
* @returns информация о текущей ссылке или null * @returns информация о текущей ссылке или null
*/ */
static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null { static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null {
// Ищем незавершенные ссылки перед курсором
const beforeCursor = text.substring(0, cursorPosition); const beforeCursor = text.substring(0, cursorPosition);
// Регулярное выражение для поиска начала ссылки без завершения // ИЗМЕНЕНО: Поддержка !@, !#, !$
const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/; const incompleteRegex = /(![@#$]|[@#$])([^\s@#$"!]*?)$/;
const match = beforeCursor.match(incompleteRegex); const match = beforeCursor.match(incompleteRegex);
if (match) { if (match) {
@ -76,13 +83,21 @@ export class ReferenceParser {
const partialName = match[2]; const partialName = match[2];
const startIndex = beforeCursor.length - match[0].length; const startIndex = beforeCursor.length - match[0].length;
const typeChar = prefix.replace('!', '');
let type: 'file' | 'prompt' | 'external';
if (typeChar === '@') type = 'file';
else if (typeChar === '#') type = 'prompt';
else type = 'external';
return { return {
fullMatch: match[0], fullMatch: match[0],
prefix: prefix, prefix: prefix,
name: partialName, name: partialName,
startIndex: startIndex, startIndex: startIndex,
endIndex: cursorPosition, endIndex: cursorPosition,
type: prefix.startsWith('@') ? 'file' : 'prompt' type: type,
permanent: prefix.startsWith('!')
}; };
} }

View File

@ -6,6 +6,7 @@
import { ItemView, Notice, WorkspaceLeaf } from 'obsidian'; import { ItemView, Notice, WorkspaceLeaf } from 'obsidian';
import { ChatPanel } from '../components/ChatPanel'; import { ChatPanel } from '../components/ChatPanel';
import LLMAgentPlugin from 'main'; import LLMAgentPlugin from 'main';
import { Attachment } from 'src/components/models/ChatHistoryItem';
export const CHAT_VIEW_TYPE = 'llm-agent-chat-view'; export const CHAT_VIEW_TYPE = 'llm-agent-chat-view';
@ -184,6 +185,8 @@ export class ChatView extends ItemView {
return; return;
} }
const cacheFolder = this.getCacheFolderPath();
// Шаг 1: Создаём узел пользователя с вложениями // Шаг 1: Создаём узел пользователя с вложениями
const sendResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/send`, { const sendResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/send`, {
method: 'POST', method: 'POST',
@ -192,7 +195,8 @@ export class ChatView extends ItemView {
message: message, message: message,
graph_id: this.selectedGraphId, graph_id: this.selectedGraphId,
parent_node_id: this.currentNode, parent_node_id: this.currentNode,
attachments: attachments || [] attachments: attachments || [],
cache_folder: cacheFolder
}) })
}); });
@ -213,7 +217,7 @@ export class ChatView extends ItemView {
const systemPromptToUse = const systemPromptToUse =
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt; this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
await this.streamLLMResponse(this.selectedGraphId, userNodeId, selectedModel, null, systemPromptToUse); await this.streamLLMResponse(this.selectedGraphId, userNodeId, selectedModel, null, systemPromptToUse, cacheFolder);
} else { } else {
throw new Error('Graph ID не определён после отправки сообщения'); throw new Error('Graph ID не определён после отправки сообщения');
} }
@ -280,13 +284,15 @@ export class ChatView extends ItemView {
userNodeId: string, userNodeId: string,
selectedModel?: string, selectedModel?: string,
existingAssistantNodeId: string | null = null, existingAssistantNodeId: string | null = null,
systemPrompt: string | null = null systemPrompt: string | null = null,
cacheFolder?: string
): Promise<void> { ): Promise<void> {
const body = { const body = {
graph_id: graphId, graph_id: graphId,
user_node_id: userNodeId, user_node_id: userNodeId,
system_prompt: systemPrompt, system_prompt: systemPrompt,
model: selectedModel || this.plugin.settings.defaultModel model: selectedModel || this.plugin.settings.defaultModel,
cache_folder: cacheFolder
}; };
if (existingAssistantNodeId) { if (existingAssistantNodeId) {
@ -465,6 +471,8 @@ export class ChatView extends ItemView {
const systemPromptToUse = const systemPromptToUse =
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt; this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
const cacheFolder = this.getCacheFolderPath();
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/regenerate`, { const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/regenerate`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -472,7 +480,8 @@ export class ChatView extends ItemView {
graph_id: data.graphId, graph_id: data.graphId,
node_id: data.nodeId, node_id: data.nodeId,
model: data.model, model: data.model,
system_prompt: systemPromptToUse system_prompt: systemPromptToUse,
cache_folder: cacheFolder
}) })
}); });
@ -566,4 +575,14 @@ export class ChatView extends ItemView {
} }
} }
} }
// ----------------------------------------------- Helpers ---------------------------------------------------------------
/**
* Возвращает абсолютный путь к папке кеша
*/
private getCacheFolderPath(): string {
// TODO: Нужно обработать случай, когда cacheFolder пустой
return this.chatPanel?.cacheManager?.getCacheFolderPath() ?? '';
}
} }

File diff suppressed because one or more lines are too long