Fix display of tool results, add support for MCP servers (basic)
This commit is contained in:
parent
4362e4f491
commit
aa4f8d5342
76
main.ts
76
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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
* Превращает скрытые маркеры .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'
|
||||
|
||||
const contentDiv = messageEl.querySelector('.chat-message-content') as HTMLElement;
|
||||
// Меняем класс, чтобы не обрабатывать див повторно при следующих тиках стриминга
|
||||
div.className = 'tool-call-ui-block';
|
||||
|
||||
let indicator = contentDiv.querySelector('.agent-steps-info');
|
||||
if (!indicator) {
|
||||
indicator = document.createElement('div');
|
||||
indicator.className = 'agent-steps-info';
|
||||
// Вставляем В НАЧАЛО контента
|
||||
contentDiv.insertBefore(indicator, contentDiv.firstChild);
|
||||
}
|
||||
// Иконка загрузки, успеха или ошибки
|
||||
let statusIcon = '<span class="tool-status-icon ring-loader" style="font-size:12px;">⏳</span>';
|
||||
if (status === 'success') statusIcon = '<span class="tool-status-icon success" style="color:var(--text-success); font-size:14px;">✅</span>';
|
||||
if (status === 'error') statusIcon = '<span class="tool-status-icon error" style="color:var(--text-error); font-size:14px;">❌</span>';
|
||||
|
||||
if (status === 'start') {
|
||||
indicator.innerHTML = `
|
||||
<div class="step-badge">
|
||||
🛠 Шаг ${step}: Подготовка и вызов <b>${toolName}</b>...
|
||||
</div>
|
||||
`;
|
||||
} else if (status === 'end') {
|
||||
indicator.innerHTML = `
|
||||
<div class="step-badge success">
|
||||
✅ Шаг ${step}: Инструмент <b>${toolName}</b> успешно завершен
|
||||
</div>
|
||||
`;
|
||||
// Прячем значок успеха через 3.5 секунды, чтобы не мозолил глаза
|
||||
setTimeout(() => {
|
||||
if (indicator && indicator.parentNode) {
|
||||
indicator.remove();
|
||||
}
|
||||
}, 3500);
|
||||
}
|
||||
// Рендерим HTML компонент. Стили зашиты инлайн для стабильности
|
||||
div.innerHTML = `
|
||||
<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;">
|
||||
<span class="tool-title" style="font-size: 13px; font-weight: 500; font-family: var(--font-interface);">🛠 Шаг ${step}: <b>${name}</b></span>
|
||||
${statusIcon}
|
||||
</div>
|
||||
<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);">
|
||||
<div class="tool-tabs" style="display:flex; border-bottom: 1px solid var(--background-modifier-border);">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Автоскролл
|
||||
const scrollContainer = this.container.closest('.view-content') as HTMLElement;
|
||||
if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||||
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 = `<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
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) {
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
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) {
|
||||
accumulatedContent += data.content;
|
||||
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user