- Глобальная горячая клавиша через RegisterHotKey (по умолчанию Win+Alt+Space) - Запись с микрофона, конец фразы по паузе через Silero VAD, звуковые сигналы - Распознавание faster-whisper large-v3-turbo на GPU, CUDA-библиотеки из pip-пакетов nvidia-* - Состояния «слушаю» и «распознаю», кнопка «Говорить» в чате и пункт в трее - Голосовая команда прерывает текущий ответ и озвучку - Тесты горячей клавиши, детектора конца фразы и сеанса голосового ввода Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
7.9 KiB
Python
235 lines
7.9 KiB
Python
"""Окно чата: история диалога и поле ввода."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from PySide6.QtCore import Qt, QTimer, Signal
|
|
from PySide6.QtGui import (
|
|
QCloseEvent,
|
|
QColor,
|
|
QFont,
|
|
QKeyEvent,
|
|
QTextBlockFormat,
|
|
QTextCharFormat,
|
|
QTextCursor,
|
|
)
|
|
from PySide6.QtWidgets import (
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QPlainTextEdit,
|
|
QPushButton,
|
|
QTextBrowser,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from agr_assistent import APP_NAME
|
|
from agr_assistent.core.assistant import Assistant, AssistantState
|
|
from agr_assistent.ui.icons import state_icon
|
|
|
|
_ROLE_STYLES = {
|
|
"user": ("Вы", "#1e88e5"),
|
|
"assistant": ("Ассистент", "#43a047"),
|
|
"error": ("Ошибка", "#e53935"),
|
|
}
|
|
|
|
_VOICE_BUTTON_TEXTS = {
|
|
AssistantState.LISTENING: "Готово",
|
|
AssistantState.RECOGNIZING: "Распознаю…",
|
|
}
|
|
|
|
# Во время стриминга перерисовываем историю не чаще, чем раз в N мс
|
|
_RENDER_INTERVAL_MS = 50
|
|
|
|
|
|
@dataclass
|
|
class _Entry:
|
|
role: str
|
|
text: str
|
|
|
|
|
|
class _MessageInput(QPlainTextEdit):
|
|
"""Enter отправляет сообщение, Shift+Enter переносит строку."""
|
|
|
|
submitted = Signal()
|
|
|
|
def keyPressEvent(self, event: QKeyEvent) -> None:
|
|
is_enter = event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter)
|
|
if is_enter and not event.modifiers() & Qt.KeyboardModifier.ShiftModifier:
|
|
self.submitted.emit()
|
|
return
|
|
super().keyPressEvent(event)
|
|
|
|
|
|
class ChatWindow(QWidget):
|
|
def __init__(self, assistant: Assistant) -> None:
|
|
super().__init__()
|
|
self._assistant = assistant
|
|
self._entries: list[_Entry] = []
|
|
|
|
self.setWindowTitle(APP_NAME)
|
|
self.setWindowIcon(state_icon(assistant.state))
|
|
self.resize(560, 680)
|
|
|
|
self._provider_label = QLabel()
|
|
new_chat_button = QPushButton("Новый диалог")
|
|
new_chat_button.clicked.connect(assistant.clear_history)
|
|
|
|
self._transcript = QTextBrowser()
|
|
self._transcript.setOpenExternalLinks(True)
|
|
|
|
self._input = _MessageInput()
|
|
self._input.setPlaceholderText(
|
|
"Напишите сообщение… (Enter — отправить, Shift+Enter — новая строка)"
|
|
)
|
|
self._input.setMaximumHeight(90)
|
|
self._input.submitted.connect(self._submit)
|
|
|
|
self._send_button = QPushButton()
|
|
self._send_button.setMinimumWidth(100)
|
|
self._send_button.clicked.connect(self._on_send_clicked)
|
|
|
|
self._voice_button = QPushButton()
|
|
self._voice_button.setMinimumWidth(100)
|
|
self._voice_button.clicked.connect(assistant.toggle_listening)
|
|
self._voice_button.setVisible(assistant.voice_available)
|
|
|
|
header = QHBoxLayout()
|
|
header.addWidget(self._provider_label, 1)
|
|
header.addWidget(new_chat_button)
|
|
|
|
buttons = QVBoxLayout()
|
|
buttons.addWidget(self._send_button)
|
|
buttons.addWidget(self._voice_button)
|
|
buttons.addStretch()
|
|
|
|
input_row = QHBoxLayout()
|
|
input_row.addWidget(self._input, 1)
|
|
input_row.addLayout(buttons)
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.addLayout(header)
|
|
layout.addWidget(self._transcript, 1)
|
|
layout.addLayout(input_row)
|
|
|
|
self._render_timer = QTimer(self)
|
|
self._render_timer.setSingleShot(True)
|
|
self._render_timer.setInterval(_RENDER_INTERVAL_MS)
|
|
self._render_timer.timeout.connect(self._render)
|
|
|
|
assistant.state_changed.connect(self._on_state_changed)
|
|
assistant.provider_changed.connect(self._update_provider_label)
|
|
assistant.user_message_added.connect(lambda text: self._append("user", text))
|
|
assistant.reply_started.connect(lambda: self._append("assistant", ""))
|
|
assistant.reply_chunk.connect(self._on_reply_chunk)
|
|
assistant.reply_finished.connect(self._on_reply_finished)
|
|
assistant.error_occurred.connect(lambda message: self._append("error", message))
|
|
assistant.history_cleared.connect(self._on_history_cleared)
|
|
|
|
self._update_provider_label()
|
|
self._on_state_changed(assistant.state)
|
|
|
|
def show_and_raise(self) -> None:
|
|
self.showNormal()
|
|
self.raise_()
|
|
self.activateWindow()
|
|
self._input.setFocus()
|
|
|
|
def toggle_visibility(self) -> None:
|
|
if self.isVisible() and not self.isMinimized():
|
|
self.hide()
|
|
else:
|
|
self.show_and_raise()
|
|
|
|
def closeEvent(self, event: QCloseEvent) -> None:
|
|
# Закрытие окна сворачивает приложение в трей; выход — через меню трея
|
|
event.ignore()
|
|
self.hide()
|
|
|
|
def _submit(self) -> None:
|
|
text = self._input.toPlainText().strip()
|
|
if not text or self._assistant.is_generating:
|
|
return
|
|
self._input.clear()
|
|
self._assistant.send(text)
|
|
|
|
def _on_send_clicked(self) -> None:
|
|
if self._assistant.is_busy:
|
|
self._assistant.cancel()
|
|
else:
|
|
self._submit()
|
|
|
|
def _on_state_changed(self, state: object) -> None:
|
|
current = self._assistant.state
|
|
self._send_button.setText("Стоп" if self._assistant.is_busy else "Отправить")
|
|
self._voice_button.setText(_VOICE_BUTTON_TEXTS.get(current, "Говорить"))
|
|
self._voice_button.setEnabled(current is not AssistantState.RECOGNIZING)
|
|
self.setWindowIcon(state_icon(current))
|
|
|
|
def _update_provider_label(self) -> None:
|
|
self._provider_label.setText(
|
|
f"Модель: <b>{self._assistant.provider_name}</b> · {self._assistant.model_name}"
|
|
)
|
|
|
|
def _on_reply_chunk(self, piece: str) -> None:
|
|
if self._entries and self._entries[-1].role == "assistant":
|
|
self._entries[-1].text += piece
|
|
if not self._render_timer.isActive():
|
|
self._render_timer.start()
|
|
|
|
def _on_reply_finished(self, text: str) -> None:
|
|
last = self._entries[-1] if self._entries else None
|
|
if last and last.role == "assistant" and not last.text.strip():
|
|
self._entries.pop()
|
|
self._render_now()
|
|
|
|
def _on_history_cleared(self) -> None:
|
|
self._entries.clear()
|
|
self._render_now()
|
|
|
|
def _append(self, role: str, text: str) -> None:
|
|
self._entries.append(_Entry(role, text))
|
|
self._render_now()
|
|
|
|
def _render_now(self) -> None:
|
|
self._render_timer.stop()
|
|
self._render()
|
|
|
|
def _render(self) -> None:
|
|
scrollbar = self._transcript.verticalScrollBar()
|
|
previous_position = scrollbar.value()
|
|
stick_to_bottom = previous_position >= scrollbar.maximum() - 4
|
|
|
|
document = self._transcript.document()
|
|
document.clear()
|
|
cursor = QTextCursor(document)
|
|
for index, entry in enumerate(self._entries):
|
|
title, color = _ROLE_STYLES[entry.role]
|
|
|
|
title_block = QTextBlockFormat()
|
|
title_block.setTopMargin(14 if index else 0)
|
|
if index:
|
|
cursor.insertBlock(title_block, QTextCharFormat())
|
|
else:
|
|
cursor.setBlockFormat(title_block)
|
|
|
|
title_format = QTextCharFormat()
|
|
title_format.setFontWeight(QFont.Weight.Bold)
|
|
title_format.setForeground(QColor(color))
|
|
cursor.insertText(title, title_format)
|
|
|
|
cursor.insertBlock(QTextBlockFormat(), QTextCharFormat())
|
|
if entry.role == "assistant":
|
|
cursor.insertMarkdown(entry.text or "…")
|
|
else:
|
|
cursor.insertText(entry.text)
|
|
|
|
# Диапазон прокрутки обновляется после раскладки документа
|
|
QTimer.singleShot(
|
|
0,
|
|
lambda: scrollbar.setValue(
|
|
scrollbar.maximum() if stick_to_bottom else previous_position
|
|
),
|
|
)
|