From 205040a5990468875b62bdcca68d3f75905bc922 Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Sat, 23 May 2026 01:00:45 +0300 Subject: [PATCH] Add voice dictation to chat panel and global stop generation button. --- main.ts | 31 ++++++ src/components/ChatPanel.ts | 193 +++++++++++++++++++++++++++++++++++- src/css/styles.css | 76 +++++++++++++- src/views/ChatView.ts | 4 + 4 files changed, 300 insertions(+), 4 deletions(-) diff --git a/main.ts b/main.ts index 8857196..453940f 100644 --- a/main.ts +++ b/main.ts @@ -46,6 +46,8 @@ interface LLMAgentSettings { voskModelPath: string; showVoicePanel: boolean; + dictationStartAction: 'start' | 'continue'; + lastActiveGraphId: string | null; lastActiveNodeId: string | null; } @@ -82,6 +84,8 @@ const DEFAULT_SETTINGS: LLMAgentSettings = { voskModelPath: '', showVoicePanel: false, + dictationStartAction: 'start', + lastActiveGraphId: null, lastActiveNodeId: null, }; @@ -190,6 +194,15 @@ export default class LLMAgentPlugin extends Plugin { 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)); @@ -274,10 +287,14 @@ export default class LLMAgentPlugin extends Plugin { 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); + } } } @@ -482,6 +499,20 @@ class SampleSettingTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .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(containerEl) .setName('Папка аудио-логов') .setDesc('Папка внутри Obsidian, куда будут сохраняться текстовые расшифровки (в формате .md). Относительно корня хранилища.') diff --git a/src/components/ChatPanel.ts b/src/components/ChatPanel.ts index a904622..e26f2fb 100644 --- a/src/components/ChatPanel.ts +++ b/src/components/ChatPanel.ts @@ -53,6 +53,17 @@ export class ChatPanel { attachedFilesContainer: HTMLDivElement; currentAttachments: Attachment[] = []; + globalStopBtn: HTMLButtonElement; + dictationBtn: HTMLButtonElement; + + // Состояние диктовки + isDictating: boolean = false; + dictationPollTimer: number | null = null; + dictationBuffer: string = ""; + dictationSessionId: string = ""; + dictationLastLen: number = 0; + hasGlobalStreams: boolean = false; + // ----------------------------------------------- Reference System Fields --------------------------------------------------------------- cacheManager: CacheManager; @@ -90,6 +101,10 @@ export class ChatPanel { this.init(); } + updateSettings(newPluginInstance: LLMAgentPlugin) { + this.props.plugin = newPluginInstance; + } + updatePlaceholder(): void { const isEmpty = this.messageInput.textContent?.trim() === ''; const isFocused = document.activeElement === this.messageInput; @@ -566,6 +581,10 @@ export class ChatPanel { + +
@@ -610,6 +629,11 @@ export class ChatPanel { + + + @@ -638,6 +662,9 @@ export class ChatPanel { this.maxTokensInput = this.container.querySelector('#max-tokens-input') as HTMLInputElement; this.maxTokensResetBtn = this.container.querySelector('#max-tokens-reset') as HTMLButtonElement; + this.globalStopBtn = this.container.querySelector('#global-stop-stream-btn') as HTMLButtonElement; + this.dictationBtn = this.container.querySelector('#dictation-button') as HTMLButtonElement; + const defaultTemp = (this.props.plugin.settings as any).temperatureChat ?? LLM_DEFAULTS.TEMPERATURE; this.tempInput.value = defaultTemp.toString(); @@ -726,6 +753,19 @@ export class ChatPanel { this.tempInput.value = def.toString(); }); + // Под микроФон + this.dictationBtn.addEventListener('click', () => this.toggleDictation()); + // Под глобальный стоп + this.globalStopBtn.addEventListener('click', () => { + this.props.plugin.eventBus.emit('stop-all-streams'); + }); + + // Подписка на изменение стримов + this.props.plugin.eventBus.on('global-stream-state-changed', (isStreaming: boolean) => { + this.hasGlobalStreams = isStreaming; + this.updateScrollButtonsVisibility(); + }); + // Close dropdown when clicking outside document.addEventListener('click', (e) => { if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) { @@ -1127,7 +1167,7 @@ export class ChatPanel { // Обновить метод handleSend() для совместимости: handleSend(): void { const message = this.messageInput.textContent?.trim() || ''; - if (message && this.props.onSendMessage) { + if ((message || this.currentAttachments.length > 0) && this.props.onSendMessage) { const valTokens = this.maxTokensInput?.value; const parsedMaxTokens = valTokens ? parseInt(valTokens, 10) : undefined; @@ -1515,13 +1555,160 @@ export class ChatPanel { private updateScrollButtonsVisibility(): void { if (!this.cachedScrollContainer || !this.cachedBtnWrapper) return; - // DOM больше не сканируется через closest/querySelector, элементы закэшированы const hasScrollbar = this.cachedScrollContainer.scrollHeight > (this.cachedScrollContainer.clientHeight + 2); - if (hasScrollbar) { + // Управляем видимостью кнопок скролла (скрываем их индивидуально) + this.scrollToTopBtn.style.display = hasScrollbar ? 'flex' : 'none'; + this.scrollToBottomBtn.style.display = hasScrollbar ? 'flex' : 'none'; + this.globalStopBtn.style.display = this.hasGlobalStreams ? 'flex' : 'none'; + + // Обертка видна, если есть либо скроллбар, либо активный стрим + if (hasScrollbar || this.hasGlobalStreams) { this.cachedBtnWrapper.classList.add('is-visible'); } else { this.cachedBtnWrapper.classList.remove('is-visible'); } } + + private async toggleDictation() { + if (this.isDictating) { + await this.stopDictation(); + } else { + await this.startDictation(); + } + } + + // ----------------------------------------------- Dictation --------------------------------------------------------------- + + private async startDictation() { + this.isDictating = true; + this.dictationBtn.classList.add('pulsing'); + this.dictationBuffer = ""; + + try { + const action = this.props.plugin.settings.dictationStartAction || 'start'; + const stRes = await fetch(`${this.props.plugin.settings.apiBaseUrl}/voice/status`); + const stData = await stRes.json(); + + if (action === 'start') { + // Если требуется чистый старт, а запись уже идет — сначала жестко глушим её + if (stData.is_running) { + await fetch(`${this.props.plugin.settings.apiBaseUrl}/voice/control`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'stop' }) + }); + // Даем бэкенду полсекунды на корректное закрытие аудио-потоков + await new Promise(resolve => setTimeout(resolve, 500)); + } + + // Запускаем чистую сессию (создаст новый файл лога на бэкенде) + await fetch(`${this.props.plugin.settings.apiBaseUrl}/voice/control`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'start' }) + }); + new Notice('Новая сессия диктовки запущена', 2000); + + } else { + // Логика для 'continue' + if (!stData.is_running) { + // Запускаем и просим бэкенд продолжить писать в последний файл + await fetch(`${this.props.plugin.settings.apiBaseUrl}/voice/control`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'continue' }) + }); + new Notice('Служба диктовки возобновлена', 2000); + } + // Если уже is_running === true, ничего не отправляем, просто цепляемся к потоку + } + + // Все команды отправлены. Запрашиваем актуальный статус, + // чтобы получить ID нового/текущего файла и стартовую длину текста + const newStRes = await fetch(`${this.props.plugin.settings.apiBaseUrl}/voice/status`); + const newStData = await newStRes.json(); + + this.dictationSessionId = newStData.session_id; + this.dictationLastLen = newStData.pure_gm_text?.length || 0; + + // Запускаем интервал сбора новых слов + this.dictationPollTimer = window.setInterval(async () => { + await this.pollDictationText(); + }, 1000); + + } catch (e) { + console.error("Ошибка старта диктовки: ", e); + this.stopDictation(); + new Notice('Ошибка подключения к службе транскрибации'); + } + } + + private async pollDictationText() { + try { + const res = await fetch(`${this.props.plugin.settings.apiBaseUrl}/voice/status`); + const data = await res.json(); + + // Если сессия сбросилась (файл лога другой) - обнуляем длину + if (data.session_id !== this.dictationSessionId) { + this.dictationSessionId = data.session_id; + this.dictationLastLen = 0; + } + + const text = data.pure_gm_text || ""; + if (text.length > this.dictationLastLen) { + // Отрезаем новый кусок + const newWords = text.substring(this.dictationLastLen).trim(); + if (newWords) { + this.dictationBuffer += (this.dictationBuffer ? " " : "") + newWords; + } + this.dictationLastLen = text.length; + } + } catch (e) { + // Игнорируем фоновые ошибки сети + } + } + + private async stopDictation() { + this.isDictating = false; + this.dictationBtn.classList.remove('pulsing'); + + if (this.dictationPollTimer) { + clearInterval(this.dictationPollTimer); + this.dictationPollTimer = null; + } + + if (this.dictationBuffer.trim().length > 0) { + try { + // Кешируем как md файл + const cacheResult = await this.cacheManager.cacheTextData( + this.dictationBuffer.trim(), + `Голосовая_заметка` // Это пойдет в имя + ); + + const now = new Date(); + const timeStr = `${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}`; + + // Формируем Attachment (Используем тип url, или external - кеш менеджер сделает md файл) + const newAttachment: Attachment = { + type: 'external', + name: `dictation-${timeStr}.md`, + sourcePath: `virtual-dictation-${timeStr}`, + cachedPath: cacheResult.cachedPath, + uuid: cacheResult.uuid, + permanent: false, + mimeType: cacheResult.mimeType + }; + + this.currentAttachments.push(newAttachment); + this.renderAttachedFiles(); + new Notice('Диктовка прикреплена'); + + } catch (e) { + console.error("Ошибка сохранения диктовки:", e); + new Notice('Не удалось сохранить файл диктовки'); + } + } else { + new Notice('Записан пустой текст, вложение отменено'); + } + + this.dictationBuffer = ""; + } } diff --git a/src/css/styles.css b/src/css/styles.css index 9edcc08..2c3c39d 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -1585,4 +1585,78 @@ .param-reset-btn:hover { color: var(--text-normal) !important; background-color: var(--background-modifier-hover); -} \ No newline at end of file +} + +/* ----------------------------------------------- Stop button and dictation --------------------------------------------------------------- */ + +/* Новая глобальная кнопка отмены генерации (внутри wrapper) */ +.global-stop-stream-btn { + pointer-events: auto; + border: 2px solid var(--text-error); /* Красная окантовка для опасности */ + border-radius: 8px; /* Квадратная форма с закруглением вместо 50% */ + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + flex-shrink: 0; + padding: 0; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: white; + background-color: var(--background-modifier-error); /* Фон красного толка */ + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); + opacity: 0.8; +} + +.global-stop-stream-btn:hover { + opacity: 1; + transform: scale(1.1); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); + background-color: var(--text-error) !important; +} + +.global-stop-stream-btn:active { + transform: scale(0.95); +} + +.global-stop-stream-btn svg { + width: 18px; + height: 18px; + stroke-width: 3px; + fill: currentColor; +} + +/* Кнопка диктовки */ +.dictation-button { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s, color 0.2s, transform 0.2s; +} + +.dictation-button:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* Анимация пульсации при записи */ +.dictation-button.pulsing { + animation: dictation-pulse 1.5s infinite; + color: var(--text-error); /* Красный цвет записи */ +} + +@keyframes dictation-pulse { + 0% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.2); opacity: 0.6; } + 100% { transform: scale(1); opacity: 1; } +} + diff --git a/src/views/ChatView.ts b/src/views/ChatView.ts index f06ec5b..1a060e2 100644 --- a/src/views/ChatView.ts +++ b/src/views/ChatView.ts @@ -226,7 +226,11 @@ export class ChatView extends ItemView { handleSettingsChanged(): void { if (this.chatPanel) { + // 1. Обновляем модель (это у вас уже было) this.chatPanel.setSelectedModel(this.plugin.settings.defaultModel); + + // 2. Уведомляем панель об обновлении всего объекта плагина (и настроек внутри) + this.chatPanel.updateSettings(this.plugin); } }