refactor(chat): R08-T01..T06 — chat_panel.py 1821 -> 345, composer 663 -> 11
presentation/chat/
chat_history_widget.py 348 T01 mạch hội thoại (từ ui/chat_view.py)
chat_bubble_style.py 202 T01 cách vẽ bong bóng, diff, đường thời gian
composer_widget.py 364 T02 thanh công cụ quanh ô nhập
chat_input_box.py 328 T02 ô nhập: Ctrl+Enter, dán ảnh, popup /skill
attachment_picker.py 215 T03 đọc tệp đính kèm + chặn theo chính sách
chat_output_panel.py 186 T05 theo dõi thư mục output, hiện tệp mới
chat_turn_runner.py 281 T06 chạy một lượt
chat_event_stream.py 228 T06 nhận sự kiện phát về từ luồng nền
chat_session_store.py 413 T06 lưu/nạp phiên, đếm token, nối lại lượt
chat_agents.py 246 T06 chọn agent, skill, định tuyến model
chat_panel_layout.py 148 T06 bố cục hai cột
chat_helpers.py 53 T06 hàm và bảng tra dùng chung
ui/chat_panel.py 345 __init__ + trạng thái
ui/chat_view.py 10 vỏ chuyển tiếp
ui/composer.py 11 vỏ chuyển tiếp
R08-T04 KHÔNG LÀM ĐƯỢC: plan đòi audio_recorder_widget.py, nhưng trong repo
KHÔNG CÓ chức năng ghi âm nào — grep 'audio|record|voice|micro' toàn ui/ chỉ
ra chữ 'record' trong nghĩa 'ghi lại transcript'. Không có gì để tách, và tôi
không dựng một widget mới nhân danh refactor. Giống hệt trường hợp
connector_settings_widget.py ở T07.
_start_turn (144 dòng) và _on_event (127) để nguyên có chủ ý: 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 loại sự
kiện. Cắt nhỏ thì phải chuyền hàng chục biến trạng thái qua lại, đọc khó hơn.
Hai lỗi tự gây, cả hai đều do script:
* regex bỏ import cũ chỉ cắt DÒNG ĐẦU của một import nhiều dòng, để lại phần
đuôi mồ côi -> IndentationError.
* _build_layout dùng biến 'root' vốn cục bộ trong __init__. Bộ test bắt được
cái này (2 bài integration đỏ), không phải checker — vì nó là lỗi dựng
widget, không phải lỗi hình học.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f0fd3a41cd
commit
577b81a641
@@ -0,0 +1,246 @@
|
||||
"""Chọn agent, skill và định tuyến model cho khung chat — R08-T06.
|
||||
|
||||
``_apply_routing`` quyết định lượt này chạy bằng model nào: người dùng
|
||||
chọn tay, hay để bộ định tuyến tự chọn theo chính sách.
|
||||
|
||||
``_note_agent_switch`` ghi lại việc đổi agent giữa chừng vào chính mạch
|
||||
hội thoại — không ghi thì đọc lại transcript sẽ thấy giọng đổi đột ngột
|
||||
mà không hiểu vì sao.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
|
||||
|
||||
class ChatAgentsMixin:
|
||||
"""Trộn vào ChatPanel."""
|
||||
|
||||
def _agent_signature(self) -> str:
|
||||
"""Identifies WHAT will run the next turn (admin agent id, or plain
|
||||
provider:model) — comparing this across turns is how a genuine
|
||||
mid-conversation switch is detected."""
|
||||
agent = getattr(self, "_admin_agent", None)
|
||||
if agent is not None:
|
||||
return f"{self._ADMIN_AGENT_PREFIX}{agent.agent_id}"
|
||||
return f"{self.ctx.config.active_provider}:{self._model}"
|
||||
|
||||
def _current_agent_label(self) -> str:
|
||||
"""Human-friendly name of what will run the next turn — for the visible
|
||||
'auto-switched model' notice in the transcript."""
|
||||
agent = getattr(self, "_admin_agent", None)
|
||||
if agent is not None:
|
||||
return agent.name
|
||||
return self._model or tr("chat.provider_default_short")
|
||||
|
||||
def _on_agent_changed(self, _i: int) -> None:
|
||||
data = self.agent_combo.currentData() or ""
|
||||
if isinstance(data, str) and data.startswith(self._ADMIN_AGENT_PREFIX):
|
||||
# An Admin-defined agent preset (Monitoring → Agents Admin): runs
|
||||
# on its pinned model (or the Settings default when unpinned) and
|
||||
# injects its instructions into every turn of this tab.
|
||||
from ...core import admin_agents
|
||||
|
||||
agent_id = data[len(self._ADMIN_AGENT_PREFIX):]
|
||||
self._admin_agent = admin_agents.load_agent(
|
||||
agent_id, admin_agents.agents_admin_dir(self.ctx.config.shared_dir))
|
||||
self._agent_user_override = True
|
||||
self._agent_provider = self.ctx.config.active_provider
|
||||
self._model = (self._admin_agent.model if self._admin_agent else "") or ""
|
||||
if self._admin_agent is not None:
|
||||
self.status_message.emit(f"{self.session_name} agent: {self._admin_agent.name}")
|
||||
self._note_agent_switch()
|
||||
return
|
||||
self._admin_agent = None
|
||||
new = data or "" # "" → provider default
|
||||
if new != self._model:
|
||||
# A deliberate pick by the user — remember it until the provider changes.
|
||||
self._agent_user_override = True
|
||||
self._agent_provider = self.ctx.config.active_provider
|
||||
self._model = new
|
||||
if self._model:
|
||||
self.status_message.emit(f"{self.session_name} agent: {self._model}")
|
||||
self._note_agent_switch()
|
||||
|
||||
def _note_agent_switch(self) -> None:
|
||||
"""Flag a pending review note for the NEXT turn when the selection
|
||||
genuinely changed mid-conversation (there's already history AND this
|
||||
isn't just the initial default being applied)."""
|
||||
sig = self._agent_signature()
|
||||
last = getattr(self, "_last_turn_agent_signature", None)
|
||||
if last is not None and sig != last and self.messages:
|
||||
self._pending_agent_switch_review = True
|
||||
|
||||
def admin_agent_prompt(self) -> str:
|
||||
"""The selected admin agent's instructions ('' when a plain model is
|
||||
selected) — appended to the project context of every turn."""
|
||||
agent = getattr(self, "_admin_agent", None)
|
||||
return agent.effective_prompt() if agent is not None else ""
|
||||
|
||||
def refresh_agents(self) -> None:
|
||||
"""Fetch the model list from the active provider (in the background) and
|
||||
fill the per-tab Agent combo — called at start and on provider change.
|
||||
|
||||
The default follows Settings; see state.resolve_agent_default."""
|
||||
from ...state import resolve_agent_default
|
||||
|
||||
name = self.ctx.config.active_provider
|
||||
setting_model = self.ctx.config.provider_conf(name).get("model", "")
|
||||
keep, self._agent_user_override = resolve_agent_default(
|
||||
name, setting_model, self._model, self._agent_provider, self._agent_user_override)
|
||||
self._model = keep
|
||||
self._agent_provider = name
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
error = ""
|
||||
try:
|
||||
prov = self.ctx.build_provider_for(name)
|
||||
models = list(getattr(prov, "list_models", lambda: [])() or [])
|
||||
if not models:
|
||||
error = getattr(prov, "last_error", "")
|
||||
except Exception as exc: # noqa: BLE001 - never break the UI over a model list
|
||||
models, error = [], str(exc)
|
||||
return {"models": models, "keep": keep, "error": error}
|
||||
|
||||
def done(result) -> None:
|
||||
self._populate_agents(result.get("models", []), result.get("keep", ""))
|
||||
# Surface the REAL reason models didn't load (network/auth/config)
|
||||
# instead of silently falling back to "(provider default)".
|
||||
err = result.get("error", "")
|
||||
if err:
|
||||
self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._agent_worker = w
|
||||
w.start()
|
||||
|
||||
def _populate_agents(self, models, keep: str) -> None:
|
||||
self.agent_combo.blockSignals(True)
|
||||
self.agent_combo.clear()
|
||||
# The Agent picker is a MODEL picker — the raw model list of the active
|
||||
# provider. Admin-defined agents (Monitoring → Agents Admin) are NOT
|
||||
# listed here: they are system-management presets, not a model/agent to
|
||||
# pick for a Cowork conversation. To apply a work agent's persona, use
|
||||
# the /agent command (built-in + custom Flow agents).
|
||||
items = list(dict.fromkeys([m for m in models if m])) # dedupe, keep order
|
||||
if keep and keep not in items:
|
||||
items.insert(0, keep)
|
||||
for m in items:
|
||||
self.agent_combo.addItem(m, m)
|
||||
if not items and self.agent_combo.count() == 0:
|
||||
# No models found and none configured — placeholder with data=None so
|
||||
# we fall back to the provider's default model (never a fake name).
|
||||
self.agent_combo.addItem("(provider default)", None)
|
||||
keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}"
|
||||
if getattr(self, "_admin_agent", None) is not None else keep)
|
||||
idx = self.agent_combo.findData(keep_data) if keep_data else -1
|
||||
if idx >= 0:
|
||||
self.agent_combo.setCurrentIndex(idx)
|
||||
self.agent_combo.blockSignals(False)
|
||||
data = self.agent_combo.currentData() or ""
|
||||
if not (isinstance(data, str) and data.startswith(self._ADMIN_AGENT_PREFIX)):
|
||||
self._model = data or ""
|
||||
|
||||
def build_provider(self):
|
||||
"""Provider for THIS tab: the selected admin agent's pinned
|
||||
provider/model when one is selected, else the tab's selected model
|
||||
(or the provider's configured default when none is chosen)."""
|
||||
agent = getattr(self, "_admin_agent", None)
|
||||
if agent is not None:
|
||||
from ...core.admin_agents import build_agent_provider
|
||||
|
||||
return build_agent_provider(self.ctx, agent)
|
||||
# An Auto/Manual routing override (set by _apply_routing for this turn)
|
||||
# wins over the tab's own provider/model selection.
|
||||
provider = self._routed_provider or self.ctx.config.active_provider
|
||||
model = self._routed_model or self._model or None
|
||||
return self.ctx.build_provider_for(provider, model)
|
||||
|
||||
def _apply_routing(self, text: str, turn: Dict[str, Any]) -> None:
|
||||
"""Auto Model Routing hook — run once per outgoing message.
|
||||
|
||||
Since R03-T04 the Off/Auto/Manual/Fallback rules live in
|
||||
``application/model_routing/routing_application_service.py``; the copy
|
||||
that used to sit here (and again in Co4E and AI-Edit) is gone. What
|
||||
remains is the widget's own job: snapshot the tab's provider/model into
|
||||
a request, host the Manual-mode modal, and render the outcome by setting
|
||||
``self._routed_provider``/``self._routed_model`` for THIS turn (honoured
|
||||
by :meth:`build_provider`) plus a status bubble.
|
||||
|
||||
Never raises — a routing failure must never block sending a message; it
|
||||
just falls back to the tab's own model.
|
||||
"""
|
||||
# Recompute fresh each message; clear any previous turn's override.
|
||||
self._routed_provider = None
|
||||
self._routed_model = None
|
||||
# An explicitly-pinned Admin agent takes precedence over routing.
|
||||
if getattr(self, "_admin_agent", None) is not None:
|
||||
return
|
||||
try:
|
||||
from ...application.model_routing import (
|
||||
RoutingRequest,
|
||||
build_routing_application_service,
|
||||
)
|
||||
from ...ui.routing_toggle import confirm_switch
|
||||
|
||||
# The model the tab WOULD use without routing — the picker's choice,
|
||||
# or the provider's configured default when nothing is picked.
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
outcome = build_routing_application_service(self.ctx).resolve(
|
||||
RoutingRequest(
|
||||
surface=self.kind, # per-workspace mode key ("cowork"/…)
|
||||
prompt=text,
|
||||
current_provider=cur_provider,
|
||||
current_model=cur_model,
|
||||
),
|
||||
# Manual mode only: the modal stays in the presentation layer so
|
||||
# the application service never imports Qt.
|
||||
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
|
||||
)
|
||||
if not outcome.switched:
|
||||
return # off / nothing better / declined → keep the tab's model
|
||||
self._routed_provider = outcome.provider
|
||||
self._routed_model = outcome.model
|
||||
notice = self.chat_view.add_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=outcome.model, task=outcome.task_type,
|
||||
gain=f"{outcome.score_gain:.2f}"))
|
||||
turn["bubbles"].append(notice)
|
||||
except Exception: # noqa: BLE001 — routing must never block a chat turn
|
||||
self._routed_provider = None
|
||||
self._routed_model = None
|
||||
|
||||
def _apply_skill_command(self, text: str):
|
||||
"""Parse a leading ``/skill`` command typed in the chat box.
|
||||
|
||||
Returns ``(prefix, request, info)`` — see ``core.skills.parse_skill_command``."""
|
||||
try:
|
||||
from ...core.skills import parse_skill_command
|
||||
return parse_skill_command(text)
|
||||
except Exception:
|
||||
return "", text, "Could not read skills from the Skills manager."
|
||||
|
||||
def _apply_agent_command(self, text: str):
|
||||
"""Parse a ``/agent`` command typed in the chat box (Cowork parity with
|
||||
Co4E): apply a named agent PERSONA to the turn. Returns
|
||||
``(prefix, request, info)`` — see ``core.agent_command.parse_agent_command``."""
|
||||
try:
|
||||
from ...core.agent_command import parse_agent_command
|
||||
return parse_agent_command(text, self.ctx.config.shared_dir)
|
||||
except Exception: # noqa: BLE001
|
||||
return "", text, "Could not read the agent catalog."
|
||||
|
||||
def _open_skills_manager(self) -> None:
|
||||
"""Open the Skills manager (add / edit / delete / enable skills)."""
|
||||
from ...ui.skills_dialog import SkillsDialog
|
||||
|
||||
SkillsDialog(self, self.ctx).exec()
|
||||
self._skills_changed()
|
||||
self.status_message.emit(tr("chatpanel.skills_updated"))
|
||||
|
||||
def _skills_changed(self) -> None:
|
||||
"""Hook after skills were edited (Code tab refreshes its Skills button)."""
|
||||
Reference in New Issue
Block a user