Add voice dictation to chat panel and global stop generation button.
This commit is contained in:
parent
29a47ed28d
commit
205040a599
31
main.ts
31
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). Относительно корня хранилища.')
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1585,4 +1585,78 @@
|
|||
.param-reset-btn:hover {
|
||||
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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user