refactor(folder): R08-T12 — folder_tab.py 1589 -> 305, tách 8 file

presentation/folder/
      ai_edit_runner.py            325  một lượt AI sửa file, từ gửi tới xem trước
      document_preview_manager.py  317  PDF/Word/Excel/PowerPoint/ảnh/HTML/mã
      ai_file_editor_dialog.py     317  dựng panel AI + chọn model
      code_editor.py               183  ô soạn mã, đánh số dòng, tô cú pháp
      ai_output_writer.py          140  phần DUY NHẤT chạm vào file người dùng
      image_model_picker.py        115  dò model sinh ảnh trên mọi provider
      file_helpers.py              112  nhận dạng loại file + ngưỡng
      workspace_file_tree.py        38  cây thư mục
    ui/folder_tab.py               305  lắp ráp + retranslate

Plan ghi 3 file; khối lượng thật cần 8. Hai file tôi thêm ngoài dự kiến vì
đọc kỹ thì chúng là ranh giới thật:

* ai_output_writer.py — tách ra vì đây là phần duy nhất THẬT SỰ ghi đè file
  của người dùng. Mọi thứ trước nó chỉ dựng bản xem trước. Ranh giới đó đáng
  nhìn thấy trong cấu trúc thư mục.
* image_model_picker.py — chỗ duy nhất trong màn Thư mục biết tới nhiều
  provider cùng lúc (nó gợi ý được model sinh ảnh của provider KHÁC cái đang
  chọn).

Gom mọi hằng nhận dạng loại file (_IMAGE_SUFFIXES, _HAS_PDF, _MAX_EDIT_BYTES…)
về file_helpers.py: cả tám file trong gói đều hỏi tới, để rải ra thì thêm một
đuôi file phải sửa vài chỗ.

LẠI IMPORT LAZY THỤT LỀ: regex đổi mức tương đối của tôi chỉ khớp đầu dòng
nên bỏ sót import nằm trong thân hàm — 3 checker đỏ. Lần này tôi sửa một lượt
cho CẢ cây presentation/ thay vì riêng thư mục vừa tách; nó tìm ra thêm 3 file
ở scheduling cũng đang sai mà chưa nổ.

