From aa4f8d5342686c7e1ebfe24dcad24b5fdf8e58c7 Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Sun, 14 Jun 2026 00:20:48 +0300 Subject: [PATCH] Fix display of tool results, add support for MCP servers (basic) --- main.ts | 76 +++++++++++++++++++ src/components/ChatPanel.ts | 144 +++++++++++++++++++++--------------- src/views/ChatView.ts | 26 ++++++- 3 files changed, 183 insertions(+), 63 deletions(-) diff --git a/main.ts b/main.ts index 0fff000..e642327 100644 --- a/main.ts +++ b/main.ts @@ -14,6 +14,14 @@ import 'src/css/styles.css'; import { LLM_DEFAULTS } from 'src/constants'; +export interface MCPServerConfig { + id: string; + name: string; + command: string; + args: string; + envString: string; +} + // ----------------------------------------------- Settings --------------------------------------------------------------- interface LLMAgentSettings { @@ -31,6 +39,7 @@ interface LLMAgentSettings { maxTokensChatDefault: string; agencyModeEnabled: boolean; // Новое: По умолчанию включен ли агент customTools: CustomTool[]; // Новое: Список созданных инструментов + mcpServers: MCPServerConfig[]; summarizationModel: string; showDebugInfo: boolean; @@ -73,6 +82,7 @@ const DEFAULT_SETTINGS: LLMAgentSettings = { maxTokensChatDefault: '', agencyModeEnabled: true, customTools: [], + mcpServers: [], summarizationModel: '', showDebugInfo: false, @@ -347,6 +357,7 @@ class SampleSettingTab extends PluginSettingTab { const tabs = [ { id: 'basic', label: '⚙️ Основные' }, { id: 'agency', label: '🤖 Агент (ReAct Tools)' }, + { id: 'mcp', label: '🔌 MCP Серверы' }, { id: 'voice', label: '🎤 Голос (Vosk)' } ]; @@ -368,6 +379,7 @@ class SampleSettingTab extends PluginSettingTab { if (id === 'basic') this.renderBasicSettings(contentContainer); if (id === 'agency') this.renderAgencySettings(contentContainer); + if (id === 'mcp') this.renderMCPSettings(contentContainer); if (id === 'voice') this.renderVoiceSettings(contentContainer); }; @@ -640,6 +652,70 @@ class SampleSettingTab extends PluginSettingTab { })); } + renderMCPSettings(container: HTMLElement) { + container.createEl('h3', { text: 'MCP (Model Context Protocol) Серверы' }); + container.createEl('p', { text: 'Стандартизированные инструменты от Anthropic (через stdio). Используйте docker, npx или uvx.' }); + + if (!this.plugin.settings.mcpServers) this.plugin.settings.mcpServers = []; + + this.plugin.settings.mcpServers.forEach((server, index) => { + const serverDiv = container.createDiv({ + attr: { style: 'border: 1px solid var(--interactive-accent); padding: 10px; margin-bottom: 15px; border-radius: 8px; background-color: var(--background-secondary-alt);' } + }); + + new Setting(serverDiv) + .setName(`Имя сервера`) + .addText(t => t.setValue(server.name).onChange(async v => { + server.name = v.replace(/[^a-zA-Z0-9_\-]/g, ''); + await this.plugin.saveSettings(); + })) + .addExtraButton(b => b.setIcon('trash').setTooltip('Удалить этот сервер').onClick(async () => { + this.plugin.settings.mcpServers.splice(index, 1); + await this.plugin.saveSettings(); + this.display(); + })); + + new Setting(serverDiv) + .setName('Команда (Command)') + .setDesc('Пример: docker, npx, uvx') + .addText(t => { + t.inputEl.style.width = '100%'; + t.setValue(server.command).onChange(async v => { + server.command = v; + await this.plugin.saveSettings(); + }); + }); + + new Setting(serverDiv) + .setName('Аргументы (Args)') + .setDesc('Через пробел. Пример: run -i --rm -e DDG_SAFE_SEARCH=MODERATE ddg-mcp-server') + .addTextArea(t => { + t.inputEl.style.width = '100%'; + t.setValue(server.args).onChange(async v => { + server.args = v; + await this.plugin.saveSettings(); + }); + }); + + new Setting(serverDiv) + .setName('Переменные окружения (Env)') + .setDesc('Опционально, через запятую. Формат (KEY=VAL,KEY2=VAL2)') + .addText(t => { + t.inputEl.style.width = '100%'; + t.setValue(server.envString || '').onChange(async v => { + server.envString = v; + await this.plugin.saveSettings(); + }); + }); + }); + + new Setting(container).addButton(b => b.setButtonText('➕ Добавить MCP сервер').setCta().onClick(async () => { + this.plugin.settings.mcpServers.push({ id: uuidv4(), name: 'duckduckgo', command: 'docker', args: 'run -i --rm ddg-mcp-server', envString: '' }); + await this.plugin.saveSettings(); + this.display(); + })); + } + renderVoiceSettings(container: HTMLElement) { container.createEl('h3', { text: 'Транскрибация Vosk' }); diff --git a/src/components/ChatPanel.ts b/src/components/ChatPanel.ts index ae0ee30..837672c 100644 --- a/src/components/ChatPanel.ts +++ b/src/components/ChatPanel.ts @@ -287,6 +287,9 @@ export class ChatPanel { const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' }); await MarkdownRenderer.renderMarkdown(msg.content, contentDiv, '', this.props.plugin); + // Оживляем плашки в старых сообщениях + this.enhanceToolCalls(contentDiv); + this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || ''); } @@ -470,43 +473,83 @@ export class ChatPanel { this.processStreamUpdates(); } - toolExecutionUpdate(nodeId: string, toolName: string, status: 'start' | 'end', step: number = 1): void { - const messageEl = this.container.querySelector(`.message[data-node-id="${nodeId}"]`); - if (!messageEl) return; - - const contentDiv = messageEl.querySelector('.chat-message-content') as HTMLElement; - - let indicator = contentDiv.querySelector('.agent-steps-info'); - if (!indicator) { - indicator = document.createElement('div'); - indicator.className = 'agent-steps-info'; - // Вставляем В НАЧАЛО контента - contentDiv.insertBefore(indicator, contentDiv.firstChild); - } - - if (status === 'start') { - indicator.innerHTML = ` -
- 🛠 Шаг ${step}: Подготовка и вызов ${toolName}... -
- `; - } else if (status === 'end') { - indicator.innerHTML = ` -
- ✅ Шаг ${step}: Инструмент ${toolName} успешно завершен -
- `; - // Прячем значок успеха через 3.5 секунды, чтобы не мозолил глаза - setTimeout(() => { - if (indicator && indicator.parentNode) { - indicator.remove(); - } - }, 3500); - } - - // Автоскролл - const scrollContainer = this.container.closest('.view-content') as HTMLElement; - if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight; + /** + * Превращает скрытые маркеры .agent-tool-call в интерактивный UI. + */ + private enhanceToolCalls(container: HTMLElement): void { + const toolDivs = container.querySelectorAll('.agent-tool-call'); + toolDivs.forEach((div) => { + const name = div.getAttribute('data-name') || 'Tool'; + const step = div.getAttribute('data-step') || '*'; + const status = div.getAttribute('data-status'); // 'start', 'success', 'error' + + // Меняем класс, чтобы не обрабатывать див повторно при следующих тиках стриминга + div.className = 'tool-call-ui-block'; + + // Иконка загрузки, успеха или ошибки + let statusIcon = ''; + if (status === 'success') statusIcon = ''; + if (status === 'error') statusIcon = ''; + + // Рендерим HTML компонент. Стили зашиты инлайн для стабильности + div.innerHTML = ` +
+ 🛠 Шаг ${step}: ${name} + ${statusIcon} +
+ + `; + + if (status !== 'start') { + const reqBase64 = div.getAttribute('data-req') || ''; + const resBase64 = div.getAttribute('data-res') || ''; + + let reqText = ''; + let resText = ''; + + try { reqText = reqBase64 ? decodeURIComponent(escape(atob(reqBase64))) : '{ }'; } catch(e) { reqText = 'Ошибка декодирования'; } + try { resText = resBase64 ? decodeURIComponent(escape(atob(resBase64))) : 'Нет данных'; } catch(e) { resText = 'Ошибка декодирования'; } + + const header = div.querySelector('.tool-header') as HTMLElement; + const body = div.querySelector('.tool-body') as HTMLElement; + const codeBlock = div.querySelector('.tool-code-block') as HTMLElement; + const tabBtns = div.querySelectorAll('.tool-tab-btn'); + + // Дефолтно показываем Request + codeBlock.textContent = reqText; + + // Обработка сворачивания/разворачивания аккордеона + header.addEventListener('click', () => { + const isHidden = body.style.display === 'none'; + body.style.display = isHidden ? 'block' : 'none'; + header.style.borderBottomLeftRadius = isHidden ? '0' : '6px'; + header.style.borderBottomRightRadius = isHidden ? '0' : '6px'; + }); + + // Обработка переключения вкладок внутри + tabBtns.forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + tabBtns.forEach(b => { + (b as HTMLElement).style.background = 'transparent'; + b.classList.remove('active'); + }); + + (btn as HTMLElement).style.background = 'var(--background-modifier-hover)'; + btn.classList.add('active'); + + const targetTab = btn.getAttribute('data-tab'); + codeBlock.textContent = targetTab === 'request' ? reqText : resText; + }); + }); + } + }); } /** @@ -555,6 +598,9 @@ export class ChatPanel { // Вызываем тяжелый маркдаун Обсидиана и ЖДЕМ await MarkdownRenderer.renderMarkdown(content, tempDiv, '', this.props.plugin); + // Оживляем плашки ПЕРЕД переброской их в финальный DOM + this.enhanceToolCalls(tempDiv); + // Моментально перебрасываем готовый DOM contentDiv.innerHTML = ''; while (tempDiv.firstChild) { @@ -835,30 +881,6 @@ export class ChatPanel { this.setAgencyToggle(); }); - // ??? - this.props.plugin.eventBus.on('agent-step', (data: { nodeId: string, step: number, tool: string, status: string }) => { - // Ищем контейнер сообщения по его ID (тот, что сейчас стримится) - const messageEl = this.container.querySelector(`.message[data-node-id="${data.nodeId}"]`); - if (!messageEl) return; - - let indicator = messageEl.querySelector('.agent-steps-info') as HTMLElement; - if (!indicator) { - indicator = document.createElement('div'); - indicator.className = 'agent-steps-info'; - // Вставляем В НАЧАЛО контента сообщения - const content = messageEl.querySelector('.chat-message-content'); - content?.prepend(indicator); - } - - if (data.status === 'start') { - indicator.innerHTML = `
🛠 Шаг ${data.step}: Выполняю ${data.tool}...
`; - } else if (data.status === 'end') { - indicator.innerHTML = `
✅ Шаг ${data.step}: ${data.tool} завершен
`; - // Через 3 секунды можно скрыть индикатор шага, чтобы не мешался - setTimeout(() => indicator.remove(), 3000); - } - }); - // Close dropdown when clicking outside document.addEventListener('click', (e) => { if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) { diff --git a/src/views/ChatView.ts b/src/views/ChatView.ts index 63d657b..b387f9f 100644 --- a/src/views/ChatView.ts +++ b/src/views/ChatView.ts @@ -288,6 +288,15 @@ export class ChatView extends ItemView { await this.fetchMessagesFromRootToNode(data.currentNode); } + private encodeBase64Safe(str: string): string { + try { + // Безопасное кодирование UTF-8 / юникода + return btoa(unescape(encodeURIComponent(str))); + } catch (e) { + return btoa(unescape(encodeURIComponent("Ошибка кодирования данных"))); + } + } + /** * Стримит ответ LLM. */ @@ -385,9 +394,22 @@ export class ChatView extends ItemView { }); } else if (data.type === 'tool_start' && assistantNodeId) { - if (this.chatPanel) this.chatPanel.toolExecutionUpdate(assistantNodeId, data.name, 'start', data.step); + accumulatedContent += `\n
\n`; + this.updateStreamingMessage(assistantNodeId, accumulatedContent); } else if (data.type === 'tool_end' && assistantNodeId) { - if (this.chatPanel) this.chatPanel.toolExecutionUpdate(assistantNodeId, data.name, 'end', data.step); + const startMarker = `
`; + const encodedReq = this.encodeBase64Safe(data.request || "{}"); + const encodedRes = this.encodeBase64Safe(data.response || "No data"); + const isError = data.is_error ? "error" : "success"; + + const endMarker = `
`; + + if (accumulatedContent.includes(startMarker)) { + accumulatedContent = accumulatedContent.replace(startMarker, endMarker); + } else { + accumulatedContent += `\n${endMarker}\n`; // На крайний случай (фоллбек) + } + this.updateStreamingMessage(assistantNodeId, accumulatedContent); } else if (data.type === 'chunk' && assistantNodeId) { accumulatedContent += data.content; this.updateStreamingMessage(assistantNodeId, accumulatedContent);