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
+115
View File
@@ -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))