## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
"""Folder Explorer screen, split into single-responsibility widgets
|
||||
(R08-T12): ``workspace_file_tree``, ``document_preview_manager``,
|
||||
``ai_edit_model_resolver``, ``ai_file_editor_dialog``, assembled by the
|
||||
``folder_tab`` shell."""
|
||||
@@ -0,0 +1,295 @@
|
||||
"""AiEditModelResolver — model picker + Auto Model Routing + image-model
|
||||
discovery for the AI-Edit panel (R08-T12, extracted from
|
||||
``ui/folder_tab.py::FolderTab``, lines 802-1036/911-961 of the original
|
||||
1587-line file: ``refresh_ai_models``, ``_scan_all_image_models``,
|
||||
``_ai_provider``, ``_ai_apply_routing``, ``_confirm_routing_switch``,
|
||||
``_ai_image_model``, ``_maybe_suggest_image_model``,
|
||||
``_suggest_cross_provider_image``).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY
|
||||
``ai_file_editor_dialog.py::AiFileEditorDialog`` — this is genuinely a
|
||||
distinct concern (which provider/model answers THIS run) from the panel's
|
||||
send/plan/edit orchestration, and splitting it out is also what keeps
|
||||
``ai_file_editor_dialog.py`` under the 400-line cap.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class AiEditModelResolver:
|
||||
"""Owns the AI-edit model combo's contents and every "which provider/
|
||||
model should THIS run use" decision — independent of the Cowork/Settings
|
||||
agent, exactly like the original panel's own picker was.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext``.
|
||||
model_combo: the ``QComboBox`` populated by :meth:`refresh`.
|
||||
on_status: ``(text) -> None`` — posts a status line into the AI
|
||||
chat (production passes ``ai_chat.add_status``).
|
||||
confirm_switch: ``(self, decision, timeout) -> bool`` — the Qt
|
||||
confirm dialog for Manual routing mode (kept as a callback so
|
||||
this class never imports a dialog itself).
|
||||
"""
|
||||
|
||||
def __init__(self, ctx, model_combo, on_status, confirm_switch) -> None:
|
||||
"""Bộ chọn model của panel AI-Edit.
|
||||
|
||||
``on_status`` và ``confirm_switch`` được tiêm vào để lớp này không tự mở hộp
|
||||
thoại — nhờ vậy test chạy được nó mà không cần giao diện.
|
||||
"""
|
||||
self.ctx = ctx
|
||||
self._combo = model_combo
|
||||
self._on_status = on_status
|
||||
self._confirm_switch = confirm_switch
|
||||
self._models: List[str] = []
|
||||
self._models_provider = ""
|
||||
self._all_image_models: List[Tuple[str, str]] = [] # [(provider_key, model)]
|
||||
self._img_scan_worker = None
|
||||
self._pending_img_suggest = False
|
||||
self._routed_provider: Optional[str] = None
|
||||
self._routed_model: Optional[str] = None
|
||||
|
||||
@property
|
||||
def models(self) -> List[str]:
|
||||
"""Danh sách model đã nạp cho provider hiện tại."""
|
||||
return self._models
|
||||
|
||||
@property
|
||||
def models_provider(self) -> str:
|
||||
"""Provider mà danh sách model đang thuộc về; '' nếu chưa nạp lần nào."""
|
||||
return self._models_provider
|
||||
|
||||
@property
|
||||
def routed_provider(self) -> Optional[str]:
|
||||
"""The provider :meth:`apply_routing` switched to for the current
|
||||
run, or ``None`` when it didn't switch (routing off/declined)."""
|
||||
return self._routed_provider
|
||||
|
||||
@property
|
||||
def routed_model(self) -> Optional[str]:
|
||||
"""Model do định tuyến tự động chọn cho lượt này; ``None`` nếu dùng model người dùng chọn."""
|
||||
return self._routed_model
|
||||
|
||||
def should_refresh(self) -> bool:
|
||||
"""True on first open, or when the active provider changed since
|
||||
the model list was last loaded — a stale list would resolve a pick
|
||||
to the wrong/default model at the new endpoint."""
|
||||
return self._combo.count() <= 1 or self._models_provider != self.ctx.config.active_provider
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Fetch the active provider's model list (background) into the
|
||||
picker. Also proactively scans ALL providers for image-capable
|
||||
models so a suggestion is ready the moment one is needed."""
|
||||
name = self.ctx.config.active_provider
|
||||
setting_model = self.ctx.config.provider_conf(name).get("model", "")
|
||||
|
||||
def job(worker):
|
||||
"""Chạy nền: hỏi provider danh sách model, lỗi thì trả về list rỗng."""
|
||||
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):
|
||||
"""Đổ danh sách vào ô chọn, giữ nguyên model đang chọn.
|
||||
|
||||
Luôn đưa model cấu hình trong Settings lên đầu, kể cả khi provider không
|
||||
liệt kê được — để ô chọn không bao giờ chỉ có mỗi "(tự động)".
|
||||
"""
|
||||
fetched = list(res.get("models", []))
|
||||
# Always offer the Settings-configured model as an explicit
|
||||
# choice, even when the provider can't list models.
|
||||
self._models = list(dict.fromkeys(
|
||||
([setting_model] if setting_model else []) + [m for m in fetched if m]))
|
||||
self._models_provider = name
|
||||
cur = self._combo.currentData()
|
||||
self._combo.blockSignals(True)
|
||||
self._combo.clear()
|
||||
self._combo.addItem(tr("folder.ai_model_auto"), None)
|
||||
for m in self._models:
|
||||
self._combo.addItem(m, m)
|
||||
idx = self._combo.findData(cur)
|
||||
self._combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self._combo.blockSignals(False)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._models_worker = w
|
||||
w.start()
|
||||
self._scan_all_image_models()
|
||||
|
||||
def _scan_all_image_models(self, then_suggest: bool = False) -> None:
|
||||
"""Dò khắp mọi provider đã cấu hình xem cái nào có model sinh ảnh.
|
||||
|
||||
Chạy sẵn ở nền để lúc cần gợi ý là có ngay. Đang dò dở mà bị gọi lại thì
|
||||
chỉ ghi nhận yêu cầu gợi ý, không dò chồng lên nhau.
|
||||
"""
|
||||
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", {}))
|
||||
candidates = [k for k, c in providers.items()
|
||||
if (c.get("base_url") or c.get("api_key"))]
|
||||
|
||||
def job(worker):
|
||||
"""Chạy nền: duyệt từng provider, lọc ra model sinh ảnh được."""
|
||||
from cowork_local.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):
|
||||
"""Ghi lại kết quả dò; có yêu cầu gợi ý đang chờ thì trả lời luôn."""
|
||||
self._img_scan_worker = None
|
||||
self._all_image_models = list(res.get("found", []))
|
||||
if self._pending_img_suggest:
|
||||
self._pending_img_suggest = False
|
||||
self._suggest_cross_provider_image()
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(self._on_image_scan_failed)
|
||||
self._img_scan_worker = w
|
||||
if then_suggest:
|
||||
self._pending_img_suggest = True
|
||||
w.start()
|
||||
|
||||
def _on_image_scan_failed(self, _err) -> None:
|
||||
"""Dò model ảnh thất bại: chỉ xoá cờ đang chạy, không báo lỗi ra màn hình —
|
||||
đây là việc chạy ngầm mà người dùng không yêu cầu.
|
||||
"""
|
||||
self._img_scan_worker = None
|
||||
|
||||
def provider(self) -> Any:
|
||||
"""Build a provider using the model chosen in the picker ('(auto)'
|
||||
-> the active provider's default), or an Auto/Manual routing
|
||||
override set by :meth:`apply_routing` for the current run."""
|
||||
if self._routed_provider or self._routed_model:
|
||||
provider = self._routed_provider or self.ctx.config.active_provider
|
||||
return self.ctx.build_provider_for(provider, self._routed_model or None)
|
||||
model = self._combo.currentData()
|
||||
return self.ctx.build_provider_for(self.ctx.config.active_provider, model or None)
|
||||
|
||||
def apply_routing(self, instruction: str) -> None:
|
||||
"""Auto Model Routing for the AI-Edit surface (always a CODING
|
||||
task). Sets the routing override :meth:`provider` honours.
|
||||
|
||||
Never raises — a routing failure must never block an edit."""
|
||||
self._routed_provider = None
|
||||
self._routed_model = None
|
||||
try:
|
||||
from cowork_local.application.model_routing import (
|
||||
RoutingRequest,
|
||||
build_routing_application_service,
|
||||
)
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
picked = self._combo.currentData()
|
||||
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,
|
||||
# An edit instruction is never a QA question, so the task
|
||||
# type is pinned rather than classified from the prompt.
|
||||
task_type=TaskType.CODING,
|
||||
),
|
||||
confirm=self._confirm_switch,
|
||||
)
|
||||
if not outcome.switched:
|
||||
return
|
||||
self._routed_provider = outcome.provider
|
||||
self._routed_model = outcome.model
|
||||
self._on_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._routed_provider = None
|
||||
self._routed_model = None
|
||||
|
||||
_IMAGE_WORDS = ("image", "picture", "photo", "illustration", "icon", "logo", "diagram",
|
||||
"ảnh", "hình", "minh họa", "biểu tượng", "画像", "イラスト")
|
||||
|
||||
def maybe_suggest_image_model(self, instruction: str) -> None:
|
||||
"""If the request looks image-related, suggest a suitable image
|
||||
model BEFORE running — active provider first, then ALL providers."""
|
||||
from cowork_local.core import image_gen
|
||||
low = (instruction or "").lower()
|
||||
if not any(w in low for w in self._IMAGE_WORDS):
|
||||
return
|
||||
picked = self._combo.currentData()
|
||||
if picked and image_gen.looks_like_image_model(picked):
|
||||
return
|
||||
local = image_gen.suggest_image_model(self._models)
|
||||
if local:
|
||||
self._on_status(tr("folder.ai_image_suggest", model=local))
|
||||
return
|
||||
if self._all_image_models:
|
||||
self._suggest_cross_provider_image()
|
||||
elif self._img_scan_worker is not None:
|
||||
self._pending_img_suggest = True
|
||||
else:
|
||||
self._scan_all_image_models(then_suggest=True)
|
||||
|
||||
def _suggest_cross_provider_image(self) -> None:
|
||||
"""Gợi ý một model sinh ảnh, kể cả khi nó thuộc provider KHÁC provider đang chọn.
|
||||
|
||||
Đây là chỗ duy nhất trong màn Thư mục biết tới nhiều provider cùng lúc.
|
||||
"""
|
||||
from cowork_local.config import PROVIDER_LABELS
|
||||
if not self._all_image_models:
|
||||
picked = self._combo.currentData()
|
||||
if picked:
|
||||
self._on_status(tr("folder.ai_image_use_selected", model=picked))
|
||||
else:
|
||||
self._on_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._on_status(tr("folder.ai_image_suggest_all") + "\n" + "\n".join(lines))
|
||||
|
||||
def image_model(self) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Resolve ``(model, base_url, api_key)`` for image generation,
|
||||
searching ALL providers — see the module docstring for priority
|
||||
order (picked model if image-capable -> active provider's image
|
||||
model -> any other provider's -> fall back to the picked model)."""
|
||||
from cowork_local.core import image_gen
|
||||
picked = self._combo.currentData()
|
||||
if picked and image_gen.looks_like_image_model(picked):
|
||||
return picked, None, None
|
||||
local = image_gen.suggest_image_model(self._models)
|
||||
if local:
|
||||
return local, None, None
|
||||
for key, model in self._all_image_models:
|
||||
conf = self.ctx.config.provider_conf(key)
|
||||
return model, (conf.get("base_url") or None), (conf.get("api_key") or None)
|
||||
return (picked or None), None, None
|
||||
|
||||
|
||||
__all__ = ["AiEditModelResolver"]
|
||||
@@ -0,0 +1,400 @@
|
||||
"""AiEditPipeline — the plan-then-edit-then-apply state machine behind the
|
||||
AI-Edit panel (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
|
||||
lines 1068-1097/1119-1146/1147-1467 of the original 1587-line file:
|
||||
``_ai_start`` through ``_ai_failed``, minus the queue/busy-badge bookkeeping
|
||||
which stays on ``ai_file_editor_dialog.py::AiFileEditorDialog`` — see that
|
||||
module's docstring for the split rationale).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY ``AiFileEditorDialog`` — same
|
||||
composition-to-respect-the-400-line-cap pattern as
|
||||
``office_document_renderer.py``. Talks to the file only through
|
||||
``document_preview_manager.py``'s public API (``ensure_editable_for_ai``,
|
||||
``write_content``, ``create_new_file``) — it never touches disk itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from cowork_local.application.workspaces.ai_edit_output import parse_ai_output
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
|
||||
|
||||
class AiEditPipeline:
|
||||
"""Runs one instruction through PLAN -> EDIT -> (review) -> APPLY/DISCARD.
|
||||
|
||||
Args:
|
||||
owner: the ``AiFileEditorDialog`` — supplies ``ai_chat``, ``preview``
|
||||
(``DocumentPreviewManager``), ``resolver``
|
||||
(``AiEditModelResolver``), ``ctx``, ``cowork_context()``, and is
|
||||
told about status changes via ``on_busy_changed``/``on_flag_done``
|
||||
so the panel's queue/badge bookkeeping stays in one place.
|
||||
"""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
"""Vòng chạy một lượt sửa tệp bằng AI.
|
||||
|
||||
``pending`` giữ nội dung model đề xuất cho tới khi người dùng đồng ý: không
|
||||
bao giờ ghi đè tệp trước khi có xác nhận.
|
||||
"""
|
||||
self._owner = owner
|
||||
self.worker: Optional[AgentWorker] = None
|
||||
self.pending: Optional[dict] = None # proposed content awaiting confirmation
|
||||
self._ctx: dict = {}
|
||||
self._prompt_usage: dict = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
self._running_file = ""
|
||||
|
||||
def start(self, instruction: str) -> None:
|
||||
"""Begin processing one instruction. Assumes the pipeline is idle
|
||||
(the panel's queue calls this when the previous run finishes)."""
|
||||
o = self._owner
|
||||
preview = o.preview
|
||||
editable = preview.stack.currentWidget() is preview.editor
|
||||
if not editable:
|
||||
editable = preview.ensure_editable_for_ai()
|
||||
o.resolver.maybe_suggest_image_model(instruction)
|
||||
o.resolver.apply_routing(instruction) # may switch to the best coding model
|
||||
has_file = editable and bool(preview.current_file)
|
||||
self._running_file = Path(preview.current_file).name if has_file else tr("folder.ai_new_file")
|
||||
o.set_busy(True)
|
||||
o.status_message.emit(tr("folder.ai_running", name=self._running_file))
|
||||
# Two phases so the PLAN is shown INLINE *before* the edit runs.
|
||||
self._ctx = {
|
||||
"filename": Path(preview.current_file).name if has_file else "",
|
||||
"content": preview.editor.toPlainText() if has_file else "",
|
||||
"convo": o.cowork_context(),
|
||||
"instruction": instruction,
|
||||
"provider": o.resolver.provider(),
|
||||
"plan": "",
|
||||
"edit_kind": preview.edit_kind,
|
||||
}
|
||||
self._prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
self._run_plan()
|
||||
|
||||
# ---- usage accounting (like Cowork's per-message footer) --------------- #
|
||||
def _add_usage(self, usage) -> None:
|
||||
"""Cộng dồn token của một lượt gọi vào tổng của cả phiên sửa file."""
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
tot = self._prompt_usage
|
||||
tot["in"] += int(usage.get("in", 0) or 0)
|
||||
tot["out"] += int(usage.get("out", 0) or 0)
|
||||
tot["cache"] += int(usage.get("cache", 0) or 0)
|
||||
tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0)
|
||||
|
||||
def _show_usage(self, bubble) -> None:
|
||||
"""Gắn dòng token/chi phí dưới bong bóng trả lời; chưa dùng token nào thì bỏ qua."""
|
||||
tot = self._prompt_usage
|
||||
if bubble is None or not (tot["in"] or tot["out"]):
|
||||
return
|
||||
from cowork_local.core import model_pricing as mp, usage_tracker as ut
|
||||
pricing = {**ut.DEFAULT_PRICING, **(self._owner.ctx.config.data.get("usage") or {})}
|
||||
line = (f"↓{mp.format_tokens(int(tot['in']))} ↑{mp.format_tokens(int(tot['out']))} "
|
||||
f"▤{mp.format_tokens(int(tot['in'] + tot['out'] + tot['cache']))} "
|
||||
f"{ut.format_cost(tot['cost'], pricing)}")
|
||||
try:
|
||||
bubble.add_usage(line)
|
||||
except Exception: # noqa: BLE001 - a usage footer must never break the edit
|
||||
pass
|
||||
|
||||
# ---- phase 1: plan ------------------------------------------------------- #
|
||||
def _run_plan(self) -> None:
|
||||
"""Bước 1 — nhờ model lập kế hoạch sửa trước khi động vào nội dung."""
|
||||
o = self._owner
|
||||
c = self._ctx
|
||||
plan_bubble = o.ai_chat.add_plan(tr("folder.ai_planning"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
|
||||
def job(worker):
|
||||
"""Chạy nền: gọi model sinh kế hoạch và đo token đã dùng."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.core.co4e_runner import _usage_delta
|
||||
provider = c["provider"]
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor. Give a SHORT numbered plan (2-4 steps) for "
|
||||
"the requested change. Plan ONLY — do NOT output any code."}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
messages.append({"role": "user", "content":
|
||||
f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
f"Request: {c['instruction']}"})
|
||||
ut.set_context("folder", c.get("filename") or "AI edit")
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, o.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"plan": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(lambda res, b=plan_bubble: self._plan_done(res, b))
|
||||
worker.failed.connect(lambda err, b=plan_bubble: self._failed(err, b))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _plan_done(self, result, plan_bubble) -> None:
|
||||
"""Có kế hoạch: hiện ra rồi chuyển sang bước sửa thật."""
|
||||
self._add_usage((result or {}).get("usage"))
|
||||
plan = ((result or {}).get("plan") or "").strip()
|
||||
self._ctx["plan"] = plan
|
||||
plan_bubble.set_plain(plan or tr("folder.ai_empty"))
|
||||
self._owner.ai_chat.scroll_to_bottom()
|
||||
self._run_edit()
|
||||
|
||||
# ---- phase 2: execute (edit the file) ------------------------------------ #
|
||||
def _run_edit(self) -> None:
|
||||
"""Bước 2 — sinh nội dung mới, phát dần vào bong bóng trả lời."""
|
||||
o = self._owner
|
||||
c = self._ctx
|
||||
bubble = o.ai_chat.add_assistant(tr("folder.ai_edit"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
|
||||
pptx_note = ("\nThis is a PPTX shown as marker blocks '### Slide N / Box M', where N is the "
|
||||
"1-based SLIDE NUMBER and M the box on that slide. When the user refers to a "
|
||||
"slide by number (e.g. 'edit slide 3'), change ONLY the blocks under '### Slide "
|
||||
"3' and leave every other slide's block exactly as-is. Each block has fields "
|
||||
"type/pos/size/font/text (and image for pictures). To change TEXT COLOUR or "
|
||||
"FONT edit the `font:` line — e.g. `font: name=Arial size=28 bold=1 "
|
||||
"color=FF0000`. Keep all block markers and structure.") if c["edit_kind"] == "pptx" else ""
|
||||
|
||||
_pptx_words = ("pptx", "powerpoint", "slide", "presentation", "deck",
|
||||
"スライド", "プレゼン", "trình chiếu", "trinh chieu", "bài thuyết trình")
|
||||
wants_new_pptx = (c["edit_kind"] != "pptx"
|
||||
and any(w in c["instruction"].lower() for w in _pptx_words))
|
||||
new_pptx_note = ("\nTo CREATE a PowerPoint, name it `FILE: <name>.pptx` and output the slides "
|
||||
"as marker blocks — one block per shape:\n"
|
||||
"### Slide 1 / Box 1\ntype: text\npos: 0.5, 0.4\nsize: 9.0, 1.2\n"
|
||||
"font: name=Calibri size=32 bold=1 color=1F3864\ntext:\nTitle here\n\n"
|
||||
"### Slide 1 / Box 2\ntype: text\npos: 0.5, 1.8\nsize: 9.0, 4.5\n"
|
||||
"text:\nBullet one\nBullet two\n\n"
|
||||
"Increment the Slide number for each new slide; pos/size are in inches; "
|
||||
"font color is RRGGBB hex.") if wants_new_pptx else ""
|
||||
|
||||
imggen_note = ""
|
||||
try:
|
||||
from cowork_local.core import image_gen
|
||||
if image_gen.is_configured(o.ctx.config):
|
||||
imggen_note = ("\nYou can also GENERATE an illustration image: add a line "
|
||||
"`IMAGE_GEN: <describe the image> => <relative/path.png>`. Use a "
|
||||
"generated image e.g. as a new picture, or (for pptx) set a picture "
|
||||
"box's `image:` field to that same path to insert it.")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def job(worker):
|
||||
"""Chạy nền: gọi model sinh nội dung sửa, kèm ngữ cảnh tệp đang mở."""
|
||||
provider = c["provider"]
|
||||
open_note = (f"the currently-open file '{c['filename']}'" if c["filename"]
|
||||
else "no file is open")
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor inside an app. Following the plan, output the "
|
||||
"COMPLETE file content in ONE fenced code block (```), and nothing after "
|
||||
"it. Preserve everything you were not asked to change.\n"
|
||||
"If the request is to CREATE A NEW file (or a different file than the one "
|
||||
"open), put a line `FILE: <relative/path/name.ext>` (relative to the "
|
||||
"current folder) immediately before the code block. Omit FILE to edit the "
|
||||
f"open file. Right now, {open_note}." + pptx_note + new_pptx_note + imggen_note}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
if c["plan"]:
|
||||
messages.append({"role": "system", "content": "Plan to follow:\n" + c["plan"]})
|
||||
cur = (f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
if c["filename"] else "No file is currently open.\n\n")
|
||||
messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"})
|
||||
|
||||
def on_text(piece: str) -> None:
|
||||
"""Đẩy từng mẩu nội dung ra ngoài để giao diện vẽ dần."""
|
||||
worker.emit_event({"type": "text", "delta": piece})
|
||||
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.core.co4e_runner import _usage_delta
|
||||
ut.set_context("folder", c.get("filename") or "AI edit")
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, on_text=on_text, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, o.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"text": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.event.connect(lambda ev, b=bubble: self._stream(ev, b))
|
||||
worker.finished_ok.connect(lambda res, b=bubble: self._done(res, b))
|
||||
worker.failed.connect(lambda err, b=bubble: self._failed(err, b))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _stream(self, ev, bubble) -> None:
|
||||
"""Vẽ từng mẩu nội dung đang phát dần và cuộn theo."""
|
||||
if isinstance(ev, dict) and ev.get("type") == "text":
|
||||
bubble.append_delta(ev.get("delta", ""))
|
||||
self._owner.ai_chat.scroll_to_bottom()
|
||||
|
||||
def _done(self, result, bubble) -> None:
|
||||
"""Sinh xong: tách phần mã khỏi phần giải thích và dựng bản xem trước.
|
||||
|
||||
KHÔNG bao giờ ghi thẳng ra tệp ở đây — chỉ ``_finalize_apply`` mới chạm đĩa,
|
||||
và chỉ sau khi người dùng bấm Áp dụng.
|
||||
"""
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
o.set_busy(False)
|
||||
self._add_usage((result or {}).get("usage"))
|
||||
self._show_usage(bubble)
|
||||
text = ((result or {}).get("text") or "").strip()
|
||||
target, new_content, summary, image_gens = parse_ai_output(text)
|
||||
if new_content is None and not image_gens:
|
||||
bubble.set_markdown(text or tr("folder.ai_empty"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
return
|
||||
create = bool(target) and (not o.preview.current_file
|
||||
or Path(target).name != Path(o.preview.current_file).name)
|
||||
self.pending = {"content": new_content, "target": target if create else None,
|
||||
"image_gens": image_gens}
|
||||
hint = tr("folder.ai_review_hint")
|
||||
bubble.set_markdown((summary + "\n\n" if summary else "") + "_" + hint + "_")
|
||||
if new_content is not None:
|
||||
old = "" if create else o.preview.editor.toPlainText()
|
||||
diff = "".join(difflib.unified_diff(
|
||||
old.splitlines(keepends=True), new_content.splitlines(keepends=True),
|
||||
fromfile=("(new file)" if create else "current"),
|
||||
tofile=(target if create else "proposed"))) or "(no textual difference)"
|
||||
title = tr("folder.ai_proposed_new", name=target) if create else tr("folder.ai_proposed")
|
||||
o.ai_chat.add_diff(title, diff)
|
||||
if image_gens:
|
||||
listing = "\n".join(f"• {p} ({prompt[:60]})" for prompt, p in image_gens)
|
||||
o.ai_chat.add_status(tr("folder.ai_image_plan") + "\n" + listing)
|
||||
o.show_confirm_row(True)
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
name = target if create else self._running_file
|
||||
o.status_message.emit(tr("folder.ai_proposed_status", name=name))
|
||||
o.set_review_status("● " + hint, current_palette().warning)
|
||||
|
||||
# ---- apply / discard ------------------------------------------------------ #
|
||||
def apply(self) -> None:
|
||||
"""Confirmed by the user. If the edit GENERATES images, ask the
|
||||
image gate then generate them (off-thread) before finalising."""
|
||||
if not self.pending:
|
||||
return
|
||||
p = self.pending
|
||||
self.pending = None
|
||||
self._owner.show_confirm_row(False)
|
||||
if p.get("image_gens"):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
|
||||
self._owner.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return
|
||||
self._generate_then_finalize(p)
|
||||
return
|
||||
self._finalize_apply(p)
|
||||
|
||||
def _generate_then_finalize(self, p: dict) -> None:
|
||||
"""Lượt sửa có yêu cầu sinh ảnh: tạo ảnh trước rồi mới ghi nội dung.
|
||||
|
||||
Phải theo thứ tự đó vì nội dung có thể tham chiếu tới đường dẫn ảnh vừa tạo.
|
||||
"""
|
||||
o = self._owner
|
||||
imgs = p.get("image_gens") or []
|
||||
root = os.path.normpath(o.preview.root)
|
||||
img_model, img_base, img_key = o.resolver.image_model()
|
||||
o.set_busy(True)
|
||||
o.status_message.emit(tr("folder.ai_generating"))
|
||||
|
||||
def job(worker):
|
||||
"""Chạy nền: sinh lần lượt từng ảnh được yêu cầu."""
|
||||
from cowork_local.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(o.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._images_done(res, pp))
|
||||
worker.failed.connect(lambda err, pp=p: self._images_done({"results": [], "err": err}, pp))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _images_done(self, res: dict, p: dict) -> None:
|
||||
"""Ảnh đã sinh xong: ghi nội dung ra đĩa."""
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
o.set_busy(False)
|
||||
created = []
|
||||
for dest, ok, msg in res.get("results", []):
|
||||
if ok:
|
||||
created.append(dest)
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_image_created", name=Path(dest).name))
|
||||
else:
|
||||
o.ai_chat.add_error(tr("folder.ai_image_failed", err=msg))
|
||||
self._finalize_apply(p, images_done=True)
|
||||
if p.get("content") is None and not p.get("target") and created:
|
||||
o.preview.open_file(created[0], reset_ai=False)
|
||||
|
||||
def _finalize_apply(self, p: dict, images_done: bool = False) -> None:
|
||||
"""Ghi nội dung đã được duyệt xuống đĩa và làm mới khung xem."""
|
||||
o = self._owner
|
||||
content = p.get("content")
|
||||
target = p.get("target")
|
||||
if content is None:
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
|
||||
return
|
||||
if target:
|
||||
dest = o.preview.create_new_file(target, content)
|
||||
if dest is None:
|
||||
return
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_created", name=Path(dest).name))
|
||||
o.status_message.emit(tr("folder.ai_created", name=Path(dest).name))
|
||||
else:
|
||||
o.preview.editor.setPlainText(content) # live update in the editor/preview
|
||||
o.preview.write_content(content, skip_image_confirm=images_done)
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_applied"))
|
||||
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
|
||||
def discard(self) -> None:
|
||||
"""Bỏ bản đề xuất đang chờ, không chạm vào tệp."""
|
||||
o = self._owner
|
||||
self.pending = None
|
||||
o.show_confirm_row(False)
|
||||
o.ai_chat.add_status(tr("folder.ai_discarded"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.set_review_status("", None)
|
||||
o.maybe_dequeue() # discarding resolves the gate → run the next queued edit
|
||||
|
||||
def _failed(self, err, bubble) -> None:
|
||||
"""Lượt sửa lỗi: hiện lỗi trong bong bóng và mở khoá lại panel."""
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
bubble.set_markdown(tr("folder.ai_error", err=err))
|
||||
o.set_busy(False)
|
||||
o.status_message.emit(tr("folder.ai_error", err=err))
|
||||
o.flag_done()
|
||||
|
||||
|
||||
__all__ = ["AiEditPipeline"]
|
||||
@@ -0,0 +1,246 @@
|
||||
"""AiFileEditorDialog — the collapsible AI-edit panel of the Folder Explorer
|
||||
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 701-800/
|
||||
1039-1066/1099-1118/1468-1517 of the original 1587-line file:
|
||||
``_build_ai_panel``, panel open/reset, the instruction queue, and the busy/
|
||||
done status line).
|
||||
|
||||
Despite the name (matching ``docs/refactor/Feature_Architecture_Proposal.md``'s
|
||||
R08-T12 file list), this is an inline collapsible ``QWidget`` panel, not a
|
||||
modal ``QDialog`` — exactly like the original ``_ai_panel`` was.
|
||||
|
||||
Composes two helpers to stay under the 400-line cap:
|
||||
``ai_edit_model_resolver.py::AiEditModelResolver`` (which provider/model
|
||||
answers a run) and ``ai_edit_pipeline.py::AiEditPipeline`` (the actual
|
||||
plan-then-edit-then-apply state machine). This class owns the widget itself,
|
||||
the instruction queue, and the busy/done status line/badge — the parts that
|
||||
needed to stay together because the queue decides when the pipeline's next
|
||||
``start()`` call happens.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget,
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver
|
||||
from cowork_local.presentation.folder.ai_edit_pipeline import AiEditPipeline
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.chat_view import ChatView
|
||||
|
||||
|
||||
class AiFileEditorDialog(QWidget):
|
||||
"""Collapsible panel: a Cowork-style inline chat timeline, this panel's
|
||||
OWN model picker + routing toggle, an instruction box, and an Apply/
|
||||
Discard confirmation bar for the proposed edit.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext``.
|
||||
preview: ``document_preview_manager.py::DocumentPreviewManager`` —
|
||||
every read/write of the actual file content goes through it.
|
||||
cowork: the shared Cowork tab (optional) — its recent messages are
|
||||
included as background context for the edit.
|
||||
"""
|
||||
|
||||
status_message = Signal(str)
|
||||
badge_changed = Signal(str) # "" | " ⏳" | " ✓" — the shell mirrors this onto its toggle button
|
||||
|
||||
def __init__(self, ctx, preview, cowork=None, parent=None):
|
||||
"""Hộp thoại sửa tệp bằng AI.
|
||||
|
||||
``resolver`` dựng sau, vì nó cần chính ô chọn model mà hàm này mới tạo.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.preview = preview
|
||||
self._cowork = cowork
|
||||
self._ai_queue: List[str] = []
|
||||
self.pipeline = AiEditPipeline(self)
|
||||
self.resolver: Optional[AiEditModelResolver] = None # built after ai_model_combo exists
|
||||
|
||||
preview.ai_reset_requested.connect(self.reset_conversation)
|
||||
preview.status_message.connect(self.status_message.emit)
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
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)
|
||||
self._ai_status = QLabel("")
|
||||
self._ai_status.setObjectName("hint")
|
||||
title_row.addWidget(self._ai_status)
|
||||
v.addLayout(title_row)
|
||||
self.ai_chat = ChatView()
|
||||
v.addWidget(self.ai_chat, 1)
|
||||
|
||||
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)
|
||||
from cowork_local.ui.routing_toggle import RoutingToggle
|
||||
self.ai_routing_toggle = RoutingToggle(self.ctx, "ai_edit")
|
||||
model_row.addWidget(self.ai_routing_toggle)
|
||||
v.addLayout(model_row)
|
||||
self.resolver = AiEditModelResolver(
|
||||
ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch)
|
||||
|
||||
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)
|
||||
|
||||
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.pipeline.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.pipeline.apply)
|
||||
cf.addWidget(self._ai_apply_btn)
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
v.addWidget(self._ai_confirm_row)
|
||||
|
||||
on_language_changed(self.retranslate)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self._ai_title.setText(tr("folder.ai_edit"))
|
||||
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
|
||||
self.ai_send_btn.setText(tr("folder.ai_send"))
|
||||
self._ai_model_lbl.setText(tr("folder.ai_model_label"))
|
||||
if self.ai_model_combo.count() >= 1 and self.ai_model_combo.itemData(0) is None:
|
||||
self.ai_model_combo.setItemText(0, tr("folder.ai_model_auto"))
|
||||
self._ai_apply_btn.setText(tr("folder.ai_apply"))
|
||||
self._ai_discard_btn.setText(tr("folder.ai_discard"))
|
||||
|
||||
# ---- called by the shell (header button, splitter owner) --------------- #
|
||||
def on_opened(self) -> None:
|
||||
"""The shell's AI toggle button was just checked ON."""
|
||||
self.ai_input.setFocus()
|
||||
if self.resolver.should_refresh():
|
||||
self.resolver.refresh()
|
||||
if self.pipeline.worker is None:
|
||||
self.badge_changed.emit("")
|
||||
self._ai_status.setText("")
|
||||
|
||||
def reset_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 self.pipeline.worker is not None:
|
||||
return
|
||||
self.ai_chat.clear()
|
||||
self.badge_changed.emit("")
|
||||
self.pipeline.pending = None
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
self._ai_status.setText("")
|
||||
|
||||
def cowork_context(self) -> str:
|
||||
"""The whole Cowork conversation (recent turns) as background context."""
|
||||
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:])
|
||||
|
||||
# ---- send / queue -------------------------------------------------------- #
|
||||
def _ai_send(self) -> None:
|
||||
"""Gửi một yêu cầu sửa; chưa chọn thư mục làm việc thì báo lỗi ngay."""
|
||||
if not self.preview.root:
|
||||
self.ai_chat.add_error(tr("folder.ai_no_file"))
|
||||
return
|
||||
instruction = self.ai_input.text().strip()
|
||||
if not instruction:
|
||||
return
|
||||
self.ai_input.clear()
|
||||
self.ai_chat.add_user(instruction)
|
||||
# QUEUE: while a run is active OR a proposal is awaiting Apply/Discard,
|
||||
# hold the new instruction and run it once the pipeline goes idle.
|
||||
if self.pipeline.worker is not None or self.pipeline.pending is not None:
|
||||
self._ai_queue.append(instruction)
|
||||
self.ai_chat.add_status(tr("folder.ai_queued", n=len(self._ai_queue)))
|
||||
self._update_queue_status()
|
||||
return
|
||||
self.pipeline.start(instruction)
|
||||
|
||||
def _update_queue_status(self) -> None:
|
||||
"""Cập nhật dòng trạng thái theo số yêu cầu còn xếp hàng."""
|
||||
n = len(self._ai_queue)
|
||||
if n:
|
||||
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};")
|
||||
|
||||
def maybe_dequeue(self) -> None:
|
||||
"""When the pipeline is fully idle, start the next queued instruction."""
|
||||
if self.pipeline.worker is not None or self.pipeline.pending is not None:
|
||||
return
|
||||
if not self._ai_queue:
|
||||
return
|
||||
nxt = self._ai_queue.pop(0)
|
||||
self._update_queue_status()
|
||||
self.pipeline.start(nxt)
|
||||
|
||||
# ---- pipeline callbacks (see ai_edit_pipeline.py) ------------------------- #
|
||||
def set_busy(self, busy: bool) -> None:
|
||||
"""Khoá/mở ô nhập và nút gửi trong lúc một lượt sửa đang chạy."""
|
||||
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.badge_changed.emit(" ⏳") # visible even when collapsed
|
||||
else:
|
||||
self._ai_status.setText("")
|
||||
self.badge_changed.emit("")
|
||||
|
||||
def flag_done(self) -> None:
|
||||
"""After a background run, show a 'done' badge 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.pipeline.worker is None and self.pipeline.pending is None and self._ai_queue:
|
||||
self.maybe_dequeue()
|
||||
return
|
||||
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().success};")
|
||||
self.badge_changed.emit(" ✓")
|
||||
|
||||
def show_confirm_row(self, visible: bool) -> None:
|
||||
"""Hiện/ẩn hàng nút Áp dụng · Huỷ cho bản đề xuất đang chờ."""
|
||||
self._ai_confirm_row.setVisible(visible)
|
||||
|
||||
def set_review_status(self, text: str, color) -> None:
|
||||
"""Đặt dòng trạng thái kèm màu (xanh khi xong, đỏ khi lỗi)."""
|
||||
self._ai_status.setText(text)
|
||||
if color:
|
||||
self._ai_status.setStyleSheet(f"color:{color};")
|
||||
|
||||
def _confirm_routing_switch(self, decision, timeout: float) -> bool:
|
||||
"""Manual mode: ask before moving this AI-Edit run to another model."""
|
||||
from cowork_local.ui.routing_toggle import confirm_switch
|
||||
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
|
||||
__all__ = ["AiFileEditorDialog"]
|
||||
@@ -0,0 +1,220 @@
|
||||
"""CodeEditor — the VS-Code-style code/text editor widget (R08-T12, split
|
||||
out of ``document_preview_manager.py`` to keep that file under the 400-line
|
||||
cap; originally ``ui/folder_tab.py``, lines 61-236 of the original
|
||||
1587-line file: the Pygments token-colour helper, ``PygmentsHighlighter``,
|
||||
``_LineNumbers``, ``CodeEditor``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QRect, QSize, Qt, QTimer
|
||||
from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat
|
||||
from PySide6.QtWidgets import QPlainTextEdit, QWidget
|
||||
|
||||
from cowork_local.theme import current_palette
|
||||
|
||||
_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy)
|
||||
|
||||
|
||||
# ── VS-Code-Dark+-ish token palette ────────────────────────────────────────
|
||||
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
|
||||
"""Dựng một định dạng ký tự cho bộ tô màu cú pháp."""
|
||||
f = QTextCharFormat()
|
||||
f.setForeground(QColor(color))
|
||||
if italic:
|
||||
f.setFontItalic(True)
|
||||
if bold:
|
||||
f.setFontWeight(QFont.Bold)
|
||||
return f
|
||||
|
||||
|
||||
class PygmentsHighlighter(QSyntaxHighlighter):
|
||||
"""Colour the whole document with Pygments and apply per-block. Re-lexes the
|
||||
full text (debounced) so multi-line strings/comments colour correctly."""
|
||||
|
||||
def __init__(self, document):
|
||||
"""Tô màu cú pháp bằng Pygments.
|
||||
|
||||
Tô lại sau một nhịp trễ (``QTimer`` chạy một lần) thay vì ngay mỗi phím: gõ
|
||||
nhanh trong file lớn mà tô đồng bộ thì giao diện khựng.
|
||||
"""
|
||||
super().__init__(document)
|
||||
from pygments.lexers.special import TextLexer
|
||||
self._lexer = TextLexer(stripnl=False)
|
||||
self._ranges: list[tuple[int, int, QTextCharFormat]] = []
|
||||
self._rules = self._build_rules()
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.setInterval(250)
|
||||
self._timer.timeout.connect(self._retokenize)
|
||||
document.contentsChanged.connect(self._timer.start)
|
||||
|
||||
@staticmethod
|
||||
def _build_rules():
|
||||
"""Dựng bảng ánh xạ loại token của Pygments sang màu theo theme đang dùng."""
|
||||
from pygments.token import (
|
||||
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
|
||||
)
|
||||
p = current_palette()
|
||||
return [
|
||||
(Comment, _fmt(p.code_comment, italic=True)),
|
||||
(Keyword.Type, _fmt(p.code_type)),
|
||||
(Keyword, _fmt(p.code_keyword)),
|
||||
(Name.Function, _fmt(p.code_func)),
|
||||
(Name.Class, _fmt(p.code_type)),
|
||||
(Name.Decorator, _fmt(p.code_func)),
|
||||
(Name.Builtin, _fmt(p.code_type)),
|
||||
(Name.Tag, _fmt(p.code_keyword)),
|
||||
(Name.Attribute, _fmt(p.code_attr)),
|
||||
(String.Doc, _fmt(p.code_comment, italic=True)),
|
||||
(String, _fmt(p.code_string)),
|
||||
(Number, _fmt(p.code_number)),
|
||||
(Operator, _fmt(p.code_fg)),
|
||||
(Punctuation, _fmt(p.code_fg)),
|
||||
(Error, _fmt(p.code_error)),
|
||||
]
|
||||
|
||||
def set_filename(self, filename: str, text: str = "") -> None:
|
||||
"""Chọn bộ phân tích cú pháp theo đuôi tệp; không nhận ra thì tắt tô màu."""
|
||||
from pygments.lexers import get_lexer_for_filename, guess_lexer
|
||||
from pygments.lexers.special import TextLexer
|
||||
from pygments.util import ClassNotFound
|
||||
try:
|
||||
self._lexer = get_lexer_for_filename(filename, stripnl=False)
|
||||
except ClassNotFound:
|
||||
try:
|
||||
self._lexer = guess_lexer(text) if text.strip() else TextLexer()
|
||||
except ClassNotFound:
|
||||
self._lexer = TextLexer(stripnl=False)
|
||||
self._retokenize()
|
||||
|
||||
def _fmt_for(self, tok):
|
||||
"""Định dạng của một token; leo dần lên token cha cho tới khi khớp một luật."""
|
||||
for ttype, fmt in self._rules:
|
||||
if tok in ttype:
|
||||
return fmt
|
||||
return None
|
||||
|
||||
def _retokenize(self) -> None:
|
||||
"""Phân tích lại toàn bộ tài liệu và ghi nhớ định dạng cho từng dòng.
|
||||
|
||||
Pygments phân tích theo cả tệp chứ không theo từng dòng, nên không thể
|
||||
tô đúng nếu chỉ nhìn một dòng — ví dụ chuỗi nhiều dòng hay khối chú thích.
|
||||
"""
|
||||
from pygments import lex
|
||||
text = self.document().toPlainText()
|
||||
self._ranges = []
|
||||
if len(text) <= _MAX_HIGHLIGHT_CHARS:
|
||||
pos = 0
|
||||
for tok, val in lex(text, self._lexer):
|
||||
fmt = self._fmt_for(tok)
|
||||
if fmt is not None and val:
|
||||
self._ranges.append((pos, pos + len(val), fmt))
|
||||
pos += len(val)
|
||||
self.rehighlight()
|
||||
|
||||
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override
|
||||
"""Áp định dạng đã tính sẵn cho một dòng. Qt gọi hàm này cho từng dòng hiện trên màn."""
|
||||
if not self._ranges:
|
||||
return
|
||||
bstart = self.currentBlock().position()
|
||||
bend = bstart + len(text)
|
||||
for start, end, fmt in self._ranges:
|
||||
if end <= bstart or start >= bend:
|
||||
continue
|
||||
s = max(start, bstart) - bstart
|
||||
e = min(end, bend) - bstart
|
||||
if e > s:
|
||||
self.setFormat(s, e - s, fmt)
|
||||
|
||||
|
||||
class _LineNumbers(QWidget):
|
||||
"""Máng số dòng vẽ bên trái ô soạn thảo."""
|
||||
def __init__(self, editor):
|
||||
"""Dải số dòng bên trái ô soạn mã."""
|
||||
super().__init__(editor)
|
||||
self._editor = editor
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
"""Bề rộng máng số dòng, do ô soạn thảo tính theo số chữ số của dòng cuối."""
|
||||
return QSize(self._editor.line_number_width(), 0)
|
||||
|
||||
def paintEvent(self, event): # noqa: N802
|
||||
"""Nhờ ô soạn thảo vẽ — nó mới biết dòng nào đang hiện ở đâu."""
|
||||
self._editor.paint_line_numbers(event)
|
||||
|
||||
|
||||
class CodeEditor(QPlainTextEdit):
|
||||
"""A dark, monospaced editor with a line-number gutter + Pygments colouring —
|
||||
the Sublime/VS-Code look for viewing & editing source files."""
|
||||
|
||||
def __init__(self):
|
||||
"""Ô soạn mã: phông đều, không tự xuống dòng, tab rộng 4 ký tự.
|
||||
|
||||
Không tự xuống dòng là cố ý — mã bị bẻ dòng thì lệch thụt đầu dòng và khó
|
||||
đọc hơn là phải cuộn ngang.
|
||||
"""
|
||||
super().__init__()
|
||||
self.setObjectName("codeEditor")
|
||||
self.setLineWrapMode(QPlainTextEdit.NoWrap)
|
||||
self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" "))
|
||||
font = QFont("Consolas")
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
font.setPointSize(10)
|
||||
self.setFont(font)
|
||||
self._gutter = _LineNumbers(self)
|
||||
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
|
||||
self.updateRequest.connect(self._on_update_request)
|
||||
self._highlighter = PygmentsHighlighter(self.document())
|
||||
self._update_gutter_width()
|
||||
|
||||
def line_number_width(self) -> int:
|
||||
"""Bề rộng cần cho máng số dòng, tính theo số chữ số của dòng cuối cùng."""
|
||||
digits = max(2, len(str(max(1, self.blockCount()))))
|
||||
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
|
||||
|
||||
def _update_gutter_width(self) -> None:
|
||||
"""Chừa lề trái đúng bằng bề rộng máng số dòng."""
|
||||
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
|
||||
|
||||
def _on_update_request(self, rect, dy: int) -> None:
|
||||
"""Cuộn hoặc vẽ lại vùng nào thì máng số dòng cuộn/vẽ lại đúng vùng đó."""
|
||||
if dy:
|
||||
self._gutter.scroll(0, dy)
|
||||
else:
|
||||
self._gutter.update(0, rect.y(), self._gutter.width(), rect.height())
|
||||
if rect.contains(self.viewport().rect()):
|
||||
self._update_gutter_width()
|
||||
|
||||
def resizeEvent(self, event): # noqa: N802
|
||||
"""Đổi kích thước thì đặt lại hình chữ nhật của máng số dòng."""
|
||||
super().resizeEvent(event)
|
||||
cr = self.contentsRect()
|
||||
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
|
||||
|
||||
def paint_line_numbers(self, event) -> None:
|
||||
"""Vẽ số của các dòng đang hiện trên màn, bỏ qua dòng bị gập hoặc nằm ngoài khung."""
|
||||
p = current_palette()
|
||||
painter = QPainter(self._gutter)
|
||||
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
|
||||
block = self.firstVisibleBlock()
|
||||
num = block.blockNumber()
|
||||
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
painter.setPen(QColor(p.code_gutter_fg))
|
||||
while block.isValid() and top <= event.rect().bottom():
|
||||
if block.isVisible() and bottom >= event.rect().top():
|
||||
painter.drawText(0, int(top), self._gutter.width() - 6,
|
||||
self.fontMetrics().height(), Qt.AlignRight,
|
||||
str(num + 1))
|
||||
block = block.next()
|
||||
top = bottom
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
num += 1
|
||||
|
||||
def load_file(self, path: str, text: str) -> None:
|
||||
"""Nạp nội dung tệp vào ô soạn thảo và bật tô màu theo đuôi tệp."""
|
||||
self.setPlainText(text)
|
||||
self._highlighter.set_filename(path, text)
|
||||
|
||||
|
||||
__all__ = ["CodeEditor", "PygmentsHighlighter"]
|
||||
@@ -0,0 +1,327 @@
|
||||
"""DocumentPreviewManager — the view/edit pane of the Folder Explorer
|
||||
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 295-373/
|
||||
410-449/649-699 of the original 1587-line file: the preview
|
||||
``QStackedWidget`` + open/save/create/external dispatch. HTML/PPTX/Excel/
|
||||
PDF/office rendering lives in ``office_document_renderer.py``; the code
|
||||
editor widget lives in ``code_editor.py`` — both split out to keep this file
|
||||
under the 400-line cap.
|
||||
|
||||
**Closes the R06-T05 loop**: ``application/workspaces/file_workspace_service.
|
||||
py::FileWorkspaceService`` existed since R06 but had zero production call
|
||||
sites (confirmed by grep before this task — ``ui/folder_tab.py`` wrote files
|
||||
with raw ``Path.write_text`` instead). Every plain-text write this class does
|
||||
(``save``, ``create_new_file``, ``write_content``) now goes through it —
|
||||
same path-containment check, same auto ``mkdir``, and (new, from
|
||||
``infrastructure/filesystem/file_tools.py::write_file``) a Python-syntax
|
||||
warning on a bad ``.py`` write, which the original code never had. A ``.pptx``
|
||||
save still goes through ``core/pptx_edit.py`` directly — that's a binary
|
||||
package build, not a text write, and ``FileWorkspaceService`` has no opinion
|
||||
on it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QPushButton, QScrollArea, QStackedWidget,
|
||||
QTextBrowser, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.workspaces import FileWorkspaceService
|
||||
from cowork_local.application.workspaces.file_preview_helpers import (
|
||||
is_probably_text, pptx_available, read_text,
|
||||
)
|
||||
from cowork_local.domain.workspaces.workspace_session import WorkspaceSession
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.folder.code_editor import CodeEditor
|
||||
from cowork_local.presentation.folder.office_document_renderer import OfficeDocumentRenderer
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.libreoffice_view import DOC_SUFFIXES
|
||||
|
||||
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"}
|
||||
_HTML_SUFFIXES = {".html", ".htm"}
|
||||
_PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint)
|
||||
_EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice)
|
||||
_MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only
|
||||
|
||||
|
||||
class DocumentPreviewManager(QWidget):
|
||||
"""View/edit pane: header (file name, Preview⇄Edit toggle, Save, Open
|
||||
externally) above a ``QStackedWidget`` that renders whichever preview a
|
||||
file's suffix calls for."""
|
||||
|
||||
status_message = Signal(str)
|
||||
ai_reset_requested = Signal() # a DIFFERENT file was opened by the user
|
||||
|
||||
def __init__(self, root: str, parent=None):
|
||||
"""Khung xem tài liệu.
|
||||
|
||||
Mọi lượt đọc tệp đi qua ``FileWorkspaceService`` nên không thoát ra ngoài
|
||||
thư mục gốc, kể cả khi đường dẫn có ``..``.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
self._office = OfficeDocumentRenderer(self)
|
||||
self._edit_kind: Optional[str] = None # None | "html" | "pptx"
|
||||
self._current_file: Optional[str] = None
|
||||
|
||||
rl = QVBoxLayout(self)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
self.file_label = QLabel("")
|
||||
self.file_label.setStyleSheet("font-weight:600;")
|
||||
self.file_label.setWordWrap(True)
|
||||
hdr.addWidget(self.file_label, 1)
|
||||
self.mode_btn = QPushButton() # Preview⇄Edit toggle (HTML / PPTX)
|
||||
self.mode_btn.setCheckable(True)
|
||||
self.mode_btn.clicked.connect(self._office.toggle_edit_mode)
|
||||
self.mode_btn.setVisible(False)
|
||||
hdr.addWidget(self.mode_btn)
|
||||
self.save_btn = QPushButton()
|
||||
self.save_btn.setIcon(icon("save"))
|
||||
self.save_btn.setObjectName("primary")
|
||||
self.save_btn.clicked.connect(self.save)
|
||||
self.save_btn.setVisible(False)
|
||||
hdr.addWidget(self.save_btn)
|
||||
self.ext_btn = QPushButton()
|
||||
self.ext_btn.setIcon(icon("upload"))
|
||||
self.ext_btn.clicked.connect(self.open_external)
|
||||
self.ext_btn.setVisible(False)
|
||||
hdr.addWidget(self.ext_btn)
|
||||
rl.addLayout(hdr)
|
||||
# Exposed so the shell can insert its own AI-panel toggle button into
|
||||
# this same header row (between mode_btn and save_btn, matching the
|
||||
# original single-class layout) without this class knowing the AI
|
||||
# panel exists.
|
||||
self.header_layout = hdr
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
self._placeholder = QLabel("")
|
||||
self._placeholder.setObjectName("hint")
|
||||
self._placeholder.setAlignment(Qt.AlignCenter)
|
||||
self.stack.addWidget(self._placeholder) # 0
|
||||
|
||||
self.editor = CodeEditor() # 1
|
||||
self.stack.addWidget(self.editor)
|
||||
|
||||
self.web = QTextBrowser() # 2
|
||||
self.web.setOpenExternalLinks(True)
|
||||
self.stack.addWidget(self.web)
|
||||
|
||||
self.doc_view = QTextBrowser() # 3
|
||||
self.doc_view.setObjectName("docPreview")
|
||||
self.stack.addWidget(self.doc_view)
|
||||
|
||||
self._img_scroll = QScrollArea() # 4
|
||||
self._img_scroll.setWidgetResizable(True)
|
||||
self._img_label = QLabel("")
|
||||
self._img_label.setAlignment(Qt.AlignCenter)
|
||||
self._img_scroll.setWidget(self._img_label)
|
||||
self.stack.addWidget(self._img_scroll)
|
||||
|
||||
rl.addWidget(self.stack, 1)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho các nút và ô trống."""
|
||||
self.save_btn.setText(tr("folder.save"))
|
||||
self.ext_btn.setText(tr("folder.open_external"))
|
||||
if not self._current_file:
|
||||
self._placeholder.setText(tr("folder.select_file"))
|
||||
self._retranslate_mode_btn()
|
||||
|
||||
def _retranslate_mode_btn(self) -> None:
|
||||
"""Nhãn nút chuyển chế độ đổi theo trạng thái: đang xem thì ghi "Sửa" và ngược lại."""
|
||||
self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked()
|
||||
else tr("folder.preview"))
|
||||
|
||||
# ---- public API used by the shell / AI panel --------------------------- #
|
||||
@property
|
||||
def current_file(self) -> Optional[str]:
|
||||
"""Đường dẫn tệp đang mở; ``None`` nếu chưa mở tệp nào."""
|
||||
return self._current_file
|
||||
|
||||
@property
|
||||
def edit_kind(self) -> Optional[str]:
|
||||
"""Loại nội dung đang sửa ('pptx', 'html', 'text'…); ``None`` nếu đang chỉ xem."""
|
||||
return self._edit_kind
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
"""Thư mục gốc mà mọi thao tác ghi phải nằm bên trong."""
|
||||
return self._root
|
||||
|
||||
def set_root(self, root: str) -> None:
|
||||
"""Đổi thư mục gốc và dựng lại dịch vụ ghi tệp gắn với phạm vi mới."""
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
|
||||
def open_file(self, path: str, reset_ai: bool = True) -> None:
|
||||
# Switching to a DIFFERENT file starts a fresh AI-edit conversation
|
||||
# (reset_ai=False when the AI itself just CREATED this file — keep
|
||||
# that chat). Whether/how to reset is the AI panel's own business —
|
||||
# this class only announces that a genuine file switch happened.
|
||||
"""Mở một tệp, tự chọn cách hiển thị theo đuôi và kích thước.
|
||||
|
||||
Thứ tự ưu tiên: ảnh → HTML → PowerPoint → Excel → tài liệu Office → nhị
|
||||
phân (quá lớn hoặc không phải văn bản) → mã nguồn. Đổi sang tệp KHÁC thì
|
||||
báo cho panel AI biết để nó bắt đầu hội thoại mới; ``reset_ai=False`` dùng
|
||||
khi chính AI vừa tạo ra tệp này — giữ nguyên đoạn chat đang dở.
|
||||
"""
|
||||
if reset_ai and path != self._current_file:
|
||||
self.ai_reset_requested.emit()
|
||||
self._current_file = path
|
||||
self.file_label.setText(path)
|
||||
suffix = Path(path).suffix.lower()
|
||||
self.mode_btn.setVisible(False)
|
||||
self.save_btn.setVisible(False)
|
||||
self.ext_btn.setVisible(False)
|
||||
self._edit_kind = None
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
size = 0
|
||||
|
||||
if suffix in _IMAGE_SUFFIXES:
|
||||
self._show_image(path)
|
||||
elif suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(path, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=True)
|
||||
elif suffix in _EXCEL_SUFFIXES:
|
||||
self._office.show_excel(path)
|
||||
elif suffix in DOC_SUFFIXES:
|
||||
self._office.show_document(path)
|
||||
elif size > _MAX_EDIT_BYTES or not is_probably_text(path):
|
||||
self._show_binary(path)
|
||||
else:
|
||||
self._show_code(path)
|
||||
|
||||
def ensure_editable_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._office.show_html(path, mode_preview=False)
|
||||
return True
|
||||
if suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=False)
|
||||
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 save(self) -> None:
|
||||
"""Lưu nội dung đang sửa xuống tệp; pptx ghi ngược vào bản trình chiếu."""
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._office.write_pptx(self.editor.toPlainText()):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, self.editor.toPlainText())
|
||||
self.status_message.emit(tr("folder.saved", name=Path(self._current_file).name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
|
||||
def write_content(self, content: str, skip_image_confirm: bool = False) -> None:
|
||||
"""Persist AI-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._office.write_pptx(content, skip_confirm=skip_image_confirm):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, content)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return
|
||||
suffix = Path(self._current_file).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(self._current_file, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES:
|
||||
self._office.show_pptx(self._current_file, mode_preview=True)
|
||||
|
||||
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 (enforced by ``FileWorkspaceService``/``WorkspaceSession``)."""
|
||||
root = os.path.normpath(self._root)
|
||||
dest = target if os.path.isabs(target) else os.path.join(root, target)
|
||||
dest = os.path.normpath(dest)
|
||||
try:
|
||||
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 cowork_local.core import pptx_edit
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
pptx_edit.create_pptx_from_text(dest, content)
|
||||
else:
|
||||
self._write_plain_text(dest, content)
|
||||
except Exception as exc: # noqa: BLE001 - OS error, containment error, or pptx build failure
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return None
|
||||
self.open_file(dest, reset_ai=False) # show the new file; keep the AI chat
|
||||
return dest
|
||||
|
||||
def open_external(self) -> None:
|
||||
"""Mở tệp đang xem bằng ứng dụng mặc định của hệ điều hành."""
|
||||
if self._current_file:
|
||||
from cowork_local.ui.osutil import open_location
|
||||
open_location(self._current_file)
|
||||
|
||||
# ---- writes ------------------------------------------------------------- #
|
||||
def _write_plain_text(self, path: str, content: str) -> None:
|
||||
"""Write ``content`` to ``path`` (must resolve inside the current
|
||||
root) via ``FileWorkspaceService`` — same containment check, ``mkdir``
|
||||
and Python-syntax warning the agent's own ``write_file`` tool gets."""
|
||||
rel = os.path.relpath(path, self._root)
|
||||
result = self._file_service.write_file(rel, content)
|
||||
if not result.get("ok"):
|
||||
raise OSError(result.get("output") or "write failed")
|
||||
|
||||
# ---- simple renderers (HTML/PPTX/Excel/PDF/office live in
|
||||
# office_document_renderer.py) --------------------------------------------- #
|
||||
def _show_code(self, path: str) -> None:
|
||||
"""Hiện tệp trong ô soạn thảo có tô màu cú pháp, cho phép sửa và lưu."""
|
||||
text = read_text(path)
|
||||
self.editor.setReadOnly(False)
|
||||
self.editor.load_file(path, text)
|
||||
self.save_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self.editor)
|
||||
|
||||
def _show_image(self, path: str) -> None:
|
||||
"""Hiện ảnh; ảnh hỏng hoặc không đọc được thì rơi về khung nhị phân."""
|
||||
from PySide6.QtGui import QPixmap
|
||||
pix = QPixmap(path)
|
||||
if pix.isNull():
|
||||
self._show_binary(path)
|
||||
return
|
||||
self._img_label.setPixmap(pix)
|
||||
self._img_label.resize(pix.size())
|
||||
self.ext_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self._img_scroll)
|
||||
|
||||
def _show_binary(self, path: str) -> None:
|
||||
"""Hiện thông báo "tệp nhị phân" kèm nút mở bằng ứng dụng ngoài."""
|
||||
self._placeholder.setText(tr("folder.binary_file"))
|
||||
self.ext_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self._placeholder)
|
||||
|
||||
|
||||
__all__ = ["DocumentPreviewManager"]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""FolderTab shell (R08-T12) — assembles
|
||||
``workspace_file_tree.py::WorkspaceFileTree``,
|
||||
``document_preview_manager.py::DocumentPreviewManager`` and
|
||||
``ai_file_editor_dialog.py::AiFileEditorDialog`` behind the splitter/terminal
|
||||
layout that used to be inline in ``ui/folder_tab.py::FolderTab.__init__``
|
||||
(lines 238-384 of the original 1587-line file).
|
||||
|
||||
The AI-panel toggle button (``ai_btn``) lives here because it controls
|
||||
things two different children own: the panel's own visibility AND the
|
||||
content splitter's sizing — a genuine shell-level concern, not either
|
||||
child's.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.folder.ai_file_editor_dialog import AiFileEditorDialog
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class FolderTab(QWidget):
|
||||
"""Two-pane file explorer: directory tree + view/edit pane (+ collapsible
|
||||
AI-edit panel, + collapsible terminal)."""
|
||||
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, cowork=None):
|
||||
"""Ghép ba phần của tab Thư mục: cây tệp, khung xem và terminal."""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._root = str(ctx.config.cowork_output_dir())
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
split = QSplitter(Qt.Horizontal)
|
||||
|
||||
self.tree = WorkspaceFileTree(self._root)
|
||||
split.addWidget(self.tree)
|
||||
|
||||
right = QWidget()
|
||||
rl = QVBoxLayout(right)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.preview = DocumentPreviewManager(self._root)
|
||||
self.ai_panel = AiFileEditorDialog(ctx, self.preview, cowork=cowork)
|
||||
|
||||
self.ai_btn = QPushButton() # expand/collapse the AI-edit panel
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.setCheckable(True)
|
||||
self.ai_btn.clicked.connect(self._toggle_ai_panel)
|
||||
# Same visual position as the original single-class header: between
|
||||
# the Preview⇄Edit toggle and Save (file_label=0, mode_btn=1).
|
||||
self.preview.header_layout.insertWidget(2, self.ai_btn)
|
||||
self.ai_panel.badge_changed.connect(self._on_ai_badge_changed)
|
||||
|
||||
content_split = QSplitter(Qt.Horizontal)
|
||||
content_split.addWidget(self.preview)
|
||||
content_split.addWidget(self.ai_panel)
|
||||
content_split.setStretchFactor(0, 1)
|
||||
content_split.setStretchFactor(1, 0)
|
||||
content_split.setSizes([700, 320])
|
||||
self._content_split = content_split
|
||||
self.ai_panel.setVisible(False) # default collapsed
|
||||
rl.addWidget(content_split, 1)
|
||||
|
||||
split.addWidget(right)
|
||||
split.setStretchFactor(0, 0)
|
||||
split.setStretchFactor(1, 1)
|
||||
split.setSizes([300, 800])
|
||||
root_layout.addWidget(split, 1)
|
||||
|
||||
# Terminal CLI below the file view — collapsible, default collapsed;
|
||||
# opening it points the shell at the current workspace folder.
|
||||
from cowork_local.ui.terminal_panel import TerminalPanel
|
||||
|
||||
self.terminal = TerminalPanel()
|
||||
self.terminal.set_cwd(self._root)
|
||||
self.terminal.expanded.connect(lambda: self.terminal.set_cwd(self._root))
|
||||
root_layout.addWidget(self.terminal)
|
||||
|
||||
self.tree.file_selected.connect(self.preview.open_file)
|
||||
self.preview.status_message.connect(self.status_message.emit)
|
||||
self.ai_panel.status_message.connect(self.status_message.emit)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self._retranslate()
|
||||
|
||||
# ---- public API ---------------------------------------------------------
|
||||
def set_root(self, path: str) -> None:
|
||||
"""Đổi thư mục gốc của cả ba widget con.
|
||||
|
||||
``WorkspaceFileTree`` lặng lẽ từ chối đường dẫn không hợp lệ, nên chỉ lan
|
||||
sang khung xem và terminal khi cây thật sự đã nhận.
|
||||
"""
|
||||
self.tree.set_root(path)
|
||||
# WorkspaceFileTree silently no-ops on an invalid path (same guard
|
||||
# the original single-class _root setter had) — mirror that here by
|
||||
# only propagating when the tree actually accepted it.
|
||||
if self.tree.root == path:
|
||||
self._root = path
|
||||
self.preview.set_root(path)
|
||||
self.terminal.set_cwd(path)
|
||||
|
||||
def _toggle_ai_panel(self) -> None:
|
||||
"""Gập/mở panel AI-Edit; mở ra thì báo cho panel biết để nó nạp model lần đầu."""
|
||||
show = self.ai_btn.isChecked()
|
||||
self.ai_panel.setVisible(show)
|
||||
if show:
|
||||
self._content_split.setSizes([700, 320])
|
||||
self.ai_panel.on_opened()
|
||||
|
||||
def _on_ai_badge_changed(self, suffix: str) -> None:
|
||||
"""Cập nhật huy hiệu trên nút AI-Edit (số việc đang chờ)."""
|
||||
self.ai_btn.setText(tr("folder.ai_edit") + suffix)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Chuyển lệnh dịch lại xuống cho cả ba widget con."""
|
||||
self.tree.retranslate()
|
||||
self.preview.retranslate()
|
||||
self.ai_panel.retranslate()
|
||||
self.ai_btn.setText(tr("folder.ai_edit"))
|
||||
self.ai_btn.setToolTip(tr("folder.ai_edit_tooltip"))
|
||||
|
||||
|
||||
__all__ = ["FolderTab"]
|
||||
@@ -0,0 +1,289 @@
|
||||
"""OfficeDocumentRenderer — HTML/PPTX/Excel/PDF/office-doc preview for
|
||||
``document_preview_manager.py`` (R08-T12, split out to keep that file under
|
||||
the 400-line cap; originally ``ui/folder_tab.py``, lines 451-647/679-694 of
|
||||
the original 1587-line file).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY a ``DocumentPreviewManager``
|
||||
rather than a widget of its own: these renderers are tightly coupled to the
|
||||
manager's shared ``QStackedWidget``/toolbar/editor — genuinely one screen's
|
||||
internal state, not an independent concern — so this is a composition split
|
||||
to respect the line-count cap, the same way
|
||||
``presentation/scheduling/kanban_board_widget.py`` composes
|
||||
``TaskApplicationService`` rather than owning that logic inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWidgets import QTabWidget, QTableWidget, QTableWidgetItem
|
||||
|
||||
from cowork_local.application.workspaces.file_preview_helpers import read_text
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
|
||||
try:
|
||||
from PySide6.QtPdf import QPdfDocument # noqa: F401
|
||||
from PySide6.QtPdfWidgets import QPdfView # noqa: F401
|
||||
HAS_PDF = True
|
||||
except Exception: # pragma: no cover - QtPdf not bundled
|
||||
HAS_PDF = False
|
||||
|
||||
|
||||
class OfficeDocumentRenderer:
|
||||
"""Renders HTML/PPTX/Excel/PDF/office docs into ``owner.stack``.
|
||||
|
||||
``owner`` is the ``DocumentPreviewManager`` — this class reaches into
|
||||
``owner.stack``/``owner.editor``/``owner.mode_btn``/``owner.ext_btn``/
|
||||
``owner.save_btn``/``owner.doc_view``/``owner.web`` because those widgets
|
||||
are shared with the manager's simpler renderers (code/image/binary);
|
||||
duplicating them here would mean two stacked widgets fighting over which
|
||||
one is "the" preview.
|
||||
"""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
"""Bộ hiện tài liệu Office.
|
||||
|
||||
Có cache PDF vì mỗi lần chuyển đổi phải gọi LibreOffice — xem lại cùng một
|
||||
tệp mà chuyển lại từ đầu thì chờ vài giây mỗi lượt.
|
||||
"""
|
||||
self._owner = owner
|
||||
self._engine = None
|
||||
self._pdf_view = None
|
||||
self._pdf_doc = None
|
||||
self._pdf_tmp: Optional[str] = None
|
||||
self._pdf_cache: dict = {}
|
||||
self._convert_worker = None
|
||||
self._xlsx_view = None
|
||||
|
||||
def show_html(self, path: str, mode_preview: bool) -> None:
|
||||
"""Hiện tệp HTML: xem đã dựng hình hoặc sửa mã nguồn, tuỳ ``mode_preview``."""
|
||||
o = self._owner
|
||||
o._edit_kind = "html"
|
||||
o.mode_btn.setVisible(True)
|
||||
o.mode_btn.setChecked(not mode_preview) # checked = Edit
|
||||
o._retranslate_mode_btn()
|
||||
if mode_preview:
|
||||
from PySide6.QtCore import QUrl
|
||||
html = read_text(path)
|
||||
engine = self._ensure_engine()
|
||||
if engine is not None:
|
||||
engine.setHtml(html, QUrl.fromLocalFile(path))
|
||||
o.stack.setCurrentWidget(engine)
|
||||
else:
|
||||
o.web.setHtml(html)
|
||||
o.stack.setCurrentWidget(o.web)
|
||||
o.save_btn.setVisible(False)
|
||||
else:
|
||||
o._show_code(path)
|
||||
|
||||
def show_pptx(self, path: str, mode_preview: bool) -> None:
|
||||
"""PPTX: Preview renders the slides (PDF via LibreOffice); Edit shows the
|
||||
deck's text (marker-delimited per box) in the editor."""
|
||||
o = self._owner
|
||||
o._edit_kind = "pptx"
|
||||
o.mode_btn.setVisible(True)
|
||||
o.mode_btn.setChecked(not mode_preview) # checked = Edit
|
||||
o._retranslate_mode_btn()
|
||||
o.ext_btn.setVisible(True)
|
||||
if mode_preview:
|
||||
self.show_document(path) # PDF render of the slides
|
||||
o.mode_btn.setVisible(True) # show_document doesn't touch it
|
||||
else:
|
||||
from cowork_local.core.pptx_edit import pptx_to_text
|
||||
try:
|
||||
text = pptx_to_text(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text = f"[could not read pptx text: {exc}]"
|
||||
o.editor.setReadOnly(False)
|
||||
o.editor.load_file(path + ".txt", text) # .txt → plain highlighting
|
||||
o.save_btn.setVisible(True)
|
||||
o.stack.setCurrentWidget(o.editor)
|
||||
|
||||
def _ensure_engine(self):
|
||||
"""Create the QWebEngineView on first HTML preview (only when WebEngine
|
||||
is safe to use); otherwise stay on the QTextBrowser fallback."""
|
||||
if not HAS_WEB_ENGINE:
|
||||
return None
|
||||
if self._engine is None:
|
||||
try:
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
self._engine = QWebEngineView()
|
||||
self._owner.stack.addWidget(self._engine)
|
||||
except Exception: # noqa: BLE001
|
||||
self._engine = None
|
||||
return self._engine
|
||||
|
||||
def toggle_edit_mode(self) -> None:
|
||||
"""Lật giữa Xem và Sửa cho tệp đang mở (HTML và PowerPoint)."""
|
||||
o = self._owner
|
||||
if not o.current_file:
|
||||
return
|
||||
preview = not o.mode_btn.isChecked() # checked = Edit
|
||||
if o._edit_kind == "pptx":
|
||||
self.show_pptx(o.current_file, mode_preview=preview)
|
||||
else:
|
||||
self.show_html(o.current_file, mode_preview=preview)
|
||||
|
||||
def show_excel(self, path: str) -> None:
|
||||
"""View a spreadsheet as a real TABLE (openpyxl) — one tab per sheet."""
|
||||
o = self._owner
|
||||
o.ext_btn.setVisible(True)
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
ensure_module("openpyxl", "openpyxl")
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
except Exception: # noqa: BLE001 - no openpyxl / unreadable → try PDF/text
|
||||
self.show_document(path)
|
||||
return
|
||||
MAX_ROWS, MAX_COLS = 2000, 100
|
||||
if self._xlsx_view is None:
|
||||
self._xlsx_view = QTabWidget()
|
||||
o.stack.addWidget(self._xlsx_view)
|
||||
tabs = self._xlsx_view
|
||||
while tabs.count():
|
||||
w = tabs.widget(0); tabs.removeTab(0); w.deleteLater()
|
||||
try:
|
||||
for ws in wb.worksheets:
|
||||
rows = list(ws.iter_rows(max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True))
|
||||
ncols = max((len(r) for r in rows), default=0)
|
||||
table = QTableWidget(len(rows), ncols)
|
||||
table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
table.horizontalHeader().setVisible(False)
|
||||
for r, row in enumerate(rows):
|
||||
for c, val in enumerate(row):
|
||||
if val is not None:
|
||||
table.setItem(r, c, QTableWidgetItem(str(val)))
|
||||
table.resizeColumnsToContents()
|
||||
title = ws.title + (" (…)" if (ws.max_row or 0) > MAX_ROWS
|
||||
or (ws.max_column or 0) > MAX_COLS else "")
|
||||
tabs.addTab(table, title)
|
||||
finally:
|
||||
wb.close()
|
||||
if tabs.count() == 0:
|
||||
self.show_document(path)
|
||||
return
|
||||
o.stack.setCurrentWidget(tabs)
|
||||
|
||||
def show_document(self, path: str) -> None:
|
||||
"""Office docs + PDF are RENDERED via QtPdf — LibreOffice converts
|
||||
them to PDF first. Falls back to text extraction when QtPdf/
|
||||
LibreOffice aren't available."""
|
||||
o = self._owner
|
||||
o.ext_btn.setVisible(True)
|
||||
suffix = Path(path).suffix.lower()
|
||||
if not HAS_PDF:
|
||||
self.show_document_text(path)
|
||||
return
|
||||
if suffix == ".pdf":
|
||||
self._render_pdf(path)
|
||||
return
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
mtime = 0
|
||||
cached = self._pdf_cache.get((path, mtime))
|
||||
if cached and os.path.exists(cached):
|
||||
self._render_pdf(cached)
|
||||
return
|
||||
from cowork_local.core.doc_extract import convert_to_pdf, find_soffice
|
||||
if not find_soffice() and os.name != "nt":
|
||||
self.show_document_text(path)
|
||||
return
|
||||
o.doc_view.setPlainText(tr("folder.converting"))
|
||||
o.stack.setCurrentWidget(o.doc_view)
|
||||
if self._pdf_tmp is None:
|
||||
import tempfile
|
||||
self._pdf_tmp = tempfile.mkdtemp(prefix="cowork_folder_pdf_")
|
||||
src, out_dir = path, self._pdf_tmp
|
||||
|
||||
def job(worker):
|
||||
"""Chạy nền: chuyển tài liệu Office sang PDF bằng LibreOffice."""
|
||||
return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)}
|
||||
|
||||
def done(result):
|
||||
"""Hiện PDF vừa chuyển và ghi vào bộ nhớ đệm theo (đường dẫn, thời điểm sửa).
|
||||
|
||||
Bỏ kết quả nếu người dùng đã chuyển sang tệp khác trong lúc chờ chuyển đổi.
|
||||
"""
|
||||
if result.get("src") != o.current_file:
|
||||
return # user moved on to another file
|
||||
pdf = result.get("pdf")
|
||||
if pdf:
|
||||
self._pdf_cache[(result["src"], result["mtime"])] = pdf
|
||||
self._render_pdf(pdf)
|
||||
else:
|
||||
self.show_document_text(src)
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(done)
|
||||
worker.failed.connect(lambda _e, p=src: self.show_document_text(p))
|
||||
self._convert_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _ensure_pdf_view(self):
|
||||
"""Dựng khung xem PDF một lần duy nhất; không có ``QtPdf`` thì trả ``None``
|
||||
để chỗ gọi rơi về cách hiện văn bản thuần.
|
||||
"""
|
||||
if not HAS_PDF:
|
||||
return None
|
||||
if self._pdf_view is None:
|
||||
from PySide6.QtPdf import QPdfDocument
|
||||
from PySide6.QtPdfWidgets import QPdfView
|
||||
self._pdf_doc = QPdfDocument(self._owner)
|
||||
self._pdf_view = QPdfView(self._owner)
|
||||
self._pdf_view.setDocument(self._pdf_doc)
|
||||
try:
|
||||
self._pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
|
||||
self._pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
except Exception: # noqa: BLE001 - enum names vary slightly across versions
|
||||
pass
|
||||
self._owner.stack.addWidget(self._pdf_view)
|
||||
return self._pdf_view
|
||||
|
||||
def _render_pdf(self, pdf_path: str) -> None:
|
||||
"""Nạp và hiện một tệp PDF; thiếu ``QtPdf`` thì rơi về trích văn bản."""
|
||||
view = self._ensure_pdf_view()
|
||||
if view is None:
|
||||
self.show_document_text(pdf_path)
|
||||
return
|
||||
self._pdf_doc.load(pdf_path)
|
||||
self._owner.stack.setCurrentWidget(view)
|
||||
|
||||
def show_document_text(self, path: str) -> None:
|
||||
"""Cách dự phòng cuối: trích văn bản từ tài liệu và hiện dưới dạng chữ thuần.
|
||||
|
||||
Dùng khi không có LibreOffice để chuyển PDF, hoặc không có khung xem PDF.
|
||||
"""
|
||||
from cowork_local.core.doc_extract import extract_text
|
||||
o = self._owner
|
||||
try:
|
||||
text, note = extract_text(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text, note = None, str(exc)
|
||||
body = text if text else tr("folder.doc_unreadable", note=note or "?")
|
||||
o.doc_view.setPlainText(body)
|
||||
o.stack.setCurrentWidget(o.doc_view)
|
||||
|
||||
def write_pptx(self, content: str, skip_confirm: bool = False) -> bool:
|
||||
"""Write edited pptx text back into the deck. If the edit REPLACES any
|
||||
image, ask the user to confirm first. ``skip_confirm`` is used when
|
||||
the image was already confirmed (e.g. just generated). Returns False
|
||||
if the user declined."""
|
||||
from cowork_local.core import pptx_edit
|
||||
o = self._owner
|
||||
if not skip_confirm and pptx_edit.image_change_requested(content):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm"))
|
||||
if ok != QMessageBox.Yes:
|
||||
o.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return False
|
||||
pptx_edit.apply_text_to_pptx(o.current_file, content)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["OfficeDocumentRenderer", "HAS_PDF"]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""WorkspaceFileTree — the folder-picker bar + directory tree pane of the
|
||||
Folder Explorer (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
|
||||
lines 264-293/386-408 of the original 1587-line file).
|
||||
|
||||
Owns navigation only: which root is browsed and which file was clicked.
|
||||
Rendering/editing the SELECTED file is
|
||||
``document_preview_manager.py::DocumentPreviewManager``'s job — this widget
|
||||
just emits :attr:`file_selected`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog, QFileSystemModel, QHBoxLayout, QLabel, QPushButton,
|
||||
QTreeView, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class WorkspaceFileTree(QWidget):
|
||||
"""The left-hand tree pane: a path bar (label + "open folder" button)
|
||||
above a ``QFileSystemModel``-backed ``QTreeView``."""
|
||||
|
||||
file_selected = Signal(str) # absolute path of the clicked file
|
||||
root_changed = Signal(str) # absolute path of the new root
|
||||
|
||||
def __init__(self, initial_root: str, parent=None):
|
||||
"""Cây tệp của thư mục làm việc, có ô đường dẫn ở trên làm tiêu đề màn hình."""
|
||||
super().__init__(parent)
|
||||
self._root = initial_root
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# The path IS the title of this screen, so it is written as one
|
||||
# rather than shown in a read-only text box that looks editable.
|
||||
# Full path on hover; the button still opens the folder picker.
|
||||
bar = QHBoxLayout()
|
||||
self.path_lbl = QLabel(self._root)
|
||||
self.path_lbl.setObjectName("folderTitle")
|
||||
self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.path_lbl.setToolTip(self._root)
|
||||
self._open_btn = QPushButton()
|
||||
self._open_btn.setIcon(icon("folder"))
|
||||
self._open_btn.setObjectName("primary")
|
||||
self._open_btn.clicked.connect(self._pick_root)
|
||||
bar.addWidget(self.path_lbl, 1)
|
||||
bar.addWidget(self._open_btn)
|
||||
root_layout.addLayout(bar)
|
||||
|
||||
self.model = QFileSystemModel()
|
||||
self.model.setRootPath(self._root)
|
||||
self.tree = QTreeView()
|
||||
self.tree.setModel(self.model)
|
||||
self.tree.setRootIndex(self.model.index(self._root))
|
||||
for col in (1, 2, 3): # hide Size / Type / Date-modified columns
|
||||
self.tree.hideColumn(col)
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.clicked.connect(self._on_tree_clicked)
|
||||
root_layout.addWidget(self.tree, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn."""
|
||||
self._open_btn.setToolTip(tr("folder.path_placeholder"))
|
||||
self._open_btn.setText(tr("folder.open_folder"))
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
"""Thư mục gốc đang hiện."""
|
||||
return self._root
|
||||
|
||||
def set_root(self, path: str) -> None:
|
||||
"""Đổi thư mục gốc; đường dẫn rỗng hoặc không tồn tại thì BỎ QUA lặng lẽ.
|
||||
|
||||
Chỗ gọi dựa vào việc ``root`` không đổi để biết cây đã từ chối đường dẫn.
|
||||
"""
|
||||
p = str(path or "").strip()
|
||||
if not p or not os.path.isdir(p):
|
||||
return
|
||||
self._root = p
|
||||
self.path_lbl.setText(p)
|
||||
self.path_lbl.setToolTip(p)
|
||||
self.model.setRootPath(p)
|
||||
self.tree.setRootIndex(self.model.index(p))
|
||||
self.root_changed.emit(p)
|
||||
|
||||
def _pick_root(self) -> None:
|
||||
"""Mở hộp thoại chọn thư mục gốc."""
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("folder.open_folder"), self._root)
|
||||
if chosen:
|
||||
self.set_root(chosen)
|
||||
|
||||
def _on_tree_clicked(self, index) -> None:
|
||||
"""Bấm vào một mục: là tệp thì phát tín hiệu mở, là thư mục thì để cây tự bung."""
|
||||
path = self.model.filePath(index)
|
||||
if path and os.path.isfile(path):
|
||||
self.file_selected.emit(path)
|
||||
|
||||
|
||||
__all__ = ["WorkspaceFileTree"]
|
||||
Reference in New Issue
Block a user