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>
141 lines
6.5 KiB
Python
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)
|