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>
318 lines
15 KiB
Python
318 lines
15 KiB
Python
"""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};")
|