Исправлена автопрокрутка журнала
Журнал перерисовывается целиком, и очистка документа сбрасывала прокрутку в начало: следующая перерисовка принимала это за прокрутку пользователем и оставляла журнал наверху. - Режим «следовать за низом» меняется только прокруткой пользователя - Позиция восстанавливается по rangeChanged, когда раскладка документа действительно обновилась - Новый запрос всегда прокручивает вниз - Тесты автопрокрутки и сохранения позиции при прокрутке вверх Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
04029f3750
commit
85f4210480
@@ -46,6 +46,8 @@ _VOICE_BUTTON_TEXTS = {
|
|||||||
_RENDER_INTERVAL_MS = 50
|
_RENDER_INTERVAL_MS = 50
|
||||||
# Журнал перерисовывается целиком, поэтому храним только последние записи
|
# Журнал перерисовывается целиком, поэтому храним только последние записи
|
||||||
_MAX_ENTRIES = 100
|
_MAX_ENTRIES = 100
|
||||||
|
# Насколько можно не дотянуть до низа, чтобы журнал всё равно следовал за новыми записями
|
||||||
|
_BOTTOM_TOLERANCE_PX = 4
|
||||||
|
|
||||||
_META_COLOR = "#9e9e9e"
|
_META_COLOR = "#9e9e9e"
|
||||||
|
|
||||||
@@ -89,6 +91,14 @@ class ChatWindow(QWidget):
|
|||||||
|
|
||||||
self._transcript = QTextBrowser()
|
self._transcript = QTextBrowser()
|
||||||
self._transcript.setOpenExternalLinks(True)
|
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 = _MessageInput()
|
||||||
self._input.setPlaceholderText(
|
self._input.setPlaceholderText(
|
||||||
@@ -135,9 +145,7 @@ class ChatWindow(QWidget):
|
|||||||
|
|
||||||
assistant.state_changed.connect(self._on_state_changed)
|
assistant.state_changed.connect(self._on_state_changed)
|
||||||
assistant.provider_changed.connect(self._update_provider_label)
|
assistant.provider_changed.connect(self._update_provider_label)
|
||||||
assistant.request_added.connect(
|
assistant.request_added.connect(self._on_request_added)
|
||||||
lambda text, follow_up: self._append(_Entry("user", text, follow_up))
|
|
||||||
)
|
|
||||||
assistant.reply_started.connect(lambda: self._append(_Entry("assistant", "")))
|
assistant.reply_started.connect(lambda: self._append(_Entry("assistant", "")))
|
||||||
assistant.reply_chunk.connect(self._on_reply_chunk)
|
assistant.reply_chunk.connect(self._on_reply_chunk)
|
||||||
assistant.reply_finished.connect(self._on_reply_finished)
|
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}"
|
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:
|
def _on_reply_chunk(self, piece: str) -> None:
|
||||||
# После действия текст ответа продолжается новой записью под ним
|
# После действия текст ответа продолжается новой записью под ним
|
||||||
if not self._entries or self._entries[-1].role != "assistant":
|
if not self._entries or self._entries[-1].role != "assistant":
|
||||||
@@ -226,9 +239,8 @@ class ChatWindow(QWidget):
|
|||||||
|
|
||||||
def _render(self) -> None:
|
def _render(self) -> None:
|
||||||
scrollbar = self._transcript.verticalScrollBar()
|
scrollbar = self._transcript.verticalScrollBar()
|
||||||
previous_position = scrollbar.value()
|
# Журнал перестраивается целиком, и clear() сбрасывает прокрутку в начало
|
||||||
stick_to_bottom = previous_position >= scrollbar.maximum() - 4
|
self._rendering = True
|
||||||
|
|
||||||
document = self._transcript.document()
|
document = self._transcript.document()
|
||||||
document.clear()
|
document.clear()
|
||||||
cursor = QTextCursor(document)
|
cursor = QTextCursor(document)
|
||||||
@@ -260,10 +272,24 @@ class ChatWindow(QWidget):
|
|||||||
else:
|
else:
|
||||||
cursor.insertText(entry.text)
|
cursor.insertText(entry.text)
|
||||||
|
|
||||||
# Диапазон прокрутки обновляется после раскладки документа
|
self._rendering = False
|
||||||
QTimer.singleShot(
|
self._restore_scroll(scrollbar.minimum(), scrollbar.maximum())
|
||||||
0,
|
|
||||||
lambda: scrollbar.setValue(
|
def _on_user_scrolled(self, value: int) -> None:
|
||||||
scrollbar.maximum() if stick_to_bottom else previous_position
|
# Перерисовка и наша же подстройка тоже двигают полосу — это не действие пользователя
|
||||||
),
|
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