Add appending of external files, fiz appending of internal files - now via metadata (attachments)
This commit is contained in:
parent
aa805a5505
commit
145417cfea
|
|
@ -3,7 +3,7 @@
|
|||
* отображение истории и подсказки команд для взаимодействия с пользователем.
|
||||
*/
|
||||
|
||||
import { ChatHistoryItem } from './models/ChatHistoryItem';
|
||||
import { ChatHistoryItem, Attachment } from './models/ChatHistoryItem';
|
||||
import { AutocompleteManager } from '../references/AutocompleteManager';
|
||||
import { ReferenceAutocomplete } from '../references/ReferenceAutocomplete';
|
||||
import { ReferenceExpander } from '../references/ReferenceExpander';
|
||||
|
|
@ -12,7 +12,7 @@ import { ReferenceParser, FileReference, ReferenceMatch } from '../references/Re
|
|||
import { ReferenceBox } from '../references/ReferenceBox';
|
||||
import { AutocompleteItem } from '../references/AutocompleteManager';
|
||||
import LLMAgentPlugin from 'main';
|
||||
import { MarkdownRenderer, setIcon, Notice } from 'obsidian'; // Добавляем импорт MarkdownRenderer
|
||||
import { MarkdownRenderer, setIcon, Notice, TFile } from 'obsidian';
|
||||
import { Dialog } from 'src/utils/Dialog';
|
||||
|
||||
interface ChatPanelProps {
|
||||
|
|
@ -38,6 +38,12 @@ export class ChatPanel {
|
|||
currentStreamingNodeId: string | null;
|
||||
stopButton: HTMLButtonElement;
|
||||
|
||||
// Элементы для файловых вложений
|
||||
attachFileButton: HTMLButtonElement;
|
||||
fileInput: HTMLInputElement;
|
||||
attachedFilesContainer: HTMLDivElement;
|
||||
externalAttachments: Attachment[] = [];
|
||||
|
||||
// ----------------------------------------------- Reference System Fields ---------------------------------------------------------------
|
||||
private autocompleteManager: AutocompleteManager;
|
||||
private referenceAutocomplete: ReferenceAutocomplete;
|
||||
|
|
@ -198,14 +204,14 @@ export class ChatPanel {
|
|||
*/
|
||||
async renderHistory(): Promise<void> {
|
||||
const historyContainer = this.container.querySelector('#chat-history') as HTMLElement;
|
||||
historyContainer.empty(); // Очищаем контейнер перед добавлением новых сообщений
|
||||
historyContainer.empty();
|
||||
|
||||
for (const msg of this.chatHistory) {
|
||||
const messageEl = historyContainer.createDiv({
|
||||
cls: `message ${msg.role} message-type-${msg.type || 'default'} group/turn-messages`
|
||||
});
|
||||
|
||||
// Информация об отправителе
|
||||
// Метаинформация
|
||||
const metaDiv = messageEl.createDiv({ cls: 'chat-meta' });
|
||||
const senderInfoDiv = metaDiv.createDiv({ cls: 'chat-sender-info' });
|
||||
const iconDiv = senderInfoDiv.createDiv({ cls: 'chat-message-icon' });
|
||||
|
|
@ -215,20 +221,24 @@ export class ChatPanel {
|
|||
: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot"><path d="M12 8V4H8"></path><rect width="16" height="12" x="4" y="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path></svg>`;
|
||||
senderInfoDiv.createSpan({ cls: 'chat-role-label', text: this.getRoleLabel(msg.role) });
|
||||
|
||||
// Контент сообщения, рендерится Markdown
|
||||
// Отображаем вложения как боксы
|
||||
if (msg.attachments && msg.attachments.length > 0) {
|
||||
const attachmentsContainer = messageEl.createDiv({ cls: 'message-attachments' });
|
||||
|
||||
for (const attachment of msg.attachments) {
|
||||
const box = this.createAttachmentBox(attachment);
|
||||
attachmentsContainer.appendChild(box);
|
||||
}
|
||||
}
|
||||
|
||||
// Контент сообщения
|
||||
const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' });
|
||||
await MarkdownRenderer.renderMarkdown(
|
||||
msg.content,
|
||||
contentDiv,
|
||||
'', // Путь к источнику, может быть пустым для сообщений чата
|
||||
this.props.plugin // Компонент, необходимый для управления жизненным циклом рендера
|
||||
);
|
||||
// Передаем graphId и nodeId
|
||||
// msg.node_id и this.props.plugin.activeGraphId используются для кнопок удаления
|
||||
await MarkdownRenderer.renderMarkdown(msg.content, contentDiv, '', this.props.plugin);
|
||||
|
||||
this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || '');
|
||||
}
|
||||
|
||||
// Прокручиваем к самому низу родительского контейнера (view-content)
|
||||
// Прокрутка
|
||||
if (this.chatHistory.length > 0) {
|
||||
setTimeout(() => {
|
||||
const scrollContainer = this.container.closest('.view-content') as HTMLElement;
|
||||
|
|
@ -239,6 +249,115 @@ export class ChatPanel {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает визуальный бокс для отображения вложения в истории.
|
||||
*/
|
||||
private createAttachmentBox(attachment: Attachment): HTMLElement {
|
||||
const box = document.createElement('span');
|
||||
box.className = 'reference-box attachment-box clickable';
|
||||
box.setAttribute('data-type', attachment.type);
|
||||
box.setAttribute('data-name', attachment.name);
|
||||
box.setAttribute('data-path', attachment.path);
|
||||
box.setAttribute('data-permanent', String(attachment.permanent));
|
||||
|
||||
// НОВОЕ: Добавляем обработчик клика
|
||||
box.addEventListener('click', () => {
|
||||
this.handleAttachmentClick(attachment);
|
||||
});
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'reference-icon';
|
||||
|
||||
// ИЗМЕНЕНО: Разные иконки для разных типов
|
||||
if (attachment.type === 'prompt') {
|
||||
icon.textContent = '⚡';
|
||||
} else if (attachment.type === 'external') {
|
||||
icon.textContent = '📎';
|
||||
} else {
|
||||
icon.textContent = '📄';
|
||||
}
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'reference-name';
|
||||
name.textContent = attachment.name;
|
||||
|
||||
box.appendChild(icon);
|
||||
box.appendChild(name);
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает клик по боксу вложения
|
||||
*/
|
||||
private handleAttachmentClick(attachment: Attachment): void {
|
||||
if (attachment.type === 'external') {
|
||||
// Для внешних файлов открываем по абсолютному пути
|
||||
if (attachment.path) {
|
||||
this.openExternalFile(attachment.path);
|
||||
} else {
|
||||
new Notice(`Не указан путь к файлу: ${attachment.name}`, 3000);
|
||||
}
|
||||
} else {
|
||||
// Для файлов из хранилища Obsidian открываем через API
|
||||
this.openVaultFile(attachment.path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает файл из хранилища Obsidian
|
||||
*/
|
||||
private openVaultFile(path: string): void {
|
||||
const file = this.props.plugin.app.vault.getAbstractFileByPath(path);
|
||||
if (file instanceof TFile) {
|
||||
this.props.plugin.app.workspace.getLeaf(false).openFile(file);
|
||||
} else {
|
||||
new Notice(`Файл не найден: ${path}`, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Простой window.electron.shell.openPath(absolutePath) не работает
|
||||
/**
|
||||
* Открывает внешний файл по абсолютному пути
|
||||
*/
|
||||
private openExternalFile(absolutePath: string): void {
|
||||
if (!window.require) {
|
||||
console.warn('Невозможно открыть внешний файл в веб-версии:', absolutePath);
|
||||
new Notice('Открытие внешних файлов доступно только в desktop-версии', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { exec } = window.require('child_process');
|
||||
const path = window.require('path');
|
||||
|
||||
// Определяем команду в зависимости от платформы
|
||||
let command: string;
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Windows: используем start
|
||||
command = `start "" "${absolutePath}"`;
|
||||
} else if (platform === 'darwin') {
|
||||
// macOS: используем open
|
||||
command = `open "${absolutePath}"`;
|
||||
} else {
|
||||
// Linux: используем xdg-open
|
||||
command = `xdg-open "${absolutePath}"`;
|
||||
}
|
||||
|
||||
exec(command, (error: Error | null) => {
|
||||
if (error) {
|
||||
console.error('Ошибка при открытии файла:', error);
|
||||
new Notice(`Не удалось открыть файл: ${error.message}`, 3000);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка при попытке открыть файл:', error);
|
||||
new Notice(`Не удалось открыть файл: ${error.message}`, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает удобочитаемую метку для роли сообщения.
|
||||
* @param {string} role - Роль отправителя сообщения (например, 'user', 'assistant').
|
||||
|
|
@ -296,47 +415,60 @@ export class ChatPanel {
|
|||
// Обновить метод 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>
|
||||
<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 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>
|
||||
`;
|
||||
|
||||
this.messageInput = this.container.querySelector('.message-input-editor') as HTMLDivElement;
|
||||
this.sendButton = this.container.querySelector('#send-button') as HTMLButtonElement;
|
||||
|
|
@ -346,12 +478,17 @@ 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();
|
||||
this.setupReferenceSystem();
|
||||
this.renderHistory();
|
||||
this.updateModelDropdown();
|
||||
this.updatePlaceholder();
|
||||
this.updateCustomSystemPromptStatus(this.props.currentGraphCustomSystemPrompt); // Инициализируем статус
|
||||
this.updateCustomSystemPromptStatus(this.props.currentGraphCustomSystemPrompt);
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Reference System Setup ---------------------------------------------------------------
|
||||
|
|
@ -376,11 +513,9 @@ export class ChatPanel {
|
|||
this.sendButton.addEventListener('click', () => this.handleSendWithReferences());
|
||||
|
||||
this.messageInput.addEventListener('keydown', (e) => {
|
||||
// Сначала проверяем автокомплит ссылок
|
||||
if (this.referenceAutocomplete && this.referenceAutocomplete.handleKeydown(e)) {
|
||||
return; // Событие обработано автокомплитом
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (this.suggestionsContainer.style.display === 'block') {
|
||||
const firstSuggestion = this.suggestionsContainer.querySelector('.suggestion-item');
|
||||
|
|
@ -410,7 +545,6 @@ export class ChatPanel {
|
|||
this.updatePlaceholder();
|
||||
});
|
||||
|
||||
// Model selection
|
||||
this.modelSelectButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleModelDropdown();
|
||||
|
|
@ -418,6 +552,11 @@ 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)) {
|
||||
|
|
@ -745,30 +884,30 @@ export class ChatPanel {
|
|||
const rawText = this.extractTextWithReferences();
|
||||
const message = rawText.trim();
|
||||
|
||||
if (!message) return;
|
||||
if (!message && this.externalAttachments.length === 0) return; // ИЗМЕНЕНО: разрешаем отправку только с файлами
|
||||
|
||||
if (!this.referenceExpander) {
|
||||
// Если система ссылок не инициализирована, отправляем как обычно
|
||||
this.handleSend();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Раскрываем ссылки перед отправкой
|
||||
const expandedText = await this.referenceExpander.expandReferences(rawText, this.currentReferences);
|
||||
// Извлекаем вложения из ссылок
|
||||
const { originalText, attachments } = await this.referenceExpander.extractAttachments(rawText, this.currentReferences);
|
||||
|
||||
// Отправляем expandedText вместо rawText
|
||||
// НОВОЕ: Объединяем вложения из ссылок и внешние файлы
|
||||
const allAttachments = [...attachments, ...this.externalAttachments];
|
||||
|
||||
// Отправляем оригинальный текст и все вложения
|
||||
if (this.props.onSendMessage) {
|
||||
this.props.onSendMessage(expandedText, this.selectedModel);
|
||||
this.props.onSendMessage(originalText || '', this.selectedModel, allAttachments);
|
||||
}
|
||||
|
||||
// Очищаем состояние
|
||||
this.clearInput();
|
||||
} catch (error) {
|
||||
console.error('Ошибка при раскрытии ссылок:', error);
|
||||
// В случае ошибки отправляем исходный текст
|
||||
console.error('Ошибка при обработке ссылок:', error);
|
||||
if (this.props.onSendMessage) {
|
||||
this.props.onSendMessage(rawText, this.selectedModel);
|
||||
this.props.onSendMessage(rawText, this.selectedModel, this.externalAttachments);
|
||||
}
|
||||
this.clearInput();
|
||||
}
|
||||
|
|
@ -780,6 +919,8 @@ export class ChatPanel {
|
|||
private clearInput(): void {
|
||||
this.messageInput.innerHTML = '';
|
||||
this.currentReferences = [];
|
||||
this.externalAttachments = [];
|
||||
this.renderAttachedFiles();
|
||||
this.updatePlaceholder();
|
||||
this.hideSuggestions();
|
||||
if (this.referenceAutocomplete) {
|
||||
|
|
@ -932,4 +1073,159 @@ 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) {
|
||||
this.attachedFilesContainer.style.display = 'none';
|
||||
this.attachedFilesContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachedFilesContainer.style.display = 'flex';
|
||||
this.attachedFilesContainer.innerHTML = '';
|
||||
|
||||
for (const attachment of this.externalAttachments) {
|
||||
const box = this.createRemovableAttachmentBox(attachment);
|
||||
this.attachedFilesContainer.appendChild(box);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает бокс вложения с кнопкой удаления
|
||||
*/
|
||||
private createRemovableAttachmentBox(attachment: Attachment): HTMLElement {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'attachment-box removable';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'attachment-icon';
|
||||
icon.textContent = '📎';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'attachment-name';
|
||||
name.textContent = attachment.name;
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'attachment-remove';
|
||||
removeBtn.innerHTML = '×';
|
||||
removeBtn.setAttribute('aria-label', 'Удалить вложение');
|
||||
removeBtn.addEventListener('click', () => {
|
||||
this.removeExternalAttachment(attachment);
|
||||
});
|
||||
|
||||
box.appendChild(icon);
|
||||
box.appendChild(name);
|
||||
box.appendChild(removeBtn);
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет внешнее вложение
|
||||
*/
|
||||
private removeExternalAttachment(attachment: Attachment): void {
|
||||
const index = this.externalAttachments.indexOf(attachment);
|
||||
if (index > -1) {
|
||||
this.externalAttachments.splice(index, 1);
|
||||
this.renderAttachedFiles();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,19 @@
|
|||
*/
|
||||
|
||||
export interface ChatHistoryItem {
|
||||
role: string;
|
||||
content: string;
|
||||
type?: 'user' | 'llm' | 'tool' | 'error' | 'default';
|
||||
role: string;
|
||||
content: string;
|
||||
type?: 'user' | 'llm' | 'tool' | 'error' | 'default';
|
||||
node_id: string;
|
||||
graph_id?: string;
|
||||
// TODO: добавить поля для снапшотов файлов при реализации
|
||||
// fileSnapshots?: Record<string, string>;
|
||||
// expandedContent?: string; // содержимое после раскрытия ссылок
|
||||
graph_id?: string;
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
type: 'file' | 'prompt' | 'external';
|
||||
name: string;
|
||||
path: string; // Относительный путь для 'file'/'prompt', абсолютный для 'external'
|
||||
permanent: boolean;
|
||||
content?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
|
@ -1166,4 +1166,101 @@
|
|||
.dialog-buttons button:not(.mod-cta):hover {
|
||||
background-color: var(--button-secondary-hover-background);
|
||||
border-color: var(--border-color-light); /* При наведении рамка может стать светлее */
|
||||
}
|
||||
|
||||
/* ----------------------------------------------- Attachment Boxes --------------------------------------------------------------- */
|
||||
|
||||
.message-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.attachment-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--background-modifier-border);
|
||||
border: 1px solid var(--background-modifier-border-hover);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.attachment-box.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-box.clickable:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--background-modifier-border-hover);
|
||||
}
|
||||
|
||||
.attachment-box .reference-icon {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.attachment-box .reference-name {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Прикрепленные файлы в поле ввода */
|
||||
.attached-files-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
|
||||
.attachment-box.removable {
|
||||
background-color: var(--interactive-normal);
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.attachment-box.removable:hover {
|
||||
background-color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
margin-left: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.attachment-remove:hover {
|
||||
color: var(--text-error);
|
||||
}
|
||||
|
||||
/* Кнопка прикрепления файла */
|
||||
.attach-file-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.attach-file-button:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.attach-file-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
|
@ -7,243 +7,319 @@ import { TFile } from 'obsidian';
|
|||
import LLMAgentPlugin from 'main';
|
||||
import { FileReference, ReferenceParser } from './ReferenceParser';
|
||||
import { TemplateEngine } from './TemplateEngine';
|
||||
import { Attachment, ChatHistoryItem } from 'src/components/models/ChatHistoryItem';
|
||||
|
||||
// ----------------------------------------------- Reference Expander ---------------------------------------------------------------
|
||||
|
||||
export class ReferenceExpander {
|
||||
private plugin: LLMAgentPlugin;
|
||||
private templateEngine: TemplateEngine;
|
||||
private plugin: LLMAgentPlugin;
|
||||
private templateEngine: TemplateEngine;
|
||||
|
||||
constructor(plugin: LLMAgentPlugin, templateEngine: TemplateEngine) {
|
||||
this.plugin = plugin;
|
||||
this.templateEngine = templateEngine;
|
||||
}
|
||||
constructor(plugin: LLMAgentPlugin, templateEngine: TemplateEngine) {
|
||||
this.plugin = plugin;
|
||||
this.templateEngine = templateEngine;
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Заменяет все ссылки на содержимое файлов (перед отправкой на сервер)
|
||||
* @param text - текст с ссылками
|
||||
* @param references - массив ссылок для обработки
|
||||
* @returns текст с развернутыми ссылками
|
||||
*/
|
||||
async expandReferences(text: string, references: FileReference[]): Promise<string> {
|
||||
if (references.length === 0) {
|
||||
return text;
|
||||
}
|
||||
/**
|
||||
* Заменяет все ссылки на содержимое файлов (перед отправкой на сервер)
|
||||
* @param text - текст с ссылками
|
||||
* @param references - массив ссылок для обработки
|
||||
* @returns текст с развернутыми ссылками
|
||||
*/
|
||||
async expandReferences(text: string, references: FileReference[]): Promise<string> {
|
||||
if (references.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let expandedText = text;
|
||||
|
||||
// Обрабатываем ссылки от конца к началу, чтобы не сбивать индексы
|
||||
const sortedReferences = [...references].sort((a, b) => {
|
||||
const aMatches = ReferenceParser.parseReferences(text).filter(m => m.name === a.name);
|
||||
const bMatches = ReferenceParser.parseReferences(text).filter(m => m.name === b.name);
|
||||
const aIndex = aMatches.length > 0 ? aMatches[aMatches.length - 1].startIndex : 0;
|
||||
const bIndex = bMatches.length > 0 ? bMatches[bMatches.length - 1].startIndex : 0;
|
||||
return bIndex - aIndex;
|
||||
});
|
||||
let expandedText = text;
|
||||
|
||||
for (const reference of sortedReferences) {
|
||||
expandedText = await this.expandSingleReference(expandedText, reference);
|
||||
}
|
||||
// Обрабатываем ссылки от конца к началу, чтобы не сбивать индексы
|
||||
const sortedReferences = [...references].sort((a, b) => {
|
||||
const aMatches = ReferenceParser.parseReferences(text).filter((m) => m.name === a.name);
|
||||
const bMatches = ReferenceParser.parseReferences(text).filter((m) => m.name === b.name);
|
||||
const aIndex = aMatches.length > 0 ? aMatches[aMatches.length - 1].startIndex : 0;
|
||||
const bIndex = bMatches.length > 0 ? bMatches[bMatches.length - 1].startIndex : 0;
|
||||
return bIndex - aIndex;
|
||||
});
|
||||
|
||||
return expandedText;
|
||||
}
|
||||
for (const reference of sortedReferences) {
|
||||
expandedText = await this.expandSingleReference(expandedText, reference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает историю сообщений, раскрывая постоянные ссылки (@@/##)
|
||||
* @param messages - массив сообщений
|
||||
* @returns обработанные сообщения
|
||||
*/
|
||||
async expandMessageHistory(messages: any[]): Promise<any[]> {
|
||||
const expandedMessages = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user') {
|
||||
const expandedContent = await this.expandPermanentReferences(message.content);
|
||||
expandedMessages.push({
|
||||
...message,
|
||||
content: expandedContent
|
||||
});
|
||||
} else {
|
||||
expandedMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
return expandedMessages;
|
||||
}
|
||||
return expandedText;
|
||||
}
|
||||
|
||||
// TODO: При реализации снапшотов - сохранять содержимое файлов
|
||||
// async expandReferencesWithSnapshots(text: string, references: FileReference[], messageId: string): Promise<{expandedText: string, snapshots: Record<string, string>}> {
|
||||
// const snapshots: Record<string, string> = {};
|
||||
// let expandedText = text;
|
||||
//
|
||||
// for (const reference of references) {
|
||||
// const content = await this.loadFileContent(reference);
|
||||
// if (content !== null) {
|
||||
// snapshots[reference.path] = content;
|
||||
// expandedText = await this.expandSingleReference(expandedText, reference, content);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return { expandedText, snapshots };
|
||||
// }
|
||||
/**
|
||||
* Обрабатывает историю сообщений, раскрывая постоянные ссылки (@@/##)
|
||||
* @param messages - массив сообщений
|
||||
* @returns обработанные сообщения
|
||||
*/
|
||||
async expandMessageHistory(messages: any[]): Promise<any[]> {
|
||||
const expandedMessages = [];
|
||||
|
||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user') {
|
||||
const expandedContent = await this.expandPermanentReferences(message.content);
|
||||
expandedMessages.push({
|
||||
...message,
|
||||
content: expandedContent
|
||||
});
|
||||
} else {
|
||||
expandedMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрывает одну ссылку в тексте
|
||||
* @param text - текст для обработки
|
||||
* @param reference - ссылка для раскрытия
|
||||
* @param preloadedContent - предзагруженное содержимое (для снапшотов)
|
||||
* @returns текст с раскрытой ссылкой
|
||||
*/
|
||||
private async expandSingleReference(text: string, reference: FileReference, preloadedContent?: string): Promise<string> {
|
||||
const content = preloadedContent || await this.loadFileContent(reference);
|
||||
|
||||
if (content === null) {
|
||||
console.warn(`Не удалось загрузить содержимое для ссылки: ${reference.name}`);
|
||||
return text;
|
||||
}
|
||||
return expandedMessages;
|
||||
}
|
||||
|
||||
// Определяем паттерн для замены
|
||||
const prefix = reference.permanent
|
||||
? (reference.type === 'file' ? '@@' : '##')
|
||||
: (reference.type === 'file' ? '@' : '#');
|
||||
|
||||
// Создаем регулярное выражение для поиска ссылки
|
||||
const escapedName = this.escapeRegExp(reference.name);
|
||||
const pattern = new RegExp(`${this.escapeRegExp(prefix)}(?:"${escapedName}"|${escapedName})(?=\\s|$|[^\\w-])`, 'g');
|
||||
|
||||
// Форматируем содержимое для вставки
|
||||
const formattedContent = this.formatContentForInsertion(content, reference);
|
||||
|
||||
return text.replace(pattern, formattedContent);
|
||||
}
|
||||
/**
|
||||
* Извлекает вложения из текста, НЕ раскрывая их в тексте.
|
||||
* Возвращает оригинальный текст и массив вложений.
|
||||
*/
|
||||
async extractAttachments(
|
||||
text: string,
|
||||
references: FileReference[]
|
||||
): Promise<{
|
||||
originalText: string;
|
||||
attachments: Attachment[];
|
||||
}> {
|
||||
const attachments: Attachment[] = [];
|
||||
|
||||
/**
|
||||
* Раскрывает только постоянные ссылки (@@/##) в тексте
|
||||
* @param text - текст для обработки
|
||||
* @returns текст с раскрытыми постоянными ссылками
|
||||
*/
|
||||
private async expandPermanentReferences(text: string): Promise<string> {
|
||||
const matches = ReferenceParser.parseReferences(text);
|
||||
const permanentMatches = matches.filter(match => match.prefix.length === 2); // @@ или ##
|
||||
|
||||
if (permanentMatches.length === 0) {
|
||||
return text;
|
||||
}
|
||||
for (const reference of references) {
|
||||
const content = await this.loadFileContent(reference);
|
||||
if (content !== null) {
|
||||
attachments.push({
|
||||
type: reference.type,
|
||||
name: reference.name,
|
||||
path: reference.path,
|
||||
permanent: reference.permanent,
|
||||
content: content
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let expandedText = text;
|
||||
|
||||
// Обрабатываем ссылки от конца к началу
|
||||
const sortedMatches = [...permanentMatches].sort((a, b) => b.startIndex - a.startIndex);
|
||||
|
||||
for (const match of sortedMatches) {
|
||||
const reference: FileReference = {
|
||||
type: match.type,
|
||||
name: match.name,
|
||||
path: await this.resolveFilePath(match.name, match.type),
|
||||
permanent: true
|
||||
};
|
||||
|
||||
const content = await this.loadFileContent(reference);
|
||||
if (content !== null) {
|
||||
const formattedContent = this.formatContentForInsertion(content, reference);
|
||||
expandedText = expandedText.substring(0, match.startIndex) +
|
||||
formattedContent +
|
||||
expandedText.substring(match.endIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return expandedText;
|
||||
}
|
||||
return {
|
||||
originalText: text,
|
||||
attachments: attachments
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает содержимое файла
|
||||
* @param reference - ссылка на файл
|
||||
* @returns содержимое файла или null если не найден
|
||||
*/
|
||||
private async loadFileContent(reference: FileReference): Promise<string | null> {
|
||||
try {
|
||||
const file = this.findFile(reference.path, reference.type);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Подготавливает сообщения для LLM с учетом вложений.
|
||||
* Раскрывает только постоянные (@@/##) ссылки и ссылки в последнем сообщении.
|
||||
*/
|
||||
async prepareMessagesForLLM(messages: ChatHistoryItem[], isLastMessage: boolean = false): Promise<any[]> {
|
||||
const preparedMessages = [];
|
||||
|
||||
const content = await this.plugin.app.vault.read(file);
|
||||
|
||||
// Для промптов обрабатываем шаблоны
|
||||
if (reference.type === 'prompt') {
|
||||
return await this.templateEngine.expandPromptTemplate(content);
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка загрузки файла ${reference.path}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
const isLast = i === messages.length - 1;
|
||||
|
||||
/**
|
||||
* Находит файл по пути
|
||||
* @param path - путь к файлу
|
||||
* @param type - тип файла
|
||||
* @returns файл или null
|
||||
*/
|
||||
private findFile(path: string, type: 'file' | 'prompt'): TFile | null {
|
||||
let file = this.plugin.app.vault.getAbstractFileByPath(path);
|
||||
|
||||
if (!file && type === 'prompt') {
|
||||
// Попробуем найти в папке промптов
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
if (promptsFolder) {
|
||||
const fullPath = `${promptsFolder}/${path}`;
|
||||
file = this.plugin.app.vault.getAbstractFileByPath(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return (file instanceof TFile) ? file : null;
|
||||
}
|
||||
if (msg.role === 'user' && msg.attachments && msg.attachments.length > 0) {
|
||||
// Определяем, какие вложения нужно раскрыть
|
||||
const attachmentsToExpand = msg.attachments.filter((att) => att.permanent || (isLast && isLastMessage));
|
||||
|
||||
/**
|
||||
* Разрешает полный путь к файлу по имени
|
||||
* @param name - имя файла
|
||||
* @param type - тип файла
|
||||
* @returns полный путь к файлу
|
||||
*/
|
||||
private async resolveFilePath(name: string, type: 'file' | 'prompt'): Promise<string> {
|
||||
if (type === 'prompt') {
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
return promptsFolder ? `${promptsFolder}/${name}.md` : `${name}.md`;
|
||||
} else {
|
||||
// Ищем файл в хранилище
|
||||
const files = this.plugin.app.vault.getMarkdownFiles();
|
||||
const matchingFile = files.find(f => f.basename === name);
|
||||
return matchingFile ? matchingFile.path : `${name}.md`;
|
||||
}
|
||||
}
|
||||
if (attachmentsToExpand.length > 0) {
|
||||
// Формируем сообщение с раскрытыми вложениями
|
||||
let expandedContent = msg.content;
|
||||
|
||||
/**
|
||||
* Форматирует содержимое для вставки в сообщение
|
||||
* @param content - содержимое файла
|
||||
* @param reference - информация о ссылке
|
||||
* @returns отформатированное содержимое
|
||||
*/
|
||||
private formatContentForInsertion(content: string, reference: FileReference): string {
|
||||
// Добавляем заголовок с информацией о файле
|
||||
const header = `\n\n--- Содержимое ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n`;
|
||||
const footer = `\n--- Конец ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n\n`;
|
||||
|
||||
return header + content.trim() + footer;
|
||||
}
|
||||
for (const attachment of attachmentsToExpand) {
|
||||
const formattedContent = this.formatContentForInsertion(attachment.content || '', attachment);
|
||||
expandedContent += '\n' + formattedContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует специальные символы для регулярного выражения
|
||||
* @param string - строка для экранирования
|
||||
* @returns экранированная строка
|
||||
*/
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
preparedMessages.push({
|
||||
role: msg.role,
|
||||
content: expandedContent
|
||||
});
|
||||
} else {
|
||||
// Вложения есть, но раскрывать не нужно
|
||||
preparedMessages.push({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Сообщение без вложений
|
||||
preparedMessages.push({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return preparedMessages;
|
||||
}
|
||||
|
||||
// TODO: При реализации снапшотов - сохранять содержимое файлов
|
||||
// async expandReferencesWithSnapshots(text: string, references: FileReference[], messageId: string): Promise<{expandedText: string, snapshots: Record<string, string>}> {
|
||||
// const snapshots: Record<string, string> = {};
|
||||
// let expandedText = text;
|
||||
//
|
||||
// for (const reference of references) {
|
||||
// const content = await this.loadFileContent(reference);
|
||||
// if (content !== null) {
|
||||
// snapshots[reference.path] = content;
|
||||
// expandedText = await this.expandSingleReference(expandedText, reference, content);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return { expandedText, snapshots };
|
||||
// }
|
||||
|
||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Раскрывает одну ссылку в тексте
|
||||
* @param text - текст для обработки
|
||||
* @param reference - ссылка для раскрытия
|
||||
* @param preloadedContent - предзагруженное содержимое (для снапшотов)
|
||||
* @returns текст с раскрытой ссылкой
|
||||
*/
|
||||
private async expandSingleReference(text: string, reference: FileReference, preloadedContent?: string): Promise<string> {
|
||||
const content = preloadedContent || (await this.loadFileContent(reference));
|
||||
|
||||
if (content === null) {
|
||||
console.warn(`Не удалось загрузить содержимое для ссылки: ${reference.name}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
// Определяем паттерн для замены
|
||||
const prefix = reference.permanent ? (reference.type === 'file' ? '@@' : '##') : reference.type === 'file' ? '@' : '#';
|
||||
|
||||
// Создаем регулярное выражение для поиска ссылки
|
||||
const escapedName = this.escapeRegExp(reference.name);
|
||||
const pattern = new RegExp(`${this.escapeRegExp(prefix)}(?:"${escapedName}"|${escapedName})(?=\\s|$|[^\\w-])`, 'g');
|
||||
|
||||
// Форматируем содержимое для вставки
|
||||
const formattedContent = this.formatContentForInsertion(content, reference);
|
||||
|
||||
return text.replace(pattern, formattedContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрывает только постоянные ссылки (@@/##) в тексте
|
||||
* @param text - текст для обработки
|
||||
* @returns текст с раскрытыми постоянными ссылками
|
||||
*/
|
||||
private async expandPermanentReferences(text: string): Promise<string> {
|
||||
const matches = ReferenceParser.parseReferences(text);
|
||||
const permanentMatches = matches.filter((match) => match.prefix.length === 2); // @@ или ##
|
||||
|
||||
if (permanentMatches.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let expandedText = text;
|
||||
|
||||
// Обрабатываем ссылки от конца к началу
|
||||
const sortedMatches = [...permanentMatches].sort((a, b) => b.startIndex - a.startIndex);
|
||||
|
||||
for (const match of sortedMatches) {
|
||||
const reference: FileReference = {
|
||||
type: match.type,
|
||||
name: match.name,
|
||||
path: await this.resolveFilePath(match.name, match.type),
|
||||
permanent: true
|
||||
};
|
||||
|
||||
const content = await this.loadFileContent(reference);
|
||||
if (content !== null) {
|
||||
const formattedContent = this.formatContentForInsertion(content, reference);
|
||||
expandedText = expandedText.substring(0, match.startIndex) + formattedContent + expandedText.substring(match.endIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return expandedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает содержимое файла
|
||||
* @param reference - ссылка на файл
|
||||
* @returns содержимое файла или null если не найден
|
||||
*/
|
||||
private async loadFileContent(reference: FileReference): Promise<string | null> {
|
||||
try {
|
||||
const file = this.findFile(reference.path, reference.type);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await this.plugin.app.vault.read(file);
|
||||
|
||||
// Для промптов обрабатываем шаблоны
|
||||
if (reference.type === 'prompt') {
|
||||
return await this.templateEngine.expandPromptTemplate(content);
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка загрузки файла ${reference.path}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит файл по пути
|
||||
* @param path - путь к файлу
|
||||
* @param type - тип файла
|
||||
* @returns файл или null
|
||||
*/
|
||||
private findFile(path: string, type: 'file' | 'prompt'): TFile | null {
|
||||
let file = this.plugin.app.vault.getAbstractFileByPath(path);
|
||||
|
||||
if (!file && type === 'prompt') {
|
||||
// Попробуем найти в папке промптов
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
if (promptsFolder) {
|
||||
const fullPath = `${promptsFolder}/${path}`;
|
||||
file = this.plugin.app.vault.getAbstractFileByPath(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return file instanceof TFile ? file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разрешает полный путь к файлу по имени
|
||||
* @param name - имя файла
|
||||
* @param type - тип файла
|
||||
* @returns полный путь к файлу
|
||||
*/
|
||||
private async resolveFilePath(name: string, type: 'file' | 'prompt'): Promise<string> {
|
||||
if (type === 'prompt') {
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
return promptsFolder ? `${promptsFolder}/${name}.md` : `${name}.md`;
|
||||
} else {
|
||||
// Ищем файл в хранилище
|
||||
const files = this.plugin.app.vault.getMarkdownFiles();
|
||||
const matchingFile = files.find((f) => f.basename === name);
|
||||
return matchingFile ? matchingFile.path : `${name}.md`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует содержимое для вставки в сообщение
|
||||
* @param content - содержимое файла
|
||||
* @param reference - информация о ссылке
|
||||
* @returns отформатированное содержимое
|
||||
*/
|
||||
private formatContentForInsertion(content: string, reference: FileReference): string {
|
||||
// Добавляем заголовок с информацией о файле
|
||||
const header = `\n\n--- Содержимое ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n`;
|
||||
const footer = `\n--- Конец ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n\n`;
|
||||
|
||||
return header + content.trim() + footer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует специальные символы для регулярного выражения
|
||||
* @param string - строка для экранирования
|
||||
* @returns экранированная строка
|
||||
*/
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
0
src/types/electron.d.ts
vendored
Normal file
0
src/types/electron.d.ts
vendored
Normal file
|
|
@ -173,7 +173,7 @@ export class ChatView extends ItemView {
|
|||
|
||||
// ----------------------------------------------- Event Handlers ---------------------------------------------------------------
|
||||
|
||||
async handleSendMessage(message: string, selectedModel?: string): Promise<void> {
|
||||
async handleSendMessage(message: string, selectedModel?: string, attachments?: Attachment[]): Promise<void> {
|
||||
try {
|
||||
if (!this.selectedGraphId) {
|
||||
new Notice('Граф не выбран или не инициализирован. Пожалуйста, начните новый чат или выберите существующий.', 3000);
|
||||
|
|
@ -184,14 +184,15 @@ export class ChatView extends ItemView {
|
|||
return;
|
||||
}
|
||||
|
||||
// Шаг 1: Создаём узел пользователя
|
||||
// Шаг 1: Создаём узел пользователя с вложениями
|
||||
const sendResponse = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
graph_id: this.selectedGraphId,
|
||||
parent_node_id: this.currentNode
|
||||
parent_node_id: this.currentNode,
|
||||
attachments: attachments || []
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -200,10 +201,8 @@ export class ChatView extends ItemView {
|
|||
}
|
||||
|
||||
const sendResult = await sendResponse.json();
|
||||
// this.selectedGraphId = sendResult.graph_id;
|
||||
const userNodeId = sendResult.node_id;
|
||||
|
||||
// Обновляем граф с новым узлом пользователя
|
||||
this.plugin.eventBus.emit('graph-selected', {
|
||||
graphId: this.selectedGraphId,
|
||||
currentNode: userNodeId
|
||||
|
|
@ -211,9 +210,8 @@ export class ChatView extends ItemView {
|
|||
|
||||
// Шаг 2: Запускаем стриминг ответа
|
||||
if (this.selectedGraphId) {
|
||||
const systemPromptToUse = this.currentGraphCustomSystemPrompt !== null ?
|
||||
this.currentGraphCustomSystemPrompt :
|
||||
this.plugin.settings.systemPrompt;
|
||||
const systemPromptToUse =
|
||||
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
|
||||
|
||||
await this.streamLLMResponse(this.selectedGraphId, userNodeId, selectedModel, null, systemPromptToUse);
|
||||
} else {
|
||||
|
|
@ -230,25 +228,20 @@ export class ChatView extends ItemView {
|
|||
* и обновляет панель чата.
|
||||
* @param {{ nodeId: string }} data - Объект, содержащий ID выбранного узла.
|
||||
*/
|
||||
handleNodeSelected(data: { nodeId: string }): void {
|
||||
this.currentNode = data.nodeId;
|
||||
if (this.selectedGraphId) {
|
||||
this.fetchMessagesFromRootToNode(data.nodeId).then(() => {
|
||||
// После загрузки новой ветки проверяем, есть ли стриминг в ней
|
||||
if (this.currentStreamingNodeId) {
|
||||
const isInNewBranch = this.chatHistory.some(
|
||||
msg => msg.node_id === this.currentStreamingNodeId
|
||||
);
|
||||
if (this.chatPanel) {
|
||||
this.chatPanel.updateStopButtonVisibility(
|
||||
isInNewBranch,
|
||||
isInNewBranch ? this.currentStreamingNodeId : null
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
handleNodeSelected(data: { nodeId: string }): void {
|
||||
this.currentNode = data.nodeId;
|
||||
if (this.selectedGraphId) {
|
||||
this.fetchMessagesFromRootToNode(data.nodeId).then(() => {
|
||||
// После загрузки новой ветки проверяем, есть ли стриминг в ней
|
||||
if (this.currentStreamingNodeId) {
|
||||
const isInNewBranch = this.chatHistory.some((msg) => msg.node_id === this.currentStreamingNodeId);
|
||||
if (this.chatPanel) {
|
||||
this.chatPanel.updateStopButtonVisibility(isInNewBranch, isInNewBranch ? this.currentStreamingNodeId : null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает событие начала нового чата. Сбрасывает текущее состояние чата
|
||||
|
|
@ -469,9 +462,8 @@ export class ChatView extends ItemView {
|
|||
async handleRegenerateMessage(data: { graphId: string; nodeId: string; model: string }): Promise<void> {
|
||||
try {
|
||||
// Определяем системный промпт для отправки: кастомный для графа, если есть, иначе глобальный
|
||||
const systemPromptToUse = this.currentGraphCustomSystemPrompt !== null ?
|
||||
this.currentGraphCustomSystemPrompt :
|
||||
this.plugin.settings.systemPrompt;
|
||||
const systemPromptToUse =
|
||||
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
|
||||
|
||||
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/chat/regenerate`, {
|
||||
method: 'POST',
|
||||
|
|
@ -529,7 +521,7 @@ export class ChatView extends ItemView {
|
|||
|
||||
handleStreamStarted(data: { nodeId: string; controller: AbortController }): void {
|
||||
this.currentStreamingNodeId = data.nodeId;
|
||||
// TODO: Временно не проверяем, что блок сообщения уже отрисован в панели,
|
||||
// TODO: Временно не проверяем, что блок сообщения уже отрисован в панели,
|
||||
// т.к. у нас пока к моменту вызова 'stream-started' сообщение ещё не отрисовано
|
||||
// const isInCurrentBranch = this.chatHistory.some((msg) => msg.node_id === data.nodeId);
|
||||
// if (this.chatPanel && isInCurrentBranch) {
|
||||
|
|
@ -574,4 +566,4 @@ export class ChatView extends ItemView {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
styles.css
81
styles.css
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user