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 = `
+
+
+
+
+
+
+
+
+ `;
+
+ 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);