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

763
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;
systemPrompt: string;
defaultModel: string;
promptsFolder: string; // Новое поле
enableFileReferences: boolean; // Новое поле
enablePromptReferences: boolean; // Новое поле
excludedFolders: string[]; // Новое поле: Список папок для исключения из автокомплита
promptsFolder: string;
enableFileReferences: boolean;
enablePromptReferences: boolean;
excludedFolders: string[];
refCacheFolder: string;
}
const DEFAULT_SETTINGS: LLMAgentSettings = {
@ -25,7 +26,8 @@ const DEFAULT_SETTINGS: LLMAgentSettings = {
promptsFolder: 'prompts', // Папка с промптами по умолчанию
enableFileReferences: true,
enablePromptReferences: true,
excludedFolders: ['templates'] // Папка "templates" по умолчанию исключена
excludedFolders: ['templates'], // Папка "templates" по умолчанию исключена
refCacheFolder: 'ref-cache' // По умолчанию относительно vault
};
// ----------------------------------------------- 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)
.setName('Включить ссылки на файлы')

View File

@ -14,10 +14,11 @@ import { AutocompleteItem } from '../references/AutocompleteManager';
import LLMAgentPlugin from 'main';
import { MarkdownRenderer, setIcon, Notice, TFile } from 'obsidian';
import { Dialog } from 'src/utils/Dialog';
import { CacheManager } from 'src/references/CacheManager';
interface ChatPanelProps {
chatHistory: ChatHistoryItem[];
onSendMessage: (message: string, selectedModel?: string) => void;
onSendMessage: (message: string, selectedModel?: string, attachments?: Attachment[]) => void;
availableModels?: string[];
selectedModel?: string;
plugin: LLMAgentPlugin;
@ -42,9 +43,11 @@ export class ChatPanel {
attachFileButton: HTMLButtonElement;
fileInput: HTMLInputElement;
attachedFilesContainer: HTMLDivElement;
externalAttachments: Attachment[] = [];
currentAttachments: Attachment[] = [];
// ----------------------------------------------- Reference System Fields ---------------------------------------------------------------
cacheManager: CacheManager;
private autocompleteManager: AutocompleteManager;
private referenceAutocomplete: ReferenceAutocomplete;
private referenceExpander: ReferenceExpander;
@ -254,13 +257,18 @@ export class ChatPanel {
*/
private createAttachmentBox(attachment: Attachment): HTMLElement {
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-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.addEventListener('click', () => {
this.handleAttachmentClick(attachment);
});
@ -268,7 +276,6 @@ export class ChatPanel {
const icon = document.createElement('span');
icon.className = 'reference-icon';
// ИЗМЕНЕНО: Разные иконки для разных типов
if (attachment.type === 'prompt') {
icon.textContent = '⚡';
} else if (attachment.type === 'external') {
@ -290,18 +297,29 @@ export class ChatPanel {
/**
* Обрабатывает клик по боксу вложения
*/
private handleAttachmentClick(attachment: Attachment): void {
if (attachment.type === 'external') {
// Для внешних файлов открываем по абсолютному пути
if (attachment.path) {
this.openExternalFile(attachment.path);
} else {
new Notice(`Не указан путь к файлу: ${attachment.name}`, 3000);
}
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 {
// Для файлов из хранилища Obsidian открываем через API
this.openVaultFile(attachment.path);
// Кеш не найден, открываем исходный файл
if (attachment.type === 'external') {
pathToOpen = attachment.sourcePath; // Уже абсолютный
} else if (attachment.type === 'file') {
pathToOpen = this.props.plugin.app.vault.adapter.getBasePath() + '/' + attachment.sourcePath;
} else {
pathToOpen = this.props.plugin.settings.promptsFolder + '/' + attachment.sourcePath;
}
}
this.openExternalFile(pathToOpen);
}
/**
@ -409,66 +427,55 @@ export class ChatPanel {
this.autocompleteManager = new AutocompleteManager(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);
}
// Обновить метод init():
init(): void {
this.container.innerHTML = `
<div class="llm-agent-chat-panel">
<div class="chat-history" id="chat-history"></div>
<div class="chat-input-container">
<div class="scroll-container">
<div
contenteditable="true"
data-testid="editor-input-main"
class="message-input-editor"
tabindex="0"
data-placeholder="Введите сообщение, @файл, #промпт или команду (/)"
></div>
<!-- НОВОЕ: Контейнер для отображения прикрепленных файлов -->
<div class="attached-files-container" id="attached-files-container" style="display: none;"></div>
</div>
<div class="chat-controls">
<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">
<button class="model-select-button" id="model-select-button">
<span class="model-name" id="selected-model-name">${this.selectedModel}</span>
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"></path>
</svg>
</button>
<div class="model-dropdown" id="model-dropdown" style="display: none;"></div>
<span class="custom-prompt-indicator" id="custom-prompt-indicator" style="display: none;"
data-tooltip-position="top" aria-label="Используется пользовательский системный промпт для этого графа">
</span>
</div>
</div>
<div class="control-buttons-right">
<button class="stop-button" id="stop-button" style="display: none;">
<span class="stop-icon"></span>
</button>
<button class="submit-button" id="send-button">
<span class="submit-text"> Enter</span>
</button>
</div>
</div>
<div class="suggestions" id="suggestions" style="display: none;"></div>
</div>
</div>
`;
<div class="llm-agent-chat-panel">
<div class="chat-history" id="chat-history"></div>
<div class="chat-input-container">
<div class="scroll-container">
<div
contenteditable="true"
data-testid="editor-input-main"
class="message-input-editor"
tabindex="0"
data-placeholder="Введите сообщение, @файл, #промпт, $внешний_файл или команду (/)"
></div>
<div class="attached-files-container" id="attached-files-container" style="display: none;"></div>
</div>
<div class="chat-controls">
<div class="control-buttons-left">
<div class="model-select-container">
<button class="model-select-button" id="model-select-button">
<span class="model-name" id="selected-model-name">${this.selectedModel}</span>
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"></path>
</svg>
</button>
<div class="model-dropdown" id="model-dropdown" style="display: none;"></div>
<span class="custom-prompt-indicator" id="custom-prompt-indicator" style="display: none;"
data-tooltip-position="top" aria-label="Используется пользовательский системный промпт для этого графа">
</span>
</div>
</div>
<div class="control-buttons-right">
<button class="stop-button" id="stop-button" style="display: none;">
<span class="stop-icon"></span>
</button>
<button class="submit-button" id="send-button">
<span class="submit-text"> Enter</span>
</button>
</div>
</div>
<div class="suggestions" id="suggestions" style="display: none;"></div>
</div>
</div>
`;
this.messageInput = this.container.querySelector('.message-input-editor') as HTMLDivElement;
this.sendButton = this.container.querySelector('#send-button') as HTMLButtonElement;
@ -478,9 +485,6 @@ export class ChatPanel {
this.customPromptIndicator = this.container.querySelector('#custom-prompt-indicator') as HTMLSpanElement;
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.setupEventListeners();
@ -552,11 +556,6 @@ export class ChatPanel {
this.stopButton.addEventListener('click', () => this.handleStopGeneration());
// НОВОЕ: Обработчики для файловых вложений
this.attachFileButton.addEventListener('click', () => {
this.openFileDialog();
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
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 message = rawText.trim();
if (!message && this.externalAttachments.length === 0) return; // ИЗМЕНЕНО: разрешаем отправку только с файлами
if (!message && this.currentAttachments.length === 0) return;
if (!this.referenceExpander) {
this.handleSend();
@ -892,11 +891,11 @@ export class ChatPanel {
}
try {
// Извлекаем вложения из ссылок
// ИЗМЕНЕНО: Извлекаем вложения из ссылок И кешируем их
const { originalText, attachments } = await this.referenceExpander.extractAttachments(rawText, this.currentReferences);
// НОВОЕ: Объединяем вложения из ссылок и внешние файлы
const allAttachments = [...attachments, ...this.externalAttachments];
// Объединяем вложения из ссылок и текущие (из attached-files-container)
const allAttachments = [...attachments, ...this.currentAttachments];
// Отправляем оригинальный текст и все вложения
if (this.props.onSendMessage) {
@ -907,7 +906,7 @@ export class ChatPanel {
} catch (error) {
console.error('Ошибка при обработке ссылок:', error);
if (this.props.onSendMessage) {
this.props.onSendMessage(rawText, this.selectedModel, this.externalAttachments);
this.props.onSendMessage(rawText, this.selectedModel, this.currentAttachments);
}
this.clearInput();
}
@ -919,7 +918,7 @@ export class ChatPanel {
private clearInput(): void {
this.messageInput.innerHTML = '';
this.currentReferences = [];
this.externalAttachments = [];
this.currentAttachments = [];
this.renderAttachedFiles();
this.updatePlaceholder();
this.hideSuggestions();
@ -932,7 +931,7 @@ export class ChatPanel {
handleSend(): void {
const message = this.messageInput.textContent?.trim() || '';
if (message && this.props.onSendMessage) {
this.props.onSendMessage(message, this.selectedModel);
this.props.onSendMessage(message, this.selectedModel, this.currentAttachments);
this.clearInput();
}
}
@ -1076,104 +1075,11 @@ export class ChatPanel {
// ----------------------------------------------- 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 {
if (this.externalAttachments.length === 0) {
if (this.currentAttachments.length === 0) {
this.attachedFilesContainer.style.display = 'none';
this.attachedFilesContainer.innerHTML = '';
return;
@ -1182,7 +1088,7 @@ export class ChatPanel {
this.attachedFilesContainer.style.display = 'flex';
this.attachedFilesContainer.innerHTML = '';
for (const attachment of this.externalAttachments) {
for (const attachment of this.currentAttachments) {
const box = this.createRemovableAttachmentBox(attachment);
this.attachedFilesContainer.appendChild(box);
}
@ -1208,7 +1114,7 @@ export class ChatPanel {
removeBtn.innerHTML = '×';
removeBtn.setAttribute('aria-label', 'Удалить вложение');
removeBtn.addEventListener('click', () => {
this.removeExternalAttachment(attachment);
this.removeAttachment(attachment);
});
box.appendChild(icon);
@ -1221,10 +1127,10 @@ export class ChatPanel {
/**
* Удаляет внешнее вложение
*/
private removeExternalAttachment(attachment: Attachment): void {
const index = this.externalAttachments.indexOf(attachment);
private removeAttachment(attachment: Attachment): void {
const index = this.currentAttachments.indexOf(attachment);
if (index > -1) {
this.externalAttachments.splice(index, 1);
this.currentAttachments.splice(index, 1);
this.renderAttachedFiles();
}
}

View File

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

View File

@ -542,7 +542,7 @@
display: inline-flex;
flex-direction: row;
box-shadow: var(--shadow-l);
box-shadow: none; /* var(--shadow-l) */
z-index: auto;
position: static;
@ -572,6 +572,21 @@
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 {
/* 200px для 5 элементов, возможно, нужно заменить на расчёт с помощью padding и размером шрифта */

View File

@ -3,7 +3,7 @@
* Обеспечивает поиск файлов в хранилище и кеширование результатов.
*/
import { TFile } from 'obsidian';
import { Notice, TFile } from 'obsidian';
import LLMAgentPlugin from 'main';
// ----------------------------------------------- Interfaces ---------------------------------------------------------------
@ -237,4 +237,36 @@ export class AutocompleteManager {
}
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> {
this.currentReference = reference;
// Получаем опции в зависимости от типа ссылки
// Для внешних файлов ($, !$) сразу открываем диалог
if (reference.type === 'external') {
const selectedFile = await this.autocompleteManager.handleExternalFileSelection();
if (selectedFile && this.onItemSelectedCallback) {
this.onItemSelectedCallback(selectedFile, reference);
}
return;
}
// Для файлов и промптов работаем как раньше
const options =
reference.type === 'file'
? await this.autocompleteManager.getFileOptions(reference.name)
@ -51,7 +60,6 @@ export class ReferenceAutocomplete {
}
this.currentOptions = options;
// Сбрасываем selection, если новые опции не содержат предыдущий выбранный элемент
if (this.selectedIndex >= this.currentOptions.length) {
this.selectedIndex = 0;
}

View File

@ -9,132 +9,133 @@ import { setIcon } from 'obsidian';
// ----------------------------------------------- Reference Box Component ---------------------------------------------------------------
export class ReferenceBox {
/**
* Создаёт визуальный бокс для отображения ссылки в сообщении
* @param reference - информация о ссылке
* @returns HTML элемент бокса
*/
static createBox(reference: FileReference): HTMLElement {
const box = document.createElement('span');
box.contentEditable = 'false'; // ДОБАВЛЕНО: Делаем бокс нередактируемым
box.className = this.getBoxClasses(reference);
box.setAttribute('data-reference-type', reference.type);
box.setAttribute('data-reference-name', reference.name);
box.setAttribute('data-reference-path', reference.path);
box.setAttribute('data-reference-permanent', reference.permanent.toString());
/**
* Создаёт визуальный бокс для отображения ссылки в сообщении
* @param reference - информация о ссылке
* @returns HTML элемент бокса
*/
static createBox(reference: FileReference): HTMLElement {
const box = document.createElement('span');
box.className = this.getBoxClasses(reference);
box.setAttribute('data-reference-type', reference.type);
box.setAttribute('data-reference-name', reference.name);
box.setAttribute('data-reference-path', reference.path);
box.setAttribute('data-reference-permanent', reference.permanent.toString());
// ДОБАВЛЕНО: Сохраняем UUID, если есть
if (reference.uuid) {
box.setAttribute('data-reference-uuid', reference.uuid);
}
// Создаем иконку
const icon = document.createElement('span');
icon.className = 'reference-box-icon';
setIcon(icon, this.getIconName(reference));
// Создаем иконку
const icon = document.createElement('span');
icon.className = 'reference-box-icon';
// Создаем текст с именем
const nameSpan = document.createElement('span');
nameSpan.className = 'reference-box-name';
nameSpan.textContent = reference.name;
// ИЗМЕНЕНО: Используем символы вместо setIcon для более предсказуемого отображения
icon.textContent = this.getIconSymbol(reference);
box.appendChild(icon);
box.appendChild(nameSpan);
// Создаем текст с именем
const nameSpan = document.createElement('span');
nameSpan.className = 'reference-box-name';
nameSpan.textContent = reference.name;
// Добавляем обработчик клика для предпросмотра (TODO: будущая функциональность)
box.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// TODO: показать предпросмотр содержимого файла
console.log('Clicked reference:', reference);
});
box.appendChild(icon);
box.appendChild(nameSpan);
return box;
}
// Добавляем обработчик клика для предпросмотра (TODO: будущая функциональность)
box.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
// TODO: показать предпросмотр содержимого файла
console.log('Clicked reference:', reference);
});
/**
* Обновляет существующий бокс
* @param boxElement - элемент бокса
* @param reference - новая информация о ссылке
*/
static updateBox(boxElement: HTMLElement, reference: FileReference): void {
boxElement.className = this.getBoxClasses(reference);
boxElement.setAttribute('data-reference-type', reference.type);
boxElement.setAttribute('data-reference-name', reference.name);
boxElement.setAttribute('data-reference-path', reference.path);
boxElement.setAttribute('data-reference-permanent', reference.permanent.toString());
return box;
}
const iconElement = boxElement.querySelector('.reference-box-icon');
const nameElement = boxElement.querySelector('.reference-box-name');
/**
* Обновляет существующий бокс
* @param boxElement - элемент бокса
* @param reference - новая информация о ссылке
*/
static updateBox(boxElement: HTMLElement, reference: FileReference): void {
boxElement.className = this.getBoxClasses(reference);
boxElement.setAttribute('data-reference-type', reference.type);
boxElement.setAttribute('data-reference-name', reference.name);
boxElement.setAttribute('data-reference-path', reference.path);
boxElement.setAttribute('data-reference-permanent', reference.permanent.toString());
if (iconElement) {
iconElement.innerHTML = '';
setIcon(iconElement as HTMLElement, this.getIconName(reference));
}
// ДОБАВЛЕНО: Обновляем UUID, если есть
if (reference.uuid) {
boxElement.setAttribute('data-reference-uuid', reference.uuid);
}
if (nameElement) {
nameElement.textContent = reference.name;
}
}
const iconElement = boxElement.querySelector('.reference-box-icon');
const nameElement = boxElement.querySelector('.reference-box-name');
/**
* Извлекает информацию о ссылке из элемента бокса
* @param boxElement - элемент бокса
* @returns информация о ссылке
*/
static extractReference(boxElement: HTMLElement): FileReference | null {
const type = boxElement.getAttribute('data-reference-type') as 'file' | 'prompt';
const name = boxElement.getAttribute('data-reference-name');
const path = boxElement.getAttribute('data-reference-path');
const permanent = boxElement.getAttribute('data-reference-permanent') === 'true';
if (iconElement) {
// ИЗМЕНЕНО: Используем символы вместо setIcon
iconElement.textContent = this.getIconSymbol(reference);
}
if (!type || !name || !path) {
return null;
}
if (nameElement) {
nameElement.textContent = reference.name;
}
}
return {
type,
name,
path,
permanent
};
}
/**
* Извлекает информацию о ссылке из элемента бокса
* @param boxElement - элемент бокса
* @returns информация о ссылке
*/
static extractReference(boxElement: HTMLElement): FileReference | null {
const type = boxElement.getAttribute('data-reference-type') as 'file' | 'prompt' | 'external';
const name = boxElement.getAttribute('data-reference-name');
const path = boxElement.getAttribute('data-reference-path');
const permanent = boxElement.getAttribute('data-reference-permanent') === 'true';
const uuid = boxElement.getAttribute('data-reference-uuid');
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
if (!type || !name || !path) {
return null;
}
/**
* Возвращает CSS классы для бокса
*/
private static getBoxClasses(reference: FileReference): string {
const baseClass = 'reference-box';
const typeClass = reference.type;
const permanentClass = reference.permanent ? 'permanent' : 'single';
return {
type,
name,
path,
permanent,
uuid: uuid || undefined
};
}
return `${baseClass} ${typeClass} ${permanentClass}`;
}
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
/**
* Возвращает имя иконки для ссылки
*/
private static getIconName(reference: FileReference): string {
if (reference.type === 'prompt') {
return 'zap';
}
/**
* Возвращает CSS классы для бокса
*/
private static getBoxClasses(reference: FileReference): string {
const baseClass = 'reference-box';
const typeClass = reference.type;
const permanentClass = reference.permanent ? 'permanent' : 'single';
// Определяем иконку по расширению файла
const extension = reference.path.split('.').pop()?.toLowerCase();
switch (extension) {
case 'md':
return 'file-text';
case 'js':
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';
}
}
return `${baseClass} ${typeClass} ${permanentClass}`;
}
/**
* Возвращает символ иконки для ссылки
* ИЗМЕНЕНО: Теперь возвращает символы вместо имен иконок Obsidian
*/
private static getIconSymbol(reference: FileReference): string {
if (reference.type === 'prompt') {
return '⚡'; // Молния для промптов
}
if (reference.type === 'external') {
return '📎'; // Знак доллара для внешних файлов
}
// Для файлов из vault используем @
return '📄';
}
}

View File

@ -8,16 +8,19 @@ import LLMAgentPlugin from 'main';
import { FileReference, ReferenceParser } from './ReferenceParser';
import { TemplateEngine } from './TemplateEngine';
import { Attachment, ChatHistoryItem } from 'src/components/models/ChatHistoryItem';
import { CacheManager } from './CacheManager';
// ----------------------------------------------- Reference Expander ---------------------------------------------------------------
export class ReferenceExpander {
private plugin: LLMAgentPlugin;
private templateEngine: TemplateEngine;
private cacheManager: CacheManager;
constructor(plugin: LLMAgentPlugin, templateEngine: TemplateEngine) {
this.plugin = plugin;
this.templateEngine = templateEngine;
this.cacheManager = new CacheManager(plugin);
}
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
@ -75,8 +78,7 @@ export class ReferenceExpander {
}
/**
* Извлекает вложения из текста, НЕ раскрывая их в тексте.
* Возвращает оригинальный текст и массив вложений.
* Извлекает вложения из текста и кеширует файлы
*/
async extractAttachments(
text: string,
@ -88,15 +90,60 @@ export class ReferenceExpander {
const attachments: Attachment[] = [];
for (const reference of references) {
const content = await this.loadFileContent(reference);
if (content !== null) {
try {
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({
type: reference.type,
name: reference.name,
path: reference.path,
sourcePath: sourcePath,
cachedPath: cachedPath,
uuid: uuid,
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 с учетом вложений.
* Раскрывает только постоянные (@@/##) ссылки и ссылки в последнем сообщении.

View File

@ -6,20 +6,21 @@
// ----------------------------------------------- Interfaces ---------------------------------------------------------------
export interface ReferenceMatch {
fullMatch: string; // "@filename" или "@@filename"
prefix: string; // "@" или "@@"
name: string; // "filename"
startIndex: number;
endIndex: number;
type: 'file' | 'prompt';
fullMatch: string;
prefix: string; // Теперь может быть @, !@, #, !#, $, !$
name: string;
startIndex: number;
endIndex: number;
type: 'file' | 'prompt' | 'external';
permanent: boolean;
}
export interface FileReference {
type: 'file' | 'prompt';
name: string;
path: string;
permanent: boolean; // true для @@ и ##
// TODO: добавить snapshotId при реализации снапшотов
type: 'file' | 'prompt' | 'external';
name: string;
path: string;
permanent: boolean;
uuid?: string;
}
// ----------------------------------------------- Reference Parser ---------------------------------------------------------------
@ -33,9 +34,7 @@ export class ReferenceParser {
static parseReferences(text: string): ReferenceMatch[] {
const matches: ReferenceMatch[] = [];
// Регулярное выражение для поиска @/@@ и #/## ссылок
// Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов
const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g;
const regex = /(![@#$]|[@#$])(?:"([^"]+)"|([^\s@#$"!]+))/g;
let match;
while ((match = regex.exec(text)) !== null) {
@ -44,13 +43,22 @@ export class ReferenceParser {
const unquotedName = match[3];
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({
fullMatch: match[0],
prefix: prefix,
name: name,
startIndex: match.index,
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
*/
static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null {
// Ищем незавершенные ссылки перед курсором
const beforeCursor = text.substring(0, cursorPosition);
// Регулярное выражение для поиска начала ссылки без завершения
const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/;
// ИЗМЕНЕНО: Поддержка !@, !#, !$
const incompleteRegex = /(![@#$]|[@#$])([^\s@#$"!]*?)$/;
const match = beforeCursor.match(incompleteRegex);
if (match) {
@ -76,13 +83,21 @@ export class ReferenceParser {
const partialName = match[2];
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 {
fullMatch: match[0],
prefix: prefix,
name: partialName,
startIndex: startIndex,
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 { ChatPanel } from '../components/ChatPanel';
import LLMAgentPlugin from 'main';
import { Attachment } from 'src/components/models/ChatHistoryItem';
export const CHAT_VIEW_TYPE = 'llm-agent-chat-view';
@ -184,6 +185,8 @@ export class ChatView extends ItemView {
return;
}
const cacheFolder = this.getCacheFolderPath();
// Шаг 1: Создаём узел пользователя с вложениями
const sendResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/send`, {
method: 'POST',
@ -192,7 +195,8 @@ export class ChatView extends ItemView {
message: message,
graph_id: this.selectedGraphId,
parent_node_id: this.currentNode,
attachments: attachments || []
attachments: attachments || [],
cache_folder: cacheFolder
})
});
@ -213,7 +217,7 @@ export class ChatView extends ItemView {
const systemPromptToUse =
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 {
throw new Error('Graph ID не определён после отправки сообщения');
}
@ -280,13 +284,15 @@ export class ChatView extends ItemView {
userNodeId: string,
selectedModel?: string,
existingAssistantNodeId: string | null = null,
systemPrompt: string | null = null
systemPrompt: string | null = null,
cacheFolder?: string
): Promise<void> {
const body = {
graph_id: graphId,
user_node_id: userNodeId,
system_prompt: systemPrompt,
model: selectedModel || this.plugin.settings.defaultModel
model: selectedModel || this.plugin.settings.defaultModel,
cache_folder: cacheFolder
};
if (existingAssistantNodeId) {
@ -465,6 +471,8 @@ export class ChatView extends ItemView {
const systemPromptToUse =
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
const cacheFolder = this.getCacheFolderPath();
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/regenerate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -472,7 +480,8 @@ export class ChatView extends ItemView {
graph_id: data.graphId,
node_id: data.nodeId,
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