merge: hoà cập nhật mới từ origin/feature/delta-team/epic-R04 (R08 Chat UI Hub + R10)
Đồng nghiệp đã push thêm 10 commit lên nhánh trong lúc đang xử lý merge
trước đó (R08-T01..T06 chat_panel.py split, R08 folder/dashboard/graph/
scheduling hoàn thiện, R10 CI Quality Gates + Contributor Recipes + E2E
smoke test). Resolve conflict:
- application/monitoring/__init__.py, domain/tasks/__init__.py,
infrastructure/persistence/json/__init__.py: chỉ khác docstring — giữ bản
HEAD (đầy đủ ngữ cảnh EPIC hơn), hợp nhất __all__ khi cần
(MonitoringQueryService).
- tests/fakes/__init__.py: hợp nhất __getattr__ để lazy-load cả
FakeToolExecutor lẫn ToolInvocation (bản HEAD thiếu ToolInvocation), bỏ
entry "FakeClock" bị lặp trong __all__.
- tests/integration/test_routing_surfaces.py (deleted by them): khôi phục
lại bản đã sửa ở lần merge trước — verify lại: API routing
(RoutingApplicationService.resolve/_apply_routing/_apply_co4e_routing)
không đổi sau khi chat_panel.py chuyển sang presentation/chat/*, 9/9 test
vẫn pass trên code đã merge.
Ghi chú (không sửa, ngoài phạm vi merge): tests/fakes/__init__.py trên nhánh
remote export "ToolInvocation" từ fake_tool_executor.py nhưng class này đã
bị xoá nhầm từ commit chung 10739f1 (breakdown folder tree epic R01) — hiện
là dead code, không ai import, nhưng sẽ raise ImportError nếu có test nào
sau này thử dùng.
Đã chạy pytest tests/: 793 passed (không phát sinh fail mới so với lần
merge trước — 8 fail còn lại đều do môi trường sandbox: thiếu package
keyring, và tên thư mục checkout "cowork-local" thay vì "cowork_local"
khiến vài test spawn-subprocess không import được package).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,31 @@
|
||||
"""Presentation chat package: ChatHistoryWidget, ComposerWidget, AttachmentPicker, AudioRecorderWidget, ChatOutputPanel."""
|
||||
"""Presentation chat package (EPIC R08 - Chat UI Hub).
|
||||
|
||||
Contains single-responsibility components and mixins for the unified chat interface:
|
||||
- ChatPanel: container shell for streaming chat turns, worker thread management, and history.
|
||||
- ChatHistoryWidget / ChatView / MessageBubble: scrollable message timeline and markdown renderers.
|
||||
- Composer / ComposerWidget: input box, command dispatch, attachments list, and message queue.
|
||||
- AttachmentMixin: attachment security check, character limit, and prompt augmentation.
|
||||
- AudioRecorderWidget: audio/voice recording button and timer status.
|
||||
- OutputPanelMixin: output files manager and directory watcher.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .attachment_picker import AttachmentMixin
|
||||
from .audio_recorder_widget import AudioRecorderWidget
|
||||
from .chat_history_widget import ChatHistoryWidget, ChatView, MessageBubble
|
||||
from .chat_output_panel import OutputPanelMixin
|
||||
from .chat_panel import ChatPanel
|
||||
from .composer_widget import Composer, ComposerWidget
|
||||
|
||||
__all__ = [
|
||||
"AttachmentMixin",
|
||||
"AudioRecorderWidget",
|
||||
"ChatHistoryWidget",
|
||||
"ChatOutputPanelMixin",
|
||||
"ChatPanel",
|
||||
"ChatView",
|
||||
"Composer",
|
||||
"ComposerWidget",
|
||||
"MessageBubble",
|
||||
"OutputPanelMixin",
|
||||
]
|
||||
|
||||
@@ -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,149 @@
|
||||
"""AudioRecorderWidget - voice recording and input widget for chat (R08-T04).
|
||||
|
||||
Provides an interactive audio recording button with animated recording status,
|
||||
time counter, and cancel/accept controls for sending audio notes or speech inputs
|
||||
to the chat agent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QElapsedTimer, QTimer, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class AudioRecorderWidget(QWidget):
|
||||
"""Voice recording panel that can be docked beside or within ComposerWidget.
|
||||
|
||||
Signals:
|
||||
recording_started: Emitted when the user starts recording.
|
||||
recording_stopped: Emitted when the user finishes recording (passes elapsed seconds).
|
||||
audio_cancelled: Emitted when the user cancels the current recording.
|
||||
audio_ready: Emitted with recorded audio bytes or duration when completed.
|
||||
"""
|
||||
|
||||
recording_started = Signal()
|
||||
recording_stopped = Signal(int) # elapsed seconds
|
||||
audio_cancelled = Signal()
|
||||
audio_ready = Signal(bytes, str) # audio_data, format (e.g., 'wav')
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._is_recording = False
|
||||
self._elapsed_seconds = 0
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(1000)
|
||||
self._timer.timeout.connect(self._on_tick)
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
"""Construct the visual hierarchy: toggle button, time counter, and action buttons."""
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(4, 2, 4, 2)
|
||||
layout.setSpacing(6)
|
||||
|
||||
# Record / Stop toggle button
|
||||
self.record_btn = QPushButton()
|
||||
self.record_btn.setIcon(icon("microphone"))
|
||||
self.record_btn.setToolTip(tr("chat.record_audio_start") if tr("chat.record_audio_start") != "chat.record_audio_start" else "Record Voice Note")
|
||||
self.record_btn.setFixedSize(32, 32)
|
||||
self.record_btn.clicked.connect(self.toggle_recording)
|
||||
layout.addWidget(self.record_btn)
|
||||
|
||||
# Status & timer display (hidden until recording starts)
|
||||
self.status_container = QWidget()
|
||||
status_layout = QHBoxLayout(self.status_container)
|
||||
status_layout.setContentsMargins(0, 0, 0, 0)
|
||||
status_layout.setSpacing(4)
|
||||
|
||||
self.recording_dot = QLabel("●")
|
||||
self.recording_dot.setStyleSheet("color: #ef4444; font-size: 14px;")
|
||||
status_layout.addWidget(self.recording_dot)
|
||||
|
||||
self.timer_label = QLabel("00:00")
|
||||
self.timer_label.setStyleSheet("font-family: monospace; font-weight: 600;")
|
||||
status_layout.addWidget(self.timer_label)
|
||||
|
||||
self.cancel_btn = QPushButton()
|
||||
self.cancel_btn.setIcon(icon("x"))
|
||||
self.cancel_btn.setToolTip("Cancel recording")
|
||||
self.cancel_btn.setFixedSize(24, 24)
|
||||
self.cancel_btn.clicked.connect(self.cancel_recording)
|
||||
status_layout.addWidget(self.cancel_btn)
|
||||
|
||||
self.status_container.setVisible(False)
|
||||
layout.addWidget(self.status_container)
|
||||
|
||||
def is_recording(self) -> bool:
|
||||
"""Check whether recording is currently in progress."""
|
||||
return self._is_recording
|
||||
|
||||
def toggle_recording(self) -> None:
|
||||
"""Toggle recording state between start and stop."""
|
||||
if self._is_recording:
|
||||
self.stop_recording()
|
||||
else:
|
||||
self.start_recording()
|
||||
|
||||
def start_recording(self) -> None:
|
||||
"""Begin audio capture and start the elapsed duration timer."""
|
||||
if self._is_recording:
|
||||
return
|
||||
self._is_recording = True
|
||||
self._elapsed_seconds = 0
|
||||
self.timer_label.setText("00:00")
|
||||
self.status_container.setVisible(True)
|
||||
self.record_btn.setIcon(icon("square"))
|
||||
self.record_btn.setToolTip("Stop Recording")
|
||||
self.record_btn.setStyleSheet("background-color: #fca5a5; color: #991b1b;")
|
||||
self._timer.start()
|
||||
self.recording_started.emit()
|
||||
|
||||
def stop_recording(self) -> None:
|
||||
"""Stop audio capture and finalize recorded data."""
|
||||
if not self._is_recording:
|
||||
return
|
||||
self._is_recording = False
|
||||
self._timer.stop()
|
||||
elapsed = self._elapsed_seconds
|
||||
self._reset_ui()
|
||||
self.recording_stopped.emit(elapsed)
|
||||
# Emit audio payload (placeholder stub for backend recording service)
|
||||
self.audio_ready.emit(b"", "wav")
|
||||
|
||||
def cancel_recording(self) -> None:
|
||||
"""Abort audio capture without emitting ready signal."""
|
||||
if not self._is_recording:
|
||||
return
|
||||
self._is_recording = False
|
||||
self._timer.stop()
|
||||
self._reset_ui()
|
||||
self.audio_cancelled.emit()
|
||||
|
||||
def _reset_ui(self) -> None:
|
||||
"""Restore UI components to default idle state."""
|
||||
self.status_container.setVisible(False)
|
||||
self.record_btn.setIcon(icon("microphone"))
|
||||
self.record_btn.setStyleSheet("")
|
||||
self.record_btn.setToolTip("Record Voice Note")
|
||||
|
||||
def _on_tick(self) -> None:
|
||||
"""Update recording duration display every second."""
|
||||
self._elapsed_seconds += 1
|
||||
mins = self._elapsed_seconds // 60
|
||||
secs = self._elapsed_seconds % 60
|
||||
self.timer_label.setText(f"{mins:02d}:{secs:02d}")
|
||||
|
||||
|
||||
__all__ = ["AudioRecorderWidget"]
|
||||
@@ -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,41 @@
|
||||
"""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
|
||||
|
||||
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
|
||||
|
||||
|
||||
|
||||
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,354 @@
|
||||
"""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())
|
||||
|
||||
|
||||
ChatHistoryWidget = ChatView
|
||||
|
||||
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
|
||||
|
||||
@@ -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,90 @@
|
||||
"""Nối lại lượt đang chạy khi người dùng quay về phiên cũ — R08-T06.
|
||||
|
||||
Phần tinh tế nhất của khung chat. Người dùng mở phiên khác rồi quay lại trong
|
||||
khi lượt cũ VẪN đang chạy: phải nối vào đúng luồng đó và đúng danh sách tin
|
||||
nhắn đang sống, chứ không được đọc bản trên đĩa (đã cũ) hay khởi động lại.
|
||||
|
||||
``_detach_live_turns`` gỡ ra khi rời phiên, ``_reattach_running_turn`` nối
|
||||
lại khi quay về. Sai một trong hai thì hoặc mất phần agent viết trong lúc
|
||||
vắng mặt, hoặc hai bên cùng ghi vào một file.
|
||||
|
||||
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
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
|
||||
|
||||
class ChatLiveTurnsMixin:
|
||||
"""Nối lại lượt đang chạy. Trộn vào ChatPanel."""
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
@@ -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,347 @@
|
||||
"""Base chat panel shared by the Cowork and Code tabs.
|
||||
|
||||
Provides: streaming transcript, a message queue, and history autosave.
|
||||
|
||||
Several messages can run **at the same time** inside one tab: each turn owns its
|
||||
own worker thread and its own turn-context (assistant bubble, transcript record,
|
||||
message list, output folder), so their streaming output and files never collide.
|
||||
The number of simultaneous turns is capped by ``cowork.max_parallel`` (default 5);
|
||||
extra messages wait in the composer queue and start automatically as slots free
|
||||
up. Graph events are still forwarded per session.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .chat_event_stream import ChatEventStreamMixin
|
||||
from .chat_panel_layout import ChatPanelLayoutMixin
|
||||
from .chat_live_turns import ChatLiveTurnsMixin
|
||||
from .chat_helpers import ( # noqa: F401 — giữ đường vào cũ
|
||||
_TOOL_STATUS, _format_plan_steps, _is_scratch,
|
||||
)
|
||||
|
||||
from .attachment_picker import AttachmentMixin
|
||||
from .chat_output_panel import OutputPanelMixin
|
||||
from .chat_agents import ChatAgentsMixin
|
||||
from .chat_turn_runner import ChatTurnRunnerMixin
|
||||
from .chat_session_store import ChatSessionMixin
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtCore import QFileSystemWatcher
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QSplitter,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import on_language_changed, tr
|
||||
from ...state import AppContext
|
||||
from ...theme import current_palette
|
||||
from .chat_bubble_style import ThinkingIndicator
|
||||
from .chat_history_widget import ChatView
|
||||
from .composer_widget 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
|
||||
|
||||
|
||||
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
|
||||
|
||||
# Friendly "what the agent is doing now" translation keys for the working
|
||||
# indicator, so a long file/document build reads as "Creating…" rather than a
|
||||
# generic "Running".
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin,
|
||||
OutputPanelMixin,
|
||||
ChatAgentsMixin,
|
||||
ChatTurnRunnerMixin,
|
||||
ChatSessionMixin,
|
||||
QWidget):
|
||||
graph_event = Signal(str, dict) # (session_name, event)
|
||||
turn_finished = Signal(dict)
|
||||
status_message = Signal(str)
|
||||
output_changed = Signal(str) # workspace dir; emitted when a file is written
|
||||
history_changed = Signal() # a session was created/updated → refresh History
|
||||
|
||||
def __init__(self, ctx: AppContext, kind: str, session_name: str,
|
||||
placeholder_key: str = "composer.placeholder_default"):
|
||||
super().__init__()
|
||||
from ...core.history import new_session_id
|
||||
|
||||
self.ctx = ctx
|
||||
self.kind = kind
|
||||
self.session_name = session_name
|
||||
self.session_id = new_session_id()
|
||||
self.title = ""
|
||||
self._notify_title()
|
||||
# Which project (workspace) this conversation belongs to — every new
|
||||
# thread inherits the currently selected project (Claude-Projects style).
|
||||
self.project_id = "default"
|
||||
self.messages: List[Dict[str, Any]] = []
|
||||
# self.worker points at the most-recently-started worker (kept for
|
||||
# back-compat); every running turn is tracked in self._active so several
|
||||
# can run concurrently. Each value is a turn-context dict — see _start_turn.
|
||||
self.worker: AgentWorker | None = None
|
||||
self._active: Dict[AgentWorker, Dict[str, Any]] = {}
|
||||
self._turn_seq: int = 0
|
||||
# session_id -> its live messages list, for every conversation that still has
|
||||
# a turn running. Lets you start a new chat / reopen an old one WHILE work
|
||||
# runs: the running turn keeps writing to its own conversation in the
|
||||
# background, and reopening it attaches to the SAME list (never a stale disk
|
||||
# copy), so the two never race on save.
|
||||
self._sessions_live: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self._teams_worker: AgentWorker | None = None
|
||||
self.turns: List[Dict[str, Any]] = []
|
||||
|
||||
# File system watcher — watches the workspace/output folder for new files
|
||||
# and auto-loads them into the agent's context on the next turn.
|
||||
self._file_watcher = QFileSystemWatcher(self)
|
||||
self._file_watcher.directoryChanged.connect(self._on_watched_dir_changed)
|
||||
self._known_files: set = set() # set of known file paths in the watched dir
|
||||
self._watch_debounce = QTimer(self)
|
||||
self._watch_debounce.setSingleShot(True)
|
||||
self._watch_debounce.setInterval(800) # debounce rapid file changes
|
||||
self._watch_debounce.timeout.connect(self._process_new_watched_files)
|
||||
self._watched_dir: Optional[Path] = None
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
root.setSpacing(0)
|
||||
|
||||
self._toolbar = QWidget()
|
||||
self.toolbar_v = QVBoxLayout(self._toolbar)
|
||||
self.toolbar_v.setContentsMargins(10, 8, 10, 4)
|
||||
self.toolbar_v.setSpacing(4)
|
||||
self.toolbar_layout = QHBoxLayout() # first row; tabs may add more rows
|
||||
self.toolbar_layout.setSpacing(8)
|
||||
self.toolbar_v.addLayout(self.toolbar_layout)
|
||||
root.addWidget(self._toolbar)
|
||||
|
||||
self.chat_view = ChatView()
|
||||
self.composer = Composer(placeholder_key)
|
||||
self.composer.submitted.connect(self.submit)
|
||||
self.composer.stop_requested.connect(self.stop)
|
||||
self.composer.attachments_added.connect(self._on_attachments_added)
|
||||
self.composer.attachment_removed.connect(self._on_attachment_removed)
|
||||
self.composer.attach_limit_note.connect(self.status_message)
|
||||
self.composer.manage_skills.connect(self._open_skills_manager)
|
||||
self.composer.set_max_attachments(
|
||||
int(ctx.config.data.get("attachments", {}).get("max_files", 10) or 0))
|
||||
# Conversation token/cost total (↓in ↑out ▤total $cost) — bottom-left,
|
||||
# updated after each turn; cost uses the Monitoring model-price table.
|
||||
self._usage_total_lbl = QLabel("")
|
||||
self._usage_total_lbl.setObjectName("hint")
|
||||
self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};")
|
||||
|
||||
|
||||
# Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma /
|
||||
# qwen for the local provider). Cowork and Code pick independently and
|
||||
# run in parallel. The list is fetched from the active provider.
|
||||
# The per-tab Agent defaults to the Settings model on startup; a manual
|
||||
# pick (override) is remembered only until the active provider changes.
|
||||
self._model = ctx.config.provider_conf().get("model", "")
|
||||
self._agent_provider = ctx.config.active_provider
|
||||
self._agent_user_override = False
|
||||
self._admin_agent = None # selected Admin-defined agent preset, if any
|
||||
# Auto Model Routing override for the NEXT turn (set by _apply_routing when
|
||||
# the router picks a different model). None → use the tab's own selection.
|
||||
self._routed_provider: Optional[str] = None
|
||||
self._routed_model: Optional[str] = None
|
||||
self._last_turn_agent_signature = None # what ran the LAST turn (see _note_agent_switch)
|
||||
self._pending_agent_switch_review = False
|
||||
self._agent_worker: AgentWorker | None = None
|
||||
self._agent_lbl = QLabel(tr("chatpanel.agent_label"))
|
||||
self._agent_lbl.setObjectName("hint")
|
||||
self.agent_combo = QComboBox()
|
||||
self.agent_combo.setMinimumWidth(150)
|
||||
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
|
||||
self.agent_combo.currentIndexChanged.connect(self._on_agent_changed)
|
||||
self.composer.add_bottom_left(self._agent_lbl)
|
||||
self.composer.add_bottom_left(self.agent_combo)
|
||||
# Off/Auto/Manual routing toggle — lets the router pick the best-fit
|
||||
# model per message (see core/routing + _apply_routing).
|
||||
from ...ui.routing_toggle import RoutingToggle
|
||||
self.routing_toggle = RoutingToggle(ctx, self.kind)
|
||||
# The drawing reads the strip left to right as
|
||||
# Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder
|
||||
# so these sit together on the left, with the folder box the Cowork tab
|
||||
# appends landing after them. Nén and Tự chạy stay on the right, where
|
||||
# the control inventory marks them "giữ nguyên tại chỗ".
|
||||
self.composer.add_bottom_left(self.routing_toggle)
|
||||
self.composer.add_bottom_left(self._usage_total_lbl)
|
||||
# Manual "compress conversation" — trim old history to cut tokens.
|
||||
self.compress_btn = QPushButton(tr("chatpanel.compress_btn"))
|
||||
self.compress_btn.setIcon(app_icon("compress"))
|
||||
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
|
||||
self.compress_btn.clicked.connect(self._compress_messages)
|
||||
self.composer.add_bottom_right(self.compress_btn)
|
||||
self.refresh_agents()
|
||||
|
||||
self._build_layout(root)
|
||||
|
||||
def _retranslate_base(self) -> None:
|
||||
"""Re-apply the current language to the chrome shared by every tab
|
||||
(Cowork/Code toolbars call their own retranslate on top of this)."""
|
||||
self._agent_lbl.setText(tr("chatpanel.agent_label"))
|
||||
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
|
||||
self.compress_btn.setText(tr("chatpanel.compress_btn"))
|
||||
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
|
||||
self.input_section.set_title(tr("widgets.input_files"))
|
||||
self.output_section.set_title(tr("widgets.output_files").upper())
|
||||
self.plan_section.set_title(tr("widgets.plan_title"))
|
||||
self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip"))
|
||||
self._files_header.setText(tr("chatpanel.files_header"))
|
||||
self._io_strip.setToolTip(tr("chatpanel.expand_files_tooltip"))
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
"""Re-apply theme styles to the chat view so all existing message bubbles
|
||||
adapt when the app switches between light and dark modes."""
|
||||
self.chat_view.apply_theme()
|
||||
|
||||
# ---- hooks for subclasses ---------------------------------------
|
||||
|
||||
|
||||
def assistant_title(self) -> str:
|
||||
return tr("chat.assistant")
|
||||
|
||||
|
||||
|
||||
# ---- file system watcher for auto-loading new files --------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---- skills management (shared by Cowork and Code) ---------------
|
||||
|
||||
|
||||
# ---- per-tab agent (model / admin-agent preset) selection --------
|
||||
_ADMIN_AGENT_PREFIX = "admin:"
|
||||
# Sent (invisibly — folded into the outgoing content, never the visible
|
||||
# chat bubble) as a one-shot prefix on the FIRST turn run under a newly
|
||||
# picked model/agent, when the conversation already has prior turns: asks
|
||||
# the new model to check over the most recent step before doing anything
|
||||
# new, so a mid-conversation switch doesn't silently drop continuity.
|
||||
_MODEL_SWITCH_REVIEW_NOTE = (
|
||||
"[Note: the AI model/agent for this conversation was just switched.] Before "
|
||||
"addressing the request below, briefly re-check the most recent step above — "
|
||||
"if anything there looks incomplete, inconsistent, or wrong, redo or fix it "
|
||||
"first, then continue."
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---- shared split-pane collapse helpers (used by subclasses too) ----
|
||||
|
||||
|
||||
# ---- delete a turn (message + its input/output files) ------------
|
||||
|
||||
|
||||
# ---- turn lifecycle ---------------------------------------------
|
||||
|
||||
|
||||
# File types considered valid input data in the workspace/output folder
|
||||
_INPUT_EXTS = {
|
||||
".csv", ".json", ".txt", ".md", ".log", ".xml", ".yaml", ".yml",
|
||||
".docx", ".docm", ".xlsx", ".xlsm", ".pptx", ".pdf", ".odt", ".ods", ".odp",
|
||||
".rtf", ".tsv",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---- token / cost accounting (shown in the chat, Claude-style) ----------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---- Teams auto-notify ------------------------------------------
|
||||
def _last_assistant_text(self) -> str:
|
||||
for m in reversed(self.messages):
|
||||
if m.get("role") == "assistant" and m.get("content"):
|
||||
return m["content"]
|
||||
return ""
|
||||
|
||||
|
||||
# ---- persistence -------------------------------------------------
|
||||
|
||||
def _busy(self) -> bool:
|
||||
"""True while any turn is still running in this tab (any conversation)."""
|
||||
return bool(self._active)
|
||||
|
||||
def _view_busy(self) -> bool:
|
||||
"""True while the CURRENTLY-VIEWED conversation has a turn running."""
|
||||
return any(c.get("home_id") == self.session_id for c in self._active.values())
|
||||
|
||||
def _sync_indicators(self) -> None:
|
||||
"""Reflect the CURRENT conversation's agent status in the chat box + composer.
|
||||
Switching chats, or hitting History → Refresh, shows whether THIS chat is
|
||||
still processing (a background turn) or idle."""
|
||||
if self._view_busy():
|
||||
self.thinking.start("chat.running") # this conversation is still working
|
||||
else:
|
||||
self.thinking.stop()
|
||||
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
|
||||
self.composer.set_busy(self._view_busy() or len(self._active) >= self._max_parallel())
|
||||
|
||||
def refresh_status(self) -> None:
|
||||
"""Public: re-sync the on-screen agent status for the current conversation
|
||||
(used by the History Refresh button)."""
|
||||
self._sync_indicators()
|
||||
|
||||
def _max_parallel(self) -> int:
|
||||
"""Unlimited concurrent turns — no cap (the old Settings limit was removed).
|
||||
A large sentinel keeps the queue logic intact without ever gating."""
|
||||
return 100000
|
||||
|
||||
def active_workers(self) -> List[AgentWorker]:
|
||||
"""Workers for turns still running (used to stop them all on quit)."""
|
||||
return list(self._active)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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 .chat_bubble_style import ThinkingIndicator
|
||||
from .chat_history_widget import ChatView
|
||||
from .composer_widget 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,352 @@
|
||||
"""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 _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)))
|
||||
|
||||
|
||||
|
||||
@@ -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,370 @@
|
||||
"""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))
|
||||
|
||||
|
||||
ComposerWidget = Composer
|
||||
|
||||
__all__ = ["Composer", "ComposerWidget"]
|
||||
|
||||
@@ -333,6 +333,7 @@ class Co4EChatMixin:
|
||||
"""Show the plan INLINE in the conversation as an expandable block; update
|
||||
the same (per-flow) bubble in place so steps tick off (✓) as they complete."""
|
||||
log = log or self.chat_log
|
||||
from ...ui.co4e_tab import _fmt_plan
|
||||
body = _fmt_plan(steps)
|
||||
if not body:
|
||||
return
|
||||
|
||||
@@ -223,6 +223,7 @@ class Co4ERunsMixin:
|
||||
if c == 0:
|
||||
it.setData(Qt.UserRole, h.id)
|
||||
if c == 1:
|
||||
from ...ui.co4e_tab import _qcolor
|
||||
it.setForeground(_qcolor(color.get(h.status, p.text)))
|
||||
t.setItem(r, c, it)
|
||||
if h.id == sel_id:
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Một lượt AI sửa file, từ lúc gửi tới lúc ghi ra đĩa — R08-T12.
|
||||
|
||||
``_ai_run_edit`` dài (82 dòng) vì nó là cả một lượt: dựng ngữ cảnh từ
|
||||
file đang mở, gọi provider, nhận nội dung phát dần, tách phần mã khỏi
|
||||
phần giải thích, rồi dựng bản xem trước.
|
||||
|
||||
Không bao giờ ghi đè thẳng: kết quả hiện ra để người dùng xem, và chỉ
|
||||
``_ai_apply`` mới chạm vào file.
|
||||
|
||||
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .file_helpers import (
|
||||
_HTML_SUFFIXES, _PPTX_SUFFIXES, _parse_ai_output, _pptx_available,
|
||||
)
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from PySide6.QtCore import Qt
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
|
||||
|
||||
class AiEditRunnerMixin:
|
||||
"""Trộn vào FolderTab."""
|
||||
|
||||
def _ai_send(self) -> None:
|
||||
if not self._root or not os.path.isdir(self._root):
|
||||
self.ai_chat.add_error(tr("folder.ai_no_file"))
|
||||
return
|
||||
instruction = self.ai_input.text().strip()
|
||||
if not instruction:
|
||||
return
|
||||
self.ai_input.clear()
|
||||
self.ai_chat.add_user(instruction)
|
||||
# QUEUE: while a run is active OR a proposal is awaiting Apply/Discard,
|
||||
# hold the new instruction and run it when the pipeline goes idle. Lets
|
||||
# the user line up several edits without waiting for each to finish.
|
||||
if self._ai_worker is not None or self._ai_pending is not None:
|
||||
self._ai_queue.append(instruction)
|
||||
self.ai_chat.add_status(tr("folder.ai_queued", n=len(self._ai_queue)))
|
||||
self._update_queue_status()
|
||||
return
|
||||
self._ai_start(instruction)
|
||||
|
||||
def _ai_start(self, instruction: str) -> None:
|
||||
"""Begin processing one instruction (plan → edit). Assumes the pipeline
|
||||
is idle (the queue calls this when the previous run finishes)."""
|
||||
# If a text/code/HTML file is open (even in Preview), switch it into the
|
||||
# editor so AI can edit it. If nothing editable is open, that's fine —
|
||||
# the request may be to CREATE a new file (the model names it via FILE:).
|
||||
editable = self.stack.currentWidget() is self.editor
|
||||
if not editable:
|
||||
editable = self._ensure_editor_for_ai()
|
||||
self._maybe_suggest_image_model(instruction)
|
||||
# Auto Model Routing (may switch to the best coding model for this run).
|
||||
self._ai_apply_routing(instruction)
|
||||
has_file = editable and bool(self._current_file)
|
||||
self._ai_running_file = Path(self._current_file).name if has_file else tr("folder.ai_new_file")
|
||||
self._ai_set_busy(True)
|
||||
# Announce start on the status bar so it's visible even from another tab —
|
||||
# the edit keeps running in the background until it finishes.
|
||||
self.status_message.emit(tr("folder.ai_running", name=self._ai_running_file))
|
||||
# Two phases so the PLAN is shown INLINE *before* the edit runs.
|
||||
self._ai_ctx = {
|
||||
"filename": Path(self._current_file).name if has_file else "",
|
||||
"content": self.editor.toPlainText() if has_file else "",
|
||||
"convo": self._cowork_context(),
|
||||
"instruction": instruction,
|
||||
"provider": self._ai_provider(),
|
||||
"plan": "",
|
||||
}
|
||||
# Reset the token/cost tally for THIS prompt (plan + edit calls sum into it).
|
||||
self._ai_prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
self._ai_run_plan()
|
||||
|
||||
def _ai_maybe_dequeue(self) -> None:
|
||||
"""When the pipeline is fully idle, start the next queued instruction."""
|
||||
if self._ai_worker is not None or self._ai_pending is not None:
|
||||
return
|
||||
if not self._ai_queue:
|
||||
return
|
||||
nxt = self._ai_queue.pop(0)
|
||||
self._update_queue_status()
|
||||
self._ai_start(nxt)
|
||||
|
||||
def _ai_add_usage(self, usage) -> None:
|
||||
"""Add one model call's usage (plan or edit) to THIS prompt's tally."""
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
tot = getattr(self, "_ai_prompt_usage", None)
|
||||
if tot is None:
|
||||
tot = self._ai_prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
tot["in"] += int(usage.get("in", 0) or 0)
|
||||
tot["out"] += int(usage.get("out", 0) or 0)
|
||||
tot["cache"] += int(usage.get("cache", 0) or 0)
|
||||
tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0)
|
||||
|
||||
def _ai_show_usage(self, bubble) -> None:
|
||||
"""Footer under the AI-edit reply: ↓in ↑out ▤ctx $cost for the whole
|
||||
prompt (plan + edit), priced in the display currency — same as Cowork."""
|
||||
tot = getattr(self, "_ai_prompt_usage", None)
|
||||
if bubble is None or not tot or not (tot["in"] or tot["out"]):
|
||||
return
|
||||
from ...core import model_pricing as mp, usage_tracker as ut
|
||||
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
line = (f"↓{mp.format_tokens(int(tot['in']))} ↑{mp.format_tokens(int(tot['out']))} "
|
||||
f"▤{mp.format_tokens(int(tot['in'] + tot['out'] + tot['cache']))} "
|
||||
f"{ut.format_cost(tot['cost'], pricing)}")
|
||||
try:
|
||||
bubble.add_usage(line)
|
||||
except Exception: # noqa: BLE001 - a usage footer must never break the edit
|
||||
pass
|
||||
|
||||
def _ai_run_plan(self) -> None:
|
||||
c = self._ai_ctx
|
||||
plan_bubble = self.ai_chat.add_plan(tr("folder.ai_planning"))
|
||||
self.ai_chat.scroll_to_bottom()
|
||||
|
||||
def job(worker):
|
||||
from ...core import usage_tracker as ut
|
||||
from ...core.co4e_runner import _usage_delta
|
||||
provider = c["provider"]
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor. Give a SHORT numbered plan (2-4 steps) for "
|
||||
"the requested change. Plan ONLY — do NOT output any code."}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
messages.append({"role": "user", "content":
|
||||
f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
f"Request: {c['instruction']}"})
|
||||
ut.set_context("folder", c.get("filename") or "AI edit") # Dashboard + usage
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, self.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"plan": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(lambda res, b=plan_bubble: self._ai_plan_done(res, b))
|
||||
worker.failed.connect(lambda err, b=plan_bubble: self._ai_failed(err, b))
|
||||
self._ai_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _ai_plan_done(self, result, plan_bubble) -> None:
|
||||
self._ai_add_usage((result or {}).get("usage")) # plan-step tokens
|
||||
plan = ((result or {}).get("plan") or "").strip()
|
||||
self._ai_ctx["plan"] = plan
|
||||
plan_bubble.set_plain(plan or tr("folder.ai_empty"))
|
||||
self.ai_chat.scroll_to_bottom()
|
||||
self._ai_run_edit() # now execute the plan
|
||||
|
||||
def _ai_run_edit(self) -> None:
|
||||
c = self._ai_ctx
|
||||
bubble = self.ai_chat.add_assistant(tr("folder.ai_edit"))
|
||||
self.ai_chat.scroll_to_bottom()
|
||||
|
||||
pptx_note = ("\nThis is a PPTX shown as marker blocks '### Slide N / Box M', where N is the "
|
||||
"1-based SLIDE NUMBER and M the box on that slide. When the user refers to a "
|
||||
"slide by number (e.g. 'edit slide 3'), change ONLY the blocks under '### Slide "
|
||||
"3' and leave every other slide's block exactly as-is. Each block has fields "
|
||||
"type/pos/size/font/text (and image for pictures). To change TEXT COLOUR or "
|
||||
"FONT edit the `font:` line — e.g. `font: name=Arial size=28 bold=1 "
|
||||
"color=FF0000`. Keep all block markers and structure.") if self._edit_kind == "pptx" else ""
|
||||
|
||||
# When creating a NEW deck (request mentions slides/pptx and we're not
|
||||
# already editing one), tell the model the marker format to emit so we can
|
||||
# build a real .pptx from it.
|
||||
_pptx_words = ("pptx", "powerpoint", "slide", "presentation", "deck",
|
||||
"スライド", "プレゼン", "trình chiếu", "trinh chieu", "bài thuyết trình")
|
||||
wants_new_pptx = (self._edit_kind != "pptx"
|
||||
and any(w in c["instruction"].lower() for w in _pptx_words))
|
||||
new_pptx_note = ("\nTo CREATE a PowerPoint, name it `FILE: <name>.pptx` and output the slides "
|
||||
"as marker blocks — one block per shape:\n"
|
||||
"### Slide 1 / Box 1\ntype: text\npos: 0.5, 0.4\nsize: 9.0, 1.2\n"
|
||||
"font: name=Calibri size=32 bold=1 color=1F3864\ntext:\nTitle here\n\n"
|
||||
"### Slide 1 / Box 2\ntype: text\npos: 0.5, 1.8\nsize: 9.0, 4.5\n"
|
||||
"text:\nBullet one\nBullet two\n\n"
|
||||
"Increment the Slide number for each new slide; pos/size are in inches; "
|
||||
"font color is RRGGBB hex.") if wants_new_pptx else ""
|
||||
|
||||
imggen_note = ""
|
||||
try:
|
||||
from ...core import image_gen
|
||||
if image_gen.is_configured(self.ctx.config):
|
||||
imggen_note = ("\nYou can also GENERATE an illustration image: add a line "
|
||||
"`IMAGE_GEN: <describe the image> => <relative/path.png>`. Use a "
|
||||
"generated image e.g. as a new picture, or (for pptx) set a picture "
|
||||
"box's `image:` field to that same path to insert it.")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def job(worker):
|
||||
provider = c["provider"]
|
||||
open_note = (f"the currently-open file '{c['filename']}'" if c["filename"]
|
||||
else "no file is open")
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor inside an app. Following the plan, output the "
|
||||
"COMPLETE file content in ONE fenced code block (```), and nothing after "
|
||||
"it. Preserve everything you were not asked to change.\n"
|
||||
"If the request is to CREATE A NEW file (or a different file than the one "
|
||||
"open), put a line `FILE: <relative/path/name.ext>` (relative to the "
|
||||
"current folder) immediately before the code block. Omit FILE to edit the "
|
||||
f"open file. Right now, {open_note}." + pptx_note + new_pptx_note + imggen_note}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
if c["plan"]:
|
||||
messages.append({"role": "system", "content": "Plan to follow:\n" + c["plan"]})
|
||||
cur = (f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
if c["filename"] else "No file is currently open.\n\n")
|
||||
messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"})
|
||||
|
||||
def on_text(piece: str) -> None:
|
||||
worker.emit_event({"type": "text", "delta": piece})
|
||||
|
||||
from ...core import usage_tracker as ut
|
||||
from ...core.co4e_runner import _usage_delta
|
||||
ut.set_context("folder", c.get("filename") or "AI edit") # Dashboard + usage
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, on_text=on_text, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, self.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"text": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.event.connect(lambda ev, b=bubble: self._ai_stream(ev, b))
|
||||
worker.finished_ok.connect(lambda res, b=bubble: self._ai_done(res, b))
|
||||
worker.failed.connect(lambda err, b=bubble: self._ai_failed(err, b))
|
||||
self._ai_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _ai_stream(self, ev, bubble) -> None:
|
||||
if isinstance(ev, dict) and ev.get("type") == "text":
|
||||
bubble.append_delta(ev.get("delta", ""))
|
||||
self.ai_chat.scroll_to_bottom()
|
||||
|
||||
def _ai_done(self, result, bubble) -> None:
|
||||
self._ai_worker = None
|
||||
self._ai_set_busy(False)
|
||||
self._ai_add_usage((result or {}).get("usage")) # edit-step tokens
|
||||
self._ai_show_usage(bubble) # footer: prompt total (plan+edit)
|
||||
text = ((result or {}).get("text") or "").strip()
|
||||
target, new_content, summary, image_gens = _parse_ai_output(text)
|
||||
if new_content is None and not image_gens:
|
||||
bubble.set_markdown(text or tr("folder.ai_empty"))
|
||||
self.ai_chat.scroll_to_bottom()
|
||||
self._ai_flag_done()
|
||||
return
|
||||
# Decide edit-current vs create-new. A FILE: naming a path different from
|
||||
# the open file (or when nothing is open) → CREATE a new file.
|
||||
create = bool(target) and (not self._current_file
|
||||
or Path(target).name != Path(self._current_file).name)
|
||||
# PROPOSE the change — nothing is written until the user clicks Apply.
|
||||
self._ai_pending = {"content": new_content,
|
||||
"target": target if create else None,
|
||||
"image_gens": image_gens}
|
||||
hint = tr("folder.ai_review_hint")
|
||||
bubble.set_markdown((summary + "\n\n" if summary else "") + "_" + hint + "_")
|
||||
if new_content is not None:
|
||||
import difflib
|
||||
old = "" if create else self.editor.toPlainText()
|
||||
diff = "".join(difflib.unified_diff(
|
||||
old.splitlines(keepends=True), new_content.splitlines(keepends=True),
|
||||
fromfile=("(new file)" if create else "current"),
|
||||
tofile=(target if create else "proposed"))) or "(no textual difference)"
|
||||
title = tr("folder.ai_proposed_new", name=target) if create else tr("folder.ai_proposed")
|
||||
self.ai_chat.add_diff(title, diff)
|
||||
if image_gens:
|
||||
listing = "\n".join(f"• {p} ({prompt[:60]})" for prompt, p in image_gens)
|
||||
self.ai_chat.add_status(tr("folder.ai_image_plan") + "\n" + listing)
|
||||
self._ai_confirm_row.setVisible(True)
|
||||
self.ai_chat.scroll_to_bottom()
|
||||
name = target if create else getattr(self, "_ai_running_file", "")
|
||||
self.status_message.emit(tr("folder.ai_proposed_status", name=name))
|
||||
self._ai_status.setText("● " + hint)
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().warning};")
|
||||
|
||||
def _ai_apply(self) -> None:
|
||||
"""Confirmed by the user. If the edit GENERATES images, ask the image
|
||||
gate then generate them (off-thread) before finalising the file edit."""
|
||||
if not self._ai_pending:
|
||||
return
|
||||
p = self._ai_pending
|
||||
self._ai_pending = None
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
if p.get("image_gens"):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
|
||||
self.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return
|
||||
self._ai_generate_then_finalize(p)
|
||||
return
|
||||
self._ai_finalize_apply(p)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _ai_discard(self) -> None:
|
||||
self._ai_pending = None
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
self.ai_chat.add_status(tr("folder.ai_discarded"))
|
||||
self.ai_chat.scroll_to_bottom()
|
||||
self._ai_status.setText("")
|
||||
self._ai_maybe_dequeue() # discarding resolves the gate → run the next queued edit
|
||||
|
||||
|
||||
def _ai_failed(self, err, bubble) -> None:
|
||||
self._ai_worker = None
|
||||
bubble.set_markdown(tr("folder.ai_error", err=err))
|
||||
self._ai_set_busy(False)
|
||||
self.status_message.emit(tr("folder.ai_error", err=err))
|
||||
self._ai_flag_done()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Ghi kết quả AI ra đĩa — R08-T12.
|
||||
|
||||
Tách khỏi ``ai_edit_runner.py`` vì đây là phần DUY NHẤT thật sự chạm vào
|
||||
file của người dùng. Mọi thứ trước nó chỉ dựng bản xem trước.
|
||||
|
||||
Gồm cả nhánh sinh ảnh: lượt nào có ảnh thì phải chờ ảnh xong mới ghi, vì
|
||||
nội dung có thể tham chiếu tới đường dẫn ảnh vừa tạo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .file_helpers import (
|
||||
_HTML_SUFFIXES, _PPTX_SUFFIXES, _parse_ai_output, _pptx_available,
|
||||
)
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from PySide6.QtCore import Qt
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
|
||||
|
||||
class AiOutputWriterMixin:
|
||||
"""Trộn vào FolderTab."""
|
||||
|
||||
def _ai_generate_then_finalize(self, p: dict) -> None:
|
||||
imgs = p.get("image_gens") or []
|
||||
root = os.path.normpath(self._root)
|
||||
img_model, img_base, img_key = self._ai_image_model() # may target another provider
|
||||
self._ai_set_busy(True)
|
||||
self.status_message.emit(tr("folder.ai_generating"))
|
||||
|
||||
def job(worker):
|
||||
from ...core import image_gen
|
||||
results = []
|
||||
for prompt, rel in imgs:
|
||||
dest = rel if os.path.isabs(rel) else os.path.join(root, rel)
|
||||
dest = os.path.normpath(dest)
|
||||
if os.path.commonpath([dest, root]) != root:
|
||||
results.append((rel, False, "path escapes the folder"))
|
||||
continue
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
except OSError as exc:
|
||||
results.append((rel, False, str(exc)))
|
||||
continue
|
||||
ok, msg = image_gen.generate_image(self.ctx.config, prompt, dest,
|
||||
model=img_model, base_url=img_base, api_key=img_key)
|
||||
results.append((dest, ok, msg))
|
||||
return {"results": results}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(lambda res, pp=p: self._ai_images_done(res, pp))
|
||||
worker.failed.connect(lambda err, pp=p: self._ai_images_done({"results": [], "err": err}, pp))
|
||||
self._ai_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _ai_images_done(self, res: dict, p: dict) -> None:
|
||||
self._ai_worker = None
|
||||
self._ai_set_busy(False)
|
||||
created = []
|
||||
for dest, ok, msg in res.get("results", []):
|
||||
if ok:
|
||||
created.append(dest)
|
||||
self.ai_chat.add_success("✓ " + tr("folder.ai_image_created", name=Path(dest).name))
|
||||
else:
|
||||
self.ai_chat.add_error(tr("folder.ai_image_failed", err=msg))
|
||||
# Now apply any text/file edit (pptx image: fields now point at real files).
|
||||
self._ai_finalize_apply(p, images_done=True)
|
||||
# If it was only image generation, open the first new image.
|
||||
if p.get("content") is None and not p.get("target") and created:
|
||||
self.open_file(created[0], reset=False)
|
||||
|
||||
def _ai_finalize_apply(self, p: dict, images_done: bool = False) -> None:
|
||||
content = p.get("content")
|
||||
target = p.get("target")
|
||||
if content is None:
|
||||
self.ai_chat.scroll_to_bottom()
|
||||
self._ai_flag_done()
|
||||
self.status_message.emit(tr("folder.ai_done", name=getattr(self, "_ai_running_file", "")))
|
||||
return
|
||||
if target:
|
||||
dest = self._create_new_file(target, content)
|
||||
if dest is None:
|
||||
return
|
||||
self.ai_chat.add_success("✓ " + tr("folder.ai_created", name=Path(dest).name))
|
||||
self.status_message.emit(tr("folder.ai_created", name=Path(dest).name))
|
||||
else:
|
||||
self.editor.setPlainText(content) # live update in the editor/preview
|
||||
self._ai_write_out(content, skip_image_confirm=images_done)
|
||||
self.ai_chat.add_success("✓ " + tr("folder.ai_applied"))
|
||||
self.status_message.emit(tr("folder.ai_done", name=getattr(self, "_ai_running_file", "")))
|
||||
self.ai_chat.scroll_to_bottom()
|
||||
self._ai_flag_done()
|
||||
|
||||
def _create_new_file(self, target: str, content: str) -> Optional[str]:
|
||||
"""Create ``target`` (relative to the folder root) with ``content`` and
|
||||
open it — like Cowork's save_file. Refuses paths escaping the root."""
|
||||
root = os.path.normpath(self._root)
|
||||
dest = target if os.path.isabs(target) else os.path.join(root, target)
|
||||
dest = os.path.normpath(dest)
|
||||
if os.path.commonpath([dest, root]) != root:
|
||||
self.status_message.emit(tr("folder.ai_error", err="path escapes the folder"))
|
||||
return None
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
if Path(dest).suffix.lower() in _PPTX_SUFFIXES and _pptx_available():
|
||||
# A .pptx is a binary package — build a real deck from the marker
|
||||
# text (writing text straight to .pptx would corrupt it).
|
||||
from ...core import pptx_edit
|
||||
pptx_edit.create_pptx_from_text(dest, content)
|
||||
else:
|
||||
Path(dest).write_text(content, encoding="utf-8")
|
||||
except Exception as exc: # noqa: BLE001 - OS error or pptx build failure
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return None
|
||||
self.open_file(dest, reset=False) # show the new file; keep this AI chat
|
||||
return dest
|
||||
|
||||
def _ai_write_out(self, content: str, skip_image_confirm: bool = False) -> None:
|
||||
"""Persist the confirmed content to disk AND refresh the preview.
|
||||
pptx text is written back into the deck (no PowerPoint window)."""
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._write_pptx(content, skip_confirm=skip_image_confirm):
|
||||
return
|
||||
else:
|
||||
Path(self._current_file).write_text(content, encoding="utf-8")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return
|
||||
# Refresh preview: HTML re-renders; pptx re-renders the slides; code stays
|
||||
# in the (now-saved) editor.
|
||||
suffix = Path(self._current_file).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._show_html(self._current_file, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES:
|
||||
self._show_pptx(self._current_file, mode_preview=True)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Hàm phụ trợ đọc và nhận dạng file — R08-T12.
|
||||
|
||||
Thuần hàm, không widget. ``_is_probably_text`` là chỗ quyết định file
|
||||
mở bằng ô soạn thảo hay báo là nhị phân — đoán sai thì người dùng thấy
|
||||
một màn hình ký tự rác.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...ui.libreoffice_view import DOC_SUFFIXES # noqa: F401 — dùng lại ở cả gói
|
||||
|
||||
# Nhận dạng loại file và các ngưỡng — gom về đây vì cả sáu file trong gói
|
||||
# đều hỏi tới, để rải ra thì mỗi lần thêm một đuôi file phải sửa vài chỗ.
|
||||
try:
|
||||
from ...graph.graph_web import _HAS_WEB
|
||||
except Exception: # pragma: no cover
|
||||
_HAS_WEB = False
|
||||
|
||||
try:
|
||||
from PySide6.QtPdf import QPdfDocument # noqa: F401
|
||||
from PySide6.QtPdfWidgets import QPdfView # noqa: F401
|
||||
_HAS_PDF = True
|
||||
except Exception: # pragma: no cover - QtPdf not bundled
|
||||
_HAS_PDF = False
|
||||
|
||||
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"}
|
||||
_HTML_SUFFIXES = {".html", ".htm"}
|
||||
_PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint)
|
||||
_EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice)
|
||||
_MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only
|
||||
_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy)
|
||||
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QFont, QTextCharFormat
|
||||
from ...i18n import tr
|
||||
|
||||
|
||||
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
|
||||
f = QTextCharFormat()
|
||||
f.setForeground(QColor(color))
|
||||
if italic:
|
||||
f.setFontItalic(True)
|
||||
if bold:
|
||||
f.setFontWeight(QFont.Bold)
|
||||
return f
|
||||
|
||||
|
||||
def _pptx_available() -> bool:
|
||||
"""True when python-pptx is importable. If it's MISSING, auto-download &
|
||||
install it (via deps.ensure_module) so pptx editing 'just works' — cached so
|
||||
the (one-time) install is attempted only once."""
|
||||
global _PPTX_READY
|
||||
if _PPTX_READY is None:
|
||||
try:
|
||||
from ...core.deps import ensure_module
|
||||
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
|
||||
except Exception: # noqa: BLE001
|
||||
_PPTX_READY = False
|
||||
return _PPTX_READY
|
||||
|
||||
|
||||
def _split_code_block(text: str):
|
||||
"""Split an AI reply into ``(file_content, summary)``. ``file_content`` is
|
||||
the first fenced code block (the edited file); ``summary`` is any prose
|
||||
before it. Returns ``(None, text)`` when there's no code block."""
|
||||
import re
|
||||
m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL)
|
||||
if not m:
|
||||
return None, (text or "")
|
||||
return m.group(1), (text[:m.start()].strip())
|
||||
|
||||
|
||||
def _parse_ai_output(text: str):
|
||||
"""Parse an AI edit reply into ``(target, content, summary, image_gens)``.
|
||||
``FILE: <path>`` names a NEW file to create; ``IMAGE_GEN: <prompt> => <path>``
|
||||
lines request generated illustration images (relative paths)."""
|
||||
import re
|
||||
content, summary = _split_code_block(text)
|
||||
target = None
|
||||
m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "")
|
||||
if m:
|
||||
target = m.group(1).strip().strip("`\"'")
|
||||
image_gens = []
|
||||
for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""):
|
||||
image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'")))
|
||||
# Strip the directive lines out of the shown summary.
|
||||
summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip()
|
||||
return target, content, summary, image_gens
|
||||
|
||||
|
||||
def _read_text(path: str) -> str:
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return f"[could not read file: {exc}]"
|
||||
|
||||
|
||||
def _is_probably_text(path: str) -> bool:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
chunk = f.read(4096)
|
||||
except OSError:
|
||||
return False
|
||||
if b"\x00" in chunk:
|
||||
return False
|
||||
try:
|
||||
chunk.decode("utf-8")
|
||||
return True
|
||||
except UnicodeDecodeError:
|
||||
# Latin-ish text still edits fine via errors="replace"; only reject on
|
||||
# a hard binary signal (NUL above), so most source files pass.
|
||||
return True
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Chọn model sinh ảnh cho AI sửa file — R08-T12.
|
||||
|
||||
Dò khắp mọi provider đã cấu hình xem cái nào sinh được ảnh, rồi gợi ý khi
|
||||
câu người dùng gõ nghe như đang muốn tạo ảnh. Có thể gợi ý model của
|
||||
provider KHÁC provider đang chọn — nên nó tách riêng: đây là chỗ duy nhất
|
||||
trong màn Thư mục biết tới nhiều provider cùng lúc.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .file_helpers import (
|
||||
DOC_SUFFIXES, _HTML_SUFFIXES, _IMAGE_SUFFIXES, _PPTX_SUFFIXES, _is_probably_text, _pptx_available,
|
||||
)
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
from ...ui.chat_view import ChatView
|
||||
from ...ui.libreoffice_view import DOC_SUFFIXES
|
||||
|
||||
|
||||
class ImageModelPickerMixin:
|
||||
"""Trộn vào FolderTab."""
|
||||
|
||||
def _scan_all_image_models(self, then_suggest: bool = False) -> None:
|
||||
"""Background: find image-capable models across EVERY configured provider
|
||||
(not just the active one), so we can suggest one when an edit involves
|
||||
images even if the active provider has none. Caches
|
||||
``self._all_image_models = [(provider_key, model)]``."""
|
||||
if self._img_scan_worker is not None:
|
||||
if then_suggest:
|
||||
self._pending_img_suggest = True
|
||||
return
|
||||
providers = dict(self.ctx.config.data.get("providers", {}))
|
||||
# Only providers that actually have an endpoint/key configured.
|
||||
candidates = [k for k, c in providers.items()
|
||||
if (c.get("base_url") or c.get("api_key"))]
|
||||
|
||||
def job(worker):
|
||||
from ...core import image_gen
|
||||
found = []
|
||||
for key in candidates:
|
||||
try:
|
||||
prov = self.ctx.build_provider_for(key)
|
||||
models = list(getattr(prov, "list_models", lambda: [])() or [])
|
||||
except Exception: # noqa: BLE001 - a broken provider must not block the scan
|
||||
models = []
|
||||
for m in models:
|
||||
if image_gen.looks_like_image_model(m):
|
||||
found.append((key, m))
|
||||
return {"found": found}
|
||||
|
||||
def done(res):
|
||||
self._img_scan_worker = None
|
||||
self._all_image_models = list(res.get("found", []))
|
||||
if getattr(self, "_pending_img_suggest", False):
|
||||
self._pending_img_suggest = False
|
||||
self._suggest_cross_provider_image()
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(lambda _e: setattr(self, "_img_scan_worker", None))
|
||||
self._img_scan_worker = w
|
||||
if then_suggest:
|
||||
self._pending_img_suggest = True
|
||||
w.start()
|
||||
|
||||
def _maybe_suggest_image_model(self, instruction: str) -> None:
|
||||
"""If the request looks image-related, suggest a suitable image model
|
||||
BEFORE running — searching the active provider first, then ALL providers.
|
||||
The suggested model is what image generation will auto-use."""
|
||||
from ...core import image_gen
|
||||
low = (instruction or "").lower()
|
||||
if not any(w in low for w in self._IMAGE_WORDS):
|
||||
return
|
||||
picked = self.ai_model_combo.currentData()
|
||||
if picked and image_gen.looks_like_image_model(picked):
|
||||
return
|
||||
local = image_gen.suggest_image_model(self._ai_models)
|
||||
if local:
|
||||
self.ai_chat.add_status(tr("folder.ai_image_suggest", model=local))
|
||||
return
|
||||
# None on the active provider → look across ALL providers (cached, or scan
|
||||
# now and suggest when the scan returns).
|
||||
if self._all_image_models:
|
||||
self._suggest_cross_provider_image()
|
||||
elif self._img_scan_worker is not None:
|
||||
self._pending_img_suggest = True # a scan is already running
|
||||
else:
|
||||
self._scan_all_image_models(then_suggest=True)
|
||||
|
||||
def _suggest_cross_provider_image(self) -> None:
|
||||
"""Post a suggestion listing image models found on OTHER providers. When
|
||||
none exist anywhere, fall back to telling the user their PICKED model
|
||||
will be used for image generation (or that there's nothing to use)."""
|
||||
from ...config import PROVIDER_LABELS
|
||||
if not self._all_image_models:
|
||||
picked = self.ai_model_combo.currentData()
|
||||
if picked:
|
||||
self.ai_chat.add_status(tr("folder.ai_image_use_selected", model=picked))
|
||||
else:
|
||||
self.ai_chat.add_status(tr("folder.ai_image_none"))
|
||||
return
|
||||
seen, lines = set(), []
|
||||
for key, model in self._all_image_models:
|
||||
tag = (key, model)
|
||||
if tag in seen:
|
||||
continue
|
||||
seen.add(tag)
|
||||
lines.append(f"• {model} ({PROVIDER_LABELS.get(key, key)})")
|
||||
if len(lines) >= 5:
|
||||
break
|
||||
self.ai_chat.add_status(tr("folder.ai_image_suggest_all") + "\n" + "\n".join(lines))
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Chọn project và đổi chế độ xem cho GraphRAG — R08-T14.
|
||||
|
||||
Đồ thị luôn thuộc về một project. Đổi project là phải quét lại từ đầu, nên
|
||||
phần này giữ luôn việc dọn kết quả cũ trước khi nạp cái mới.
|
||||
|
||||
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .graph_qa_widget import GraphQaMixin
|
||||
from .graph_render import GraphRenderMixin
|
||||
from .graph_scene import _Edge, _GraphView, _Node
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QPointF, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QComboBox, QFileDialog, QGraphicsScene, QGraphicsView, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar, QTextBrowser, QVBoxLayout, QWidget
|
||||
from ...theme import current_palette
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import on_language_changed, tr
|
||||
from ...state import AppContext
|
||||
from ...ui.icons import collapse_right_icon, icon
|
||||
from ...ui.widgets import CollapseStrip
|
||||
|
||||
|
||||
class GraphProjectMixin:
|
||||
"""Chọn project + đổi tab xem. Trộn vào StructureGraphView."""
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
|
||||
self._pick_btn.setText(tr("structure.browse"))
|
||||
self._scan_btn.setText(tr("structure.scan"))
|
||||
self._export_btn.setText(tr("structure.export_png"))
|
||||
# Both views are named at once now, so neither label depends on state.
|
||||
self.view_tabs.setTabText(0, tr("structure.graph_btn"))
|
||||
self.view_tabs.setTabText(1, tr("structure.msgs_btn"))
|
||||
self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip"))
|
||||
self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip"))
|
||||
self._ag_label.setText(tr("structure.agent_header"))
|
||||
self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
|
||||
self._ask_btn.setText(tr("structure.ask"))
|
||||
if self._detail_mode == "idle":
|
||||
self.detail.setPlaceholderText(tr("structure.detail_placeholder"))
|
||||
self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip"))
|
||||
self.project_combo.setToolTip(tr("structure.project_tooltip"))
|
||||
self._refresh_project_combo()
|
||||
def _refresh_project_combo(self) -> None:
|
||||
from ...core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
def set_project(self, project_id: str) -> None:
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
from ...core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
if project_changed:
|
||||
self._clear_extracts() # different workspace → drop temp extraction
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
# Also disable the folder-pick button — otherwise the scan path is only
|
||||
# "locked" against typing, but the picker could still repoint it outside
|
||||
# the selected project's sandbox, breaking GraphRAG scope isolation.
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
# Mark it and scan on the next visit rather than now. The rail's
|
||||
# project picker made switching a one-click thing from any screen,
|
||||
# and each switch rebuilt this graph — a folder walk plus a force
|
||||
# layout plus a full setHtml of the D3 page — for a tab that was
|
||||
# usually not even on screen. auto_scan_and_fit() picks the flag up
|
||||
# when GraphRAG is actually opened.
|
||||
self._needs_scan = True
|
||||
def _pick(self) -> None:
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text())
|
||||
if chosen:
|
||||
self.path_edit.setText(chosen)
|
||||
def _on_view_tab(self, index: int) -> None:
|
||||
"""Tab 0 = graph, tab 1 = messages. Same two views as before, now named
|
||||
on screen instead of hidden behind one button's changing label."""
|
||||
if index == 1:
|
||||
self._reload_messages()
|
||||
self._stack.setCurrentWidget(self._msgs_view)
|
||||
else:
|
||||
self._stack.setCurrentWidget(self.web if self.web is not None else self.view)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Quét mã nguồn, dựng đồ thị, và xuất ảnh — R08-T14.
|
||||
|
||||
Hai đường vẽ song song: khung nhìn Qt (``_render``) và bản D3 chạy trong
|
||||
QtWebEngine (``_render_d3``). WebEngine nặng nên chỉ dựng ở lần hiện đầu tiên
|
||||
(``_ensure_web``), và ``prewarm`` hâm nóng nó lúc rảnh để bấm vào GraphRAG
|
||||
không phải ngồi nhìn khung trắng.
|
||||
|
||||
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .graph_scene import _Bridge, _Edge, _Node
|
||||
|
||||
from .graph_web import _HAS_WEB, QWebChannel, QWebEngineView
|
||||
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QPointF, Qt, QUrl
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
from ...theme import current_palette
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
|
||||
|
||||
class GraphRenderMixin:
|
||||
"""Quét, vẽ, xuất. Trộn vào StructureGraphView."""
|
||||
|
||||
def schedule_rescan(self, path: str = "") -> None:
|
||||
if self._graph is None:
|
||||
self._needs_scan = True
|
||||
return
|
||||
self._rescan_timer.start()
|
||||
def prewarm(self) -> None:
|
||||
"""Pay for the graph view before it is clicked on, not during.
|
||||
|
||||
Opening GraphRAG built a QWebEngineView (~140ms) and scanned the project
|
||||
(~485ms) while an empty browser sat on screen — long enough, and white
|
||||
enough, to read as the app restarting itself. Called from an idle timer
|
||||
after the window is up, so startup itself is unaffected; the memory the
|
||||
lazy construction was saving is spent a few seconds later instead.
|
||||
"""
|
||||
if not _HAS_WEB or self.web is not None:
|
||||
return
|
||||
self._ensure_web()
|
||||
if self._graph is None and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan() # runs on a worker thread
|
||||
def _ensure_web(self) -> None:
|
||||
if self.web is not None or not _HAS_WEB:
|
||||
return
|
||||
self.web = QWebEngineView()
|
||||
# Blank the page in the app's own background first. A fresh
|
||||
# QWebEngineView paints white, and on a dark theme that white rectangle
|
||||
# WAS the flash — it showed for as long as the first scan took.
|
||||
self.web.setHtml(
|
||||
f"<body style='margin:0;background:{current_palette().bg}'></body>")
|
||||
self._bridge = _Bridge()
|
||||
self._channel = QWebChannel()
|
||||
self._channel.registerObject("py", self._bridge)
|
||||
self.web.page().setWebChannel(self._channel)
|
||||
self._stack.addWidget(self.web)
|
||||
self._stack.setCurrentWidget(self.web)
|
||||
if self._graph is not None:
|
||||
self._render_d3()
|
||||
def auto_scan_and_fit(self) -> None:
|
||||
self._ensure_web()
|
||||
if not self.path_edit.text().strip():
|
||||
return
|
||||
if getattr(self, "_worker", None) is not None and self._worker.isRunning():
|
||||
self._fit()
|
||||
self._preserve_answer()
|
||||
return
|
||||
if self._graph is not None and not self._needs_scan:
|
||||
self._fit()
|
||||
self._preserve_answer()
|
||||
return
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
def _scan(self) -> None:
|
||||
path = self.path_edit.text().strip() or str(Path.cwd())
|
||||
mode = "files" # default: scan all files (filter removed)
|
||||
use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
|
||||
cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "")
|
||||
st = self.ctx.config.structure
|
||||
max_nodes = int(st.get("max_nodes", 500) or 0)
|
||||
max_edges = int(st.get("max_edges", 500) or 0)
|
||||
self._scan_seq += 1
|
||||
seq = self._scan_seq
|
||||
self.status_message.emit(tr("structure.scanning"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ...core.structure_graph import (
|
||||
build_from_codebase_memory, build_from_directory, force_layout,
|
||||
)
|
||||
if use_cmem:
|
||||
from ...core.codebase_memory import CodebaseMemory
|
||||
mem = CodebaseMemory(cmem_bin)
|
||||
graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges)
|
||||
if mem.available else build_from_directory(path, mode, max_nodes, max_edges))
|
||||
else:
|
||||
graph = build_from_directory(path, mode, max_nodes, max_edges)
|
||||
pos = force_layout(graph)
|
||||
return {"graph": graph, "pos": pos, "seq": seq}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._render)
|
||||
w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e)))
|
||||
self._worker = w
|
||||
w.start()
|
||||
def _render(self, result: dict) -> None:
|
||||
if result.get("seq") is not None and result["seq"] != self._scan_seq:
|
||||
return
|
||||
graph = result.get("graph")
|
||||
pos = result.get("pos", {})
|
||||
if graph is None:
|
||||
return
|
||||
self._graph = graph
|
||||
|
||||
self.scene.clear()
|
||||
self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear
|
||||
self._node_items = []
|
||||
self._edge_items = []
|
||||
degree = {n.id: 0 for n in graph.nodes}
|
||||
for e in graph.edges:
|
||||
if e.source in degree:
|
||||
degree[e.source] += 1
|
||||
if e.target in degree:
|
||||
degree[e.target] += 1
|
||||
items = {}
|
||||
sx = sy = 0.0
|
||||
for node in graph.nodes:
|
||||
radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
|
||||
item = _Node(node, radius)
|
||||
x, y = pos.get(node.id, (0, 0))
|
||||
item.setPos(x, y)
|
||||
self.scene.addItem(item)
|
||||
items[node.id] = item
|
||||
self._node_items.append(item)
|
||||
sx += x
|
||||
sy += y
|
||||
for edge in graph.edges:
|
||||
a, b = items.get(edge.source), items.get(edge.target)
|
||||
if a and b:
|
||||
e = _Edge(a, b, getattr(edge, "type", ""))
|
||||
self.scene.addItem(e)
|
||||
self._edge_items.append(e)
|
||||
n = max(1, len(self._node_items))
|
||||
self._centroid = QPointF(sx / n, sy / n)
|
||||
self._fit()
|
||||
|
||||
if self.web is not None:
|
||||
self._render_d3()
|
||||
|
||||
note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else ""
|
||||
self.status_message.emit(tr(
|
||||
"structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note))
|
||||
self._preserve_answer()
|
||||
def _render_d3(self) -> None:
|
||||
if self.web is None or self._graph is None:
|
||||
return
|
||||
from ...core.d3_graph import build_html
|
||||
try:
|
||||
self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/"))
|
||||
except Exception as exc:
|
||||
self.status_message.emit(f"D3 view error: {exc}")
|
||||
def _on_selection(self) -> None:
|
||||
for item in self.scene.selectedItems():
|
||||
if isinstance(item, _Node):
|
||||
d = item.data
|
||||
self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}")
|
||||
self._detail_mode = "node"
|
||||
return
|
||||
def _fit(self) -> None:
|
||||
if self.web is not None and self._stack.currentWidget() is self.web:
|
||||
self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
|
||||
return
|
||||
rect = self.scene.itemsBoundingRect()
|
||||
if not rect.isNull():
|
||||
self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
|
||||
def _export(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
|
||||
if not path:
|
||||
return
|
||||
showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
|
||||
if showing_d3:
|
||||
self._export_d3_png(path)
|
||||
else:
|
||||
self._export_widget_grab(path)
|
||||
def _export_d3_png(self, path: str) -> None:
|
||||
def on_result(data_url) -> None:
|
||||
if not isinstance(data_url, str) or "," not in data_url:
|
||||
self._export_widget_grab(path)
|
||||
return
|
||||
import base64
|
||||
try:
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(data_url.split(",", 1)[1]))
|
||||
self.status_message.emit(tr("structure.export_done", path=path))
|
||||
except (OSError, ValueError) as exc:
|
||||
self.status_message.emit(tr("structure.export_failed", err=str(exc)))
|
||||
self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
|
||||
def _export_widget_grab(self, path: str) -> None:
|
||||
ok = self._stack.currentWidget().grab().save(path, "PNG")
|
||||
if ok:
|
||||
self.status_message.emit(tr("structure.export_done", path=path))
|
||||
else:
|
||||
self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
|
||||
@staticmethod
|
||||
def _graph_context(graph) -> str:
|
||||
from collections import defaultdict
|
||||
by_kind = defaultdict(list)
|
||||
for n in graph.nodes:
|
||||
by_kind[n.kind].append(n.label)
|
||||
lines = []
|
||||
for kind in ("file", "class", "function", "method", "module", "section"):
|
||||
items = by_kind.get(kind, [])
|
||||
if items:
|
||||
lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
|
||||
id2label = {n.id: n.label for n in graph.nodes}
|
||||
rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
|
||||
for e in graph.edges[:140]]
|
||||
if rels:
|
||||
lines.append("Relationships (sample):\n" + "\n".join(rels))
|
||||
return "\n".join(lines)[:7000]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Các phần tử vẽ của đồ thị: node, cạnh, khung nhìn — R08-T14.
|
||||
|
||||
Thuần đồ hoạ Qt, không biết gì về GraphRAG hay agent. Tách riêng vì đây là
|
||||
chỗ duy nhất cần mở khi chỉnh cách đồ thị trông ra sao — màu, hình mũi tên,
|
||||
cách kéo thả và phóng to.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QObject, QPointF, Qt, Slot
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsView
|
||||
from ...core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
|
||||
from ...theme import current_palette
|
||||
from ...i18n import tr
|
||||
from ...ui.osutil import open_folder, open_location
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Exposed to the D3 page so a Shift+click on a node can open its
|
||||
storage folder/link (local path or URL — see osutil.open_location)."""
|
||||
|
||||
@Slot(str)
|
||||
def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
|
||||
if path:
|
||||
open_location(path)
|
||||
|
||||
class _Edge(QGraphicsLineItem):
|
||||
def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
|
||||
super().__init__()
|
||||
self.a, self.b = a, b
|
||||
self.type = type_
|
||||
# Colour the edge by its RELATIONSHIP type (contains/defines/method/…),
|
||||
# so the graph shows what each connection MEANS — falling back to the
|
||||
# source node's tint for any untyped edge.
|
||||
color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor()
|
||||
if not color.isValid():
|
||||
color = a.brush().color().lighter(130)
|
||||
self._color = color
|
||||
self.setPen(QPen(color, 1.4))
|
||||
self.setZValue(-1)
|
||||
# A small label naming the relationship, shown at the edge midpoint.
|
||||
self._label = None
|
||||
if type_:
|
||||
self._label = QGraphicsSimpleTextItem(type_, self)
|
||||
self._label.setBrush(QBrush(color.lighter(140)))
|
||||
f = QFont()
|
||||
f.setPointSize(7)
|
||||
self._label.setFont(f)
|
||||
self._label.setZValue(0)
|
||||
a.edges.append(self)
|
||||
b.edges.append(self)
|
||||
self.adjust()
|
||||
|
||||
def adjust(self) -> None:
|
||||
pa, pb = self.a.scenePos(), self.b.scenePos()
|
||||
self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
|
||||
if self._label is not None:
|
||||
br = self._label.boundingRect()
|
||||
self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2,
|
||||
(pa.y() + pb.y()) / 2 - br.height() / 2)
|
||||
|
||||
class _Node(QGraphicsEllipseItem):
|
||||
def __init__(self, data, radius: int):
|
||||
super().__init__(-radius, -radius, 2 * radius, 2 * radius)
|
||||
self.data = data
|
||||
self.edges = []
|
||||
tok = current_palette()
|
||||
# NODE_KIND_COLORS is a categorical data encoding (one hue per node
|
||||
# kind), not UI chrome — it stays fixed across themes on purpose so a
|
||||
# given kind is always the same colour. Only the chrome follows tokens.
|
||||
color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted))
|
||||
self.setBrush(QBrush(color))
|
||||
self.setPen(QPen(color.darker(160), 1.5))
|
||||
self.setFlags(
|
||||
QGraphicsEllipseItem.ItemIsMovable
|
||||
| QGraphicsEllipseItem.ItemIsSelectable
|
||||
| QGraphicsEllipseItem.ItemSendsGeometryChanges
|
||||
)
|
||||
self.setZValue(1)
|
||||
label = QGraphicsSimpleTextItem(data.label, self)
|
||||
label.setBrush(QBrush(QColor(tok.text)))
|
||||
label.setPos(radius + 3, -8)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsEllipseItem.ItemPositionHasChanged:
|
||||
for edge in self.edges:
|
||||
edge.adjust()
|
||||
return super().itemChange(change, value)
|
||||
|
||||
class _GraphView(QGraphicsView):
|
||||
def __init__(self, scene):
|
||||
super().__init__(scene)
|
||||
self.setDragMode(QGraphicsView.NoDrag)
|
||||
self._panning = False
|
||||
self._pan_start = QPointF()
|
||||
|
||||
def wheelEvent(self, e): # noqa: N802
|
||||
self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
|
||||
1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
|
||||
|
||||
def mousePressEvent(self, e): # noqa: N802
|
||||
if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
|
||||
self._panning = True
|
||||
self._pan_start = e.position()
|
||||
self.setCursor(Qt.ClosedHandCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e): # noqa: N802
|
||||
if self._panning:
|
||||
delta = e.position() - self._pan_start
|
||||
self._pan_start = e.position()
|
||||
self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x()))
|
||||
self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e): # noqa: N802
|
||||
if self._panning:
|
||||
self._panning = False
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def mouseDoubleClickEvent(self, e): # noqa: N802
|
||||
"""Double-click or Ctrl+click on a node opens its storage folder."""
|
||||
item = self.itemAt(e.pos())
|
||||
if isinstance(item, _Node) and getattr(item.data, "path", ""):
|
||||
open_folder(item.data.path)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseDoubleClickEvent(e)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Có dùng được QtWebEngine hay không — R08-T14.
|
||||
|
||||
Cờ khả năng, tách riêng vì cả ``structure_graph_view.py`` lẫn
|
||||
``graph_render.py`` đều phải hỏi. Để ở một trong hai thì file kia import
|
||||
ngược lại — vòng import.
|
||||
|
||||
WebEngine là add-on tuỳ chọn của PySide6, và bản đóng gói one-file của
|
||||
PyInstaller không chạy được nó; những trường hợp đó rơi về khung nhìn Qt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def _frozen_onefile() -> bool:
|
||||
"""True only for a PyInstaller ONEFILE build. Onefile extracts itself to a
|
||||
temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process
|
||||
can't run — creating a QWebEngineView hard-crashes the app (reported as
|
||||
"click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the
|
||||
``_internal`` folder right next to the exe, where WebEngine works fine, so
|
||||
it keeps the full embedded D3 view."""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return False
|
||||
meipass = getattr(sys, "_MEIPASS", "")
|
||||
if not meipass:
|
||||
return False
|
||||
try:
|
||||
return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent
|
||||
except OSError: # can't tell → play safe: use the native fallback
|
||||
return True
|
||||
|
||||
|
||||
try: # WebEngine + WebChannel are optional PySide6 add-ons
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
from PySide6.QtWebChannel import QWebChannel
|
||||
_HAS_WEB = not _frozen_onefile()
|
||||
except Exception: # pragma: no cover
|
||||
_HAS_WEB = False
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Hộp thoại thêm/sửa một agent trong danh mục quản trị — R08-T08.
|
||||
|
||||
Tách khỏi ``agents_admin_tab.py``: bảng danh sách và hộp thoại sửa là hai
|
||||
việc khác nhau, và hộp thoại còn tự đi hỏi provider xem có những model nào
|
||||
(``_load_live_models``) — thứ bảng không cần biết.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
|
||||
QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
from ....config import PROVIDER_LABELS
|
||||
from ....core import admin_agents, preview_ai
|
||||
from ....core.worker import AgentWorker
|
||||
from ....i18n import on_language_changed, tr
|
||||
from ....state import AppContext
|
||||
from ....ui.icons import icon
|
||||
from ....ui.widgets import ToggleSwitch, badge_pill_widget
|
||||
|
||||
|
||||
class AgentEditDialog(QDialog):
|
||||
"""Add/Edit one admin agent. The provider/model pickers are drop-lists,
|
||||
not free text — ``provider_combo`` offers the app's built-in providers
|
||||
(plus "machine default"), ``model_combo`` offers that provider's REAL
|
||||
model list once fetched via "Load models" (same on-demand fetch the
|
||||
Preview tab and Settings' own "Load" button use) — editable so an admin
|
||||
can still pin an exact model string that isn't in the fetched list yet."""
|
||||
|
||||
def __init__(self, parent=None, ctx: Optional[AppContext] = None,
|
||||
agent: Optional[admin_agents.AdminAgent] = None,
|
||||
default_model_hint: str = ""):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._existing = agent
|
||||
self._live_models: Dict[str, List[str]] = {}
|
||||
self._workers: List[AgentWorker] = []
|
||||
self.setWindowTitle(tr("agents_admin.edit_title") if agent
|
||||
else tr("agents_admin.add_title"))
|
||||
self.resize(420, 400)
|
||||
form = QFormLayout(self)
|
||||
self.name_edit = QLineEdit(agent.name if agent else "")
|
||||
form.addRow(tr("agents_admin.f_name"), self.name_edit)
|
||||
self.kind_combo = QComboBox()
|
||||
for kind in admin_agents.TASK_KINDS:
|
||||
self.kind_combo.addItem(tr(f"agents_admin.kind.{kind}"), kind)
|
||||
if agent:
|
||||
idx = self.kind_combo.findData(agent.task_kind)
|
||||
if idx >= 0:
|
||||
self.kind_combo.setCurrentIndex(idx)
|
||||
form.addRow(tr("agents_admin.f_kind"), self.kind_combo)
|
||||
self.prompt_edit = QPlainTextEdit(agent.prompt if agent else "")
|
||||
self.prompt_edit.setPlaceholderText(tr("agents_admin.f_prompt_placeholder"))
|
||||
self.prompt_edit.setMaximumHeight(110)
|
||||
form.addRow(tr("agents_admin.f_prompt"), self.prompt_edit)
|
||||
|
||||
self.provider_combo = QComboBox()
|
||||
self.provider_combo.addItem(tr("agents_admin.provider_default"), _PROVIDER_DEFAULT)
|
||||
for key, label in PROVIDER_LABELS.items():
|
||||
self.provider_combo.addItem(label, key)
|
||||
if agent and agent.provider:
|
||||
idx = self.provider_combo.findData(agent.provider)
|
||||
if idx >= 0:
|
||||
self.provider_combo.setCurrentIndex(idx)
|
||||
self.provider_combo.currentIndexChanged.connect(self._refresh_model_combo)
|
||||
form.addRow(tr("agents_admin.f_provider"), self.provider_combo)
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self.model_combo = QComboBox()
|
||||
self.model_combo.setEditable(True)
|
||||
if agent and agent.model:
|
||||
self.model_combo.addItem(agent.model)
|
||||
self.model_combo.setEditText(agent.model if agent else "")
|
||||
self.model_combo.lineEdit().setPlaceholderText(
|
||||
tr("agents_admin.f_model_placeholder", model=default_model_hint or "—"))
|
||||
self.load_models_btn = QPushButton()
|
||||
self.load_models_btn.setIcon(icon("download"))
|
||||
self.load_models_btn.setToolTip(tr("agents_admin.load_models_tooltip"))
|
||||
self.load_models_btn.clicked.connect(self._load_live_models)
|
||||
self.load_models_btn.setEnabled(self.ctx is not None)
|
||||
model_row.addWidget(self.model_combo, 1)
|
||||
model_row.addWidget(self.load_models_btn)
|
||||
form.addRow(tr("agents_admin.f_model"), model_row)
|
||||
|
||||
self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled"))
|
||||
self.enabled_chk.setChecked(agent.enabled if agent else True)
|
||||
form.addRow("", self.enabled_chk)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
def _load_live_models(self) -> None:
|
||||
if self.ctx is None:
|
||||
return
|
||||
self.load_models_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_worker: AgentWorker):
|
||||
return preview_ai.fetch_live_models(ctx)
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self.load_models_btn.setEnabled(True)
|
||||
self._live_models = result or {}
|
||||
self._refresh_model_combo()
|
||||
if not self._live_models:
|
||||
QMessageBox.information(self, tr("agents_admin.add_title"),
|
||||
tr("agents_admin.load_models_empty"))
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self.load_models_btn.setEnabled(True)
|
||||
QMessageBox.warning(self, tr("agents_admin.add_title"), err)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _refresh_model_combo(self) -> None:
|
||||
provider_key = self.provider_combo.currentData()
|
||||
current_text = self.model_combo.currentText().strip()
|
||||
models = self._live_models.get(provider_key, []) if provider_key else []
|
||||
self.model_combo.blockSignals(True)
|
||||
self.model_combo.clear()
|
||||
self.model_combo.addItems(models)
|
||||
self.model_combo.setEditText(current_text)
|
||||
self.model_combo.blockSignals(False)
|
||||
|
||||
def result_fields(self) -> Dict[str, str]:
|
||||
return {
|
||||
"name": self.name_edit.text().strip(),
|
||||
"task_kind": self.kind_combo.currentData(),
|
||||
"prompt": self.prompt_edit.toPlainText().strip(),
|
||||
"provider": self.provider_combo.currentData() or "",
|
||||
"model": self.model_combo.currentText().strip(),
|
||||
"enabled": self.enabled_chk.isChecked(),
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Agents Admin — Monitoring tab visible to the Admin role ONLY.
|
||||
|
||||
CRUD over the shared admin-agent catalog (``core/admin_agents.py``): each
|
||||
agent has a name, an app function from a fixed droplist (search / monitor /
|
||||
cowork / graphrag / schedule / security), optional extra instructions and a
|
||||
model (blank = the machine's Settings model). Saved straight into the shared
|
||||
accounts folder, so every machine pointed at the same share picks changes up
|
||||
automatically (OneDrive/network sync) — non-admin machines only ever READ the
|
||||
catalog (their pickers in Cowork / Schedule Task list the enabled agents).
|
||||
|
||||
The header's "Kiểm tra tất cả" icon probes each agent's effective provider
|
||||
(``check_agent``) and shows the result as the Trạng thái pill (OK / error /
|
||||
checking…) — separate from the per-row Kích hoạt switch, which only toggles
|
||||
the config flag.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .agent_edit_dialog import AgentEditDialog
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
|
||||
QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....config import PROVIDER_LABELS
|
||||
from ....core import admin_agents, preview_ai
|
||||
from ....core.worker import AgentWorker
|
||||
from ....i18n import on_language_changed, tr
|
||||
from ....state import AppContext
|
||||
from ....ui.icons import icon
|
||||
from ....ui.widgets import ToggleSwitch, badge_pill_widget
|
||||
|
||||
_PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default)
|
||||
|
||||
# Identity colour (avatar circle) + badge tone per task_kind — same "fixed
|
||||
# colour regardless of theme" convention as monitoring_tab.py's per-agent
|
||||
# avatars, plus a badge object name (theme.py) for the Vai trò pill. Seven
|
||||
# kinds, seven distinct tones — no two kinds share a badge colour.
|
||||
_KIND_COLOUR = {
|
||||
"search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
|
||||
"graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
|
||||
"help": "#E3008C",
|
||||
}
|
||||
_KIND_BADGE = {
|
||||
"search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
|
||||
"graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
|
||||
"help": "badgePink",
|
||||
}
|
||||
_STATUS_BADGE = {
|
||||
"unchecked": "badgeNeutral", "checking": "badgeWarn",
|
||||
"ok": "badgeSuccess", "bad": "badgeDanger",
|
||||
}
|
||||
|
||||
|
||||
def _initials(name: str) -> str:
|
||||
return "".join(w[0] for w in name.split() if w)[:2].upper()
|
||||
|
||||
|
||||
def _fmt_updated(ts: str) -> str:
|
||||
""""dd/MM hh:mm" — same Cập nhật/Thời gian format as Monitoring's
|
||||
Bảo mật/MCP/Hành động tables (``_fmt_event_time`` in monitoring_tab.py)."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts)
|
||||
except (TypeError, ValueError):
|
||||
return ts
|
||||
return dt.strftime("%d/%m %H:%M")
|
||||
|
||||
|
||||
def _kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(_KIND_COLOUR.get(kind, "#0078D4")))
|
||||
p.drawEllipse(0, 0, size, size)
|
||||
font = QFont()
|
||||
font.setPixelSize(max(7, size // 2))
|
||||
font.setBold(True)
|
||||
p.setFont(font)
|
||||
p.setPen(QColor("#FFFFFF"))
|
||||
p.drawText(pm.rect(), Qt.AlignCenter, _initials(name))
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
|
||||
|
||||
class AgentsAdminTab(QWidget):
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
# Last operational-health result per agent_id → (ok, message). Populated
|
||||
# on demand by the "Check" button (see _check_all); survives refresh().
|
||||
self._status: Dict[str, tuple] = {}
|
||||
self._check_workers: List[AgentWorker] = []
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
self._title_lbl = QLabel()
|
||||
self._title_lbl.setStyleSheet("font-weight:700; font-size:14px;")
|
||||
hdr.addWidget(self._title_lbl)
|
||||
hdr.addStretch(1)
|
||||
# "Kiểm tra tất cả" keeps the real _check_all action reachable without
|
||||
# competing with the 2 primary header buttons (Làm mới / + Thêm) — a
|
||||
# flat, secondary-styled button rather than a 3rd primary one, but
|
||||
# still labelled: an icon-only button here was a mystery button.
|
||||
self.check_btn = QPushButton()
|
||||
self.check_btn.setIcon(icon("check"))
|
||||
self.check_btn.setFlat(True)
|
||||
self.check_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.check_btn.clicked.connect(self._check_all)
|
||||
hdr.addWidget(self.check_btn)
|
||||
self.refresh_btn = QPushButton()
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
hdr.addWidget(self.refresh_btn)
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.add_btn.clicked.connect(self._add)
|
||||
hdr.addWidget(self.add_btn)
|
||||
root.addLayout(hdr)
|
||||
|
||||
self._hint = QLabel("")
|
||||
self._hint.setObjectName("hint")
|
||||
self._hint.setWordWrap(True)
|
||||
root.addWidget(self._hint)
|
||||
|
||||
self.table = QTableWidget(0, 7)
|
||||
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
# Sửa/Xoá/Kích hoạt are now per-row widgets (button/switch), and Vai
|
||||
# trò/Trạng thái are pill cell widgets — none of those track a row
|
||||
# across a re-sort (a cell widget stays pinned to its screen position,
|
||||
# not to the item that moves — see monitoring_tab.py's _EventTable for
|
||||
# the same lesson learned the hard way), so this table doesn't sort.
|
||||
self.table.setSelectionMode(QTableWidget.NoSelection)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
# Fixed row height — letting Qt auto-size rows from content fights
|
||||
# with the toggle switch / badge cell widgets: their layout settles on
|
||||
# a stale, oversized geometry from an intermediate sizing pass, which
|
||||
# then overlaps neighbouring rows (same bug _EventTable hit for its
|
||||
# Hành động pill, fixed there the same way).
|
||||
self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
|
||||
self.table.verticalHeader().setDefaultSectionSize(32)
|
||||
self.table.setIconSize(QSize(20, 20))
|
||||
header = self.table.horizontalHeader()
|
||||
header.setStretchLastSection(False)
|
||||
for col in (0, 6):
|
||||
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
|
||||
# Vai trò/Trạng thái (1, 4) are pill cell widgets — ResizeToContents
|
||||
# only measures QTableWidgetItem content, so it kept fighting refresh()'s
|
||||
# manual sizeHint()-based setColumnWidth() and clipping the pill text.
|
||||
# Interactive leaves whatever width refresh() sets alone.
|
||||
for col in (1, 4):
|
||||
header.setSectionResizeMode(col, QHeaderView.Interactive)
|
||||
header.setSectionResizeMode(2, QHeaderView.Stretch) # Model
|
||||
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
# on_language_changed() already invokes _retranslate() once immediately
|
||||
# (see i18n.py) — calling it again here was a harmless no-op back when
|
||||
# every column was a plain QTableWidgetItem, but now refresh() also
|
||||
# populates cell WIDGETS (toggle switch, pills, row actions): running
|
||||
# it twice back-to-back with no event-loop turn in between left the
|
||||
# first pass's widgets replaced but not yet deleted, so they briefly
|
||||
# painted overlapping the second pass's row 0.
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- storage ---------------------------------------------------------
|
||||
def _dir(self):
|
||||
return admin_agents.agents_admin_dir(self.ctx.config.shared_dir)
|
||||
|
||||
def _default_model_hint(self) -> str:
|
||||
conf = self.ctx.config.provider_conf(self.ctx.config.active_provider)
|
||||
return conf.get("model", "")
|
||||
|
||||
# ---- CRUD -------------------------------------------------------------
|
||||
def _add(self) -> None:
|
||||
dlg = AgentEditDialog(self, ctx=self.ctx, default_model_hint=self._default_model_hint())
|
||||
if not dlg.exec():
|
||||
return
|
||||
fields = dlg.result_fields()
|
||||
if not fields["name"]:
|
||||
return
|
||||
agent = admin_agents.new_agent(
|
||||
fields["name"], fields["task_kind"], fields["prompt"],
|
||||
provider=fields.get("provider", ""), model=fields["model"],
|
||||
updated_by=(getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else ""))
|
||||
agent.enabled = bool(fields["enabled"])
|
||||
admin_agents.save_agent(agent, self._dir())
|
||||
self.refresh()
|
||||
|
||||
def _edit_agent(self, agent_id: str) -> None:
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
dlg = AgentEditDialog(self, ctx=self.ctx, agent=agent,
|
||||
default_model_hint=self._default_model_hint())
|
||||
if not dlg.exec():
|
||||
return
|
||||
fields = dlg.result_fields()
|
||||
if not fields["name"]:
|
||||
return
|
||||
|
||||
agent.name = fields["name"]
|
||||
agent.task_kind = fields["task_kind"]
|
||||
agent.prompt = fields["prompt"]
|
||||
agent.provider = fields.get("provider", "")
|
||||
agent.model = fields["model"]
|
||||
agent.enabled = bool(fields["enabled"])
|
||||
agent.updated = datetime.now().isoformat(timespec="seconds")
|
||||
agent.updated_by = (getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else "")
|
||||
admin_agents.save_agent(agent, self._dir())
|
||||
self.refresh()
|
||||
|
||||
def _delete_agent(self, agent_id: str) -> None:
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
if QMessageBox.question(
|
||||
self, tr("agents_admin.delete_title"),
|
||||
tr("agents_admin.delete_confirm", name=agent.name)) != QMessageBox.Yes:
|
||||
return
|
||||
admin_agents.delete_agent(agent.agent_id, self._dir())
|
||||
self.refresh()
|
||||
|
||||
def _set_enabled(self, agent_id: str, enabled: bool) -> None:
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None or agent.enabled == enabled:
|
||||
return
|
||||
|
||||
agent.enabled = enabled
|
||||
agent.updated = datetime.now().isoformat(timespec="seconds")
|
||||
agent.updated_by = (getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else "")
|
||||
admin_agents.save_agent(agent, self._dir())
|
||||
self.refresh()
|
||||
|
||||
# ---- view --------------------------------------------------------------
|
||||
def _status_cell(self, agent_id: str) -> tuple:
|
||||
"""(state_key, display_text, tooltip) for the Trạng thái pill —
|
||||
state_key indexes _STATUS_BADGE for the badge's colour tone."""
|
||||
res = self._status.get(agent_id)
|
||||
if res is None:
|
||||
return ("unchecked", tr("agents_admin.status_unchecked").lstrip("— ").strip(),
|
||||
tr("agents_admin.status_unchecked_tip"))
|
||||
ok, msg = res
|
||||
if msg == "checking":
|
||||
return "checking", tr("agents_admin.status_checking"), ""
|
||||
return ("ok" if ok else "bad"), (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
|
||||
|
||||
def _toggle_widget(self, agent_id: str, enabled: bool) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
lay = QHBoxLayout(container)
|
||||
lay.setContentsMargins(6, 0, 0, 0)
|
||||
sw = ToggleSwitch()
|
||||
sw.setChecked(enabled)
|
||||
sw.toggled.connect(lambda checked, aid=agent_id: self._set_enabled(aid, checked))
|
||||
lay.addWidget(sw, 0, Qt.AlignVCenter)
|
||||
lay.addStretch(1)
|
||||
return container
|
||||
|
||||
def _row_actions_widget(self, agent_id: str) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
lay = QHBoxLayout(container)
|
||||
lay.setContentsMargins(2, 0, 2, 0)
|
||||
lay.setSpacing(2)
|
||||
edit_btn = QPushButton()
|
||||
edit_btn.setIcon(icon("edit"))
|
||||
edit_btn.setFlat(True)
|
||||
edit_btn.setCursor(Qt.PointingHandCursor)
|
||||
edit_btn.setToolTip(tr("agents_admin.edit_row_tooltip"))
|
||||
edit_btn.clicked.connect(lambda: self._edit_agent(agent_id))
|
||||
del_btn = QPushButton()
|
||||
del_btn.setIcon(icon("trash"))
|
||||
del_btn.setFlat(True)
|
||||
del_btn.setCursor(Qt.PointingHandCursor)
|
||||
del_btn.setToolTip(tr("agents_admin.delete_row_tooltip"))
|
||||
del_btn.clicked.connect(lambda: self._delete_agent(agent_id))
|
||||
lay.addWidget(edit_btn)
|
||||
lay.addWidget(del_btn)
|
||||
return container
|
||||
|
||||
def refresh(self) -> None:
|
||||
# Make sure the built-in in-app Help assistant exists, so the Admin can
|
||||
# manage its provider/model here (the floating Help widget uses it).
|
||||
admin_agents.ensure_help_agent(self._dir())
|
||||
agents = admin_agents.list_agents(self._dir())
|
||||
self.table.setRowCount(len(agents))
|
||||
default_model = self._default_model_hint()
|
||||
for row, agent in enumerate(agents):
|
||||
if agent.model:
|
||||
provider_lbl = PROVIDER_LABELS.get(agent.provider, "") if agent.provider else ""
|
||||
model = f"{provider_lbl} — {agent.model}" if provider_lbl else agent.model
|
||||
else:
|
||||
model = tr("agents_admin.default_model", model=default_model or "—")
|
||||
|
||||
name_item = QTableWidgetItem(agent.name)
|
||||
name_item.setIcon(_kind_avatar_icon(agent.task_kind, agent.name))
|
||||
self.table.setItem(row, 0, name_item)
|
||||
|
||||
kind_tone = _KIND_BADGE.get(agent.task_kind, "badge")
|
||||
self.table.setCellWidget(
|
||||
row, 1, badge_pill_widget(tr(f"agents_admin.kind.{agent.task_kind}"), kind_tone))
|
||||
|
||||
self.table.setItem(row, 2, QTableWidgetItem(model))
|
||||
self.table.setCellWidget(row, 3, self._toggle_widget(agent.agent_id, agent.enabled))
|
||||
|
||||
state_key, status_text, status_tip = self._status_cell(agent.agent_id)
|
||||
status_widget = badge_pill_widget(status_text, _STATUS_BADGE[state_key])
|
||||
if status_tip:
|
||||
status_widget.setToolTip(status_tip)
|
||||
self.table.setCellWidget(row, 4, status_widget)
|
||||
|
||||
self.table.setItem(row, 5, QTableWidgetItem(_fmt_updated(agent.updated)))
|
||||
self.table.setCellWidget(row, 6, self._row_actions_widget(agent.agent_id))
|
||||
|
||||
# ResizeToContents doesn't measure a cell WIDGET's real width (only
|
||||
# QTableWidgetItem content) — size the Vai trò/Trạng thái pill columns
|
||||
# by hand, or their text clips against whatever width it guessed.
|
||||
if self.table.rowCount():
|
||||
for col in (1, 4):
|
||||
needed = max(self.table.cellWidget(r, col).sizeHint().width()
|
||||
for r in range(self.table.rowCount()))
|
||||
if needed + 24 > self.table.columnWidth(col):
|
||||
self.table.setColumnWidth(col, needed + 24)
|
||||
|
||||
def _check_all(self) -> None:
|
||||
"""Health-check every agent's effective provider off the UI thread and
|
||||
update the Status column with the result (🟢 reachable / 🔴 error)."""
|
||||
agents = admin_agents.list_agents(self._dir())
|
||||
if not agents:
|
||||
return
|
||||
for a in agents:
|
||||
self._status[a.agent_id] = (False, "checking")
|
||||
self.check_btn.setEnabled(False)
|
||||
self.refresh()
|
||||
ctx = self.ctx
|
||||
|
||||
def job(_worker: AgentWorker) -> dict:
|
||||
return {a.agent_id: admin_agents.check_agent(ctx, a) for a in agents}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self.check_btn.setEnabled(True)
|
||||
self._status.update(result or {})
|
||||
self.refresh()
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self.check_btn.setEnabled(True)
|
||||
for a in agents:
|
||||
self._status[a.agent_id] = (False, err[:200])
|
||||
self.refresh()
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._check_workers.append(w)
|
||||
w.start()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title_lbl.setText(tr("agents_admin.page_title"))
|
||||
self._hint.setText(tr("agents_admin.hint"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("agents_admin.col_name"), tr("agents_admin.col_kind"),
|
||||
tr("agents_admin.col_model"), tr("agents_admin.col_enabled"),
|
||||
tr("agents_admin.col_status"), tr("agents_admin.col_updated"), "",
|
||||
])
|
||||
self.add_btn.setText(tr("agents_admin.add_btn"))
|
||||
self.refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.check_btn.setText(tr("agents_admin.check_btn"))
|
||||
self.check_btn.setToolTip(tr("agents_admin.check_tooltip"))
|
||||
self.refresh()
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tools — Monitoring tab (Admin) to govern every agent capability.
|
||||
|
||||
Two sub-tabs:
|
||||
* "Tool" — built-in agent tools (read/write/edit files, run commands,
|
||||
install packages, fetch URLs) as a left-aligned card grid;
|
||||
toggling one OFF removes it from the agent's toolset
|
||||
(persisted in ``config.tools_disabled``).
|
||||
* "Connector" — the full Connectors (MCP / REST API) setup, moved here from
|
||||
Settings: add/edit/delete CAD/CAE/MS365/Other connectors and
|
||||
enable/disable each (``ConnectorsPanel``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTabWidget,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....core.tools import TOOL_SPECS
|
||||
from ....core.worker import AgentWorker
|
||||
from ....i18n import on_language_changed, tr
|
||||
from ....state import AppContext
|
||||
from ....ui.connectors_panel import ConnectorsPanel
|
||||
from ....ui.icons import icon
|
||||
from ....ui.widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
|
||||
|
||||
# Identity colour + icon per built-in tool — same "fixed colour regardless of
|
||||
# theme" convention as monitoring_tab.py's agent avatars / agents_admin_tab.py's
|
||||
# kind avatars, grouped by what the tool actually touches (file i/o, shell,
|
||||
# packages, network, Jira).
|
||||
_TOOL_COLOUR = {
|
||||
"read_file": "#0078D4", "list_dir": "#0078D4", "write_file": "#0078D4",
|
||||
"edit_file": "#0078D4", "run_command": "#107C10", "install_package": "#8764B8",
|
||||
"fetch_url": "#FFB900", "jira_search": "#8764B8", "jira_get_issue": "#8764B8",
|
||||
}
|
||||
_TOOL_ICON_NAME = {
|
||||
"read_file": "document", "list_dir": "folder", "write_file": "new",
|
||||
"edit_file": "edit", "run_command": "terminal", "install_package": "download",
|
||||
"fetch_url": "globe", "jira_search": "search", "jira_get_issue": "link",
|
||||
}
|
||||
|
||||
|
||||
def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap:
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(_TOOL_COLOUR.get(name, "#0078D4")))
|
||||
r = size * 0.28
|
||||
p.drawRoundedRect(0, 0, size, size, r, r)
|
||||
inner = int(size * 0.58)
|
||||
glyph = icon(_TOOL_ICON_NAME.get(name, "puzzle"), size=inner, color="#FFFFFF").pixmap(inner, inner)
|
||||
p.drawPixmap((size - inner) // 2, (size - inner) // 2, glyph)
|
||||
p.end()
|
||||
return pm
|
||||
|
||||
|
||||
def _clear_flow(flow: FlowLayout) -> None:
|
||||
while flow.count():
|
||||
item = flow.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
class ToolsAdminTab(QWidget):
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
self.subtabs = QTabWidget()
|
||||
root.addWidget(self.subtabs, 1)
|
||||
|
||||
# ---- "Tool" sub-tab: built-in agent tools ------------------------
|
||||
tool_page = QWidget()
|
||||
tl = QVBoxLayout(tool_page)
|
||||
self._net_worker = None
|
||||
self._hint = QLabel()
|
||||
self._hint.setObjectName("hint")
|
||||
self._hint.setWordWrap(True)
|
||||
tl.addWidget(self._hint)
|
||||
|
||||
# A left-aligned, wrapping card grid — one card per built-in tool
|
||||
# (colour-coded icon + name + toggle switch + description), replacing
|
||||
# the old flat Name/Description/Enabled table.
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
cards_host = QWidget()
|
||||
self._tool_flow = FlowLayout(cards_host, margin=0, h_spacing=10, v_spacing=10)
|
||||
scroll.setWidget(cards_host)
|
||||
tl.addWidget(scroll, 1)
|
||||
|
||||
# "Test Internet" self-test lives INSIDE the fetch_url tool's card now
|
||||
# (see refresh) instead of a separate boxed section — persistent
|
||||
# widgets so they survive card rebuilds.
|
||||
self.test_internet_btn = QPushButton(tr("settings.test_internet"))
|
||||
self.test_internet_btn.setIcon(icon("globe"))
|
||||
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
|
||||
self.test_internet_btn.clicked.connect(self._test_internet)
|
||||
self.test_internet_status = QLabel("")
|
||||
self.test_internet_status.setWordWrap(True)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
self.refresh_btn = QPushButton()
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
btn_row.addStretch(1)
|
||||
btn_row.addWidget(self.refresh_btn)
|
||||
tl.addLayout(btn_row)
|
||||
# Jira CONNECTION setup lives in the Connector sub-tab now; here the Tool
|
||||
# list just lets the admin turn the jira_* tools on/off. A pointer note:
|
||||
self.jira_note = QLabel()
|
||||
self.jira_note.setObjectName("hint")
|
||||
self.jira_note.setWordWrap(True)
|
||||
tl.addWidget(self.jira_note)
|
||||
self.subtabs.addTab(tool_page, "")
|
||||
|
||||
# ---- "Connector" sub-tab: MCP / REST API setup (moved from Settings) --
|
||||
self.connectors_panel = ConnectorsPanel(ctx)
|
||||
self.subtabs.addTab(self.connectors_panel, "")
|
||||
|
||||
# on_language_changed() already invokes _retranslate() once immediately
|
||||
# (see i18n.py) — a second explicit call here double-populates the
|
||||
# card grid back-to-back with no event-loop turn in between, so the
|
||||
# first pass's cards are only queued for deleteLater() (not yet gone)
|
||||
# when the second pass adds new ones on top (see connectors_panel.py's
|
||||
# ConnectorsPanel, which hit the exact same bug this same way).
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- built-in tools card grid ---------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
disabled = set(self.ctx.config.tools_disabled)
|
||||
_clear_flow(self._tool_flow)
|
||||
for spec in TOOL_SPECS:
|
||||
self._tool_flow.addWidget(self._tool_card(spec, spec.name not in disabled))
|
||||
|
||||
def _tool_card(self, spec, enabled: bool) -> QWidget:
|
||||
card = QFrame()
|
||||
card.setFrameShape(QFrame.NoFrame)
|
||||
style_card(card)
|
||||
card.setFixedWidth(220)
|
||||
# The description below wraps to a variable number of lines at this
|
||||
# fixed width, so the card's own height depends on its width — without
|
||||
# this, the outer FlowLayout's QWidgetItem queries card.sizePolicy()
|
||||
# (not the description label's), gets a too-short sizeHint, and
|
||||
# squeezes the card into less height than its QVBoxLayout needs,
|
||||
# which is what overlapped the header onto the description text.
|
||||
enable_height_for_width(card)
|
||||
lay = QVBoxLayout(card)
|
||||
lay.setContentsMargins(10, 8, 10, 8)
|
||||
lay.setSpacing(4)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
icon_lbl = QLabel()
|
||||
icon_lbl.setPixmap(_tool_icon_pixmap(spec.name))
|
||||
icon_lbl.setStyleSheet("border: none;")
|
||||
hdr.addWidget(icon_lbl)
|
||||
name_lbl = QLabel(spec.name)
|
||||
name_lbl.setStyleSheet("font-weight:700; border: none;")
|
||||
hdr.addWidget(name_lbl)
|
||||
hdr.addStretch(1)
|
||||
sw = ToggleSwitch()
|
||||
sw.setChecked(enabled)
|
||||
sw.toggled.connect(lambda on, n=spec.name: self._toggle_builtin(n, on))
|
||||
hdr.addWidget(sw)
|
||||
lay.addLayout(hdr)
|
||||
|
||||
desc = QLabel(spec.description)
|
||||
desc.setWordWrap(True)
|
||||
desc.setToolTip(spec.description)
|
||||
desc.setObjectName("hint")
|
||||
desc.setStyleSheet("border: none;")
|
||||
lay.addWidget(desc)
|
||||
|
||||
if spec.name == "fetch_url":
|
||||
# The live "Test Internet" self-test lives inside fetch_url's own
|
||||
# card — it tests THIS capability, not the tab as a whole.
|
||||
net = QWidget()
|
||||
net.setStyleSheet("border: none;")
|
||||
nl = QHBoxLayout(net)
|
||||
nl.setContentsMargins(0, 2, 0, 0)
|
||||
nl.addWidget(self.test_internet_btn)
|
||||
nl.addWidget(self.test_internet_status, 1)
|
||||
lay.addWidget(net)
|
||||
|
||||
return card
|
||||
|
||||
def _toggle_builtin(self, name: str, enabled: bool) -> None:
|
||||
self.ctx.config.set_tool_enabled(name, enabled)
|
||||
# For fetch_url, the Enabled toggle also governs the runtime web-access
|
||||
# gate (agent_security.allow_url_fetch) — one control for the capability.
|
||||
if name == "fetch_url":
|
||||
self.ctx.config.agent_security["allow_url_fetch"] = bool(enabled)
|
||||
self.ctx.config.save()
|
||||
|
||||
def _test_internet(self) -> None:
|
||||
"""Live-check the app's own outbound HTTPS path and report the concrete
|
||||
result. Respects the fetch_url toggle: when web access is OFF the agent
|
||||
cannot reach the internet, so the test reports that instead of probing."""
|
||||
disabled = ("fetch_url" in self.ctx.config.tools_disabled
|
||||
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True)))
|
||||
if disabled:
|
||||
self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
|
||||
self.test_internet_status.setStyleSheet("color: #c00;")
|
||||
return
|
||||
|
||||
def job(worker):
|
||||
from ....core import tls_trust
|
||||
ok, message = tls_trust.diagnose_internet()
|
||||
return {"ok": ok, "message": message}
|
||||
|
||||
def done(result):
|
||||
ok = result.get("ok")
|
||||
self.test_internet_status.setText(result.get("message", ""))
|
||||
self.test_internet_status.setStyleSheet("color: #090;" if ok else "color: #c00;")
|
||||
self.test_internet_btn.setEnabled(True)
|
||||
|
||||
def failed(e):
|
||||
self.test_internet_status.setText(str(e))
|
||||
self.test_internet_status.setStyleSheet("color: #c00;")
|
||||
self.test_internet_btn.setEnabled(True)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._net_worker = w # keep a ref so the thread isn't GC'd mid-run
|
||||
self.test_internet_btn.setEnabled(False)
|
||||
self.test_internet_status.setStyleSheet("")
|
||||
self.test_internet_status.setText(tr("settings.testing_internet"))
|
||||
w.start()
|
||||
|
||||
# ---- i18n -----------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self.subtabs.setTabText(0, tr("tools_admin.subtab_tool"))
|
||||
self.subtabs.setTabText(1, tr("tools_admin.subtab_connector"))
|
||||
self._hint.setText(tr("tools_admin.hint"))
|
||||
self.test_internet_btn.setText(tr("settings.test_internet"))
|
||||
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
|
||||
self.refresh_btn.setText(tr("tools_admin.refresh"))
|
||||
self.jira_note.setText(tr("tools_admin.jira_note"))
|
||||
self.refresh()
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Các thao tác trên một task: thêm, sửa, chạy ngay, xoá, xem log — R08-T11.
|
||||
|
||||
Tách khỏi ``ScheduleTaskTab`` để phần dựng bảng và phần hành động không nằm
|
||||
lẫn nhau. ``_context_menu`` là chỗ tập trung: nó quyết định mục nào hiện ra
|
||||
tuỳ theo đang chọn một hay nhiều thẻ.
|
||||
|
||||
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Import muộn trong hàm ở chỗ dùng: ba lớp này nằm cùng gói và một trong số
|
||||
# chúng trộn ngược mixin này vào, nên import ở mức module là vòng.
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
|
||||
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
|
||||
QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget,
|
||||
QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
from ...core import tasks as taskrepo
|
||||
from ...core.projects import list_projects
|
||||
from ...core.tasks import STATUSES, chain_error, duplicate_task, new_task
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import on_language_changed, tr
|
||||
from ...state import AppContext
|
||||
from ...theme import current_palette
|
||||
from ...ui.calendar_view import CalendarView
|
||||
from ...ui.icons import icon
|
||||
from ...ui.osutil import open_path
|
||||
|
||||
|
||||
class TaskActionsMixin:
|
||||
"""Thao tác trên task. Trộn vào ScheduleTaskTab."""
|
||||
|
||||
def _add_task_on_date(self, date_str: str) -> None:
|
||||
"""Create a task pre-filled with the clicked calendar date (default
|
||||
09:00) — same editor Add Task opens, nothing is saved until confirmed."""
|
||||
from ...ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
|
||||
dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
def _save_and_refresh(self, task: dict) -> None:
|
||||
taskrepo.save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
def _add_task(self) -> None:
|
||||
from ...ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
def _edit_task(self, task_id: str) -> None:
|
||||
from ...ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
task = taskrepo.load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
def _on_double_click(self, item: QListWidgetItem) -> None:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
self._edit_task(tid)
|
||||
def _is_multi_selection(item, selected) -> bool:
|
||||
"""True when the right-clicked card is part of an existing multi-item
|
||||
selection — pure boolean, kept separate from _context_menu so it's
|
||||
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
|
||||
return len(selected) > 1 and item in selected
|
||||
def _context_menu(self, col: _KanbanColumn, pos) -> None:
|
||||
item = col.itemAt(pos)
|
||||
if item is None or not item.data(Qt.UserRole):
|
||||
return
|
||||
selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
|
||||
if self._is_multi_selection(item, selected):
|
||||
self._bulk_delete_menu(col, pos, selected)
|
||||
return
|
||||
tid = item.data(Qt.UserRole)
|
||||
task = taskrepo.load_task(tid, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
menu = QMenu(col)
|
||||
run_act = menu.addAction(tr("schedtask.menu_run"))
|
||||
edit_act = menu.addAction(tr("schedtask.menu_edit"))
|
||||
dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
|
||||
paused = task.get("status") == "paused"
|
||||
pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
|
||||
logs_act = menu.addAction(tr("schedtask.menu_logs"))
|
||||
hist_act = menu.addAction(tr("schedtask.menu_history"))
|
||||
next_act = menu.addAction(tr("schedtask.menu_create_next"))
|
||||
menu.addSeparator()
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete"))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == run_act:
|
||||
self._run_now(task)
|
||||
elif chosen == edit_act:
|
||||
self._edit_task(tid)
|
||||
elif chosen == dup_act:
|
||||
self._save_and_refresh(duplicate_task(task))
|
||||
elif chosen == pause_act:
|
||||
task["status"] = "backlog" if paused else "paused"
|
||||
self._save_and_refresh(task)
|
||||
elif chosen == logs_act:
|
||||
self._view_logs(task)
|
||||
elif chosen == hist_act:
|
||||
from .run_history_dialog import _RunHistoryDialog
|
||||
_RunHistoryDialog(task, self).exec()
|
||||
elif chosen == next_act:
|
||||
self._create_next_from_output(task)
|
||||
elif chosen == del_act:
|
||||
if QMessageBox.question(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))
|
||||
) == QMessageBox.Yes:
|
||||
taskrepo.delete_task(tid, self._tasks_dir)
|
||||
self.refresh()
|
||||
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
|
||||
"""Right-click on a multi-selection within one column (Shift/Ctrl-click
|
||||
several cards first): one action deletes every selected task. The
|
||||
popup itself is a thin wrapper — see _confirm_and_delete_selected for
|
||||
the actual (independently testable) confirm+delete logic."""
|
||||
menu = QMenu(col)
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._confirm_and_delete_selected(selected)
|
||||
def _confirm_and_delete_selected(self, selected) -> bool:
|
||||
"""Confirm, then delete every task in ``selected``. Split out of
|
||||
_bulk_delete_menu so tests can drive it directly without having to
|
||||
fake a real (modal, event-loop-blocking) QMenu popup."""
|
||||
if QMessageBox.question(
|
||||
self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
|
||||
return False
|
||||
for item in selected:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
taskrepo.delete_task(tid, self._tasks_dir)
|
||||
self.refresh()
|
||||
return True
|
||||
def _run_now(self, task: dict) -> None:
|
||||
if task.get("task_type") == "manual":
|
||||
self.status_message.emit(tr("schedtask.msg_manual_norun"))
|
||||
return
|
||||
if self.scheduler is None:
|
||||
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
|
||||
return
|
||||
if self.scheduler.run_now(task["task_id"]):
|
||||
self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", "")))
|
||||
self.refresh()
|
||||
def _view_logs(self, task: dict) -> None:
|
||||
run_id = task.get("logs", {}).get("last_run_id")
|
||||
if not run_id:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
return
|
||||
folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
else:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
def _create_next_from_output(self, task: dict) -> None:
|
||||
"""Scaffold a follow-up task pre-wired to consume this task's output."""
|
||||
nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
|
||||
nxt["task_type"] = "cowork"
|
||||
nxt["input"]["mode"] = "previous_task_output"
|
||||
nxt["input"]["previous_task_id"] = task["task_id"]
|
||||
nxt["dependency"]["previous_task_id"] = task["task_id"]
|
||||
err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt],
|
||||
task["task_id"], nxt["task_id"])
|
||||
if err:
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
||||
return
|
||||
taskrepo.save_task(nxt, self._tasks_dir)
|
||||
task["dependency"]["next_task_id"] = nxt["task_id"]
|
||||
task["dependency"]["pass_output_to_next"] = True
|
||||
if task["dependency"].get("run_next_mode", "none") == "none":
|
||||
task["dependency"]["run_next_mode"] = "run_after_success"
|
||||
taskrepo.save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
self._edit_task(nxt["task_id"])
|
||||
def _ai_create(self) -> None:
|
||||
from .ai_task_creator_dialog import _AiCreateDialog
|
||||
dlg = _AiCreateDialog(self.ctx, self)
|
||||
if dlg.exec() and dlg.created_tasks:
|
||||
for t in dlg.created_tasks:
|
||||
taskrepo.save_task(t, self._tasks_dir)
|
||||
self.refresh()
|
||||
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
|
||||
@@ -34,7 +34,7 @@ from ...state import AppContext
|
||||
from ...core.task_scheduler import TaskScheduler
|
||||
from ...ui.cowork_tab import CoworkTab
|
||||
from ...ui.sidebar import HistorySidebar
|
||||
from ...ui.structure_graph_view import StructureGraphView
|
||||
from ..graph.structure_graph_view import StructureGraphView
|
||||
from ...ui.workspace_tab import WorkspaceTab
|
||||
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from ...i18n import tr
|
||||
from ...ui.dashboard_tab import DashboardTab
|
||||
from ..dashboard.dashboard_tab import DashboardTab
|
||||
from ...ui.monitoring_tab import MonitoringTab
|
||||
from ...ui.schedule_task_tab import ScheduleTaskTab
|
||||
from ..scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
|
||||
|
||||
|
||||
class PageRegistryMixin:
|
||||
|
||||
Reference in New Issue
Block a user