diff --git a/main.ts b/main.ts index 2e2226e..6940663 100644 --- a/main.ts +++ b/main.ts @@ -17,6 +17,15 @@ interface LLMAgentSettings { enablePromptReferences: boolean; excludedFolders: string[]; refCacheFolder: string; + + voiceModel: string; + voiceInterval: number; + voiceDisplayLimit: number; // 3000 + voiceContextLimit: number; // N2 символов для LLM + promptRegularPath: string; + promptCommandsPath: string; + markerStart: string; // "старт" + markerStop: string; // "стоп" } const DEFAULT_SETTINGS: LLMAgentSettings = { @@ -27,7 +36,16 @@ const DEFAULT_SETTINGS: LLMAgentSettings = { enableFileReferences: true, enablePromptReferences: true, excludedFolders: ['.obsidian', 'llm-agent-backend', 'templates', 'ref-cache'], // Папка "templates" по умолчанию исключена - refCacheFolder: 'ref-cache' // По умолчанию относительно vault + refCacheFolder: 'ref-cache', // По умолчанию относительно vault + + voiceModel: '', + voiceInterval: 1, + voiceDisplayLimit: 3000, + voiceContextLimit: 3000, + promptRegularPath: '', + promptCommandsPath: '', + markerStart: 'старт', + markerStop: 'стоп' }; // ----------------------------------------------- Main Plugin Class --------------------------------------------------------------- @@ -38,6 +56,30 @@ export default class LLMAgentPlugin extends Plugin { webSocketService: WebSocketService; activeStreamControllers: Map; + async syncVoiceSettings() { + try { + const response = await fetch(`${this.settings.apiBaseUrl}/settings/sync`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + systemPrompt: this.settings.systemPrompt, + interval: this.settings.voiceInterval, + context_limit: this.settings.voiceContextLimit, + marker_start: this.settings.markerStart, + marker_stop: this.settings.markerStop, + // Передаем также пути к файлам промптов, если нужно + prompt_regular: this.settings.promptRegularPath, + prompt_commands: this.settings.promptCommandsPath + }) + }); + if (response.ok) { + console.log("LLM Agent: Voice settings synced with backend."); + } + } catch (e) { + console.error("LLM Agent: Failed to sync settings", e); + } + } + async onload() { await this.loadSettings(); @@ -84,6 +126,10 @@ export default class LLMAgentPlugin extends Plugin { // This adds a settings tab so the user can configure various aspects of the plugin this.addSettingTab(new SampleSettingTab(this.app, this)); + + + // Вызываем один раз при запуске + await this.syncVoiceSettings(); } onunload() { @@ -165,15 +211,16 @@ class SampleSettingTab extends PluginSettingTab { display(): void { const { containerEl } = this; - containerEl.empty(); + containerEl.createEl('h2', { text: 'Основные настройки' }); + new Setting(containerEl) .setName('API Base URL') .setDesc('Базовый URL для API LLM Agent') .addText((text) => text - .setPlaceholder('Введите URL') + .setPlaceholder('http://localhost:5000/api') .setValue(this.plugin.settings.apiBaseUrl) .onChange(async (value) => { this.plugin.settings.apiBaseUrl = value; @@ -181,7 +228,6 @@ class SampleSettingTab extends PluginSettingTab { }) ); - // Новое поле для системного промпта new Setting(containerEl) .setName('Системный промпт') .setDesc('Этот промпт будет отправляться LLM перед каждым диалогом.') @@ -195,7 +241,92 @@ class SampleSettingTab extends PluginSettingTab { }) ); - // Новое поле для папки промптов + containerEl.createEl('h2', { text: 'Голосовое управление (Voice Service)' }); + + 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.syncVoiceSettings(); + }) + ); + + 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.syncVoiceSettings(); + }) + ); + + 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.syncVoiceSettings(); + }) + ); + + 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.syncVoiceSettings(); + }) + ); + + new Setting(containerEl) + .setName('Регулярный промпт') + .setDesc('Инструкции для Промпта №1.') + .addTextArea((text) => + text + // .setPlaceholder('prompts/regular_analysis.md') + .setValue(this.plugin.settings.promptRegularPath) + .onChange(async (value) => { + this.plugin.settings.promptRegularPath = value; + await this.plugin.saveSettings(); + await this.plugin.syncVoiceSettings(); + }) + ); + + new Setting(containerEl) + .setName('Промпт команд') + .setDesc('Инструкции для Промпта №2.') + .addTextArea((text) => + text + // .setPlaceholder('prompts/voice_commands.md') + .setValue(this.plugin.settings.promptCommandsPath) + .onChange(async (value) => { + this.plugin.settings.promptCommandsPath = value; + await this.plugin.saveSettings(); + await this.plugin.syncVoiceSettings(); + }) + ); + + containerEl.createEl('h2', { text: 'Управление ссылками и файлами' }); + new Setting(containerEl) .setName('Папка с промптами') .setDesc('Путь к папке, содержащей промпты для команд #/##') @@ -251,11 +382,12 @@ class SampleSettingTab extends PluginSettingTab { .addToggle((toggle) => toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => { this.plugin.settings.enableFileReferences = value; - await this.plugin.saveSettings(); - }) + await this.plugin.saveSettings(); + }) ); // Динамический список исключенных папок + containerEl.createEl('h3', { text: 'Исключенные папки' }); this.plugin.settings.excludedFolders.forEach((folder, index) => { new Setting(containerEl) .setClass('llm-agent-excluded-folder-item') // Добавляем класс для стилизации @@ -263,11 +395,11 @@ class SampleSettingTab extends PluginSettingTab { text.setPlaceholder('Путь к папке (например, templates/)' + index) .setValue(folder) .onChange(async (value) => { - this.plugin.settings.excludedFolders[index] = value; - await this.plugin.saveSettings(); + this.plugin.settings.excludedFolders[index] = value; + await this.plugin.saveSettings(); // Очищаем кэш автокомплита при изменении настроек this.plugin.eventBus.emit('settings-changed'); - }); + }); }) .addExtraButton((button) => { button diff --git a/src/components/VoicePanel.ts b/src/components/VoicePanel.ts new file mode 100644 index 0000000..61c79d3 --- /dev/null +++ b/src/components/VoicePanel.ts @@ -0,0 +1,71 @@ +import LLMAgentPlugin from 'main'; + +export class VoicePanel { + private activeTab: 'res1' | 'res2' | 'trans' = 'res1'; + + constructor( + private container: HTMLElement, + private plugin: LLMAgentPlugin + ) { + this.render(); + this.startPolling(); + } + + render() { + this.container.innerHTML = ` +
+
+
+ + + +
+
+ + + +
+
+
+
+ `; + + // Навешиваем логику вкладок + this.container.querySelectorAll('.v-tab').forEach((tab) => { + tab.addEventListener('click', (e) => { + this.container.querySelectorAll('.v-tab').forEach((t) => t.classList.remove('active')); + (e.target as HTMLElement).classList.add('active'); + this.activeTab = (e.target as HTMLElement).dataset.tab as any; + }); + }); + + // Навешиваем управление через API + const control = async (action: string) => { + await fetch(`${this.plugin.settings.apiBaseUrl}/voice/control`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }) + }); + }; + + this.container.querySelector('#v-new')?.addEventListener('click', () => control('start')); + this.container.querySelector('#v-cont')?.addEventListener('click', () => control('continue')); + this.container.querySelector('#v-stop')?.addEventListener('click', () => control('stop')); + } + + async startPolling() { + setInterval(async () => { + const res = await fetch(`${this.plugin.settings.apiBaseUrl}/voice/status`); + const data = await res.json(); + this.updateContent(data); + }, 3000); + } + + updateContent(data: any) { + const body = this.container.querySelector('#v-body'); + if (!body) return; + if (this.activeTab === 'trans') body.textContent = data.transcription; + if (this.activeTab === 'res1') body.textContent = data.response1; + if (this.activeTab === 'res2') body.innerHTML = data.response2.join('
---
'); + } +} diff --git a/src/css/styles.css b/src/css/styles.css index 129a654..d479525 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -740,9 +740,71 @@ .llm-agent-graph-main { display: flex; + flex-direction: column; height: 100%; - width: 100%; - /* background-color: var(--background-primary); */ +} + +.upper-content-wrapper { + flex: 1; + display: flex; + overflow: hidden; +} + +/* Голосовая панель */ +.voice-panel-fixed-bottom { + display: flex; + flex-direction: column; + border-top: 1px solid var(--background-modifier-border); +} + +.voice-panel-container { + display: flex; + flex-direction: column; + height: 100%; + padding: 5px; +} + +.voice-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 5px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.v-tabs { + display: flex; + gap: 2px; +} + +.v-tab { + padding: 4px 8px; + font-size: 11px; + cursor: pointer; + background: transparent; + border: none; + color: var(--text-muted); +} + +.v-tab.active { + color: var(--text-accent); + border-bottom: 2px solid var(--text-accent); +} + +.voice-body { + flex: 1; + overflow-y: auto; + font-size: 12px; + font-family: var(--font-monospace); + padding: 10px; + background-color: var(--background-primary); + white-space: pre-wrap; /* Чтобы транскрибация не уходила в одну строку */ +} + +/* Кнопки управления голосом */ +.v-controls button { + margin-right: 5px; + padding: 4px 10px; } .llm-agent-history-sidebar { diff --git a/src/views/GraphView.ts b/src/views/GraphView.ts index 194a54c..82058c2 100644 --- a/src/views/GraphView.ts +++ b/src/views/GraphView.ts @@ -14,6 +14,7 @@ import { HistoryPanel } from '../components/HistoryPanel'; // Предполаг import { GraphPanelComponent } from '../components/GraphPanel'; // Импортируем React-компонент и ReactDOM import { Dialog } from 'src/utils/Dialog'; import { GraphSettingsModal } from '../modals/GraphSettingsModal'; +import { VoicePanel } from '../components/VoicePanel'; export const GRAPH_VIEW_TYPE = 'llm-agent-graph-view'; @@ -90,60 +91,81 @@ export class GraphView extends ItemView { const container = this.containerEl.children[1] as HTMLElement; container.empty(); - // Создаем основной контейнер с flexbox layout + // 1. Основной контейнер - теперь вертикальный стек const mainContainer = container.createDiv('llm-agent-graph-main'); mainContainer.style.cssText = ` display: flex; + flex-direction: column; /* Элементы кладутся друг под друга */ height: 100%; width: 100%; - background-color: var(--background-primary); + overflow: hidden; `; - // Левая панель для истории - this.historyContainer = mainContainer.createDiv('llm-agent-history-sidebar') as HTMLDivElement; + // 2. Верхняя часть (История + Граф) + const upperContent = mainContainer.createDiv('upper-content-wrapper'); + upperContent.style.cssText = ` + display: flex; + flex-direction: row; /* Элементы бок о бок */ + flex: 1; /* Занимает всё свободное место (около 75-80%) */ + width: 100%; + min-height: 0; /* Важно для корректного скролла внутри */ + `; + + // 3. Левая панель для истории (внутри верхней части) + this.historyContainer = upperContent.createDiv('llm-agent-history-sidebar'); this.historyContainer.classList.add(this.isHistoryCollapsed ? 'collapsed' : 'expanded'); - // Правая панель для графа - this.graphContainer = mainContainer.createDiv('llm-agent-graph-container') as HTMLDivElement; + // 4. Правая панель для графа (внутри верхней части) + this.graphContainer = upperContent.createDiv('llm-agent-graph-container'); this.graphContainer.style.cssText = ` flex: 1; position: relative; background-color: var(--background-primary); `; - // Инициализируем панель истории + // 5. Контейнер для голосовой панели (строго ВНИЗУ) + const voiceContainer = mainContainer.createDiv('voice-panel-fixed-bottom'); + voiceContainer.style.cssText = ` + height: 200px; /* Фиксированная высота или % */ + width: 100%; + border-top: 1px solid var(--divider-color); + background-color: var(--background-secondary); + overflow: hidden; + flex-shrink: 0; /* Не дает панели сжиматься */ + `; + + // Инициализация компонентов this.historyPanel = new HistoryPanel( this.historyContainer, - { - graphs: this.graphsList, - eventBus: this.plugin.eventBus - }, + { graphs: this.graphsList, eventBus: this.plugin.eventBus }, this.plugin ); - // Создаем кнопки переключения и глобальные кнопки действий this.addToggleButton(); - this.addGlobalActionButtons(); // Добавляем вызов новой функции - - // Инициализируем React Flow + this.addGlobalActionButtons(); this.initializeGraphPanel(); - // Подписываемся на события - this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this)); - this.plugin.eventBus.on('graph-selected', this.handleGraphSelected.bind(this)); - this.plugin.eventBus.on('delete-node', this.handleDeleteNodeFromChat.bind(this)); // Слушаем событие из ChatPanel + // Инициализируем VoicePanel + new VoicePanel(voiceContainer, this.plugin); - // WebSocket Event Handlers + // Подписки на события + this.setupEventListeners(); + + // Загружаем данные + await this.fetchGraphsList(); + } + + // Вынес для чистоты + private setupEventListeners() { + this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this)); + this.plugin.eventBus.on('graph-selected', this.handleGraphSelected.bind(this)); + this.plugin.eventBus.on('delete-node', this.handleDeleteNodeFromChat.bind(this)); this.plugin.eventBus.on('ws-graph-title-updated', this.handleGraphTitleUpdated.bind(this)); this.plugin.eventBus.on('ws-node-title-updated', this.handleNodeTitleUpdated.bind(this)); this.plugin.eventBus.on('ws-node-type-changed', this.handleNodeTypeChanged.bind(this)); - this.plugin.eventBus.on('stream-started', this.handleStreamStarted.bind(this)); this.plugin.eventBus.on('stream-ended', this.handleStreamEnded.bind(this)); - - // Загружаем список графов - await this.fetchGraphsList(); - } + } /** * Вызывается при закрытии представления. Очищает ресурсы.