CI / test (pull_request) Canceled after 0s
- co4e_config_panel: StepConfigPanel reorganized into four collapsible, borderless sections (Co ban / Model & Quyen / Skills & Tep / Agent song song) instead of one long form. Agent song song is its own section, shown only for parallel-variant steps. Fixes a QVBoxLayout without a trailing stretch that was handing every section an equal share of the scroll area's leftover height instead of sizing to content. - schedule_task_tab / theme: Kanban board lane gutters and per-lane margins trimmed so the seven lanes read their card content more clearly on a laptop-width window. - chat_panel / widgets: the Output Files list in Cowork/Code no longer stops at a fixed height with empty panel below it - it now fills the column down to the composer. - composer: Attach/Send/Stop buttons match the input box's height and align to its top/bottom edge instead of floating centered beside it. - app.py: the sidebar's "All projects" link reads as a link (italic, accent color) instead of a plain row. - i18n: labels for the three renamed Co4E section headers (VI/EN/JA).
1796 lines
88 KiB
Python
1796 lines
88 KiB
Python
"""Base chat panel shared by the Cowork and Code tabs.
|
||
|
||
Provides: streaming transcript, a message queue, and history autosave.
|
||
|
||
Several messages can run **at the same time** inside one tab: each turn owns its
|
||
own worker thread and its own turn-context (assistant bubble, transcript record,
|
||
message list, output folder), so their streaming output and files never collide.
|
||
The number of simultaneous turns is capped by ``cowork.max_parallel`` (default 5);
|
||
extra messages wait in the composer queue and start automatically as slots free
|
||
up. Graph events are still forwarded per session.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from PySide6.QtCore import Qt, QTimer, Signal
|
||
from PySide6.QtCore import QFileSystemWatcher
|
||
from PySide6.QtWidgets import (
|
||
QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QSplitter,
|
||
QVBoxLayout, QWidget,
|
||
)
|
||
|
||
from ..core.worker import AgentWorker
|
||
from ..i18n import on_language_changed, tr
|
||
from ..state import AppContext
|
||
from ..theme import current_palette
|
||
from .chat_view import ChatView, ThinkingIndicator
|
||
from .composer import Composer
|
||
from .icons import collapse_right_icon, icon as app_icon
|
||
from .osutil import is_image, open_path
|
||
from .widgets import CollapsibleSection, CollapseStrip, PlanSection
|
||
|
||
|
||
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
|
||
|
||
# Friendly "what the agent is doing now" translation keys for the working
|
||
# indicator, so a long file/document build reads as "Creating…" rather than a
|
||
# generic "Running".
|
||
_TOOL_STATUS = {
|
||
"save_file": "chat.creating",
|
||
"write_file": "chat.creating",
|
||
"run_command": "chat.creating",
|
||
"edit_file": "chat.editing",
|
||
"install_package": "chat.installing",
|
||
"read_file": "chat.reading",
|
||
}
|
||
|
||
|
||
def _format_plan_steps(steps) -> str:
|
||
"""Render plan steps ``[{title, status}]`` as an icon checklist for the chat."""
|
||
lines = []
|
||
for s in steps or []:
|
||
title = str((s or {}).get("title", "")).strip()
|
||
if not title:
|
||
continue
|
||
icon = _PLAN_ICONS.get(str((s or {}).get("status", "pending")).lower(), "○")
|
||
lines.append(f"{icon} {title}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _is_scratch(path: str) -> bool:
|
||
"""True for helper/intermediate files (kept out of the Output list)."""
|
||
try:
|
||
return ".scratch" in Path(path).parts
|
||
except Exception: # noqa: BLE001
|
||
return False
|
||
|
||
|
||
class ChatPanel(QWidget):
|
||
graph_event = Signal(str, dict) # (session_name, event)
|
||
turn_finished = Signal(dict)
|
||
status_message = Signal(str)
|
||
output_changed = Signal(str) # workspace dir; emitted when a file is written
|
||
history_changed = Signal() # a session was created/updated → refresh History
|
||
|
||
def __init__(self, ctx: AppContext, kind: str, session_name: str,
|
||
placeholder_key: str = "composer.placeholder_default"):
|
||
super().__init__()
|
||
from ..core.history import new_session_id
|
||
|
||
self.ctx = ctx
|
||
self.kind = kind
|
||
self.session_name = session_name
|
||
self.session_id = new_session_id()
|
||
self.title = ""
|
||
self._notify_title()
|
||
# Which project (workspace) this conversation belongs to — every new
|
||
# thread inherits the currently selected project (Claude-Projects style).
|
||
self.project_id = "default"
|
||
self.messages: List[Dict[str, Any]] = []
|
||
# self.worker points at the most-recently-started worker (kept for
|
||
# back-compat); every running turn is tracked in self._active so several
|
||
# can run concurrently. Each value is a turn-context dict — see _start_turn.
|
||
self.worker: AgentWorker | None = None
|
||
self._active: Dict[AgentWorker, Dict[str, Any]] = {}
|
||
self._turn_seq: int = 0
|
||
# session_id -> its live messages list, for every conversation that still has
|
||
# a turn running. Lets you start a new chat / reopen an old one WHILE work
|
||
# runs: the running turn keeps writing to its own conversation in the
|
||
# background, and reopening it attaches to the SAME list (never a stale disk
|
||
# copy), so the two never race on save.
|
||
self._sessions_live: Dict[str, List[Dict[str, Any]]] = {}
|
||
self._teams_worker: AgentWorker | None = None
|
||
self.turns: List[Dict[str, Any]] = []
|
||
|
||
# File system watcher — watches the workspace/output folder for new files
|
||
# and auto-loads them into the agent's context on the next turn.
|
||
self._file_watcher = QFileSystemWatcher(self)
|
||
self._file_watcher.directoryChanged.connect(self._on_watched_dir_changed)
|
||
self._known_files: set = set() # set of known file paths in the watched dir
|
||
self._watch_debounce = QTimer(self)
|
||
self._watch_debounce.setSingleShot(True)
|
||
self._watch_debounce.setInterval(800) # debounce rapid file changes
|
||
self._watch_debounce.timeout.connect(self._process_new_watched_files)
|
||
self._watched_dir: Optional[Path] = None
|
||
|
||
root = QVBoxLayout(self)
|
||
root.setContentsMargins(0, 0, 0, 0)
|
||
root.setSpacing(0)
|
||
|
||
self._toolbar = QWidget()
|
||
self.toolbar_v = QVBoxLayout(self._toolbar)
|
||
self.toolbar_v.setContentsMargins(10, 8, 10, 4)
|
||
self.toolbar_v.setSpacing(4)
|
||
self.toolbar_layout = QHBoxLayout() # first row; tabs may add more rows
|
||
self.toolbar_layout.setSpacing(8)
|
||
self.toolbar_v.addLayout(self.toolbar_layout)
|
||
root.addWidget(self._toolbar)
|
||
|
||
self.chat_view = ChatView()
|
||
self.composer = Composer(placeholder_key)
|
||
self.composer.submitted.connect(self.submit)
|
||
self.composer.stop_requested.connect(self.stop)
|
||
self.composer.attachments_added.connect(self._on_attachments_added)
|
||
self.composer.attachment_removed.connect(self._on_attachment_removed)
|
||
self.composer.attach_limit_note.connect(self.status_message)
|
||
self.composer.manage_skills.connect(self._open_skills_manager)
|
||
self.composer.set_max_attachments(
|
||
int(ctx.config.data.get("attachments", {}).get("max_files", 10) or 0))
|
||
# Conversation token/cost total (↓in ↑out ▤total $cost) — bottom-left,
|
||
# updated after each turn; cost uses the Monitoring model-price table.
|
||
self._usage_total_lbl = QLabel("")
|
||
self._usage_total_lbl.setObjectName("hint")
|
||
self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};")
|
||
|
||
|
||
# Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma /
|
||
# qwen for the local provider). Cowork and Code pick independently and
|
||
# run in parallel. The list is fetched from the active provider.
|
||
# The per-tab Agent defaults to the Settings model on startup; a manual
|
||
# pick (override) is remembered only until the active provider changes.
|
||
self._model = ctx.config.provider_conf().get("model", "")
|
||
self._agent_provider = ctx.config.active_provider
|
||
self._agent_user_override = False
|
||
self._admin_agent = None # selected Admin-defined agent preset, if any
|
||
# Auto Model Routing override for the NEXT turn (set by _apply_routing when
|
||
# the router picks a different model). None → use the tab's own selection.
|
||
self._routed_provider: Optional[str] = None
|
||
self._routed_model: Optional[str] = None
|
||
self._last_turn_agent_signature = None # what ran the LAST turn (see _note_agent_switch)
|
||
self._pending_agent_switch_review = False
|
||
self._agent_worker: AgentWorker | None = None
|
||
self._agent_lbl = QLabel(tr("chatpanel.agent_label"))
|
||
self._agent_lbl.setObjectName("hint")
|
||
self.agent_combo = QComboBox()
|
||
self.agent_combo.setMinimumWidth(150)
|
||
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
|
||
self.agent_combo.currentIndexChanged.connect(self._on_agent_changed)
|
||
self.composer.add_bottom_left(self._agent_lbl)
|
||
self.composer.add_bottom_left(self.agent_combo)
|
||
# Off/Auto/Manual routing toggle — lets the router pick the best-fit
|
||
# model per message (see core/routing + _apply_routing).
|
||
from .routing_toggle import RoutingToggle
|
||
self.routing_toggle = RoutingToggle(ctx, self.kind)
|
||
# The drawing reads the strip left to right as
|
||
# Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder
|
||
# so these sit together on the left, with the folder box the Cowork tab
|
||
# appends landing after them. Nén and Tự chạy stay on the right, where
|
||
# the control inventory marks them "giữ nguyên tại chỗ".
|
||
self.composer.add_bottom_left(self.routing_toggle)
|
||
self.composer.add_bottom_left(self._usage_total_lbl)
|
||
# Manual "compress conversation" — trim old history to cut tokens.
|
||
self.compress_btn = QPushButton(tr("chatpanel.compress_btn"))
|
||
self.compress_btn.setIcon(app_icon("compress"))
|
||
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
|
||
self.compress_btn.clicked.connect(self._compress_messages)
|
||
self.composer.add_bottom_right(self.compress_btn)
|
||
self.refresh_agents()
|
||
|
||
# Chat column: transcript expands, the chat box is pinned at the bottom.
|
||
chat_col = QWidget()
|
||
cc = QVBoxLayout(chat_col)
|
||
cc.setContentsMargins(0, 0, 0, 0)
|
||
cc.setSpacing(0)
|
||
cc.addWidget(self.chat_view, 1)
|
||
self.thinking = ThinkingIndicator() # animated "working…" line while we wait
|
||
cc.addWidget(self.thinking)
|
||
self.center_split = QSplitter(Qt.Horizontal)
|
||
self.center_split.addWidget(chat_col)
|
||
root.addWidget(self.center_split, 1)
|
||
|
||
# The composer spans the whole screen, under BOTH columns — that is how
|
||
# the drawing lays it out, and it is the reason the files panel can sit
|
||
# beside the transcript without narrowing what you type into. Inside the
|
||
# chat column it stopped at the panel's edge and the input shrank
|
||
# whenever files appeared.
|
||
composer_wrap = QWidget()
|
||
cwl = QVBoxLayout(composer_wrap)
|
||
cwl.setContentsMargins(8, 4, 8, 8)
|
||
cwl.addWidget(self.composer)
|
||
root.addWidget(composer_wrap)
|
||
|
||
# Right sidebar: Output files only (see below — Input is tracked but
|
||
# not shown).
|
||
self.input_section = CollapsibleSection(tr("widgets.input_files"))
|
||
# No cap: this section owns the whole right panel (its header is
|
||
# hoisted into io_hdr below), so the list should fill the space down
|
||
# to the composer instead of stopping at a fixed height with empty
|
||
# panel below it.
|
||
self.output_section = CollapsibleSection(tr("widgets.output_files").upper(), max_height=None)
|
||
# Input files are NOT shown in Cowork's UI anymore — but they're still
|
||
# fully tracked (add/remove/paths()) exactly as before, since that list
|
||
# is what gets written into the conversation's own "inputs" field on
|
||
# save (kept alongside the conversation; nothing here deletes the
|
||
# user's actual files — the conversation JSON itself only disappears
|
||
# when the conversation is deleted, same as always). Give input_section
|
||
# a real, permanently-hidden PARENT (not just "never added to a layout")
|
||
# so its own internal auto-show-on-add() call can never pop it up as a
|
||
# stray floating window.
|
||
self._input_hidden_host = QWidget(self)
|
||
self._input_hidden_host.setVisible(False)
|
||
_hh_lay = QVBoxLayout(self._input_hidden_host)
|
||
_hh_lay.setContentsMargins(0, 0, 0, 0)
|
||
_hh_lay.addWidget(self.input_section)
|
||
self.plan_section = PlanSection(tr("widgets.plan_title")) # live step checklist, above the Files panel
|
||
# The plan is shown INLINE in the conversation now (see add_plan), so this
|
||
# legacy right-panel checklist is parked inside the permanently-hidden
|
||
# host. Without a parent it would pop as a stray top-level "Plan (N)"
|
||
# window the moment set_steps() made it visible — parenting it here keeps
|
||
# its set_steps/clear calls truly inert (a hidden ancestor never renders).
|
||
_hh_lay.addWidget(self.plan_section)
|
||
self.input_section.activated.connect(self._open_io_item)
|
||
self.output_section.activated.connect(self._open_io_item)
|
||
# Right-click a file → Open / "View & AI Edit" (in-app viewer+editor).
|
||
for section in (self.input_section, self.output_section):
|
||
section.list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
section.list.customContextMenuRequested.connect(
|
||
lambda pos, s=section: self._io_context_menu(s, pos))
|
||
self._io_widget = QWidget()
|
||
iol = QVBoxLayout(self._io_widget)
|
||
iol.setContentsMargins(6, 6, 6, 6)
|
||
iol.setSpacing(4)
|
||
io_hdr = QHBoxLayout()
|
||
self._io_collapse_btn = QPushButton()
|
||
self._io_collapse_btn.setIcon(collapse_right_icon())
|
||
self._io_collapse_btn.setFixedWidth(28)
|
||
self._io_collapse_btn.clicked.connect(lambda: self._set_io_collapsed(True))
|
||
self._files_header = QLabel()
|
||
self._files_header.setStyleSheet("font-weight:600;")
|
||
# The drawing gives this panel ONE heading — "TỆP ĐẦU RA (3) ›" — and
|
||
# the section already draws exactly that, count included. A separate
|
||
# "Files" label above it was the same thing said twice, so the section's
|
||
# own header moves onto this row and the collapse chevron sits at its
|
||
# right, where the drawing puts it. _files_header stays for the tabs
|
||
# that still label their panel, just not in this layout.
|
||
self._files_header.setVisible(False)
|
||
io_hdr.addWidget(self.output_section.header, 1)
|
||
io_hdr.addWidget(self._io_collapse_btn)
|
||
# The plan now shows INLINE in the conversation (an expandable block whose
|
||
# steps tick off as they complete), not in this right panel — so it's kept
|
||
# out of the layout here. The object stays (its set_steps/clear calls are
|
||
# harmless no-ops on a hidden widget).
|
||
self.plan_section.setVisible(False)
|
||
iol.addLayout(io_hdr)
|
||
bl_host = QWidget()
|
||
bl = QVBoxLayout(bl_host)
|
||
bl.setContentsMargins(0, 0, 0, 0)
|
||
bl.setSpacing(4)
|
||
bl.addWidget(self.output_section, 1) # Output only — Input is tracked but hidden; fills down to the composer
|
||
iol.addWidget(bl_host, 1)
|
||
|
||
# Collapsing shrinks the panel to a thin clickable line (not hidden).
|
||
# The collapse button lives in the panel header; the strip re-expands.
|
||
self._io_strip = CollapseStrip(tr("chatpanel.expand_files_tooltip"), expand_dir="left")
|
||
self._io_strip.clicked.connect(lambda: self._set_io_collapsed(False))
|
||
self._io_strip.setVisible(False)
|
||
self._io_pane = QWidget()
|
||
pl = QHBoxLayout(self._io_pane)
|
||
pl.setContentsMargins(0, 0, 0, 0)
|
||
pl.setSpacing(0)
|
||
pl.addWidget(self._io_strip)
|
||
pl.addWidget(self._io_widget, 1)
|
||
|
||
self.center_split.addWidget(self._io_pane)
|
||
self.center_split.setStretchFactor(0, 1)
|
||
self.center_split.setStretchFactor(1, 0)
|
||
self.center_split.setChildrenCollapsible(False)
|
||
self.center_split.setSizes([820, 220])
|
||
on_language_changed(self._retranslate_base)
|
||
|
||
def _retranslate_base(self) -> None:
|
||
"""Re-apply the current language to the chrome shared by every tab
|
||
(Cowork/Code toolbars call their own retranslate on top of this)."""
|
||
self._agent_lbl.setText(tr("chatpanel.agent_label"))
|
||
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
|
||
self.compress_btn.setText(tr("chatpanel.compress_btn"))
|
||
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
|
||
self.input_section.set_title(tr("widgets.input_files"))
|
||
self.output_section.set_title(tr("widgets.output_files").upper())
|
||
self.plan_section.set_title(tr("widgets.plan_title"))
|
||
self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip"))
|
||
self._files_header.setText(tr("chatpanel.files_header"))
|
||
self._io_strip.setToolTip(tr("chatpanel.expand_files_tooltip"))
|
||
|
||
def apply_theme(self) -> None:
|
||
"""Re-apply theme styles to the chat view so all existing message bubbles
|
||
adapt when the app switches between light and dark modes."""
|
||
self.chat_view.apply_theme()
|
||
|
||
# ---- hooks for subclasses ---------------------------------------
|
||
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 _turn_output_dir(self, turn_id: str) -> Optional[Path]:
|
||
"""Isolated output folder for one turn (None = share/no files). Overridden
|
||
by tabs that write files, so concurrent turns never clobber each other."""
|
||
return None
|
||
|
||
def assistant_title(self) -> str:
|
||
return tr("chat.assistant")
|
||
|
||
def workspace_dir(self) -> Optional[Path]:
|
||
"""Folder shown via the 'open folder' link on messages (None = no link)."""
|
||
return None
|
||
|
||
def register_output(self, path: str) -> None:
|
||
"""Add a finished file to the Output list — skips intermediate/helper
|
||
files (.scratch/ and, for Cowork, generator scripts) so only real
|
||
deliverables show up. Auto-expands the Files panel if it was collapsed."""
|
||
if _is_scratch(path) or self._is_intermediate_output(path):
|
||
return
|
||
# Auto-expand the Files panel if collapsed so the new file is visible.
|
||
if not self._io_widget.isVisible():
|
||
self._set_io_collapsed(False)
|
||
self.output_section.add(path)
|
||
wd = self.workspace_dir()
|
||
if wd:
|
||
# Let the Structure (RAG) graph auto-refresh from this workspace.
|
||
self.output_changed.emit(str(wd))
|
||
|
||
# ---- file system watcher for auto-loading new files --------------
|
||
def _start_watching(self, directory: Path) -> None:
|
||
"""Start watching ``directory`` for new files. When new supported files
|
||
appear, they are automatically loaded into the agent's context on the
|
||
next turn (via ``_augment``)."""
|
||
if self._watched_dir == directory:
|
||
return
|
||
self._stop_watching()
|
||
try:
|
||
directory = directory.resolve()
|
||
if not directory.is_dir():
|
||
return
|
||
self._watched_dir = directory
|
||
self._file_watcher.addPath(str(directory))
|
||
# Snapshot the current set of files so we can detect NEW ones.
|
||
self._known_files = set(
|
||
str(p) for p in directory.iterdir()
|
||
if p.is_file() and not p.name.startswith(".")
|
||
and p.suffix.lower() in self._INPUT_EXTS
|
||
)
|
||
except OSError:
|
||
self._watched_dir = None
|
||
self._known_files = set()
|
||
|
||
def _stop_watching(self) -> None:
|
||
"""Stop watching the current directory."""
|
||
if self._watched_dir is not None:
|
||
try:
|
||
self._file_watcher.removePath(str(self._watched_dir))
|
||
except OSError:
|
||
pass
|
||
self._watched_dir = None
|
||
self._known_files = set()
|
||
|
||
def _on_watched_dir_changed(self, path: str) -> None:
|
||
"""Called when the watched directory changes. Debounces rapid changes."""
|
||
if path == str(self._watched_dir):
|
||
self._watch_debounce.start()
|
||
|
||
def _process_new_watched_files(self) -> None:
|
||
"""Compare current files against the known set and notify about new ones."""
|
||
if self._watched_dir is None:
|
||
return
|
||
try:
|
||
current = set(
|
||
str(p) for p in self._watched_dir.iterdir()
|
||
if p.is_file() and not p.name.startswith(".")
|
||
and p.suffix.lower() in self._INPUT_EXTS
|
||
)
|
||
except OSError:
|
||
return
|
||
new_files = current - self._known_files
|
||
if not new_files:
|
||
self._known_files = current
|
||
return
|
||
self._known_files = current
|
||
# Add new files to the Input section so the user can see them.
|
||
for fp in sorted(new_files):
|
||
self.input_section.add(fp)
|
||
# Emit a status message so the user knows new files were detected.
|
||
names = ", ".join(Path(p).name for p in sorted(new_files))
|
||
self.status_message.emit(
|
||
tr("chatpanel.new_files_detected", names=names, n=len(new_files))
|
||
)
|
||
|
||
def _is_intermediate_output(self, path: str) -> bool:
|
||
"""Override hook: hide helper/generator files from the Output list."""
|
||
return False
|
||
|
||
def on_file_written(self, path: str) -> None:
|
||
"""Hook: the agent created/edited a file (shown in the Output box)."""
|
||
self.register_output(path)
|
||
|
||
def on_inputs_added(self, paths: List[str]) -> None:
|
||
for p in paths:
|
||
self.input_section.add(p)
|
||
|
||
def _on_attachments_added(self, paths: List[str]) -> None:
|
||
# Push attachments into the Input box as soon as they're attached.
|
||
for p in paths:
|
||
self.input_section.add(p)
|
||
|
||
def _on_attachment_removed(self, path: str) -> None:
|
||
# A file added by mistake was removed in the composer — drop it from the
|
||
# Input panel too (only matters before the message is sent).
|
||
self.input_section.remove(path)
|
||
|
||
def _open_io_item(self, path: str) -> None:
|
||
open_path(path)
|
||
|
||
def _io_context_menu(self, section, pos) -> None:
|
||
"""Right-click menu on a file in the Input/Output lists: Open with the
|
||
OS app, or view + AI-edit it inside the app (FileEditDialog)."""
|
||
item = section.list.itemAt(pos)
|
||
if item is None:
|
||
return
|
||
path = item.data(Qt.UserRole)
|
||
if not path:
|
||
return
|
||
from PySide6.QtWidgets import QMenu
|
||
|
||
menu = QMenu(self)
|
||
act_open = menu.addAction(app_icon("link"), tr("chatpanel.menu_open"))
|
||
act_edit = menu.addAction(app_icon("edit"), tr("chatpanel.menu_ai_edit"))
|
||
chosen = menu.exec(section.list.mapToGlobal(pos))
|
||
if chosen is act_open:
|
||
open_path(path)
|
||
elif chosen is act_edit:
|
||
from .file_edit_dialog import FileEditDialog
|
||
|
||
FileEditDialog(self.ctx, path, self).exec()
|
||
|
||
# ---- skills management (shared by Cowork and Code) ---------------
|
||
def _open_skills_manager(self) -> None:
|
||
"""Open the Skills manager (add / edit / delete / enable skills)."""
|
||
from .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)."""
|
||
|
||
# ---- per-tab agent (model / admin-agent preset) selection --------
|
||
_ADMIN_AGENT_PREFIX = "admin:"
|
||
# Sent (invisibly — folded into the outgoing content, never the visible
|
||
# chat bubble) as a one-shot prefix on the FIRST turn run under a newly
|
||
# picked model/agent, when the conversation already has prior turns: asks
|
||
# the new model to check over the most recent step before doing anything
|
||
# new, so a mid-conversation switch doesn't silently drop continuity.
|
||
_MODEL_SWITCH_REVIEW_NOTE = (
|
||
"[Note: the AI model/agent for this conversation was just switched.] Before "
|
||
"addressing the request below, briefly re-check the most recent step above — "
|
||
"if anything there looks incomplete, inconsistent, or wrong, redo or fix it "
|
||
"first, then continue."
|
||
)
|
||
|
||
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.
|
||
|
||
Off → no-op. Auto → silently switch to the best-fit model. Manual → ask
|
||
the user (modal, with the configured confirm timeout) before switching.
|
||
Sets ``self._routed_provider``/``self._routed_model`` for THIS turn;
|
||
:meth:`build_provider` honours them. 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
|
||
if not (text or "").strip():
|
||
return
|
||
try:
|
||
mode = self.ctx.project_routing_mode(self.kind) # per-workspace mode
|
||
if mode == "off":
|
||
return
|
||
service = self.ctx.routing()
|
||
cur_provider = self.ctx.config.active_provider
|
||
cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||
result = service.route(self.kind, text, cur_provider, cur_model, mode_override=mode)
|
||
if not result.should_switch:
|
||
return
|
||
target = result.target()
|
||
if target is None:
|
||
return
|
||
to_provider, to_model = target
|
||
if mode == "manual":
|
||
from .routing_toggle import confirm_switch
|
||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||
if not confirm_switch(self, result.decision, timeout):
|
||
return # declined / timed out → keep current model
|
||
self._routed_provider = to_provider
|
||
self._routed_model = to_model
|
||
notice = self.chat_view.add_status(tr(
|
||
"routing.switched_notice",
|
||
model=to_model, task=result.task_type.value,
|
||
gain=f"{result.decision.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 _compress_messages(self) -> None:
|
||
"""Manual compress: keep the system prompt + the last 2 turns verbatim and
|
||
DIGEST all older messages into one compact summary, shrinking it until the
|
||
whole conversation is under 25% of its original token size."""
|
||
if self._view_busy():
|
||
self.status_message.emit(tr("chatpanel.compress_busy"))
|
||
return
|
||
from ..core.usage_tracker import estimate_tokens
|
||
|
||
msgs = list(self.messages)
|
||
|
||
def _tok(ms):
|
||
return sum(estimate_tokens(str(m.get("content", ""))) for m in ms)
|
||
|
||
orig = _tok(msgs)
|
||
systems = [m for m in msgs if m.get("role") == "system"]
|
||
rest = [m for m in msgs if m.get("role") != "system"]
|
||
starts = [i for i, m in enumerate(rest) if m.get("role") == "user"]
|
||
if len(starts) <= 2 or orig <= 0:
|
||
self.status_message.emit(tr("chatpanel.compress_short"))
|
||
return
|
||
cut = starts[-2] # keep the last 2 turns verbatim
|
||
old, recent = rest[:cut], rest[cut:]
|
||
old_tok = _tok(old) or 1 # target: digest < 25% of the OLD part
|
||
|
||
def _digest(per_msg: int):
|
||
parts = []
|
||
for m in old:
|
||
c = str(m.get("content", "")).strip().replace("\n", " ")
|
||
if c:
|
||
parts.append(f"- {m.get('role', '')}: {c[:per_msg]}")
|
||
body = "\n".join(parts)
|
||
return {"role": "user",
|
||
"content": f"[{tr('chatpanel.compress_digest_header', n=len(old))}]\n{body}"}
|
||
|
||
per_msg = 240
|
||
digest = _digest(per_msg)
|
||
# shrink the digest until the OLD conversation is under 25% of its size
|
||
while _tok([digest]) > 0.25 * old_tok and per_msg > 20:
|
||
per_msg = max(20, per_msg // 2)
|
||
digest = _digest(per_msg)
|
||
self.messages = systems + [digest] + recent
|
||
pct = int(_tok([digest]) * 100 / old_tok)
|
||
self.status_message.emit(tr("chatpanel.compress_reduced", pct=pct, n=len(old)))
|
||
|
||
def _set_io_collapsed(self, collapsed: bool) -> None:
|
||
self._io_widget.setVisible(not collapsed)
|
||
self._io_strip.setVisible(collapsed)
|
||
strip_w = CollapseStrip.WIDTH + 2
|
||
if collapsed:
|
||
self._io_pane.setMaximumWidth(strip_w)
|
||
self._collapse_split_pane(self._io_pane, strip_w)
|
||
else:
|
||
self._io_pane.setMaximumWidth(16777215) # QWIDGETSIZE_MAX
|
||
self._restore_split_sizes()
|
||
|
||
# ---- shared split-pane collapse helpers (used by subclasses too) ----
|
||
def _collapse_split_pane(self, pane: QWidget, strip_w: int) -> None:
|
||
"""Shrink one splitter pane to ``strip_w`` and hand the freed width to
|
||
the widest remaining pane. Works for any number of panes."""
|
||
sizes = self.center_split.sizes()
|
||
idx = self.center_split.indexOf(pane)
|
||
if not (0 <= idx < len(sizes)):
|
||
return
|
||
diff = sizes[idx] - strip_w
|
||
sizes[idx] = strip_w
|
||
others = [i for i in range(len(sizes)) if i != idx and sizes[i] > 0]
|
||
if others and diff != 0:
|
||
big = max(others, key=lambda i: sizes[i])
|
||
sizes[big] = max(strip_w, sizes[big] + diff)
|
||
self.center_split.setSizes(sizes)
|
||
|
||
def _restore_split_sizes(self) -> None:
|
||
"""Default expanded layout; panes still collapsed stay thin (max-width)."""
|
||
self.center_split.setSizes([820, 220])
|
||
|
||
# ---- delete a turn (message + its input/output files) ------------
|
||
def _delete_turn(self, turn: Dict[str, Any]) -> None:
|
||
files = [p for p in (turn.get("inputs", []) + turn.get("outputs", [])) if p]
|
||
if files:
|
||
preview = "\n".join("• " + str(p) for p in files[:12])
|
||
prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview)
|
||
else:
|
||
prompt = tr("chatpanel.delete_confirm_plain")
|
||
if QMessageBox.question(self, tr("chatpanel.delete_confirm_title"), prompt) != QMessageBox.Yes:
|
||
return
|
||
for bubble in turn.get("bubbles", []):
|
||
bubble.setParent(None)
|
||
bubble.deleteLater()
|
||
ids = {id(m) for m in turn.get("messages", [])}
|
||
if ids:
|
||
self.messages = [m for m in self.messages if id(m) not in ids]
|
||
for p in files:
|
||
try:
|
||
fp = Path(p)
|
||
if fp.is_file():
|
||
fp.unlink()
|
||
except OSError:
|
||
pass
|
||
if turn in self.turns:
|
||
self.turns.remove(turn)
|
||
self._rebuild_io()
|
||
self._autosave()
|
||
self.status_message.emit(tr("chatpanel.delete_done"))
|
||
|
||
def _rebuild_io(self) -> None:
|
||
self.input_section.clear()
|
||
self.output_section.clear()
|
||
for t in self.turns:
|
||
for p in t.get("inputs", []):
|
||
self.input_section.add(p)
|
||
for p in t.get("outputs", []):
|
||
self.output_section.add(p)
|
||
|
||
# ---- turn lifecycle ---------------------------------------------
|
||
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.
|
||
self._start_turn(text, attachments or [])
|
||
|
||
def _attach_char_limit(self) -> int:
|
||
"""Per-file content cap (characters) from the Settings token limit
|
||
(~4 chars/token)."""
|
||
try:
|
||
tokens = int(self.ctx.config.data.get("attachments", {}).get("max_tokens", 500000))
|
||
except (TypeError, ValueError):
|
||
tokens = 500000
|
||
return max(1000, tokens) * 4
|
||
|
||
# File types considered valid input data in the workspace/output folder
|
||
_INPUT_EXTS = {
|
||
".csv", ".json", ".txt", ".md", ".log", ".xml", ".yaml", ".yml",
|
||
".docx", ".docm", ".xlsx", ".xlsm", ".pptx", ".pdf", ".odt", ".ods", ".odp",
|
||
".rtf", ".tsv",
|
||
}
|
||
|
||
def _augment(self, text: str, attachments: List[str], notify=None) -> str:
|
||
"""Embed attachment paths AND their extracted contents into the prompt so
|
||
the agent actually reads and analyses each attached file.
|
||
|
||
Additionally, scans the workspace/output folder for existing files and
|
||
loads them as input data so the agent can read/process them automatically.
|
||
|
||
``notify``, if given, is called with UI-visible events (a live "reading
|
||
page X/Y" progress notice, and a warning when a file's content could not
|
||
be read) instead of failures being silently handed to the model as an
|
||
opaque inline note."""
|
||
has_attachments = bool(attachments)
|
||
limit = self._attach_char_limit()
|
||
lines = [text] if text else []
|
||
|
||
# --- User-attached files ---
|
||
if has_attachments:
|
||
lines.append("\n[Attachments] — read and use these files to answer the request:")
|
||
for p in attachments:
|
||
lines.extend(self._read_one_attachment(p, limit, notify))
|
||
|
||
# --- Auto-load existing workspace/output folder files as input data ---
|
||
# This is what makes "📁 Chọn thư mục khác" useful as an INPUT folder
|
||
# too: every file already in the chosen folder is read and embedded so
|
||
# the agent can act on their contents without manual attaching.
|
||
workspace = self.workspace_dir()
|
||
max_files = int(self.ctx.config.data.get("attachments", {})
|
||
.get("max_files", 10) or 0)
|
||
if workspace is not None:
|
||
lines.extend(self._folder_input_lines(
|
||
workspace,
|
||
"[Workspace files] — existing files in output folder, "
|
||
"read and use as input data. The user expects you to "
|
||
"process these files automatically:",
|
||
limit, max_files, notify))
|
||
|
||
# --- Project knowledge (Claude-Projects style) ---
|
||
# Only scanned separately when it's a DIFFERENT folder from the
|
||
# session's own workspace — for Cowork the two are now the same
|
||
# folder (a project has one shared workspace, no per-thread
|
||
# sub-folder), so this never double-scans the same directory.
|
||
knowledge = self.project_knowledge_dir()
|
||
if knowledge is not None and knowledge != workspace:
|
||
lines.extend(self._folder_input_lines(
|
||
knowledge,
|
||
"[Project files] — shared knowledge files of this project, "
|
||
"available to every conversation in it. Read and use them "
|
||
"as context for the request:",
|
||
limit, max_files, notify))
|
||
|
||
return "\n".join(lines)
|
||
|
||
def project_knowledge_dir(self):
|
||
"""Folder of project-level shared knowledge files (None = no project
|
||
knowledge). Overridden by the Cowork tab for non-default projects."""
|
||
return None
|
||
|
||
def _folder_input_lines(self, folder: Path, header: str, limit: int,
|
||
max_files: int, notify=None) -> list:
|
||
"""Embed a folder's readable files into the prompt — recursing into
|
||
every sub-folder, any depth, not just the top level, so files placed
|
||
in nested folders are read and processed too (same per-message file
|
||
cap as manual attachments — Settings → Attachments → max files;
|
||
0 = unlimited — so a folder with dozens of files can't blow the
|
||
context window)."""
|
||
from ..core.doc_extract import find_input_files
|
||
|
||
out: list = []
|
||
shown, total = find_input_files(folder, self._INPUT_EXTS, max_files)
|
||
if shown:
|
||
out.append("\n" + header)
|
||
for f in shown:
|
||
out.extend(self._read_one_attachment(str(f), limit, notify))
|
||
if total > len(shown):
|
||
skipped = total - len(shown)
|
||
out.append(f"…({skipped} more files in the folder were not "
|
||
"loaded — per-message attachment limit; mention a "
|
||
"file by name if the user asks about it)")
|
||
if notify is not None:
|
||
notify({"type": "notice", "level": "warning",
|
||
"text": tr("chat.workspace_files_capped",
|
||
shown=len(shown), total=total)})
|
||
return out
|
||
|
||
def _read_one_attachment(self, path: str, limit: int, notify=None) -> list:
|
||
"""Read and format one attachment/workspace file. Returns list of lines.
|
||
|
||
Handles every file type: images (noted with path), MS Office / PDF /
|
||
OpenDocument / text (extracted), and ZIP archives — which are auto-
|
||
extracted into the workspace and their contents read + processed."""
|
||
name = Path(path).name
|
||
result = []
|
||
if is_image(path):
|
||
result.append(f"- {name} (image at {path})")
|
||
return result
|
||
from ..core.doc_extract import is_zip
|
||
if is_zip(path):
|
||
result.extend(self._read_zip_attachment(path, name, limit, notify))
|
||
return result
|
||
|
||
def progress(page: int, total: int, _name=name) -> None:
|
||
if notify is not None and total > 1:
|
||
notify({"type": "notice", "level": "progress",
|
||
"text": tr("chat.reading_progress", name=_name, page=page, total=total)})
|
||
|
||
content, note = self._read_attachment_text(path, progress=progress)
|
||
if content is None:
|
||
result.append(f"- {name} ({note}; located at {path})")
|
||
if notify is not None:
|
||
notify({"type": "notice", "level": "warning",
|
||
"text": tr("chat.attachment_failed", name=name, note=note)})
|
||
return result
|
||
self._enforce_attachment_security(name, content) # raises SecurityBlocked on a violation
|
||
extra = ""
|
||
if len(content) > limit:
|
||
content = content[:limit]
|
||
extra = f"\n…(truncated to ~{limit // 4} tokens)…"
|
||
result.append(f"- {name} ({path})")
|
||
result.append(f"\n--- Content of {name} ---\n{content}{extra}\n--- end of {name} ---")
|
||
return result
|
||
|
||
def _read_zip_attachment(self, path: str, name: str, limit: int, notify=None) -> list:
|
||
"""Auto-extract a .zip into the workspace and read+process its files, so
|
||
an attached archive is unpacked and its contents used automatically."""
|
||
from ..core.doc_extract import extract_archive
|
||
ws = self.workspace_dir()
|
||
dest = (Path(ws) if ws is not None else Path(path).parent) / Path(name).stem
|
||
files = extract_archive(path, dest)
|
||
result = [f"- {name} (archive) — extracted {len(files)} file(s) into the workspace at "
|
||
f"{dest}. Read/edit them there as needed."]
|
||
if self.workspace_dir() is not None:
|
||
self.output_changed.emit(str(self.workspace_dir())) # let the graph/folder refresh
|
||
max_files = int(self.ctx.config.data.get("attachments", {}).get("max_files", 10) or 0)
|
||
shown = files[:max_files] if max_files else files
|
||
for f in shown:
|
||
result.extend(self._read_one_attachment(str(f), limit, notify))
|
||
if max_files and len(files) > max_files:
|
||
result.append(f"- …and {len(files) - max_files} more file(s) in {dest} "
|
||
"(not inlined; open/read them from the workspace as needed).")
|
||
return result
|
||
|
||
def _enforce_attachment_security(self, filename: str, content: str) -> None:
|
||
"""Agent Security's attachment layer (Settings → 🛡 Agent Security) —
|
||
scans extracted file content for malicious payloads BEFORE it enters
|
||
the model's context. No-op when disabled. Raises SecurityBlocked
|
||
(propagates out of _augment → the worker job → AgentWorker.failed,
|
||
which the panel shows as a chat error) on a violation."""
|
||
sec = self.ctx.config.data.get("agent_security", {})
|
||
if not sec.get("enabled") or not sec.get("validate_attachments", True):
|
||
return
|
||
from ..core.agent_security import SecurityBlocked, combined_rules_text, validate_attachment
|
||
from ..core.agent_security_alert import notify_admin
|
||
|
||
rules_text = combined_rules_text(self.ctx.config)
|
||
verdict = validate_attachment(self.build_provider(), filename, content, rules_text)
|
||
if verdict.allowed:
|
||
return
|
||
notify_admin(self.ctx.config, verdict, detail=f"file: {filename}")
|
||
raise SecurityBlocked(verdict)
|
||
|
||
@staticmethod
|
||
def _read_attachment_text(path: str, progress=None):
|
||
"""Best-effort text extraction so the agent can read the attachment.
|
||
Returns (text, note); text is None when nothing readable was found.
|
||
|
||
Delegates to core.doc_extract, which parses docx/xlsx/pptx/odf directly
|
||
(stdlib, no extra packages), uses pypdf for PDFs (reporting per-page
|
||
``progress`` for multi-page files), and falls back to a headless
|
||
LibreOffice conversion for anything else."""
|
||
from ..core.doc_extract import extract_text
|
||
|
||
return extract_text(path, progress=progress)
|
||
|
||
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 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 _start_turn(self, text: str, attachments: Optional[List[str]] = None) -> None:
|
||
attachments = attachments or []
|
||
typed = text
|
||
prefix, request, info = self._apply_skill_command(text)
|
||
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.
|
||
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(),
|
||
"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 _on_event(self, ctx: Dict[str, Any], ev: Dict[str, Any]) -> None:
|
||
etype = ev.get("type")
|
||
# Track the in-progress state even while this turn is a detached background
|
||
# job, so reopening its conversation can re-render the CURRENT task (partial
|
||
# answer + live plan) — see _reattach_running_turn.
|
||
if etype == "text":
|
||
ctx["partial"] = ctx.get("partial", "") + ev.get("delta", "")
|
||
elif etype == "assistant_done":
|
||
ctx["partial"] = ""
|
||
elif etype == "plan_set":
|
||
ctx["plan_steps"] = ev.get("steps") or []
|
||
# A turn only RENDERS into the transcript/sidebar of the conversation it was
|
||
# started in. If the user navigated away, skip live rendering (the data is
|
||
# tracked above and shown when the conversation is reopened).
|
||
if ctx.get("detached") or ctx.get("home_id") != self.session_id:
|
||
return
|
||
record = ctx["record"]
|
||
if etype == "text":
|
||
self.thinking.stop() # real output is streaming now
|
||
if ctx["assistant"] is None:
|
||
ctx["assistant"] = self.chat_view.add_assistant(self.assistant_title())
|
||
ctx["last_assistant"] = ctx["assistant"] # for the per-turn usage footer
|
||
record["bubbles"].append(ctx["assistant"])
|
||
folder = self.workspace_dir()
|
||
if folder:
|
||
ctx["assistant"].add_folder_link(str(folder))
|
||
ctx["assistant"].append_delta(ev.get("delta", ""))
|
||
elif etype == "assistant_done":
|
||
self.graph_event.emit(self.session_name, ev)
|
||
ctx["assistant"] = None
|
||
ctx["reasoning"] = None # next step starts a fresh Thinking box
|
||
self._autosave() # persist latest result (crash-safe, mid-turn)
|
||
elif etype == "tool_proposed":
|
||
# Show WHAT it's doing (e.g. "Creating…" while a document is generated).
|
||
self.thinking.start(_TOOL_STATUS.get(ev.get("name"), "chat.running"))
|
||
if ev.get("name") == "update_plan":
|
||
return # the plan tool drives the Plan view, not a chat bubble
|
||
# Show the step in the transcript (the code being written / diff /
|
||
# command being run) so the whole process is visible, CLI-style.
|
||
preview = ev.get("preview") or {}
|
||
body = preview.get("text", "")
|
||
if body:
|
||
icons = {"diff": "✎", "command": "▶"}
|
||
title = preview.get("title") or ev.get("name", "tool")
|
||
label = f"{icons.get(preview.get('kind'), '⚙')} {title}"
|
||
# A diff/create/edit preview renders as a colored before/after
|
||
# (additions/deletions), not a flat text block.
|
||
if preview.get("kind") == "diff":
|
||
step = self.chat_view.add_diff(label, body, True)
|
||
else:
|
||
step = self.chat_view.add_tool(label, body, True)
|
||
record["bubbles"].append(step)
|
||
# Remember this step's bubble so live stdout/stderr ("tool_output")
|
||
# can be appended to it in real time while the command runs.
|
||
ctx.setdefault("step_bubbles", {})[ev.get("id")] = step
|
||
self.graph_event.emit(self.session_name, ev)
|
||
elif etype == "tool_output":
|
||
# Live output from a running command/install (see run_cancellable) —
|
||
# append to its step bubble so progress is visible before it finishes.
|
||
step = ctx.get("step_bubbles", {}).get(ev.get("id"))
|
||
if step is not None:
|
||
step.append_plain(ev.get("delta", ""))
|
||
elif etype == "notice":
|
||
# A UI-visible aside outside the model's own turn: either a live
|
||
# "reading page X/Y" progress line, or a warning that something
|
||
# (e.g. an attachment) could not be processed.
|
||
if ev.get("level") == "progress":
|
||
self.thinking.set_progress_text(ev.get("text", ""))
|
||
else:
|
||
bubble = self.chat_view.add_tool(
|
||
tr("chat.attachment_warning_title"), ev.get("text", ""), False)
|
||
record["bubbles"].append(bubble)
|
||
elif etype == "tool_result":
|
||
ctx.get("step_bubbles", {}).pop(ev.get("id"), None)
|
||
self.thinking.start("chat.running") # back to the model for the next step
|
||
if ev.get("name") == "update_plan":
|
||
return # plan tool: no chat bubble (Plan view already updated)
|
||
mark = "✓" if ev.get("ok") else "✗"
|
||
tool_bubble = self.chat_view.add_tool(
|
||
f"{ev.get('name')} {mark}", ev.get("output", ""), ev.get("ok", True))
|
||
record["bubbles"].append(tool_bubble)
|
||
folder = ev.get("path") or self.workspace_dir()
|
||
if folder:
|
||
tool_bubble.add_folder_link(str(folder), tr("chat.open_folder"))
|
||
if ev.get("path"):
|
||
record["outputs"].append(ev["path"])
|
||
self.on_file_written(ev["path"])
|
||
# Files produced by a command (e.g. a script that builds a .pptx) —
|
||
# surface the real deliverable, not the generator script.
|
||
for pr in ev.get("produced", []) or []:
|
||
record["outputs"].append(pr)
|
||
self.register_output(pr)
|
||
self.graph_event.emit(self.session_name, ev)
|
||
self._autosave() # persist after each tool result (crash-safe)
|
||
elif etype == "outputs_removed":
|
||
# Intermediate/generator files were cleaned up — drop them from Output.
|
||
for p in ev.get("paths", []) or []:
|
||
self.output_section.remove(p)
|
||
if p in record.get("outputs", []):
|
||
record["outputs"].remove(p)
|
||
elif etype == "outputs_added":
|
||
# Deliverables flattened out of a sub-folder into the Output root.
|
||
for p in ev.get("paths", []) or []:
|
||
if p not in record.get("outputs", []):
|
||
record["outputs"].append(p)
|
||
self.register_output(p)
|
||
elif etype == "reasoning":
|
||
# A reasoning model is "thinking" (Qwen3/DeepSeek-R1 etc.). Relabel the
|
||
# indicator AND stream the reasoning into a collapsed "🧠 Thinking" box
|
||
# so the process is visible without flooding the chat.
|
||
self.thinking.set_label("chat.thinking")
|
||
piece = ev.get("delta", "")
|
||
if piece:
|
||
if ctx.get("reasoning") is None:
|
||
ctx["reasoning"] = self.chat_view.add_reasoning()
|
||
record["bubbles"].append(ctx["reasoning"])
|
||
ctx["reasoning"].append_delta(piece)
|
||
elif etype == "plan_set":
|
||
steps = ev.get("steps") or []
|
||
self.on_plan(steps) # Plan panel (right sidebar)
|
||
# Also show the checklist inline in the chat, updated in place.
|
||
body = _format_plan_steps(steps)
|
||
if ctx.get("plan_bubble") is None:
|
||
ctx["plan_bubble"] = self.chat_view.add_plan(body)
|
||
record["bubbles"].append(ctx["plan_bubble"])
|
||
else:
|
||
ctx["plan_bubble"].set_plain(body)
|
||
|
||
def on_plan(self, steps) -> None:
|
||
"""Render the current message's step checklist in the Plan panel above the
|
||
Output list. The agent sends the full list on each ``update_plan`` call."""
|
||
self.plan_section.set_steps(steps)
|
||
|
||
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 _on_permission(self, ctx: Dict[str, Any], action: Dict[str, Any]) -> None:
|
||
# Auto-approves UNLESS this workspace requires confirming commands —
|
||
# a per-workspace Auto-run override (see AppContext.project_confirm_commands),
|
||
# falling back to the global "confirm before running commands" setting.
|
||
# Resolve on THIS turn's worker, never the latest — several turns may
|
||
# be awaiting approval at once.
|
||
if self.ctx.project_confirm_commands():
|
||
from .permission_dialog import PermissionDialog
|
||
|
||
approved, _remember = PermissionDialog.ask(action, parent=self)
|
||
ctx["worker"].resolve_permission(approved)
|
||
return
|
||
ctx["worker"].resolve_permission(True)
|
||
|
||
def _finalize_turn(self, ctx: Dict[str, Any]) -> None:
|
||
"""Merge one turn's new messages into its OWN conversation's history.
|
||
|
||
"New" = everything the job appended after this turn's snapshot. Drop any
|
||
system prompt the agent inserted when the history already carries one, so
|
||
two turns started from an empty history don't leave a duplicate system
|
||
message. Merges into ``home_messages`` (the list of the conversation the
|
||
turn started in) so a background turn saves to the right chat even after the
|
||
user switched away. Same object refs are reused, so _delete_turn's id-based
|
||
removal still finds them."""
|
||
home = ctx["home_messages"]
|
||
local = ctx["messages"]
|
||
new = local[ctx["snapshot_len"]:]
|
||
if any(m.get("role") == "system" for m in home):
|
||
new = [m for m in new if m.get("role") != "system"]
|
||
home.extend(new)
|
||
ctx["record"]["messages"] = new
|
||
|
||
def _end_turn(self, ctx: Dict[str, Any]) -> None:
|
||
"""Shared teardown for a finished/failed turn: merge history, drop the
|
||
worker, release the conversation once nothing else is running for it, and
|
||
refresh the (global) running/capacity indicators."""
|
||
self._finalize_turn(ctx)
|
||
self._active.pop(ctx["worker"], None)
|
||
home_id = ctx.get("home_id")
|
||
if home_id and not any(c.get("home_id") == home_id for c in self._active.values()):
|
||
self._sessions_live.pop(home_id, None)
|
||
# Update the chat-box indicator for the CURRENT view: stop it once the viewed
|
||
# conversation is idle (a live turn's own streaming manages it otherwise, so
|
||
# we don't restart it here and disturb streaming).
|
||
if not self._view_busy():
|
||
self.thinking.stop()
|
||
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
||
# Re-evaluate the per-conversation gate: sends dispatch again only when THIS
|
||
# conversation is idle and the global cap allows.
|
||
self.composer.set_busy(self._view_busy() or len(self._active) >= self._max_parallel())
|
||
|
||
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 _save_snapshot(self, session_id: str, messages: List[Dict[str, Any]],
|
||
title: str, inputs: Optional[List[str]] = None) -> None:
|
||
"""Persist a conversation by id (used both to register it in History the
|
||
moment it starts and to save a finished background turn). No-op until it has
|
||
a user message. Never raises into the UI."""
|
||
if not self.ctx.config.history.get("autosave", True):
|
||
return
|
||
if not any(m.get("role") == "user" for m in messages):
|
||
return
|
||
try:
|
||
from ..core.history import save_conversation
|
||
save_conversation(
|
||
self.ctx.config.history_dir(), self.kind, session_id,
|
||
messages, title, inputs=list(inputs or []), outputs=[],
|
||
# Only the CURRENT view knows its project for sure; a background
|
||
# turn's save must not overwrite another conversation's project
|
||
# with whatever the user is viewing now (save_conversation keeps
|
||
# the stored value when '' is passed).
|
||
project_id=self.project_id if session_id == self.session_id else "",
|
||
)
|
||
except Exception:
|
||
pass # persistence must never disrupt the UI
|
||
|
||
def _persist_session(self, ctx: Dict[str, Any]) -> None:
|
||
"""Save a BACKGROUND turn's conversation (it isn't the current view, so the
|
||
view-based _autosave can't). Outputs are rebuilt from disk on reopen."""
|
||
self._save_snapshot(ctx["home_id"], ctx["home_messages"],
|
||
ctx.get("home_title", ""),
|
||
inputs=ctx.get("record", {}).get("inputs", []))
|
||
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 _finalize_plan(self, ctx: Dict[str, Any]) -> None:
|
||
"""On a successful finish, keep the plan visible with every step ticked
|
||
'done' (so a completed plan can be reviewed) — it is cleared only when the
|
||
NEXT message starts a fresh plan (see _start_turn)."""
|
||
steps = ctx.get("plan_steps")
|
||
if not steps:
|
||
return
|
||
changed = False
|
||
for s in steps:
|
||
if s.get("status") != "done":
|
||
s["status"] = "done"
|
||
changed = True
|
||
if changed:
|
||
self.on_plan(steps) # re-render (Plan panel for Cowork / preview for Code)
|
||
pb = ctx.get("plan_bubble")
|
||
if pb is not None:
|
||
pb.set_plain(_format_plan_steps(steps))
|
||
|
||
# ---- token / cost accounting (shown in the chat, Claude-style) ----------
|
||
def _usage_label(self) -> str:
|
||
return self.title or self.session_id
|
||
|
||
def _session_events(self):
|
||
from ..core import usage_tracker as ut
|
||
label = self._usage_label()
|
||
return [e for e in ut.load_events()
|
||
if e.get("source") == self.kind and e.get("label") == label]
|
||
|
||
def refresh_usage(self) -> None:
|
||
"""Show what this conversation has already cost.
|
||
|
||
The label was written only at the end of a turn, so opening a thread
|
||
from History left the strip blank however much it had spent.
|
||
"""
|
||
from ..core import model_pricing as mp
|
||
from ..core import usage_tracker as ut
|
||
|
||
cur = self._usage_snapshot()
|
||
if not (cur["in"] or cur["out"] or cur["cache"]):
|
||
self._usage_total_lbl.setText("")
|
||
return
|
||
# same source _show_usage reads, so the two never disagree
|
||
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||
self._usage_total_lbl.setText(
|
||
f"↓{mp.format_tokens(cur['in'])} ↑{mp.format_tokens(cur['out'])} "
|
||
f"▤{mp.format_tokens(cur['in'] + cur['out'] + cur['cache'])} "
|
||
f"{ut.format_cost(self._session_cost_usd(), pricing)}")
|
||
|
||
def _usage_snapshot(self) -> Dict[str, int]:
|
||
"""Cumulative in/out/cache tokens for THIS conversation so far."""
|
||
snap = {"in": 0, "out": 0, "cache": 0}
|
||
for e in self._session_events():
|
||
snap["in"] += int(e.get("in", 0) or 0)
|
||
snap["out"] += int(e.get("out", 0) or 0)
|
||
snap["cache"] += int(e.get("cache", 0) or 0)
|
||
return snap
|
||
|
||
def _session_cost_usd(self) -> float:
|
||
from ..core import model_pricing as mp
|
||
return sum(mp.turn_cost_usd(e.get("model", ""), e.get("in", 0), e.get("out", 0),
|
||
self.ctx.config) for e in self._session_events())
|
||
|
||
def _show_usage(self, ctx: Dict[str, Any]) -> None:
|
||
"""Per-turn footer under the assistant message + the running conversation
|
||
total (bottom-left). Cost uses the Monitoring model-price table and the
|
||
display currency, and auto-updates when the model is switched."""
|
||
from ..core import model_pricing as mp, usage_tracker as ut
|
||
cur = self._usage_snapshot()
|
||
base = ctx.get("usage_base") or {"in": 0, "out": 0, "cache": 0}
|
||
d_in = max(0, cur["in"] - base.get("in", 0))
|
||
d_out = max(0, cur["out"] - base.get("out", 0))
|
||
d_cache = max(0, cur["cache"] - base.get("cache", 0))
|
||
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||
# Condensed format (tight icon+value, single-space separators) — the
|
||
# old 4-space-wide separators made this label wide enough that it got
|
||
# crowded out of the composer's bottom row by the Local-folder button
|
||
# sharing the same row.
|
||
bub = ctx.get("last_assistant")
|
||
if bub is not None and (d_in or d_out):
|
||
turn_usd = mp.turn_cost_usd(self._model, d_in, d_out, self.ctx.config)
|
||
bub.add_usage(f"↓{mp.format_tokens(d_in)} ↑{mp.format_tokens(d_out)} "
|
||
f"▤{mp.format_tokens(d_in + d_out + d_cache)} "
|
||
f"{ut.format_cost(turn_usd, pricing)}")
|
||
self._usage_total_lbl.setText(
|
||
f"↓{mp.format_tokens(cur['in'])} ↑{mp.format_tokens(cur['out'])} "
|
||
f"▤{mp.format_tokens(cur['in'] + cur['out'] + cur['cache'])} "
|
||
f"{ut.format_cost(self._session_cost_usd(), pricing)}")
|
||
|
||
def _on_finished(self, ctx: Dict[str, Any], result: Dict[str, Any]) -> None:
|
||
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:
|
||
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.
|
||
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:
|
||
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}")))
|
||
|
||
# ---- Teams auto-notify ------------------------------------------
|
||
def _last_assistant_text(self) -> str:
|
||
for m in reversed(self.messages):
|
||
if m.get("role") == "assistant" and m.get("content"):
|
||
return m["content"]
|
||
return ""
|
||
|
||
def _maybe_notify_teams(self, result: Dict[str, Any]) -> None:
|
||
teams = self.ctx.config.teams
|
||
notifier = self.ctx.teams_notifier()
|
||
if not (teams.get("notify_on_complete") and notifier.configured):
|
||
return
|
||
summary = self._last_assistant_text() or "Task completed."
|
||
facts = {"Session": self.session_name, "Model": self.ctx.config.model_label()}
|
||
wd = self.workspace_dir()
|
||
if wd:
|
||
facts["Folder"] = str(wd)
|
||
if result.get("error"):
|
||
facts["Status"] = "Error"
|
||
|
||
def job(worker: AgentWorker):
|
||
ok, detail = notifier.send(f"Cowork {self.session_name} — task done", summary[:1200], facts)
|
||
return {"ok": ok, "detail": detail}
|
||
|
||
w = AgentWorker(job)
|
||
w.finished_ok.connect(lambda r: self.status_message.emit(r.get("detail", "")))
|
||
self._teams_worker = w
|
||
w.start()
|
||
|
||
# ---- persistence -------------------------------------------------
|
||
def _autosave(self) -> None:
|
||
if not self.ctx.config.history.get("autosave", True):
|
||
return
|
||
if not any(m.get("role") == "user" for m in self.messages):
|
||
return
|
||
try:
|
||
from ..core.history import save_conversation
|
||
path = save_conversation(
|
||
self.ctx.config.history_dir(), self.kind, self.session_id,
|
||
self.messages, self.title,
|
||
inputs=self.input_section.paths(),
|
||
outputs=self.output_section.paths(),
|
||
project_id=self.project_id,
|
||
)
|
||
# Remember this as the session to restore next launch (crash-safe).
|
||
last = self.ctx.config.data.setdefault("last_session", {})
|
||
if last.get(self.kind) != str(path):
|
||
last[self.kind] = str(path)
|
||
self.ctx.save()
|
||
except Exception:
|
||
pass # autosave must never disrupt the UI
|
||
|
||
def _busy(self) -> bool:
|
||
"""True while any turn is still running in this tab (any conversation)."""
|
||
return bool(self._active)
|
||
|
||
def _view_busy(self) -> bool:
|
||
"""True while the CURRENTLY-VIEWED conversation has a turn running."""
|
||
return any(c.get("home_id") == self.session_id for c in self._active.values())
|
||
|
||
def _sync_indicators(self) -> None:
|
||
"""Reflect the CURRENT conversation's agent status in the chat box + composer.
|
||
Switching chats, or hitting History → Refresh, shows whether THIS chat is
|
||
still processing (a background turn) or idle."""
|
||
if self._view_busy():
|
||
self.thinking.start("chat.running") # this conversation is still working
|
||
else:
|
||
self.thinking.stop()
|
||
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
||
self.composer.set_busy(self._view_busy() or len(self._active) >= self._max_parallel())
|
||
|
||
def refresh_status(self) -> None:
|
||
"""Public: re-sync the on-screen agent status for the current conversation
|
||
(used by the History Refresh button)."""
|
||
self._sync_indicators()
|
||
|
||
def _max_parallel(self) -> int:
|
||
"""Unlimited concurrent turns — no cap (the old Settings limit was removed).
|
||
A large sentinel keeps the queue logic intact without ever gating."""
|
||
return 100000
|
||
|
||
def active_workers(self) -> List[AgentWorker]:
|
||
"""Workers for turns still running (used to stop them all on quit)."""
|
||
return list(self._active)
|
||
|
||
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)
|
||
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 new_session(self) -> None:
|
||
from ..core.history import new_session_id
|
||
|
||
# Allowed while work is running: current turns keep going in the background.
|
||
self._detach_live_turns()
|
||
self.messages = []
|
||
self.session_id = new_session_id()
|
||
self.title = ""
|
||
self.turns = []
|
||
self.chat_view.clear()
|
||
self.composer.clear_queue()
|
||
self.composer.reset_input() # clear leftover text / "Attached: …" hint
|
||
self.plan_section.clear()
|
||
self.input_section.clear()
|
||
self.output_section.clear()
|
||
self.graph_event.emit(self.session_name, {"type": "reset"})
|
||
self._sync_indicators()
|
||
self.history_changed.emit() # current view changed → refresh History highlight
|
||
|
||
def _notify_title(self) -> None:
|
||
"""Let a screen that heads itself with the thread title follow along.
|
||
|
||
The thread also decides what the usage strip should read, so refresh
|
||
that here rather than at each of the three places the title changes.
|
||
"""
|
||
hook = getattr(self, "refresh_title", None)
|
||
if callable(hook):
|
||
hook()
|
||
if getattr(self, "_usage_total_lbl", None) is not None:
|
||
self.refresh_usage()
|
||
|
||
def load_conversation(self, conv: Dict[str, Any]) -> None:
|
||
"""Switch the view to a stored conversation. Allowed while work is running —
|
||
the current turns keep going in the background."""
|
||
sid = conv.get("session_id") or self.session_id
|
||
# Clicking the conversation you're already viewing while it has a running
|
||
# turn must NOT tear down its live rendering — just no-op.
|
||
if sid == self.session_id and self._view_busy():
|
||
return
|
||
self._detach_live_turns()
|
||
self.session_id = sid
|
||
self.title = conv.get("title", "")
|
||
self._notify_title()
|
||
self.project_id = conv.get("project_id", "") or "default"
|
||
# If this conversation still has a turn running in the background, attach to
|
||
# its LIVE message list (not a stale disk copy) so the two never race on save.
|
||
if sid in self._sessions_live:
|
||
self.messages = self._sessions_live[sid]
|
||
else:
|
||
self.messages = list(conv.get("messages", []))
|
||
self.turns = []
|
||
self.chat_view.clear()
|
||
self.composer.clear_queue()
|
||
self.composer.reset_input() # clear leftover text / "Attached: …" hint
|
||
self.plan_section.clear()
|
||
self.input_section.clear()
|
||
self.output_section.clear()
|
||
self.graph_event.emit(self.session_name, {"type": "reset"})
|
||
for m in self.messages:
|
||
role = m.get("role")
|
||
if role == "user":
|
||
self.chat_view.add_user(m.get("content", ""))
|
||
self.graph_event.emit(self.session_name, {"type": "user", "content": m.get("content", "")})
|
||
elif role == "assistant":
|
||
if m.get("content"):
|
||
self.chat_view.add_assistant(self.assistant_title()).set_markdown(m["content"])
|
||
self.graph_event.emit(self.session_name, {"type": "assistant_done", "content": m["content"]})
|
||
for tc in m.get("tool_calls", []) or []:
|
||
self.graph_event.emit(self.session_name, {
|
||
"type": "tool_proposed", "name": tc.get("name", ""),
|
||
"args": tc.get("arguments", {}),
|
||
"preview": {"text": str(tc.get("arguments", {}))},
|
||
})
|
||
elif role == "tool":
|
||
self.chat_view.add_tool(m.get("name", "tool"), m.get("content", ""), True)
|
||
self.graph_event.emit(self.session_name, {
|
||
"type": "tool_result", "name": m.get("name", ""),
|
||
"ok": True, "output": m.get("content", ""),
|
||
})
|
||
# Restore the Input/Output file lists too.
|
||
for p in conv.get("inputs", []):
|
||
self.input_section.add(p)
|
||
for p in conv.get("outputs", []):
|
||
self.output_section.add(p)
|
||
# If this conversation has a turn running in the background, re-render the
|
||
# in-progress task and re-attach it so it keeps streaming live here.
|
||
running = self._running_ctx_for(sid)
|
||
if running is not None:
|
||
self._reattach_running_turn(running)
|
||
elif self.messages:
|
||
# A past (already finished) session — surface a link to its output
|
||
# folder even though the live "done" marker isn't replayed.
|
||
folder = self.workspace_dir()
|
||
if folder:
|
||
marker = self.chat_view.add_status(tr("chat.session_folder_marker"))
|
||
marker.add_folder_link(str(folder), tr("chat.open_folder_short"))
|
||
# Jump to the newest message after the transcript is rebuilt.
|
||
self.chat_view.scroll_to_bottom()
|
||
self._sync_indicators()
|
||
self.history_changed.emit() # current view changed → refresh History highlight
|