feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView
Team Hoa, EPIC R08 (UI/Application Separation) - Team Hoa scope only
(R08-T11 -> T14; R08-T01->T10 belong to Team Duy/Team Nam).
- R08-T11: ui/schedule_task_tab.py (795 lines) -> presentation/scheduling/
{kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,
ai_task_import_dialog,run_history_dialog}.py + schedule_task_tab.py
shell. Kanban CRUD/drag-drop now goes through
application/scheduling/task_application_service.py (R07-T04) instead of
~30 lines of inline if/elif per drag target.
- R08-T12: ui/folder_tab.py (1587 lines, the largest of the four) ->
presentation/folder/{workspace_file_tree,document_preview_manager,
code_editor,office_document_renderer,ai_file_editor_dialog,
ai_edit_model_resolver,ai_edit_pipeline}.py + folder_tab.py shell.
Closes the R06-T05 loop: FileWorkspaceService existed since R06 with
zero production call sites (confirmed by grep); every plain-text write
(save/create/write_content) now goes through it, gaining path
containment and a Python-syntax warning the original code never had.
Pure helpers (_read_text, _is_probably_text, _pptx_available,
_split_code_block, _parse_ai_output) moved to
application/workspaces/{file_preview_helpers,ai_edit_output}.py.
- R08-T13: ui/dashboard_tab.py (437 lines) -> presentation/dashboard/
{token_usage_card_widget,usage_chart_widget,habits_widget}.py +
dashboard_tab.py shell, backed by a new
application/monitoring/dashboard_query_service.py (pricing/period/
summary queries the three widgets used to each recompute separately).
Directory-ownership note left in the checklist for Team Nam.
- R08-T14: ui/structure_graph_view.py (1035 lines) ->
presentation/graph/{graph_scene_items,graph_renderer,
graph_messages_view,graph_qa_widget}.py + structure_graph_view.py
shell. Extraction helpers (_pdf_to_markdown, _extract_file_contents)
moved to application/workspaces/graph_index_service.py (pure Python).
Renderer and Q&A panel talk only through signals
(node_selected/graph_rendered/raw_json_ready/project_changed) - neither
imports the other.
- presentation/shared/web_engine_support.py: HAS_WEB_ENGINE, previously
duplicated (folder_tab imported it FROM structure_graph_view.py) - now
one shared flag instead of one screen importing another screen's module.
All four old ui/*.py files deleted; app.py and ui/workspace_tab.py updated
to the new import paths (each god-file only had 1-2 real construction
sites, so import sites were updated directly rather than kept as a
strangler-fig shim - unlike core/tools.py at R05, which had dozens).
pytest: 377 pass (+94 vs the R07 baseline of 328; same 4 pre-existing
failures as the R05/R06 baseline, unrelated to this work).
scripts/check_imports.py: PASS. python -c "import cowork_local.app": OK.
Every new file < 400 lines (largest: graph_renderer.py, 391).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
"""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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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):
|
||||
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:
|
||||
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:
|
||||
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):
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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):
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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"]
|
||||
Reference in New Issue
Block a user