Bấm "Cuộc trò chuyện mới" trước đây để lại một khung trắng: không có gì nói người dùng đang làm trong project nào, thư mục có bao nhiêu tệp, hay bắt đầu từ đâu. Đây là trạng thái RỖNG — một trong bốn trạng thái mà mọi khung dữ liệu phải có, và là trạng thái duy nhất người dùng nhìn thấy trước khi gõ chữ đầu tiên. Gồm lời chào theo tên, dòng bối cảnh (project · số tệp · số skill đang bật), và bốn thẻ gợi ý. Hai quyết định: - Thẻ ĐIỀN câu gợi ý vào ô nhập chứ không gửi luôn. Câu gợi ý là điểm bắt đầu; người dùng gần như luôn cần thêm chi tiết của riêng họ, và gửi ngay sẽ tiêu một lượt gọi model cho một câu hỏi chung chung. - Dấu phía trên lời chào không bấm được — nó là dấu hiệu thị giác. Một nút không làm gì tệ hơn không có nút. Dòng bối cảnh phân biệt KHÔNG BIẾT với 0: đếm được 0 tệp thì hiện "0 tệp", còn không đọc được thư mục thì bỏ hẳn mảnh đó — hiện "0 tệp" khi người dùng vừa thấy có tệp trong thư mục còn tệ hơn là thiếu một mảnh. show_welcome() được móc ở ba chỗ: new_session(), load_conversation() (theo số tin nhắn đã lưu), và TRƯỚC mọi nhánh add_user trong turn runner — đặt sau từng add_user() thì dễ sót đúng một nhánh, và nhánh đó sẽ hiện cả hai thứ cùng lúc. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
319 lines
16 KiB
Python
319 lines
16 KiB
Python
"""Chạy một lượt chat, từ lúc bấm Gửi tới lúc kết thúc — R08-T06.
|
||
|
||
``_start_turn`` (144 dòng) và ``_on_event`` (127) là hai hàm dài nhất
|
||
trong màn này, và cố ý để nguyên: cái đầu dựng trọn ngữ cảnh một lượt
|
||
rồi giao cho luồng nền, cái sau phân nhánh theo từng loại sự kiện phát
|
||
về. Cắt nhỏ thì phải chuyền hàng chục biến trạng thái qua lại.
|
||
|
||
Mỗi lượt có luồng riêng và ngữ cảnh riêng, nên chạy song song nhiều lượt
|
||
trong cùng một khung chat được.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
from PySide6.QtCore import Qt, Signal
|
||
from ...core.worker import AgentWorker
|
||
from ...i18n import tr
|
||
from ...state import AppContext
|
||
# Nhập thẳng từ chỗ ở thật, không đi qua vỏ chuyển tiếp ``ui/composer.py``:
|
||
# vỏ ấy lại nhập ngược vào gói này, nên đi vòng qua nó tạo một chu trình
|
||
# import — ``import cowork_local.ui.composer`` khi chưa nạp gói này sẽ hỏng.
|
||
from .composer_widget import Composer
|
||
|
||
|
||
class ChatTurnRunnerMixin:
|
||
"""Trộn vào ChatPanel."""
|
||
|
||
def submit(self, text: str, attachments: Optional[List[str]] = None) -> None:
|
||
# Composer only emits 'submitted' when not busy; queued items are
|
||
# drained from here after each turn completes.
|
||
"""Gửi một tin nhắn mới.
|
||
|
||
Composer chỉ phát ``submitted`` khi khung chat đang rảnh; tin xếp hàng được
|
||
rút dần trong :meth:`_drain_queue` sau mỗi lượt xong.
|
||
"""
|
||
self._start_turn(text, attachments or [])
|
||
|
||
def run_prompts(self, prompts: List[str]) -> None:
|
||
"""Enqueue several prompts and run them (used by flows). They start up to
|
||
the parallel limit; the rest stay queued and start as slots free up."""
|
||
prompts = [p for p in prompts if p and p.strip()]
|
||
if not prompts:
|
||
return
|
||
for p in prompts:
|
||
self.composer.enqueue(p)
|
||
self._drain_queue()
|
||
|
||
def build_job(self, text: str, messages: List[Dict[str, Any]],
|
||
out_dir: Optional[Path]):
|
||
"""Return the agent job for this turn.
|
||
|
||
``messages`` is the turn's OWN message list (a snapshot of the history so
|
||
far plus the new user message) — the job must read/append to it, never to
|
||
``self.messages``, so parallel turns don't race. ``out_dir`` is the turn's
|
||
isolated output folder (or None when the tab produces no files)."""
|
||
raise NotImplementedError
|
||
|
||
def _start_turn(self, text: str, attachments: Optional[List[str]] = None) -> None:
|
||
"""Khởi động một lượt chat: xử lý lệnh ``/skill``, dựng snapshot yêu cầu, rồi
|
||
chạy agent ở luồng nền.
|
||
|
||
Lệnh ``/skill`` dạng liệt kê/chọn được trả lời tại chỗ, không tốn một lượt
|
||
gọi model nào.
|
||
"""
|
||
attachments = attachments or []
|
||
typed = text
|
||
prefix, request, info = self._apply_skill_command(text)
|
||
# Moi duong tra ve som duoi day cung them mot bong nguoi dung vao khung,
|
||
# nen man gioi thieu phai nhuong cho ngay tai day — dat sau tung
|
||
# add_user() thi de sot dung mot nhanh, va nhanh do se hien ca hai thu.
|
||
self.show_welcome(False)
|
||
if info is not None:
|
||
# A local /skill command (list / select / error) — answer inline.
|
||
self.chat_view.add_user(typed)
|
||
self.chat_view.add_assistant(self.assistant_title()).set_markdown(info)
|
||
self._drain_queue()
|
||
return
|
||
text = request
|
||
# /agent directive → apply a named agent persona to this turn (parity with
|
||
# the Co4E chat). Combined with any /skill prefix already parsed above.
|
||
agent_prefix, text, agent_info = self._apply_agent_command(text)
|
||
if agent_info is not None:
|
||
self.chat_view.add_user(typed)
|
||
self.chat_view.add_assistant(self.assistant_title()).set_markdown(agent_info)
|
||
self._drain_queue()
|
||
return
|
||
if agent_prefix:
|
||
prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix
|
||
if not self.title:
|
||
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
|
||
self.title = (base[:60] + "…") if len(base) > 60 else base
|
||
self._notify_title()
|
||
|
||
# Reset the Plan panel so each message starts from a clean checklist (the
|
||
# previous message's plan never lingers/flickers into this one).
|
||
self.plan_section.clear()
|
||
|
||
# Each turn works on its OWN message list: a snapshot of the history so far
|
||
# plus the new user message, merged back into self.messages when the turn
|
||
# finishes (see _finalize_turn). This keeps concurrent turns from racing on
|
||
# the shared list. The user content is filled in by the worker (below) —
|
||
# reading attachment text can pip-install a parser or call LibreOffice,
|
||
# which must not run on the UI thread.
|
||
snapshot = list(self.messages)
|
||
user_msg: Dict[str, Any] = {"role": "user", "content": prefix or text}
|
||
local_messages = snapshot + [user_msg]
|
||
|
||
# Consume the pending switch-review flag exactly once, for THIS turn —
|
||
# and record what's running it so the next genuine switch is detected
|
||
# against this, not against the selection that was current mid-turn.
|
||
review_switch = self._pending_agent_switch_review
|
||
self._pending_agent_switch_review = False
|
||
self._last_turn_agent_signature = self._agent_signature()
|
||
|
||
bubble = self.chat_view.add_user(text or "(attachment)")
|
||
turn: Dict[str, Any] = {"bubbles": [bubble], "messages": [],
|
||
"inputs": list(attachments), "outputs": []}
|
||
if review_switch:
|
||
# Make the mid-conversation model switch VISIBLE (it was silent
|
||
# before): a one-line notice so the user sees the run continued
|
||
# smoothly on the newly-picked model rather than wondering.
|
||
notice = self.chat_view.add_status(
|
||
tr("chat.model_switched", model=self._current_agent_label()))
|
||
turn["bubbles"].append(notice)
|
||
self.turns.append(turn)
|
||
bubble.add_delete_link(lambda t=turn: self._delete_turn(t))
|
||
if attachments:
|
||
bubble.add_attachments(attachments)
|
||
self.on_inputs_added(attachments)
|
||
folder = self.workspace_dir()
|
||
if folder:
|
||
bubble.add_folder_link(str(folder))
|
||
|
||
self.graph_event.emit(self.session_name, {"type": "user", "content": text})
|
||
|
||
# Auto Model Routing: may switch this turn's provider/model (Auto), or
|
||
# ask first (Manual). Runs before build_job so build_provider() sees the
|
||
# routed choice. No-op when the toggle is Off.
|
||
self._apply_routing(text, turn)
|
||
|
||
self._turn_seq += 1
|
||
out_dir = self._turn_output_dir(f"t{self._turn_seq}")
|
||
base_job = self.build_job(text, local_messages, out_dir)
|
||
|
||
def job(worker, _m=user_msg, _t=text, _a=attachments, _p=prefix, _j=base_job,
|
||
_review=review_switch):
|
||
# Worker thread: do the (possibly slow) attachment extraction here so
|
||
# the UI stays responsive, then run the real agent job.
|
||
"""Chạy nền: trích nội dung tệp đính kèm rồi mới chạy agent.
|
||
|
||
Trích ở luồng nền vì tệp lớn (PDF, Office) mất vài giây — làm ở luồng giao
|
||
diện là cả cửa sổ đứng hình.
|
||
"""
|
||
from ...core import usage_tracker
|
||
usage_tracker.set_context(self.kind, self.title or self.session_id)
|
||
body = self._augment(_t, _a, notify=worker.emit_event)
|
||
notes = self._session_notes()
|
||
if notes:
|
||
body = f"{body}\n\n{notes}" if body else notes
|
||
_m["content"] = (_p + "\n\n---\n\n" + body) if _p else body
|
||
if _review:
|
||
# Invisible to the chat bubble (that already shows the plain
|
||
# typed text) — only the payload actually sent to the model
|
||
# carries the note.
|
||
_m["content"] = f"{self._MODEL_SWITCH_REVIEW_NOTE}\n\n{_m['content']}"
|
||
return _j(worker)
|
||
|
||
worker = AgentWorker(job)
|
||
# A self-contained context for THIS turn, so its streaming events and files
|
||
# never touch another running turn's state. Signals bind the context via a
|
||
# default-arg so the right ctx is delivered on the UI thread. The "home_*"
|
||
# fields pin the turn to the conversation it started in, so it keeps saving
|
||
# there even if the user switches to another chat while it runs.
|
||
ctx: Dict[str, Any] = {
|
||
"worker": worker, "user_msg": user_msg, "assistant": None,
|
||
"record": turn, "messages": local_messages,
|
||
"snapshot_len": len(snapshot), "out_dir": out_dir,
|
||
"home_id": self.session_id, "home_messages": self.messages,
|
||
"home_title": self.title, "home_out_root": self.workspace_dir(),
|
||
# R06-T04: captured NOW, at submit time — see _persist_session's
|
||
# use of this. Without it, a background turn (this session isn't
|
||
# the one currently displayed) saves into whatever
|
||
# ctx.config.history_dir() resolves to AT THE TIME IT FINISHES,
|
||
# which is the *currently viewed* project's history folder if the
|
||
# user switched projects (ui/workspace_tab.py::_load_current)
|
||
# while this turn was still running — silently saving one
|
||
# project's conversation into another project's history folder.
|
||
"home_history_dir": self.ctx.config.history_dir(),
|
||
"detached": False,
|
||
# For re-rendering the in-progress turn if the user reopens this chat:
|
||
"display_text": text, "partial": "", "plan_steps": [],
|
||
# token/cost accounting: cumulative session usage BEFORE this turn, so
|
||
# the turn's own tokens are (after − before).
|
||
"usage_base": self._usage_snapshot(),
|
||
}
|
||
self._sessions_live[self.session_id] = self.messages
|
||
self._active[worker] = ctx
|
||
self.worker = worker
|
||
# Record the conversation in History right away (with the new user message,
|
||
# so it has a title) — it shows up and can be selected while it's running.
|
||
self._save_snapshot(self.session_id, local_messages, self.title)
|
||
self.history_changed.emit()
|
||
worker.event.connect(lambda ev, c=ctx: self._on_event(c, ev))
|
||
worker.permission_requested.connect(lambda a, c=ctx: self._on_permission(c, a))
|
||
worker.finished_ok.connect(lambda r, c=ctx: self._on_finished(c, r))
|
||
worker.failed.connect(lambda e, c=ctx: self._on_failed(c, e))
|
||
|
||
self.composer.set_running(True)
|
||
# One turn at a time PER conversation: this conversation now has a running
|
||
# turn, so further sends here go to the Queue (in order, no interleaving).
|
||
# Other conversations can still run in parallel up to the global cap.
|
||
if self._view_busy() or len(self._active) >= self._max_parallel():
|
||
self.composer.set_busy(True)
|
||
self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}")))
|
||
self.thinking.start("chat.running")
|
||
worker.start()
|
||
|
||
|
||
|
||
def _cleanup_turn(self, ctx: Dict[str, Any], ok: bool) -> None:
|
||
"""Hook: a turn just ended (``ok`` = finished vs failed). Given the turn
|
||
context, so a tab can promote/discard that turn's isolated output folder.
|
||
No-op in the base."""
|
||
|
||
def _session_notes(self) -> str:
|
||
"""Extra context folded into the outgoing user message (same layer as
|
||
attachment content) — e.g. Cowork lists files already produced earlier
|
||
in this conversation so the agent can reference/revise them by name
|
||
without the user re-uploading. No-op in the base."""
|
||
return ""
|
||
|
||
|
||
|
||
|
||
def _turn_is_live(self, ctx: Dict[str, Any]) -> bool:
|
||
"""True when the turn belongs to the currently-viewed conversation."""
|
||
return ctx.get("home_id") == self.session_id and not ctx.get("detached")
|
||
|
||
|
||
def _on_finished(self, ctx: Dict[str, Any], result: Dict[str, Any]) -> None:
|
||
"""Lượt chạy xong: dọn sandbox, giữ lại kế hoạch đã hoàn thành, rồi rút tiếp
|
||
tin trong hàng đợi.
|
||
|
||
Chỉ vẽ lên màn khi lượt này vẫn thuộc hội thoại ĐANG mở — người dùng có thể
|
||
đã chuyển sang hội thoại khác trong lúc chờ.
|
||
"""
|
||
live = self._turn_is_live(ctx)
|
||
self._end_turn(ctx)
|
||
self._cleanup_turn(ctx, True) # promote this turn's output folder, if any
|
||
self.status_message.emit(tr("chatpanel.done", name=tr(f"app.tab.{self.kind}")))
|
||
if live:
|
||
self._finalize_plan(ctx) # keep the completed plan shown
|
||
try:
|
||
self._show_usage(ctx) # per-turn + conversation token/cost
|
||
except Exception: # noqa: BLE001 — usage display must never break a turn
|
||
pass
|
||
done = self.chat_view.add_success(tr("chat.done_marker")) # green done marker in the chat box
|
||
folder = self.workspace_dir()
|
||
if folder:
|
||
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
|
||
ctx["record"]["bubbles"].append(done)
|
||
self._autosave()
|
||
else:
|
||
self._persist_session(ctx) # save the background conversation by id
|
||
self.turn_finished.emit(result)
|
||
# Notify only once EVERYTHING is done (no running turns, empty queue).
|
||
if not self._active and not self.composer.has_queue():
|
||
self._maybe_notify_teams(result)
|
||
self._drain_queue()
|
||
|
||
def _on_failed(self, ctx: Dict[str, Any], err: str) -> None:
|
||
"""Lượt chạy lỗi: huỷ thư mục kết quả tạm và hiện lỗi (nếu hội thoại còn đang mở)."""
|
||
live = self._turn_is_live(ctx)
|
||
self._end_turn(ctx)
|
||
self._cleanup_turn(ctx, False) # discard this turn's output sandbox
|
||
if live:
|
||
self.chat_view.add_error(err)
|
||
self.graph_event.emit(self.session_name, {"type": "error", "content": err})
|
||
from ...providers.base import is_model_not_found_error
|
||
|
||
if is_model_not_found_error(err) and ctx.get("display_text"):
|
||
# A "soft" failure, not a crash: the selected model itself is
|
||
# invalid/unavailable. Put the message back in the composer so
|
||
# the user can just pick a different model in Settings and hit
|
||
# Send again, instead of having to retype the whole prompt.
|
||
self.composer.set_text(ctx["display_text"])
|
||
else:
|
||
self._persist_session(ctx)
|
||
self.status_message.emit(tr("chatpanel.failed", name=tr(f"app.tab.{self.kind}")))
|
||
self.turn_finished.emit({"error": err})
|
||
self._drain_queue()
|
||
|
||
def _drain_queue(self) -> None:
|
||
# Start the NEXT queued message only while THIS conversation is idle (one
|
||
# turn at a time here) and the global cap allows. Starting one flips
|
||
# _view_busy() to True, so exactly one runs — the queue drains in order.
|
||
"""Rút tin nhắn kế tiếp trong hàng đợi.
|
||
|
||
Chỉ chạy khi hội thoại NÀY đang rảnh và chưa chạm trần số lượt song song
|
||
toàn cục. Khởi động một lượt làm ``_view_busy()`` thành True nên đúng một
|
||
tin được lấy mỗi vòng — hàng đợi rút theo đúng thứ tự.
|
||
"""
|
||
while (not self._view_busy() and len(self._active) < self._max_parallel()
|
||
and self.composer.has_queue()):
|
||
nxt = self.composer.pop_next()
|
||
if not nxt:
|
||
break
|
||
self._start_turn(nxt.get("text", ""), nxt.get("attachments", []))
|
||
|
||
def stop(self) -> None:
|
||
"""Dừng mọi lượt đang chạy của hội thoại này và xoá sạch hàng đợi."""
|
||
if not self._active:
|
||
return
|
||
for w in list(self._active):
|
||
if w.isRunning():
|
||
w.request_stop()
|
||
self.composer.clear_queue() # don't start anything still waiting
|
||
self.status_message.emit(tr("chatpanel.stopping", name=tr(f"app.tab.{self.kind}")))
|