diff --git a/presentation/chat/chat_live_turns.py b/presentation/chat/chat_live_turns.py new file mode 100644 index 0000000..9b6e733 --- /dev/null +++ b/presentation/chat/chat_live_turns.py @@ -0,0 +1,90 @@ +"""Nối lại lượt đang chạy khi người dùng quay về phiên cũ — R08-T06. + +Phần tinh tế nhất của khung chat. Người dùng mở phiên khác rồi quay lại trong +khi lượt cũ VẪN đang chạy: phải nối vào đúng luồng đó và đúng danh sách tin +nhắn đang sống, chứ không được đọc bản trên đĩa (đã cũ) hay khởi động lại. + +``_detach_live_turns`` gỡ ra khi rời phiên, ``_reattach_running_turn`` nối +lại khi quay về. Sai một trong hai thì hoặc mất phần agent viết trong lúc +vắng mặt, hoặc hai bên cùng ghi vào một file. + +Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMessageBox +from ...core.worker import AgentWorker +from ...i18n import tr + + +class ChatLiveTurnsMixin: + """Nối lại lượt đang chạy. Trộn vào ChatPanel.""" + + def _detach_live_turns(self) -> None: + """Before switching away from the current conversation, turn its running + turns into background jobs: they stop rendering into the (about-to-be- + cleared) transcript but keep running and save to their own conversation.""" + for c in self._active.values(): + if c.get("home_id") == self.session_id: + c["detached"] = True + c["assistant"] = None # its bubbles are about to be cleared + + def _running_ctx_for(self, session_id: str) -> Optional[Dict[str, Any]]: + """The in-progress turn's context for a conversation (one at a time), or None.""" + for c in self._active.values(): + if c.get("home_id") == session_id: + return c + return None + + def _reattach_running_turn(self, ctx: Dict[str, Any]) -> None: + """Re-render an in-progress turn into the current transcript and re-attach it + so it keeps streaming live — used when reopening a running conversation, so + the user sees the CURRENT task (message + steps so far + live plan), not just + the last saved state.""" + record = ctx["record"] + record["bubbles"] = [] # the old bubbles were cleared on the view switch + # 1) the user's message that is being processed + ub = self.chat_view.add_user(ctx.get("display_text") or "(attachment)") + record["bubbles"].append(ub) + # 2) steps already completed this turn (assistant text / tool results); found + # by identity after the user message (a system prompt may sit before it). + # Snapshot the list — the worker thread may still be appending to it. + msgs = list(ctx.get("messages", [])) + ui = next((i for i, m in enumerate(msgs) if m is ctx.get("user_msg")), -1) + for m in (msgs[ui + 1:] if ui >= 0 else []): + role = m.get("role") + if role == "assistant" and (m.get("content") or "").strip(): + b = self.chat_view.add_assistant(self.assistant_title()) + b.set_markdown(m["content"]) + record["bubbles"].append(b) + elif role == "tool": + b = self.chat_view.add_tool(m.get("name", "tool"), m.get("content", ""), True) + record["bubbles"].append(b) + # 3) the live plan checklist (if any) — inline, expandable + steps = ctx.get("plan_steps") or [] + if steps: + self.on_plan(steps) + from ...ui.chat_panel import _format_plan_steps + pb = self.chat_view.add_plan(_format_plan_steps(steps)) + record["bubbles"].append(pb) + ctx["plan_bubble"] = pb + # 4) the partial answer of the step currently streaming — re-attach so new + # deltas keep appending to this bubble. + ctx["assistant"] = None + ctx["reasoning"] = None + if (ctx.get("partial") or "").strip(): + ab = self.chat_view.add_assistant(self.assistant_title()) + ab.set_markdown(ctx["partial"]) + record["bubbles"].append(ab) + ctx["assistant"] = ab + # 5) live again → future events render here + ctx["detached"] = False + self.chat_view.scroll_to_bottom() + + def running_session_ids(self): + """Set of conversation ids that currently have a turn running (for the + History status markers).""" + return set(self._sessions_live) diff --git a/presentation/chat/chat_session_store.py b/presentation/chat/chat_session_store.py index 1578680..5ae9a74 100644 --- a/presentation/chat/chat_session_store.py +++ b/presentation/chat/chat_session_store.py @@ -60,10 +60,6 @@ class ChatSessionMixin: history_dir=ctx.get("home_history_dir")) self.history_changed.emit() - def running_session_ids(self): - """Set of conversation ids that currently have a turn running (for the - History status markers).""" - return set(self._sessions_live) def _usage_label(self) -> str: return self.title or self.session_id @@ -352,63 +348,5 @@ class ChatSessionMixin: pct = int(_tok([digest]) * 100 / old_tok) self.status_message.emit(tr("chatpanel.compress_reduced", pct=pct, n=len(old))) - def _detach_live_turns(self) -> None: - """Before switching away from the current conversation, turn its running - turns into background jobs: they stop rendering into the (about-to-be- - cleared) transcript but keep running and save to their own conversation.""" - for c in self._active.values(): - if c.get("home_id") == self.session_id: - c["detached"] = True - c["assistant"] = None # its bubbles are about to be cleared - def _running_ctx_for(self, session_id: str) -> Optional[Dict[str, Any]]: - """The in-progress turn's context for a conversation (one at a time), or None.""" - for c in self._active.values(): - if c.get("home_id") == session_id: - return c - return None - def _reattach_running_turn(self, ctx: Dict[str, Any]) -> None: - """Re-render an in-progress turn into the current transcript and re-attach it - so it keeps streaming live — used when reopening a running conversation, so - the user sees the CURRENT task (message + steps so far + live plan), not just - the last saved state.""" - record = ctx["record"] - record["bubbles"] = [] # the old bubbles were cleared on the view switch - # 1) the user's message that is being processed - ub = self.chat_view.add_user(ctx.get("display_text") or "(attachment)") - record["bubbles"].append(ub) - # 2) steps already completed this turn (assistant text / tool results); found - # by identity after the user message (a system prompt may sit before it). - # Snapshot the list — the worker thread may still be appending to it. - msgs = list(ctx.get("messages", [])) - ui = next((i for i, m in enumerate(msgs) if m is ctx.get("user_msg")), -1) - for m in (msgs[ui + 1:] if ui >= 0 else []): - role = m.get("role") - if role == "assistant" and (m.get("content") or "").strip(): - b = self.chat_view.add_assistant(self.assistant_title()) - b.set_markdown(m["content"]) - record["bubbles"].append(b) - elif role == "tool": - b = self.chat_view.add_tool(m.get("name", "tool"), m.get("content", ""), True) - record["bubbles"].append(b) - # 3) the live plan checklist (if any) — inline, expandable - steps = ctx.get("plan_steps") or [] - if steps: - self.on_plan(steps) - from ...ui.chat_panel import _format_plan_steps - pb = self.chat_view.add_plan(_format_plan_steps(steps)) - record["bubbles"].append(pb) - ctx["plan_bubble"] = pb - # 4) the partial answer of the step currently streaming — re-attach so new - # deltas keep appending to this bubble. - ctx["assistant"] = None - ctx["reasoning"] = None - if (ctx.get("partial") or "").strip(): - ab = self.chat_view.add_assistant(self.assistant_title()) - ab.set_markdown(ctx["partial"]) - record["bubbles"].append(ab) - ctx["assistant"] = ab - # 5) live again → future events render here - ctx["detached"] = False - self.chat_view.scroll_to_bottom() diff --git a/ui/chat_panel.py b/ui/chat_panel.py index caf32d6..3b82e77 100644 --- a/ui/chat_panel.py +++ b/ui/chat_panel.py @@ -13,6 +13,7 @@ from __future__ import annotations from ..presentation.chat.chat_event_stream import ChatEventStreamMixin from ..presentation.chat.chat_panel_layout import ChatPanelLayoutMixin +from ..presentation.chat.chat_live_turns import ChatLiveTurnsMixin from ..presentation.chat.chat_helpers import ( # noqa: F401 — giữ đường vào cũ _TOOL_STATUS, _format_plan_steps, _is_scratch, ) @@ -55,7 +56,7 @@ _PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗" -class ChatPanel(ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin, +class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin, OutputPanelMixin, ChatAgentsMixin, ChatTurnRunnerMixin,