756 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-27 23:23:06 +09:00
co-authored by Claude Opus 5
parent 982fecc8dc
commit f0fd3a41cd
9 changed files with 1566 additions and 1298 deletions
@@ -0,0 +1,317 @@
"""Khung AI sửa file: dựng panel và chọn model — R08-T12.
Phần chạy thật nằm ở ``ai_edit_runner.py``; ở đây là giao diện và việc
chọn model, gồm cả dò model sinh ảnh trên mọi provider đã cấu hình.
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
"""
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 AiFileEditorPanelMixin:
"""Trộn vào FolderTab."""
def _build_ai_panel(self) -> QWidget:
self._ai_panel = QWidget()
v = QVBoxLayout(self._ai_panel)
v.setContentsMargins(6, 0, 0, 0)
v.setSpacing(4)
title_row = QHBoxLayout()
self._ai_title = QLabel(tr("folder.ai_edit"))
self._ai_title.setStyleSheet("font-weight:600;")
title_row.addWidget(self._ai_title)
title_row.addStretch(1)
# Live status — stays visible so that, after doing other tasks and
# coming back to this tab, the current "processing/done" state is shown.
self._ai_status = QLabel("")
self._ai_status.setObjectName("hint")
title_row.addWidget(self._ai_status)
v.addLayout(title_row)
# A Cowork-style inline timeline (streaming bubbles + plan) — the AI edit
# "processing" reads exactly like the Cowork chat.
self.ai_chat = ChatView()
v.addWidget(self.ai_chat, 1)
# AI-edit's OWN model picker (independent of the Cowork/Settings agent) —
# the chosen model runs the edit; "(auto)" uses the provider default.
self._ai_models: list[str] = []
model_row = QHBoxLayout()
self._ai_model_lbl = QLabel(tr("folder.ai_model_label"))
self._ai_model_lbl.setObjectName("hint")
model_row.addWidget(self._ai_model_lbl)
self.ai_model_combo = QComboBox()
self.ai_model_combo.addItem(tr("folder.ai_model_auto"), None)
model_row.addWidget(self.ai_model_combo, 1)
# Off/Auto/Manual routing toggle for AI-Edit (surface key "ai_edit").
from ...ui.routing_toggle import RoutingToggle
self.ai_routing_toggle = RoutingToggle(self.ctx, "ai_edit")
model_row.addWidget(self.ai_routing_toggle)
# Routing override for the next AI-edit run (set by _ai_apply_routing).
self._ai_routed_provider = None
self._ai_routed_model = None
v.addLayout(model_row)
row = QHBoxLayout()
self.ai_input = QLineEdit()
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
self.ai_input.returnPressed.connect(self._ai_send)
row.addWidget(self.ai_input, 1)
self.ai_send_btn = QPushButton(tr("folder.ai_send"))
self.ai_send_btn.setObjectName("primary")
self.ai_send_btn.clicked.connect(self._ai_send)
row.addWidget(self.ai_send_btn)
v.addLayout(row)
# Confirmation bar — the proposed edit is NOT applied/saved until the
# user reviews the diff and clicks Apply (Discard keeps the original).
self._ai_confirm_row = QWidget()
cf = QHBoxLayout(self._ai_confirm_row)
cf.setContentsMargins(0, 0, 0, 0)
cf.addStretch(1)
self._ai_discard_btn = QPushButton(tr("folder.ai_discard"))
self._ai_discard_btn.clicked.connect(self._ai_discard)
cf.addWidget(self._ai_discard_btn)
self._ai_apply_btn = QPushButton(tr("folder.ai_apply"))
self._ai_apply_btn.setObjectName("primary")
self._ai_apply_btn.clicked.connect(self._ai_apply)
cf.addWidget(self._ai_apply_btn)
self._ai_confirm_row.setVisible(False)
self._ai_pending = None # proposed content awaiting confirmation
v.addWidget(self._ai_confirm_row)
return self._ai_panel
def _reset_ai_conversation(self) -> None:
"""Clear the AI-edit chat so each file starts a clean conversation. A
run in progress (editing the previous file) is left untouched — the
reset applies the next time a file is opened while idle."""
if getattr(self, "ai_chat", None) is None or self._ai_worker is not None:
return
self.ai_chat.clear()
self.ai_btn.setText(tr("folder.ai_edit"))
self._ai_pending = None
self._ai_confirm_row.setVisible(False)
if hasattr(self, "_ai_status"):
self._ai_status.setText("")
def _toggle_ai_panel(self) -> None:
show = self.ai_btn.isChecked()
self._ai_panel.setVisible(show)
if show:
self._content_split.setSizes([700, 320])
self.ai_input.setFocus()
# Populate the list on first open, AND re-fetch when the active
# provider changed since it was last loaded — otherwise the picker
# would keep another provider's models and a pick would resolve to
# the wrong/default model at the new endpoint.
if (self.ai_model_combo.count() <= 1
or self._ai_models_provider != self.ctx.config.active_provider):
self.refresh_ai_models()
# Reopening acknowledges any 'done' badge (unless still running).
if self._ai_worker is None:
self.ai_btn.setText(tr("folder.ai_edit"))
self._ai_status.setText("")
def refresh_ai_models(self) -> None:
"""Fetch the active provider's model list (background) into the AI-edit
picker — independent of the Cowork/Settings agent. Called on first open
and whenever the active provider changes, so the picked model always
belongs to the provider that will actually run the edit."""
name = self.ctx.config.active_provider
setting_model = self.ctx.config.provider_conf(name).get("model", "")
def job(worker):
prov = self.ctx.build_provider_for(name)
try:
models = list(getattr(prov, "list_models", lambda: [])() or [])
except Exception: # noqa: BLE001
models = []
return {"models": models}
def done(res):
fetched = list(res.get("models", []))
# Always offer the Settings-configured model as an explicit choice,
# even when the provider can't list models (some gateways don't) —
# so the picker is never just "(auto)" and the user can always pick a
# concrete model instead of falling through to the default.
self._ai_models = list(dict.fromkeys(
([setting_model] if setting_model else []) + [m for m in fetched if m]))
self._ai_models_provider = name
cur = self.ai_model_combo.currentData()
self.ai_model_combo.blockSignals(True)
self.ai_model_combo.clear()
self.ai_model_combo.addItem(tr("folder.ai_model_auto"), None)
for m in self._ai_models:
self.ai_model_combo.addItem(m, m)
# Keep the user's pick if it exists on THIS provider; otherwise reset
# to "(auto)" (a stale pick must never be sent to the new endpoint).
idx = self.ai_model_combo.findData(cur)
self.ai_model_combo.setCurrentIndex(idx if idx >= 0 else 0)
self.ai_model_combo.blockSignals(False)
w = AgentWorker(job)
w.finished_ok.connect(done)
self._ai_models_worker = w
w.start()
# Proactively discover image models across ALL providers so an image
# suggestion is ready the moment the user asks for one.
self._scan_all_image_models()
def _ensure_editor_for_ai(self) -> bool:
"""Make the current file editable in the code editor (switching an HTML
preview to edit, or loading a text file). Returns False when there's no
file open or it isn't a text/code file."""
path = self._current_file
if not path or not os.path.isfile(path):
return False
suffix = Path(path).suffix.lower()
if suffix in _HTML_SUFFIXES:
self._show_html(path, mode_preview=False) # → editor with the HTML source
return True
if suffix in _PPTX_SUFFIXES and _pptx_available():
self._show_pptx(path, mode_preview=False) # → editor with the deck's text
return True
if suffix in DOC_SUFFIXES or suffix in _IMAGE_SUFFIXES:
return False
if _is_probably_text(path):
self._show_code(path)
return True
return False
def _ai_provider(self):
"""Build a provider using the model chosen in AI-edit's own picker
('(auto)' → the active provider's default). NOT tied to the Cowork agent.
An Auto/Manual routing override (set by :meth:`_ai_apply_routing` for the
current run) takes precedence over the picker."""
if getattr(self, "_ai_routed_provider", None) or getattr(self, "_ai_routed_model", None):
provider = self._ai_routed_provider or self.ctx.config.active_provider
return self.ctx.build_provider_for(provider, self._ai_routed_model or None)
model = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None
return self.ctx.build_provider_for(self.ctx.config.active_provider, model or None)
def _ai_apply_routing(self, instruction: str) -> None:
"""Auto Model Routing for the AI-Edit surface (always a CODING task).
R03-T05: routes through the shared ``RoutingApplicationService`` instead
of repeating the Off/Auto/Manual/Fallback rules locally. Sets
``self._ai_routed_provider``/``_ai_routed_model`` for this run;
:meth:`_ai_provider` honours them. Never raises."""
self._ai_routed_provider = None
self._ai_routed_model = None
try:
from ...application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from ...ui.routing_toggle import confirm_switch
cur_provider = self.ctx.config.active_provider
picked = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None
cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "")
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface="ai_edit",
prompt=instruction,
current_provider=cur_provider,
current_model=cur_model,
# AI-Edit turns are always code edits, so the task type is
# pinned rather than classified from the instruction text.
task_type="coding",
),
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not outcome.switched:
return
self._ai_routed_provider = outcome.provider
self._ai_routed_model = outcome.model
self.ai_chat.add_status(tr(
"routing.switched_notice",
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
except Exception: # noqa: BLE001 — routing must never block an edit
self._ai_routed_provider = None
self._ai_routed_model = None
def _ai_image_model(self):
"""Resolve the model+endpoint for image generation, searching ALL
providers. Returns ``(model, base_url, api_key)`` — ``base_url``/``api_key``
are ``None`` when the active provider is used; set when the image model
lives on a DIFFERENT provider.
Priority: the picked model if image-capable → an image model on the active
provider → the first image model found on ANY other provider → FALL BACK
to whatever model the user picked in AI-edit (so generation is still
attempted with their choice); ``None`` only when nothing is picked
('(auto)' → provider default)."""
from ...core import image_gen
picked = self.ai_model_combo.currentData() if hasattr(self, "ai_model_combo") else None
if picked and image_gen.looks_like_image_model(picked):
return picked, None, None
local = image_gen.suggest_image_model(self._ai_models)
if local:
return local, None, None
for key, model in self._all_image_models: # any other configured provider
conf = self.ctx.config.provider_conf(key)
return model, (conf.get("base_url") or None), (conf.get("api_key") or None)
# No image-specific model found anywhere → use the user's PICKED model
# (or provider default when '(auto)' is selected).
return (picked or None), None, None
def _cowork_context(self) -> str:
"""The whole Cowork conversation (recent turns) as background context —
so the AI edit is aware of what was discussed there."""
cw = self._cowork
msgs = getattr(cw, "messages", None) if cw is not None else None
if not msgs:
return ""
lines = [f"{m['role']}: {str(m['content'])[:1000]}"
for m in msgs if m.get("role") in ("user", "assistant") and m.get("content")]
return "\n".join(lines[-12:])
def _ai_set_busy(self, busy: bool) -> None:
self.ai_input.setEnabled(not busy)
self.ai_send_btn.setEnabled(not busy)
if busy:
self._ai_status.setText("⏳ " + tr("folder.ai_status_running"))
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
self.ai_btn.setText(tr("folder.ai_edit") + " ⏳") # visible even when collapsed
else:
self._ai_status.setText("")
self.ai_btn.setText(tr("folder.ai_edit"))
def _ai_flag_done(self) -> None:
"""After a background run, show a 'done' badge on the panel/button so the
user notices the result when they return to the tab; cleared on reopen.
If more instructions are queued, start the next one instead."""
if self._ai_worker is None and self._ai_pending is None and self._ai_queue:
self._ai_maybe_dequeue()
return
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
self._ai_status.setStyleSheet(f"color:{current_palette().success};")
if not self.ai_btn.isChecked() or self._ai_panel.isHidden():
self.ai_btn.setText(tr("folder.ai_edit") + " ✓")
def _update_queue_status(self) -> None:
"""Reflect the number of queued instructions on the panel status line."""
n = len(self._ai_queue)
if n and hasattr(self, "_ai_status"):
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
+ " · " + tr("folder.ai_queue_count", n=n))
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")