diff --git a/presentation/chat/attachment_picker.py b/presentation/chat/attachment_picker.py new file mode 100644 index 0000000..f04dc25 --- /dev/null +++ b/presentation/chat/attachment_picker.py @@ -0,0 +1,215 @@ +"""Tệp đính kèm của một lượt chat — R08-T03. + +Đọc nội dung tệp người dùng kèm vào rồi ghép vào câu hỏi. Ba thứ đáng +chú ý: + +* ``_enforce_attachment_security`` chạy TRƯỚC khi nội dung vào ngữ cảnh + model — đây là một trong ba tầng kiểm của R09. +* ``_attach_char_limit`` cắt bớt tệp quá dài; không cắt thì một tệp log + vài chục MB đủ làm hỏng cả lượt. +* Kèm cả thư mục thì chỉ lấy DANH SÁCH tệp, không đọc nội dung từng cái. +""" +from __future__ import annotations + +from pathlib import Path +from typing import List +from PySide6.QtCore import Qt +from ...core.worker import AgentWorker +from ...i18n import tr +from ...ui.osutil import is_image + + +class AttachmentMixin: + """Trộn vào ChatPanel.""" + + 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 _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 + + 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 _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 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 diff --git a/presentation/chat/chat_agents.py b/presentation/chat/chat_agents.py new file mode 100644 index 0000000..256afc2 --- /dev/null +++ b/presentation/chat/chat_agents.py @@ -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).""" diff --git a/presentation/chat/chat_bubble_style.py b/presentation/chat/chat_bubble_style.py new file mode 100644 index 0000000..05f85bb --- /dev/null +++ b/presentation/chat/chat_bubble_style.py @@ -0,0 +1,202 @@ +"""Cách vẽ một bong bóng chat: màu, đường thời gian, diff, trạng thái — R08-T01. + +Tách khỏi ``chat_history_widget.py``: đây là phần quyết định TRÔNG THẾ NÀO, +còn file kia quyết định HIỆN CÁI GÌ. + +``diff_to_html`` tô màu phần thêm/bớt khi agent sửa file; ``_TimelineGutter`` +vẽ đường dọc nối các lượt, ``ThinkingIndicator`` là ba chấm lúc chờ. +""" +from __future__ import annotations + +import html +from pathlib import Path +from PySide6.QtCore import QPointF, Qt, QTimer, Signal +from PySide6.QtGui import QColor, QPainter, QPen, QPixmap +from PySide6.QtWidgets import ( + QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser, + QVBoxLayout, QWidget, +) +from ...i18n import on_language_changed, tr +from ...theme import palette, resolve_theme +from ...config import CONFIG_DIR +from ...ui.osutil import is_image, open_folder, open_path + + +def _app_theme() -> str: + """Resolve the current app theme (light or dark) from config.""" + try: + import json + with open(CONFIG_DIR / "config.json", "r", encoding="utf-8") as f: + data = json.load(f) + return resolve_theme(data.get("theme", "dark")) + except Exception: # noqa: BLE001 + return "dark" + + +def _p(): + """Design tokens for the theme in effect right now.""" + return palette(_app_theme()) + + +def _dot_color(role: str) -> str: + """Timeline dot colour for a message role.""" + p = _p() + return { + "user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool, + "error": p.role_error, "success": p.role_result, + }.get(role, p.text_faint) + + +class _TimelineGutter(QWidget): + """The left rail of the point-conversation: a vertical connector line with a + role-colored dot near the top, so stacked messages read as a timeline + (Claude-Code style) instead of separate boxes.""" + + def __init__(self, role: str): + super().__init__() + self._role = role + self.setFixedWidth(22) + + def set_role(self, role: str) -> None: + self._role = role + self.update() + + def paintEvent(self, _e): # noqa: N802 + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + tok = _p() + x = 11.0 + cy = 15.0 + # connector line (faint) running the full height → continuous rail + p.setPen(QPen(QColor(tok.border), 2)) + p.drawLine(int(x), 0, int(x), self.height()) + # a background ring lifts the dot off the line + p.setPen(Qt.NoPen) + p.setBrush(QColor(tok.bg)) + p.drawEllipse(QPointF(x, cy), 7.5, 7.5) + p.setBrush(QColor(_dot_color(self._role))) + p.drawEllipse(QPointF(x, cy), 4.5, 4.5) + + +def _diff_legend(diff_text: str) -> str: + """A small badge pair labeling what the colors mean: 'Before → After' for + an edit, or a single 'Added'/'Removed' badge for a pure create/delete — + so the before/after distinction is explicit, not just implied by color.""" + has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines()) + has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines()) + p = _p() + + def pill(bg: str, fg: str, key: str) -> str: + return (f'{html.escape(tr(key))}') + + if has_add and has_del: + badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before") + + f' → ' + + pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after")) + elif has_add: + badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added") + elif has_del: + badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed") + else: + return "" + return f'
{badge}
' + + +def diff_to_html(diff_text: str) -> str: + """Render a unified diff with GitHub/Claude-Code-style line coloring — + additions green, deletions red, hunk headers highlighted — plus an + explicit Before/After (or Added/Removed) legend, instead of a flat text + block, so a before/after edit reads at a glance. A brand-new file (an + empty 'before') naturally renders as all-green, which is exactly what + ``difflib.unified_diff`` already produces for it.""" + legend = _diff_legend(diff_text) + p = _p() + rows = [] + for ln in diff_text.splitlines(): + esc = html.escape(ln) if ln else " " + if ln.startswith(("+++", "---")): + rows.append(f'
{esc}
') + elif ln.startswith("@@"): + rows.append(f'
{esc}
') + elif ln.startswith("+"): + rows.append(f'
{esc}
') + elif ln.startswith("-"): + rows.append(f'
{esc}
') + else: + rows.append(f"
{esc}
") + body = "".join(rows) or "(no textual change)" + return (f'{legend}
{body}
') + + +def format_status_line(base: str, ticks: int) -> str: + """Animated status line for the working indicator, e.g. ``🤖 Running..`` and, + once the wait is a few seconds long, ``🤖 Running. · 5s`` — so a slow + synthesis clearly reads as still running. ``ticks`` advances every 500 ms.""" + dots = "." * (ticks % 4) + secs = ticks // 2 + suffix = f" · {secs}s" if secs >= 3 else "" + return f"{base}{dots}{suffix}" + + +class ThinkingIndicator(QWidget): + """A small animated 'the agent is working' line shown while waiting for a + result, so a long wait never looks like a frozen / empty screen. + + Renders a bot icon + status (e.g. ``🤖 Running…``) and, once the wait passes + a few seconds, the elapsed time — so a long synthesis clearly reads as still + running rather than stuck.""" + + def __init__(self): + super().__init__() + lay = QHBoxLayout(self) + lay.setContentsMargins(14, 2, 14, 4) + lay.setSpacing(0) + self._label = QLabel("") + self._label.setObjectName("hint") + lay.addWidget(self._label) + lay.addStretch(1) + self._base_key = "chat.running" + self._override: str | None = None + self._ticks = 0 + self._timer = QTimer(self) + self._timer.setInterval(500) + self._timer.timeout.connect(self._tick) + self.setVisible(False) + on_language_changed(self._render) + + def start(self, label_key: str = "chat.running") -> None: + self._base_key = label_key + self._override = None + self._ticks = 0 + self._render() + self.setVisible(True) + if not self._timer.isActive(): + self._timer.start() + + def set_label(self, label_key: str) -> None: + if label_key != self._base_key: + self._base_key = label_key + self._override = None + self._render() + + def set_progress_text(self, text: str) -> None: + """Show an already-formatted, literal status line (e.g. a live "reading + page 12/40" or streamed command-output detail) instead of a translated + key — used for fine-grained progress within a single step.""" + self._override = text + self._render() + + def stop(self) -> None: + self._timer.stop() + self._override = None + self.setVisible(False) + + def _tick(self) -> None: + self._ticks += 1 + self._render() + + def _render(self) -> None: + base = self._override if self._override is not None else tr(self._base_key) + self._label.setText(format_status_line(base, self._ticks)) diff --git a/presentation/chat/chat_event_stream.py b/presentation/chat/chat_event_stream.py new file mode 100644 index 0000000..8ac56d1 --- /dev/null +++ b/presentation/chat/chat_event_stream.py @@ -0,0 +1,228 @@ +"""Nhận sự kiện phát về từ luồng chạy nền — R08-T06. + +Agent chạy ở luồng khác và bắn sự kiện dần: chữ, lời gọi tool, kế hoạch, xin +quyền. ``_on_event`` phân nhánh theo loại rồi cập nhật đúng bong bóng. + +``_on_permission`` là chỗ giao diện hỏi người dùng — cổng chính sách chỉ trả +lời ALLOW/DENY/ASK, còn hỏi thế nào là việc của tầng này (xem +``docs/architecture/security-policy.md`` mục 5). + +Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional +from PySide6.QtCore import Qt, Signal +from ...core.worker import AgentWorker +from ...i18n import tr +from ...state import AppContext +from ...ui.composer import Composer + + +class ChatEventStreamMixin: + """Xử lý sự kiện của một lượt. Trộn vào ChatPanel.""" + + 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). + from ...ui.chat_panel import _TOOL_STATUS + 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. + from ...ui.chat_panel import _format_plan_steps + 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 _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 ...ui.permission_dialog import PermissionDialog + + approved, _remember = PermissionDialog.ask(action, parent=self) + ctx["worker"].resolve_permission(approved) + return + ctx["worker"].resolve_permission(True) + + 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)) + + 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()) diff --git a/presentation/chat/chat_helpers.py b/presentation/chat/chat_helpers.py new file mode 100644 index 0000000..613a03a --- /dev/null +++ b/presentation/chat/chat_helpers.py @@ -0,0 +1,53 @@ +"""Hàm và bảng tra dùng chung trong khung chat — R08-T06. + +Thuần hàm, không widget. Gom về đây vì cả năm file trong gói đều hỏi tới. +""" +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 ...ui.chat_view import ChatView, ThinkingIndicator +from ...ui.composer import Composer +from ...ui.icons import collapse_right_icon, icon as app_icon +from ...ui.osutil import is_image, open_path +from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection + + +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 + + +_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", +} diff --git a/presentation/chat/chat_history_widget.py b/presentation/chat/chat_history_widget.py new file mode 100644 index 0000000..905d864 --- /dev/null +++ b/presentation/chat/chat_history_widget.py @@ -0,0 +1,348 @@ +"""Scrollable chat transcript built from message bubbles.""" +from __future__ import annotations + +from .chat_bubble_style import ( # noqa: F401 — giữ đường vào cũ + ThinkingIndicator, _TimelineGutter, _app_theme, _diff_legend, _dot_color, _p, + diff_to_html, format_status_line, +) + +import html +from pathlib import Path + +from PySide6.QtCore import QPointF, Qt, QTimer, Signal +from PySide6.QtGui import QColor, QPainter, QPen, QPixmap +from PySide6.QtWidgets import ( + QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser, + QVBoxLayout, QWidget, +) + +from ...i18n import on_language_changed, tr +from ...theme import palette, resolve_theme +from ...config import CONFIG_DIR +from ...ui.osutil import is_image, open_folder, open_path + + + + + + + + + + + + + + + + + + +class MessageBubble(QFrame): + """One message; assistant/tool bubbles render markdown via QTextBrowser.""" + + def __init__(self, role: str, title: str = "", collapsible: bool = False, + collapsed: bool = True): + super().__init__() + self.role = role + self._text = "" + self._collapsible = collapsible + self._title = title + self._head = None + # Point-conversation layout: [dot rail][content column]. + outer = QHBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(6) + self._gutter = _TimelineGutter(role) + outer.addWidget(self._gutter) + content = QWidget() + lay = QVBoxLayout(content) + lay.setContentsMargins(2, 4, 8, 8) + lay.setSpacing(4) + self._content_layout = lay + outer.addWidget(content, 1) + + if title: + if collapsible: + # Clickable header that folds long tool output away to keep the + # transcript short. Collapsed by default; click to expand. + self._head = QPushButton(title) + self._head.setCursor(Qt.PointingHandCursor) + self._head.setStyleSheet( + "QPushButton { text-align:left; border:none; background:transparent;" + f" font-weight:600; color:{_p().text_muted}; padding:0; }}") + self._head.clicked.connect(self._toggle_body) + lay.addWidget(self._head) + else: + head = QLabel(title) + head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};") + lay.addWidget(head) + + self.body = QTextBrowser() + self.body.setOpenExternalLinks(True) + self.body.setFrameShape(QFrame.NoFrame) + # Text color adapts to theme. + self._apply_theme_styles(role) + lay.addWidget(self.body) + + self._apply_style(role) + if collapsible and collapsed: + self.body.setVisible(False) + if collapsible: + self._update_head() + + def _toggle_body(self) -> None: + self.body.setVisible(not self.body.isVisible()) + if self.body.isVisible(): + self._autosize() + self._update_head() + + def _update_head(self) -> None: + if not self._head: + return + expanded = self.body.isVisible() + arrow = "▾" if expanded else "▸" + preview = "" + if not expanded and self._text.strip(): + first = self._text.strip().splitlines()[0] + if len(first) > 70: + first = first[:70] + "…" + preview = f" {first}" + self._head.setText(f"{arrow} {self._title}{preview}") + + def _current_theme(self) -> str: + """Resolve the current app theme (light or dark).""" + return _app_theme() + + def _apply_theme_styles(self, role: str) -> None: + """Apply text color to the body QTextBrowser based on current theme + role.""" + p = _p() + text_color = { + "success": p.success, + "error": p.danger, + "tool": p.text_muted, # secondary, like Claude's steps + }.get(role, p.text) + self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};") + + def _apply_style(self, role: str) -> None: + """Flat timeline row — no bubble box; the left dot/rail conveys role and + structure (Claude-Code style). The user's own message gets a faint tint + so questions are easy to pick out when scanning.""" + p = _p() + if role == "user": + self.setStyleSheet( + f"QFrame {{ background: {p.surface}; border: none; " + f"border-radius: {p.radius}px; }}") + else: + self.setStyleSheet("QFrame { background: transparent; border: none; }") + + def apply_theme(self) -> None: + """Re-apply theme-dependent styles so existing rows adapt when the app + theme switches (light ↔ dark).""" + self._apply_theme_styles(self.role) + self._apply_style(self.role) + self._gutter.set_role(self.role) + + def chat_view(self): + """Walk up the parent chain to find the enclosing ChatView, if any.""" + p = self.parent() + while p is not None: + if isinstance(p, ChatView): + return p + p = p.parent() + return None + + def append_delta(self, delta: str) -> None: + self._text += delta + self.set_markdown(self._text) + + def set_markdown(self, text: str) -> None: + self._text = text + self.body.setMarkdown(text) + self._autosize() + if self._collapsible: + self._update_head() + + def set_plain(self, text: str) -> None: + self._text = text + self.body.setPlainText(text) + self._autosize() + if self._collapsible: + self._update_head() + + def append_plain(self, delta: str) -> None: + self._text += delta + self.set_plain(self._text) + + def set_diff(self, diff_text: str) -> None: + """Render a unified diff (see :func:`diff_to_html`) with colored + before/after lines instead of a flat text block.""" + self._text = diff_text + self.body.setHtml(diff_to_html(diff_text)) + self._autosize() + if self._collapsible: + self._update_head() + + def add_usage(self, text: str) -> None: + """A small muted token/cost footer under the message (↓in ↑out ▤ctx $cost), + like Claude Code. Replaces any previous usage line on this bubble.""" + existing = getattr(self, "_usage_lbl", None) + if existing is not None: + existing.setText(text) + return + lbl = QLabel(text) + lbl.setObjectName("faint") + lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;") + self._usage_lbl = lbl + self._content_layout.addWidget(lbl) + + def add_delete_link(self, callback) -> None: + link = QLabel(f'{tr("chat.delete_link")}') + link.setToolTip(tr("chat.delete_tooltip")) + link.linkActivated.connect(lambda *_: callback()) + self._content_layout.addWidget(link) + + def add_folder_link(self, folder: str, label: str | None = None) -> None: + label = label or tr("chat.open_workspace") + link = QLabel(f'{label}') + link.setToolTip(str(folder)) + link.linkActivated.connect(lambda *_: open_folder(folder)) + self._content_layout.addWidget(link) + + def add_attachments(self, paths) -> None: + """Show attached files: images as thumbnails, others as clickable links.""" + for p in paths: + path = str(p) + name = Path(path).name + if is_image(path): + pix = QPixmap(path) + if not pix.isNull(): + thumb = QLabel() + thumb.setPixmap(pix.scaledToWidth(min(320, pix.width()), Qt.SmoothTransformation)) + thumb.setToolTip(name) + thumb.setCursor(Qt.PointingHandCursor) + self._content_layout.addWidget(thumb) + continue + file_link = QLabel(f'{name}') + file_link.setToolTip(path) + file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp)) + self._content_layout.addWidget(file_link) + + def _autosize(self) -> None: + width = self.body.viewport().width() + if width <= 0: + width = 560 # sensible default before the widget is laid out + self.body.document().setTextWidth(width) + height = int(self.body.document().size().height()) + 12 + self.body.setFixedHeight(max(28, min(height, 1200))) + + def resizeEvent(self, event): # noqa: N802 - re-flow on width change + super().resizeEvent(event) + self._autosize() + + +class ChatView(QScrollArea): + """Scrollable chat transcript. + + Emits ``theme_changed`` (via the apply_theme method) so every child + ``MessageBubble`` can re-apply its theme-aware inline styles when the + app switches between light and dark modes.""" + + def __init__(self): + super().__init__() + self.setWidgetResizable(True) + self._container = QWidget() + self._lay = QVBoxLayout(self._container) + self._lay.setContentsMargins(12, 12, 12, 12) + self._lay.setSpacing(10) + self._lay.addStretch(1) + self.setWidget(self._container) + + def apply_theme(self) -> None: + """Ask every MessageBubble inside this view to re-apply theme styles. + + Called from ``ChatPanel.apply_theme`` whenever the app theme changes.""" + for i in range(self._lay.count()): + item = self._lay.itemAt(i) + w = item.widget() if item else None + if isinstance(w, MessageBubble): + w.apply_theme() + + def _add(self, bubble: MessageBubble) -> MessageBubble: + # insert before the trailing stretch + self._lay.insertWidget(self._lay.count() - 1, bubble) + self._scroll_to_bottom() + return bubble + + def add_user(self, text: str) -> MessageBubble: + b = MessageBubble("user", tr("chat.you")) + b.set_plain(text) + return self._add(b) + + def add_assistant(self, title: str | None = None) -> MessageBubble: + b = MessageBubble("assistant", title or tr("chat.assistant")) + return self._add(b) + + def add_tool(self, title: str, body: str, ok: bool = True) -> MessageBubble: + # Tool steps (run command, generated code/diff, output) are collapsible to + # keep the transcript short — collapsed when OK, expanded on error. + b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok) + b.set_plain(body) + return self._add(b) + + def add_diff(self, title: str, diff_text: str, ok: bool = True) -> MessageBubble: + """Like :meth:`add_tool`, but renders ``diff_text`` as a colored + before/after diff (see :func:`diff_to_html`) instead of flat text.""" + b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok) + b.set_diff(diff_text) + return self._add(b) + + def add_plan(self, body: str) -> MessageBubble: + """The task plan shown INLINE in the timeline (never a pop-up or side + panel) — a permanent, always-expanded row whose steps tick off as they + complete. The agent re-sends the full list on each update; the caller + updates this same row in place via ``set_plain``.""" + b = MessageBubble("tool", tr("widgets.plan_title"), collapsible=False) + b.set_plain(body) + return self._add(b) + + def add_reasoning(self, title: str | None = None) -> MessageBubble: + # The model's private reasoning — a collapsed, collapsible box so the user + # can see it's thinking (and expand to read) without it flooding the chat. + b = MessageBubble("tool", title or tr("chat.thinking"), collapsible=True, collapsed=True) + return self._add(b) + + def add_error(self, text: str) -> MessageBubble: + b = MessageBubble("error", tr("chat.error")) + b.set_plain(text) + return self._add(b) + + def add_status(self, text: str) -> MessageBubble: + """A small one-line status marker in the transcript (e.g. '✅ Đã hoàn thành').""" + b = MessageBubble("tool", "") + b.set_plain(text) + return self._add(b) + + def add_success(self, text: str) -> MessageBubble: + """Like :meth:`add_status`, but styled green — used for the "turn done" + marker so completion reads as an unmistakable success signal.""" + b = MessageBubble("success", "") + b.set_plain(text) + return self._add(b) + + def clear(self) -> None: + while self._lay.count() > 1: + item = self._lay.takeAt(0) + w = item.widget() + if w: + w.deleteLater() + + def scroll_to_bottom(self) -> None: + """Scroll to the newest message, deferred so freshly-added bubbles have + finished sizing (their height is computed after layout).""" + QTimer.singleShot(0, self._scroll_to_bottom) + QTimer.singleShot(80, self._scroll_to_bottom) + + def _scroll_to_bottom(self) -> None: + bar = self.verticalScrollBar() + bar.setValue(bar.maximum()) diff --git a/presentation/chat/chat_input_box.py b/presentation/chat/chat_input_box.py new file mode 100644 index 0000000..9bf3875 --- /dev/null +++ b/presentation/chat/chat_input_box.py @@ -0,0 +1,328 @@ +"""Ô nhập của khung chat — R08-T02. + +Tự giãn cao theo nội dung, Ctrl+Enter để gửi, dán ảnh từ clipboard thành tệp +đính kèm, và popup gợi ý khi gõ ``/skill`` hoặc ``/agent``. + +Tách khỏi ``composer_widget.py`` vì đây là phần bắt phím và chuột; phần kia +là thanh công cụ quanh nó. +""" +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Dict, List +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QImage, QKeyEvent +from PySide6.QtWidgets import ( + QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem, + QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, +) +from ...config import CONFIG_DIR +from ...i18n import on_language_changed, tr +from ...theme import current_palette +from ...ui.icons import icon, IconLabel + + +class _SkillPopup(QListWidget): + """The ``/skill`` picker. + + Shown as a NON-activating overlay (``WA_ShowWithoutActivating``) — crucially it + does NOT grab the keyboard, so the input keeps focus and the user can keep + typing their request after ``/skill``. Navigation / accept / Esc are handled by + the parent ``_Input``'s key handler (which still receives every key); clicking + an item selects it; the popup auto-hides when the input loses focus.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint + | Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint) + self.setAttribute(Qt.WA_ShowWithoutActivating, True) + self.setFocusPolicy(Qt.NoFocus) + + +class _Input(QPlainTextEdit): + """Plain text edit: submits on Enter, accepts pasted/dropped images & files.""" + + submit = Signal() + media_added = Signal(list) + manage_skills = Signal() # user picked "Manage skills…" in the /skill popup + + MIN_HEIGHT = 64 # ~2 lines + MAX_HEIGHT = 220 # ~8 lines, then it scrolls + + def __init__(self): + super().__init__() + self.setAcceptDrops(True) + # Use a clean Latin/Vietnamese-friendly UI font for the input (the global + # '*' rule falls back to Japanese faces, which mis-render some glyphs). + self.setStyleSheet( + "font-family: 'Segoe UI', 'Helvetica Neue', 'Arial', sans-serif; font-size: 14px;" + ) + # Grow with the text (up to MAX_HEIGHT), then scroll instead. + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.textChanged.connect(self._adjust_height) + # "/skill" + "/agent" command popup — lists skills / agents inline. + self._skill_popup = _SkillPopup(self) + self._popup_kind = "skill" # which command the popup is showing + self._skill_popup.itemClicked.connect(self._accept_item) + self.textChanged.connect(self._maybe_show_skills) + self._adjust_height() + + # ---- /skill autocomplete ---------------------------------------- + def _skill_token(self): + """Locate a ``/skill[:partial]`` command the cursor is currently typing — + ANYWHERE in the message, not just at the start (so "dùng /skill:foo …" + with text typed before it still triggers the picker). Mirrors + ``core.skills.parse_skill_command``'s whitespace-boundary rule. + + Returns ``(start_offset, partial_filter)`` — ``start_offset`` is where the + ``/skill`` token begins in the document, ``partial_filter`` is the text + typed after ``:`` (``''`` while still typing the command word itself) — or + ``None`` when the cursor isn't inside a ``/skill`` token.""" + import re + pos = self.textCursor().position() + before = self.toPlainText()[:pos] + # The token is the whitespace-delimited word ending at the cursor; its + # start must be the document start or follow whitespace (same boundary + # parse_skill_command enforces with its (?= 2 and "/skill".startswith(token): + return start, "" # typing "/s", "/sk", … "/skill" → show the whole list + m = re.match(r"^/skill:?([\w\-.]*)$", token) + return (start, m.group(1)) if m else None + + def _skill_filter(self): + """Return the partial filter while a '/skill' command is being typed + (anywhere in the message), or None.""" + tok = self._skill_token() + return tok[1] if tok else None + + def _agent_token(self): + """Locate a ``/agent[:partial]`` command the cursor is typing (mirror of + ``_skill_token``). Returns ``(start_offset, partial)`` or None.""" + import re + pos = self.textCursor().position() + before = self.toPlainText()[:pos] + start = re.search(r"\S*$", before).start() + token = before[start:] + if len(token) >= 2 and "/agent".startswith(token): + return start, "" + m = re.match(r"^/agent:?([\w\-.]*)$", token) + return (start, m.group(1)) if m else None + + def _maybe_show_skills(self) -> None: + # One popup serves both commands: show skills while typing /skill, agents + # while typing /agent (Cowork parity with the Co4E chat). + stok = self._skill_token() + if stok is not None: + self._popup_kind = "skill" + self._populate_skill_popup(stok[1]) + self._show_cmd_popup() + return + atok = self._agent_token() + if atok is not None: + self._popup_kind = "agent" + self._populate_agent_popup(atok[1]) + self._show_cmd_popup() + return + self._skill_popup.hide() + + def _populate_skill_popup(self, filt: str) -> None: + try: + from ..core.skills import builtin_skills, list_skills + # Include always-on built-ins so the picker is usable before the user + # has created any custom skill. + skills = list_skills() + builtin_skills() + except Exception: + skills = [] + f = (filt or "").lower() + matches = [s for s in skills + if f in s.name.lower() or f in s.slug.lower() or f in (s.description or "").lower()] + self._skill_popup.clear() + for s in matches: + text = ("✓ " if s.enabled else " ") + s.name + if s.description: + text += f" — {s.description}" + item = QListWidgetItem(text) + item.setData(Qt.UserRole, s.slug) + self._skill_popup.addItem(item) + if not matches: + empty = QListWidgetItem(tr("composer.no_skills")) + empty.setFlags(Qt.NoItemFlags) + self._skill_popup.addItem(empty) + manage = QListWidgetItem(tr("composer.manage_skills")) + manage.setData(Qt.UserRole, "__manage__") + self._skill_popup.addItem(manage) + self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1) + + def _populate_agent_popup(self, filt: str) -> None: + try: + from ..core.agent_command import collect_agents + agents = collect_agents("") # built-ins + local admin + custom agents + except Exception: + agents = [] + f = (filt or "").lower() + matches = [a for a in agents + if f in a["slug"].lower() or f in a["name"].lower() or f in (a.get("desc") or "").lower()] + self._skill_popup.clear() + for a in matches: + text = a["name"] + (f" — {a['desc']}" if a.get("desc") else "") + item = QListWidgetItem(text) + item.setData(Qt.UserRole, a["slug"]) + self._skill_popup.addItem(item) + if not matches: + empty = QListWidgetItem(tr("composer.no_agents")) + empty.setFlags(Qt.NoItemFlags) + self._skill_popup.addItem(empty) + self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1) + + def _show_cmd_popup(self) -> None: + rows = min(7, self._skill_popup.count()) + h = 10 + rows * 22 + self._skill_popup.resize(max(300, self.width()), h) + top_left = self.mapToGlobal(self.rect().topLeft()) + self._skill_popup.move(top_left.x(), top_left.y() - h - 2) + self._skill_popup.show() + + def _dismiss_skill_popup(self) -> None: + """Hide the /skill picker (Esc).""" + self._skill_popup.hide() + + def focusOutEvent(self, e) -> None: # noqa: N802 + # The popup never grabs focus, so a click away lands here → dismiss it + # (unless the click is on the popup itself, e.g. picking an item). + if not self._skill_popup.underMouse(): + self._skill_popup.hide() + super().focusOutEvent(e) + + def _accept_item(self, item=None) -> None: + """Dispatch popup selection to the right handler based on which command + (``/skill`` or ``/agent``) the popup is currently showing.""" + if self._popup_kind == "agent": + self._accept_agent(item) + else: + self._accept_skill(item) + + def _replace_token(self, tok, replacement: str) -> None: + pos = self.textCursor().position() + start = tok[0] if tok else pos + full = self.toPlainText() + new_text = full[:start] + replacement + full[pos:] + new_pos = start + len(replacement) + self.blockSignals(True) + self.setPlainText(new_text) + self.blockSignals(False) + cur = self.textCursor() + cur.setPosition(min(new_pos, len(new_text))) + self.setTextCursor(cur) + self._adjust_height() + self.setFocus() + + def _accept_skill(self, item=None) -> None: + item = item or self._skill_popup.currentItem() + self._skill_popup.hide() + if item is None: + return + slug = item.data(Qt.UserRole) + if slug == "__manage__": + self.manage_skills.emit() # open the Skills manager + return + if not slug: + return + # Replace ONLY the /skill token the cursor is on — text typed before it + # ("dùng …") and after it is preserved, so the command can sit mid-sentence. + self._replace_token(self._skill_token(), f"/skill:{slug} ") + + def _accept_agent(self, item=None) -> None: + item = item or self._skill_popup.currentItem() + self._skill_popup.hide() + if item is None: + return + slug = item.data(Qt.UserRole) + if not slug: + return + self._replace_token(self._agent_token(), f"/agent:{slug} ") + + def _adjust_height(self, *_a) -> None: + # QPlainTextEdit reports the document height in LINES (not pixels), so + # convert via line spacing to get the real pixel height. + lines = self.document().size().height() or 1 + line_px = self.fontMetrics().lineSpacing() + h = int(lines * line_px + 2 * self.frameWidth() + 12) + h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h)) + if h != self.height(): + self.setFixedHeight(h) + + def keyPressEvent(self, e: QKeyEvent) -> None: # noqa: N802 + if self._skill_popup.isVisible(): + k = e.key() + if k in (Qt.Key_Down, Qt.Key_Up): + n = self._skill_popup.count() + if n: + step = 1 if k == Qt.Key_Down else -1 + self._skill_popup.setCurrentRow((self._skill_popup.currentRow() + step) % n) + return + if k == Qt.Key_Tab: + self._accept_item() # Tab = autocomplete the highlighted item + return + if k == Qt.Key_Escape: + self._dismiss_skill_popup() + return + if k in (Qt.Key_Return, Qt.Key_Enter): + item = self._skill_popup.currentItem() + slug = item.data(Qt.UserRole) if item else None + is_agent = self._popup_kind == "agent" + tok = self._agent_token() if is_agent else self._skill_token() + prefix = "/agent:" if is_agent else "/skill:" + token = self.toPlainText()[tok[0]:self.textCursor().position()] if tok else "" + exact = bool(slug) and slug != "__manage__" and token == f"{prefix}{slug}" + if slug and slug != "__manage__" and not exact: + # A suggestion is highlighted but not yet fully typed — + # Enter completes it into the box first (same as Tab), + # instead of submitting a partial/mistyped slug that + # the parser would just reject as "not found". + self._accept_item(item) + return + # Slug already fully typed (or nothing usable is highlighted, + # e.g. the "no skills found" placeholder) — Enter RUNS the + # /skill command as typed: hide the popup and fall through to + # the normal submit below. + self._skill_popup.hide() + if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier): + self.submit.emit() + return + super().keyPressEvent(e) + + def insertFromMimeData(self, source) -> None: # noqa: N802 - paste + paths = _paths_from_mime(source) + if paths: + self.media_added.emit(paths) + return + super().insertFromMimeData(source) + + def canInsertFromMimeData(self, source) -> bool: # noqa: N802 + if source.hasImage() or source.hasUrls(): + return True + return super().canInsertFromMimeData(source) + + def dragEnterEvent(self, e) -> None: # noqa: N802 + if e.mimeData().hasUrls() or e.mimeData().hasImage(): + e.acceptProposedAction() + return + super().dragEnterEvent(e) + + def dragMoveEvent(self, e) -> None: # noqa: N802 + if e.mimeData().hasUrls() or e.mimeData().hasImage(): + e.acceptProposedAction() + return + super().dragMoveEvent(e) + + def dropEvent(self, e) -> None: # noqa: N802 + paths = _paths_from_mime(e.mimeData()) + if paths: + self.media_added.emit(paths) + e.acceptProposedAction() + return + super().dropEvent(e) diff --git a/presentation/chat/chat_output_panel.py b/presentation/chat/chat_output_panel.py new file mode 100644 index 0000000..eede096 --- /dev/null +++ b/presentation/chat/chat_output_panel.py @@ -0,0 +1,187 @@ +"""Khung tệp vào/ra của một lượt chat — R08-T05. + +Agent có thể tạo tệp trong lúc chạy. Thay vì bắt người dùng tự đi tìm, +khung này theo dõi thư mục output và hiện tệp mới ngay khi có. + +``_is_intermediate_output`` là chỗ lọc: một lượt chạy đẻ ra nhiều tệp +trung gian mà người dùng không quan tâm; hiện hết thì khung thành bãi rác. +""" +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QWidget +from ...i18n import tr +from ...ui.icons import icon as app_icon +from ...ui.osutil import open_path +from ...ui.widgets import CollapseStrip + + +class OutputPanelMixin: + """Trộn vào ChatPanel.""" + + 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.""" + from ...ui.chat_panel import _is_scratch + 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)) + + 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 _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 ...ui.file_edit_dialog import FileEditDialog + + FileEditDialog(self.ctx, path, self).exec() + + 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) + + 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() + + 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]) + + 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 workspace_dir(self) -> Optional[Path]: + """Folder shown via the 'open folder' link on messages (None = no link).""" + return None diff --git a/presentation/chat/chat_panel_layout.py b/presentation/chat/chat_panel_layout.py new file mode 100644 index 0000000..4a098d4 --- /dev/null +++ b/presentation/chat/chat_panel_layout.py @@ -0,0 +1,149 @@ +"""Bố cục khung chat — R08-T06. + +Hai cột: mạch hội thoại bên trái, khung tệp đầu ra bên phải. Ô nhập nằm dưới +CẢ HAI cột — đó là lý do khung tệp đứng cạnh mạch hội thoại mà không làm hẹp +chỗ gõ. Đặt trong cột chat thì ô nhập co lại mỗi lần có tệp xuất hiện. + +Vài widget cố ý được gắn vào một cha ẩn vĩnh viễn thay vì bỏ hẳn: khung tệp +đầu vào và bảng kế hoạch cũ vẫn còn được gọi ``set_steps``/``add`` ở nơi +khác. Không có cha thì lần gọi đầu tiên sẽ bật lên thành một cửa sổ nổi lạc +lõng giữa màn hình. + +Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional +from PySide6.QtCore import Qt, 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 ...ui.chat_view import ChatView, ThinkingIndicator +from ...ui.composer import Composer +from ...ui.icons import collapse_right_icon, icon as app_icon +from ...ui.osutil import is_image, open_path +from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection + + +class ChatPanelLayoutMixin: + """Dựng bố cục. Trộn vào ChatPanel.""" + + def _build_layout(self, root) -> None: + """``root`` là QVBoxLayout gốc do ``__init__`` dựng.""" + # 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) diff --git a/presentation/chat/chat_session_store.py b/presentation/chat/chat_session_store.py new file mode 100644 index 0000000..1578680 --- /dev/null +++ b/presentation/chat/chat_session_store.py @@ -0,0 +1,414 @@ +"""Lưu, nạp lại phiên chat và đếm token — R08-T06. + +``_reattach_running_turn`` là phần tinh tế nhất: người dùng chuyển sang +phiên khác rồi quay lại trong khi lượt cũ vẫn đang chạy, thì phải nối +lại đúng luồng đó chứ không được khởi động lại. + +``_compress_messages`` nén ngữ cảnh khi hội thoại dài quá cửa sổ model. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMessageBox +from ...core.worker import AgentWorker +from ...i18n import tr + + +class ChatSessionMixin: + """Trộn vào ChatPanel.""" + + def _save_snapshot(self, session_id: str, messages: List[Dict[str, Any]], + title: str, inputs: Optional[List[str]] = None, + history_dir: Optional[Path] = 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. + + ``history_dir``, when given, is used INSTEAD of + ``self.ctx.config.history_dir()`` — see ``_persist_session`` (R06-T04): + a background turn must save into the project it started in, not + whichever project happens to be selected in the Workspace screen by + the time the turn finishes. + """ + 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( + history_dir if history_dir is not None else 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", []), + history_dir=ctx.get("home_history_dir")) + self.history_changed.emit() + + def running_session_ids(self): + """Set of conversation ids that currently have a turn running (for the + History status markers).""" + return set(self._sessions_live) + + def _usage_label(self) -> str: + return self.title or self.session_id + + 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 _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 _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() + + 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 + + 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 _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 _detach_live_turns(self) -> None: + """Before switching away from the current conversation, turn its running + turns into background jobs: they stop rendering into the (about-to-be- + cleared) transcript but keep running and save to their own conversation.""" + for c in self._active.values(): + if c.get("home_id") == self.session_id: + c["detached"] = True + c["assistant"] = None # its bubbles are about to be cleared + + def _running_ctx_for(self, session_id: str) -> Optional[Dict[str, Any]]: + """The in-progress turn's context for a conversation (one at a time), or None.""" + for c in self._active.values(): + if c.get("home_id") == session_id: + return c + return None + + def _reattach_running_turn(self, ctx: Dict[str, Any]) -> None: + """Re-render an in-progress turn into the current transcript and re-attach it + so it keeps streaming live — used when reopening a running conversation, so + the user sees the CURRENT task (message + steps so far + live plan), not just + the last saved state.""" + record = ctx["record"] + record["bubbles"] = [] # the old bubbles were cleared on the view switch + # 1) the user's message that is being processed + ub = self.chat_view.add_user(ctx.get("display_text") or "(attachment)") + record["bubbles"].append(ub) + # 2) steps already completed this turn (assistant text / tool results); found + # by identity after the user message (a system prompt may sit before it). + # Snapshot the list — the worker thread may still be appending to it. + msgs = list(ctx.get("messages", [])) + ui = next((i for i, m in enumerate(msgs) if m is ctx.get("user_msg")), -1) + for m in (msgs[ui + 1:] if ui >= 0 else []): + role = m.get("role") + if role == "assistant" and (m.get("content") or "").strip(): + b = self.chat_view.add_assistant(self.assistant_title()) + b.set_markdown(m["content"]) + record["bubbles"].append(b) + elif role == "tool": + b = self.chat_view.add_tool(m.get("name", "tool"), m.get("content", ""), True) + record["bubbles"].append(b) + # 3) the live plan checklist (if any) — inline, expandable + steps = ctx.get("plan_steps") or [] + if steps: + self.on_plan(steps) + from ...ui.chat_panel import _format_plan_steps + pb = self.chat_view.add_plan(_format_plan_steps(steps)) + record["bubbles"].append(pb) + ctx["plan_bubble"] = pb + # 4) the partial answer of the step currently streaming — re-attach so new + # deltas keep appending to this bubble. + ctx["assistant"] = None + ctx["reasoning"] = None + if (ctx.get("partial") or "").strip(): + ab = self.chat_view.add_assistant(self.assistant_title()) + ab.set_markdown(ctx["partial"]) + record["bubbles"].append(ab) + ctx["assistant"] = ab + # 5) live again → future events render here + ctx["detached"] = False + self.chat_view.scroll_to_bottom() diff --git a/presentation/chat/chat_turn_runner.py b/presentation/chat/chat_turn_runner.py new file mode 100644 index 0000000..2b3f322 --- /dev/null +++ b/presentation/chat/chat_turn_runner.py @@ -0,0 +1,281 @@ +"""Chạy một lượt chat, từ lúc bấm Gửi tới lúc kết thúc — R08-T06. + +``_start_turn`` (144 dòng) và ``_on_event`` (127) là hai hàm dài nhất +trong màn này, và cố ý để nguyên: cái đầu dựng trọn ngữ cảnh một lượt +rồi giao cho luồng nền, cái sau phân nhánh theo từng loại sự kiện phát +về. Cắt nhỏ thì phải chuyền hàng chục biến trạng thái qua lại. + +Mỗi lượt có luồng riêng và ngữ cảnh riêng, nên chạy song song nhiều lượt +trong cùng một khung chat được. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional +from PySide6.QtCore import Qt, Signal +from ...core.worker import AgentWorker +from ...i18n import tr +from ...state import AppContext +from ...ui.composer import Composer + + +class ChatTurnRunnerMixin: + """Trộn vào ChatPanel.""" + + def submit(self, text: str, attachments: Optional[List[str]] = None) -> None: + # Composer only emits 'submitted' when not busy; queued items are + # drained from here after each turn completes. + self._start_turn(text, attachments or []) + + def run_prompts(self, prompts: List[str]) -> None: + """Enqueue several prompts and run them (used by flows). They start up to + the parallel limit; the rest stay queued and start as slots free up.""" + prompts = [p for p in prompts if p and p.strip()] + if not prompts: + return + for p in prompts: + self.composer.enqueue(p) + self._drain_queue() + + def build_job(self, text: str, messages: List[Dict[str, Any]], + out_dir: Optional[Path]): + """Return the agent job for this turn. + + ``messages`` is the turn's OWN message list (a snapshot of the history so + far plus the new user message) — the job must read/append to it, never to + ``self.messages``, so parallel turns don't race. ``out_dir`` is the turn's + isolated output folder (or None when the tab produces no files).""" + raise NotImplementedError + + def _start_turn(self, text: str, attachments: Optional[List[str]] = None) -> None: + 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(), + # R06-T04: captured NOW, at submit time — see _persist_session's + # use of this. Without it, a background turn (this session isn't + # the one currently displayed) saves into whatever + # ctx.config.history_dir() resolves to AT THE TIME IT FINISHES, + # which is the *currently viewed* project's history folder if the + # user switched projects (ui/workspace_tab.py::_load_current) + # while this turn was still running — silently saving one + # project's conversation into another project's history folder. + "home_history_dir": self.ctx.config.history_dir(), + "detached": False, + # For re-rendering the in-progress turn if the user reopens this chat: + "display_text": text, "partial": "", "plan_steps": [], + # token/cost accounting: cumulative session usage BEFORE this turn, so + # the turn's own tokens are (after − before). + "usage_base": self._usage_snapshot(), + } + self._sessions_live[self.session_id] = self.messages + self._active[worker] = ctx + self.worker = worker + # Record the conversation in History right away (with the new user message, + # so it has a title) — it shows up and can be selected while it's running. + self._save_snapshot(self.session_id, local_messages, self.title) + self.history_changed.emit() + worker.event.connect(lambda ev, c=ctx: self._on_event(c, ev)) + worker.permission_requested.connect(lambda a, c=ctx: self._on_permission(c, a)) + worker.finished_ok.connect(lambda r, c=ctx: self._on_finished(c, r)) + worker.failed.connect(lambda e, c=ctx: self._on_failed(c, e)) + + self.composer.set_running(True) + # One turn at a time PER conversation: this conversation now has a running + # turn, so further sends here go to the Queue (in order, no interleaving). + # Other conversations can still run in parallel up to the global cap. + if self._view_busy() or len(self._active) >= self._max_parallel(): + self.composer.set_busy(True) + self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}"))) + self.thinking.start("chat.running") + worker.start() + + + + def _cleanup_turn(self, ctx: Dict[str, Any], ok: bool) -> None: + """Hook: a turn just ended (``ok`` = finished vs failed). Given the turn + context, so a tab can promote/discard that turn's isolated output folder. + No-op in the base.""" + + def _session_notes(self) -> str: + """Extra context folded into the outgoing user message (same layer as + attachment content) — e.g. Cowork lists files already produced earlier + in this conversation so the agent can reference/revise them by name + without the user re-uploading. No-op in the base.""" + return "" + + + + + def _turn_is_live(self, ctx: Dict[str, Any]) -> bool: + """True when the turn belongs to the currently-viewed conversation.""" + return ctx.get("home_id") == self.session_id and not ctx.get("detached") + + + def _on_finished(self, ctx: Dict[str, Any], result: Dict[str, Any]) -> None: + 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}"))) diff --git a/presentation/chat/composer_widget.py b/presentation/chat/composer_widget.py new file mode 100644 index 0000000..5384988 --- /dev/null +++ b/presentation/chat/composer_widget.py @@ -0,0 +1,364 @@ +"""Message composer: multiline input, attachments, Send/Stop, message queue. + +Several turns can run at once (up to the configured parallel limit). Once that +limit is reached the composer switches to "Queue" mode: extra messages (with +their attachments) are held in the queue and dispatched automatically as running +turns finish and free up a slot. Files/images can be attached to a message. +""" +from __future__ import annotations + +from .chat_input_box import _Input, _SkillPopup + +from datetime import datetime +from pathlib import Path +from typing import Dict, List + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QImage, QKeyEvent +from PySide6.QtWidgets import ( + QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem, + QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, +) + +from ...config import CONFIG_DIR +from ...i18n import on_language_changed, tr +from ...theme import current_palette +from ...ui.icons import icon, IconLabel + + +def _save_pasted_image(image) -> str | None: + """Save a clipboard/drag QImage to the config dir; return its path.""" + try: + if not isinstance(image, QImage) or image.isNull(): + return None + folder = CONFIG_DIR / "pasted" + folder.mkdir(parents=True, exist_ok=True) + name = "paste-" + datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3] + ".png" + path = folder / name + if image.save(str(path), "PNG"): + return str(path) + except Exception: + return None + return None + + +def _is_local_skill_command(text: str) -> bool: + """True for a bare ``/skill`` (list) or ``/skill:`` (select) command that + is answered inline instantly — these must run even while a turn is busy, so they + bypass the message queue (unlike ``/skill: ``, which is a real + turn and should queue).""" + import re + t = (text or "").strip() + return t == "/skill" or bool(re.match(r"^/skill:[\w\-.]+$", t)) + + +def _is_local_agent_command(text: str) -> bool: + """Same as ``_is_local_skill_command`` but for the ``/agent`` directive: a bare + ``/agent`` (list) or ``/agent:`` (select) is answered inline instantly.""" + import re + t = (text or "").strip() + return t == "/agent" or bool(re.match(r"^/agent:[\w\-.]+$", t)) + + +def _paths_from_mime(md) -> List[str]: + paths: List[str] = [] + if md.hasUrls(): + for u in md.urls(): + if u.isLocalFile(): + paths.append(u.toLocalFile()) + if not paths and md.hasImage(): + p = _save_pasted_image(md.imageData()) + if p: + paths.append(p) + return paths + + + + + + +class Composer(QWidget): + submitted = Signal(str, list) # (text, attachment paths) + stop_requested = Signal() + queue_changed = Signal(int) + attachments_added = Signal(list) # current attachment paths (pushed to the Input box) + attachment_removed = Signal(str) # a wrongly-added attachment was removed + attach_limit_note = Signal(str) # shown when the attachment-count limit is hit + manage_skills = Signal() # relayed from the /skill popup "Manage skills…" + + def __init__(self, placeholder_key: str = "composer.placeholder_default"): + super().__init__() + self._placeholder_key = placeholder_key # i18n key, re-looked-up on language change + self._queue: List[Dict] = [] # each: {"text": str, "attachments": [str]} + self._attachments: List[str] = [] + self._max_attachments = 0 # 0 = unlimited; set from Settings + self._busy = False + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(6) + + # --- queue strip (hidden when empty) --- + self.queue_box = QWidget() + qlay = QVBoxLayout(self.queue_box) + qlay.setContentsMargins(0, 0, 0, 0) + self.queue_label = QLabel() + self.queue_label.setObjectName("hint") + self.queue_list = QListWidget() + self.queue_list.setMaximumHeight(78) + self.queue_list.itemDoubleClicked.connect(self._remove_queue_item) + qlay.addWidget(self.queue_label) + qlay.addWidget(self.queue_list) + self.queue_box.setVisible(False) + root.addWidget(self.queue_box) + + # --- attachments strip (hidden when empty) --- + self.attach_box = QWidget() + alay = QVBoxLayout(self.attach_box) + alay.setContentsMargins(0, 0, 0, 0) + self.attach_label = QLabel() + self.attach_label.setObjectName("hint") + self.attach_list = QListWidget() + # Single horizontal row of chips; scroll sideways when there are many. + self.attach_list.setFlow(QListView.LeftToRight) + self.attach_list.setWrapping(False) + self.attach_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.attach_list.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.attach_list.setFixedHeight(40) + self.attach_list.itemDoubleClicked.connect(self._remove_attachment) + alay.addWidget(self.attach_label) + alay.addWidget(self.attach_list) + self.attach_box.setVisible(False) + root.addWidget(self.attach_box) + + # --- input row --- + row = QHBoxLayout() + self.input = _Input() + self.input.setPlaceholderText(tr(self._placeholder_key)) + self.input.submit.connect(self._on_submit) + self.input.media_added.connect(self._add_paths) + self.input.manage_skills.connect(self.manage_skills.emit) + row.addWidget(self.input, 1) + + btns = QVBoxLayout() + self.attach_btn = QPushButton("") + self.attach_btn.setIcon(icon("attach")) + self.attach_btn.clicked.connect(self._pick_attachments) + self.send_btn = QPushButton() + self.send_btn.setIcon(icon("upload")) + self.send_btn.setObjectName("primary") + self.send_btn.clicked.connect(self._on_submit) + self.stop_btn = QPushButton() + self.stop_btn.setIcon(icon("stop")) + self.stop_btn.setObjectName("danger") + self.stop_btn.setVisible(False) + self.stop_btn.clicked.connect(self.stop_requested.emit) + # Attach pinned to the input's top edge, Send (and Stop, once a turn + # is running) pinned to its bottom edge — the gap between them is + # absorbed by this stretch instead of splitting evenly above/below + # the whole button column, which is what centering it did before. + btns.addWidget(self.attach_btn) + btns.addStretch(1) + btns.addWidget(self.send_btn) + btns.addWidget(self.stop_btn) + row.addLayout(btns) + root.addLayout(row) + + # bottom row: left slot (e.g. Cowork's output-folder picker) — stretch — + # right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork) + # Its own strip UNDER the typing box, styled as a status line rather + # than a second toolbar: the design asks for the typing area to be just + # input · attach · send, with agent / routing / usage / folder reading + # as status underneath. They stay interactive — only quieter. + self._bottom_left_count = 0 + self.extra_bar = QWidget() + self.extra_bar.setObjectName("composerStatus") + self.extra_row = QHBoxLayout(self.extra_bar) + self.extra_row.setContentsMargins(2, 2, 2, 0) + self.extra_row.setSpacing(6) + self.extra_row.addStretch(1) + root.addWidget(self.extra_bar) + + on_language_changed(self._retranslate) + + def _retranslate(self) -> None: + self.queue_list.setToolTip(tr("composer.queue_tooltip")) + self.attach_list.setToolTip(tr("composer.attachments_tooltip")) + self.attach_btn.setToolTip(tr("composer.attach_btn_tooltip")) + self.send_btn.setText(tr("composer.queue_btn") if self._busy else tr("composer.send")) + self.stop_btn.setText(tr("composer.stop")) + if self.input.toPlainText().strip() == "" and not self._attachments: + self.input.setPlaceholderText(tr(self._placeholder_key)) + self._refresh_queue() + self._refresh_attachments() + + def add_bottom_right(self, widget) -> None: + self.extra_row.addWidget(widget) + + def add_bottom_left(self, widget) -> None: + """Insert before the stretch, after any previously-added left widget — + so repeated calls read left-to-right in call order, same row as + whatever add_bottom_right widgets (e.g. the Agent combo) sit on the + right of the stretch.""" + self.extra_row.insertWidget(self._bottom_left_count, widget) + self._bottom_left_count += 1 + + # ---- public API -------------------------------------------------- + def set_text(self, text: str) -> None: + self.input.setPlainText(text) + self.input.setFocus() + + def reset_input(self) -> None: + """Clear the input + pending attachments and restore the default placeholder + (used on New chat so no stale text or 'Attached: …' hint carries over).""" + self.input.clear() + self._attachments = [] + self._refresh_attachments() + self.input.setPlaceholderText(tr(self._placeholder_key)) + + def set_busy(self, busy: bool) -> None: + """Capacity gate: when True, new sends are queued (the Send button reads + 'Queue'). Independent of whether any turn is running — see set_running.""" + self._busy = busy + self.send_btn.setText(tr("composer.queue_btn") if busy else tr("composer.send")) + + def set_running(self, running: bool) -> None: + """Show the Stop button whenever at least one turn is running (may be True + even when not at capacity, so a single in-flight message can be stopped).""" + self.stop_btn.setVisible(running) + + def has_queue(self) -> bool: + return bool(self._queue) + + def pop_next(self) -> Dict | None: + if not self._queue: + return None + item = self._queue.pop(0) + self._refresh_queue() + return item + + def clear_queue(self) -> None: + self._queue.clear() + self._refresh_queue() + + def enqueue(self, text: str, attachments: List[str] | None = None) -> None: + self._queue.append({"text": text, "attachments": list(attachments or [])}) + self._refresh_queue() + + # ---- attachments ------------------------------------------------- + def set_max_attachments(self, n: int) -> None: + self._max_attachments = max(0, int(n or 0)) + + def _add_one(self, path: str) -> bool: + """Add a file unless it's a duplicate or the count limit is reached. + Returns False (and notifies) when the limit blocked it.""" + if not path or path in self._attachments: + return True + if self._max_attachments and len(self._attachments) >= self._max_attachments: + self.attach_limit_note.emit(tr("chatpanel.attach_limit", n=self._max_attachments)) + return False + self._attachments.append(path) + return True + + def _pick_attachments(self) -> None: + files, _ = QFileDialog.getOpenFileNames( + self, tr("composer.attach_dialog_title"), "", + tr("composer.attach_dialog_filter"), + ) + for f in files: + if not self._add_one(f): + break + self._refresh_attachments() + + def _add_paths(self, paths: List[str]) -> None: + """Add attachments from paste / drag-drop.""" + for p in paths: + if not self._add_one(p): + break + self._refresh_attachments() + if paths: + names = ", ".join(Path(p).name for p in paths) + self.input.setPlaceholderText(tr("chatpanel.attached_hint", names=names)) + + def _remove_attachment(self, item: QListWidgetItem) -> None: + idx = self.attach_list.row(item) + if 0 <= idx < len(self._attachments): + self._remove_attachment_path(self._attachments[idx]) + + def _remove_attachment_path(self, path: str) -> None: + """Remove one wrongly-added file (✕ button or double-click).""" + if path in self._attachments: + self._attachments.remove(path) + self._refresh_attachments() + self.attachment_removed.emit(path) # also drop it from the Input panel + + def _refresh_attachments(self) -> None: + self.attach_list.clear() + for p in self._attachments: + item = QListWidgetItem() + row = QWidget() + _cp = current_palette() + row.setStyleSheet( + f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};" + f" border-radius: {_cp.radius_sm}px;") + h = QHBoxLayout(row) + h.setContentsMargins(8, 2, 4, 2) + h.setSpacing(4) + short = Path(p).name + if len(short) > 22: + short = short[:19] + "…" + name = IconLabel("attach", short, size=13) + name.setToolTip(p) + remove = QPushButton() + remove.setIcon(icon("close", size=12)) + remove.setObjectName("danger") + remove.setFixedSize(18, 18) + remove.setToolTip(tr("composer.remove_tooltip")) + remove.setCursor(Qt.PointingHandCursor) + remove.clicked.connect(lambda _=False, path=p: self._remove_attachment_path(path)) + h.addWidget(name) # compact chip (no stretch → many fit in one row) + h.addWidget(remove) + item.setSizeHint(row.sizeHint()) + self.attach_list.addItem(item) + self.attach_list.setItemWidget(item, row) + self.attach_label.setText(tr("composer.attachments_label", n=len(self._attachments))) + self.attach_box.setVisible(bool(self._attachments)) + if self._attachments: + self.attachments_added.emit(list(self._attachments)) + + # ---- submit / queue ---------------------------------------------- + def _on_submit(self) -> None: + text = self.input.toPlainText().strip() + attachments = list(self._attachments) + if not text and not attachments: + return + self.input.clear() + self._attachments = [] + self._refresh_attachments() + self.input.setPlaceholderText(tr(self._placeholder_key)) # clear any "Attached: …" hint + # A local /skill or /agent list/select command is answered inline instantly + # — run it now even while a turn is busy (don't bury it in the queue). + if self._busy and not (_is_local_skill_command(text) or _is_local_agent_command(text)): + self._queue.append({"text": text, "attachments": attachments}) + self._refresh_queue() + else: + self.submitted.emit(text, attachments) + + def _remove_queue_item(self, item: QListWidgetItem) -> None: + idx = self.queue_list.row(item) + if 0 <= idx < len(self._queue): + self._queue.pop(idx) + self._refresh_queue() + + def _refresh_queue(self) -> None: + self.queue_list.clear() + for i, entry in enumerate(self._queue, 1): + text = entry.get("text", "") + n = len(entry.get("attachments", [])) + preview = text if len(text) <= 70 else text[:70] + "…" + if n: + preview += f" (+{n})" + self.queue_list.addItem(f"{i}. {preview}") + self.queue_label.setText(tr("composer.queue_label", n=len(self._queue))) + self.queue_box.setVisible(bool(self._queue)) + self.queue_changed.emit(len(self._queue)) diff --git a/ui/chat_panel.py b/ui/chat_panel.py index ac44885..caf32d6 100644 --- a/ui/chat_panel.py +++ b/ui/chat_panel.py @@ -11,6 +11,18 @@ up. Graph events are still forwarded per session. """ from __future__ import annotations +from ..presentation.chat.chat_event_stream import ChatEventStreamMixin +from ..presentation.chat.chat_panel_layout import ChatPanelLayoutMixin +from ..presentation.chat.chat_helpers import ( # noqa: F401 — giữ đường vào cũ + _TOOL_STATUS, _format_plan_steps, _is_scratch, +) + +from ..presentation.chat.attachment_picker import AttachmentMixin +from ..presentation.chat.chat_output_panel import OutputPanelMixin +from ..presentation.chat.chat_agents import ChatAgentsMixin +from ..presentation.chat.chat_turn_runner import ChatTurnRunnerMixin +from ..presentation.chat.chat_session_store import ChatSessionMixin + from pathlib import Path from typing import Any, Dict, List, Optional @@ -37,37 +49,18 @@ _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): +class ChatPanel(ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin, + OutputPanelMixin, + ChatAgentsMixin, + ChatTurnRunnerMixin, + ChatSessionMixin, + QWidget): graph_event = Signal(str, dict) # (session_name, event) turn_finished = Signal(dict) status_message = Signal(str) @@ -188,116 +181,7 @@ class ChatPanel(QWidget): 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) + self._build_layout(root) def _retranslate_base(self) -> None: """Re-apply the current language to the chrome shared by every tab @@ -319,166 +203,27 @@ class ChatPanel(QWidget): 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:" @@ -494,330 +239,25 @@ class ChatPanel(QWidget): "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. - - 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 .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 _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 = { @@ -826,753 +266,39 @@ class ChatPanel(QWidget): ".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(), - # R06-T04: captured NOW, at submit time — see _persist_session's - # use of this. Without it, a background turn (this session isn't - # the one currently displayed) saves into whatever - # ctx.config.history_dir() resolves to AT THE TIME IT FINISHES, - # which is the *currently viewed* project's history folder if the - # user switched projects (ui/workspace_tab.py::_load_current) - # while this turn was still running — silently saving one - # project's conversation into another project's history folder. - "home_history_dir": self.ctx.config.history_dir(), - "detached": False, - # For re-rendering the in-progress turn if the user reopens this chat: - "display_text": text, "partial": "", "plan_steps": [], - # token/cost accounting: cumulative session usage BEFORE this turn, so - # the turn's own tokens are (after − before). - "usage_base": self._usage_snapshot(), - } - self._sessions_live[self.session_id] = self.messages - self._active[worker] = ctx - self.worker = worker - # Record the conversation in History right away (with the new user message, - # so it has a title) — it shows up and can be selected while it's running. - self._save_snapshot(self.session_id, local_messages, self.title) - self.history_changed.emit() - worker.event.connect(lambda ev, c=ctx: self._on_event(c, ev)) - worker.permission_requested.connect(lambda a, c=ctx: self._on_permission(c, a)) - worker.finished_ok.connect(lambda r, c=ctx: self._on_finished(c, r)) - worker.failed.connect(lambda e, c=ctx: self._on_failed(c, e)) - - self.composer.set_running(True) - # One turn at a time PER conversation: this conversation now has a running - # turn, so further sends here go to the Queue (in order, no interleaving). - # Other conversations can still run in parallel up to the global cap. - if self._view_busy() or len(self._active) >= self._max_parallel(): - self.composer.set_busy(True) - self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}"))) - self.thinking.start("chat.running") - worker.start() - - def _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, - history_dir: Optional[Path] = 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. - - ``history_dir``, when given, is used INSTEAD of - ``self.ctx.config.history_dir()`` — see ``_persist_session`` (R06-T04): - a background turn must save into the project it started in, not - whichever project happens to be selected in the Workspace screen by - the time the turn finishes. - """ - 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( - history_dir if history_dir is not None else 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", []), - history_dir=ctx.get("home_history_dir")) - self.history_changed.emit() - - def running_session_ids(self): - """Set of conversation ids that currently have a turn running (for the - History status markers).""" - return set(self._sessions_live) - - def _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: @@ -1581,50 +307,8 @@ class ChatPanel(QWidget): 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).""" @@ -1659,163 +343,3 @@ class ChatPanel(QWidget): """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 diff --git a/ui/chat_view.py b/ui/chat_view.py index 5e4bc96..8ca2c18 100644 --- a/ui/chat_view.py +++ b/ui/chat_view.py @@ -1,507 +1,13 @@ -"""Scrollable chat transcript built from message bubbles.""" +"""Vỏ chuyển tiếp — R08-T01. + +Phần thân đã chuyển sang ``presentation/chat/chat_history_widget.py``. +Giữ đường import cũ cho ``ui/chat_panel.py`` và checker. +""" from __future__ import annotations -import html -from pathlib import Path - -from PySide6.QtCore import QPointF, Qt, QTimer, Signal -from PySide6.QtGui import QColor, QPainter, QPen, QPixmap -from PySide6.QtWidgets import ( - QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser, - QVBoxLayout, QWidget, +from ..presentation.chat.chat_bubble_style import ( # noqa: F401 + ThinkingIndicator, _TimelineGutter, diff_to_html, format_status_line, +) +from ..presentation.chat.chat_history_widget import ( # noqa: F401 + ChatView, MessageBubble, ) - -from ..i18n import on_language_changed, tr -from ..theme import palette, resolve_theme -from ..config import CONFIG_DIR -from .osutil import is_image, open_folder, open_path - - -def _app_theme() -> str: - """Resolve the current app theme (light or dark) from config.""" - try: - import json - with open(CONFIG_DIR / "config.json", "r", encoding="utf-8") as f: - data = json.load(f) - return resolve_theme(data.get("theme", "dark")) - except Exception: # noqa: BLE001 - return "dark" - - -def _p(): - """Design tokens for the theme in effect right now.""" - return palette(_app_theme()) - - -def _dot_color(role: str) -> str: - """Timeline dot colour for a message role.""" - p = _p() - return { - "user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool, - "error": p.role_error, "success": p.role_result, - }.get(role, p.text_faint) - - -class _TimelineGutter(QWidget): - """The left rail of the point-conversation: a vertical connector line with a - role-colored dot near the top, so stacked messages read as a timeline - (Claude-Code style) instead of separate boxes.""" - - def __init__(self, role: str): - super().__init__() - self._role = role - self.setFixedWidth(22) - - def set_role(self, role: str) -> None: - self._role = role - self.update() - - def paintEvent(self, _e): # noqa: N802 - p = QPainter(self) - p.setRenderHint(QPainter.Antialiasing) - tok = _p() - x = 11.0 - cy = 15.0 - # connector line (faint) running the full height → continuous rail - p.setPen(QPen(QColor(tok.border), 2)) - p.drawLine(int(x), 0, int(x), self.height()) - # a background ring lifts the dot off the line - p.setPen(Qt.NoPen) - p.setBrush(QColor(tok.bg)) - p.drawEllipse(QPointF(x, cy), 7.5, 7.5) - p.setBrush(QColor(_dot_color(self._role))) - p.drawEllipse(QPointF(x, cy), 4.5, 4.5) - - -def _diff_legend(diff_text: str) -> str: - """A small badge pair labeling what the colors mean: 'Before → After' for - an edit, or a single 'Added'/'Removed' badge for a pure create/delete — - so the before/after distinction is explicit, not just implied by color.""" - has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines()) - has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines()) - p = _p() - - def pill(bg: str, fg: str, key: str) -> str: - return (f'{html.escape(tr(key))}') - - if has_add and has_del: - badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before") - + f' → ' - + pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after")) - elif has_add: - badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added") - elif has_del: - badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed") - else: - return "" - return f'
{badge}
' - - -def diff_to_html(diff_text: str) -> str: - """Render a unified diff with GitHub/Claude-Code-style line coloring — - additions green, deletions red, hunk headers highlighted — plus an - explicit Before/After (or Added/Removed) legend, instead of a flat text - block, so a before/after edit reads at a glance. A brand-new file (an - empty 'before') naturally renders as all-green, which is exactly what - ``difflib.unified_diff`` already produces for it.""" - legend = _diff_legend(diff_text) - p = _p() - rows = [] - for ln in diff_text.splitlines(): - esc = html.escape(ln) if ln else " " - if ln.startswith(("+++", "---")): - rows.append(f'
{esc}
') - elif ln.startswith("@@"): - rows.append(f'
{esc}
') - elif ln.startswith("+"): - rows.append(f'
{esc}
') - elif ln.startswith("-"): - rows.append(f'
{esc}
') - else: - rows.append(f"
{esc}
") - body = "".join(rows) or "(no textual change)" - return (f'{legend}
{body}
') - - -def format_status_line(base: str, ticks: int) -> str: - """Animated status line for the working indicator, e.g. ``🤖 Running..`` and, - once the wait is a few seconds long, ``🤖 Running. · 5s`` — so a slow - synthesis clearly reads as still running. ``ticks`` advances every 500 ms.""" - dots = "." * (ticks % 4) - secs = ticks // 2 - suffix = f" · {secs}s" if secs >= 3 else "" - return f"{base}{dots}{suffix}" - - -class ThinkingIndicator(QWidget): - """A small animated 'the agent is working' line shown while waiting for a - result, so a long wait never looks like a frozen / empty screen. - - Renders a bot icon + status (e.g. ``🤖 Running…``) and, once the wait passes - a few seconds, the elapsed time — so a long synthesis clearly reads as still - running rather than stuck.""" - - def __init__(self): - super().__init__() - lay = QHBoxLayout(self) - lay.setContentsMargins(14, 2, 14, 4) - lay.setSpacing(0) - self._label = QLabel("") - self._label.setObjectName("hint") - lay.addWidget(self._label) - lay.addStretch(1) - self._base_key = "chat.running" - self._override: str | None = None - self._ticks = 0 - self._timer = QTimer(self) - self._timer.setInterval(500) - self._timer.timeout.connect(self._tick) - self.setVisible(False) - on_language_changed(self._render) - - def start(self, label_key: str = "chat.running") -> None: - self._base_key = label_key - self._override = None - self._ticks = 0 - self._render() - self.setVisible(True) - if not self._timer.isActive(): - self._timer.start() - - def set_label(self, label_key: str) -> None: - if label_key != self._base_key: - self._base_key = label_key - self._override = None - self._render() - - def set_progress_text(self, text: str) -> None: - """Show an already-formatted, literal status line (e.g. a live "reading - page 12/40" or streamed command-output detail) instead of a translated - key — used for fine-grained progress within a single step.""" - self._override = text - self._render() - - def stop(self) -> None: - self._timer.stop() - self._override = None - self.setVisible(False) - - def _tick(self) -> None: - self._ticks += 1 - self._render() - - def _render(self) -> None: - base = self._override if self._override is not None else tr(self._base_key) - self._label.setText(format_status_line(base, self._ticks)) - - -class MessageBubble(QFrame): - """One message; assistant/tool bubbles render markdown via QTextBrowser.""" - - def __init__(self, role: str, title: str = "", collapsible: bool = False, - collapsed: bool = True): - super().__init__() - self.role = role - self._text = "" - self._collapsible = collapsible - self._title = title - self._head = None - # Point-conversation layout: [dot rail][content column]. - outer = QHBoxLayout(self) - outer.setContentsMargins(0, 0, 0, 0) - outer.setSpacing(6) - self._gutter = _TimelineGutter(role) - outer.addWidget(self._gutter) - content = QWidget() - lay = QVBoxLayout(content) - lay.setContentsMargins(2, 4, 8, 8) - lay.setSpacing(4) - self._content_layout = lay - outer.addWidget(content, 1) - - if title: - if collapsible: - # Clickable header that folds long tool output away to keep the - # transcript short. Collapsed by default; click to expand. - self._head = QPushButton(title) - self._head.setCursor(Qt.PointingHandCursor) - self._head.setStyleSheet( - "QPushButton { text-align:left; border:none; background:transparent;" - f" font-weight:600; color:{_p().text_muted}; padding:0; }}") - self._head.clicked.connect(self._toggle_body) - lay.addWidget(self._head) - else: - head = QLabel(title) - head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};") - lay.addWidget(head) - - self.body = QTextBrowser() - self.body.setOpenExternalLinks(True) - self.body.setFrameShape(QFrame.NoFrame) - # Text color adapts to theme. - self._apply_theme_styles(role) - lay.addWidget(self.body) - - self._apply_style(role) - if collapsible and collapsed: - self.body.setVisible(False) - if collapsible: - self._update_head() - - def _toggle_body(self) -> None: - self.body.setVisible(not self.body.isVisible()) - if self.body.isVisible(): - self._autosize() - self._update_head() - - def _update_head(self) -> None: - if not self._head: - return - expanded = self.body.isVisible() - arrow = "▾" if expanded else "▸" - preview = "" - if not expanded and self._text.strip(): - first = self._text.strip().splitlines()[0] - if len(first) > 70: - first = first[:70] + "…" - preview = f" {first}" - self._head.setText(f"{arrow} {self._title}{preview}") - - def _current_theme(self) -> str: - """Resolve the current app theme (light or dark).""" - return _app_theme() - - def _apply_theme_styles(self, role: str) -> None: - """Apply text color to the body QTextBrowser based on current theme + role.""" - p = _p() - text_color = { - "success": p.success, - "error": p.danger, - "tool": p.text_muted, # secondary, like Claude's steps - }.get(role, p.text) - self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};") - - def _apply_style(self, role: str) -> None: - """Flat timeline row — no bubble box; the left dot/rail conveys role and - structure (Claude-Code style). The user's own message gets a faint tint - so questions are easy to pick out when scanning.""" - p = _p() - if role == "user": - self.setStyleSheet( - f"QFrame {{ background: {p.surface}; border: none; " - f"border-radius: {p.radius}px; }}") - else: - self.setStyleSheet("QFrame { background: transparent; border: none; }") - - def apply_theme(self) -> None: - """Re-apply theme-dependent styles so existing rows adapt when the app - theme switches (light ↔ dark).""" - self._apply_theme_styles(self.role) - self._apply_style(self.role) - self._gutter.set_role(self.role) - - def chat_view(self): - """Walk up the parent chain to find the enclosing ChatView, if any.""" - p = self.parent() - while p is not None: - if isinstance(p, ChatView): - return p - p = p.parent() - return None - - def append_delta(self, delta: str) -> None: - self._text += delta - self.set_markdown(self._text) - - def set_markdown(self, text: str) -> None: - self._text = text - self.body.setMarkdown(text) - self._autosize() - if self._collapsible: - self._update_head() - - def set_plain(self, text: str) -> None: - self._text = text - self.body.setPlainText(text) - self._autosize() - if self._collapsible: - self._update_head() - - def append_plain(self, delta: str) -> None: - self._text += delta - self.set_plain(self._text) - - def set_diff(self, diff_text: str) -> None: - """Render a unified diff (see :func:`diff_to_html`) with colored - before/after lines instead of a flat text block.""" - self._text = diff_text - self.body.setHtml(diff_to_html(diff_text)) - self._autosize() - if self._collapsible: - self._update_head() - - def add_usage(self, text: str) -> None: - """A small muted token/cost footer under the message (↓in ↑out ▤ctx $cost), - like Claude Code. Replaces any previous usage line on this bubble.""" - existing = getattr(self, "_usage_lbl", None) - if existing is not None: - existing.setText(text) - return - lbl = QLabel(text) - lbl.setObjectName("faint") - lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;") - self._usage_lbl = lbl - self._content_layout.addWidget(lbl) - - def add_delete_link(self, callback) -> None: - link = QLabel(f'{tr("chat.delete_link")}') - link.setToolTip(tr("chat.delete_tooltip")) - link.linkActivated.connect(lambda *_: callback()) - self._content_layout.addWidget(link) - - def add_folder_link(self, folder: str, label: str | None = None) -> None: - label = label or tr("chat.open_workspace") - link = QLabel(f'{label}') - link.setToolTip(str(folder)) - link.linkActivated.connect(lambda *_: open_folder(folder)) - self._content_layout.addWidget(link) - - def add_attachments(self, paths) -> None: - """Show attached files: images as thumbnails, others as clickable links.""" - for p in paths: - path = str(p) - name = Path(path).name - if is_image(path): - pix = QPixmap(path) - if not pix.isNull(): - thumb = QLabel() - thumb.setPixmap(pix.scaledToWidth(min(320, pix.width()), Qt.SmoothTransformation)) - thumb.setToolTip(name) - thumb.setCursor(Qt.PointingHandCursor) - self._content_layout.addWidget(thumb) - continue - file_link = QLabel(f'{name}') - file_link.setToolTip(path) - file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp)) - self._content_layout.addWidget(file_link) - - def _autosize(self) -> None: - width = self.body.viewport().width() - if width <= 0: - width = 560 # sensible default before the widget is laid out - self.body.document().setTextWidth(width) - height = int(self.body.document().size().height()) + 12 - self.body.setFixedHeight(max(28, min(height, 1200))) - - def resizeEvent(self, event): # noqa: N802 - re-flow on width change - super().resizeEvent(event) - self._autosize() - - -class ChatView(QScrollArea): - """Scrollable chat transcript. - - Emits ``theme_changed`` (via the apply_theme method) so every child - ``MessageBubble`` can re-apply its theme-aware inline styles when the - app switches between light and dark modes.""" - - def __init__(self): - super().__init__() - self.setWidgetResizable(True) - self._container = QWidget() - self._lay = QVBoxLayout(self._container) - self._lay.setContentsMargins(12, 12, 12, 12) - self._lay.setSpacing(10) - self._lay.addStretch(1) - self.setWidget(self._container) - - def apply_theme(self) -> None: - """Ask every MessageBubble inside this view to re-apply theme styles. - - Called from ``ChatPanel.apply_theme`` whenever the app theme changes.""" - for i in range(self._lay.count()): - item = self._lay.itemAt(i) - w = item.widget() if item else None - if isinstance(w, MessageBubble): - w.apply_theme() - - def _add(self, bubble: MessageBubble) -> MessageBubble: - # insert before the trailing stretch - self._lay.insertWidget(self._lay.count() - 1, bubble) - self._scroll_to_bottom() - return bubble - - def add_user(self, text: str) -> MessageBubble: - b = MessageBubble("user", tr("chat.you")) - b.set_plain(text) - return self._add(b) - - def add_assistant(self, title: str | None = None) -> MessageBubble: - b = MessageBubble("assistant", title or tr("chat.assistant")) - return self._add(b) - - def add_tool(self, title: str, body: str, ok: bool = True) -> MessageBubble: - # Tool steps (run command, generated code/diff, output) are collapsible to - # keep the transcript short — collapsed when OK, expanded on error. - b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok) - b.set_plain(body) - return self._add(b) - - def add_diff(self, title: str, diff_text: str, ok: bool = True) -> MessageBubble: - """Like :meth:`add_tool`, but renders ``diff_text`` as a colored - before/after diff (see :func:`diff_to_html`) instead of flat text.""" - b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok) - b.set_diff(diff_text) - return self._add(b) - - def add_plan(self, body: str) -> MessageBubble: - """The task plan shown INLINE in the timeline (never a pop-up or side - panel) — a permanent, always-expanded row whose steps tick off as they - complete. The agent re-sends the full list on each update; the caller - updates this same row in place via ``set_plain``.""" - b = MessageBubble("tool", tr("widgets.plan_title"), collapsible=False) - b.set_plain(body) - return self._add(b) - - def add_reasoning(self, title: str | None = None) -> MessageBubble: - # The model's private reasoning — a collapsed, collapsible box so the user - # can see it's thinking (and expand to read) without it flooding the chat. - b = MessageBubble("tool", title or tr("chat.thinking"), collapsible=True, collapsed=True) - return self._add(b) - - def add_error(self, text: str) -> MessageBubble: - b = MessageBubble("error", tr("chat.error")) - b.set_plain(text) - return self._add(b) - - def add_status(self, text: str) -> MessageBubble: - """A small one-line status marker in the transcript (e.g. '✅ Đã hoàn thành').""" - b = MessageBubble("tool", "") - b.set_plain(text) - return self._add(b) - - def add_success(self, text: str) -> MessageBubble: - """Like :meth:`add_status`, but styled green — used for the "turn done" - marker so completion reads as an unmistakable success signal.""" - b = MessageBubble("success", "") - b.set_plain(text) - return self._add(b) - - def clear(self) -> None: - while self._lay.count() > 1: - item = self._lay.takeAt(0) - w = item.widget() - if w: - w.deleteLater() - - def scroll_to_bottom(self) -> None: - """Scroll to the newest message, deferred so freshly-added bubbles have - finished sizing (their height is computed after layout).""" - QTimer.singleShot(0, self._scroll_to_bottom) - QTimer.singleShot(80, self._scroll_to_bottom) - - def _scroll_to_bottom(self) -> None: - bar = self.verticalScrollBar() - bar.setValue(bar.maximum()) diff --git a/ui/composer.py b/ui/composer.py index 0f41822..4d717d7 100644 --- a/ui/composer.py +++ b/ui/composer.py @@ -1,663 +1,11 @@ -"""Message composer: multiline input, attachments, Send/Stop, message queue. +"""Vỏ chuyển tiếp — R08-T02. -Several turns can run at once (up to the configured parallel limit). Once that -limit is reached the composer switches to "Queue" mode: extra messages (with -their attachments) are held in the queue and dispatched automatically as running -turns finish and free up a slot. Files/images can be attached to a message. +Phần thân đã chuyển sang ``presentation/chat/composer_widget.py`` (thanh công +cụ) và ``chat_input_box.py`` (ô nhập). """ from __future__ import annotations -from datetime import datetime -from pathlib import Path -from typing import Dict, List - -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QImage, QKeyEvent -from PySide6.QtWidgets import ( - QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem, - QPlainTextEdit, QPushButton, QVBoxLayout, QWidget, +from ..presentation.chat.chat_input_box import _Input, _SkillPopup # noqa: F401 +from ..presentation.chat.composer_widget import ( # noqa: F401 + Composer, ) - -from ..config import CONFIG_DIR -from ..i18n import on_language_changed, tr -from ..theme import current_palette -from .icons import icon, IconLabel - - -def _save_pasted_image(image) -> str | None: - """Save a clipboard/drag QImage to the config dir; return its path.""" - try: - if not isinstance(image, QImage) or image.isNull(): - return None - folder = CONFIG_DIR / "pasted" - folder.mkdir(parents=True, exist_ok=True) - name = "paste-" + datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3] + ".png" - path = folder / name - if image.save(str(path), "PNG"): - return str(path) - except Exception: - return None - return None - - -def _is_local_skill_command(text: str) -> bool: - """True for a bare ``/skill`` (list) or ``/skill:`` (select) command that - is answered inline instantly — these must run even while a turn is busy, so they - bypass the message queue (unlike ``/skill: ``, which is a real - turn and should queue).""" - import re - t = (text or "").strip() - return t == "/skill" or bool(re.match(r"^/skill:[\w\-.]+$", t)) - - -def _is_local_agent_command(text: str) -> bool: - """Same as ``_is_local_skill_command`` but for the ``/agent`` directive: a bare - ``/agent`` (list) or ``/agent:`` (select) is answered inline instantly.""" - import re - t = (text or "").strip() - return t == "/agent" or bool(re.match(r"^/agent:[\w\-.]+$", t)) - - -def _paths_from_mime(md) -> List[str]: - paths: List[str] = [] - if md.hasUrls(): - for u in md.urls(): - if u.isLocalFile(): - paths.append(u.toLocalFile()) - if not paths and md.hasImage(): - p = _save_pasted_image(md.imageData()) - if p: - paths.append(p) - return paths - - -class _SkillPopup(QListWidget): - """The ``/skill`` picker. - - Shown as a NON-activating overlay (``WA_ShowWithoutActivating``) — crucially it - does NOT grab the keyboard, so the input keeps focus and the user can keep - typing their request after ``/skill``. Navigation / accept / Esc are handled by - the parent ``_Input``'s key handler (which still receives every key); clicking - an item selects it; the popup auto-hides when the input loses focus.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint - | Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint) - self.setAttribute(Qt.WA_ShowWithoutActivating, True) - self.setFocusPolicy(Qt.NoFocus) - - -class _Input(QPlainTextEdit): - """Plain text edit: submits on Enter, accepts pasted/dropped images & files.""" - - submit = Signal() - media_added = Signal(list) - manage_skills = Signal() # user picked "Manage skills…" in the /skill popup - - MIN_HEIGHT = 64 # ~2 lines - MAX_HEIGHT = 220 # ~8 lines, then it scrolls - - def __init__(self): - super().__init__() - self.setAcceptDrops(True) - # Use a clean Latin/Vietnamese-friendly UI font for the input (the global - # '*' rule falls back to Japanese faces, which mis-render some glyphs). - self.setStyleSheet( - "font-family: 'Segoe UI', 'Helvetica Neue', 'Arial', sans-serif; font-size: 14px;" - ) - # Grow with the text (up to MAX_HEIGHT), then scroll instead. - self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.textChanged.connect(self._adjust_height) - # "/skill" + "/agent" command popup — lists skills / agents inline. - self._skill_popup = _SkillPopup(self) - self._popup_kind = "skill" # which command the popup is showing - self._skill_popup.itemClicked.connect(self._accept_item) - self.textChanged.connect(self._maybe_show_skills) - self._adjust_height() - - # ---- /skill autocomplete ---------------------------------------- - def _skill_token(self): - """Locate a ``/skill[:partial]`` command the cursor is currently typing — - ANYWHERE in the message, not just at the start (so "dùng /skill:foo …" - with text typed before it still triggers the picker). Mirrors - ``core.skills.parse_skill_command``'s whitespace-boundary rule. - - Returns ``(start_offset, partial_filter)`` — ``start_offset`` is where the - ``/skill`` token begins in the document, ``partial_filter`` is the text - typed after ``:`` (``''`` while still typing the command word itself) — or - ``None`` when the cursor isn't inside a ``/skill`` token.""" - import re - pos = self.textCursor().position() - before = self.toPlainText()[:pos] - # The token is the whitespace-delimited word ending at the cursor; its - # start must be the document start or follow whitespace (same boundary - # parse_skill_command enforces with its (?= 2 and "/skill".startswith(token): - return start, "" # typing "/s", "/sk", … "/skill" → show the whole list - m = re.match(r"^/skill:?([\w\-.]*)$", token) - return (start, m.group(1)) if m else None - - def _skill_filter(self): - """Return the partial filter while a '/skill' command is being typed - (anywhere in the message), or None.""" - tok = self._skill_token() - return tok[1] if tok else None - - def _agent_token(self): - """Locate a ``/agent[:partial]`` command the cursor is typing (mirror of - ``_skill_token``). Returns ``(start_offset, partial)`` or None.""" - import re - pos = self.textCursor().position() - before = self.toPlainText()[:pos] - start = re.search(r"\S*$", before).start() - token = before[start:] - if len(token) >= 2 and "/agent".startswith(token): - return start, "" - m = re.match(r"^/agent:?([\w\-.]*)$", token) - return (start, m.group(1)) if m else None - - def _maybe_show_skills(self) -> None: - # One popup serves both commands: show skills while typing /skill, agents - # while typing /agent (Cowork parity with the Co4E chat). - stok = self._skill_token() - if stok is not None: - self._popup_kind = "skill" - self._populate_skill_popup(stok[1]) - self._show_cmd_popup() - return - atok = self._agent_token() - if atok is not None: - self._popup_kind = "agent" - self._populate_agent_popup(atok[1]) - self._show_cmd_popup() - return - self._skill_popup.hide() - - def _populate_skill_popup(self, filt: str) -> None: - try: - from ..core.skills import builtin_skills, list_skills - # Include always-on built-ins so the picker is usable before the user - # has created any custom skill. - skills = list_skills() + builtin_skills() - except Exception: - skills = [] - f = (filt or "").lower() - matches = [s for s in skills - if f in s.name.lower() or f in s.slug.lower() or f in (s.description or "").lower()] - self._skill_popup.clear() - for s in matches: - text = ("✓ " if s.enabled else " ") + s.name - if s.description: - text += f" — {s.description}" - item = QListWidgetItem(text) - item.setData(Qt.UserRole, s.slug) - self._skill_popup.addItem(item) - if not matches: - empty = QListWidgetItem(tr("composer.no_skills")) - empty.setFlags(Qt.NoItemFlags) - self._skill_popup.addItem(empty) - manage = QListWidgetItem(tr("composer.manage_skills")) - manage.setData(Qt.UserRole, "__manage__") - self._skill_popup.addItem(manage) - self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1) - - def _populate_agent_popup(self, filt: str) -> None: - try: - from ..core.agent_command import collect_agents - agents = collect_agents("") # built-ins + local admin + custom agents - except Exception: - agents = [] - f = (filt or "").lower() - matches = [a for a in agents - if f in a["slug"].lower() or f in a["name"].lower() or f in (a.get("desc") or "").lower()] - self._skill_popup.clear() - for a in matches: - text = a["name"] + (f" — {a['desc']}" if a.get("desc") else "") - item = QListWidgetItem(text) - item.setData(Qt.UserRole, a["slug"]) - self._skill_popup.addItem(item) - if not matches: - empty = QListWidgetItem(tr("composer.no_agents")) - empty.setFlags(Qt.NoItemFlags) - self._skill_popup.addItem(empty) - self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1) - - def _show_cmd_popup(self) -> None: - rows = min(7, self._skill_popup.count()) - h = 10 + rows * 22 - self._skill_popup.resize(max(300, self.width()), h) - top_left = self.mapToGlobal(self.rect().topLeft()) - self._skill_popup.move(top_left.x(), top_left.y() - h - 2) - self._skill_popup.show() - - def _dismiss_skill_popup(self) -> None: - """Hide the /skill picker (Esc).""" - self._skill_popup.hide() - - def focusOutEvent(self, e) -> None: # noqa: N802 - # The popup never grabs focus, so a click away lands here → dismiss it - # (unless the click is on the popup itself, e.g. picking an item). - if not self._skill_popup.underMouse(): - self._skill_popup.hide() - super().focusOutEvent(e) - - def _accept_item(self, item=None) -> None: - """Dispatch popup selection to the right handler based on which command - (``/skill`` or ``/agent``) the popup is currently showing.""" - if self._popup_kind == "agent": - self._accept_agent(item) - else: - self._accept_skill(item) - - def _replace_token(self, tok, replacement: str) -> None: - pos = self.textCursor().position() - start = tok[0] if tok else pos - full = self.toPlainText() - new_text = full[:start] + replacement + full[pos:] - new_pos = start + len(replacement) - self.blockSignals(True) - self.setPlainText(new_text) - self.blockSignals(False) - cur = self.textCursor() - cur.setPosition(min(new_pos, len(new_text))) - self.setTextCursor(cur) - self._adjust_height() - self.setFocus() - - def _accept_skill(self, item=None) -> None: - item = item or self._skill_popup.currentItem() - self._skill_popup.hide() - if item is None: - return - slug = item.data(Qt.UserRole) - if slug == "__manage__": - self.manage_skills.emit() # open the Skills manager - return - if not slug: - return - # Replace ONLY the /skill token the cursor is on — text typed before it - # ("dùng …") and after it is preserved, so the command can sit mid-sentence. - self._replace_token(self._skill_token(), f"/skill:{slug} ") - - def _accept_agent(self, item=None) -> None: - item = item or self._skill_popup.currentItem() - self._skill_popup.hide() - if item is None: - return - slug = item.data(Qt.UserRole) - if not slug: - return - self._replace_token(self._agent_token(), f"/agent:{slug} ") - - def _adjust_height(self, *_a) -> None: - # QPlainTextEdit reports the document height in LINES (not pixels), so - # convert via line spacing to get the real pixel height. - lines = self.document().size().height() or 1 - line_px = self.fontMetrics().lineSpacing() - h = int(lines * line_px + 2 * self.frameWidth() + 12) - h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h)) - if h != self.height(): - self.setFixedHeight(h) - - def keyPressEvent(self, e: QKeyEvent) -> None: # noqa: N802 - if self._skill_popup.isVisible(): - k = e.key() - if k in (Qt.Key_Down, Qt.Key_Up): - n = self._skill_popup.count() - if n: - step = 1 if k == Qt.Key_Down else -1 - self._skill_popup.setCurrentRow((self._skill_popup.currentRow() + step) % n) - return - if k == Qt.Key_Tab: - self._accept_item() # Tab = autocomplete the highlighted item - return - if k == Qt.Key_Escape: - self._dismiss_skill_popup() - return - if k in (Qt.Key_Return, Qt.Key_Enter): - item = self._skill_popup.currentItem() - slug = item.data(Qt.UserRole) if item else None - is_agent = self._popup_kind == "agent" - tok = self._agent_token() if is_agent else self._skill_token() - prefix = "/agent:" if is_agent else "/skill:" - token = self.toPlainText()[tok[0]:self.textCursor().position()] if tok else "" - exact = bool(slug) and slug != "__manage__" and token == f"{prefix}{slug}" - if slug and slug != "__manage__" and not exact: - # A suggestion is highlighted but not yet fully typed — - # Enter completes it into the box first (same as Tab), - # instead of submitting a partial/mistyped slug that - # the parser would just reject as "not found". - self._accept_item(item) - return - # Slug already fully typed (or nothing usable is highlighted, - # e.g. the "no skills found" placeholder) — Enter RUNS the - # /skill command as typed: hide the popup and fall through to - # the normal submit below. - self._skill_popup.hide() - if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier): - self.submit.emit() - return - super().keyPressEvent(e) - - def insertFromMimeData(self, source) -> None: # noqa: N802 - paste - paths = _paths_from_mime(source) - if paths: - self.media_added.emit(paths) - return - super().insertFromMimeData(source) - - def canInsertFromMimeData(self, source) -> bool: # noqa: N802 - if source.hasImage() or source.hasUrls(): - return True - return super().canInsertFromMimeData(source) - - def dragEnterEvent(self, e) -> None: # noqa: N802 - if e.mimeData().hasUrls() or e.mimeData().hasImage(): - e.acceptProposedAction() - return - super().dragEnterEvent(e) - - def dragMoveEvent(self, e) -> None: # noqa: N802 - if e.mimeData().hasUrls() or e.mimeData().hasImage(): - e.acceptProposedAction() - return - super().dragMoveEvent(e) - - def dropEvent(self, e) -> None: # noqa: N802 - paths = _paths_from_mime(e.mimeData()) - if paths: - self.media_added.emit(paths) - e.acceptProposedAction() - return - super().dropEvent(e) - - -class Composer(QWidget): - submitted = Signal(str, list) # (text, attachment paths) - stop_requested = Signal() - queue_changed = Signal(int) - attachments_added = Signal(list) # current attachment paths (pushed to the Input box) - attachment_removed = Signal(str) # a wrongly-added attachment was removed - attach_limit_note = Signal(str) # shown when the attachment-count limit is hit - manage_skills = Signal() # relayed from the /skill popup "Manage skills…" - - def __init__(self, placeholder_key: str = "composer.placeholder_default"): - super().__init__() - self._placeholder_key = placeholder_key # i18n key, re-looked-up on language change - self._queue: List[Dict] = [] # each: {"text": str, "attachments": [str]} - self._attachments: List[str] = [] - self._max_attachments = 0 # 0 = unlimited; set from Settings - self._busy = False - - root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(6) - - # --- queue strip (hidden when empty) --- - self.queue_box = QWidget() - qlay = QVBoxLayout(self.queue_box) - qlay.setContentsMargins(0, 0, 0, 0) - self.queue_label = QLabel() - self.queue_label.setObjectName("hint") - self.queue_list = QListWidget() - self.queue_list.setMaximumHeight(78) - self.queue_list.itemDoubleClicked.connect(self._remove_queue_item) - qlay.addWidget(self.queue_label) - qlay.addWidget(self.queue_list) - self.queue_box.setVisible(False) - root.addWidget(self.queue_box) - - # --- attachments strip (hidden when empty) --- - self.attach_box = QWidget() - alay = QVBoxLayout(self.attach_box) - alay.setContentsMargins(0, 0, 0, 0) - self.attach_label = QLabel() - self.attach_label.setObjectName("hint") - self.attach_list = QListWidget() - # Single horizontal row of chips; scroll sideways when there are many. - self.attach_list.setFlow(QListView.LeftToRight) - self.attach_list.setWrapping(False) - self.attach_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.attach_list.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.attach_list.setFixedHeight(40) - self.attach_list.itemDoubleClicked.connect(self._remove_attachment) - alay.addWidget(self.attach_label) - alay.addWidget(self.attach_list) - self.attach_box.setVisible(False) - root.addWidget(self.attach_box) - - # --- input row --- - row = QHBoxLayout() - self.input = _Input() - self.input.setPlaceholderText(tr(self._placeholder_key)) - self.input.submit.connect(self._on_submit) - self.input.media_added.connect(self._add_paths) - self.input.manage_skills.connect(self.manage_skills.emit) - row.addWidget(self.input, 1) - - btns = QVBoxLayout() - self.attach_btn = QPushButton("") - self.attach_btn.setIcon(icon("attach")) - self.attach_btn.clicked.connect(self._pick_attachments) - self.send_btn = QPushButton() - self.send_btn.setIcon(icon("upload")) - self.send_btn.setObjectName("primary") - self.send_btn.clicked.connect(self._on_submit) - self.stop_btn = QPushButton() - self.stop_btn.setIcon(icon("stop")) - self.stop_btn.setObjectName("danger") - self.stop_btn.setVisible(False) - self.stop_btn.clicked.connect(self.stop_requested.emit) - # Attach pinned to the input's top edge, Send (and Stop, once a turn - # is running) pinned to its bottom edge — the gap between them is - # absorbed by this stretch instead of splitting evenly above/below - # the whole button column, which is what centering it did before. - btns.addWidget(self.attach_btn) - btns.addStretch(1) - btns.addWidget(self.send_btn) - btns.addWidget(self.stop_btn) - row.addLayout(btns) - root.addLayout(row) - - # bottom row: left slot (e.g. Cowork's output-folder picker) — stretch — - # right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork) - # Its own strip UNDER the typing box, styled as a status line rather - # than a second toolbar: the design asks for the typing area to be just - # input · attach · send, with agent / routing / usage / folder reading - # as status underneath. They stay interactive — only quieter. - self._bottom_left_count = 0 - self.extra_bar = QWidget() - self.extra_bar.setObjectName("composerStatus") - self.extra_row = QHBoxLayout(self.extra_bar) - self.extra_row.setContentsMargins(2, 2, 2, 0) - self.extra_row.setSpacing(6) - self.extra_row.addStretch(1) - root.addWidget(self.extra_bar) - - on_language_changed(self._retranslate) - - def _retranslate(self) -> None: - self.queue_list.setToolTip(tr("composer.queue_tooltip")) - self.attach_list.setToolTip(tr("composer.attachments_tooltip")) - self.attach_btn.setToolTip(tr("composer.attach_btn_tooltip")) - self.send_btn.setText(tr("composer.queue_btn") if self._busy else tr("composer.send")) - self.stop_btn.setText(tr("composer.stop")) - if self.input.toPlainText().strip() == "" and not self._attachments: - self.input.setPlaceholderText(tr(self._placeholder_key)) - self._refresh_queue() - self._refresh_attachments() - - def add_bottom_right(self, widget) -> None: - self.extra_row.addWidget(widget) - - def add_bottom_left(self, widget) -> None: - """Insert before the stretch, after any previously-added left widget — - so repeated calls read left-to-right in call order, same row as - whatever add_bottom_right widgets (e.g. the Agent combo) sit on the - right of the stretch.""" - self.extra_row.insertWidget(self._bottom_left_count, widget) - self._bottom_left_count += 1 - - # ---- public API -------------------------------------------------- - def set_text(self, text: str) -> None: - self.input.setPlainText(text) - self.input.setFocus() - - def reset_input(self) -> None: - """Clear the input + pending attachments and restore the default placeholder - (used on New chat so no stale text or 'Attached: …' hint carries over).""" - self.input.clear() - self._attachments = [] - self._refresh_attachments() - self.input.setPlaceholderText(tr(self._placeholder_key)) - - def set_busy(self, busy: bool) -> None: - """Capacity gate: when True, new sends are queued (the Send button reads - 'Queue'). Independent of whether any turn is running — see set_running.""" - self._busy = busy - self.send_btn.setText(tr("composer.queue_btn") if busy else tr("composer.send")) - - def set_running(self, running: bool) -> None: - """Show the Stop button whenever at least one turn is running (may be True - even when not at capacity, so a single in-flight message can be stopped).""" - self.stop_btn.setVisible(running) - - def has_queue(self) -> bool: - return bool(self._queue) - - def pop_next(self) -> Dict | None: - if not self._queue: - return None - item = self._queue.pop(0) - self._refresh_queue() - return item - - def clear_queue(self) -> None: - self._queue.clear() - self._refresh_queue() - - def enqueue(self, text: str, attachments: List[str] | None = None) -> None: - self._queue.append({"text": text, "attachments": list(attachments or [])}) - self._refresh_queue() - - # ---- attachments ------------------------------------------------- - def set_max_attachments(self, n: int) -> None: - self._max_attachments = max(0, int(n or 0)) - - def _add_one(self, path: str) -> bool: - """Add a file unless it's a duplicate or the count limit is reached. - Returns False (and notifies) when the limit blocked it.""" - if not path or path in self._attachments: - return True - if self._max_attachments and len(self._attachments) >= self._max_attachments: - self.attach_limit_note.emit(tr("chatpanel.attach_limit", n=self._max_attachments)) - return False - self._attachments.append(path) - return True - - def _pick_attachments(self) -> None: - files, _ = QFileDialog.getOpenFileNames( - self, tr("composer.attach_dialog_title"), "", - tr("composer.attach_dialog_filter"), - ) - for f in files: - if not self._add_one(f): - break - self._refresh_attachments() - - def _add_paths(self, paths: List[str]) -> None: - """Add attachments from paste / drag-drop.""" - for p in paths: - if not self._add_one(p): - break - self._refresh_attachments() - if paths: - names = ", ".join(Path(p).name for p in paths) - self.input.setPlaceholderText(tr("chatpanel.attached_hint", names=names)) - - def _remove_attachment(self, item: QListWidgetItem) -> None: - idx = self.attach_list.row(item) - if 0 <= idx < len(self._attachments): - self._remove_attachment_path(self._attachments[idx]) - - def _remove_attachment_path(self, path: str) -> None: - """Remove one wrongly-added file (✕ button or double-click).""" - if path in self._attachments: - self._attachments.remove(path) - self._refresh_attachments() - self.attachment_removed.emit(path) # also drop it from the Input panel - - def _refresh_attachments(self) -> None: - self.attach_list.clear() - for p in self._attachments: - item = QListWidgetItem() - row = QWidget() - _cp = current_palette() - row.setStyleSheet( - f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};" - f" border-radius: {_cp.radius_sm}px;") - h = QHBoxLayout(row) - h.setContentsMargins(8, 2, 4, 2) - h.setSpacing(4) - short = Path(p).name - if len(short) > 22: - short = short[:19] + "…" - name = IconLabel("attach", short, size=13) - name.setToolTip(p) - remove = QPushButton() - remove.setIcon(icon("close", size=12)) - remove.setObjectName("danger") - remove.setFixedSize(18, 18) - remove.setToolTip(tr("composer.remove_tooltip")) - remove.setCursor(Qt.PointingHandCursor) - remove.clicked.connect(lambda _=False, path=p: self._remove_attachment_path(path)) - h.addWidget(name) # compact chip (no stretch → many fit in one row) - h.addWidget(remove) - item.setSizeHint(row.sizeHint()) - self.attach_list.addItem(item) - self.attach_list.setItemWidget(item, row) - self.attach_label.setText(tr("composer.attachments_label", n=len(self._attachments))) - self.attach_box.setVisible(bool(self._attachments)) - if self._attachments: - self.attachments_added.emit(list(self._attachments)) - - # ---- submit / queue ---------------------------------------------- - def _on_submit(self) -> None: - text = self.input.toPlainText().strip() - attachments = list(self._attachments) - if not text and not attachments: - return - self.input.clear() - self._attachments = [] - self._refresh_attachments() - self.input.setPlaceholderText(tr(self._placeholder_key)) # clear any "Attached: …" hint - # A local /skill or /agent list/select command is answered inline instantly - # — run it now even while a turn is busy (don't bury it in the queue). - if self._busy and not (_is_local_skill_command(text) or _is_local_agent_command(text)): - self._queue.append({"text": text, "attachments": attachments}) - self._refresh_queue() - else: - self.submitted.emit(text, attachments) - - def _remove_queue_item(self, item: QListWidgetItem) -> None: - idx = self.queue_list.row(item) - if 0 <= idx < len(self._queue): - self._queue.pop(idx) - self._refresh_queue() - - def _refresh_queue(self) -> None: - self.queue_list.clear() - for i, entry in enumerate(self._queue, 1): - text = entry.get("text", "") - n = len(entry.get("attachments", [])) - preview = text if len(text) <= 70 else text[:70] + "…" - if n: - preview += f" (+{n})" - self.queue_list.addItem(f"{i}. {preview}") - self.queue_label.setText(tr("composer.queue_label", n=len(self._queue))) - self.queue_box.setVisible(bool(self._queue)) - self.queue_changed.emit(len(self._queue))