Files
cowork-local/presentation/folder/ai_output_writer.py
T
Nam Pham Dinh ThanhandClaude Opus 5 f0fd3a41cd 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>
2026-08-27 23:23:06 +09:00

141 lines
6.5 KiB
Python

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