Compare commits
1
Commits
04029f3750
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f4210480 |
@@ -46,6 +46,8 @@ _VOICE_BUTTON_TEXTS = {
|
||||
_RENDER_INTERVAL_MS = 50
|
||||
# Журнал перерисовывается целиком, поэтому храним только последние записи
|
||||
_MAX_ENTRIES = 100
|
||||
# Насколько можно не дотянуть до низа, чтобы журнал всё равно следовал за новыми записями
|
||||
_BOTTOM_TOLERANCE_PX = 4
|
||||
|
||||
_META_COLOR = "#9e9e9e"
|
||||
|
||||
@@ -89,6 +91,14 @@ class ChatWindow(QWidget):
|
||||
|
||||
self._transcript = QTextBrowser()
|
||||
self._transcript.setOpenExternalLinks(True)
|
||||
# Прилипать к низу, пока пользователь сам не прокрутил журнал вверх
|
||||
self._follow_bottom = True
|
||||
self._scroll_position = 0
|
||||
self._rendering = False
|
||||
self._adjusting_scroll = False
|
||||
transcript_scrollbar = self._transcript.verticalScrollBar()
|
||||
transcript_scrollbar.valueChanged.connect(self._on_user_scrolled)
|
||||
transcript_scrollbar.rangeChanged.connect(self._restore_scroll)
|
||||
|
||||
self._input = _MessageInput()
|
||||
self._input.setPlaceholderText(
|
||||
@@ -135,9 +145,7 @@ class ChatWindow(QWidget):
|
||||
|
||||
assistant.state_changed.connect(self._on_state_changed)
|
||||
assistant.provider_changed.connect(self._update_provider_label)
|
||||
assistant.request_added.connect(
|
||||
lambda text, follow_up: self._append(_Entry("user", text, follow_up))
|
||||
)
|
||||
assistant.request_added.connect(self._on_request_added)
|
||||
assistant.reply_started.connect(lambda: self._append(_Entry("assistant", "")))
|
||||
assistant.reply_chunk.connect(self._on_reply_chunk)
|
||||
assistant.reply_finished.connect(self._on_reply_finished)
|
||||
@@ -190,6 +198,11 @@ class ChatWindow(QWidget):
|
||||
f"Модель: <b>{self._assistant.provider_name}</b> · {self._assistant.model_name}"
|
||||
)
|
||||
|
||||
def _on_request_added(self, text: str, follow_up: bool) -> None:
|
||||
# Новый запрос всегда показываем, даже если журнал был прокручен вверх
|
||||
self._follow_bottom = True
|
||||
self._append(_Entry("user", text, follow_up))
|
||||
|
||||
def _on_reply_chunk(self, piece: str) -> None:
|
||||
# После действия текст ответа продолжается новой записью под ним
|
||||
if not self._entries or self._entries[-1].role != "assistant":
|
||||
@@ -226,9 +239,8 @@ class ChatWindow(QWidget):
|
||||
|
||||
def _render(self) -> None:
|
||||
scrollbar = self._transcript.verticalScrollBar()
|
||||
previous_position = scrollbar.value()
|
||||
stick_to_bottom = previous_position >= scrollbar.maximum() - 4
|
||||
|
||||
# Журнал перестраивается целиком, и clear() сбрасывает прокрутку в начало
|
||||
self._rendering = True
|
||||
document = self._transcript.document()
|
||||
document.clear()
|
||||
cursor = QTextCursor(document)
|
||||
@@ -260,10 +272,24 @@ class ChatWindow(QWidget):
|
||||
else:
|
||||
cursor.insertText(entry.text)
|
||||
|
||||
# Диапазон прокрутки обновляется после раскладки документа
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: scrollbar.setValue(
|
||||
scrollbar.maximum() if stick_to_bottom else previous_position
|
||||
),
|
||||
)
|
||||
self._rendering = False
|
||||
self._restore_scroll(scrollbar.minimum(), scrollbar.maximum())
|
||||
|
||||
def _on_user_scrolled(self, value: int) -> None:
|
||||
# Перерисовка и наша же подстройка тоже двигают полосу — это не действие пользователя
|
||||
if self._rendering or self._adjusting_scroll:
|
||||
return
|
||||
scrollbar = self._transcript.verticalScrollBar()
|
||||
self._follow_bottom = value >= scrollbar.maximum() - _BOTTOM_TOLERANCE_PX
|
||||
self._scroll_position = value
|
||||
|
||||
def _restore_scroll(self, _minimum: int, maximum: int) -> None:
|
||||
"""Диапазон прокрутки меняется после раскладки документа, иногда позже перерисовки."""
|
||||
if self._rendering:
|
||||
return
|
||||
self._adjusting_scroll = True
|
||||
try:
|
||||
scrollbar = self._transcript.verticalScrollBar()
|
||||
scrollbar.setValue(maximum if self._follow_bottom else self._scroll_position)
|
||||
finally:
|
||||
self._adjusting_scroll = False
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QCoreApplication
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from agr_assistent.core.memory import MemoryStore
|
||||
from agr_assistent.ui.chat_window import ChatWindow
|
||||
from tests.fake_llm import FakeLLMServer
|
||||
from tests.test_assistant_flow import _assistant
|
||||
|
||||
|
||||
def _pump(seconds: float = 0.1) -> None:
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
QCoreApplication.processEvents()
|
||||
time.sleep(0.005)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(qapp: QApplication, fake_llm: FakeLLMServer, tmp_path) -> ChatWindow: # type: ignore[no-untyped-def]
|
||||
memory = MemoryStore(tmp_path / "memory.sqlite3")
|
||||
chat = ChatWindow(_assistant(fake_llm, memory))
|
||||
chat.resize(500, 300)
|
||||
chat.show()
|
||||
yield chat
|
||||
chat.hide()
|
||||
memory.close()
|
||||
|
||||
|
||||
def _scrollbar(window: ChatWindow): # type: ignore[no-untyped-def]
|
||||
return window._transcript.verticalScrollBar()
|
||||
|
||||
|
||||
def _stream_reply(window: ChatWindow, request: str, chunks: int) -> None:
|
||||
assistant = window._assistant
|
||||
assistant.request_added.emit(request, False)
|
||||
assistant.reply_started.emit()
|
||||
for index in range(chunks):
|
||||
assistant.reply_chunk.emit(f"Строка ответа номер {index}.\n\n")
|
||||
_pump(0.01)
|
||||
assistant.reply_finished.emit("")
|
||||
_pump(0.2)
|
||||
|
||||
|
||||
def test_journal_follows_new_content(window: ChatWindow) -> None:
|
||||
for request in range(3):
|
||||
_stream_reply(window, f"Вопрос {request}", chunks=15)
|
||||
|
||||
scrollbar = _scrollbar(window)
|
||||
assert scrollbar.maximum() > 0
|
||||
assert scrollbar.value() == scrollbar.maximum()
|
||||
|
||||
|
||||
def test_journal_keeps_position_when_user_scrolled_up(window: ChatWindow) -> None:
|
||||
_stream_reply(window, "Длинный вопрос", chunks=30)
|
||||
scrollbar = _scrollbar(window)
|
||||
scrollbar.setValue(10)
|
||||
_pump()
|
||||
|
||||
assistant = window._assistant
|
||||
assistant.reply_started.emit()
|
||||
for index in range(10):
|
||||
assistant.reply_chunk.emit(f"Ещё строка {index}.\n\n")
|
||||
_pump(0.01)
|
||||
_pump(0.2)
|
||||
assert scrollbar.value() == 10
|
||||
|
||||
# Новый запрос пользователя снова прокручивает вниз
|
||||
assistant.request_added.emit("Новый вопрос", False)
|
||||
_pump(0.2)
|
||||
assert scrollbar.value() == scrollbar.maximum()
|
||||
Reference in New Issue
Block a user