refactor(chat): R08-T01..T06 — chat_panel.py 1821 -> 345, composer 663 -> 11
presentation/chat/
chat_history_widget.py 348 T01 mạch hội thoại (từ ui/chat_view.py)
chat_bubble_style.py 202 T01 cách vẽ bong bóng, diff, đường thời gian
composer_widget.py 364 T02 thanh công cụ quanh ô nhập
chat_input_box.py 328 T02 ô nhập: Ctrl+Enter, dán ảnh, popup /skill
attachment_picker.py 215 T03 đọc tệp đính kèm + chặn theo chính sách
chat_output_panel.py 186 T05 theo dõi thư mục output, hiện tệp mới
chat_turn_runner.py 281 T06 chạy một lượt
chat_event_stream.py 228 T06 nhận sự kiện phát về từ luồng nền
chat_session_store.py 413 T06 lưu/nạp phiên, đếm token, nối lại lượt
chat_agents.py 246 T06 chọn agent, skill, định tuyến model
chat_panel_layout.py 148 T06 bố cục hai cột
chat_helpers.py 53 T06 hàm và bảng tra dùng chung
ui/chat_panel.py 345 __init__ + trạng thái
ui/chat_view.py 10 vỏ chuyển tiếp
ui/composer.py 11 vỏ chuyển tiếp
R08-T04 KHÔNG LÀM ĐƯỢC: plan đòi audio_recorder_widget.py, nhưng trong repo
KHÔNG CÓ chức năng ghi âm nào — grep 'audio|record|voice|micro' toàn ui/ chỉ
ra chữ 'record' trong nghĩa 'ghi lại transcript'. Không có gì để tách, và tôi
không dựng một widget mới nhân danh refactor. Giống hệt trường hợp
connector_settings_widget.py ở T07.
_start_turn (144 dòng) và _on_event (127) để nguyên có chủ ý: cái đầu dựng
trọn ngữ cảnh một lượt rồi giao cho luồng nền, cái sau phân nhánh theo loại sự
kiện. Cắt nhỏ thì phải chuyền hàng chục biến trạng thái qua lại, đọc khó hơn.
Hai lỗi tự gây, cả hai đều do script:
* regex bỏ import cũ chỉ cắt DÒNG ĐẦU của một import nhiều dòng, để lại phần
đuôi mồ côi -> IndentationError.
* _build_layout dùng biến 'root' vốn cục bộ trong __init__. Bộ test bắt được
cái này (2 bài integration đỏ), không phải checker — vì nó là lỗi dựng
widget, không phải lỗi hình học.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f0fd3a41cd
commit
577b81a641
@@ -0,0 +1,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
|
||||
@@ -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)."""
|
||||
@@ -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'<span style="background:{bg}; color:{fg}; padding:1px 8px; '
|
||||
f'border-radius:4px; font-weight:600;">{html.escape(tr(key))}</span>')
|
||||
|
||||
if has_add and has_del:
|
||||
badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before")
|
||||
+ f'<span style="color:{p.text_muted};"> → </span>'
|
||||
+ 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'<div style="margin-bottom:6px;">{badge}</div>'
|
||||
|
||||
|
||||
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'<div style="color:{p.text_muted};">{esc}</div>')
|
||||
elif ln.startswith("@@"):
|
||||
rows.append(f'<div style="color:{p.accent};">{esc}</div>')
|
||||
elif ln.startswith("+"):
|
||||
rows.append(f'<div style="background:{p.diff_add_bg}; color:{p.diff_add_fg};">{esc}</div>')
|
||||
elif ln.startswith("-"):
|
||||
rows.append(f'<div style="background:{p.diff_del_bg}; color:{p.diff_del_fg};">{esc}</div>')
|
||||
else:
|
||||
rows.append(f"<div>{esc}</div>")
|
||||
body = "".join(rows) or "(no textual change)"
|
||||
return (f'{legend}<div style="font-family:{p.font_mono}; font-size:12.5px; '
|
||||
f'white-space:pre-wrap;">{body}</div>')
|
||||
|
||||
|
||||
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))
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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'<a href="#del" style="color:{_p().danger};">{tr("chat.delete_link")}</a>')
|
||||
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'<a href="#open" style="color:{_p().accent};">{label}</a>')
|
||||
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'<a href="#open" style="color:{_p().accent};">{name}</a>')
|
||||
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())
|
||||
@@ -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 (?<!\S) lookbehind).
|
||||
start = re.search(r"\S*$", before).start()
|
||||
token = before[start:]
|
||||
if len(token) >= 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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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}")))
|
||||
@@ -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:<name>`` (select) command that
|
||||
is answered inline instantly — these must run even while a turn is busy, so they
|
||||
bypass the message queue (unlike ``/skill:<name> <request>``, 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:<name>`` (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))
|
||||
+19
-1495
File diff suppressed because it is too large
Load Diff
+10
-504
@@ -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'<span style="background:{bg}; color:{fg}; padding:1px 8px; '
|
||||
f'border-radius:4px; font-weight:600;">{html.escape(tr(key))}</span>')
|
||||
|
||||
if has_add and has_del:
|
||||
badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before")
|
||||
+ f'<span style="color:{p.text_muted};"> → </span>'
|
||||
+ 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'<div style="margin-bottom:6px;">{badge}</div>'
|
||||
|
||||
|
||||
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'<div style="color:{p.text_muted};">{esc}</div>')
|
||||
elif ln.startswith("@@"):
|
||||
rows.append(f'<div style="color:{p.accent};">{esc}</div>')
|
||||
elif ln.startswith("+"):
|
||||
rows.append(f'<div style="background:{p.diff_add_bg}; color:{p.diff_add_fg};">{esc}</div>')
|
||||
elif ln.startswith("-"):
|
||||
rows.append(f'<div style="background:{p.diff_del_bg}; color:{p.diff_del_fg};">{esc}</div>')
|
||||
else:
|
||||
rows.append(f"<div>{esc}</div>")
|
||||
body = "".join(rows) or "(no textual change)"
|
||||
return (f'{legend}<div style="font-family:{p.font_mono}; font-size:12.5px; '
|
||||
f'white-space:pre-wrap;">{body}</div>')
|
||||
|
||||
|
||||
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'<a href="#del" style="color:{_p().danger};">{tr("chat.delete_link")}</a>')
|
||||
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'<a href="#open" style="color:{_p().accent};">{label}</a>')
|
||||
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'<a href="#open" style="color:{_p().accent};">{name}</a>')
|
||||
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())
|
||||
|
||||
+6
-658
@@ -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:<name>`` (select) command that
|
||||
is answered inline instantly — these must run even while a turn is busy, so they
|
||||
bypass the message queue (unlike ``/skill:<name> <request>``, 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:<name>`` (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 (?<!\S) lookbehind).
|
||||
start = re.search(r"\S*$", before).start()
|
||||
token = before[start:]
|
||||
if len(token) >= 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))
|
||||
|
||||
Reference in New Issue
Block a user