"""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};")