llm-agent-plugin/main.ts
2026-05-16 18:55:53 +03:00

709 lines
26 KiB
TypeScript
Raw 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 'src/css/styles.css';
import { LLM_DEFAULTS } from 'src/constants';
// ----------------------------------------------- Settings ---------------------------------------------------------------
interface LLMAgentSettings {
apiBaseUrl: string;
systemPrompt: string;
defaultModel: string;
promptsFolder: string;
enableFileReferences: boolean;
enablePromptReferences: boolean;
excludedFolders: string[];
refCacheFolder: string;
temperatureChat: number;
maxTokensChatDefault: string;
summarizationModel: string;
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;
showVoicePanel: boolean;
lastActiveGraphId: string | null;
lastActiveNodeId: string | null;
}
const DEFAULT_SETTINGS: LLMAgentSettings = {
apiBaseUrl: 'http://localhost:5000/api',
systemPrompt: 'Ты полезный ИИ ассистент.',
defaultModel: LLM_DEFAULTS.MODEL,
promptsFolder: 'prompts',
enableFileReferences: true,
enablePromptReferences: true,
excludedFolders: ['.obsidian', 'llm-agent-backend', 'templates', 'ref-cache'],
refCacheFolder: 'ref-cache',
temperatureChat: LLM_DEFAULTS.TEMPERATURE,
maxTokensChatDefault: '',
summarizationModel: '',
voiceCommandModel: '',
logsFolder: 'audio-logs',
voiceInterval: 1,
metricsInterval: 15,
voiceDisplayLimit: 30000,
voiceContextLimit: 30000,
temperatureVoice: LLM_DEFAULTS.TEMPERATURE,
maxTokensVoiceRegular: '',
maxTokensVoiceCommands: '',
promptRegular: '',
promptCommands: '',
promptMetrics: '',
markerStart: 'старт',
markerStop: 'стоп',
showVoicePanel: false,
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[];
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);
const adapter = this.app.vault.adapter;
if (adapter instanceof FileSystemAdapter) {
// Соединяем базовый путь хранилища и относительный путь к логам
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 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();
// Регистрируем кастомные 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 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);
}
handleStreamEnded(data: { nodeId: string }): void {
this.activeStreamControllers.delete(data.nodeId);
}
}
// ----------------------------------------------- Sample Setting Tab ---------------------------------------------------------------
class SampleSettingTab extends PluginSettingTab {
plugin: LLMAgentPlugin;
constructor(app: App, plugin: LLMAgentPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Основные настройки' });
new Setting(containerEl)
.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(containerEl)
.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();
});
});
// Этот промпт будет отправляться LLM перед каждым диалогом
this.addPromptAutocomplete(new Setting(containerEl).setName('Системный промпт'), 'systemPrompt');
new Setting(containerEl)
.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(containerEl)
.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();
})
);
new Setting(containerEl)
.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();
})
);
containerEl.createEl('h2', { text: 'Голосовое управление (Voice Service)' });
new Setting(containerEl)
.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(containerEl)
.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();
});
});
// Инструкции для Промпта №1.
this.addPromptAutocomplete(
new Setting(containerEl).setName('Регулярный промпт'),
'promptRegular'
);
// Инструкции для Промпта №2.
this.addPromptAutocomplete(
new Setting(containerEl).setName('Промпт команд'),
'promptCommands'
);
this.addPromptAutocomplete(
new Setting(containerEl).setName('Промпт качественной аналитики'),
'promptMetrics'
);
new Setting(containerEl)
.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(containerEl)
.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(containerEl)
.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(containerEl)
.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(containerEl)
.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();
})
);
new Setting(containerEl)
.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(containerEl)
.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(containerEl)
.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(containerEl)
.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();
})
);
containerEl.createEl('h2', { text: 'Управление ссылками и файлами' });
new Setting(containerEl)
.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(containerEl)
.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(containerEl)
.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(containerEl)
.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(containerEl)
.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();
})
);
// Динамический список исключенных папок
containerEl.createEl('h3', { text: 'Исключенные папки' });
this.plugin.settings.excludedFolders.forEach((folder, index) => {
new Setting(containerEl)
.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(containerEl).addButton((button) => {
button
.setButtonText('Добавить папку')
.setIcon('plus')
.onClick(async () => {
this.plugin.settings.excludedFolders.push(''); // Добавляем пустое поле
await this.plugin.saveSettings();
await this.plugin.syncSettings();
this.display(); // Перерисовать настройки для обновления списка
});
});
}
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();
}
}
});
});
}
}