Add voice dictation to chat panel and global stop generation button.

This commit is contained in:
dimitrievgs 2026-05-23 01:00:45 +03:00
parent 29a47ed28d
commit 205040a599
4 changed files with 300 additions and 4 deletions

31
main.ts
View File

@ -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). Относительно корня хранилища.')

View File

@ -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 {
<button class="scroll-to-bottom-btn" id="scroll-to-bottom-btn" title="Прокрутить вниз" aria-label="Прокрутить к последнему сообщению">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline></svg>
</button>
<button class="global-stop-stream-btn" id="global-stop-stream-btn" style="display: none;" title="Отменить все генерации" aria-label="Остановить ответы LLM">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>
</button>
</div>
<div class="chat-history" id="chat-history"></div>
@ -610,6 +629,11 @@ export class ChatPanel {
<button class="stop-button" id="stop-button" style="display: none;">
<span class="stop-icon"></span>
</button>
<button class="dictation-button" id="dictation-button" title="Извлечь голос (GM)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="22"></line></svg>
</button>
<button class="submit-button" id="send-button">
<span class="submit-text"> Enter</span>
</button>
@ -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 = "";
}
}

View File

@ -1586,3 +1586,77 @@
color: var(--text-normal) !important;
background-color: var(--background-modifier-hover);
}
/* ----------------------------------------------- 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; }
}

View File

@ -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);
}
}