Fix display of tool results, add support for MCP servers (basic)

This commit is contained in:
dimitrievgs 2026-06-14 00:20:48 +03:00
parent 4362e4f491
commit aa4f8d5342
3 changed files with 183 additions and 63 deletions

76
main.ts
View File

@ -14,6 +14,14 @@ import 'src/css/styles.css';
import { LLM_DEFAULTS } from 'src/constants'; import { LLM_DEFAULTS } from 'src/constants';
export interface MCPServerConfig {
id: string;
name: string;
command: string;
args: string;
envString: string;
}
// ----------------------------------------------- Settings --------------------------------------------------------------- // ----------------------------------------------- Settings ---------------------------------------------------------------
interface LLMAgentSettings { interface LLMAgentSettings {
@ -31,6 +39,7 @@ interface LLMAgentSettings {
maxTokensChatDefault: string; maxTokensChatDefault: string;
agencyModeEnabled: boolean; // Новое: По умолчанию включен ли агент agencyModeEnabled: boolean; // Новое: По умолчанию включен ли агент
customTools: CustomTool[]; // Новое: Список созданных инструментов customTools: CustomTool[]; // Новое: Список созданных инструментов
mcpServers: MCPServerConfig[];
summarizationModel: string; summarizationModel: string;
showDebugInfo: boolean; showDebugInfo: boolean;
@ -73,6 +82,7 @@ const DEFAULT_SETTINGS: LLMAgentSettings = {
maxTokensChatDefault: '', maxTokensChatDefault: '',
agencyModeEnabled: true, agencyModeEnabled: true,
customTools: [], customTools: [],
mcpServers: [],
summarizationModel: '', summarizationModel: '',
showDebugInfo: false, showDebugInfo: false,
@ -347,6 +357,7 @@ class SampleSettingTab extends PluginSettingTab {
const tabs = [ const tabs = [
{ id: 'basic', label: '⚙️ Основные' }, { id: 'basic', label: '⚙️ Основные' },
{ id: 'agency', label: '🤖 Агент (ReAct Tools)' }, { id: 'agency', label: '🤖 Агент (ReAct Tools)' },
{ id: 'mcp', label: '🔌 MCP Серверы' },
{ id: 'voice', label: '🎤 Голос (Vosk)' } { id: 'voice', label: '🎤 Голос (Vosk)' }
]; ];
@ -368,6 +379,7 @@ class SampleSettingTab extends PluginSettingTab {
if (id === 'basic') this.renderBasicSettings(contentContainer); if (id === 'basic') this.renderBasicSettings(contentContainer);
if (id === 'agency') this.renderAgencySettings(contentContainer); if (id === 'agency') this.renderAgencySettings(contentContainer);
if (id === 'mcp') this.renderMCPSettings(contentContainer);
if (id === 'voice') this.renderVoiceSettings(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) { renderVoiceSettings(container: HTMLElement) {
container.createEl('h3', { text: 'Транскрибация Vosk' }); container.createEl('h3', { text: 'Транскрибация Vosk' });

View File

@ -287,6 +287,9 @@ export class ChatPanel {
const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' }); const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' });
await MarkdownRenderer.renderMarkdown(msg.content, contentDiv, '', this.props.plugin); await MarkdownRenderer.renderMarkdown(msg.content, contentDiv, '', this.props.plugin);
// Оживляем плашки в старых сообщениях
this.enhanceToolCalls(contentDiv);
this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || ''); this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || '');
} }
@ -470,43 +473,83 @@ export class ChatPanel {
this.processStreamUpdates(); this.processStreamUpdates();
} }
toolExecutionUpdate(nodeId: string, toolName: string, status: 'start' | 'end', step: number = 1): void { /**
const messageEl = this.container.querySelector(`.message[data-node-id="${nodeId}"]`); * Превращает скрытые маркеры .agent-tool-call в интерактивный UI.
if (!messageEl) return; */
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'
const contentDiv = messageEl.querySelector('.chat-message-content') as HTMLElement; // Меняем класс, чтобы не обрабатывать див повторно при следующих тиках стриминга
div.className = 'tool-call-ui-block';
let indicator = contentDiv.querySelector('.agent-steps-info'); // Иконка загрузки, успеха или ошибки
if (!indicator) { let statusIcon = '<span class="tool-status-icon ring-loader" style="font-size:12px;">⏳</span>';
indicator = document.createElement('div'); if (status === 'success') statusIcon = '<span class="tool-status-icon success" style="color:var(--text-success); font-size:14px;">✅</span>';
indicator.className = 'agent-steps-info'; if (status === 'error') statusIcon = '<span class="tool-status-icon error" style="color:var(--text-error); font-size:14px;">❌</span>';
// Вставляем В НАЧАЛО контента
contentDiv.insertBefore(indicator, contentDiv.firstChild);
}
if (status === 'start') { // Рендерим HTML компонент. Стили зашиты инлайн для стабильности
indicator.innerHTML = ` div.innerHTML = `
<div class="step-badge"> <div class="tool-header" style="display:flex; justify-content:space-between; align-items:center; padding: 6px 12px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 6px; cursor: ${status === 'start' ? 'default' : 'pointer'}; margin-top: 8px; margin-bottom: 8px;">
🛠 Шаг ${step}: Подготовка и вызов <b>${toolName}</b>... <span class="tool-title" style="font-size: 13px; font-weight: 500; font-family: var(--font-interface);">🛠 Шаг ${step}: <b>${name}</b></span>
</div> ${statusIcon}
`; </div>
} else if (status === 'end') { <div class="tool-body" style="display: none; border: 1px solid var(--background-modifier-border); border-top: none; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; margin-top: -8px; margin-bottom: 8px; background: var(--background-primary);">
indicator.innerHTML = ` <div class="tool-tabs" style="display:flex; border-bottom: 1px solid var(--background-modifier-border);">
<div class="step-badge success"> <button class="tool-tab-btn active" data-tab="request" style="flex:1; border:none; border-right: 1px solid var(--background-modifier-border); border-radius: 0; background: var(--background-modifier-hover); padding: 4px; font-size: 12px; box-shadow: none;">Request</button>
Шаг ${step}: Инструмент <b>${toolName}</b> успешно завершен <button class="tool-tab-btn" data-tab="response" style="flex:1; border:none; border-radius: 0; background: transparent; padding: 4px; font-size: 12px; box-shadow: none;">Response</button>
</div> </div>
`; <pre class="tool-code-block" style="margin: 0; padding: 8px; font-size: 11px; max-height: 250px; overflow-y: auto; white-space: pre-wrap; background: var(--background-primary); border: none;"></pre>
// Прячем значок успеха через 3.5 секунды, чтобы не мозолил глаза </div>
setTimeout(() => { `;
if (indicator && indicator.parentNode) {
indicator.remove();
}
}, 3500);
}
// Автоскролл if (status !== 'start') {
const scrollContainer = this.container.closest('.view-content') as HTMLElement; const reqBase64 = div.getAttribute('data-req') || '';
if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight; 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); await MarkdownRenderer.renderMarkdown(content, tempDiv, '', this.props.plugin);
// Оживляем плашки ПЕРЕД переброской их в финальный DOM
this.enhanceToolCalls(tempDiv);
// Моментально перебрасываем готовый DOM // Моментально перебрасываем готовый DOM
contentDiv.innerHTML = ''; contentDiv.innerHTML = '';
while (tempDiv.firstChild) { while (tempDiv.firstChild) {
@ -835,30 +881,6 @@ export class ChatPanel {
this.setAgencyToggle(); 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 = `<div class="step-badge">🛠 Шаг ${data.step}: Выполняю <b>${data.tool}</b>...</div>`;
} else if (data.status === 'end') {
indicator.innerHTML = `<div class="step-badge success">✅ Шаг ${data.step}: ${data.tool} завершен</div>`;
// Через 3 секунды можно скрыть индикатор шага, чтобы не мешался
setTimeout(() => indicator.remove(), 3000);
}
});
// Close dropdown when clicking outside // Close dropdown when clicking outside
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) { if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) {

View File

@ -288,6 +288,15 @@ export class ChatView extends ItemView {
await this.fetchMessagesFromRootToNode(data.currentNode); 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. * Стримит ответ LLM.
*/ */
@ -385,9 +394,22 @@ export class ChatView extends ItemView {
}); });
} }
else if (data.type === 'tool_start' && assistantNodeId) { else if (data.type === 'tool_start' && assistantNodeId) {
if (this.chatPanel) this.chatPanel.toolExecutionUpdate(assistantNodeId, data.name, 'start', data.step); accumulatedContent += `\n<div class="agent-tool-call" data-name="${data.name}" data-step="${data.step}" data-status="start"></div>\n`;
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
} else if (data.type === 'tool_end' && assistantNodeId) { } else if (data.type === 'tool_end' && assistantNodeId) {
if (this.chatPanel) this.chatPanel.toolExecutionUpdate(assistantNodeId, data.name, 'end', data.step); const startMarker = `<div class="agent-tool-call" data-name="${data.name}" data-step="${data.step}" data-status="start"></div>`;
const encodedReq = this.encodeBase64Safe(data.request || "{}");
const encodedRes = this.encodeBase64Safe(data.response || "No data");
const isError = data.is_error ? "error" : "success";
const endMarker = `<div class="agent-tool-call" data-name="${data.name}" data-step="${data.step}" data-status="${isError}" data-req="${encodedReq}" data-res="${encodedRes}"></div>`;
if (accumulatedContent.includes(startMarker)) {
accumulatedContent = accumulatedContent.replace(startMarker, endMarker);
} else {
accumulatedContent += `\n${endMarker}\n`; // На крайний случай (фоллбек)
}
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
} else if (data.type === 'chunk' && assistantNodeId) { } else if (data.type === 'chunk' && assistantNodeId) {
accumulatedContent += data.content; accumulatedContent += data.content;
this.updateStreamingMessage(assistantNodeId, accumulatedContent); this.updateStreamingMessage(assistantNodeId, accumulatedContent);