Этап 1: трей, окно чата и текстовый диалог с LLM
- PySide6: значок в трее с индикацией состояния, окно чата со стримингом и markdown
- Единый OpenAI-совместимый клиент (Ollama, LM Studio, OpenRouter)
- config.yaml с автосозданием, слиянием с умолчаниями и подстановкой ${ENV}
- Тесты конфига и сборки сообщений
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
26a01aa210
commit
7933613234
@@ -0,0 +1,216 @@
|
||||
"""Окно чата: история диалога и поле ввода."""
|
||||
|
||||
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
|
||||
from agr_assistent.ui.icons import state_icon
|
||||
|
||||
_ROLE_STYLES = {
|
||||
"user": ("Вы", "#1e88e5"),
|
||||
"assistant": ("Ассистент", "#43a047"),
|
||||
"error": ("Ошибка", "#e53935"),
|
||||
}
|
||||
|
||||
# Во время стриминга перерисовываем историю не чаще, чем раз в 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)
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.addWidget(self._provider_label, 1)
|
||||
header.addWidget(new_chat_button)
|
||||
|
||||
input_row = QHBoxLayout()
|
||||
input_row.addWidget(self._input, 1)
|
||||
input_row.addWidget(self._send_button)
|
||||
|
||||
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_busy:
|
||||
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:
|
||||
self._send_button.setText("Стоп" if self._assistant.is_busy else "Отправить")
|
||||
self.setWindowIcon(state_icon(self._assistant.state))
|
||||
|
||||
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
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user