llm-agent-plugin/main.ts
2026-07-06 01:50:38 +03:00

1200 lines
47 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, ItemView, WorkspaceLeaf, TFile, FileSystemAdapter } from 'obsidian';
import { EventBus } from './src/utils/EventBus';
import { GraphView, GRAPH_VIEW_TYPE } from './src/views/GraphView';
import { ChatView, CHAT_VIEW_TYPE } from './src/views/ChatView';
import { WebSocketService } from './src/utils/WebSocketService';
import { TemplateEngine } from './src/references/TemplateEngine';
import { AutocompleteManager } from './src/references/AutocompleteManager';
import { ReferenceAutocomplete } from './src/references/ReferenceAutocomplete';
import { ReferenceParser } from './src/references/ReferenceParser';
import { CustomTool } from './src/components/models/ChatHistoryItem';
import { VaultQueryHandler } from 'src/utils/VaultQueryHandler';
import { v4 as uuidv4 } from 'uuid';
import 'src/css/styles.css';
import { LLM_DEFAULTS } from 'src/constants';
export interface MCPToolState {
name: string;
originalDescription: string;
customDescription: string;
isEnabled?: boolean;
}
export interface MCPServerConfig {
id: string;
name: string;
command: string;
args: string;
envString: string;
serverInstruction?: string;
fetchedTools?: MCPToolState[];
isEnabled?: boolean;
}
// ----------------------------------------------- Settings ---------------------------------------------------------------
interface LLMAgentSettings {
apiBaseUrl: string;
systemPrompt: string;
defaultModel: string;
promptsFolder: string;
scriptsFolder: string; // Новое: Папка со скриптами питона
enableFileReferences: boolean;
enablePromptReferences: boolean;
excludedFolders: string[];
refCacheFolder: string;
temperatureChat: number;
maxTokensChatDefault: string;
agencyModeEnabled: boolean; // Новое: По умолчанию включен ли агент
customTools: CustomTool[]; // Новое: Список созданных инструментов
mcpServers: MCPServerConfig[];
summarizationModel: string;
showDebugInfo: boolean;
voiceCommandModel: string;
logsFolder: string;
voiceInterval: number;
metricsInterval: number;
voiceDisplayLimit: number;
voiceContextLimit: number;
temperatureVoice: number;
maxTokensVoiceRegular: string;
maxTokensVoiceCommands: string;
promptRegular: string;
promptCommands: string;
promptMetrics: string;
markerStart: string;
markerStop: string;
voskModelPath: string;
showVoicePanel: boolean;
dictationStartAction: 'start' | 'continue';
lastActiveGraphId: string | null;
lastActiveNodeId: string | null;
}
const DEFAULT_SETTINGS: LLMAgentSettings = {
apiBaseUrl: 'http://localhost:5000/api',
systemPrompt: 'Ты полезный ИИ ассистент.',
defaultModel: LLM_DEFAULTS.MODEL,
promptsFolder: 'prompts',
scriptsFolder: 'scripts',
enableFileReferences: true,
enablePromptReferences: true,
excludedFolders: ['.obsidian', 'llm-agent-backend', 'templates', 'ref-cache'],
refCacheFolder: 'ref-cache',
temperatureChat: LLM_DEFAULTS.TEMPERATURE,
maxTokensChatDefault: '',
agencyModeEnabled: true,
customTools: [],
mcpServers: [],
summarizationModel: '',
showDebugInfo: false,
voiceCommandModel: '',
logsFolder: 'audio-logs',
voiceInterval: 1,
metricsInterval: 15,
voiceDisplayLimit: 30000,
voiceContextLimit: 30000,
temperatureVoice: LLM_DEFAULTS.TEMPERATURE,
maxTokensVoiceRegular: '',
maxTokensVoiceCommands: '',
promptRegular: '',
promptCommands: '',
promptMetrics: '',
markerStart: 'старт',
markerStop: 'стоп',
voskModelPath: '',
showVoicePanel: false,
dictationStartAction: 'start',
lastActiveGraphId: null,
lastActiveNodeId: null,
};
// ----------------------------------------------- Main Plugin Class ---------------------------------------------------------------
export default class LLMAgentPlugin extends Plugin {
settings: LLMAgentSettings;
eventBus: EventBus;
webSocketService: WebSocketService;
activeStreamControllers: Map<string, AbortController>;
availableModels: string[];
vaultQueryHandler: VaultQueryHandler | undefined;
async syncSettings() {
try {
const engine = new TemplateEngine(this);
// Клонируем настройки, чтобы не испортить исходные пути в конфиге плагина
const settingsToSync = JSON.parse(JSON.stringify(this.settings));
// Собираем промпты (раскрываем все цепочки ссылок) для работы с аудиологами на бекенде
settingsToSync.systemPrompt = await engine.processRawSettingValue(this.settings.systemPrompt);
settingsToSync.promptRegular = await engine.processRawSettingValue(this.settings.promptRegular);
settingsToSync.promptCommands = await engine.processRawSettingValue(this.settings.promptCommands);
settingsToSync.promptMetrics = await engine.processRawSettingValue(this.settings.promptMetrics);
// Раскрываем скрытые промпты у всех тулов
for (let i = 0; i < settingsToSync.customTools.length; i++) {
if (settingsToSync.customTools[i].hiddenPrompt) {
settingsToSync.customTools[i].hiddenPromptExpanded = await engine.processRawSettingValue(settingsToSync.customTools[i].hiddenPrompt);
} else {
settingsToSync.customTools[i].hiddenPromptExpanded = "";
}
// Конвертируем относительный путь скрипта в абсолютный для бэкенда
const adapter = this.app.vault.adapter;
if (adapter instanceof FileSystemAdapter && settingsToSync.customTools[i].scriptPath) {
const vaultPath = adapter.getBasePath();
const cleanScriptPath = settingsToSync.customTools[i].scriptPath.replace('@', '').trim();
settingsToSync.customTools[i].scriptPath = `${vaultPath}/${cleanScriptPath}`;
}
}
const adapter = this.app.vault.adapter;
if (adapter instanceof FileSystemAdapter) {
const vaultPath = adapter.getBasePath();
settingsToSync.vaultAbsolutePath = vaultPath;
// Соединяем базовый путь хранилища и относительный путь к логам
settingsToSync.absoluteLogsPath = adapter.getBasePath() +
(this.settings.logsFolder ? '/' + this.settings.logsFolder : '');
}
const response = await fetch(`${this.settings.apiBaseUrl}/settings/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settingsToSync)
});
if (response.ok) {
console.log("LLM Agent: Settings and Tools synced with backend.");
}
} catch (e) {
console.error("LLM Agent: Failed to sync settings", e);
}
}
/**
* Загружает список доступных моделей с бэкенда.
* @returns {Promise<void>}
*/
async fetchAvailableModels(): Promise<void> {
try {
const response = await fetch(`${this.settings.apiBaseUrl}/models`);
if (!response.ok) {
throw new Error(`Ошибка загрузки моделей: ${response.status}`);
}
this.availableModels = await response.json();
} catch (error) {
console.error('Ошибка загрузки доступных моделей:', error);
// Fallback к базовым моделям
this.availableModels = [LLM_DEFAULTS.MODEL, 'gpt-4o-mini', 'claude-3-5-sonnet-20241022'];
}
}
async onload() {
await this.loadSettings();
this.eventBus = new EventBus();
this.webSocketService = new WebSocketService(this.eventBus, this.settings.apiBaseUrl);
this.webSocketService.connect();
// Инициализируем обработчик vault-запросов от бэкенда
this.vaultQueryHandler = new VaultQueryHandler(this.app);
// Регистрируем хук: бэкенд просит Obsidian выполнить операцию с vault
this.webSocketService.onServerEvent(
'vault_query_request',
async (data: { request_id: string; type: string; payload: Record<string, any> }) => {
const { request_id, type, payload } = data;
console.log(`🔍 vault_query_request: type=${type}, id=${request_id}`);
try {
const result = await this.vaultQueryHandler.handle({
request_id,
type,
payload
});
this.webSocketService.emit('vault_query_response', {
request_id,
result
});
console.log(`✅ vault_query_response отправлен: id=${request_id}`);
} catch (e: any) {
const errorMsg = e?.message ?? String(e);
console.error(`❌ vault_query_request ошибка: ${errorMsg}`);
this.webSocketService.emit('vault_query_response', {
request_id,
error: errorMsg
});
}
}
);
// Регистрируем кастомные views
this.registerView(GRAPH_VIEW_TYPE, (leaf) => new GraphView(leaf, this));
this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this));
// Добавляем команды
this.addCommand({
id: 'open-llm-agent-graph',
name: 'Открыть граф диалогов LLM Agent',
callback: () => this.activateGraphView()
});
this.addCommand({
id: 'open-llm-agent-chat',
name: 'Открыть чат LLM Agent',
callback: () => this.activateChatView()
});
this.addCommand({
id: 'new-llm-agent-chat',
name: 'Новый чат LLM Agent',
callback: () => this.handleNewChat()
});
// Добавляем иконку в ribbon
this.addRibbonIcon('brain-circuit', 'LLM Agent', () => {
this.activateGraphView();
this.activateChatView();
});
console.log('LLM Agent plugin загружен');
this.activeStreamControllers = new Map<string, AbortController>();
this.eventBus.on('stream-started', this.handleStreamStarted.bind(this));
this.eventBus.on('stream-ended', this.handleStreamEnded.bind(this));
this.eventBus.on('stop-all-streams', () => {
this.activeStreamControllers.forEach((controller, nodeId) => {
controller.abort();
});
this.activeStreamControllers.clear();
new Notice('Отменены все генерации во всех графах', 2000);
this.eventBus.emit('global-stream-state-changed', false);
});
// This adds a settings tab so the user can configure various aspects of the plugin
this.addSettingTab(new SampleSettingTab(this.app, this));
await this.fetchAvailableModels();
// Ждем полной инициализации хранилища перед вызовом зависимых функций
// await this.syncSettings();
this.app.workspace.onLayoutReady(async () => {
await this.syncSettings();
});
this.registerEvent(
this.app.vault.on('modify', async (file) => {
// Если изменился любой md файл, перестраховываемся и синхроним настройки
// (Backend получит обновленный текст промптов, если на них есть ссылки)
if (file instanceof TFile && file.extension === 'md' && file.path.startsWith(this.settings.promptsFolder)) {
// TODO: Проверить, что срабатывает
await this.syncSettings();
}
})
);
}
onunload() {
if (this.webSocketService) {
this.webSocketService.disconnect();
}
this.activeStreamControllers.clear();
this.eventBus.off('stream-started', this.handleStreamStarted);
this.eventBus.off('stream-ended', this.handleStreamEnded);
console.log('LLM Agent plugin выгружен');
}
// ----------------------------------------------- Settings Management ---------------------------------------------------------------
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
this.eventBus.emit('settings-changed');
}
// ----------------------------------------------- View Management ---------------------------------------------------------------
async activateGraphView() {
const existingLeaf = this.app.workspace.getLeavesOfType(GRAPH_VIEW_TYPE)[0];
if (existingLeaf) {
this.app.workspace.revealLeaf(existingLeaf);
} else {
await this.app.workspace.getLeaf('tab').setViewState({
type: GRAPH_VIEW_TYPE,
active: true
});
}
}
async activateChatView() {
const existingLeaf = this.app.workspace.getLeavesOfType(CHAT_VIEW_TYPE)[0];
if (existingLeaf) {
this.app.workspace.revealLeaf(existingLeaf);
} else {
const rightLeaf = this.app.workspace.getRightLeaf(false);
if (rightLeaf) {
await rightLeaf.setViewState({
type: CHAT_VIEW_TYPE,
active: true
});
}
}
}
// ----------------------------------------------- Event Handlers ---------------------------------------------------------------
handleNewChat() {
this.eventBus.emit('new-chat');
}
handleStreamStarted(data: { nodeId: string; controller: AbortController }): void {
this.activeStreamControllers.set(data.nodeId, data.controller);
this.eventBus.emit('global-stream-state-changed', true);
}
handleStreamEnded(data: { nodeId: string }): void {
this.activeStreamControllers.delete(data.nodeId);
if (this.activeStreamControllers.size === 0) {
this.eventBus.emit('global-stream-state-changed', false);
}
}
}
// ----------------------------------------------- Sample Setting Tab ---------------------------------------------------------------
class SampleSettingTab extends PluginSettingTab {
plugin: LLMAgentPlugin;
activeTabId: string = 'basic';
constructor(app: App, plugin: LLMAgentPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
// Навигация (Табы)
const tabHeader = containerEl.createDiv({ cls: 'settings-tab-header' });
tabHeader.style.cssText = 'display: flex; gap: 10px; margin-bottom: 20px; border-bottom: 1px solid var(--background-modifier-border); padding-bottom: 10px;';
const tabs = [
{ id: 'basic', label: '⚙️ Основные' },
{ id: 'agency', label: '🤖 Агент (ReAct Tools)' },
{ id: 'mcp', label: '🔌 MCP Серверы' },
{ id: 'voice', label: '🎤 Голос (Vosk)' }
];
const contentContainer = containerEl.createDiv({ cls: 'settings-tab-content' });
const renderTab = (id: string) => {
contentContainer.empty();
// Подсветка активной кнопки
Array.from(tabHeader.children).forEach((child: HTMLElement) => {
if (child.dataset.tabId === id) {
child.style.backgroundColor = 'var(--interactive-accent)';
child.style.color = 'var(--text-on-accent)';
} else {
child.style.backgroundColor = 'var(--background-secondary)';
child.style.color = 'var(--text-normal)';
}
});
if (id === 'basic') this.renderBasicSettings(contentContainer);
if (id === 'agency') this.renderAgencySettings(contentContainer);
if (id === 'mcp') this.renderMCPSettings(contentContainer);
if (id === 'voice') this.renderVoiceSettings(contentContainer);
};
tabs.forEach(tab => {
const btn = tabHeader.createEl('button', { text: tab.label });
btn.dataset.tabId = tab.id;
btn.style.cssText = 'border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer; transition: 0.2s;';
btn.onclick = () => {
this.activeTabId = tab.id;
renderTab(this.activeTabId);
};
});
renderTab(this.activeTabId);
}
renderBasicSettings(container: HTMLElement) {
container.createEl('h3', { text: 'Глобальные параметры' });
new Setting(container)
.setName('API Base URL')
.setDesc('Базовый URL для API LLM Agent')
.addText((text) =>
text
.setPlaceholder('http://localhost:5000/api')
.setValue(this.plugin.settings.apiBaseUrl)
.onChange(async (value) => {
this.plugin.settings.apiBaseUrl = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Модель по умолчанию')
.setDesc('Выберите модель для основного чата.')
.addDropdown((dropdown) => {
this.plugin.availableModels.forEach(model => dropdown.addOption(model, model));
dropdown.setValue(this.plugin.settings.defaultModel)
.onChange(async (value) => {
this.plugin.settings.defaultModel = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
});
});
new Setting(container)
.setName('Модель суммаризации')
.setDesc('Выберите модель для суммаризации.')
.addDropdown((dropdown) => {
this.plugin.availableModels.forEach(model => dropdown.addOption(model, model));
dropdown.setValue(this.plugin.settings.summarizationModel)
.onChange(async (value) => {
this.plugin.settings.summarizationModel = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
});
});
new Setting(container)
.setName('Температура чата по умолчанию')
.setDesc(`Значение от 0.0 до 2.0 (по умолчанию ${LLM_DEFAULTS.TEMPERATURE}). Влияет на креативность.`)
.addText((text) =>
text
.setPlaceholder(String(LLM_DEFAULTS.TEMPERATURE))
.setValue(String(this.plugin.settings.temperatureChat))
.onChange(async (value) => {
const num = parseFloat(value);
this.plugin.settings.temperatureChat = isNaN(num) ? LLM_DEFAULTS.TEMPERATURE : num;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Макс. токенов чата по умолчанию')
.setDesc('Оставьте пустым для отсутствия лимита.')
.addText((text) =>
text
.setPlaceholder('Лимит')
.setValue(this.plugin.settings.maxTokensChatDefault)
.onChange(async (value) => {
this.plugin.settings.maxTokensChatDefault = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
// Этот промпт будет отправляться LLM перед каждым диалогом
this.addPromptAutocomplete(new Setting(container).setName('Глобальный системный промпт'), 'systemPrompt');
container.createEl('h3', { text: 'Управление ссылками и файлами' });
new Setting(container)
.setName('Папка с промптами')
.setDesc('Путь к папке, содержащей промпты для команд #/##')
.addText((text) =>
text
.setPlaceholder('prompts')
.setValue(this.plugin.settings.promptsFolder)
.onChange(async (value) => {
this.plugin.settings.promptsFolder = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Папка кеша для вложений')
.setDesc('Абсолютный или относительный путь к папке для кеширования файлов')
.addText((text) =>
text
.setPlaceholder('ref-cache')
.setValue(this.plugin.settings.refCacheFolder)
.onChange(async (value) => {
this.plugin.settings.refCacheFolder = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
// Настройки ссылок на файлы
new Setting(container)
.setName('Включить ссылки на файлы')
.setDesc('Разрешить использование @/@@ ссылок на файлы в хранилище')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => {
this.plugin.settings.enableFileReferences = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
// Настройки ссылок на промпты
new Setting(container)
.setName('Включить ссылки на промпты')
.setDesc('Разрешить использование #/## ссылок на промпты')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.enablePromptReferences).onChange(async (value) => {
this.plugin.settings.enablePromptReferences = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
// Новое поле для настройки исключенных папок
new Setting(container)
.setName('Исключенные папки для ссылок')
.setDesc('Список относительных путей к папкам, файлы из которых не будут предлагаться в автодополнении ссылок (@/@@).')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => {
this.plugin.settings.enableFileReferences = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
// Динамический список исключенных папок
container.createEl('h3', { text: 'Исключенные папки' });
this.plugin.settings.excludedFolders.forEach((folder, index) => {
new Setting(container)
.setClass('llm-agent-excluded-folder-item') // Добавляем класс для стилизации
.addText((text) => {
text.setPlaceholder('Путь к папке (например, templates/)' + index)
.setValue(folder)
.onChange(async (value) => {
this.plugin.settings.excludedFolders[index] = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
// Очищаем кэш автокомплита при изменении настроек
this.plugin.eventBus.emit('settings-changed');
});
})
.addExtraButton((button) => {
button
.setIcon('x')
.setTooltip('Удалить папку')
.onClick(async () => {
this.plugin.settings.excludedFolders.splice(index, 1);
await this.plugin.saveSettings();
await this.plugin.syncSettings();
this.display(); // Перерисовать настройки для обновления списка
// Очищаем кэш автокомплита при изменении настроек
this.plugin.eventBus.emit('settings-changed');
});
});
});
new Setting(container).addButton((button) => {
button
.setButtonText('Добавить папку')
.setIcon('plus')
.onClick(async () => {
this.plugin.settings.excludedFolders.push(''); // Добавляем пустое поле
await this.plugin.saveSettings();
await this.plugin.syncSettings();
this.display(); // Перерисовать настройки для обновления списка
});
});
}
renderAgencySettings(container: HTMLElement) {
container.createEl('h3', { text: 'Фреймворк Инструментов (ReAct Engine)' });
new Setting(container)
.setName('Агентность включена по умолчанию')
.setDesc('Разрешает ИИ вызывать ваши питоновские скрипты по мере необходимости')
.addToggle((t) => t.setValue(this.plugin.settings.agencyModeEnabled).onChange(async (v) => {
this.plugin.settings.agencyModeEnabled = v;
await this.plugin.saveSettings();
}));
new Setting(container)
.setName('Вывод отладочной информации')
.setDesc('Выводить JSON-запросы и результаты работы инструментов после ответа агента.')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.showDebugInfo).onChange(async (value) => {
this.plugin.settings.showDebugInfo = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
container.createEl('h4', { text: 'Кастомные инструменты (Mini-Agents)', cls: 'agency-tools-header' });
if (!this.plugin.settings.customTools) this.plugin.settings.customTools = [];
this.plugin.settings.customTools.forEach((tool, index) => {
const toolDiv = 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(toolDiv)
.setName(`Название (только англ.)`)
.addText(t => t.setValue(tool.name).onChange(async v => {
tool.name = v.replace(/[^a-zA-Z0-9_]/g, '');
await this.plugin.saveSettings();
}))
.addExtraButton(b => b.setIcon('trash').setTooltip('Удалить этот инструмент').onClick(async () => {
this.plugin.settings.customTools.splice(index, 1);
await this.plugin.saveSettings();
this.display(); // Полностью перерисовать
}));
new Setting(toolDiv)
.setName('Описание для ИИ (Когда применять?)')
.addTextArea(t => {
t.inputEl.style.width = '100%';
t.setValue(tool.description).onChange(async v => {
tool.description = v;
await this.plugin.saveSettings();
});
});
const scriptRow = toolDiv.createDiv({ attr: { style: 'display: flex; gap: 10px; align-items: flex-end;' } });
const scriptSetting = new Setting(scriptRow)
.setName('Скрипт')
.setDesc('Путь к .py (используйте @)');
scriptSetting.settingEl.style.flex = '3';
scriptSetting.settingEl.style.border = 'none';
this.addPromptAutocompleteStrict(scriptSetting, tool, 'scriptPath', 'py');
const methodSetting = new Setting(scriptRow)
.setName('Метод')
.setDesc('Имя функции')
.addText(t => t
.setPlaceholder('') // 'main'
.setValue(tool.methodName || '') // 'main'
.onChange(async v => {
tool.methodName = v.trim();
await this.plugin.saveSettings();
})
);
methodSetting.settingEl.style.flex = '1';
methodSetting.settingEl.style.border = 'none';
methodSetting.infoEl.style.display = 'none'; // Скроем описание метода для компактности
const formatSetting = new Setting(toolDiv)
.setName('Форматирующий промпт (Пост-процессинг)')
.setDesc('Стиль ответа, прикладываемый к результату работы скрипта (Используйте #)');
this.addPromptAutocompleteStrict(formatSetting, tool, 'hiddenPrompt');
});
new Setting(container).addButton(b => b.setButtonText(' Добавить новый инструмент').setCta().onClick(async () => {
this.plugin.settings.customTools.push({ id: uuidv4(), name: 'New_Tool', description: '', scriptPath: '', methodName: '', hiddenPrompt: '' }); // methodName: 'main'
await this.plugin.saveSettings();
this.display();
}));
}
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) => {
if (server.isEnabled === undefined) server.isEnabled = true;
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); transition: opacity 0.3s; opacity: ${server.isEnabled ? '1' : '0.5'}` }
});
new Setting(serverDiv)
.setName(`Имя сервера`)
.addToggle(tg => tg
.setTooltip(server.isEnabled ? 'Выключить сервер' : 'Включить сервер')
.setValue(server.isEnabled!)
.onChange(async v => {
server.isEnabled = v;
serverDiv.style.opacity = v ? '1' : '0.5';
tg.setTooltip(v ? 'Выключить сервер' : 'Включить сервер');
await this.plugin.saveSettings();
await this.plugin.syncSettings(); // Мгновенно синхроним с бэкендом
})
)
.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(serverDiv)
.setName('Общая инструкция для сервера (Над-инструкция)')
.setDesc('Эти инструкции будут добавлены в системный промпт при использовании графа.')
.addTextArea(t => {
t.inputEl.style.width = '100%';
t.inputEl.style.height = '60px';
t.setValue(server.serverInstruction || '').onChange(async v => {
server.serverInstruction = v;
await this.plugin.saveSettings();
});
});
this.createFetchedTools(server, serverDiv);
});
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: '', isEnabled: true });
await this.plugin.saveSettings();
this.display();
}));
}
createFetchedTools(server: MCPServerConfig, serverDiv: HTMLDivElement) {
const toolsContainer = serverDiv.createDiv({ cls: 'mcp-tools-container', attr: { style: 'margin-top: 15px; border-top: 1px solid var(--background-modifier-border); padding-top: 10px;' } });
const fetchBtn = toolsContainer.createEl('button', { text: '🔄 Подгрузить/Обновить инструменты' });
fetchBtn.style.marginBottom = '10px';
fetchBtn.onclick = async () => {
fetchBtn.textContent = '⏳ Подгрузка...';
fetchBtn.disabled = true;
try {
const response = await fetch(`${this.plugin.settings.apiBaseUrl}/mcp/fetch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(server)
});
if (!response.ok) throw new Error("Ошибка HTTP: " + response.status);
const remoteTools: {name: string, description: string}[] = await response.json();
// Логика синхронизации: оставляем кастомные описания, удаляем пропавшие, добавляем новые
if (!server.fetchedTools) server.fetchedTools = [];
const existingMap = new Map(server.fetchedTools.map(t => [t.name, t]));
server.fetchedTools = remoteTools.map(rt => {
if (existingMap.has(rt.name)) {
const existing = existingMap.get(rt.name)!;
return { ...existing, originalDescription: rt.description }; // обновляем оригинальное описание на всякий случай
}
return { name: rt.name, originalDescription: rt.description, customDescription: '', isEnabled: true };
});
await this.plugin.saveSettings();
new Notice(`Загружено инструментов: ${remoteTools.length}`);
this.display(); // Перерисовываем UI
} catch (e) {
console.error(e);
new Notice('Ошибка подгрузки инструментов: ' + e);
} finally {
fetchBtn.textContent = '🔄 Подгрузить/Обновить инструменты';
fetchBtn.disabled = false;
}
};
// Отрисовка списка тулов
if (server.fetchedTools && server.fetchedTools.length > 0) {
server.fetchedTools.forEach((tool, tIndex) => {
if (tool.isEnabled === undefined) tool.isEnabled = true;
const toolDiv = toolsContainer.createDiv({
attr: { style: `margin-bottom: 10px; padding: 10px; background: var(--background-primary); border-radius: 6px; transition: opacity 0.3s; opacity: ${tool.isEnabled ? '1' : '0.5'}` }
});
// Заголовок тула с переключателем
const headerDiv = toolDiv.createDiv({ attr: { style: 'display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 5px;' } });
headerDiv.createEl('div', { text: `🛠 ${tool.name}`, attr: { style: 'font-weight: bold; margin-top: 5px;' } });
const toggleSetting = new Setting(headerDiv)
.addToggle(tg => tg
.setTooltip(tool.isEnabled ? 'Выключить инструмент' : 'Включить инструмент')
.setValue(tool.isEnabled!)
.onChange(async v => {
tool.isEnabled = v;
toolDiv.style.opacity = v ? '1' : '0.5';
tg.setTooltip(v ? 'Выключить инструмент' : 'Включить инструмент');
await this.plugin.saveSettings();
await this.plugin.syncSettings(); // Мгновенно синхроним с бэкендом
})
);
// Убираем лишние рамки и отступы у элемента Setting для компактности в шапке
toggleSetting.settingEl.style.border = 'none';
toggleSetting.settingEl.style.padding = '0';
toggleSetting.infoEl.remove();
// Описание
toolDiv.createEl('div', { text: tool.originalDescription, attr: { style: 'font-size: 0.85em; color: var(--text-muted); margin-bottom: 8px;' } });
new Setting(toolDiv)
.setName('Дополнительные инструкции')
.setDesc('Этот текст будет добавлен к оригинальному описанию инструмента. Оставьте пустым, если не требуется.')
.addTextArea(t => {
t.inputEl.style.width = '100%';
t.setValue(tool.customDescription).onChange(async v => {
tool.customDescription = v;
await this.plugin.saveSettings();
});
});
});
}
}
renderVoiceSettings(container: HTMLElement) {
container.createEl('h3', { text: 'Транскрибация Vosk' });
new Setting(container)
.setName('Отображать голосовую панель')
.setDesc('Показывать нижнюю панель Voice Service в графе диалогов.')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.showVoicePanel).onChange(async (value) => {
this.plugin.settings.showVoicePanel = value;
await this.plugin.saveSettings();
// Событие 'settings-changed' уже вызывается внутри saveSettings,
// поэтому панель будет скрываться/появляться мгновенно
})
);
new Setting(container)
.setName('Путь к модели Vosk')
.setDesc('Абсолютный путь к папке с распакованной языковой моделью (например: C:\\vosk-models\\vosk-model-small-ru)')
.addText((text) =>
text
.setPlaceholder('Путь к модели на диске')
.setValue(this.plugin.settings.voskModelPath)
.onChange(async (value) => {
this.plugin.settings.voskModelPath = value.replace(/"/g, '').trim(); // Убираем кавычки, если скопировали путь как путь
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Модель команд')
.setDesc('LLM для обработки голосовых команд.')
.addDropdown((dropdown) => {
this.plugin.availableModels.forEach(model => dropdown.addOption(model, model));
dropdown.setValue(this.plugin.settings.voiceCommandModel)
.onChange(async (value) => {
this.plugin.settings.voiceCommandModel = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
});
});
new Setting(container)
.setName('Температура Voice')
.setDesc(`Температура для голосовых запросов (по умолчанию ${LLM_DEFAULTS.TEMPERATURE})`)
.addText((text) =>
text
.setPlaceholder(String(LLM_DEFAULTS.TEMPERATURE))
.setValue(String(this.plugin.settings.temperatureVoice))
.onChange(async (value) => {
const num = parseFloat(value);
this.plugin.settings.temperatureVoice = isNaN(num) ? LLM_DEFAULTS.TEMPERATURE : num;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Макс. токенов Voice (Регулярный)')
.setDesc('Лимит токенов для регулярного анализа. Оставьте пустым для безлимита.')
.addText((text) =>
text
.setPlaceholder('Лимит')
.setValue(this.plugin.settings.maxTokensVoiceRegular)
.onChange(async (value) => {
this.plugin.settings.maxTokensVoiceRegular = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Макс. токенов Voice (Команды)')
.setDesc('Лимит токенов для голосовых команд. Оставьте пустым для безлимита.')
.addText((text) =>
text
.setPlaceholder('Лимит')
.setValue(this.plugin.settings.maxTokensVoiceCommands)
.onChange(async (value) => {
this.plugin.settings.maxTokensVoiceCommands = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Действие диктовки')
.setDesc('При нажатии записи в чате, если голос не запущен, как его стартовать?')
.addDropdown((dropdown) => {
dropdown.addOption('start', 'Начать заново (Start New)');
dropdown.addOption('continue', 'Продолжить (Continue)');
dropdown.setValue(this.plugin.settings.dictationStartAction)
.onChange(async (value: 'start' | 'continue') => {
this.plugin.settings.dictationStartAction = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
});
});
new Setting(container)
.setName('Папка аудио-логов')
.setDesc('Папка внутри Obsidian, куда будут сохраняться текстовые расшифровки (в формате .md). Относительно корня хранилища.')
.addText((text) =>
text
.setPlaceholder('audio-logs')
.setValue(this.plugin.settings.logsFolder)
.onChange(async (value) => {
// Убираем лишние слэши для корректности пути
this.plugin.settings.logsFolder = value.replace(/^\/+|\/+$/g, '').trim();
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Объем контекста (символы)')
.setDesc('Сколько последних символов транскрибации отправлять в модель.')
.addText((text) =>
text
.setValue(String(this.plugin.settings.voiceContextLimit))
.onChange(async (value) => {
this.plugin.settings.voiceContextLimit = Number(value);
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Маркер начала команды')
.setDesc('Слово, после которого текст считается командой (Response 2).')
.addText((text) =>
text
.setPlaceholder('старт')
.setValue(this.plugin.settings.markerStart)
.onChange(async (value) => {
this.plugin.settings.markerStart = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Маркер конца команды')
.setDesc('Слово, завершающее захват голосовой команды.')
.addText((text) =>
text
.setPlaceholder('стоп')
.setValue(this.plugin.settings.markerStop)
.onChange(async (value) => {
this.plugin.settings.markerStop = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
container.createEl('h3', { text: 'Интервалы и Аналитика' });
new Setting(container)
.setName('Интервал регулярного анализа (мин)')
.setDesc('Как часто LLM анализирует последние фразы (Prompt 1).')
.addText((text) =>
text
.setValue(String(this.plugin.settings.voiceInterval))
.onChange(async (value) => {
this.plugin.settings.voiceInterval = Number(value);
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
new Setting(container)
.setName('Интервал аналитики (мин)')
.setDesc('Как часто Python и LLM генерируют графики и качественный анализ (Таб 4).')
.addText((text) =>
text
.setValue(String(this.plugin.settings.metricsInterval))
.onChange(async (value) => {
this.plugin.settings.metricsInterval = Number(value) || 15;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
container.createEl('h3', { text: 'Промпты голоса' });
this.addPromptAutocomplete(new Setting(container).setName('Регулярный промпт'), 'promptRegular');
this.addPromptAutocomplete(new Setting(container).setName('Промпт команд (Marker)'), 'promptCommands');
this.addPromptAutocomplete(new Setting(container).setName('Промпт Качественной Аналитики'), 'promptMetrics');
}
private addPromptAutocompleteStrict(setting: Setting, obj: any, key: string, filterExtension?: string) {
const manager = new AutocompleteManager(this.plugin);
setting.addText((text) => {
const el = text.inputEl;
const autocomplete = new ReferenceAutocomplete(el, manager);
autocomplete.onItemSelected(async (item, match) => {
const val = el.value;
const before = val.substring(0, match.startIndex);
const after = val.substring(match.endIndex);
// ИЗМЕНЕНО: Если это файл (скрипт), берем ВЕСЬ ПУТЬ к нему, а не просто Имя.
// Это позволяет бэкенду выполнять скрипты из подпапок (напр. scripts/my_script.py)
const textToUse = item.type === 'file' ? item.path : item.name;
const nameToInsert = textToUse.includes(' ') ? `"${textToUse}"` : textToUse;
const prefix = item.type === 'file' ? '@' : '#';
const newValue = before + prefix + nameToInsert + after;
el.value = newValue;
obj[key] = newValue;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
autocomplete.hideAutocomplete();
});
text.setValue(String(obj[key])).onChange(async (value) => {
obj[key] = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
const cursor = el.selectionStart;
const ref = ReferenceParser.getCurrentReference(el.value, cursor);
if (ref) {
const rect = el.getBoundingClientRect();
// ИЗМЕНЕНО: Отправляем фильтр расширения (если он был задан)
autocomplete.showAutocomplete(ref, rect, filterExtension);
} else {
autocomplete.hideAutocomplete();
}
});
el.addEventListener('keydown', (e) => { if (autocomplete.isVisible() && autocomplete.handleKeydown(e)) { e.stopPropagation(); } });
});
}
private addPromptAutocomplete(setting: Setting, settingKey: keyof LLMAgentSettings) {
const manager = new AutocompleteManager(this.plugin);
setting.addTextArea((text) => {
const el = text.inputEl;
const autocomplete = new ReferenceAutocomplete(el, manager);
autocomplete.onItemSelected(async (item, match) => {
const val = el.value;
const before = val.substring(0, match.startIndex);
const after = val.substring(match.endIndex);
// Если в имени есть пробел — оборачиваем в кавычки
const nameToInsert = item.name.includes(' ') ? `"${item.name}"` : item.name;
const newValue = before + "#" + nameToInsert + after;
// Обновляем визуально в поле
el.value = newValue;
// Обновляем в конфиге (используем приведение типа, чтобы TS не ругался)
(this.plugin.settings as any)[settingKey] = newValue;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
autocomplete.hideAutocomplete();
});
text.setPlaceholder('Впишите текст или начните с # для выбора промпта')
.setValue(String(this.plugin.settings[settingKey]))
.onChange(async (value) => {
// Прямое присваивание через приведение к any для обхода строгой проверки типов
(this.plugin.settings as any)[settingKey] = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
const cursor = el.selectionStart;
const textValue = el.value;
const ref = ReferenceParser.getCurrentReference(textValue, cursor);
if (ref && ref.type === 'prompt') {
// Используем getBoundingClientRect для позиционирования меню под полем ввода
const rect = el.getBoundingClientRect();
autocomplete.showAutocomplete(ref, rect);
} else {
autocomplete.hideAutocomplete();
}
});
el.addEventListener('keydown', (e) => {
if (autocomplete.isVisible()) {
if (autocomplete.handleKeydown(e)) {
e.stopPropagation();
}
}
});
});
}
}