From a71085b39e759912b3b0a649f82b926b19c156cb Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Sun, 30 Aug 2026 10:36:31 +0900 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20xo=C3=A1=201.400=20d=C3=B2ng=20?= =?UTF-8?q?m=C3=A3=20ch=E1=BA=BFt=20c=C3=B2n=20s=C3=B3t=20sau=20merge=20v?= =?UTF-8?q?=C3=A0=205=20g=C3=B3i=20r=E1=BB=97ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hai bản tách song song của cùng một god-file cùng được giữ lại sau một lần merge. Bản chết không ai import, và hai file trong đó còn không import nổi: `graph_render.py` lấy `GraphQaMixin` không tồn tại, `task_actions.py` lấy `ui.calendar_view` đã bị xoá. Kèm theo 5 gói chỉ có `__init__.py` với docstring hứa những module chưa bao giờ được tạo. Hai trong số đó (`adapters/qt/`, `infrastructure/platform/qt/`) là vị trí đã bị bác bỏ có ghi lý do — `QtSchedulerClock` nằm ở `infrastructure/qt/`, và lý do vì sao không đặt ở `platform/` vẫn còn nguyên trong `infrastructure/qt/__init__.py`. Không cổng nào bắt được đám này: file không ai import vẫn đúng chiều phụ thuộc, vẫn sạch credential, vẫn dưới 400 dòng. Cổng O ở commit sau đi tìm đúng khoảng trống đó. Co-Authored-By: Claude Opus 5 (1M context) --- adapters/__init__.py | 12 - adapters/qt/__init__.py | 0 application/settings/__init__.py | 1 - infrastructure/platform/__init__.py | 1 - infrastructure/platform/qt/__init__.py | 1 - presentation/folder/ai_edit_runner.py | 325 ---------------------- presentation/folder/ai_output_writer.py | 140 ---------- presentation/folder/file_helpers.py | 114 -------- presentation/folder/image_model_picker.py | 115 -------- presentation/graph/graph_project.py | 109 -------- presentation/graph/graph_render.py | 227 --------------- presentation/graph/graph_scene.py | 138 --------- presentation/graph/graph_web.py | 38 --- presentation/scheduling/task_actions.py | 194 ------------- 14 files changed, 1415 deletions(-) delete mode 100644 adapters/__init__.py delete mode 100644 adapters/qt/__init__.py delete mode 100644 application/settings/__init__.py delete mode 100644 infrastructure/platform/__init__.py delete mode 100644 infrastructure/platform/qt/__init__.py delete mode 100644 presentation/folder/ai_edit_runner.py delete mode 100644 presentation/folder/ai_output_writer.py delete mode 100644 presentation/folder/file_helpers.py delete mode 100644 presentation/folder/image_model_picker.py delete mode 100644 presentation/graph/graph_project.py delete mode 100644 presentation/graph/graph_render.py delete mode 100644 presentation/graph/graph_scene.py delete mode 100644 presentation/graph/graph_web.py delete mode 100644 presentation/scheduling/task_actions.py diff --git a/adapters/__init__.py b/adapters/__init__.py deleted file mode 100644 index c84fa5b..0000000 --- a/adapters/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""adapters/ — Adapter riêng cho Qt (clock, thread, timer). - -Kế hoạch gốc đặt tên thư mục này là ``platform/``. Không dùng được: chạy -bất kỳ script nào từ thư mục gốc repo (``python tools/...``, -``python scripts/...``) thì ``platform/`` **che khuất module ``platform`` -của thư viện chuẩn**, và ``import keyring`` chết ngay với -``AttributeError: module 'platform' has no attribute 'system'``. -Repo có 26 script chạy đúng kiểu đó. - -Đổi tên là cách duy nhất chắc chắn — không thể bắt mọi người nhớ "đừng bao -giờ chạy python từ thư mục gốc". -""" diff --git a/adapters/qt/__init__.py b/adapters/qt/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/application/settings/__init__.py b/application/settings/__init__.py deleted file mode 100644 index c759b25..0000000 --- a/application/settings/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Application settings package: Settings application service.""" diff --git a/infrastructure/platform/__init__.py b/infrastructure/platform/__init__.py deleted file mode 100644 index 9115969..0000000 --- a/infrastructure/platform/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Infrastructure platform adapters package.""" diff --git a/infrastructure/platform/qt/__init__.py b/infrastructure/platform/qt/__init__.py deleted file mode 100644 index ca02309..0000000 --- a/infrastructure/platform/qt/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Infrastructure Qt platform adapters: QtSchedulerClock.""" diff --git a/presentation/folder/ai_edit_runner.py b/presentation/folder/ai_edit_runner.py deleted file mode 100644 index 088d6a0..0000000 --- a/presentation/folder/ai_edit_runner.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Một lượt AI sửa file, từ lúc gửi tới lúc ghi ra đĩa — R08-T12. - -``_ai_run_edit`` dài (82 dòng) vì nó là cả một lượt: dựng ngữ cảnh từ -file đang mở, gọi provider, nhận nội dung phát dần, tách phần mã khỏi -phần giải thích, rồi dựng bản xem trước. - -Không bao giờ ghi đè thẳng: kết quả hiện ra để người dùng xem, và chỉ -``_ai_apply`` mới chạm vào file. - -Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. -""" -from __future__ import annotations - -from .file_helpers import ( - _HTML_SUFFIXES, _PPTX_SUFFIXES, _parse_ai_output, _pptx_available, -) - -import os -from pathlib import Path -from typing import Optional -from PySide6.QtCore import Qt -from ...core.worker import AgentWorker -from ...i18n import tr -from ...theme import current_palette - - -class AiEditRunnerMixin: - """Trộn vào FolderTab.""" - - def _ai_send(self) -> None: - if not self._root or not os.path.isdir(self._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 when the pipeline goes idle. Lets - # the user line up several edits without waiting for each to finish. - if self._ai_worker is not None or self._ai_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._ai_start(instruction) - - def _ai_start(self, instruction: str) -> None: - """Begin processing one instruction (plan → edit). Assumes the pipeline - is idle (the queue calls this when the previous run finishes).""" - # If a text/code/HTML file is open (even in Preview), switch it into the - # editor so AI can edit it. If nothing editable is open, that's fine — - # the request may be to CREATE a new file (the model names it via FILE:). - editable = self.stack.currentWidget() is self.editor - if not editable: - editable = self._ensure_editor_for_ai() - self._maybe_suggest_image_model(instruction) - # Auto Model Routing (may switch to the best coding model for this run). - self._ai_apply_routing(instruction) - has_file = editable and bool(self._current_file) - self._ai_running_file = Path(self._current_file).name if has_file else tr("folder.ai_new_file") - self._ai_set_busy(True) - # Announce start on the status bar so it's visible even from another tab — - # the edit keeps running in the background until it finishes. - self.status_message.emit(tr("folder.ai_running", name=self._ai_running_file)) - # Two phases so the PLAN is shown INLINE *before* the edit runs. - self._ai_ctx = { - "filename": Path(self._current_file).name if has_file else "", - "content": self.editor.toPlainText() if has_file else "", - "convo": self._cowork_context(), - "instruction": instruction, - "provider": self._ai_provider(), - "plan": "", - } - # Reset the token/cost tally for THIS prompt (plan + edit calls sum into it). - self._ai_prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0} - self._ai_run_plan() - - def _ai_maybe_dequeue(self) -> None: - """When the pipeline is fully idle, start the next queued instruction.""" - if self._ai_worker is not None or self._ai_pending is not None: - return - if not self._ai_queue: - return - nxt = self._ai_queue.pop(0) - self._update_queue_status() - self._ai_start(nxt) - - def _ai_add_usage(self, usage) -> None: - """Add one model call's usage (plan or edit) to THIS prompt's tally.""" - if not isinstance(usage, dict): - return - tot = getattr(self, "_ai_prompt_usage", None) - if tot is None: - tot = self._ai_prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0} - 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 _ai_show_usage(self, bubble) -> None: - """Footer under the AI-edit reply: ↓in ↑out ▤ctx $cost for the whole - prompt (plan + edit), priced in the display currency — same as Cowork.""" - tot = getattr(self, "_ai_prompt_usage", None) - if bubble is None or not tot or not (tot["in"] or tot["out"]): - return - from ...core import model_pricing as mp, usage_tracker as ut - pricing = {**ut.DEFAULT_PRICING, **(self.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 - - def _ai_run_plan(self) -> None: - c = self._ai_ctx - plan_bubble = self.ai_chat.add_plan(tr("folder.ai_planning")) - self.ai_chat.scroll_to_bottom() - - def job(worker): - from ...core import usage_tracker as ut - from ...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") # Dashboard + usage - 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, self.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._ai_plan_done(res, b)) - worker.failed.connect(lambda err, b=plan_bubble: self._ai_failed(err, b)) - self._ai_worker = worker - worker.start() - - def _ai_plan_done(self, result, plan_bubble) -> None: - self._ai_add_usage((result or {}).get("usage")) # plan-step tokens - plan = ((result or {}).get("plan") or "").strip() - self._ai_ctx["plan"] = plan - plan_bubble.set_plain(plan or tr("folder.ai_empty")) - self.ai_chat.scroll_to_bottom() - self._ai_run_edit() # now execute the plan - - def _ai_run_edit(self) -> None: - c = self._ai_ctx - bubble = self.ai_chat.add_assistant(tr("folder.ai_edit")) - self.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 self._edit_kind == "pptx" else "" - - # When creating a NEW deck (request mentions slides/pptx and we're not - # already editing one), tell the model the marker format to emit so we can - # build a real .pptx from it. - _pptx_words = ("pptx", "powerpoint", "slide", "presentation", "deck", - "スライド", "プレゼン", "trình chiếu", "trinh chieu", "bài thuyết trình") - wants_new_pptx = (self._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: .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 ...core import image_gen - if image_gen.is_configured(self.ctx.config): - imggen_note = ("\nYou can also GENERATE an illustration image: add a line " - "`IMAGE_GEN: => `. 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 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 ...core import usage_tracker as ut - from ...core.co4e_runner import _usage_delta - ut.set_context("folder", c.get("filename") or "AI edit") # Dashboard + usage - 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, self.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._ai_stream(ev, b)) - worker.finished_ok.connect(lambda res, b=bubble: self._ai_done(res, b)) - worker.failed.connect(lambda err, b=bubble: self._ai_failed(err, b)) - self._ai_worker = worker - worker.start() - - def _ai_stream(self, ev, bubble) -> None: - if isinstance(ev, dict) and ev.get("type") == "text": - bubble.append_delta(ev.get("delta", "")) - self.ai_chat.scroll_to_bottom() - - def _ai_done(self, result, bubble) -> None: - self._ai_worker = None - self._ai_set_busy(False) - self._ai_add_usage((result or {}).get("usage")) # edit-step tokens - self._ai_show_usage(bubble) # footer: prompt total (plan+edit) - 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")) - self.ai_chat.scroll_to_bottom() - self._ai_flag_done() - return - # Decide edit-current vs create-new. A FILE: naming a path different from - # the open file (or when nothing is open) → CREATE a new file. - create = bool(target) and (not self._current_file - or Path(target).name != Path(self._current_file).name) - # PROPOSE the change — nothing is written until the user clicks Apply. - self._ai_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: - import difflib - old = "" if create else self.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") - self.ai_chat.add_diff(title, diff) - if image_gens: - listing = "\n".join(f"• {p} ({prompt[:60]})" for prompt, p in image_gens) - self.ai_chat.add_status(tr("folder.ai_image_plan") + "\n" + listing) - self._ai_confirm_row.setVisible(True) - self.ai_chat.scroll_to_bottom() - name = target if create else getattr(self, "_ai_running_file", "") - self.status_message.emit(tr("folder.ai_proposed_status", name=name)) - self._ai_status.setText("● " + hint) - self._ai_status.setStyleSheet(f"color:{current_palette().warning};") - - def _ai_apply(self) -> None: - """Confirmed by the user. If the edit GENERATES images, ask the image - gate then generate them (off-thread) before finalising the file edit.""" - if not self._ai_pending: - return - p = self._ai_pending - self._ai_pending = None - self._ai_confirm_row.setVisible(False) - if p.get("image_gens"): - from PySide6.QtWidgets import QMessageBox - if QMessageBox.question(self, tr("folder.ai_image_confirm_title"), - tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes: - self.status_message.emit(tr("folder.ai_image_declined")) - return - self._ai_generate_then_finalize(p) - return - self._ai_finalize_apply(p) - - - - - - def _ai_discard(self) -> None: - self._ai_pending = None - self._ai_confirm_row.setVisible(False) - self.ai_chat.add_status(tr("folder.ai_discarded")) - self.ai_chat.scroll_to_bottom() - self._ai_status.setText("") - self._ai_maybe_dequeue() # discarding resolves the gate → run the next queued edit - - - def _ai_failed(self, err, bubble) -> None: - self._ai_worker = None - bubble.set_markdown(tr("folder.ai_error", err=err)) - self._ai_set_busy(False) - self.status_message.emit(tr("folder.ai_error", err=err)) - self._ai_flag_done() diff --git a/presentation/folder/ai_output_writer.py b/presentation/folder/ai_output_writer.py deleted file mode 100644 index 6581853..0000000 --- a/presentation/folder/ai_output_writer.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Ghi kết quả AI ra đĩa — R08-T12. - -Tách khỏi ``ai_edit_runner.py`` vì đây là phần DUY NHẤT thật sự chạm vào -file của người dùng. Mọi thứ trước nó chỉ dựng bản xem trước. - -Gồm cả nhánh sinh ảnh: lượt nào có ảnh thì phải chờ ảnh xong mới ghi, vì -nội dung có thể tham chiếu tới đường dẫn ảnh vừa tạo. -""" -from __future__ import annotations - -from .file_helpers import ( - _HTML_SUFFIXES, _PPTX_SUFFIXES, _parse_ai_output, _pptx_available, -) -import os -from pathlib import Path -from typing import Optional -from PySide6.QtCore import Qt -from ...core.worker import AgentWorker -from ...i18n import tr -from ...theme import current_palette - - -class AiOutputWriterMixin: - """Trộn vào FolderTab.""" - - def _ai_generate_then_finalize(self, p: dict) -> None: - imgs = p.get("image_gens") or [] - root = os.path.normpath(self._root) - img_model, img_base, img_key = self._ai_image_model() # may target another provider - self._ai_set_busy(True) - self.status_message.emit(tr("folder.ai_generating")) - - def job(worker): - from ...core import image_gen - results = [] - for prompt, rel in imgs: - dest = rel if os.path.isabs(rel) else os.path.join(root, rel) - dest = os.path.normpath(dest) - if os.path.commonpath([dest, root]) != root: - results.append((rel, False, "path escapes the folder")) - continue - try: - os.makedirs(os.path.dirname(dest) or root, exist_ok=True) - except OSError as exc: - results.append((rel, False, str(exc))) - continue - ok, msg = image_gen.generate_image(self.ctx.config, prompt, dest, - model=img_model, base_url=img_base, api_key=img_key) - results.append((dest, ok, msg)) - return {"results": results} - - worker = AgentWorker(job) - worker.finished_ok.connect(lambda res, pp=p: self._ai_images_done(res, pp)) - worker.failed.connect(lambda err, pp=p: self._ai_images_done({"results": [], "err": err}, pp)) - self._ai_worker = worker - worker.start() - - def _ai_images_done(self, res: dict, p: dict) -> None: - self._ai_worker = None - self._ai_set_busy(False) - created = [] - for dest, ok, msg in res.get("results", []): - if ok: - created.append(dest) - self.ai_chat.add_success("✓ " + tr("folder.ai_image_created", name=Path(dest).name)) - else: - self.ai_chat.add_error(tr("folder.ai_image_failed", err=msg)) - # Now apply any text/file edit (pptx image: fields now point at real files). - self._ai_finalize_apply(p, images_done=True) - # If it was only image generation, open the first new image. - if p.get("content") is None and not p.get("target") and created: - self.open_file(created[0], reset=False) - - def _ai_finalize_apply(self, p: dict, images_done: bool = False) -> None: - content = p.get("content") - target = p.get("target") - if content is None: - self.ai_chat.scroll_to_bottom() - self._ai_flag_done() - self.status_message.emit(tr("folder.ai_done", name=getattr(self, "_ai_running_file", ""))) - return - if target: - dest = self._create_new_file(target, content) - if dest is None: - return - self.ai_chat.add_success("✓ " + tr("folder.ai_created", name=Path(dest).name)) - self.status_message.emit(tr("folder.ai_created", name=Path(dest).name)) - else: - self.editor.setPlainText(content) # live update in the editor/preview - self._ai_write_out(content, skip_image_confirm=images_done) - self.ai_chat.add_success("✓ " + tr("folder.ai_applied")) - self.status_message.emit(tr("folder.ai_done", name=getattr(self, "_ai_running_file", ""))) - self.ai_chat.scroll_to_bottom() - self._ai_flag_done() - - def _create_new_file(self, target: str, content: str) -> Optional[str]: - """Create ``target`` (relative to the folder root) with ``content`` and - open it — like Cowork's save_file. Refuses paths escaping the root.""" - root = os.path.normpath(self._root) - dest = target if os.path.isabs(target) else os.path.join(root, target) - dest = os.path.normpath(dest) - if os.path.commonpath([dest, root]) != root: - self.status_message.emit(tr("folder.ai_error", err="path escapes the folder")) - return None - try: - os.makedirs(os.path.dirname(dest) or root, exist_ok=True) - if Path(dest).suffix.lower() in _PPTX_SUFFIXES and _pptx_available(): - # A .pptx is a binary package — build a real deck from the marker - # text (writing text straight to .pptx would corrupt it). - from ...core import pptx_edit - pptx_edit.create_pptx_from_text(dest, content) - else: - Path(dest).write_text(content, encoding="utf-8") - except Exception as exc: # noqa: BLE001 - OS error or pptx build failure - self.status_message.emit(tr("folder.save_error", err=str(exc))) - return None - self.open_file(dest, reset=False) # show the new file; keep this AI chat - return dest - - def _ai_write_out(self, content: str, skip_image_confirm: bool = False) -> None: - """Persist the confirmed content to disk AND refresh the preview. - pptx text is written back into the deck (no PowerPoint window).""" - if not self._current_file: - return - try: - if self._edit_kind == "pptx": - if not self._write_pptx(content, skip_confirm=skip_image_confirm): - return - else: - Path(self._current_file).write_text(content, encoding="utf-8") - except Exception as exc: # noqa: BLE001 - self.status_message.emit(tr("folder.save_error", err=str(exc))) - return - # Refresh preview: HTML re-renders; pptx re-renders the slides; code stays - # in the (now-saved) editor. - suffix = Path(self._current_file).suffix.lower() - if suffix in _HTML_SUFFIXES: - self._show_html(self._current_file, mode_preview=True) - elif suffix in _PPTX_SUFFIXES: - self._show_pptx(self._current_file, mode_preview=True) diff --git a/presentation/folder/file_helpers.py b/presentation/folder/file_helpers.py deleted file mode 100644 index 8241830..0000000 --- a/presentation/folder/file_helpers.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Hàm phụ trợ đọc và nhận dạng file — R08-T12. - -Thuần hàm, không widget. ``_is_probably_text`` là chỗ quyết định file -mở bằng ô soạn thảo hay báo là nhị phân — đoán sai thì người dùng thấy -một màn hình ký tự rác. -""" -from __future__ import annotations - -from ...ui.libreoffice_view import DOC_SUFFIXES # noqa: F401 — dùng lại ở cả gói - -# Nhận dạng loại file và các ngưỡng — gom về đây vì cả sáu file trong gói -# đều hỏi tới, để rải ra thì mỗi lần thêm một đuôi file phải sửa vài chỗ. -try: - from ...graph.graph_web import _HAS_WEB -except Exception: # pragma: no cover - _HAS_WEB = False - -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 - -_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 -_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy) - - -import os -from pathlib import Path -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor, QFont, QTextCharFormat -from ...i18n import tr - - -def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat: - f = QTextCharFormat() - f.setForeground(QColor(color)) - if italic: - f.setFontItalic(True) - if bold: - f.setFontWeight(QFont.Bold) - return f - - -def _pptx_available() -> bool: - """True when python-pptx is importable. If it's MISSING, auto-download & - install it (via deps.ensure_module) so pptx editing 'just works' — cached so - the (one-time) install is attempted only once.""" - global _PPTX_READY - if _PPTX_READY is None: - try: - from ...core.deps import ensure_module - _PPTX_READY = ensure_module("pptx", "python-pptx") is not None - except Exception: # noqa: BLE001 - _PPTX_READY = False - return _PPTX_READY - - -def _split_code_block(text: str): - """Split an AI reply into ``(file_content, summary)``. ``file_content`` is - the first fenced code block (the edited file); ``summary`` is any prose - before it. Returns ``(None, text)`` when there's no code block.""" - import re - m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL) - if not m: - return None, (text or "") - return m.group(1), (text[:m.start()].strip()) - - -def _parse_ai_output(text: str): - """Parse an AI edit reply into ``(target, content, summary, image_gens)``. - ``FILE: `` names a NEW file to create; ``IMAGE_GEN: => `` - lines request generated illustration images (relative paths).""" - import re - content, summary = _split_code_block(text) - target = None - m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "") - if m: - target = m.group(1).strip().strip("`\"'") - image_gens = [] - for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""): - image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'"))) - # Strip the directive lines out of the shown summary. - summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip() - return target, content, summary, image_gens - - -def _read_text(path: str) -> str: - try: - return Path(path).read_text(encoding="utf-8", errors="replace") - except OSError as exc: - return f"[could not read file: {exc}]" - - -def _is_probably_text(path: str) -> bool: - try: - with open(path, "rb") as f: - chunk = f.read(4096) - except OSError: - return False - if b"\x00" in chunk: - return False - try: - chunk.decode("utf-8") - return True - except UnicodeDecodeError: - # Latin-ish text still edits fine via errors="replace"; only reject on - # a hard binary signal (NUL above), so most source files pass. - return True diff --git a/presentation/folder/image_model_picker.py b/presentation/folder/image_model_picker.py deleted file mode 100644 index 095c42f..0000000 --- a/presentation/folder/image_model_picker.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Chọn model sinh ảnh cho AI sửa file — R08-T12. - -Dò khắp mọi provider đã cấu hình xem cái nào sinh được ảnh, rồi gợi ý khi -câu người dùng gõ nghe như đang muốn tạo ảnh. Có thể gợi ý model của -provider KHÁC provider đang chọn — nên nó tách riêng: đây là chỗ duy nhất -trong màn Thư mục biết tới nhiều provider cùng lúc. -""" -from __future__ import annotations - -from .file_helpers import ( - DOC_SUFFIXES, _HTML_SUFFIXES, _IMAGE_SUFFIXES, _PPTX_SUFFIXES, _is_probably_text, _pptx_available, -) -import os -from pathlib import Path -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget -from ...core.worker import AgentWorker -from ...i18n import tr -from ...theme import current_palette -from ...ui.chat_view import ChatView -from ...ui.libreoffice_view import DOC_SUFFIXES - - -class ImageModelPickerMixin: - """Trộn vào FolderTab.""" - - def _scan_all_image_models(self, then_suggest: bool = False) -> None: - """Background: find image-capable models across EVERY configured provider - (not just the active one), so we can suggest one when an edit involves - images even if the active provider has none. Caches - ``self._all_image_models = [(provider_key, model)]``.""" - 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", {})) - # Only providers that actually have an endpoint/key configured. - candidates = [k for k, c in providers.items() - if (c.get("base_url") or c.get("api_key"))] - - def job(worker): - from ...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): - self._img_scan_worker = None - self._all_image_models = list(res.get("found", [])) - if getattr(self, "_pending_img_suggest", False): - self._pending_img_suggest = False - self._suggest_cross_provider_image() - - w = AgentWorker(job) - w.finished_ok.connect(done) - w.failed.connect(lambda _e: setattr(self, "_img_scan_worker", None)) - self._img_scan_worker = w - if then_suggest: - self._pending_img_suggest = True - w.start() - - def _maybe_suggest_image_model(self, instruction: str) -> None: - """If the request looks image-related, suggest a suitable image model - BEFORE running — searching the active provider first, then ALL providers. - The suggested model is what image generation will auto-use.""" - from ...core import image_gen - low = (instruction or "").lower() - if not any(w in low for w in self._IMAGE_WORDS): - return - picked = self.ai_model_combo.currentData() - if picked and image_gen.looks_like_image_model(picked): - return - local = image_gen.suggest_image_model(self._ai_models) - if local: - self.ai_chat.add_status(tr("folder.ai_image_suggest", model=local)) - return - # None on the active provider → look across ALL providers (cached, or scan - # now and suggest when the scan returns). - if self._all_image_models: - self._suggest_cross_provider_image() - elif self._img_scan_worker is not None: - self._pending_img_suggest = True # a scan is already running - else: - self._scan_all_image_models(then_suggest=True) - - def _suggest_cross_provider_image(self) -> None: - """Post a suggestion listing image models found on OTHER providers. When - none exist anywhere, fall back to telling the user their PICKED model - will be used for image generation (or that there's nothing to use).""" - from ...config import PROVIDER_LABELS - if not self._all_image_models: - picked = self.ai_model_combo.currentData() - if picked: - self.ai_chat.add_status(tr("folder.ai_image_use_selected", model=picked)) - else: - self.ai_chat.add_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.ai_chat.add_status(tr("folder.ai_image_suggest_all") + "\n" + "\n".join(lines)) diff --git a/presentation/graph/graph_project.py b/presentation/graph/graph_project.py deleted file mode 100644 index 4cb6c0a..0000000 --- a/presentation/graph/graph_project.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Chọn project và đổi chế độ xem cho GraphRAG — R08-T14. - -Đồ thị luôn thuộc về một project. Đổi project là phải quét lại từ đầu, nên -phần này giữ luôn việc dọn kết quả cũ trước khi nạp cái mới. - -Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. -""" -from __future__ import annotations - -from .graph_qa_widget import GraphQaMixin -from .graph_render import GraphRenderMixin -from .graph_scene import _Edge, _GraphView, _Node -import re -import sys -from pathlib import Path -from PySide6.QtCore import QPointF, Qt, QTimer, Signal -from PySide6.QtGui import QColor -from PySide6.QtWidgets import QComboBox, QFileDialog, QGraphicsScene, QGraphicsView, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar, QTextBrowser, QVBoxLayout, QWidget -from ...theme import current_palette -from ...core.worker import AgentWorker -from ...i18n import on_language_changed, tr -from ...state import AppContext -from ...ui.icons import collapse_right_icon, icon -from ...ui.widgets import CollapseStrip - - -class GraphProjectMixin: - """Chọn project + đổi tab xem. Trộn vào StructureGraphView.""" - - def _retranslate(self) -> None: - self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) - self._pick_btn.setText(tr("structure.browse")) - self._scan_btn.setText(tr("structure.scan")) - self._export_btn.setText(tr("structure.export_png")) - # Both views are named at once now, so neither label depends on state. - self.view_tabs.setTabText(0, tr("structure.graph_btn")) - self.view_tabs.setTabText(1, tr("structure.msgs_btn")) - self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip")) - self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip")) - self._ag_label.setText(tr("structure.agent_header")) - self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) - self._ask_btn.setText(tr("structure.ask")) - if self._detail_mode == "idle": - self.detail.setPlaceholderText(tr("structure.detail_placeholder")) - self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip")) - self.project_combo.setToolTip(tr("structure.project_tooltip")) - self._refresh_project_combo() - def _refresh_project_combo(self) -> None: - from ...core.projects import list_projects - - keep = self._active_project_id - self.project_combo.blockSignals(True) - self.project_combo.clear() - self.project_combo.addItem(tr("structure.project_none"), "") - row_to_select = 0 - for i, p in enumerate(list_projects(), start=1): - self.project_combo.addItem(p.name, p.project_id) - if p.project_id == keep: - row_to_select = i - self.project_combo.setCurrentIndex(row_to_select) - self.project_combo.blockSignals(False) - def set_project(self, project_id: str) -> None: - pid = project_id or "" - self._refresh_project_combo() - target = self.project_combo.findData(pid) - if target < 0: - target = 0 - if self.project_combo.currentIndex() == target: - self._on_project_changed(target) - else: - self.project_combo.setCurrentIndex(target) - def _on_project_changed(self, _idx: int) -> None: - from ...core.projects import load_project - - pid = self.project_combo.currentData() or "" - project_changed = pid != self._active_project_id - if project_changed: - self._clear_extracts() # different workspace → drop temp extraction - self._active_project_id = pid - locked = bool(pid) - self.path_edit.setReadOnly(locked) - # Also disable the folder-pick button — otherwise the scan path is only - # "locked" against typing, but the picker could still repoint it outside - # the selected project's sandbox, breaking GraphRAG scope isolation. - self._pick_btn.setEnabled(not locked) - if locked: - project = load_project(pid) - if project is not None: - self.path_edit.setText(str(project.workspace_dir())) - if project_changed: - # Mark it and scan on the next visit rather than now. The rail's - # project picker made switching a one-click thing from any screen, - # and each switch rebuilt this graph — a folder walk plus a force - # layout plus a full setHtml of the D3 page — for a tab that was - # usually not even on screen. auto_scan_and_fit() picks the flag up - # when GraphRAG is actually opened. - self._needs_scan = True - def _pick(self) -> None: - chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text()) - if chosen: - self.path_edit.setText(chosen) - def _on_view_tab(self, index: int) -> None: - """Tab 0 = graph, tab 1 = messages. Same two views as before, now named - on screen instead of hidden behind one button's changing label.""" - if index == 1: - self._reload_messages() - self._stack.setCurrentWidget(self._msgs_view) - else: - self._stack.setCurrentWidget(self.web if self.web is not None else self.view) diff --git a/presentation/graph/graph_render.py b/presentation/graph/graph_render.py deleted file mode 100644 index de53cc6..0000000 --- a/presentation/graph/graph_render.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Quét mã nguồn, dựng đồ thị, và xuất ảnh — R08-T14. - -Hai đường vẽ song song: khung nhìn Qt (``_render``) và bản D3 chạy trong -QtWebEngine (``_render_d3``). WebEngine nặng nên chỉ dựng ở lần hiện đầu tiên -(``_ensure_web``), và ``prewarm`` hâm nóng nó lúc rảnh để bấm vào GraphRAG -không phải ngồi nhìn khung trắng. - -Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. -""" -from __future__ import annotations - -from .graph_scene import _Bridge, _Edge, _Node - -from .graph_web import _HAS_WEB, QWebChannel, QWebEngineView - -import math -import re -from pathlib import Path -from PySide6.QtCore import QPointF, Qt, QUrl -from PySide6.QtGui import QColor -from PySide6.QtWidgets import QFileDialog -from ...theme import current_palette -from ...core.worker import AgentWorker -from ...i18n import tr - - -class GraphRenderMixin: - """Quét, vẽ, xuất. Trộn vào StructureGraphView.""" - - def schedule_rescan(self, path: str = "") -> None: - if self._graph is None: - self._needs_scan = True - return - self._rescan_timer.start() - def prewarm(self) -> None: - """Pay for the graph view before it is clicked on, not during. - - Opening GraphRAG built a QWebEngineView (~140ms) and scanned the project - (~485ms) while an empty browser sat on screen — long enough, and white - enough, to read as the app restarting itself. Called from an idle timer - after the window is up, so startup itself is unaffected; the memory the - lazy construction was saving is spent a few seconds later instead. - """ - if not _HAS_WEB or self.web is not None: - return - self._ensure_web() - if self._graph is None and self.path_edit.text().strip(): - self._needs_scan = False - self._scan() # runs on a worker thread - def _ensure_web(self) -> None: - if self.web is not None or not _HAS_WEB: - return - self.web = QWebEngineView() - # Blank the page in the app's own background first. A fresh - # QWebEngineView paints white, and on a dark theme that white rectangle - # WAS the flash — it showed for as long as the first scan took. - self.web.setHtml( - f"") - self._bridge = _Bridge() - self._channel = QWebChannel() - self._channel.registerObject("py", self._bridge) - self.web.page().setWebChannel(self._channel) - self._stack.addWidget(self.web) - self._stack.setCurrentWidget(self.web) - if self._graph is not None: - self._render_d3() - def auto_scan_and_fit(self) -> None: - self._ensure_web() - if not self.path_edit.text().strip(): - return - if getattr(self, "_worker", None) is not None and self._worker.isRunning(): - self._fit() - self._preserve_answer() - return - if self._graph is not None and not self._needs_scan: - self._fit() - self._preserve_answer() - return - self._needs_scan = False - self._scan() - def _scan(self) -> None: - path = self.path_edit.text().strip() or str(Path.cwd()) - mode = "files" # default: scan all files (filter removed) - use_cmem = bool(self.ctx.config.codebase_memory.get("enabled")) - cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "") - st = self.ctx.config.structure - max_nodes = int(st.get("max_nodes", 500) or 0) - max_edges = int(st.get("max_edges", 500) or 0) - self._scan_seq += 1 - seq = self._scan_seq - self.status_message.emit(tr("structure.scanning")) - - def job(worker: AgentWorker): - from ...core.structure_graph import ( - build_from_codebase_memory, build_from_directory, force_layout, - ) - if use_cmem: - from ...core.codebase_memory import CodebaseMemory - mem = CodebaseMemory(cmem_bin) - graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges) - if mem.available else build_from_directory(path, mode, max_nodes, max_edges)) - else: - graph = build_from_directory(path, mode, max_nodes, max_edges) - pos = force_layout(graph) - return {"graph": graph, "pos": pos, "seq": seq} - - w = AgentWorker(job) - w.finished_ok.connect(self._render) - w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e))) - self._worker = w - w.start() - def _render(self, result: dict) -> None: - if result.get("seq") is not None and result["seq"] != self._scan_seq: - return - graph = result.get("graph") - pos = result.get("pos", {}) - if graph is None: - return - self._graph = graph - - self.scene.clear() - self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear - self._node_items = [] - self._edge_items = [] - degree = {n.id: 0 for n in graph.nodes} - for e in graph.edges: - if e.source in degree: - degree[e.source] += 1 - if e.target in degree: - degree[e.target] += 1 - items = {} - sx = sy = 0.0 - for node in graph.nodes: - radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0)))) - item = _Node(node, radius) - x, y = pos.get(node.id, (0, 0)) - item.setPos(x, y) - self.scene.addItem(item) - items[node.id] = item - self._node_items.append(item) - sx += x - sy += y - for edge in graph.edges: - a, b = items.get(edge.source), items.get(edge.target) - if a and b: - e = _Edge(a, b, getattr(edge, "type", "")) - self.scene.addItem(e) - self._edge_items.append(e) - n = max(1, len(self._node_items)) - self._centroid = QPointF(sx / n, sy / n) - self._fit() - - if self.web is not None: - self._render_d3() - - note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else "" - self.status_message.emit(tr( - "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note)) - self._preserve_answer() - def _render_d3(self) -> None: - if self.web is None or self._graph is None: - return - from ...core.d3_graph import build_html - try: - self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/")) - except Exception as exc: - self.status_message.emit(f"D3 view error: {exc}") - def _on_selection(self) -> None: - for item in self.scene.selectedItems(): - if isinstance(item, _Node): - d = item.data - self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}") - self._detail_mode = "node" - return - def _fit(self) -> None: - if self.web is not None and self._stack.currentWidget() is self.web: - self.web.page().runJavaScript("window.fitGraph && window.fitGraph();") - return - rect = self.scene.itemsBoundingRect() - if not rect.isNull(): - self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio) - def _export(self) -> None: - path, _ = QFileDialog.getSaveFileName( - self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)") - if not path: - return - showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web) - if showing_d3: - self._export_d3_png(path) - else: - self._export_widget_grab(path) - def _export_d3_png(self, path: str) -> None: - def on_result(data_url) -> None: - if not isinstance(data_url, str) or "," not in data_url: - self._export_widget_grab(path) - return - import base64 - try: - with open(path, "wb") as f: - f.write(base64.b64decode(data_url.split(",", 1)[1])) - self.status_message.emit(tr("structure.export_done", path=path)) - except (OSError, ValueError) as exc: - self.status_message.emit(tr("structure.export_failed", err=str(exc))) - self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result) - def _export_widget_grab(self, path: str) -> None: - ok = self._stack.currentWidget().grab().save(path, "PNG") - if ok: - self.status_message.emit(tr("structure.export_done", path=path)) - else: - self.status_message.emit(tr("structure.export_failed", err="grab() returned no image")) - @staticmethod - def _graph_context(graph) -> str: - from collections import defaultdict - by_kind = defaultdict(list) - for n in graph.nodes: - by_kind[n.kind].append(n.label) - lines = [] - for kind in ("file", "class", "function", "method", "module", "section"): - items = by_kind.get(kind, []) - if items: - lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60])) - id2label = {n.id: n.label for n in graph.nodes} - rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}" - for e in graph.edges[:140]] - if rels: - lines.append("Relationships (sample):\n" + "\n".join(rels)) - return "\n".join(lines)[:7000] diff --git a/presentation/graph/graph_scene.py b/presentation/graph/graph_scene.py deleted file mode 100644 index 138c3aa..0000000 --- a/presentation/graph/graph_scene.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Các phần tử vẽ của đồ thị: node, cạnh, khung nhìn — R08-T14. - -Thuần đồ hoạ Qt, không biết gì về GraphRAG hay agent. Tách riêng vì đây là -chỗ duy nhất cần mở khi chỉnh cách đồ thị trông ra sao — màu, hình mũi tên, -cách kéo thả và phóng to. -""" -from __future__ import annotations - -import re -from pathlib import Path -from PySide6.QtCore import QObject, QPointF, Qt, Slot -from PySide6.QtGui import QBrush, QColor, QFont, QPen -from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsView -from ...core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS -from ...theme import current_palette -from ...i18n import tr -from ...ui.osutil import open_folder, open_location - - -class _Bridge(QObject): - """Exposed to the D3 page so a Shift+click on a node can open its - storage folder/link (local path or URL — see osutil.open_location).""" - - @Slot(str) - def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name - if path: - open_location(path) - -class _Edge(QGraphicsLineItem): - def __init__(self, a: "_Node", b: "_Node", type_: str = ""): - super().__init__() - self.a, self.b = a, b - self.type = type_ - # Colour the edge by its RELATIONSHIP type (contains/defines/method/…), - # so the graph shows what each connection MEANS — falling back to the - # source node's tint for any untyped edge. - color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor() - if not color.isValid(): - color = a.brush().color().lighter(130) - self._color = color - self.setPen(QPen(color, 1.4)) - self.setZValue(-1) - # A small label naming the relationship, shown at the edge midpoint. - self._label = None - if type_: - self._label = QGraphicsSimpleTextItem(type_, self) - self._label.setBrush(QBrush(color.lighter(140))) - f = QFont() - f.setPointSize(7) - self._label.setFont(f) - self._label.setZValue(0) - a.edges.append(self) - b.edges.append(self) - self.adjust() - - def adjust(self) -> None: - pa, pb = self.a.scenePos(), self.b.scenePos() - self.setLine(pa.x(), pa.y(), pb.x(), pb.y()) - if self._label is not None: - br = self._label.boundingRect() - self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2, - (pa.y() + pb.y()) / 2 - br.height() / 2) - -class _Node(QGraphicsEllipseItem): - def __init__(self, data, radius: int): - super().__init__(-radius, -radius, 2 * radius, 2 * radius) - self.data = data - self.edges = [] - tok = current_palette() - # NODE_KIND_COLORS is a categorical data encoding (one hue per node - # kind), not UI chrome — it stays fixed across themes on purpose so a - # given kind is always the same colour. Only the chrome follows tokens. - color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted)) - self.setBrush(QBrush(color)) - self.setPen(QPen(color.darker(160), 1.5)) - self.setFlags( - QGraphicsEllipseItem.ItemIsMovable - | QGraphicsEllipseItem.ItemIsSelectable - | QGraphicsEllipseItem.ItemSendsGeometryChanges - ) - self.setZValue(1) - label = QGraphicsSimpleTextItem(data.label, self) - label.setBrush(QBrush(QColor(tok.text))) - label.setPos(radius + 3, -8) - - def itemChange(self, change, value): # noqa: N802 - if change == QGraphicsEllipseItem.ItemPositionHasChanged: - for edge in self.edges: - edge.adjust() - return super().itemChange(change, value) - -class _GraphView(QGraphicsView): - def __init__(self, scene): - super().__init__(scene) - self.setDragMode(QGraphicsView.NoDrag) - self._panning = False - self._pan_start = QPointF() - - def wheelEvent(self, e): # noqa: N802 - self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15, - 1.15 if e.angleDelta().y() > 0 else 1 / 1.15) - - def mousePressEvent(self, e): # noqa: N802 - if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None: - self._panning = True - self._pan_start = e.position() - self.setCursor(Qt.ClosedHandCursor) - e.accept() - return - super().mousePressEvent(e) - - def mouseMoveEvent(self, e): # noqa: N802 - if self._panning: - delta = e.position() - self._pan_start - self._pan_start = e.position() - self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x())) - self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y())) - e.accept() - return - super().mouseMoveEvent(e) - - def mouseReleaseEvent(self, e): # noqa: N802 - if self._panning: - self._panning = False - self.setCursor(Qt.ArrowCursor) - e.accept() - return - super().mouseReleaseEvent(e) - - def mouseDoubleClickEvent(self, e): # noqa: N802 - """Double-click or Ctrl+click on a node opens its storage folder.""" - item = self.itemAt(e.pos()) - if isinstance(item, _Node) and getattr(item.data, "path", ""): - open_folder(item.data.path) - e.accept() - return - super().mouseDoubleClickEvent(e) - diff --git a/presentation/graph/graph_web.py b/presentation/graph/graph_web.py deleted file mode 100644 index 0211088..0000000 --- a/presentation/graph/graph_web.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Có dùng được QtWebEngine hay không — R08-T14. - -Cờ khả năng, tách riêng vì cả ``structure_graph_view.py`` lẫn -``graph_render.py`` đều phải hỏi. Để ở một trong hai thì file kia import -ngược lại — vòng import. - -WebEngine là add-on tuỳ chọn của PySide6, và bản đóng gói one-file của -PyInstaller không chạy được nó; những trường hợp đó rơi về khung nhìn Qt. -""" -from __future__ import annotations - -import sys -from pathlib import Path - -def _frozen_onefile() -> bool: - """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a - temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process - can't run — creating a QWebEngineView hard-crashes the app (reported as - "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the - ``_internal`` folder right next to the exe, where WebEngine works fine, so - it keeps the full embedded D3 view.""" - if not getattr(sys, "frozen", False): - return False - meipass = getattr(sys, "_MEIPASS", "") - if not meipass: - return False - try: - return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent - except OSError: # can't tell → play safe: use the native fallback - return True - - -try: # WebEngine + WebChannel are optional PySide6 add-ons - from PySide6.QtWebEngineWidgets import QWebEngineView - from PySide6.QtWebChannel import QWebChannel - _HAS_WEB = not _frozen_onefile() -except Exception: # pragma: no cover - _HAS_WEB = False diff --git a/presentation/scheduling/task_actions.py b/presentation/scheduling/task_actions.py deleted file mode 100644 index 431e59c..0000000 --- a/presentation/scheduling/task_actions.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Các thao tác trên một task: thêm, sửa, chạy ngay, xoá, xem log — R08-T11. - -Tách khỏi ``ScheduleTaskTab`` để phần dựng bảng và phần hành động không nằm -lẫn nhau. ``_context_menu`` là chỗ tập trung: nó quyết định mục nào hiện ra -tuỳ theo đang chọn một hay nhiều thẻ. - -Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. -""" -from __future__ import annotations - -# Import muộn trong hàm ở chỗ dùng: ba lớp này nằm cùng gói và một trong số -# chúng trộn ngược mixin này vào, nên import ở mức module là vòng. - -import copy -from pathlib import Path -from typing import Dict, List, Optional -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, - QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, - QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, - QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, -) -from ...core import tasks as taskrepo -from ...core.projects import list_projects -from ...core.tasks import STATUSES, chain_error, duplicate_task, new_task -from ...core.worker import AgentWorker -from ...i18n import on_language_changed, tr -from ...state import AppContext -from ...theme import current_palette -from ...ui.calendar_view import CalendarView -from ...ui.icons import icon -from ...ui.osutil import open_path - - -class TaskActionsMixin: - """Thao tác trên task. Trộn vào ScheduleTaskTab.""" - - def _add_task_on_date(self, date_str: str) -> None: - """Create a task pre-filled with the clicked calendar date (default - 09:00) — same editor Add Task opens, nothing is saved until confirmed.""" - from ...ui.task_editor_dialog import TaskEditorDialog - - t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"}) - dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - self.status_message.emit(tr("schedtask.msg_created")) - def _save_and_refresh(self, task: dict) -> None: - taskrepo.save_task(task, self._tasks_dir) - self.refresh() - def _add_task(self) -> None: - from ...ui.task_editor_dialog import TaskEditorDialog - - dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - self.status_message.emit(tr("schedtask.msg_created")) - def _edit_task(self, task_id: str) -> None: - from ...ui.task_editor_dialog import TaskEditorDialog - - task = taskrepo.load_task(task_id, self._tasks_dir) - if not task: - return - dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - def _on_double_click(self, item: QListWidgetItem) -> None: - tid = item.data(Qt.UserRole) - if tid: - self._edit_task(tid) - def _is_multi_selection(item, selected) -> bool: - """True when the right-clicked card is part of an existing multi-item - selection — pure boolean, kept separate from _context_menu so it's - testable without ever invoking Qt's (modal, event-loop-blocking) menu.""" - return len(selected) > 1 and item in selected - def _context_menu(self, col: _KanbanColumn, pos) -> None: - item = col.itemAt(pos) - if item is None or not item.data(Qt.UserRole): - return - selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)] - if self._is_multi_selection(item, selected): - self._bulk_delete_menu(col, pos, selected) - return - tid = item.data(Qt.UserRole) - task = taskrepo.load_task(tid, self._tasks_dir) - if not task: - return - menu = QMenu(col) - run_act = menu.addAction(tr("schedtask.menu_run")) - edit_act = menu.addAction(tr("schedtask.menu_edit")) - dup_act = menu.addAction(tr("schedtask.menu_duplicate")) - paused = task.get("status") == "paused" - pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause")) - logs_act = menu.addAction(tr("schedtask.menu_logs")) - hist_act = menu.addAction(tr("schedtask.menu_history")) - next_act = menu.addAction(tr("schedtask.menu_create_next")) - menu.addSeparator() - del_act = menu.addAction(tr("schedtask.menu_delete")) - chosen = menu.exec(col.viewport().mapToGlobal(pos)) - if chosen == run_act: - self._run_now(task) - elif chosen == edit_act: - self._edit_task(tid) - elif chosen == dup_act: - self._save_and_refresh(duplicate_task(task)) - elif chosen == pause_act: - task["status"] = "backlog" if paused else "paused" - self._save_and_refresh(task) - elif chosen == logs_act: - self._view_logs(task) - elif chosen == hist_act: - from .run_history_dialog import _RunHistoryDialog - _RunHistoryDialog(task, self).exec() - elif chosen == next_act: - self._create_next_from_output(task) - elif chosen == del_act: - if QMessageBox.question(self, tr("schedtask.menu_delete"), - tr("schedtask.delete_confirm", title=task.get("title", "")) - ) == QMessageBox.Yes: - taskrepo.delete_task(tid, self._tasks_dir) - self.refresh() - def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None: - """Right-click on a multi-selection within one column (Shift/Ctrl-click - several cards first): one action deletes every selected task. The - popup itself is a thin wrapper — see _confirm_and_delete_selected for - the actual (independently testable) confirm+delete logic.""" - menu = QMenu(col) - del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected))) - chosen = menu.exec(col.viewport().mapToGlobal(pos)) - if chosen == del_act: - self._confirm_and_delete_selected(selected) - def _confirm_and_delete_selected(self, selected) -> bool: - """Confirm, then delete every task in ``selected``. Split out of - _bulk_delete_menu so tests can drive it directly without having to - fake a real (modal, event-loop-blocking) QMenu popup.""" - if QMessageBox.question( - self, tr("schedtask.menu_delete"), - tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes: - return False - for item in selected: - tid = item.data(Qt.UserRole) - if tid: - taskrepo.delete_task(tid, self._tasks_dir) - self.refresh() - return True - def _run_now(self, task: dict) -> None: - if task.get("task_type") == "manual": - self.status_message.emit(tr("schedtask.msg_manual_norun")) - return - if self.scheduler is None: - self.status_message.emit(tr("schedtask.msg_no_scheduler")) - return - if self.scheduler.run_now(task["task_id"]): - self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", ""))) - self.refresh() - def _view_logs(self, task: dict) -> None: - run_id = task.get("logs", {}).get("last_run_id") - if not run_id: - QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) - return - folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id - if folder.exists(): - open_path(str(folder)) - else: - QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) - def _create_next_from_output(self, task: dict) -> None: - """Scaffold a follow-up task pre-wired to consume this task's output.""" - nxt = new_task(tr("schedtask.next_of", title=task.get("title", ""))) - nxt["task_type"] = "cowork" - nxt["input"]["mode"] = "previous_task_output" - nxt["input"]["previous_task_id"] = task["task_id"] - nxt["dependency"]["previous_task_id"] = task["task_id"] - err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt], - task["task_id"], nxt["task_id"]) - if err: - QMessageBox.warning(self, tr("schedtask.g_dependency"), err) - return - taskrepo.save_task(nxt, self._tasks_dir) - task["dependency"]["next_task_id"] = nxt["task_id"] - task["dependency"]["pass_output_to_next"] = True - if task["dependency"].get("run_next_mode", "none") == "none": - task["dependency"]["run_next_mode"] = "run_after_success" - taskrepo.save_task(task, self._tasks_dir) - self.refresh() - self._edit_task(nxt["task_id"]) - def _ai_create(self) -> None: - from .ai_task_creator_dialog import _AiCreateDialog - dlg = _AiCreateDialog(self.ctx, self) - if dlg.exec() and dlg.created_tasks: - for t in dlg.created_tasks: - taskrepo.save_task(t, self._tasks_dir) - self.refresh() - self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks))) From e5c184ce07539da73ecda54355539b171dcf5b31 Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Sun, 30 Aug 2026 10:36:52 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat(gates):=20th=C3=AAm=20c=E1=BB=95ng=20C?= =?UTF-8?q?ASAN=20th=E1=BB=A9=20t=C6=B0=20(Gate=20O)=20v=C3=A0=20m?= =?UTF-8?q?=E1=BB=9F=20c=E1=BB=95ng=20LOC=20ra=20c=E1=BA=A3=20c=C3=A2y=20m?= =?UTF-8?q?=C3=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate O — module production phải có ít nhất một nơi import --------------------------------------------------------- Ba cổng đang có đều không bắt được mã chết, đúng như 1.400 dòng ở commit trước đã chứng minh. Gate O dựng đồ thị import bằng AST từ `__init__`/`__main__`/`app`, theo cả import muộn trong thân hàm. Hai ngoại lệ tự động để `ALLOWLIST` không phải chép lại cùng một lý do nhiều lần: `__init__.py` của gói mà mọi thành viên đều dormant, và module chỉ được chính mã dormant đã miễn trừ import. Cổng cũng đếm tuổi 9 seam chưa nối dây (nhãn `SEAM · dựng `) và nhắc khi quá 30 ngày. Chỉ [WARN], không làm CI đỏ: để nó đỏ thì CI sẽ đỏ vào một buổi sáng mà không ai sửa gì, và cách nhanh nhất để xanh lại là sửa ngày. Cổng LOC — quét 366 file thay vì 191 ------------------------------------ `DEFAULT_TARGET_DIRS` chỉ có 4 gói Clean Architecture, nên một file 944 dòng trong `ui/` vẫn qua cổng. Nay quét cả `ui/`, `core/`, `providers/`, `security/`, `mcp_servers/` và các module ở thư mục gốc. 18 file đã dài hơn 400 dòng từ trước nằm trong `LEGACY_ALLOWANCE` — bánh cóc chỉ quay một chiều, và nó đo DÒNG MÃ chứ không đo dòng vật lý. Bánh cóc chỉ hỏi một câu, "file này có đang để thêm việc vào không?", mà viết thêm một docstring thì không. Đếm dòng vật lý ở đó biến cổng thành thứ phạt người viết tài liệu, và cách dễ nhất để làm nó xanh lại sẽ là xoá bớt chú thích. Trần 400 vẫn đếm dòng vật lý — đó là hợp đồng đã chốt của cổng S. CI -- Ghim tên thư mục checkout là `cowork_local`: nhiều test characterization sinh tiến trình con `python -c "from cowork_local... import ..."`, mà tiến trình con chỉ import được khi trên sys.path có thư mục mang đúng tên gói. Checkout vào thư mục tên khác làm 73 test đỏ vì lý do không liên quan tới mã. Co-Authored-By: Claude Opus 5 (1M context) --- .gitea/workflows/ci.yaml | 22 +- scripts/check_loc.py | 231 +++++++++++++++++--- scripts/check_orphan_modules.py | 369 ++++++++++++++++++++++++++++++++ scripts/run_quality_gate.py | 10 +- 4 files changed, 602 insertions(+), 30 deletions(-) create mode 100644 scripts/check_orphan_modules.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index beb45be..dce0038 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -12,16 +12,29 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 + defaults: + run: + working-directory: cowork_local + env: + # Tiến trình con của test import `cowork_local` qua đường này. + PYTHONPATH: ${{ github.workspace }} steps: + # Checkout PHẢI nằm trong thư mục tên đúng `cowork_local`. + # Nhiều test characterization sinh tiến trình con chạy + # `python -c "from cowork_local... import ..."`; tiến trình con đó chỉ + # import được khi trên sys.path có một thư mục mang đúng tên gói. Checkout + # vào thư mục tên khác làm 73 test đỏ vì lý do không liên quan tới mã. - name: Check out source uses: actions/checkout@v4 + with: + path: cowork_local - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - cache-dependency-path: requirements-test.txt + cache-dependency-path: cowork_local/requirements-test.txt - name: Install test dependencies run: python -m pip install --disable-pip-version-check -r requirements-test.txt @@ -68,3 +81,10 @@ jobs: else echo "scripts/check_imports.py chưa có — Team Duy viết, hạn 30/08. Bỏ qua." fi + + # Cổng O bổ sung sau đợt đối chiếu AS-IS/TO-BE: ba check trên đều không + # bắt được mã chết (file không ai import vẫn đúng chiều phụ thuộc, vẫn + # sạch credential, vẫn dưới 400 dòng). Đợt đó tìm ra 1.400 dòng mã trùng + # lặp chết lọt qua đúng theo cách này. + - name: "CASAN Check O — module production phải có nơi import" + run: python scripts/check_orphan_modules.py diff --git a/scripts/check_loc.py b/scripts/check_loc.py index 4bb8a03..03eebbd 100644 --- a/scripts/check_loc.py +++ b/scripts/check_loc.py @@ -2,13 +2,27 @@ """Lines-of-Code (LOC) Quality Guard (EPIC R10 - CASAN Gate S). Enforces the Single Responsibility Principle by ensuring that no production -Python file in Clean Architecture packages exceeds the configured limit (400 LOC). +Python file exceeds the configured limit (400 LOC). + +Phạm vi quét là TOÀN BỘ cây mã production, không chỉ bốn gói Clean +Architecture: ``ui/``, ``core/``, ``providers/``, ``security/``, +``mcp_servers/`` và các module nằm thẳng ở thư mục gốc đều được tính. Trước +đợt mở rộng này, 18 file dài hơn 400 dòng (dài nhất 944) vẫn qua cổng chỉ vì +chúng nằm ngoài bốn gói kia. + +18 file đó không thể sửa hết trong một lần, nên chúng nằm trong +``LEGACY_ALLOWANCE`` với trần riêng bằng đúng số dòng hiện tại — một bánh cóc +chỉ quay một chiều: nợ cũ được giữ nguyên nhưng không được phình thêm, và mỗi +lần file co bớt thì cổng in ra lời nhắc hạ con số xuống. """ from __future__ import annotations import argparse +import ast +import io import os import sys +import tokenize from pathlib import Path from typing import List, Tuple @@ -19,10 +33,52 @@ if hasattr(sys.stdout, "reconfigure"): except Exception: pass -# Default target directories strictly subjected to the 400 LOC constraint -DEFAULT_TARGET_DIRS = ["domain", "application", "infrastructure", "presentation"] +# Toàn bộ cây mã production. Bốn gói Clean Architecture là phần cổng này canh +# từ đầu; ``ui``/``core``/``providers``/``security``/``mcp_servers`` được đưa +# vào sau đợt đối chiếu AS-IS/TO-BE — trước đó chúng nằm ngoài tầm quét, nên +# một file 944 dòng vẫn qua cổng chỉ vì nó không nằm trong bốn gói kia. +DEFAULT_TARGET_DIRS = [ + "domain", "application", "infrastructure", "presentation", + "ui", "core", "providers", "security", "mcp_servers", +] DEFAULT_MAX_LINES = 400 +#: Các file ``.py`` nằm thẳng ở thư mục gốc cũng được quét (không đệ quy) — +#: ``app.py``, ``state.py``, ``theme*.py``… đều là mã production. +SCAN_ROOT_MODULES = True + +#: Nợ cũ: file đã dài hơn 400 dòng TỪ TRƯỚC khi cổng mở rộng sang ``ui``/ +#: ``core``/``providers``. Giá trị là số dòng tại thời điểm ghi nhận và đóng +#: vai trò trần riêng của từng file — đây là bánh cóc CHỈ QUAY MỘT CHIỀU: +#: +#: * file vượt quá trần riêng -> cổng đỏ (đang làm nợ cũ tệ thêm) +#: * file co xuống dưới trần -> [INFO] nhắc hạ con số xuống +#: * file co xuống <= 400 dòng -> [INFO] nhắc gỡ hẳn khỏi danh sách +#: +#: Không bao giờ thêm mục mới vào đây để làm cổng xanh trở lại: file mới viết +#: phải dưới 400 dòng ngay từ đầu. Nới một con số cũng vậy — cách duy nhất +#: đúng là tách file. +LEGACY_ALLOWANCE = { + "ui/workspace_tab.py": 566, + "ui/widgets.py": 505, + "ui/task_editor_dialog.py": 627, + "ui/accounts_tab.py": 559, + "core/skills.py": 405, + "core/chat_agent.py": 419, + "ui/flow_dialog.py": 483, + "core/tasks.py": 339, + "core/co4e.py": 330, + "core/task_executors.py": 347, + "ui/help_agent_widget.py": 313, + "core/structure_graph.py": 346, + "ui/cowork_tab.py": 255, + "providers/base.py": 224, + "ui/co4e_tab.py": 180, + "providers/openai_compat.py": 279, + "core/doc_extract.py": 283, + "ui/connectors_panel.py": 281, +} + def count_file_lines(file_path: Path) -> int: """Read a python file and return total physical line count.""" @@ -34,43 +90,149 @@ def count_file_lines(file_path: Path) -> int: return 0 -def scan_directories( - root_dir: Path, target_dirs: List[str], max_lines: int, verbose: bool = False -) -> Tuple[int, List[Tuple[str, int]]]: - """Recursively scan target packages for files exceeding the maximum LOC limit. +def iter_source_files(root_dir: Path, target_dirs: List[str]): + """Sinh (đường dẫn tương đối, số dòng) cho mọi file production cần quét. - Returns: - A tuple of (total_files_scanned, list_of_violations_as_(relative_path, line_count)) + Ngoài các gói trong ``target_dirs``, quét thêm các ``.py`` nằm thẳng ở thư + mục gốc (``app.py``, ``state.py``, ``theme*.py``…) — chúng cũng là mã chạy + thật nhưng không thuộc gói nào, nên trước đây không ai canh. """ - total_files = 0 - violations: List[Tuple[str, int]] = [] - for target in target_dirs: dir_path = root_dir / target if not dir_path.is_dir(): - if verbose: - print(f"[INFO] Skipping missing directory: {target}") continue + for current_root, dirnames, files in os.walk(dir_path): + dirnames[:] = [d for d in dirnames if d != "__pycache__"] + for file_name in sorted(files): + if file_name.endswith(".py"): + full = Path(current_root) / file_name + yield full.relative_to(root_dir).as_posix(), count_file_lines(full) - for current_root, _, files in os.walk(dir_path): - for file_name in files: - if not file_name.endswith(".py"): - continue + if SCAN_ROOT_MODULES: + for full in sorted(root_dir.glob("*.py")): + yield full.name, count_file_lines(full) - full_path = Path(current_root) / file_name - rel_path = full_path.relative_to(root_dir).as_posix() - lines = count_file_lines(full_path) - total_files += 1 - if verbose: - print(f" {rel_path}: {lines} lines") +def iter_code_sizes(root_dir: Path, target_dirs: List[str]): + """Như :func:`iter_source_files` nhưng đếm DÒNG MÃ — dành cho bánh cóc.""" + for target in target_dirs: + dir_path = root_dir / target + if not dir_path.is_dir(): + continue + for current_root, dirnames, files in os.walk(dir_path): + dirnames[:] = [d for d in dirnames if d != "__pycache__"] + for file_name in sorted(files): + if file_name.endswith(".py"): + full = Path(current_root) / file_name + yield full.relative_to(root_dir).as_posix(), count_code_lines(full) - if lines > max_lines: - violations.append((rel_path, lines)) + if SCAN_ROOT_MODULES: + for full in sorted(root_dir.glob("*.py")): + yield full.name, count_code_lines(full) + + +def count_code_lines(file_path: Path) -> int: + """Số dòng MÃ của một file: bỏ docstring, chú thích và dòng trống. + + Dùng riêng cho bánh cóc ``LEGACY_ALLOWANCE``, không dùng cho trần 400 dòng. + Lý do: bánh cóc có một câu hỏi duy nhất — "file này có đang ĐỂ THÊM + VIỆC vào không?" — mà viết thêm một docstring thì không. Đếm dòng vật lý + ở đây biến cổng thành thứ phạt người viết tài liệu, và cách dễ nhất để làm + nó xanh lại sẽ là xoá bớt chú thích — đúng thứ không ai muốn. + + Trần 400 dòng thì VẪN đếm dòng vật lý: đó là hợp đồng đã chốt của cổng + S từ đầu, đổi cách đo là âm thầm nới nó ra cho mọi file. + """ + try: + src = file_path.read_text(encoding="utf-8", errors="ignore") + except OSError as exc: + print(f"[WARN] Failed to read {file_path}: {exc}", file=sys.stderr) + return 0 + + skip: set = set() + try: + tree = ast.parse(src) + except SyntaxError: + return len(src.splitlines()) + for node in ast.walk(tree): + if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, + ast.AsyncFunctionDef)): + continue + body = getattr(node, "body", None) + if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \ + and isinstance(body[0].value.value, str): + skip.update(range(body[0].lineno, body[0].end_lineno + 1)) + + try: + for tok in tokenize.generate_tokens(io.StringIO(src).readline): + if tok.type == tokenize.COMMENT: + skip.add(tok.start[0]) + except (tokenize.TokenError, IndentationError): + pass + + return sum(1 for i, line in enumerate(src.splitlines(), 1) + if i not in skip and line.strip()) + + +def scan_directories( + root_dir: Path, target_dirs: List[str], max_lines: int, verbose: bool = False +) -> Tuple[int, List[Tuple[str, int]]]: + """Quét cây mã production, đối chiếu với trần chung và trần riêng của nợ cũ. + + Trả về ``(số file đã quét, danh sách vi phạm)``. Một file bị tính là vi + phạm khi nó vượt trần chung VÀ không có trong ``LEGACY_ALLOWANCE``, hoặc + khi nó có trong danh sách nợ cũ nhưng đã phình quá con số ghi ở đó. + """ + total_files = 0 + violations: List[Tuple[str, int]] = [] + code_sizes = dict(iter_code_sizes(root_dir, target_dirs)) + + for rel_path, lines in iter_source_files(root_dir, target_dirs): + total_files += 1 + if verbose: + print(f" {rel_path}: {lines} lines") + + allowance = LEGACY_ALLOWANCE.get(rel_path) + if allowance is None: + if lines > max_lines: + violations.append((rel_path, lines)) + else: + # Nợ cũ: đo bằng dòng mã, không đo bằng dòng vật lý. + code = code_sizes.get(rel_path, lines) + if code > allowance: + violations.append((rel_path, code)) return total_files, violations +def audit_legacy(root_dir: Path, target_dirs: List[str], max_lines: int) -> List[str]: + """Các dòng nhắc về ``LEGACY_ALLOWANCE`` — chỉ để báo, không làm cổng đỏ. + + Bánh cóc chỉ có nghĩa khi con số được siết lại mỗi lần file co bớt; nếu + không ai nhắc thì nó đứng yên mãi ở mức của lần ghi đầu tiên. + + Hai thước đo, mỗi thước trả lời một câu khác nhau: + + * **Gỡ hẳn khỏi danh sách** chỉ đúng khi file đã xuống dưới trần đo bằng + DÒNG VẬT LÝ — vì đó mới là thước của trần 400. Nhắc gỡ một file 950 + dòng chỉ vì phần mã của nó dưới 400 là lời khuyên sai: gỡ xong cổng đỏ + ngay. + * **Hạ con số xuống** đo bằng DÒNG MÃ, cùng thước với chính bánh cóc. + """ + code = dict(iter_code_sizes(root_dir, target_dirs)) + physical = dict(iter_source_files(root_dir, target_dirs)) + notes: List[str] = [] + for rel_path, allowance in sorted(LEGACY_ALLOWANCE.items()): + lines = code.get(rel_path) + if lines is None: + notes.append(f"{rel_path}: file khong con ton tai - go khoi LEGACY_ALLOWANCE") + elif physical.get(rel_path, lines) <= max_lines: + notes.append(f"{rel_path}: nay chi {physical[rel_path]} dong - go khoi LEGACY_ALLOWANCE") + elif lines < allowance: + notes.append(f"{rel_path}: {lines} dong ma (tran dang ghi {allowance}) - ha con so xuong {lines}") + return notes + + def main() -> int: """CLI entry point for the LOC guard script.""" parser = argparse.ArgumentParser( @@ -115,14 +277,27 @@ def main() -> int: verbose=args.verbose, ) + notes = audit_legacy(root_dir, args.dirs, args.max_lines) + if notes: + print(f"\n[INFO] {len(notes)} muc trong LEGACY_ALLOWANCE co the siet lai:") + for note in notes: + print(f" - {note}") + if violations: print(f"\n[FAIL] Found {len(violations)} oversized file(s) (> {args.max_lines} LOC):") for file_path, lines in sorted(violations, key=lambda x: x[1], reverse=True): - print(f" ❌ {file_path}: {lines} lines (exceeds limit by {lines - args.max_lines})") + allowance = LEGACY_ALLOWANCE.get(file_path) + if allowance: + print(f" ❌ {file_path}: {lines} dong ma - no cu ghi la {allowance}, " + f"nay phinh them {lines - allowance}. Tach bot, dung noi con so.") + else: + print(f" ❌ {file_path}: {lines} lines (exceeds limit by {lines - args.max_lines})") print("\nAction Required: Refactor oversized files into smaller single-responsibility modules.") return 1 - print(f"\n[PASS] All {total_files} production files in {args.dirs} satisfy <= {args.max_lines} LOC limit.") + legacy = len(LEGACY_ALLOWANCE) + print(f"\n[PASS] All {total_files} production files in {args.dirs} satisfy <= {args.max_lines} LOC limit " + f"({legacy} file no cu duoc mien tru, khong file nao phinh them).") return 0 diff --git a/scripts/check_orphan_modules.py b/scripts/check_orphan_modules.py new file mode 100644 index 0000000..c87d522 --- /dev/null +++ b/scripts/check_orphan_modules.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +"""Cổng chất lượng: không để module production nào không ai import (CASAN Gate O). + +Vì sao cần cổng này +------------------- +Ba cổng đang có (Clean Architecture / Secrets / LOC) đều **không bắt được mã +chết**: một file không ai import vẫn qua cả ba, vì nó đúng chiều phụ thuộc, +không chứa credential, và ngắn hơn 400 dòng. Đợt kiểm chứng ngày 29/08 tìm ra +**1.400 dòng mã trùng lặp chết** lọt qua đúng theo cách đó — hai bản tách song +song của cùng một god-file cùng được giữ lại sau một lần merge, trong đó hai +file còn không import nổi (``GraphQaMixin`` không tồn tại, +``ui.calendar_view`` đã bị xoá). Không ai phát hiện vì không có gì đi tìm. + +Cách làm +-------- +Dựng đồ thị import tĩnh bằng AST, bắt đầu từ ``__init__`` / ``__main__`` / +``app``, đi theo cả import tuyệt đối lẫn tương đối, kể cả import nằm trong thân +hàm (mã này dùng import muộn rất nhiều). Import một module con cũng chạy +``__init__.py`` của mọi gói cha, nên các gói cha đó cũng được coi là tới được. + +Module không tới được mà KHÔNG nằm trong ``ALLOWLIST`` thì cổng đỏ. Hai +ngoại lệ tự động, để danh sách miễn trừ không phải chép lại cùng một lý do +nhiều lần: ``__init__.py`` của một gói mà mọi thành viên đều dormant, và +module chỉ được chính mã dormant đã miễn trừ import. + +Seam chưa nối dây +------------------ +Một phần ``ALLOWLIST`` là *seam*: hợp đồng dựng trước để hai nhóm làm song +song, chờ bên kia nối vào. Những file đó mang nhãn ``SEAM · dựng `` +trong docstring đầu file, và cổng này đếm tuổi của chúng. Quá +``SEAM_MAX_AGE_DAYS`` thì in [WARN] — chỉ nhắc, không làm cổng đỏ. + +Danh sách miễn trừ +------------------ +``ALLOWLIST`` là mã dormant đã có TỪ TRƯỚC đợt refactor (xem +``docs/architecture/dormant-code.md``) cộng các seam đã dựng nhưng chưa nối +dây. Đây là danh sách **chỉ được co lại**: xoá hoặc nối dây một mục thì gỡ nó +khỏi đây, đừng bao giờ thêm mục mới để làm cổng xanh trở lại. + +Chạy: python scripts/check_orphan_modules.py +""" +from __future__ import annotations + +import argparse +import ast +import os +import re +import sys +from datetime import date +from pathlib import Path +from typing import Dict, List, Optional, Set, Tuple + +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: # noqa: BLE001 + pass + +REPO_ROOT = Path(__file__).resolve().parent.parent +PACKAGE = "cowork_local" + +#: Không phải mã production — không quét. +SKIP_TOP = { + ".git", ".gitea", ".vibeflow-preview", "__pycache__", "assets", "config", + "docs", "scripts", "skill_library", "slides", "tests", "tools", +} + +#: Điểm bắt đầu: mọi thứ tới được từ đây là mã đang sống. +ROOTS = (PACKAGE, f"{PACKAGE}.__main__", f"{PACKAGE}.app") + +#: Bao nhiêu ngày thì một seam chưa nối dây đáng được nhắc lại. +#: +#: Seam là hợp đồng dựng trước để hai nhóm làm song song — hợp lý trong vài +#: tuần, nhưng quá lâu thì nó không còn là hợp đồng nữa mà thành mã chết có lời +#: biện hộ. 30 ngày là một chu kỳ epic của dự án này: qua một chu kỳ mà vẫn +#: chưa ai nối thì phải quyết — nối, hoặc xoá. +#: +#: Chỉ nhắc, KHÔNG làm cổng đỏ: để nó đỏ thì CI sẽ đỏ vào một buổi sáng mà +#: không ai sửa gì cả, và cách nhanh nhất để xanh lại là sửa ngày. +SEAM_MAX_AGE_DAYS = 30 + +#: Nhận dạng nhãn seam trong docstring đầu file. +_SEAM_RE = re.compile(r"SEAM \u00b7 d\u1ef1ng (\d{4})-(\d{2})-(\d{2})") + +#: Mã dormant được chấp nhận, kèm lý do. CHỈ ĐƯỢC CO LẠI. +ALLOWLIST: Dict[str, str] = { + # --- dormant từ trước refactor (docs/architecture/dormant-code.md) ----- + "core/account_excel.py": "quản lý tài khoản — chưa bật, dormant từ trước R01", + "core/accounts.py": "quản lý tài khoản — chưa bật, dormant từ trước R01", + "core/codebase_memory_ui.py": "phần UI của codebase-memory — chưa bật", + "core/custom_agents.py": "bản agent tự tạo cũ, đã thay bằng core/co4e.py", + "core/graph_server.py": "máy chủ HTTP phục vụ đồ thị D3 — chỉ dùng khi bật cờ", + "core/groups.py": "nhóm người dùng — chưa bật, dormant từ trước R01", + "security/action_validator.py": "lớp bọc mỏng quanh command_risk_classifier", + "security/attachment_validator.py": "lớp bọc mỏng quanh command_risk_classifier", + "security/prompt_validator.py": "lớp bọc mỏng quanh command_risk_classifier", + "ui/accounts_tab.py": "màn quản lý tài khoản — chưa bật", + "ui/agent_manager_tab.py": "đã thay bằng presentation/monitoring/tabs/agents_admin_tab.py", + "ui/flow_dialog.py": "trình sửa luồng cũ, đã thay bằng Co4E Studio", + "ui/login_dialog.py": "bản này không có lớp đăng nhập", + "ui/mcp_servers_dialog.py": "đã thay bằng ui/connectors_panel.py", + "ui/skill_manager_tab.py": "đã thay bằng ui/skills_dialog.py", + # --- chạy bằng tiến trình con, không ai import ------------------------ + "mcp_servers/ms365_server.py": "chạy bằng subprocess (state.py)", + "mcp_servers/project_context_server.py": "chạy bằng subprocess", + "mcp_servers/project_context/server.py": "chạy bằng subprocess", + "mcp_servers/project_context/foundation.py": "gói MCP chạy bằng subprocess", + "mcp_servers/project_context/registry.py": "gói MCP chạy bằng subprocess", + "mcp_servers/project_context/runtime.py": "gói MCP chạy bằng subprocess", + "mcp_servers/project_context/providers/change.py": "gói MCP chạy bằng subprocess", + "mcp_servers/project_context/providers/issue.py": "gói MCP chạy bằng subprocess", + "mcp_servers/project_context/providers/knowledge.py": "gói MCP chạy bằng subprocess", + "mcp_servers/project_context/tools/change_context.py": "gói MCP chạy bằng subprocess", + "mcp_servers/project_context/tools/issue_context.py": "gói MCP chạy bằng subprocess", + "mcp_servers/project_context/tools/knowledge_search.py": "gói MCP chạy bằng subprocess", + # --- seam đã dựng, chưa nối dây (xem F-05 trong báo cáo đối chiếu) ---- + "application/workflows/co4e_workflow_service.py": "R07-T06 — bootstrap chưa gọi build_co4e_tab", + "presentation/co4e/co4e_tab.py": "factory chờ bootstrap.py nối, hạn đã ghi trong file", + "domain/security/tool_policy.py": "hình dạng dữ liệu, chờ nối vào ToolPolicyGateway", + "domain/workflows/run_record.py": "DTO, dùng khi Co4EWorkflowService được nối dây", + "domain/agents/agent_event_codec.py": "shim tạm, bỏ khi chat_panel dùng event có kiểu", + "infrastructure/filesystem/execution_workspace.py": "R06-T03 — chưa có call site", + "infrastructure/sandbox/sandbox_capabilities.py": "ma trận năng lực sandbox, chưa nối", + "infrastructure/config/settings_facade.py": "R02-T03 — chưa có call site", + "infrastructure/config/config_repository.py": "Protocol, chỉ dùng làm chú thích kiểu", + # --- vỏ chuyển tiếp (strangler-fig) ------------------------------------ + # Vỏ chuyển tiếp tồn tại để giữ ĐƯỜNG IMPORT CŨ chạy được, nên việc mã mới + # không import nó là trạng thái ĐÚNG chứ không phải thiếu sót. Ba vỏ còn + # lại (ui/chat_panel.py, ui/monitoring_tab.py, core/tools.py) hiện vẫn tới + # được vì presentation/ đang nhập ngược qua chúng — đi vòng như thế tạo chu + # trình import, và khi nào gỡ nốt thì chúng cũng xuống đây. + "ui/composer.py": "vỏ chuyển tiếp R08-T02, giữ đường import cũ", + # --- hạ tầng test / công cụ ------------------------------------------- + "conftest.py": "pytest tự nạp, không ai import", +} + + +def _discover() -> Dict[str, str]: + """{tên module đầy đủ: đường dẫn tương đối} cho mọi file production.""" + out: Dict[str, str] = {} + for dirpath, dirnames, filenames in os.walk(REPO_ROOT): + rel_dir = os.path.relpath(dirpath, REPO_ROOT).replace("\\", "/") + if rel_dir == ".": + rel_dir = "" + dirnames[:] = [d for d in dirnames if d not in SKIP_TOP] + else: + dirnames[:] = [d for d in dirnames if d != "__pycache__"] + for name in filenames: + if not name.endswith(".py"): + continue + rel = f"{rel_dir}/{name}" if rel_dir else name + parts = rel[:-3].split("/") + if parts[-1] == "__init__": + parts = parts[:-1] + out[".".join([PACKAGE] + parts) if parts else PACKAGE] = rel + return out + + +def _targets(module: str, node: ast.AST, modules: Dict[str, str]) -> List[str]: + """Các module trong gói mà một câu lệnh import trỏ tới.""" + names: List[str] = [] + if isinstance(node, ast.Import): + names = [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom): + if node.level: + parts = module.split(".") + # gói (__init__) tự nó là tiền tố; module thường thì lấy gói cha + if modules.get(module, "").endswith("__init__.py"): + pkg = parts + else: + pkg = parts[:-1] + up = node.level - 1 + if up: + pkg = pkg[: len(pkg) - up] + base = ".".join(pkg) + head = f"{base}.{node.module}" if node.module else base + names = [head] + [f"{head}.{a.name}" for a in node.names] + elif node.module: + names = [node.module] + [f"{node.module}.{a.name}" for a in node.names] + resolved = [] + for n in names: + if n in modules: + resolved.append(n) + elif n.rsplit(".", 1)[0] in modules: + resolved.append(n.rsplit(".", 1)[0]) + return resolved + + +def _reach(modules: Dict[str, str], edges: Dict[str, Set[str]], + roots) -> Set[str]: + """Tập module tới được từ ``roots`` theo đồ thị import.""" + seen: Set[str] = set() + stack = [r for r in roots if r in modules] + while stack: + module = stack.pop() + if module in seen: + continue + seen.add(module) + stack.extend(edges.get(module, ())) + # Import một module con cũng chạy __init__.py của mọi gói cha. + parts = module.split(".") + for i in range(1, len(parts)): + parent = ".".join(parts[:i]) + if parent in modules and parent not in seen: + stack.append(parent) + return seen + + +def find_orphans() -> List[str]: + """Đường dẫn các module production không tới được từ điểm bắt đầu.""" + modules = _discover() + edges: Dict[str, Set[str]] = {} + for module, rel in modules.items(): + try: + tree = ast.parse((REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace")) + except SyntaxError: + edges[module] = set() + continue + found: Set[str] = set() + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + found.update(_targets(module, node, modules)) + edges[module] = found + + seen = _reach(modules, edges, ROOTS) + return sorted(modules[m] for m in set(modules) - seen) + + +def find_dormant_reachable() -> Set[str]: + """Module chỉ tới được từ một mục trong ``ALLOWLIST``. + + Một module mà người import duy nhất là mã dormant đã được miễn trừ thì + dormant vì ĐÚNG LÝ DO ẤY — bắt nó phải có dòng miễn trừ riêng chỉ nhân đôi + cùng một thông tin, và tệ hơn là khiến người ta ngại tách file trong vùng + dormant. Đổi lại, khi mục dormant kia được nối dây hoặc bị xoá, cả nhánh + này tự động theo — sống theo hoặc bị báo lên, không có dòng miễn trừ cũ + nào ở lại che mắt. + """ + modules = _discover() + rel_to_mod = {rel: mod for mod, rel in modules.items()} + edges: Dict[str, Set[str]] = {} + for module, rel in modules.items(): + try: + tree = ast.parse((REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace")) + except SyntaxError: + edges[module] = set() + continue + found: Set[str] = set() + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + found.update(_targets(module, node, modules)) + edges[module] = found + + dormant_roots = [rel_to_mod[rel] for rel in ALLOWLIST if rel in rel_to_mod] + from_real = _reach(modules, edges, ROOTS) + from_dormant = _reach(modules, edges, dormant_roots) + # Bỏ chính các mục ALLOWLIST — chúng đã có dòng miễn trừ riêng, kể lại + # ở đây chỉ làm nhiễu. + return {modules[m] for m in from_dormant - from_real} - set(ALLOWLIST) + + +def seam_ages(today: Optional[date] = None) -> List[Tuple[str, int, bool]]: + """(đường dẫn, số ngày dormant, có nhãn không) cho mọi seam trong ALLOWLIST. + + Seam được nhận ra bằng chính nhãn ``SEAM · dựng `` trong docstring + đầu file chứ không bằng một danh sách thứ hai ở đây: hai danh sách là hai + chỗ phải nhớ cập nhật, và chỗ thứ hai bao giờ cũng là chỗ bị quên. + + Ngày trong nhãn là ngày file được thêm vào repo (lấy từ git lúc đặt nhãn), + không phải một hạn ai đó tự đặt. + """ + today = today or date.today() + out: List[Tuple[str, int, bool]] = [] + for rel in sorted(ALLOWLIST): + path = REPO_ROOT / rel + if not path.is_file(): + continue + head = path.read_text(encoding="utf-8", errors="replace")[:4000] + m = _SEAM_RE.search(head) + if m is None: + continue + made = date(int(m.group(1)), int(m.group(2)), int(m.group(3))) + age = (today - made).days + out.append((rel, age, age > SEAM_MAX_AGE_DAYS)) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--list-allowed", action="store_true", + help="In danh sách miễn trừ rồi thoát") + args = parser.parse_args() + + if args.list_allowed: + for rel, why in sorted(ALLOWLIST.items()): + print(f" {rel:62} {why}") + return 0 + + print("=" * 70) + print("CASAN Guard 'O' (Orphan): module production phải có ít nhất 1 nơi import") + print("=" * 70) + + orphans = find_orphans() + orphan_set = set(orphans) + + def _dormant_package(rel: str) -> bool: + """``__init__.py`` của một gói mà KHÔNG module nào bên trong tới được. + + Gói kiểu đó dormant vì thành viên của nó dormant — báo riêng nó ra chỉ + nhân đôi cùng một phát hiện. Gói còn dù chỉ một module đang sống thì + ``__init__.py`` cũng phải sống theo (import module con chạy nó), nên + luật này không che được mã chết thật. + """ + if not rel.endswith("__init__.py"): + return False + pkg = rel[: -len("__init__.py")] + members = [m for m in _discover().values() + if m.startswith(pkg) and m != rel] + return bool(members) and all( + m in orphan_set or m in ALLOWLIST for m in members) + + via_dormant = find_dormant_reachable() + unexpected = [o for o in orphans + if o not in ALLOWLIST + and o not in via_dormant + and not _dormant_package(o)] + stale = sorted(set(ALLOWLIST) - orphan_set) + + if stale: + print(f"\n[INFO] {len(stale)} mục trong ALLOWLIST nay đã có nơi import — gỡ khỏi danh sách:") + for rel in stale: + print(f" - {rel}") + + if unexpected: + print(f"\n[FAIL] {len(unexpected)} module production không ai import:") + for rel in unexpected: + print(f" x {rel}") + print("\nXoá chúng, hoặc nối dây, hoặc thêm vào ALLOWLIST kèm lý do") + print("(scripts/check_orphan_modules.py) nếu đó là mã dormant có chủ ý.") + return 1 + + seams = seam_ages() + overdue = [(rel, age) for rel, age, late in seams if late] + if overdue: + print(f"\n[WARN] {len(overdue)}/{len(seams)} seam đã dựng quá {SEAM_MAX_AGE_DAYS} " + f"ngày mà chưa nối dây — nối, hoặc xoá:") + for rel, age in sorted(overdue, key=lambda x: -x[1]): + print(f" ! {rel}: {age} ngày") + elif seams: + oldest = max(age for _, age, _ in seams) + print(f"\n[INFO] {len(seams)} seam chưa nối dây, cái lâu nhất {oldest} ngày " + f"(nhắc khi quá {SEAM_MAX_AGE_DAYS}).") + + total = len(_discover()) + # __init__.py của gói toàn thành viên dormant đã có luật riêng ở trên — + # không kể lại lần nữa. + implicit = sorted(r for r in via_dormant if not _dormant_package(r)) + if implicit: + print(f"\n[INFO] {len(implicit)} module chỉ được mã dormant import — " + f"dormant theo cùng lý do, không cần dòng miễn trừ riêng:") + for rel in implicit: + print(f" - {rel}") + print(f"\n[PASS] {total - len(orphans)}/{total} module production đều có nơi import " + f"({len(orphans)} mục dormant đã được miễn trừ).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index dfe0d12..513b3b4 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -8,7 +8,8 @@ Verification Stages (CASAN): 1. [C] Clean Architecture Guard (scripts/check_imports.py) 2. [A] Atomic & Secrets Audit (scripts/audit_security.py) 3. [S] Single Responsibility / LOC Guard (scripts/check_loc.py) - 4. [A/N] Automated Tests & No-Regression Suite (pytest) + 4. [O] Orphan Module Guard (scripts/check_orphan_modules.py) + 5. [A/N] Automated Tests & No-Regression Suite (pytest) """ from __future__ import annotations @@ -90,6 +91,13 @@ def main() -> int: "S - Single Responsibility LOC Limit (<= 400 LOC)", [sys.executable, str(REPO_ROOT / "scripts" / "check_loc.py"), "--max-lines", "400"], ), + # Ba cổng trên không cổng nào bắt được mã chết: một file không ai import + # vẫn đúng chiều phụ thuộc, vẫn không có credential, vẫn dưới 400 dòng. + # Cổng O đi tìm đúng khoảng trống đó. + ( + "O - Orphan Module Guard (moi module phai co noi import)", + [sys.executable, str(REPO_ROOT / "scripts" / "check_orphan_modules.py")], + ), ] if not args.skip_tests: From 81b948201197765fea79d0031a43b209fb95c939 Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Sun, 30 Aug 2026 10:37:06 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(tools):=205=20checker=20UI=20h=E1=BB=8F?= =?UTF-8?q?ng=20sau=20=C4=91=E1=BB=A3t=20t=C3=A1ch=20widget=20R08?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools/` không đổi một byte nào giữa hai bản, nhưng 5 checker vẫn chết vì chúng tìm control bằng `getattr(root, "ten")` trên đúng widget cũ — mà R08 đã dời control xuống widget con. Thêm ba helper dùng chung vào `capture_screens.py`: * `_own_member` — tên do app khai trên widget, không phải thừa kế từ Qt * `owner_of` — widget thật sự đang giữ tên đó, duyệt theo bề rộng * `control` — lấy control dù nó nằm ở cấp nào `check_controls_alive` từ "MẤT 24 control" về 0, kèm liệt kê 22 control đã đổi chỗ và 2 cái đổi tên. `check_probes_bite` từ 1/4 lên 6/6 phép cấy lỗi đều bị bắt — phép cấy thứ hai trỏ vào `ui/schedule_task_tab.py` đã bị xoá, nay trỏ vào `presentation/scheduling/kanban_board_widget.py`. Co-Authored-By: Claude Opus 5 (1M context) --- tools/capture_screens.py | 57 +++++ tools/check_controls_alive.py | 32 ++- tools/check_dashboard.py | 21 +- tools/check_design_parity.py | 87 ++++--- tools/check_graphrag_rescan.py | 6 +- tools/check_layout_geometry.py | 433 +++++++++++++++++---------------- tools/check_probes_bite.py | 4 +- 7 files changed, 378 insertions(+), 262 deletions(-) diff --git a/tools/capture_screens.py b/tools/capture_screens.py index b7c2a53..bdfdd0d 100644 --- a/tools/capture_screens.py +++ b/tools/capture_screens.py @@ -37,6 +37,63 @@ OUT_DIR = REPO / "docs" / "screens" THEMES = ("dark", "light") +def _own_member(widget, name: str) -> bool: + """True when `name` is declared by the app on `widget`, not inherited from Qt. + + Instance attributes live in ``vars(widget)``; methods live on the class, so + both are checked. Only classes defined inside ``cowork_local`` count, so a + Qt base class that happens to use the same name can never be mistaken for + the app's own control. + """ + if name in vars(widget): + return True + for base in type(widget).__mro__: + if getattr(base, "__module__", "").startswith("cowork_local") and name in vars(base): + return True + return False + + +def owner_of(root, name: str): + """The widget in `root`'s subtree that actually holds `name` today. + + EPIC R08 split every screen's god-widget into child widgets, so a control + that used to be ``tab.gran_combo`` now lives at ``tab.chart.gran_combo``, + and ``ScheduleTaskTab.columns`` moved to ``ScheduleTaskTab.kanban.columns``. + Checkers ask for a control by name and get back whichever widget owns it + today, so a further split does not break them again — while a control that + is genuinely gone still returns ``None`` and is still reported as a loss. + + Breadth-first, so the shallowest owner wins if a name appears twice. + """ + from PySide6.QtWidgets import QWidget + + seen: set[int] = set() + queue = [root] + while queue: + w = queue.pop(0) + if id(w) in seen: + continue + seen.add(id(w)) + if _own_member(w, name): + return w + queue.extend(c for c in w.children() if isinstance(c, QWidget)) + return None + + +def control(root, name: str, default=None): + """The control named `name` anywhere in `root`'s subtree — see `owner_of`. + + Falls back to a plain ``getattr`` on `root` so a screen that already bridges + its old attribute names itself keeps working: ``MonitoringTab.__getattr__`` + maps ``ov_*`` onto the extracted tabs, and a name served that way is on no + widget's ``__dict__`` for `owner_of` to find. + """ + holder = owner_of(root, name) + if holder is not None: + return getattr(holder, name, default) + return getattr(root, name, default) + + def _isolate_home() -> Path: """Copy the real config dir into a temp HOME and repoint the env at it.""" real = Path.home() / ".cowork_local" diff --git a/tools/check_controls_alive.py b/tools/check_controls_alive.py index 9979d26..0fe36c6 100644 --- a/tools/check_controls_alive.py +++ b/tools/check_controls_alive.py @@ -25,7 +25,9 @@ sys.path.insert(0, str(REPO.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent)) os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, owner_of, +) # controls.json lists every control in a FILE, and several files hold more than # one class (schedule_task_tab.py alone has the tab plus three dialogs). Only @@ -82,6 +84,11 @@ MOVED = { }, "ui\\structure_graph_view.py": { "self._msgs_toggle_btn": "→ cặp tab Đồ thị | Tin nhắn (view_tabs)", + # R08-T14 split the screen and renamed these two on the way out. Both + # are still on screen, under a new owner and a new name, so they are + # deliberate moves rather than losses. + "self._ag_collapse": "→ GraphQaWidget._collapse_btn (nút thu gọn bảng Agent)", + "self._msgs_view": "→ GraphMessagesView.widget (cây Tin nhắn theo ngày)", }, "app.py": { "self.settings_btn": "→ nút Cài đặt ở đáy rail (_nav_settings_btn)", @@ -163,8 +170,9 @@ def main() -> int: .read_text(encoding="utf-8")) own = owners(win) - alive = dead = moved = skipped = other_class = 0 + alive = dead = moved = skipped = other_class = relocated = 0 losses: list[tuple[str, str, str]] = [] + moves: list[tuple[str, str, str]] = [] for rec in index: holder = own.get(rec["file"]) if holder is None: @@ -187,14 +195,28 @@ def main() -> int: elif var in MOVED.get(rec["file"], {}): moved += 1 else: - dead += 1 - losses.append((rec["file"], var, - c.get("label_vi") or c.get("label") or "?")) + # EPIC R08 extracted sub-widgets out of every screen, so a + # control can still be on screen while no longer being a direct + # attribute of the screen's own widget (FolderTab.mode_btn -> + # FolderTab.preview.mode_btn). Searching the subtree keeps the + # subtraction test honest: it still fails on a control that is + # genuinely gone, but a relocation now reads as a relocation. + sub = owner_of(holder, name) + if sub is not None: + relocated += 1 + moves.append((rec["file"], var, type(sub).__name__)) + else: + dead += 1 + losses.append((rec["file"], var, + c.get("label_vi") or c.get("label") or "?")) print(f"control con song : {alive}") print(f"co y doi cho : {moved}") for f, v, w in [(f, v, MOVED[f][v]) for f in MOVED for v in MOVED[f]]: print(f" {v:26} {w}") + print(f"doi cho khi tach : {relocated} (con tren man, nam trong widget con)") + for f, var, own in sorted(moves): + print(f" {var:26} -> {own}.{var.split('.', 1)[1]}") print(f"thuoc class khac : {other_class} (hop thoai dinh nghia cung file)") print(f"khong kiem duoc : {skipped} (dialog dung rieng, bien cuc bo)") print(f"MAT : {dead}") diff --git a/tools/check_dashboard.py b/tools/check_dashboard.py index 0c856a2..72fcb40 100644 --- a/tools/check_dashboard.py +++ b/tools/check_dashboard.py @@ -19,7 +19,7 @@ sys.path.insert(0, str(REPO.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent)) os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 +from capture_screens import _apply_theme, _isolate_home, _load_fonts, control # noqa: E402 HEADER = ["_title", "chart_prev_btn", "_chart_period_lbl", "chart_next_btn", "gran_combo", "metric_combo", "currency_lbl", "currency_combo", @@ -42,7 +42,11 @@ def main() -> int: from cowork_local.i18n import set_language from cowork_local.state import AppContext - from cowork_local.ui.dashboard_tab import DashboardTab + # R08-T13 moved the screen out of ui/ into presentation/dashboard/ and + # split its header controls across UsageChartWidget / HabitsWidget, so + # every control below is looked up through `control()` rather than as a + # direct attribute of the tab. + from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab set_language("vi") tab = DashboardTab(AppContext(AppConfig.load())) @@ -53,7 +57,7 @@ def main() -> int: app.processEvents() fails: list[str] = [] - missing = [n for n in HEADER if getattr(tab, n, None) is None] + missing = [n for n in HEADER if control(tab, n) is None] print(f"control header con nguyen: {len(HEADER) - len(missing)}/{len(HEADER)}") if missing: fails.append(f"mat control: {missing}") @@ -61,7 +65,7 @@ def main() -> int: # Two rows: everything in the header must sit at one of exactly two y bands. tops = {} for n in HEADER: - w = getattr(tab, n) + w = control(tab, n) tops.setdefault(round(w.mapTo(tab, w.rect().topLeft()).y() / 10), []).append(n) print(f"so hang cua header : {len(tops)}") for band, names in sorted(tops.items()): @@ -70,14 +74,15 @@ def main() -> int: fails.append(f"header co {len(tops)} hang, cho 2") # Still wired: changing the metric must not throw and must stick. - before = tab.metric_combo.currentData() - tab.metric_combo.setCurrentIndex(1 - tab.metric_combo.currentIndex()) + metric = control(tab, "metric_combo") + before = metric.currentData() + metric.setCurrentIndex(1 - metric.currentIndex()) app.processEvents() - after = tab.metric_combo.currentData() + after = metric.currentData() print(f"doi chi so bieu do : {before} -> {after}") if after == before: fails.append("combo chi so khong doi duoc") - tab.refresh_btn.click() + control(tab, "refresh_btn").click() app.processEvents() print("bam Lam moi : khong loi") diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py index 30148bd..0567c49 100644 --- a/tools/check_design_parity.py +++ b/tools/check_design_parity.py @@ -25,7 +25,16 @@ sys.path.insert(0, str(REPO.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent)) os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, control, +) + +# EPIC R08 split every screen's god-widget into child widgets, so controls +# this file used to read straight off the screen object now live one level +# down (DashboardTab.chart.gran_combo, ScheduleTaskTab.kanban.columns, the +# Monitoring Overview sections, the Settings sub-pages...). `control()` +# looks a name up anywhere in the screen's subtree, so this checker keeps +# measuring the real control and still returns None when one is truly gone. def page_proposals(): @@ -107,7 +116,7 @@ def main() -> int: """How many distinct y-bands the named widgets occupy.""" bands = set() for n in names: - w = getattr(widget, n, None) + w = control(widget, n) if w is not None: bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12)) return len(bands) @@ -123,29 +132,31 @@ def main() -> int: "currency_combo"]) add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng") # Taller than the small tiles AND a bigger number = it reads as the headline. - taller = dash.card_cost.height() > dash.card_total.height() * 1.5 - bigger = "34px" in dash.card_cost.value_lbl.styleSheet() + card_cost = control(dash, "card_cost") + card_total = control(dash, "card_total") + taller = card_cost.height() > card_total.height() * 1.5 + bigger = "34px" in card_cost.value_lbl.styleSheet() add("dashboard", "Chi phí làm thẻ chính", taller and bigger, - f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · " + f"cao {card_cost.height()}px vs thẻ phụ {card_total.height()}px · " f"cỡ số {'34px' if bigger else 'như cũ'}") # --- 2 Schedule Kanban --- - lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or [])) + lanes = len(control(sched, "columns") or {}) if not lanes: from cowork_local.core.tasks import STATUSES lanes = len(STATUSES) add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane") - has_combo = getattr(sched, "view_combo", None) is not None + has_combo = control(sched, "view_combo") is not None add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo, "vẫn là combo" if has_combo else "đã thành tab") # The lane is only outlined while it actually holds something — seed data # may leave it empty, so drop a card in and read the style back. - run_col = sched.columns.get("running") + run_col = (control(sched, "columns") or {}).get("running") styled = "" if run_col is not None: from PySide6.QtWidgets import QListWidgetItem run_col.addItem(QListWidgetItem("probe")) - sched.column_headers["running"].setStyleSheet("") + control(sched, "column_headers")["running"].setStyleSheet("") sched.refresh() app.processEvents() styled = run_col.styleSheet() @@ -184,13 +195,13 @@ def main() -> int: # status line under the typing box, not inside it. So the test is that the # TYPING row holds only input + attach/send/stop, and the rest sits in its # own strip below. Demanding an empty strip would mean deleting features. - composer = getattr(chat, "composer", None) - bar = getattr(composer, "extra_bar", None) + composer = control(chat, "composer") + bar = control(composer, "extra_bar") from PySide6.QtWidgets import QPlainTextEdit, QTextEdit typing = composer.input in_typing_row = typing.parentWidget() is composer below = bar is not None and bar.objectName() == "composerStatus" - usage = getattr(chat, "_usage_total_lbl", None) + usage = control(chat, "_usage_total_lbl") usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage) add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi", below and usage_in_bar, @@ -199,23 +210,25 @@ def main() -> int: # --- 6 Co4E --- add("workspace-co4e", "Bỏ dải tab flow", - not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn") + not (control(co4e, "flow_scroll").isVisible() + or control(co4e, "flow_add_btn").isVisible()), "đã ẩn") + sections = control(co4e, "_sections") or [] add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách", - len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng") + len(sections) >= 3, f"{len(sections)} mục xếp chồng") add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải", - not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải") + not control(co4e, "flow_scroll").isVisible(), "nav → cột trái → panel phải") # --- 7 Folder / 8 GraphRAG --- folder = ws.tabs.widget(ws._folder_tab_idx) - title_lbl = getattr(folder, "path_lbl", None) + title_lbl = control(folder, "path_lbl") add("workspace-folder", "Path bar gộp vào tiêu đề", - title_lbl is not None and getattr(folder, "path_edit", None) is None, + title_lbl is not None and control(folder, "path_edit") is None, f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập") # "Thin bar at the bottom" = the terminal is the last thing in the column # and starts collapsed; the AI panel is a hideable right-hand pane. # Geometry is meaningless for a page that has never been shown, so ask the # widgets what state they are in instead of how tall they currently are. - term = getattr(folder, "terminal", None) + term = control(folder, "terminal") lay = folder.layout() last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None collapsed = term is not None and term._body.isHidden() @@ -227,13 +240,14 @@ def main() -> int: # One row = the path box and Export share a y-band. def band(w): return round(w.mapTo(graph, w.rect().topLeft()).y() / 10) - one_row = band(graph.path_edit) == band(graph._export_btn) + g_path, g_export = control(graph, "path_edit"), control(graph, "_export_btn") + one_row = band(g_path) == band(g_export) add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row, - f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}") + f"path y≈{band(g_path) * 10} · Export y≈{band(g_export) * 10}") # The toggle is _msgs_toggle_btn (the audit's MOVES table calls it # _msg_btn — a stale name); while it exists, this is still one button whose # label flips, not a pair of tabs. - toggle = getattr(graph, "_msgs_toggle_btn", None) + toggle = control(graph, "_msgs_toggle_btn") add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None, "vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab") @@ -248,7 +262,7 @@ def main() -> int: body = area.widget() if body is None or body.layout() is None: continue - if body.layout().indexOf(mon.ov_usage_group) >= 0: + if body.layout().indexOf(control(mon, "ov_usage_group")) >= 0: ov = body break assert ov is not None, "khong tim thay cot Tong quan" @@ -257,25 +271,29 @@ def main() -> int: "cột dọc" if one_col else "vẫn 2 cột") # Its own section = it is a direct child of the single column, not sharing a # row with the resource meters as it used to. - own = ov.layout().indexOf(mon.ov_pricing_group) >= 0 + pricing = control(mon, "ov_pricing_group") + own = ov.layout().indexOf(pricing) >= 0 add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own, - f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px") - strip = not mon.tabs.tabBar().isHidden() + f"là mục riêng trong cột, rộng {pricing.width()}px") + mon_tabs = control(mon, "tabs") + strip = not mon_tabs.tabBar().isHidden() add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng", - strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab") + strip and mon_tabs.count() == 8, f"dải tab hiện={strip}, {mon_tabs.count()} tab") # --- 17/18 dialogs --- from cowork_local.ui.settings_dialog import SettingsDialog from cowork_local.ui.task_editor_dialog import TaskEditorDialog s = SettingsDialog(win.ctx) + s_list = control(s, "section_list") add("dialog-settings", "Thêm cột mục lục bên trái", - s.section_list.count() == 5, f"{s.section_list.count()} mục") + s_list.count() == 5, f"{s_list.count()} mục") # The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider # left as its own group — not everything merged together. from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch - in_general = s._general_box.isAncestorOf(s.language_combo) and \ - s._general_box.isAncestorOf(s.theme_combo) - prov_apart = not s._general_box.isAncestorOf(s.provider_combo) + gen_box = control(s, "_general_box") + in_general = gen_box.isAncestorOf(control(s, "language_combo")) and \ + gen_box.isAncestorOf(control(s, "theme_combo")) + prov_apart = not gen_box.isAncestorOf(control(s, "provider_combo")) add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng", in_general and prov_apart, f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}") @@ -288,10 +306,11 @@ def main() -> int: f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper") s.close() t = TaskEditorDialog(ctx=win.ctx) - rows = [t.section_list.item(i).text() for i in range(t.section_list.count())] - like_settings = (t.section_list.count() == 5 - and t.section_stack.count() == 5 - and not hasattr(t, "step_tabs")) + t_list, t_stack = control(t, "section_list"), control(t, "section_stack") + rows = [t_list.item(i).text() for i in range(t_list.count())] + like_settings = (t_list.count() == 5 + and t_stack.count() == 5 + and control(t, "step_tabs") is None) add("dialog-task-editor", "Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group", like_settings, " · ".join(rows)) diff --git a/tools/check_graphrag_rescan.py b/tools/check_graphrag_rescan.py index 860a50c..79535fc 100644 --- a/tools/check_graphrag_rescan.py +++ b/tools/check_graphrag_rescan.py @@ -21,7 +21,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from capture_screens import ( # noqa: E402 - _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, owner_of) def main() -> int: @@ -50,6 +50,10 @@ def main() -> int: win.show() app.processEvents() st, w = win.structure, win.workspace + # R08-T14 split StructureGraphView: the browser view, the cached graph + # and the scan/render steps all moved onto GraphRenderer, while the shell + # only forwards the public methods. Probe the widget that owns them. + st = owner_of(st, "web") or st fails: list[str] = [] # startup itself must not build any of it diff --git a/tools/check_layout_geometry.py b/tools/check_layout_geometry.py index e806a5e..5a0cce8 100644 --- a/tools/check_layout_geometry.py +++ b/tools/check_layout_geometry.py @@ -1,213 +1,220 @@ -"""Round 2: does the built layout have the SHAPE the wireframes draw? - -Round 1 asks "does the feature exist". A screen can pass that and still be laid -out wrongly — right widgets, wrong order, wrong side, wrong proportions. This -round measures real geometry against what the audit page's wireframes depict: -reading order of the rail, section order down Monitoring, which side each pane -is on, and the size relationships the design calls out (hero card, the dot). - -Run: python tools/check_layout_geometry.py -""" -from __future__ import annotations - -import os -import sys - -sys.stdout.reconfigure(encoding="utf-8", errors="replace") -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(REPO.parent)) -sys.path.insert(0, str(Path(__file__).resolve().parent)) -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") - -from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 - -# The rail, top to bottom, as the audit page's rail() helper draws it. -RAIL_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"] -RAIL_BOTTOM = ["Dashboard", "Giám sát"] -# Monitoring ▸ Tổng quan, in the order the wireframe stacks it: what it cost → -# what the machine is doing → what the agent may touch → per-model prices → -# what actually happened. -MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group", - "ov_pricing_group", "ov_activity_group", "ov_audit_group"] - - -def main() -> int: - sandbox = _isolate_home() - from PySide6.QtWidgets import QApplication - - app = QApplication([]) - _load_fonts() - _freeze_schedulers() - - _apply_theme(app) # measure the styled window, not a bare one - from cowork_local.config import AppConfig, CONFIG_DIR - assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" - - from seed_demo_data import seed - seed() - - from cowork_local.app import MainWindow - from cowork_local.i18n import set_language - from cowork_local.state import AppContext - - set_language("vi") - win = MainWindow(AppContext(AppConfig.load()), user_name="local") - win.resize(1600, 950) - win.show() - for _ in range(8): - app.processEvents() - ws = win.workspace - fails: list[str] = [] - - def top_of(w, ref): - return w.mapTo(ref, w.rect().topLeft()).y() - - def left_of(w, ref): - return w.mapTo(ref, w.rect().topLeft()).x() - - # --- 1. rail: reading order, and the rail is on the LEFT --------------- - rows = [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())] - bottom = [win.nav_bottom.topLevelItem(i).text(0) - for i in range(win.nav_bottom.topLevelItemCount())] - print(f"thanh menu : {rows}") - print(f"nhom day : {bottom}") - if rows != RAIL_ORDER: - fails.append(f"thu tu thanh menu lech: {rows} != {RAIL_ORDER}") - if bottom != RAIL_BOTTOM: - fails.append(f"thu tu nhom day lech: {bottom} != {RAIL_BOTTOM}") - rail_x = left_of(win._nav_wrap, win) - content_x = left_of(win.pages, win) - print(f"rail x={rail_x} · noi dung x={content_x}") - if rail_x >= content_x: - fails.append("rail khong nam ben trai noi dung") - - # --- 2. rail header order: picker ABOVE the new-chat button ------------ - py, by = top_of(win.nav_project, win), top_of(win.nav_new_chat, win) - ry = top_of(win.nav_recents, win) - ay = top_of(win._account_row, win) - print(f"bo chon y={py} · nut chat moi y={by} · GAN DAY y={ry} · tai khoan y={ay}") - if not (py < by < ry < ay): - fails.append("thu tu doc cua rail sai (bo chon → chat moi → GAN DAY → tai khoan)") - - # --- 3. Monitoring: one column, sections in the drawn order ------------ - win._goto(win._ROW_MONITORING, None) - for _ in range(8): - app.processEvents() - mon = win._page_widgets[win._ROW_MONITORING] - tops = [(n, top_of(getattr(mon, n), mon)) for n in MON_ORDER if getattr(mon, n, None)] - lefts = {n: left_of(getattr(mon, n), mon) for n, _y in tops} - print("Monitoring, tu tren xuong:") - for n, y in tops: - print(f" {n:28} y={y:5} x={lefts[n]}") - if [n for n, _ in sorted(tops, key=lambda t: t[1])] != [n for n, _ in tops]: - fails.append("thu tu muc trong Monitoring khong khop ban ve") - # Sandbox and Permissions share a row; everything else is full width. - perm_y = top_of(mon.ov_permissions_group, mon) - sbx_y = top_of(mon.ov_sandbox_details_group, mon) - same_row = abs(perm_y - sbx_y) < 20 - print(f"Sandbox | Quyen cung hang: {same_row}") - if not same_row: - fails.append("Sandbox va Quyen khong cung mot hang") - price_w = mon.ov_pricing_group.width() - res_w = mon.ov_resource_group.width() - print(f"bang gia rong {price_w}px · tai nguyen {res_w}px (deu tron be ngang)") - if price_w < res_w * 0.95: - fails.append("bang gia model khong chiem tron be ngang") - - # --- 3b. Schedule: all seven lanes on screen, no horizontal scroll ----- - win._goto(win._ROW_SCHEDULE, None) - for _ in range(8): - app.processEvents() - sched = win._page_widgets[win._ROW_SCHEDULE] - from PySide6.QtWidgets import QScrollArea - lanes = list(sched.columns.values()) - # The page holds more than one scroll area — take the one the lanes live in. - board = next(sa for sa in sched.findChildren(QScrollArea) - if sa.isAncestorOf(lanes[0])) - rightmost = max(left_of(c, board.widget()) + c.width() for c in lanes) - fits = rightmost <= board.viewport().width() + 2 - print(f"Schedule: {len(lanes)} lane · mep phai x={rightmost} · " - f"khung rong {board.viewport().width()} · vua mot man = {fits}") - if len(lanes) != 7: - fails.append(f"chi co {len(lanes)} lane, thiet ke la 7") - if not fits: - fails.append(f"lane thu 7 nam ngoai man ({rightmost} > " - f"{board.viewport().width()}) — phai cuon ngang") - - # --- 4. Dashboard: hero left, taller; supporting tiles in a 2x2 -------- - win._goto(win._ROW_DASHBOARD, None) - for _ in range(8): - app.processEvents() - dash = win._page_widgets[win._ROW_DASHBOARD] - hero, small = dash.card_cost, dash.card_total - print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · " - f"the phu x={left_of(small, dash)} cao={small.height()}") - if left_of(hero, dash) >= left_of(small, dash): - fails.append("the Chi phi khong nam ben trai cac the phu") - if hero.height() < small.height() * 1.5: - fails.append("the Chi phi khong cao gap ruoi the phu") - row1 = top_of(dash.card_total, dash) - row2 = top_of(dash.card_out, dash) - print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)") - if row2 <= row1: - fails.append("4 the phu khong xep 2x2") - - # --- 5. Cowork: the dot clears the composer, and is the declared size -- - win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx) - for _ in range(8): - app.processEvents() - dock = win.help_agent - comp = ws._cowork.composer - dock_bottom = top_of(dock, win) + dock.height() - comp_top = top_of(comp, win) - print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}") - # Reading _DOT and comparing against it makes this unfailable — change the - # constant and the expectation moves with it (check_probes_bite caught - # exactly that). Bound what the design actually claims instead: a square - # chip, big enough to hit, far smaller than the 84x64 button it replaced. - # 26px was drawn, 52px is what the user asked for; 64 is the ceiling past - # which "gọn" stops being true. - if not 24 <= dock.width() <= 64 or dock.width() != dock.height(): - fails.append(f"cham tro ly {dock.width()}x{dock.height()}px, " - f"cho o khoang 24..64 va phai vuong") - if dock_bottom > comp_top: - fails.append("cham tro ly de len o nhap") - if left_of(dock, win) + dock.width() > win.width(): - fails.append("cham tro ly tran ra ngoai cua so") - - # --- 6. Co4E: sidebar left, canvas middle, config right --------------- - win._goto(win._ROW_WORKSPACE, ws._co4e_tab_idx) - for _ in range(8): - app.processEvents() - import cowork_local.ui.co4e_tab as co4e_mod - c4 = win.findChildren(co4e_mod.Co4ETab)[0] - xs = [c4._split.widget(i).x() for i in range(c4._split.count())] - print(f"Co4E 3 pane x = {xs}") - if xs != sorted(xs): - fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)") - heads = [h.text() for h, _b, _s in c4._sections.values()] - print(f"cot sidebar: {heads}") - if len(heads) != 4: - fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4") - - print() - if fails: - print("*** LECH BO CUC ***") - for f in fails: - print(" " + f) - return 1 - print("KET QUA VONG 2: hinh hoc khop ban ve") - return 0 - - -if __name__ == "__main__": - _rc = main() - # Qt (WebEngine especially) crashes during interpreter teardown with - # 0xC0000409 AFTER the work is done, which would mask the real result — - # and check_probes_bite reads these exit codes to decide whether a probe - # caught its mutation. Leave immediately with the verdict instead. - sys.stdout.flush() - sys.stderr.flush() - os._exit(_rc) +"""Round 2: does the built layout have the SHAPE the wireframes draw? + +Round 1 asks "does the feature exist". A screen can pass that and still be laid +out wrongly — right widgets, wrong order, wrong side, wrong proportions. This +round measures real geometry against what the audit page's wireframes depict: +reading order of the rail, section order down Monitoring, which side each pane +is on, and the size relationships the design calls out (hero card, the dot). + +Run: python tools/check_layout_geometry.py +""" +from __future__ import annotations + +import os +import sys + + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts, control, +) + +# The rail, top to bottom, as the audit page's rail() helper draws it. +RAIL_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"] +RAIL_BOTTOM = ["Dashboard", "Giám sát"] +# Monitoring ▸ Tổng quan, in the order the wireframe stacks it: what it cost → +# what the machine is doing → what the agent may touch → per-model prices → +# what actually happened. +MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group", + "ov_pricing_group", "ov_activity_group", "ov_audit_group"] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + + _apply_theme(app) # measure the styled window, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1600, 950) + win.show() + for _ in range(8): + app.processEvents() + ws = win.workspace + fails: list[str] = [] + + def top_of(w, ref): + return w.mapTo(ref, w.rect().topLeft()).y() + + def left_of(w, ref): + return w.mapTo(ref, w.rect().topLeft()).x() + + # --- 1. rail: reading order, and the rail is on the LEFT --------------- + rows = [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())] + bottom = [win.nav_bottom.topLevelItem(i).text(0) + for i in range(win.nav_bottom.topLevelItemCount())] + print(f"thanh menu : {rows}") + print(f"nhom day : {bottom}") + if rows != RAIL_ORDER: + fails.append(f"thu tu thanh menu lech: {rows} != {RAIL_ORDER}") + if bottom != RAIL_BOTTOM: + fails.append(f"thu tu nhom day lech: {bottom} != {RAIL_BOTTOM}") + rail_x = left_of(win._nav_wrap, win) + content_x = left_of(win.pages, win) + print(f"rail x={rail_x} · noi dung x={content_x}") + if rail_x >= content_x: + fails.append("rail khong nam ben trai noi dung") + + # --- 2. rail header order: picker ABOVE the new-chat button ------------ + py, by = top_of(win.nav_project, win), top_of(win.nav_new_chat, win) + ry = top_of(win.nav_recents, win) + ay = top_of(win._account_row, win) + print(f"bo chon y={py} · nut chat moi y={by} · GAN DAY y={ry} · tai khoan y={ay}") + if not (py < by < ry < ay): + fails.append("thu tu doc cua rail sai (bo chon → chat moi → GAN DAY → tai khoan)") + + # --- 3. Monitoring: one column, sections in the drawn order ------------ + win._goto(win._ROW_MONITORING, None) + for _ in range(8): + app.processEvents() + mon = win._page_widgets[win._ROW_MONITORING] + tops = [(n, top_of(getattr(mon, n), mon)) for n in MON_ORDER if getattr(mon, n, None)] + lefts = {n: left_of(getattr(mon, n), mon) for n, _y in tops} + print("Monitoring, tu tren xuong:") + for n, y in tops: + print(f" {n:28} y={y:5} x={lefts[n]}") + if [n for n, _ in sorted(tops, key=lambda t: t[1])] != [n for n, _ in tops]: + fails.append("thu tu muc trong Monitoring khong khop ban ve") + # Sandbox and Permissions share a row; everything else is full width. + perm_y = top_of(mon.ov_permissions_group, mon) + sbx_y = top_of(mon.ov_sandbox_details_group, mon) + same_row = abs(perm_y - sbx_y) < 20 + print(f"Sandbox | Quyen cung hang: {same_row}") + if not same_row: + fails.append("Sandbox va Quyen khong cung mot hang") + price_w = mon.ov_pricing_group.width() + res_w = mon.ov_resource_group.width() + print(f"bang gia rong {price_w}px · tai nguyen {res_w}px (deu tron be ngang)") + if price_w < res_w * 0.95: + fails.append("bang gia model khong chiem tron be ngang") + + # --- 3b. Schedule: all seven lanes on screen, no horizontal scroll ----- + win._goto(win._ROW_SCHEDULE, None) + for _ in range(8): + app.processEvents() + sched = win._page_widgets[win._ROW_SCHEDULE] + from PySide6.QtWidgets import QScrollArea + # R08-T11 moved the Kanban lanes onto KanbanBoardWidget; the shell only + # holds the header and the view switch. + lanes = list(control(sched, "columns").values()) + # The page holds more than one scroll area — take the one the lanes live in. + board = next(sa for sa in sched.findChildren(QScrollArea) + if sa.isAncestorOf(lanes[0])) + rightmost = max(left_of(c, board.widget()) + c.width() for c in lanes) + fits = rightmost <= board.viewport().width() + 2 + print(f"Schedule: {len(lanes)} lane · mep phai x={rightmost} · " + f"khung rong {board.viewport().width()} · vua mot man = {fits}") + if len(lanes) != 7: + fails.append(f"chi co {len(lanes)} lane, thiet ke la 7") + if not fits: + fails.append(f"lane thu 7 nam ngoai man ({rightmost} > " + f"{board.viewport().width()}) — phai cuon ngang") + + # --- 4. Dashboard: hero left, taller; supporting tiles in a 2x2 -------- + win._goto(win._ROW_DASHBOARD, None) + for _ in range(8): + app.processEvents() + dash = win._page_widgets[win._ROW_DASHBOARD] + # R08-T13 moved the stat cards onto TokenUsageCardWidget. + hero, small = control(dash, "card_cost"), control(dash, "card_total") + print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · " + f"the phu x={left_of(small, dash)} cao={small.height()}") + if left_of(hero, dash) >= left_of(small, dash): + fails.append("the Chi phi khong nam ben trai cac the phu") + if hero.height() < small.height() * 1.5: + fails.append("the Chi phi khong cao gap ruoi the phu") + row1 = top_of(control(dash, "card_total"), dash) + row2 = top_of(control(dash, "card_out"), dash) + print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)") + if row2 <= row1: + fails.append("4 the phu khong xep 2x2") + + # --- 5. Cowork: the dot clears the composer, and is the declared size -- + win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx) + for _ in range(8): + app.processEvents() + dock = win.help_agent + comp = control(ws._cowork, "composer") + dock_bottom = top_of(dock, win) + dock.height() + comp_top = top_of(comp, win) + print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}") + # Reading _DOT and comparing against it makes this unfailable — change the + # constant and the expectation moves with it (check_probes_bite caught + # exactly that). Bound what the design actually claims instead: a square + # chip, big enough to hit, far smaller than the 84x64 button it replaced. + # 26px was drawn, 52px is what the user asked for; 64 is the ceiling past + # which "gọn" stops being true. + if not 24 <= dock.width() <= 64 or dock.width() != dock.height(): + fails.append(f"cham tro ly {dock.width()}x{dock.height()}px, " + f"cho o khoang 24..64 va phai vuong") + if dock_bottom > comp_top: + fails.append("cham tro ly de len o nhap") + if left_of(dock, win) + dock.width() > win.width(): + fails.append("cham tro ly tran ra ngoai cua so") + + # --- 6. Co4E: sidebar left, canvas middle, config right --------------- + win._goto(win._ROW_WORKSPACE, ws._co4e_tab_idx) + for _ in range(8): + app.processEvents() + import cowork_local.ui.co4e_tab as co4e_mod + c4 = win.findChildren(co4e_mod.Co4ETab)[0] + c4_split = control(c4, "_split") + xs = [c4_split.widget(i).x() for i in range(c4_split.count())] + print(f"Co4E 3 pane x = {xs}") + if xs != sorted(xs): + fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)") + heads = [h.text() for h, _b, _s in (control(c4, "_sections") or {}).values()] + print(f"cot sidebar: {heads}") + if len(heads) != 4: + fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4") + + print() + if fails: + print("*** LECH BO CUC ***") + for f in fails: + print(" " + f) + return 1 + print("KET QUA VONG 2: hinh hoc khop ban ve") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_probes_bite.py b/tools/check_probes_bite.py index efa2ba7..d957992 100644 --- a/tools/check_probes_bite.py +++ b/tools/check_probes_bite.py @@ -33,7 +33,9 @@ MUTATIONS = [ "ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104", "check_layout_geometry.py"), ("tra lane Running ve khong vien", - "ui/schedule_task_tab.py", + # R08-T11 doi cho: ScheduleTaskTab tach ra, phan ve Kanban (ke ca vien + # canh bao cua lane Running) nam o presentation/scheduling/. + "presentation/scheduling/kanban_board_widget.py", 'if status == "running" and counts[status]:', 'if False:', "check_design_parity.py"), From fa94a0b2879e7dfc20129ddb71ad55a2cd9697e1 Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Sun, 30 Aug 2026 10:38:20 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(launcher):=20install.bat=20+=20run.bat?= =?UTF-8?q?,=20v=C3=A0=208=20th=C6=B0=20vi=E1=BB=87n=20thi=E1=BA=BFu=20tro?= =?UTF-8?q?ng=20requirements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trình chạy ---------- Cả hai lệnh trong README đều không chạy được từ một thư mục checkout tên khác `cowork_local`: python -m cowork_local -> No module named cowork_local python __main__.py -> ModuleNotFoundError: No module named 'cowork_local' Không sửa được bằng mẹo sys.path, vì `state.py` khởi động máy chủ MCP MS365 bằng tiến trình con `python -m cowork_local.mcp_servers.ms365_server` — tiến trình con cũng phải import được. Hai script tạo một junction ở `%LOCALAPPDATA%\CoworkLocal\launcher` thay vì bắt người dùng đổi tên thư mục làm việc. Môi trường ảo đặt ở `%LOCALAPPDATA%\CoworkLocal\venv`, cố ý KHÔNG đặt trong repo: các cổng chất lượng quét toàn bộ cây thư mục chứ không đọc `.gitignore`, nên một `.venv` ở đây sẽ biến vài nghìn module thư viện thành "mã production không ai import" và làm Gate O đỏ. requirements.txt ---------------- Chạy thử `run.bat` trên một profile trắng thì app chết ngay lúc mở: presentation/folder/code_editor.py:41 ModuleNotFoundError: No module named 'pygments' Quét toàn bộ import bên thứ ba thì thiếu 8 thư viện, trong đó `pygments` và `pydantic` là bắt buộc — import không có try/except, nên triệu chứng không phải "tính năng đó không chạy" mà là app không mở được. Nghĩa là cài đúng theo requirements.txt xong app vẫn hỏng. Đã tách rõ nhóm bắt buộc / tuỳ chọn kèm lý do từng dòng. `opendataloader-pdf` để nguyên dạng chú thích vì code tự cài khi cần qua `core/deps.py::ensure_module`. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 27 ++++++- install.bat | 205 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 19 ++++- run.bat | 112 ++++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 4 deletions(-) create mode 100644 install.bat create mode 100644 run.bat diff --git a/README.md b/README.md index eea2b0d..469d9f8 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,35 @@ infrastructure/ (Adapters, LLM Providers, Atomic Persistence, Keyring SecretStor ## 🚀 Quick Start -### 1. Run the Desktop Application -From the repository root: +### 1. Windows — two double-clicks + +``` +install.bat once, to install the Python dependencies +run.bat every time, to start the app +``` + +`install.bat` builds an isolated virtualenv under `%LOCALAPPDATA%\CoworkLocal` +(deliberately **outside** the repo — the quality gates walk the whole directory +tree, so a `.venv` in here would turn every vendored module into a Gate O +violation). Add `--dev` to also install the test dependencies, or `--system` to +skip the virtualenv and install into the Python already on `PATH`. + +Both scripts also make the source importable under its package name. That step +is not optional: `python -m cowork_local` only resolves when the checkout +directory is literally named `cowork_local`, and the MS365 MCP server is +launched as a subprocess with `python -m cowork_local.mcp_servers.ms365_server`, +so a differently-named checkout breaks the app *and* its subprocesses. The +scripts create a junction instead of forcing anyone to rename their folder. + +### 2. Any platform — run from source + +From the **parent** of a checkout directory named `cowork_local`: ```bash python -m cowork_local ``` -### 2. Run Automated Tests +### 3. Run Automated Tests ```bash python -m pip install -r requirements-test.txt pytest -q diff --git a/install.bat b/install.bat new file mode 100644 index 0000000..c689691 --- /dev/null +++ b/install.bat @@ -0,0 +1,205 @@ +@echo off +rem =========================================================================== +rem Cowork-Local BamBOO - cai dat thu vien Python (chay MOT lan) +rem +rem Cach dung: +rem install.bat cai vao moi truong ao rieng (khuyen dung) +rem install.bat --dev cai them thu vien de chay test +rem install.bat --system cai thang vao Python dang co, khong dung venv +rem install.bat --force dung lai moi truong ao tu dau +rem +rem Cai gi va cai o dau: +rem %LOCALAPPDATA%\CoworkLocal\venv moi truong ao +rem %LOCALAPPDATA%\CoworkLocal\launcher lien ket de import duoc goi +rem +rem Vi sao KHONG dat venv trong repo: cac cong chat luong +rem (scripts/check_orphan_modules.py, check_imports.py) quet TOAN BO cay thu +rem muc chu khong doc .gitignore, nen mot thu muc .venv o day se bien vai nghin +rem module cua thu vien thanh "ma production khong ai import" va lam cong do. +rem =========================================================================== +setlocal EnableExtensions EnableDelayedExpansion +chcp 65001 >nul 2>&1 + +set "REPO=%~dp0" +set "REPO=%REPO:~0,-1%" +set "APPHOME=%LOCALAPPDATA%\CoworkLocal" +set "VENV=%APPHOME%\venv" +set "LAUNCHER=%APPHOME%\launcher" + +set "DEV=0" +set "USE_SYSTEM=0" +set "FORCE=0" + +:parse_args +if "%~1"=="" goto args_done +if /I "%~1"=="--dev" set "DEV=1" & shift & goto parse_args +if /I "%~1"=="--system" set "USE_SYSTEM=1" & shift & goto parse_args +if /I "%~1"=="--force" set "FORCE=1" & shift & goto parse_args +if /I "%~1"=="-h" goto usage +if /I "%~1"=="--help" goto usage +echo [LOI] Khong hieu tham so: %~1 +goto usage +:args_done + +echo. +echo =========================================================================== +echo Cowork-Local BamBOO — cài đặt +echo =========================================================================== +echo Mã nguồn : %REPO% +echo Cài vào : %APPHOME% +echo. + +rem -------------------------------------------------------------------------- +rem 1. Tim Python +rem +rem Uu tien "py -3" chu khong phai "python": tren Windows 11, python.exe trong +rem WindowsApps thuong chi la lien ket mo Microsoft Store chu khong phai +rem Python that. +rem -------------------------------------------------------------------------- +set "PY=" +py -3 -c "import sys" >nul 2>&1 && set "PY=py -3" +if not defined PY ( + python -c "import sys" >nul 2>&1 && set "PY=python" +) +if not defined PY ( + echo [LỖI] Không tìm thấy Python trên máy này. + echo Cài Python 3.11 trở lên từ https://www.python.org/downloads/ + echo và nhớ tích "Add python.exe to PATH" khi cài. + goto fail +) + +for /f "delims=" %%V in ('%PY% -c "import sys;print('%%d.%%d'%%sys.version_info[:2])" 2^>nul') do set "PYVER=%%V" +echo [1/5] Python %PYVER% (%PY%) + +%PY% -c "import sys;raise SystemExit(0 if sys.version_info>=(3,11) else 1)" >nul 2>&1 +if errorlevel 1 ( + echo [LỖI] Cần Python 3.11 trở lên, máy đang có %PYVER%. + goto fail +) + +rem -------------------------------------------------------------------------- +rem 2. Moi truong ao +rem -------------------------------------------------------------------------- +if "%USE_SYSTEM%"=="1" ( + echo [2/5] Bỏ qua môi trường ảo — cài thẳng vào Python đang có ^(--system^) + set "PIP=%PY% -m pip" + goto deps +) + +if "%FORCE%"=="1" if exist "%VENV%" ( + echo [2/5] Xoá môi trường ảo cũ... + rmdir /s /q "%VENV%" 2>nul +) + +if exist "%VENV%\Scripts\python.exe" ( + echo [2/5] Môi trường ảo đã có — dùng lại +) else ( + echo [2/5] Tạo môi trường ảo... + %PY% -m venv "%VENV%" + if errorlevel 1 ( + echo [LỖI] Không tạo được môi trường ảo. + echo Thử lại với: install.bat --system + goto fail + ) +) +set "PIP="%VENV%\Scripts\python.exe" -m pip" + +rem -------------------------------------------------------------------------- +rem 3. Cai thu vien +rem -------------------------------------------------------------------------- +:deps +echo [3/5] Cập nhật pip... +%PIP% install --disable-pip-version-check --quiet --upgrade pip +if errorlevel 1 echo ^(bỏ qua — pip cũ vẫn dùng được^) + +echo [3/5] Cài thư viện từ requirements.txt ^(PySide6 khá nặng, chờ vài phút^)... +%PIP% install --disable-pip-version-check -r "%REPO%\requirements.txt" +if errorlevel 1 ( + echo [LỖI] Cài thư viện thất bại. + echo Nếu máy qua proxy công ty, đặt biến môi trường HTTPS_PROXY rồi chạy lại. + goto fail +) + +if "%DEV%"=="1" ( + echo [3/5] Cài thêm thư viện chạy test ^(--dev^)... + %PIP% install --disable-pip-version-check -r "%REPO%\requirements-test.txt" + if errorlevel 1 ( + echo [LỖI] Cài thư viện test thất bại. + goto fail + ) +) + +rem -------------------------------------------------------------------------- +rem 4. Lien ket de goi import duoc dung ten +rem +rem Ma nguon phai import duoc duoi dung ten "cowork_local". Thu muc nay ten la +rem "%~nx0"'s parent — neu no khong phai "cowork_local" thi ca +rem "python -m cowork_local" lan "python __main__.py" deu bao +rem ModuleNotFoundError, va tien trinh con chay may chu MCP MS365 +rem (state.py: python -m cowork_local.mcp_servers.ms365_server) cung hong theo. +rem +rem Junction giai quyet ca hai ma khong phai doi ten thu muc lam viec, khong +rem phai sua mot dong code nao, va khong can quyen quan tri. +rem -------------------------------------------------------------------------- +for %%I in ("%REPO%") do set "REPO_NAME=%%~nxI" +if /I "%REPO_NAME%"=="cowork_local" ( + echo [4/5] Thư mục đã đúng tên "cowork_local" — không cần liên kết + goto smoke +) + +if not exist "%LAUNCHER%" mkdir "%LAUNCHER%" >nul 2>&1 +if exist "%LAUNCHER%\cowork_local" rmdir "%LAUNCHER%\cowork_local" >nul 2>&1 +mklink /J "%LAUNCHER%\cowork_local" "%REPO%" >nul +if errorlevel 1 ( + echo [LỖI] Không tạo được liên kết thư mục. + echo Thư mục "%REPO_NAME%" không phải tên gói Python hợp lệ nên + echo ứng dụng không import được chính nó. Cách khác: đổi tên thư mục + echo mã nguồn thành "cowork_local". + goto fail +) +echo [4/5] Đã tạo liên kết: %LAUNCHER%\cowork_local + +rem -------------------------------------------------------------------------- +rem 5. Chay thu mot lan +rem -------------------------------------------------------------------------- +:smoke +if "%USE_SYSTEM%"=="1" (set "RUNPY=%PY%") else (set "RUNPY="%VENV%\Scripts\python.exe"") +if /I "%REPO_NAME%"=="cowork_local" ( + for %%I in ("%REPO%\..") do set "PKGPATH=%%~fI" +) else ( + set "PKGPATH=%LAUNCHER%" +) + +echo [5/5] Kiểm tra lại... +set "PYTHONPATH=!PKGPATH!" +%RUNPY% -c "import cowork_local, PySide6; print(' cowork_local + PySide6 nạp được')" +if errorlevel 1 ( + echo [LỖI] Cài xong nhưng vẫn chưa import được gói. + goto fail +) + +echo. +echo =========================================================================== +echo XONG. Từ giờ chỉ cần bấm đúp vào run.bat +echo =========================================================================== +echo. +pause +exit /b 0 + +:usage +echo. +echo install.bat [--dev] [--system] [--force] +echo. +echo --dev cài thêm thư viện để chạy test ^(pytest, pydantic^) +echo --system cài thẳng vào Python đang có, không tạo môi trường ảo +echo --force xoá môi trường ảo cũ rồi tạo lại từ đầu +echo. +pause +exit /b 2 + +:fail +echo. +echo Cài đặt KHÔNG thành công. Xem thông báo lỗi ở trên. +echo. +pause +exit /b 1 diff --git a/requirements.txt b/requirements.txt index a762b54..3151a94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,4 +21,21 @@ msal>=1.24.0 # OAuth device-code flow for MS365 keyring>=24.0.0 # OS credential store (token cache) # --- MCP (Model Context Protocol) --- -mcp>=1.0.0 # MCP client SDK (stdio transport) \ No newline at end of file +mcp>=1.0.0 # MCP client SDK (stdio transport) + +# --- Bắt buộc: import KHÔNG có try/except, thiếu là app chết lúc khởi động --- +# Hai dòng dưới đây bị bỏ sót cho tới 30/08. Triệu chứng của việc thiếu chúng +# không phải "tính năng đó không chạy" mà là ModuleNotFoundError ngay khi dựng +# cửa sổ chính — cài đúng theo requirements.txt xong app vẫn không mở được. +pygments>=2.15.0 # tô màu cú pháp — presentation/folder/code_editor.py +pydantic>=2,<3 # kiểu dữ liệu định tuyến — core/routing/models.py + +# --- Tuỳ chọn: mỗi chỗ dùng đều bọc try/except, thiếu thì mất tính năng --- +# Vẫn cài mặc định vì đều nhẹ và đều là tính năng người dùng nhìn thấy được. +networkx>=3.0 # bố cục đồ thị đẹp hơn cho GraphRAG nhiều node +holidays>=0.40 # lịch nghỉ theo quốc gia cho màn Lịch task +pywin32>=306; sys_platform == "win32" # Office -> PDF, thông báo Outlook + +# opendataloader-pdf # bộ đọc PDF thay thế — KHÔNG cài sẵn có chủ ý: +# # application/workspaces/graph_index_service.py tự cài +# # khi cần, qua core/deps.py::ensure_module. diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..6c54853 --- /dev/null +++ b/run.bat @@ -0,0 +1,112 @@ +@echo off +rem =========================================================================== +rem Cowork-Local BamBOO - chay ung dung +rem +rem Bam dup vao file nay la xong. +rem +rem Lan dau tien phai chay install.bat truoc. +rem +rem Tham so truyen vao duoc chuyen thang cho QApplication (xem app.py::run), +rem nen dung duoc cac co cua Qt, vi du: run.bat -style Fusion +rem =========================================================================== +setlocal EnableExtensions EnableDelayedExpansion +chcp 65001 >nul 2>&1 +title Cowork-Local BamBOO + +set "REPO=%~dp0" +set "REPO=%REPO:~0,-1%" +set "APPHOME=%LOCALAPPDATA%\CoworkLocal" +set "VENV=%APPHOME%\venv" +set "LAUNCHER=%APPHOME%\launcher" + +rem -------------------------------------------------------------------------- +rem 1. Chon trinh thong dich +rem +rem Uu tien moi truong ao do install.bat dung. Khong co thi quay ve Python cua +rem he thong — nguoi dung co the da chay "install.bat --system". +rem -------------------------------------------------------------------------- +rem Duong dan luon duoc boc trong dau nhay: %LOCALAPPDATA% co the chua khoang +rem trang neu ten dang nhap co khoang trang. +if exist "%VENV%\Scripts\python.exe" ( + set "RUNPY="%VENV%\Scripts\python.exe"" +) else ( + set "RUNPY=" + for /f "delims=" %%P in ('where py 2^>nul') do if not defined RUNPY set "RUNPY="%%P" -3" + if not defined RUNPY ( + for /f "delims=" %%P in ('where python 2^>nul') do if not defined RUNPY set "RUNPY="%%P"" + ) +) + +if not defined RUNPY ( + echo. + echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã. + echo. + pause + exit /b 1 +) + +rem Chua co moi truong ao thi kiem xem Python he thong co du thu vien khong. +rem Khong kiem thi nguoi dung chi nhan duoc mot ModuleNotFoundError tho, chang +rem biet la phai chay install.bat. +if not exist "%VENV%\Scripts\python.exe" ( + !RUNPY! -c "import PySide6" >nul 2>&1 + if errorlevel 1 ( + echo. + echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã. + echo. + pause + exit /b 1 + ) +) + +rem -------------------------------------------------------------------------- +rem 2. Duong dan de import duoc goi "cowork_local" +rem +rem Thu muc ma nguon phai mang dung ten "cowork_local" thi Python moi import +rem duoc no. Neu khong, install.bat da tao mot junction; o day chi kiem tra va +rem tu dung lai neu no bi xoa — de nguoi dung khong phai chay lai install.bat +rem chi vi mot thu muc tam bi don. +rem -------------------------------------------------------------------------- +for %%I in ("%REPO%") do set "REPO_NAME=%%~nxI" +if /I "%REPO_NAME%"=="cowork_local" ( + for %%I in ("%REPO%\..") do set "PKGPATH=%%~fI" +) else ( + if not exist "%LAUNCHER%\cowork_local" ( + if not exist "%LAUNCHER%" mkdir "%LAUNCHER%" >nul 2>&1 + mklink /J "%LAUNCHER%\cowork_local" "%REPO%" >nul 2>&1 + if errorlevel 1 ( + echo. + echo [LỖI] Không tạo được liên kết thư mục. Chạy install.bat lại. + echo. + pause + exit /b 1 + ) + ) + set "PKGPATH=%LAUNCHER%" +) + +rem -------------------------------------------------------------------------- +rem 3. Chay +rem +rem Dat thu muc lam viec o goc ma nguon va PYTHONPATH tro toi thu muc CHA cua +rem goi — dung cach CI dang chay (.gitea/workflows/ci.yaml). Tien trinh con +rem (may chu MCP MS365) thua ke PYTHONPATH nay nen cung import duoc. +rem -------------------------------------------------------------------------- +if defined PYTHONPATH ( + set "PYTHONPATH=!PKGPATH!;%PYTHONPATH%" +) else ( + set "PYTHONPATH=!PKGPATH!" +) +set "PYTHONIOENCODING=utf-8" +cd /d "%REPO%" + +!RUNPY! -m cowork_local %* +set "RC=%ERRORLEVEL%" + +if not "%RC%"=="0" ( + echo. + echo Ứng dụng thoát với mã lỗi %RC%. Xem thông báo ở trên. + echo. + pause +) +exit /b %RC% From d20306be08829b4a0916c07ac7b68aee7bbd62d3 Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Sun, 30 Aug 2026 10:41:02 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix(ui):=20d=E1=BA=A3i=20ch=E1=BB=8Dn=20ng?= =?UTF-8?q?=C3=B4n=20ng=E1=BB=AF=20c=E1=BA=AFt=20m=E1=BA=A5t=20ch=E1=BB=AF?= =?UTF-8?q?=20khi=20m=E1=BB=A5c=20=C4=91ang=20=C4=91=C6=B0=E1=BB=A3c=20ch?= =?UTF-8?q?=E1=BB=8Dn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `theme_qss.py` đặt `font-weight: 600` cho nút đang chọn, nhưng `QPushButton` tính `sizeHint()` theo phông thường. Chữ đậm rộng hơn — nên đúng lúc một mục được chọn thì nó không còn đủ chỗ và Qt cắt bớt chữ. Đo được trước khi vá: Tiếng Việt 85px cần 87px thiếu 2px English 67px cần 69px thiếu 2px Tự động (theo hệ thống) 170px cần 177px thiếu 7px 日本語 50px cần 50px — Tiếng Việt lộ rõ nhất vì nó vừa là nhãn dài nhất trong dải ngôn ngữ, vừa có dấu, và với người dùng tiếng Việt thì nó LUÔN là mục đang được chọn, tức luôn là mục bị in đậm. Chữ Nhật không dính vì bề rộng glyph CJK không đổi theo độ đậm. Cách vá: chừa sẵn bề rộng cho chữ đậm ngay khi tạo nút. Không viết cứng con số padding nào — lấy phần khung bằng cách trừ bề rộng chữ khỏi `sizeHint()`, rồi cộng lại bề rộng chính chữ ấy ở độ đậm 600, nên QSS đổi padding thì phép đo tự theo. Vá cả đường đổi nhãn khi chuyển ngôn ngữ, nếu không đổi sang tiếng Anh xong bề rộng vẫn giữ theo nhãn tiếng Việt cũ. `SegmentedControl` phải tách ra file riêng vì `ui/widgets.py` đang ở đúng 505 dòng mã = đúng trần bánh cóc của cổng LOC, thêm một dòng là cổng đỏ. File cũ giảm còn 466 dòng và vẫn nối lại tên cũ nên hai chỗ đang import không phải sửa gì. Kiểm cả 3 ngôn ngữ: 18/18 nút đều đủ chỗ. Co-Authored-By: Claude Opus 5 (1M context) --- ui/segmented_control.py | 134 +++++++++++++++++++++++++++++++++++++++ ui/widgets.py | 136 ++++++++++++++++++++++------------------ 2 files changed, 208 insertions(+), 62 deletions(-) create mode 100644 ui/segmented_control.py diff --git a/ui/segmented_control.py b/ui/segmented_control.py new file mode 100644 index 0000000..6b8df2d --- /dev/null +++ b/ui/segmented_control.py @@ -0,0 +1,134 @@ +"""Dải nút chọn một trong nhiều — tách khỏi ``ui/widgets.py``. + +Thay ``QComboBox`` ở những chỗ chỉ có hai đến bốn lựa chọn và người dùng nên +thấy hết cùng lúc: ngôn ngữ và giao diện trong Cài đặt. Mở một danh sách xổ +xuống chỉ để biết trong đó có gì là một cú bấm thừa. + +Tách ra vì hai lẽ. Một, ``ui/widgets.py`` đã chạm đúng trần nợ cũ của cổng LOC +nên không nhận thêm được dòng nào. Hai, chỗ này có một luật riêng đáng đứng +một mình: bề rộng nút phải chừa sẵn cho chữ IN ĐẬM — xem +:meth:`SegmentedControl._reserve_bold_width`. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont, QFontMetrics +from PySide6.QtWidgets import QHBoxLayout, QPushButton, QWidget + + +class SegmentedControl(QWidget): + """Two-to-four choices shown side by side instead of hidden in a drop-list. + + Exposes the slice of the QComboBox API this app's settings code uses + (addItem / findData / currentData / setCurrentIndex / currentIndexChanged), + so it drops into an existing form without touching the save/load paths. + """ + + currentIndexChanged = Signal(int) + + #: Độ đậm mà ``theme_qss.py`` áp cho nút đang chọn + #: (``QPushButton#segItem:checked { font-weight: 600 }``). Đổi ở QSS thì + #: phải đổi cả ở đây, nếu không chữ lại bị cắt. + _CHECKED_WEIGHT = QFont.DemiBold + + def __init__(self, parent=None): + """Dải nút chọn một trong nhiều — thay ``QComboBox`` khi chỉ có vài lựa chọn và + nên thấy hết cùng lúc. + """ + super().__init__(parent) + self._data: list = [] + self._buttons: list = [] + self._current = -1 + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + self._lay = lay + lay.addStretch(1) + + def addItem(self, text: str, data=None) -> None: # noqa: N802 - Qt-style name + """Thêm một lựa chọn kèm dữ liệu đi kèm.""" + btn = QPushButton(text) + btn.setObjectName("segItem") + btn.setCheckable(True) + btn.setCursor(Qt.PointingHandCursor) + index = len(self._buttons) + btn.clicked.connect(lambda _c=False, i=index: self.setCurrentIndex(i)) + self._lay.insertWidget(index, btn) + self._buttons.append(btn) + self._data.append(data) + self._reserve_bold_width(btn) + if self._current < 0: + self.setCurrentIndex(0) + + @staticmethod + def _reserve_bold_width(btn: QPushButton) -> None: + """Chừa sẵn bề rộng cho chữ khi nút được chọn và bị in đậm. + + ``QPushButton`` tính ``sizeHint()`` theo phông ĐANG dùng, tức phông + thường. Nhưng QSS lại đặt ``font-weight: 600`` cho nút đang chọn, và + chữ đậm rộng hơn chữ thường — nên đúng lúc một mục được chọn thì nó + không còn đủ chỗ và Qt cắt bớt chữ đi. + + Nhãn càng dài, thiếu càng nhiều: đo trên bản 30/08 thì "Tiếng Việt" + thiếu 2px, "English" 2px, còn "Tự động (theo hệ thống)" thiếu tới 7px. + Tiếng Việt lộ rõ nhất vì nó vừa là nhãn dài vừa có dấu, và với người + dùng tiếng Việt thì nó luôn là mục ĐANG được chọn. + + Cách đo: lấy phần khung (viền + padding do QSS quy định) bằng cách trừ + bề rộng chữ khỏi ``sizeHint()``, rồi cộng lại bề rộng của chính chữ ấy + ở độ đậm khi được chọn. Không viết cứng con số padding nào — QSS đổi + thì phép đo tự theo. + """ + btn.ensurePolished() + text = btn.text() + normal = btn.font() + chrome = btn.sizeHint().width() - QFontMetrics(normal).horizontalAdvance(text) + bold = QFont(normal) + bold.setWeight(SegmentedControl._CHECKED_WEIGHT) + btn.setMinimumWidth(chrome + QFontMetrics(bold).horizontalAdvance(text)) + + def findData(self, value) -> int: # noqa: N802 + """Chỉ số của lựa chọn mang dữ liệu ``value``; -1 nếu không có.""" + return self._data.index(value) if value in self._data else -1 + + def currentData(self): # noqa: N802 + """Dữ liệu của lựa chọn đang chọn; ``None`` nếu chưa chọn gì.""" + return self._data[self._current] if 0 <= self._current < len(self._data) else None + + def currentIndex(self) -> int: # noqa: N802 + """Chỉ số lựa chọn đang chọn; -1 nếu chưa chọn gì.""" + return self._current + + def count(self) -> int: + """Số lựa chọn đang có.""" + return len(self._buttons) + + def setItemText(self, index: int, text: str) -> None: # noqa: N802 + """Đổi nhãn một lựa chọn (dùng khi đổi ngôn ngữ). + + Tính lại bề rộng tối thiểu: nhãn mới dài ngắn khác nhau, giữ nguyên số + cũ thì hoặc cắt chữ hoặc chừa một khoảng trống vô cớ. + """ + if 0 <= index < len(self._buttons): + btn = self._buttons[index] + btn.setText(text) + btn.setMinimumWidth(0) + self._reserve_bold_width(btn) + + def setCurrentIndex(self, index: int) -> None: # noqa: N802 + """Chọn một mục và phát tín hiệu đổi. + + Chỉ số không hợp lệ hoặc trùng mục đang chọn thì chỉ đồng bộ lại trạng thái + nút, không phát tín hiệu — tránh vòng lặp khi chỗ gọi lại đặt lại chỉ số. + """ + if not (0 <= index < len(self._buttons)) or index == self._current: + for i, b in enumerate(self._buttons): + b.setChecked(i == self._current) + return + self._current = index + for i, b in enumerate(self._buttons): + b.setChecked(i == index) + self.currentIndexChanged.emit(index) + + +__all__ = ["SegmentedControl"] diff --git a/ui/widgets.py b/ui/widgets.py index c980d00..36ce64e 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -18,6 +18,9 @@ from PySide6.QtWidgets import ( from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING from ..theme import current_palette from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon +# Chuyen sang ui/segmented_control.py de file nay khong vuot tran no cu cua +# cong LOC; noi lai duoi ten cu vi 2 cho goi dang import tu day. +from .segmented_control import SegmentedControl # noqa: F401 def badge_pill_widget(text: str, object_name: str) -> QWidget: @@ -59,6 +62,11 @@ class FlowLayout(QLayout): FlowLayout example, ported).""" def __init__(self, parent=None, margin: int = 0, h_spacing: int = 8, v_spacing: int = 8): + """Layout tự xuống dòng khi hết bề ngang. + + Phải bật ``heightForWidth`` trên widget cha, nếu không Qt không hỏi lại chiều + cao và hàng tràn ra bị cắt mất. + """ super().__init__(parent) self._h_spacing = h_spacing self._v_spacing = v_spacing @@ -68,34 +76,44 @@ class FlowLayout(QLayout): enable_height_for_width(parent) def addItem(self, item) -> None: # noqa: N802 - Qt override + """Thêm một item vào cuối dòng chảy.""" self._items.append(item) def count(self) -> int: # noqa: N802 - Qt override + """Số item đang có trong layout.""" return len(self._items) def itemAt(self, index: int): # noqa: N802 - Qt override + """Item ở vị trí ``index``; ``None`` nếu ngoài phạm vi.""" return self._items[index] if 0 <= index < len(self._items) else None def takeAt(self, index: int): # noqa: N802 - Qt override + """Lấy item ra khỏi layout và trả về; ``None`` nếu ngoài phạm vi.""" return self._items.pop(index) if 0 <= index < len(self._items) else None def expandingDirections(self): # noqa: N802 - Qt override + """Không tự bung theo hướng nào — chiều cao do ``heightForWidth`` quyết định.""" return Qt.Orientations(Qt.Orientation(0)) def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt override + """Luôn ``True``: chiều cao của layout phụ thuộc bề rộng được cấp.""" return True def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override + """Chiều cao cần có nếu chỉ được cấp ``width`` — tính bằng cách xếp thử, không vẽ thật.""" return self._do_layout(QRect(0, 0, width, 0), test_only=True) def setGeometry(self, rect) -> None: # noqa: N802 - Qt override + """Xếp lại các item vào vùng được cấp.""" super().setGeometry(rect) self._do_layout(rect, test_only=False) def sizeHint(self): # noqa: N802 - Qt override + """Kích thước mong muốn — bằng kích thước tối thiểu.""" return self.minimumSize() def minimumSize(self): # noqa: N802 - Qt override + """Kích thước tối thiểu: đủ chứa item lớn nhất cộng lề.""" size = QSize() for item in self._items: size = size.expandedTo(item.minimumSize()) @@ -104,6 +122,11 @@ class FlowLayout(QLayout): return size def _do_layout(self, rect, test_only: bool) -> int: + """Xếp item thành nhiều dòng, xuống dòng khi hết bề rộng. + + ``test_only=True`` chỉ TÍNH chiều cao mà không dời widget nào — dùng cho + ``heightForWidth``, vì Qt hỏi chiều cao trước khi thật sự cấp vùng. + """ m = self.contentsMargins() effective = QRect(rect.x() + m.left(), rect.y() + m.top(), rect.width() - m.left() - m.right(), @@ -140,6 +163,7 @@ class StatCard(QFrame): shared by Dashboard and Monitoring's token/cost displays.""" def __init__(self): + """Thẻ một con số kèm nhãn — viên gạch của Bảng điều khiển và Giám sát.""" super().__init__() self.setFrameShape(QFrame.NoFrame) style_card(self) @@ -164,6 +188,7 @@ class StatCard(QFrame): lay.addWidget(self.sub_lbl) def set(self, title: str, value: str, sub: str = "") -> None: + """Đặt tiêu đề, giá trị và dòng phụ cho thẻ.""" self.title_lbl.setText(title) self.value_lbl.setText(value) self.sub_lbl.setText(sub) @@ -191,6 +216,7 @@ class BudgetCard(QFrame): (the app turns the remaining balance red past 85% budget used).""" def __init__(self): + """Thẻ ngân sách: số đã dùng trên hạn mức, kèm thanh tiến độ.""" super().__init__() self.setFrameShape(QFrame.NoFrame) style_card(self) @@ -227,6 +253,7 @@ class BudgetCard(QFrame): lay.addLayout(row) def set(self, title: str, value: str, sub: str, warn: bool = False) -> None: + """Đặt nội dung thẻ; ``warn=True`` tô con số bằng màu cảnh báo.""" self.title_lbl.setText(title) self.value_lbl.setText(value) self.value_lbl.setStyleSheet( @@ -235,6 +262,7 @@ class BudgetCard(QFrame): def fmt_tokens(n: int) -> str: + """Rút gọn số token cho dễ đọc: ``1_500`` → '1.5K', ``2_000_000`` → '2.00M'.""" if n >= 1_000_000: return f"{n / 1e6:.2f}M" if n >= 1_000: @@ -249,6 +277,11 @@ class _WheelGuard(QObject): spin box the cursor happens to pass over, silently changing values.""" def eventFilter(self, obj, event): # noqa: N802 + """Chặn lăn chuột trên widget chưa có focus. + + Không chặn thì lăn qua một combo box giữa trang sẽ âm thầm đổi giá trị của + nó thay vì cuộn trang — nuốt sự kiện để vùng cuộn nhận được. + """ if event.type() == QEvent.Wheel and not obj.hasFocus(): event.ignore() return True # eat it → the scroll area scrolls instead @@ -324,6 +357,11 @@ class _NarrowGuard(QObject): """ def __init__(self, owner: QWidget, threshold: int, apply): + """Tự gập một panel khi cửa sổ hẹp lại dưới ``threshold``. + + ``_auto`` phân biệt "ta đang giữ nó gập" với "người dùng tự gập": không + phân biệt thì kéo rộng cửa sổ ra sẽ bung cả panel mà người dùng cố ý gập. + """ super().__init__(owner) self._owner = owner self._threshold = threshold @@ -332,6 +370,7 @@ class _NarrowGuard(QObject): self._window = None def attach(self) -> None: + """Bắt đầu theo dõi sự kiện đổi kích thước của cửa sổ chứa widget.""" win = self._owner.window() if win is not None and win is not self._owner and win is not self._window: win.installEventFilter(self) @@ -344,11 +383,17 @@ class _NarrowGuard(QObject): self.check() def eventFilter(self, obj, ev): # noqa: N802 - Qt override + """Cửa sổ đổi kích thước thì kiểm lại xem có phải chuyển sang bố cục hẹp không.""" if ev.type() == QEvent.Resize and obj is self._window: self.check() return super().eventFilter(obj, ev) def check(self) -> None: + """Áp bố cục hẹp/rộng theo bề rộng cửa sổ. + + Ngưỡng được viết theo tỉ lệ hiển thị chuẩn và nhân lên theo tỉ lệ thật của + máy (xem ``ui_scale()``), nên màn 125%/150% không bị chuyển nhầm sớm. + """ win = self._owner.window() width = win.width() if win is not None else self._owner.width() # The threshold is written for the baseline scale and grows with the @@ -380,16 +425,19 @@ class ToggleSwitch(QCheckBox): _W, _H = 34, 18 def __init__(self, text: str = "", parent=None): + """Công tắc gạt kiểu iOS, vẽ thay cho ô tick.""" super().__init__(text, parent) self.setCursor(Qt.PointingHandCursor) def sizeHint(self): # noqa: N802 - Qt override + """Chừa thêm chỗ cho phần gạt bên cạnh nhãn.""" base = super().sizeHint() base.setWidth(base.width() + self._W) base.setHeight(max(base.height(), self._H + 4)) return base def paintEvent(self, _e): # noqa: N802 - Qt override + """Tự vẽ rãnh và núm gạt theo màu của theme đang dùng.""" from ..theme import current_palette p = current_palette() painter = QPainter(self) @@ -416,68 +464,6 @@ class ToggleSwitch(QCheckBox): painter.end() -class SegmentedControl(QWidget): - """Two-to-four choices shown side by side instead of hidden in a drop-list. - - Exposes the slice of the QComboBox API this app's settings code uses - (addItem / findData / currentData / setCurrentIndex / currentIndexChanged), - so it drops into an existing form without touching the save/load paths. - """ - - currentIndexChanged = Signal(int) - - def __init__(self, parent=None): - super().__init__(parent) - self._data: list = [] - self._buttons: list = [] - self._current = -1 - lay = QHBoxLayout(self) - lay.setContentsMargins(0, 0, 0, 0) - lay.setSpacing(0) - self._lay = lay - lay.addStretch(1) - - def addItem(self, text: str, data=None) -> None: # noqa: N802 - Qt-style name - from PySide6.QtWidgets import QPushButton - btn = QPushButton(text) - btn.setObjectName("segItem") - btn.setCheckable(True) - btn.setCursor(Qt.PointingHandCursor) - index = len(self._buttons) - btn.clicked.connect(lambda _c=False, i=index: self.setCurrentIndex(i)) - self._lay.insertWidget(index, btn) - self._buttons.append(btn) - self._data.append(data) - if self._current < 0: - self.setCurrentIndex(0) - - def findData(self, value) -> int: # noqa: N802 - return self._data.index(value) if value in self._data else -1 - - def currentData(self): # noqa: N802 - return self._data[self._current] if 0 <= self._current < len(self._data) else None - - def currentIndex(self) -> int: # noqa: N802 - return self._current - - def count(self) -> int: - return len(self._buttons) - - def setItemText(self, index: int, text: str) -> None: # noqa: N802 - if 0 <= index < len(self._buttons): - self._buttons[index].setText(text) - - def setCurrentIndex(self, index: int) -> None: # noqa: N802 - if not (0 <= index < len(self._buttons)) or index == self._current: - for i, b in enumerate(self._buttons): - b.setChecked(i == self._current) - return - self._current = index - for i, b in enumerate(self._buttons): - b.setChecked(i == index) - self.currentIndexChanged.emit(index) - - def section_panels(sections, width: int = 260): """Left list + right panel: pick a section, see that section only. @@ -544,6 +530,9 @@ def section_index(scroll, sections, width: int = 260): index.setFixedWidth(max(120, min(width, natural))) def _jump(item): + """Bấm một mục trong cột mục lục: cuộn sao cho mép trên của mục đó lên đúng + đỉnh vùng nhìn, chứ không chỉ "đâu đó trong tầm mắt". + """ anchor = item.data(Qt.UserRole) if anchor is not None: # Scroll so the section's top edge lands at the top of the viewport, @@ -583,6 +572,11 @@ class CollapseStrip(QWidget): WIDTH = 18 # click target width; wide enough to show the expand arrow def __init__(self, tooltip: str = "Click to expand", expand_dir: str = "right"): + """Dải mảnh còn lại sau khi gập một panel; bấm vào là bung ra. + + ``expand_dir`` quyết định mũi tên chỉ hướng nào — panel gập ở mép trái bung + sang phải và ngược lại. + """ super().__init__() self._hover = False self._dir = "left" if expand_dir == "left" else "right" @@ -592,21 +586,25 @@ class CollapseStrip(QWidget): self.setToolTip(tooltip) def enterEvent(self, e) -> None: # noqa: N802 + """Rê chuột vào thì làm nổi dải lên.""" self._hover = True self.update() super().enterEvent(e) def leaveEvent(self, e) -> None: # noqa: N802 + """Rời chuột thì trả dải về trạng thái thường.""" self._hover = False self.update() super().leaveEvent(e) def mousePressEvent(self, e) -> None: # noqa: N802 + """Bấm trái vào dải thì phát tín hiệu mở lại panel.""" if e.button() == Qt.LeftButton: self.clicked.emit() super().mousePressEvent(e) def paintEvent(self, e) -> None: # noqa: N802 + """Vẽ dải: nền theo theme cộng mũi tên chỉ hướng sẽ bung ra.""" p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) w = self.width() @@ -661,6 +659,7 @@ class PlanSection(QWidget): @staticmethod def _step_icon(status: str): + """Icon tương ứng trạng thái một bước: đang chạy, xong, lỗi hay còn chờ.""" if status == STEP_RUNNING: return icon("play", color=DOT_BLUE) if status == STEP_DONE: @@ -670,6 +669,9 @@ class PlanSection(QWidget): return dot_icon(DOT_GREY) # pending def __init__(self, title: str = "Plan", max_height: int = 150): + """Khối kế hoạch nhiều bước trong bong bóng chat, có giới hạn chiều cao để một + kế hoạch dài không đẩy phần trả lời ra khỏi màn hình. + """ super().__init__() self._title = title self._count = 0 @@ -715,6 +717,7 @@ class PlanSection(QWidget): self._update_header() def clear(self) -> None: + """Xoá sạch kế hoạch và ẩn cả khối đi.""" self.list.clear() self._count = 0 self.setVisible(False) @@ -726,10 +729,12 @@ class PlanSection(QWidget): self._update_header() def _toggle(self, on: bool) -> None: + """Gập/mở danh sách bước.""" self.list.setVisible(on) self._update_header() def _update_header(self) -> None: + """Cập nhật dòng tiêu đề: mũi tên gập/mở kèm số bước.""" arrow = "▾" if self.header.isChecked() else "▸" self.header.setText(f"{arrow} {self._title} ({self._count})") @@ -773,6 +778,7 @@ class CollapsibleSection(QWidget): self._update_header() def add(self, path: str) -> None: + """Thêm một đường dẫn vào mục; đã có rồi thì bỏ qua.""" if not path or path in self._paths: return self._paths.append(path) @@ -787,6 +793,7 @@ class CollapsibleSection(QWidget): self._update_header() def remove(self, path: str) -> None: + """Gỡ một đường dẫn khỏi mục.""" if path not in self._paths: return i = self._paths.index(path) @@ -797,9 +804,11 @@ class CollapsibleSection(QWidget): self._update_header() def paths(self) -> list[str]: + """Bản sao danh sách đường dẫn đang hiện trong mục.""" return list(self._paths) def clear(self) -> None: + """Xoá sạch mục.""" self._paths.clear() self.list.clear() self.setVisible(False) @@ -811,14 +820,17 @@ class CollapsibleSection(QWidget): self._update_header() def _toggle(self, on: bool) -> None: + """Gập/mở danh sách.""" self.list.setVisible(on) self._update_header() def _update_header(self) -> None: + """Cập nhật dòng tiêu đề: mũi tên gập/mở kèm số mục.""" arrow = "▾" if self.header.isChecked() else "▸" self.header.setText(f"{arrow} {self._title} ({len(self._paths)})") def _emit(self, item: QListWidgetItem) -> None: + """Bấm một dòng: phát đường dẫn lên để chỗ gọi mở tệp.""" path = item.data(Qt.UserRole) if path: self.activated.emit(path) From e29a0ccdbd31d1e630ed9557322a69abf0dceb8f Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Sun, 30 Aug 2026 10:41:38 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20v=C3=A1=204=20h=E1=BB=93i=20quy?= =?UTF-8?q?,=20t=C3=A1ch=204=20file=20ch=E1=BA=A1m=20tr=E1=BA=A7n=20LOC,?= =?UTF-8?q?=20docstring=20l=C3=AAn=20100%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hồi quy đã vá ------------- F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang `chat_input_box.py` nhưng để `_paths_from_mime()` ở lại `composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều gắn 1 tệp, khớp bản trước refactor. F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm `folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang `ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu tiên hỏng thì đổi provider chính là lúc phải thử lại. F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)` mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px. `check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor. F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()` và không bao giờ chạy. Tách file (F-09) ---------------- Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật: graph_renderer.py -> graph_scene_builder.py + graph_export.py co4e_workflow_service.py -> co4e_run_history.py json_config_repository.py -> config_sections.py agents_admin_tab.py -> shared/agent_kind_visuals.py File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`, giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau giữa các bảng Giám sát nữa. Docstring --------- 41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng. Seam chưa nối dây (F-05) ------------------------ 9 seam mang nhãn `SEAM · dựng ` kèm hai câu: được nối khi nào, và để dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O đọc nhãn đó và nhắc khi quá 30 ngày. 859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ. Co-Authored-By: Claude Opus 5 (1M context) --- __main__.py | 7 + app.py | 7 + .../conversation_application_service.py | 6 + .../conversations/core_runtime_adapter.py | 12 ++ .../conversations/tool_policy_gateway.py | 10 +- .../model_routing/core_routing_adapter.py | 4 + .../routing_application_service.py | 4 + .../monitoring/dashboard_query_service.py | 8 ++ application/monitoring/dto/__init__.py | 3 + application/monitoring/dto/audit_event_dto.py | 4 + .../monitoring/monitoring_query_service.py | 6 + application/monitoring/repository/__init__.py | 1 + .../repository/audit_event_repository.py | 12 ++ .../scheduling/ai_task_planner_service.py | 6 + .../scheduling/task_application_service.py | 4 + application/workflows/co4e_run_history.py | 99 +++++++++++++ .../workflows/co4e_workflow_service.py | 115 ++++++++------- .../workspaces/file_preview_helpers.py | 8 ++ .../workspaces/file_workspace_service.py | 3 + config.py | 10 ++ core/accounts.py | 9 ++ core/admin_agents.py | 13 ++ core/agent_command.py | 7 + core/agent_roles.py | 2 + core/agent_security.py | 11 ++ core/agent_security_types.py | 2 + core/ai_task_planner.py | 4 + core/appcontainer_sandbox.py | 1 + core/chat_agent.py | 12 ++ core/co4e.py | 57 ++++++++ core/co4e_builtins.py | 1 + core/co4e_run_manager.py | 39 ++++++ core/co4e_runner.py | 10 ++ core/code_agent.py | 5 + core/codebase_memory.py | 17 +++ core/codebase_memory_ui.py | 16 +++ core/context_budget.py | 16 +++ core/cron.py | 15 ++ core/custom_agents.py | 25 ++++ core/custom_icons.py | 4 + core/d3_graph.py | 1 + core/deps.py | 13 ++ core/doc_extract.py | 21 +++ core/doc_style_extract.py | 4 + core/ext_connectors.py | 11 ++ core/flows.py | 24 ++++ core/graph_server.py | 18 +++ core/groups.py | 7 + core/history.py | 17 +++ core/holiday_calendar.py | 5 + core/image_gen.py | 4 + core/jira_tool.py | 4 + core/link_fetch.py | 7 + core/mcp_client.py | 18 +++ core/model_pricing.py | 14 ++ core/ms365_auth.py | 10 ++ core/ms365_graph.py | 24 ++++ core/ms365_local.py | 8 ++ core/ms365_tools.py | 6 + core/permissions.py | 13 ++ core/pptx_edit.py | 10 ++ core/projects.py | 7 + core/routing/classifier.py | 5 + core/routing/clients.py | 6 + core/routing/models.py | 1 + core/routing/orchestrator.py | 3 + core/routing/prober.py | 12 ++ core/routing/scheduler.py | 10 ++ core/routing/selector.py | 7 + core/routing/service.py | 18 +++ core/routing/store.py | 7 + core/routing/switch_controller.py | 3 + core/sandbox_manager.py | 1 + core/skills.py | 15 ++ core/structure_graph.py | 30 ++++ core/task_excel.py | 2 + core/task_executors.py | 13 ++ core/task_import.py | 9 ++ core/task_scheduler.py | 30 +++- core/tasks.py | 13 ++ core/teams.py | 12 ++ core/telemetry_shared.py | 5 + core/tls_trust.py | 7 + core/tools.py | 4 + core/usage_cost.py | 1 + core/usage_periods.py | 3 + core/usage_tracker.py | 5 + core/windows_sandbox_vm.py | 1 + core/worker.py | 24 ++++ core/xlsx_write.py | 1 + domain/agents/agent_event.py | 31 +++++ domain/agents/agent_event_codec.py | 10 ++ domain/security/tool_policy.py | 18 +++ domain/tasks/schedule_calculator.py | 5 + domain/tools/tool_registry.py | 8 ++ domain/workflows/__init__.py | 1 + domain/workflows/run_record.py | 22 +++ domain/workspaces/workspace_session.py | 3 + i18n.py | 7 + infrastructure/config/config_repository.py | 15 ++ infrastructure/config/config_sections.py | 128 +++++++++++++++++ .../config/json_config_repository.py | 131 +++++++----------- infrastructure/config/schema_migration.py | 5 + infrastructure/config/settings_facade.py | 38 +++++ infrastructure/filesystem/command_tools.py | 10 ++ .../filesystem/execution_workspace.py | 13 ++ infrastructure/filesystem/fetch_tools.py | 2 + infrastructure/filesystem/file_tools.py | 10 ++ infrastructure/filesystem/tool_context.py | 6 + infrastructure/mcp/mcp_source_manager.py | 6 + .../persistence/json/atomic_json_file.py | 9 ++ .../json/conversation_repository_impl.py | 18 +++ .../persistence/json/task_repository_impl.py | 11 ++ .../json/workspace_repository_impl.py | 9 ++ infrastructure/providers/provider_registry.py | 10 ++ infrastructure/qt/qt_scheduler_clock.py | 5 + .../sandbox/sandbox_capabilities.py | 24 ++++ infrastructure/secrets/__init__.py | 3 + infrastructure/secrets/keyring_adapter.py | 13 ++ infrastructure/telemetry/audit_logger.py | 10 ++ infrastructure/telemetry/usage_sink.py | 16 +++ mcp_servers/ms365_server.py | 10 ++ mcp_servers/project_context/foundation.py | 35 ++++- .../project_context/providers/change.py | 15 +- .../project_context/providers/issue.py | 15 +- .../project_context/providers/knowledge.py | 15 +- mcp_servers/project_context/registry.py | 1 + mcp_servers/project_context/runtime.py | 12 ++ mcp_servers/project_context/server.py | 13 ++ .../project_context/tools/change_context.py | 10 ++ .../project_context/tools/issue_context.py | 11 ++ .../project_context/tools/knowledge_search.py | 7 + paths.py | 1 + presentation/chat/__init__.py | 5 +- presentation/chat/attachment_picker.py | 5 + presentation/chat/audio_recorder_widget.py | 1 + presentation/chat/chat_agents.py | 8 ++ presentation/chat/chat_bubble_style.py | 13 ++ presentation/chat/chat_event_stream.py | 16 ++- presentation/chat/chat_history_widget.py | 42 ++++++ presentation/chat/chat_input_box.py | 74 ++++++++-- presentation/chat/chat_output_panel.py | 4 + presentation/chat/chat_panel.py | 12 ++ presentation/chat/chat_session_store.py | 26 ++++ presentation/chat/chat_turn_runner.py | 35 ++++- presentation/chat/composer_mime.py | 86 ++++++++++++ presentation/chat/composer_widget.py | 91 +++++------- presentation/co4e/agent_list_panel.py | 1 + presentation/co4e/canvas_geometry.py | 12 ++ presentation/co4e/canvas_interaction_mixin.py | 39 ++++++ presentation/co4e/canvas_items.py | 56 ++++++++ presentation/co4e/co4e_agents.py | 10 ++ presentation/co4e/co4e_canvas_widget.py | 67 +++++++++ presentation/co4e/co4e_chat.py | 27 ++++ presentation/co4e/co4e_chat_view.py | 15 ++ presentation/co4e/co4e_flow_tabs.py | 14 ++ presentation/co4e/co4e_layout.py | 5 + presentation/co4e/co4e_run_control_widget.py | 5 + presentation/co4e/co4e_runs.py | 30 ++++ presentation/co4e/co4e_sidebar.py | 19 +++ presentation/co4e/co4e_tab.py | 11 ++ presentation/co4e/co4e_workflow_crud.py | 32 +++++ .../co4e/node_property_actions_mixin.py | 9 ++ presentation/co4e/node_property_panel.py | 15 ++ presentation/co4e/palette_list.py | 4 + presentation/co4e/skills_list_panel.py | 5 + presentation/co4e/step_config_section.py | 12 ++ presentation/dashboard/dashboard_tab.py | 23 ++- presentation/dashboard/habits_widget.py | 17 +++ .../dashboard/token_usage_card_widget.py | 8 ++ presentation/dashboard/usage_chart_widget.py | 54 +++++++- presentation/folder/ai_edit_model_resolver.py | 28 ++++ presentation/folder/ai_edit_pipeline.py | 28 ++++ presentation/folder/ai_file_editor_dialog.py | 10 ++ presentation/folder/code_editor.py | 30 ++++ .../folder/document_preview_manager.py | 23 +++ presentation/folder/folder_tab.py | 9 ++ .../folder/office_document_renderer.py | 20 +++ presentation/folder/workspace_file_tree.py | 9 ++ presentation/graph/graph_export.py | 81 +++++++++++ presentation/graph/graph_messages_view.py | 11 ++ presentation/graph/graph_qa_widget.py | 23 +++ presentation/graph/graph_renderer.py | 120 ++++++++-------- presentation/graph/graph_scene_builder.py | 95 +++++++++++++ presentation/graph/graph_scene_items.py | 34 +++++ presentation/graph/structure_graph_view.py | 18 +++ presentation/monitoring/monitoring_tab.py | 32 +++++ presentation/monitoring/shared/__init__.py | 3 + .../monitoring/shared/agent_kind_visuals.py | 67 +++++++++ presentation/monitoring/shared/ai_filter.py | 9 ++ presentation/monitoring/shared/badges.py | 11 ++ .../monitoring/shared/event_detail_panel.py | 7 + presentation/monitoring/shared/event_table.py | 23 +++ .../monitoring/shared/filter_scaffold.py | 12 ++ presentation/monitoring/shared/formatters.py | 2 + .../monitoring/shared/open_settings.py | 5 + presentation/monitoring/tabs/__init__.py | 3 + .../monitoring/tabs/action_logs_tab.py | 5 + .../monitoring/tabs/agent_edit_dialog.py | 11 ++ .../monitoring/tabs/agent_status_tab.py | 4 + .../monitoring/tabs/agents_admin_tab.py | 102 +++++--------- presentation/monitoring/tabs/mcp_tab.py | 5 + presentation/monitoring/tabs/overview_tab.py | 37 +++++ presentation/monitoring/tabs/pricing_panel.py | 16 +++ presentation/monitoring/tabs/sandbox_tab.py | 7 + .../monitoring/tabs/security_events_tab.py | 9 ++ .../monitoring/tabs/security_settings_tab.py | 5 + .../monitoring/tabs/tools_admin_tab.py | 15 ++ .../scheduling/ai_task_creator_dialog.py | 16 +++ .../scheduling/ai_task_import_dialog.py | 9 ++ .../scheduling/calendar_view_widget.py | 33 +++++ .../scheduling/kanban_board_widget.py | 27 ++++ presentation/scheduling/run_history_dialog.py | 2 + presentation/scheduling/schedule_task_tab.py | 27 ++++ .../settings/general_settings_widget.py | 10 ++ .../settings/parameter_settings_widget.py | 10 ++ .../settings/provider_settings_widget.py | 43 ++++++ .../settings/routing_settings_widget.py | 6 + presentation/shell/bootstrap.py | 4 + presentation/shell/lifecycle_coordinator.py | 10 ++ presentation/shell/main_window.py | 24 ++++ presentation/shell/nav_rail.py | 2 + presentation/shell/page_registry.py | 32 ++++- presentation/shell/rail_metrics.py | 5 + presentation/shell/rail_project.py | 7 + presentation/shell/session_events.py | 5 + presentation/shell/toast.py | 2 + presentation/shell/top_bar.py | 10 ++ presentation/shell/tray_manager.py | 5 + providers/anthropic.py | 28 ++++ providers/base.py | 42 ++++++ providers/openai_compat.py | 39 ++++++ security/attachment_validator.py | 1 + security/audit_logger.py | 12 ++ security/command_risk_classifier.py | 32 +++++ state.py | 9 ++ tests/integration/test_folder_tab.py | 20 +++ ui/accounts_tab.py | 96 +++++++++++++ ui/agent_manager_tab.py | 27 ++++ ui/co4e_agent_dialog.py | 17 +++ ui/co4e_tab.py | 42 +++++- ui/composer.py | 14 +- ui/connectors_panel.py | 37 +++++ ui/cowork_tab.py | 43 ++++++ ui/ext_connector_dialog.py | 15 ++ ui/file_edit_dialog.py | 16 +++ ui/flow_dialog.py | 71 ++++++++++ ui/help_agent_widget.py | 41 ++++++ ui/icons.py | 6 + ui/icons_admin_tab.py | 13 ++ ui/libreoffice_view.py | 39 ++++++ ui/login_dialog.py | 45 ++++++ ui/mcp_servers_dialog.py | 13 ++ ui/osutil.py | 1 + ui/permission_dialog.py | 8 ++ ui/routing_toggle.py | 18 +++ ui/settings_dialog.py | 15 ++ ui/sidebar.py | 24 ++++ ui/skill_manager_tab.py | 32 +++++ ui/skills_dialog.py | 36 +++++ ui/spline_chart.py | 10 ++ ui/task_editor_dialog.py | 22 +++ ui/terminal_panel.py | 35 +++++ ui/workspace_tab.py | 76 +++++++++- 264 files changed, 4593 insertions(+), 359 deletions(-) create mode 100644 application/workflows/co4e_run_history.py create mode 100644 infrastructure/config/config_sections.py create mode 100644 presentation/chat/composer_mime.py create mode 100644 presentation/graph/graph_export.py create mode 100644 presentation/graph/graph_scene_builder.py create mode 100644 presentation/monitoring/shared/agent_kind_visuals.py diff --git a/__main__.py b/__main__.py index 4c2c993..0f70253 100644 --- a/__main__.py +++ b/__main__.py @@ -17,6 +17,13 @@ def main() -> int: # a plain script (`python __main__.py`), `__package__` is empty so the # relative import fails — in that case put the package root (the parent # of this file's directory) on sys.path and use an absolute import. + """Điểm vào ``python -m cowork_local``. + + Import muộn để công cụ kiểu ``-h`` và test nạp được gói mà không phải dựng cả + ứng dụng Qt. Chạy như script thường (``python __main__.py``) thì + ``__package__`` rỗng nên import tương đối hỏng — lúc đó đưa thư mục cha vào + ``sys.path`` và dùng import tuyệt đối. + """ if __package__: from .app import run else: diff --git a/app.py b/app.py index 2249ee5..1db3034 100644 --- a/app.py +++ b/app.py @@ -61,6 +61,12 @@ def _set_windows_app_id() -> None: def run(argv: List[str] | None = None) -> int: + """Điểm vào ứng dụng: dựng Composition Root, gieo dữ liệu mặc định, áp theme + rồi mở cửa sổ chính. + + Mọi bước gieo (skill dựng sẵn, flow dựng sẵn) đều bọc trong ``try`` — việc + dọn nhà không bao giờ được phép chặn app khởi động. + """ argv = argv if argv is not None else sys.argv _set_windows_app_id() app = QApplication.instance() or QApplication(argv) @@ -115,6 +121,7 @@ def run(argv: List[str] | None = None) -> int: win = MainWindow(ctx, user_name="local") def _reapply_system_theme(*_a): + """Theme đang để "Theo hệ thống" thì áp lại mỗi khi Windows đổi sáng/tối.""" if ctx.config.theme == "system": set_active_theme("system") app.setStyleSheet(stylesheet("system")) diff --git a/application/conversations/conversation_application_service.py b/application/conversations/conversation_application_service.py index fdc9046..400e441 100644 --- a/application/conversations/conversation_application_service.py +++ b/application/conversations/conversation_application_service.py @@ -75,6 +75,12 @@ class ConversationApplicationService: permission_request: Optional[PermissionRequest] = None, attachment_reader: Optional[AttachmentReader] = None, ) -> None: + """Nhận vào các cổng (port) thay vì tự dựng phụ thuộc. + + ``model`` và ``tools`` bắt buộc; mọi thứ còn lại là tuỳ chọn và để None thì + bỏ qua bước đó. Nhờ vậy test dựng được service với đúng phần nó cần kiểm, + không phải dựng cả provider thật lẫn sandbox. + """ self._model = model self._tools = tools # Every hook is optional so the service degrades to a plain chat turn. diff --git a/application/conversations/core_runtime_adapter.py b/application/conversations/core_runtime_adapter.py index aabf714..0fea5ea 100644 --- a/application/conversations/core_runtime_adapter.py +++ b/application/conversations/core_runtime_adapter.py @@ -51,9 +51,11 @@ class CoreModelCall: """ def __init__(self, provider: Any) -> None: + """Bọc một provider của ``core/`` vào cổng ``ModelCallPort``.""" self._provider = provider def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None): + """Gọi model một lượt, có tự phục hồi khi tràn context hoặc bị giới hạn tốc độ.""" from ...core.code_agent import _call_provider_with_recovery return _call_provider_with_recovery(self._provider, messages, tools, on_text, @@ -66,6 +68,11 @@ class CoreToolRuntime: def __init__(self, output_dir: Path, *, title: str = "", extra_tools: Optional[Sequence[Any]] = None, extra_executor=None, security_config: Any = None, agent_role: str = "") -> None: + """Bọc bộ tool của ``core/`` vào cổng ``ToolRuntimePort``. + + Tên các tool phụ được gom sẵn vào một ``set`` ngay tại đây: mỗi lượt gọi tool + đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool. + """ self._output_dir = Path(output_dir) self._title = title self._extra_tools = list(extra_tools or ()) @@ -80,6 +87,7 @@ class CoreToolRuntime: # -- the configured extra tools, for the system-prompt hints ---------- # @property def extra_names(self) -> frozenset: + """Tên các tool bổ sung (MCP, connector) ngoài bộ dựng sẵn.""" return frozenset(self._extra_names) def _tool_context(self): @@ -206,6 +214,7 @@ class CoreToolRuntime: "plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]} def snapshot(self) -> Any: + """Ảnh chụp thư mục kết quả trước lượt chạy — dùng để biết tệp nào mới sinh ra.""" from ...core.tools import _snapshot return _snapshot(self._output_dir) @@ -294,16 +303,19 @@ def build_cowork_conversation_service( _apply_project_context(messages, project_context) def prompt_guard(messages: List[Dict[str, Any]]) -> None: + """Chốt an toàn cho prompt trước khi gửi: quét dấu hiệu tiêm lệnh.""" from ...core import agent_security agent_security.enforce_prompt(provider, messages, security_config, emit) def command_guard(name: str, args: Dict[str, Any]) -> None: + """Chốt an toàn cho lệnh shell trước khi chạy: phân loại rủi ro và chặn/hỏi.""" from ...core import agent_security agent_security.enforce_command(provider, name, args, security_config, emit) def compact(messages: List[Dict[str, Any]], cancel) -> None: + """Nén lịch sử hội thoại khi gần đầy cửa sổ ngữ cảnh.""" from ...core import context_budget context_budget.maybe_compact(provider, messages, security_config, diff --git a/application/conversations/tool_policy_gateway.py b/application/conversations/tool_policy_gateway.py index 7d60ffd..6c66295 100644 --- a/application/conversations/tool_policy_gateway.py +++ b/application/conversations/tool_policy_gateway.py @@ -39,7 +39,10 @@ from cowork_local.domain.tools import ToolCapability, ToolRegistry class ConfirmGate(Protocol): """Shape of the existing ``PermissionGate`` both engines already use.""" - def request(self, payload: Dict[str, Any]) -> bool: ... + """Hỏi người dùng; trả về ``True`` nếu được đồng ý.""" + def request(self, payload: Dict[str, Any]) -> bool: + """Hỏi người dùng về một lời gọi tool; trả về ``True`` nếu được đồng ý.""" + ... class ToolPolicyGateway: @@ -54,6 +57,11 @@ class ToolPolicyGateway: """ def __init__(self, registry: ToolRegistry, gated_capabilities: ToolCapability) -> None: + """Nhận sổ đăng ký tool và tập năng lực cần xin phép. + + Truyền vào chứ không viết cứng: mỗi bề mặt chat có ngưỡng riêng, và test đặt + được ngưỡng của mình mà không đụng cấu hình thật. + """ self._registry = registry self._gated_capabilities = gated_capabilities diff --git a/application/model_routing/core_routing_adapter.py b/application/model_routing/core_routing_adapter.py index f2fdfa8..4e4afd9 100644 --- a/application/model_routing/core_routing_adapter.py +++ b/application/model_routing/core_routing_adapter.py @@ -33,6 +33,7 @@ class CoreRoutingEngine: """ def __init__(self, routing_service: Any) -> None: + """Bọc ``core/routing/service.py`` vào cổng quyết định định tuyến.""" self._routing_service = routing_service def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation: @@ -126,6 +127,9 @@ class AppContextModeResolver: """ def __init__(self, ctx: Any) -> None: + """Đọc chế độ định tuyến từ ``AppContext``, để tầng application không phải biết + hình dạng của context. + """ self._ctx = ctx def mode_for(self, surface: str) -> RoutingMode: diff --git a/application/model_routing/routing_application_service.py b/application/model_routing/routing_application_service.py index 9faf703..6db95a6 100644 --- a/application/model_routing/routing_application_service.py +++ b/application/model_routing/routing_application_service.py @@ -78,6 +78,10 @@ class RoutingApplicationService: *, confirm_timeout_sec: Optional[Callable[[], float]] = None, ) -> None: + """``mode_resolver`` để None thì mọi bề mặt đều coi như đang ở chế độ mặc định. + ``confirm_timeout_sec`` là hàm chứ không phải số: người dùng đổi thiết lập + giữa chừng thì lần hỏi sau phải theo giá trị mới. + """ self._decision_port = decision_port self._mode_resolver = mode_resolver # A callable rather than a number: the timeout lives in mutable config diff --git a/application/monitoring/dashboard_query_service.py b/application/monitoring/dashboard_query_service.py index 4a5194d..9de2971 100644 --- a/application/monitoring/dashboard_query_service.py +++ b/application/monitoring/dashboard_query_service.py @@ -32,6 +32,9 @@ class DashboardQueryService: """ def __init__(self, ctx: Any, directory: Optional[Path] = None) -> None: + """``directory`` để None thì đọc thư mục telemetry mặc định; test trỏ nó vào + ``tmp_path`` để không chạm dữ liệu thật. + """ self.ctx = ctx self._directory = directory @@ -94,16 +97,21 @@ class DashboardQueryService: return ut.period_totals(events, granularity, self.pricing(), offset) def period_range_label(self, granularity: str, offset: int) -> str: + """Nhãn hiển thị của một kỳ (tuần/tháng/năm cộng độ lệch).""" from cowork_local.core import usage_tracker as ut return ut.period_range_label(granularity, offset) def budget_status(self): + """Tình trạng ngân sách: đã dùng bao nhiêu, còn lại bao nhiêu, có vượt ngưỡng chưa.""" from cowork_local.core import usage_tracker as ut return ut.budget_status(self.ctx.config) def set_budget(self, amount: float, currency: str) -> None: + """Đặt hạn mức ngân sách mới — mở một chu kỳ đếm mới, chi tiêu trước đó không + còn được tính vào. + """ from cowork_local.core import usage_tracker as ut ut.set_budget(self.ctx.config, amount, currency) diff --git a/application/monitoring/dto/__init__.py b/application/monitoring/dto/__init__.py index e69de29..d81abbf 100644 --- a/application/monitoring/dto/__init__.py +++ b/application/monitoring/dto/__init__.py @@ -0,0 +1,3 @@ +"""DTO của phân hệ Giám sát: hình dạng dữ liệu mà tầng application trả cho +giao diện, không phụ thuộc nguồn đọc. +""" diff --git a/application/monitoring/dto/audit_event_dto.py b/application/monitoring/dto/audit_event_dto.py index 31ba1f3..8bd6dca 100644 --- a/application/monitoring/dto/audit_event_dto.py +++ b/application/monitoring/dto/audit_event_dto.py @@ -12,6 +12,9 @@ from typing import Any, Dict @dataclass(frozen=True) class AuditEventDTO: + """Một sự kiện kiểm toán ở dạng tầng application dùng — không phụ thuộc khuôn + lưu trên đĩa, nên đổi định dạng nhật ký không kéo theo sửa giao diện. + """ ts: str kind: str name: str @@ -40,6 +43,7 @@ class AuditEventDTO: ) def to_dict(self) -> Dict[str, Any]: + """Bản ghi dưới dạng dict cho lớp giao diện.""" return { "ts": self.ts, "kind": self.kind, "agent_role": self.agent_role, "name": self.name, "ok": self.ok, "detail": self.detail, diff --git a/application/monitoring/monitoring_query_service.py b/application/monitoring/monitoring_query_service.py index 0024f7e..1a35451 100644 --- a/application/monitoring/monitoring_query_service.py +++ b/application/monitoring/monitoring_query_service.py @@ -16,6 +16,7 @@ from .repository.audit_event_repository import AuditEventRepository @dataclass(frozen=True) class Page: + """Một trang kết quả truy vấn nhật ký: các mục, tổng số, số trang và cỡ trang.""" items: List[AuditEventDTO] total: int page: int @@ -23,6 +24,7 @@ class Page: @property def has_more(self) -> bool: + """Còn trang sau nữa không.""" return self.page * self.page_size < self.total @@ -31,11 +33,15 @@ class MonitoringQueryService: audit log; this service never writes anything.""" def __init__(self, repository: AuditEventRepository) -> None: + """Nhận kho sự kiện kiểm toán qua tham số — bản thật đọc đĩa, bản test nằm + trong bộ nhớ. + """ self._repository = repository def query(self, kind: Optional[str] = None, ok: Optional[bool] = None, text: Optional[str] = None, sort_by: str = "ts", descending: bool = True, page: int = 1, page_size: int = 50) -> Page: + """Lọc theo loại/kết quả/từ khoá, sắp xếp rồi cắt thành một trang.""" events = self._repository.load(kind=kind) if ok is not None: diff --git a/application/monitoring/repository/__init__.py b/application/monitoring/repository/__init__.py index e69de29..099e195 100644 --- a/application/monitoring/repository/__init__.py +++ b/application/monitoring/repository/__init__.py @@ -0,0 +1 @@ +"""Cổng đọc dữ liệu của phân hệ Giám sát — hợp đồng, không phải cài đặt.""" diff --git a/application/monitoring/repository/audit_event_repository.py b/application/monitoring/repository/audit_event_repository.py index a08492c..81fe1d7 100644 --- a/application/monitoring/repository/audit_event_repository.py +++ b/application/monitoring/repository/audit_event_repository.py @@ -13,7 +13,13 @@ from ..dto.audit_event_dto import AuditEventDTO class AuditEventRepository(Protocol): + """Cổng đọc nhật ký kiểm toán mà tầng application dùng. + + Chỉ là hợp đồng: bản cài đặt thật đọc từ file cục bộ hoặc thư mục chia sẻ, + còn test truyền vào bộ giả. + """ def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]: + """Đọc sự kiện kiểm toán, lọc theo loại nếu có.""" ... @@ -22,9 +28,11 @@ class CanonicalAuditEventRepository: — the only place this application service reaches into infrastructure.""" def __init__(self, audit_logger) -> None: + """Bọc bộ ghi nhật ký kiểm toán chuẩn để đọc sự kiện ra.""" self._audit_logger = audit_logger def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]: + """Đọc sự kiện từ nhật ký và đổi sang DTO của tầng application.""" events = self._audit_logger.load_events(kind=kind) return [AuditEventDTO.from_raw(e.to_dict()) for e in events] @@ -33,9 +41,13 @@ class InMemoryAuditEventRepository: """Test double — holds a fixed list of events, no file I/O.""" def __init__(self, events: List[AuditEventDTO]) -> None: + """Nhận sẵn danh sách sự kiện. Chép lại chứ không giữ tham chiếu: bên gọi sửa + danh sách gốc thì kết quả test không được đổi theo. + """ self._events = list(events) def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]: + """Trả về danh sách đã nạp sẵn, lọc theo loại nếu có.""" if kind is None: return list(self._events) return [e for e in self._events if e.kind == kind] diff --git a/application/scheduling/ai_task_planner_service.py b/application/scheduling/ai_task_planner_service.py index 6fbf15e..4371f7d 100644 --- a/application/scheduling/ai_task_planner_service.py +++ b/application/scheduling/ai_task_planner_service.py @@ -43,6 +43,9 @@ class AiTaskPlannerService: """ def __init__(self, provider_factory: Optional[ProviderFactory] = None) -> None: + """``provider_factory`` là hàm dựng provider, gọi lúc cần chứ không dựng sẵn — + provider có thể bị đổi giữa hai lần lập kế hoạch. + """ self._provider_factory = provider_factory def plan( @@ -86,6 +89,9 @@ class AiTaskPlannerService: return import_tasks(path) def _resolve_provider(self) -> Any: + """Provider dùng để lập kế hoạch; chưa cấu hình thì báo lỗi rõ ràng ngay tại + đây thay vì để lỗi nổ ra ở tận tầng HTTP. + """ if self._provider_factory is None: raise RuntimeError("No provider available to plan tasks.") return self._provider_factory() diff --git a/application/scheduling/task_application_service.py b/application/scheduling/task_application_service.py index d96c28d..cd62a85 100644 --- a/application/scheduling/task_application_service.py +++ b/application/scheduling/task_application_service.py @@ -75,6 +75,9 @@ class TaskApplicationService: """ def __init__(self, repository: Any, run_now: Optional[RunNowFn] = None) -> None: + """``run_now`` để None thì service chỉ đọc/ghi task, không chạy được cái nào — + đúng cho ngữ cảnh không có scheduler (test, hay màn chỉ xem). + """ self._repository = repository self._run_now = run_now @@ -118,6 +121,7 @@ class TaskApplicationService: return task def delete(self, task_id: str) -> bool: + """Xoá một task; trả về ``False`` nếu id không tồn tại.""" if self._repository.get(task_id) is None: return False self._repository.delete(task_id) diff --git a/application/workflows/co4e_run_history.py b/application/workflows/co4e_run_history.py new file mode 100644 index 0000000..606b036 --- /dev/null +++ b/application/workflows/co4e_run_history.py @@ -0,0 +1,99 @@ +"""Đọc/ghi file lịch sử run của Co4E — tách khỏi ``co4e_workflow_service.py``. + +``Co4EWorkflowService`` lo vòng đời các run đang chạy; chỗ này lo đúng một +việc: đưa ``RunRecord`` ra đĩa và lấy lại được. Tách ra vì hành vi đọc/ghi ở +đây có những ràng buộc rất riêng — được ghi lại nguyên vẹn bên dưới — mà trộn +lẫn vào file điều phối thì không ai đọc tới. + +DTO ở ``domain/workflows/run_record.py`` không được chạm đĩa, nên việc này +nằm ở tầng application chứ không nằm trong domain. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, List, Tuple + +from ...domain.workflows.run_record import RunRecord +from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile + +#: Giữ N run gần nhất trên đĩa. Lịch sử chỉ để người dùng nhìn lại, không +#: phải sổ kiểm toán — để nó lớn vô hạn thì mỗi lần lưu lại phải tuần tự hoá +#: cả file, và lần lưu ấy nằm ngay trên đường đi của mọi sự kiện tiến độ. +HISTORY_CAP = 500 + + +class RunHistoryStore: + """Một file JSON chứa lịch sử run, kèm hai quy ước phải giữ nguyên. + + **Không cách ly file hỏng.** Bản đầu dùng ``AtomicJsonFile.read()``, nhưng + review thấy nó đổi hành vi thật so với ``Co4ERunManager`` cũ: gặp JSON + hỏng, ``AtomicJsonFile.read()`` ĐỔI TÊN file thành ``.bad-`` rồi + mới trả về mặc định, trong khi bản cũ chỉ bắt lỗi và ĐỂ NGUYÊN file tại + chỗ. Đó là thay đổi quan sát được trên đĩa mà không test nào khoá lại và + không có chú thích báo trước — Lâm (N3) quyết ngày 24/08: giữ hành vi cũ. + Vì thế :meth:`load` đọc thủ công bằng ``json.loads``. + + **Ghi hỏng không được làm vỡ luồng gọi.** :meth:`save` nuốt ``OSError``, + đúng như ``core/co4e_run_manager.py::_save_history``. Nó nằm trên đường đi + của mọi hook tiến độ (``_on_event``/``_on_finished``/``_on_failed``); để + lỗi ghi đĩa (đầy đĩa, mất quyền) ném ra là vỡ cả lượt xử lý sự kiện đang + chạy, chỉ vì lịch sử lần này không lưu được. Người dùng vẫn thấy Flow + Status đúng trong phiên hiện tại, chỉ là bản ghi trên đĩa lùi một bước. + + Ghi thì vẫn qua ``AtomicJsonFile``: bản tự viết bằng tmp + ``replace`` + thiếu ``fsync`` (dữ liệu có thể còn trong bộ đệm khi mất điện) và + ``Path.replace`` thỉnh thoảng bị Defender từ chối trên Windows. + """ + + def __init__(self, path: Path): + """Trỏ vào một file JSON. Chưa tồn tại cũng không sao — :meth:`load` coi như + lịch sử rỗng và :meth:`save` tự tạo thư mục cha. + """ + self.path = Path(path) + + def load(self) -> Tuple[Dict[str, RunRecord], int]: + """Đọc lịch sử; trả về ``({id: RunRecord}, số thứ tự lớn nhất đã dùng)``. + + Số thứ tự trả kèm để bên gọi sinh id tiếp theo không đụng vào id đã có + trong lịch sử — không có nó thì sau mỗi lần khởi động lại, ``run1`` + mới sẽ ghi đè ``run1`` cũ. + + File không có, không đọc được, hay JSON hỏng đều trả về rỗng: mất lịch + sử là chuyện chấp nhận được, chặn ứng dụng khởi động thì không. Từng + bản ghi hỏng cũng bị bỏ riêng lẻ, để một dòng lỗi không kéo theo cả + file. + """ + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {}, 0 + + runs: Dict[str, RunRecord] = {} + max_seq = 0 + for rec in data.get("runs", []): + try: + record = RunRecord.from_dict(rec) + except Exception: + continue + if not record.id: + continue + runs[record.id] = record + if record.id.startswith("run") and record.id[3:].isdigit(): + max_seq = max(max_seq, int(record.id[3:])) + return runs, max_seq + + def save(self, runs: List[RunRecord]) -> None: + """Ghi ``HISTORY_CAP`` run gần nhất xuống đĩa, ghi nguyên tử. + + Lỗi ghi bị nuốt có chủ ý — xem docstring của lớp. + """ + payload = {"runs": [r.to_dict() for r in runs[-HISTORY_CAP:]]} + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + AtomicJsonFile(self.path).write(payload) + except OSError: + pass + + +__all__ = ["RunHistoryStore", "HISTORY_CAP"] diff --git a/application/workflows/co4e_workflow_service.py b/application/workflows/co4e_workflow_service.py index 3b2d3cb..d2b0c70 100644 --- a/application/workflows/co4e_workflow_service.py +++ b/application/workflows/co4e_workflow_service.py @@ -32,12 +32,20 @@ Qt thật (bọc ``AgentWorker`` — xem ``core/worker.py``) là việc của wi KHÔNG xoá/sửa ``core/co4e_run_manager.py`` — lớp cũ tiếp tục chạy song song cho tới khi widget Co4E Studio thật (``ui/co4e_tab.py``) chuyển hẳn sang dùng service này. + +SEAM · dựng 2026-08-25 · chưa nối dây (F-05) +------------------------------------------------------------ +Được nối khi: ``ui/co4e_tab.py`` bỏ ``Co4ERunManager`` và nhận service này qua ``build_co4e_tab(ctx, workflow_service)``. +Để dormant thì sao: Hai bản cùng giữ vòng đời run đang chạy song song. Càng +để lâu thì sửa một lỗi lại phải sửa hai nơi — và đến một lúc sẽ có người +quên nơi thứ hai. + +Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên +và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng +đọc theo — đừng sửa ngày để làm im lời nhắc. """ from __future__ import annotations -from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile - -import json import os from datetime import datetime from pathlib import Path @@ -45,12 +53,13 @@ from typing import Callable, Dict, List, Optional, Protocol, Set from ...core.co4e import CO4E_DIR, STEP_DONE, STEP_ERROR, STEP_PLANNED, Workflow, slugify, workflow_to_dict from ...domain.workflows.run_record import RunRecord +from .co4e_run_history import RunHistoryStore _TERMINAL_NODE = {STEP_DONE, STEP_ERROR, STEP_PLANNED} -_HISTORY_CAP = 500 # giữ N run gần nhất trên đĩa def _now_str() -> str: + """Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' — đúng định dạng lịch sử run đang lưu.""" return datetime.now().strftime("%Y-%m-%d %H:%M") @@ -70,15 +79,24 @@ class RunnerJob(Protocol): đồng bộ trong test. """ - def emit_event(self, ev: dict) -> None: ... - def is_cancelled(self) -> bool: ... + def emit_event(self, ev: dict) -> None: + """Đẩy một sự kiện tiến độ từ luồng nền về service.""" + ... + + def is_cancelled(self) -> bool: + """``True`` khi người dùng đã bấm dừng — thân job phải tự kiểm để thoát sớm.""" + ... class RunWorkerHandle(Protocol): """Điều khiển một job đang chạy nền — tương ứng phần ``AgentWorker.request_stop()`` cũ mà ``Co4ERunManager.stop()`` gọi.""" - def request_stop(self) -> None: ... + def request_stop(self) -> None: + """Xin dừng run. Chỉ là yêu cầu: job đang chạy phải tự thấy qua + ``is_cancelled()`` rồi thoát, không ai giết luồng giữa chừng. + """ + ... class WorkflowRunner(Protocol): @@ -94,7 +112,9 @@ class WorkflowRunner(Protocol): def start(self, run_id: str, job: Callable[[RunnerJob], Optional[dict]], on_event: Callable[[dict], None], on_finished: Callable[[Optional[dict]], None], - on_failed: Callable[[str], None]) -> RunWorkerHandle: ... + on_failed: Callable[[str], None]) -> RunWorkerHandle: + """Chạy ``job`` và trả về tay cầm để dừng nó.""" + ... class Co4EWorkflowService: @@ -108,6 +128,12 @@ class Co4EWorkflowService: def __init__(self, ctx, *, history_path: Optional[Path] = None, runner: Optional[WorkflowRunner] = None): + """Dựng service. + + ``runner`` để None nghĩa là chưa có ai chạy được run — đúng trạng thái hiện + nay, vì adapter Qt thật thuộc về tầng ``presentation/`` và chưa được nối. + Test tiêm runner chạy đồng bộ vào đây. + """ self.ctx = ctx self._runs: Dict[str, RunRecord] = {} self._worker_handles: Dict[str, RunWorkerHandle] = {} @@ -116,10 +142,12 @@ class Co4EWorkflowService: self._project_id: str = "" # workspace đang chọn — Flow Status lọc theo no self._runner = runner # DTO domain khong duoc cham dia (xem domain/workflows/run_record.py), - # nen viec doc/ghi file lich su nam o day, tang application. + # nen viec doc/ghi file lich su nam o tang application — cu the la + # co4e_run_history.py::RunHistoryStore. self._history_path_value = ( Path(history_path) if history_path is not None else (CO4E_DIR / "run_history.json") ) + self._history = RunHistoryStore(self._history_path_value) self._changed_callbacks: List[Callable[[], None]] = [] self._event_callbacks: List[Callable[[str, dict], None]] = [] self._load_history() # khoi phuc lich su cu de Flow Status @@ -127,71 +155,42 @@ class Co4EWorkflowService: # ---- callback thay Signal --------------------------------------------- def on_changed(self, cb: Callable[[], None]) -> None: + """Đăng ký callback gọi mỗi khi danh sách run đổi — thay cho signal Qt cũ.""" self._changed_callbacks.append(cb) def on_event(self, cb: Callable[[str, dict], None]) -> None: + """Đăng ký callback nhận sự kiện tiến độ của từng run — thay cho signal Qt cũ.""" self._event_callbacks.append(cb) def _emit_changed(self) -> None: + """Lưu lịch sử rồi báo mọi người đăng ký.""" self._save_history() # xem docstring dau file: giu dung thu tu ban Qt cu for cb in self._changed_callbacks: cb() def _emit_event(self, run_id: str, ev) -> None: + """Chuyển một sự kiện tiến độ tới mọi callback đã đăng ký.""" for cb in self._event_callbacks: cb(run_id, ev) # ---- persistence -------------------------------------------------- - # Doc/ghi thu cong (json.loads/write_text + tmp.replace), KHONG dung - # AtomicJsonFile — ban dau file nay dung AtomicJsonFile.read(), nhung - # review phat hien no doi hanh vi that so voi Co4ERunManager cu: gap - # JSON hong, AtomicJsonFile.read() ĐOI TEN file hong thanh - # ".bad-" (quarantine) roi moi tra ve mac dinh, trong - # khi ban cu chi bat loi va ĐE NGUYEN file hong tai cho, khong dong gi - # vao no. Day la mot thay doi quan sat duoc tren dia ma khong test nao - # khoa lai va khong co comment bao truoc — Lam (N3) da quyet 24/08: - # GIU HANH VI CU nguyen van (khong quarantine), vi day la buoc tach - # chi duoc phep doi hanh vi khi da noi ra ro rang va co lưới an toan, - # khong phai luc nay. def _load_history(self) -> None: - try: - data = json.loads(self._history_path_value.read_text(encoding="utf-8")) - except (OSError, ValueError): - return - max_seq = 0 - for rec in data.get("runs", []): - try: - record = RunRecord.from_dict(rec) - except Exception: - continue - if not record.id: - continue - self._runs[record.id] = record - if record.id.startswith("run") and record.id[3:].isdigit(): - max_seq = max(max_seq, int(record.id[3:])) - self._seq = max_seq # tranh sinh id trung voi lich su + """Khôi phục lịch sử run từ đĩa lúc khởi động. + + Lấy luôn số thứ tự lớn nhất đã dùng để ``_next_id()`` không sinh trùng + id với run cũ. + """ + self._runs, self._seq = self._history.load() def _save_history(self) -> None: - runs = list(self._runs.values())[-_HISTORY_CAP:] - payload = {"runs": [r.to_dict() for r in runs]} - try: - self._history_path_value.parent.mkdir(parents=True, exist_ok=True) - # AtomicJsonFile thay cho tmp+replace tự viết: bản cũ thiếu fsync - # (dữ liệu có thể còn trong bộ đệm khi mất điện) và dùng thẳng - # Path.replace, vốn thỉnh thoảng bị Defender từ chối trên Windows. - AtomicJsonFile(self._history_path_value).write(payload) - except OSError: - # Giu dung hanh vi cu (core/co4e_run_manager.py::_save_history): - # mot lan luu that bai (day dia, mat quyen...) KHONG duoc phep - # chan luong goi cua moi hook (_on_event/_on_finished/_on_failed) - # dang di qua _emit_changed(). Bo try/except nay se lam mot loi - # ghi dia lam vo ca luot xu ly su kien dang chay, chi vi lich su - # khong luu duoc lan nay -- nguoi dung van thay Flow Status dung - # trong phien hien tai, chi la ban ghi tren dia lui lai mot buoc. - pass + """Ghi lịch sử xuống đĩa. Lỗi ghi bị nuốt có chủ ý — xem + ``co4e_run_history.py::RunHistoryStore``. + """ + self._history.save(list(self._runs.values())) # ---- lifecycle ---------------------------------------------------- def _next_id(self) -> str: + """Sinh id run kế tiếp ('run1', 'run2', ...), không đụng id đã có trong lịch sử.""" self._seq += 1 return f"run{self._seq}" @@ -247,6 +246,7 @@ class Co4EWorkflowService: # ---- worker callbacks (goi tu runner, thay slot Qt cu) ----------------- def _on_event(self, run_id: str, ev) -> None: + """Nhận sự kiện từ job đang chạy và cập nhật bản ghi run.""" record = self._runs.get(run_id) if record is not None and isinstance(ev, dict): t = ev.get("type") @@ -271,6 +271,7 @@ class Co4EWorkflowService: self._emit_event(run_id, ev) def _on_finished(self, run_id: str) -> None: + """Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'.""" record = self._runs.get(run_id) if record is not None and record.status == "running": # job returned without a run_done event (shouldn't happen) — settle it @@ -278,6 +279,7 @@ class Co4EWorkflowService: self._emit_changed() def _on_failed(self, run_id: str, err: str) -> None: + """Job ném lỗi: ghi lỗi vào bản ghi và báo ra ngoài một sự kiện ``run_error``.""" record = self._runs.get(run_id) if record is not None: record.status = "error" @@ -287,6 +289,7 @@ class Co4EWorkflowService: # ---- control -------------------------------------------------------- def stop(self, run_id: str) -> None: + """Yêu cầu dừng một run đang chạy và đánh dấu 'stopped'.""" record = self._runs.get(run_id) worker = self._worker_handles.get(run_id) if record is not None and worker is not None and record.running: @@ -295,6 +298,7 @@ class Co4EWorkflowService: self._emit_changed() def stop_all(self) -> None: + """Dừng mọi run của workspace đang chọn (Flow Status vốn lọc theo project).""" # Only the CURRENT workspace's runs (Flow Status is per-project). for run_id in [r for r, rec in self._runs.items() if self._belongs(rec)]: self.stop(run_id) @@ -315,6 +319,7 @@ class Co4EWorkflowService: self._emit_changed() def remove(self, run_id: str) -> None: + """Xoá một run khỏi lịch sử; đang chạy thì dừng trước.""" record = self._runs.get(run_id) if record is not None and record.running: self.stop(run_id) @@ -323,6 +328,7 @@ class Co4EWorkflowService: self._emit_changed() def clear_finished(self) -> None: + """Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy.""" # Only clear finished runs of the CURRENT workspace. for run_id in [r for r, rec in self._runs.items() if not rec.running and self._belongs(rec)]: self._runs.pop(run_id, None) @@ -343,9 +349,11 @@ class Co4EWorkflowService: return list(self._runs.values()) def get(self, run_id: str) -> Optional[RunRecord]: + """Lấy một run theo id; ``None`` nếu không có.""" return self._runs.get(run_id) def active_count(self) -> int: + """Số run đang chạy của workspace đang chọn — dùng cho huy hiệu trên tab.""" return sum(1 for r in self._runs.values() if r.running and self._belongs(r)) def set_current_project(self, project_id: str) -> None: @@ -363,6 +371,7 @@ class Co4EWorkflowService: self._output_root = Path(root) if root else None def _out_dir(self, wf: Workflow) -> Path: + """Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có.""" # Flow deliverables are written into the SELECTED workspace (the active # project's folder) so they land where the user works with files (Folder # tab), not in the config/install folder. One subfolder per flow keeps diff --git a/application/workspaces/file_preview_helpers.py b/application/workspaces/file_preview_helpers.py index 393395a..3f1c78f 100644 --- a/application/workspaces/file_preview_helpers.py +++ b/application/workspaces/file_preview_helpers.py @@ -28,6 +28,9 @@ def pptx_available() -> bool: def read_text(path: str) -> str: + """Đọc tệp dạng văn bản, thay ký tự hỏng thay vì ném lỗi; không đọc được thì + trả về chuỗi rỗng. + """ try: return Path(path).read_text(encoding="utf-8", errors="replace") except OSError as exc: @@ -35,6 +38,11 @@ def read_text(path: str) -> str: def is_probably_text(path: str) -> bool: + """Đoán tệp này có phải văn bản không, bằng cách tìm byte NUL trong phần đầu. + + Đoán sai theo hướng "là văn bản" sẽ hiện một màn hình ký tự rác, nên phép + thử cố tình bảo thủ. + """ try: with open(path, "rb") as f: chunk = f.read(4096) diff --git a/application/workspaces/file_workspace_service.py b/application/workspaces/file_workspace_service.py index 67ff35e..c4252dd 100644 --- a/application/workspaces/file_workspace_service.py +++ b/application/workspaces/file_workspace_service.py @@ -32,6 +32,9 @@ class FileWorkspaceService: """ def __init__(self, session) -> None: # WorkspaceSession - see module docstring + """Nhận một ``WorkspaceSession`` — mọi đường dẫn về sau đều bị nó chặn trong + phạm vi cho phép. + """ self._session = session def list_tree(self, rel: str = ".") -> Dict[str, Any]: diff --git a/config.py b/config.py index bfdacd4..df36bc3 100644 --- a/config.py +++ b/config.py @@ -275,6 +275,11 @@ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]: + """Cho phép biến môi trường ghi đè cấu hình. + + Dùng khi chạy trong container/CI: đặt endpoint và khoá qua biến môi trường mà + không phải sửa file cấu hình. + """ data = copy.deepcopy(data) oc = data["providers"]["openai_compat"] if os.getenv("OPENAI_API_KEY"): @@ -361,6 +366,11 @@ class AppConfig(JsonConfigRepository): """ def __init__(self, data=None, path: Path = CONFIG_PATH, **kw): + """Mở cấu hình từ đĩa, hoặc dựng thẳng từ dict khi truyền ``data``. + + Dạng ``AppConfig(data=..., path=...)`` là để 13 file test dựng cấu hình mà + không chạm đĩa; giữ nguyên vì bỏ đi là phải sửa cả 13 file. + """ if data is None: super().__init__(Path(path), **kw) return diff --git a/core/accounts.py b/core/accounts.py index 9c8a212..378e1bf 100644 --- a/core/accounts.py +++ b/core/accounts.py @@ -35,6 +35,7 @@ _LAST_LOGIN_PATH = CONFIG_DIR / "last_login.json" def save_last_login(username: str, role: str) -> None: + """Nhớ tài khoản đăng nhập gần nhất để lần mở sau điền sẵn.""" try: _LAST_LOGIN_PATH.parent.mkdir(parents=True, exist_ok=True) _LAST_LOGIN_PATH.write_text( @@ -44,6 +45,7 @@ def save_last_login(username: str, role: str) -> None: def load_last_login() -> Optional[Tuple[str, str]]: + """Cặp (tên đăng nhập, vai trò) của lần đăng nhập gần nhất; ``None`` nếu chưa có.""" try: data = json.loads(_LAST_LOGIN_PATH.read_text(encoding="utf-8")) username, role = data.get("username", ""), data.get("role", "") @@ -61,6 +63,7 @@ CODE_LENGTH = 12 @dataclass class Account: + """Một tài khoản người dùng: tên đăng nhập, vai trò, tên hiển thị và nhóm.""" username: str role: str display_name: str = "" @@ -73,6 +76,7 @@ class Account: def accounts_dir(shared_dir: str) -> Path: + """Thư mục chứa tài khoản, nằm trong thư mục chia sẻ của đội.""" return Path(shared_dir).expanduser() / "accounts" @@ -93,6 +97,7 @@ def generate_code(existing_codes: Optional[Set[str]] = None) -> str: def save_account(account: Account, directory: Path) -> Path: + """Ghi một tài khoản ra ``.json`` (tên file đã được làm sạch).""" directory.mkdir(parents=True, exist_ok=True) path = directory / f"{_safe_username(account.username)}.json" path.write_text(json.dumps(asdict(account), ensure_ascii=False, indent=2), encoding="utf-8") @@ -100,6 +105,7 @@ def save_account(account: Account, directory: Path) -> Path: def load_account(username: str, directory: Path) -> Optional[Account]: + """Đọc một tài khoản theo tên đăng nhập; không có thì trả ``None``.""" path = directory / f"{_safe_username(username)}.json" if not path.exists(): return None @@ -112,6 +118,7 @@ def load_account(username: str, directory: Path) -> Optional[Account]: def list_accounts(directory: Path) -> List[Account]: + """Liệt kê mọi tài khoản trong thư mục; thư mục chưa có thì trả list rỗng.""" if not directory.exists(): return [] out: List[Account] = [] @@ -124,6 +131,7 @@ def list_accounts(directory: Path) -> List[Account]: def delete_account(username: str, directory: Path) -> bool: + """Xoá file tài khoản; trả về ``True`` nếu có file để xoá.""" path = directory / f"{_safe_username(username)}.json" try: path.unlink() @@ -133,6 +141,7 @@ def delete_account(username: str, directory: Path) -> bool: def find_by_username(username: str, directory: Path) -> Optional[Account]: + """Bí danh của :func:`load_account`, giữ cho mã cũ gọi theo tên này vẫn chạy.""" return load_account(username, directory) diff --git a/core/admin_agents.py b/core/admin_agents.py index 30c6d4f..092228f 100644 --- a/core/admin_agents.py +++ b/core/admin_agents.py @@ -74,6 +74,7 @@ _KIND_PROMPTS = { @dataclass class AdminAgent: + """Một agent chuyên trách do quản trị cấu hình: prompt riêng, provider và model riêng.""" agent_id: str name: str task_kind: str = "cowork" @@ -85,6 +86,9 @@ class AdminAgent: updated_by: str = "" def effective_prompt(self) -> str: + """Prompt hệ thống thật sự dùng: prompt mặc định theo loại việc, rồi tới phần + quản trị viết thêm. + """ parts = [_KIND_PROMPTS.get(self.task_kind, ""), (self.prompt or "").strip()] return "\n\n".join(p for p in parts if p) @@ -98,12 +102,17 @@ def agents_admin_dir(shared_dir: str = "") -> Path: def _slug(name: str) -> str: + """Định danh an toàn cho tên file, suy từ tên agent.""" s = re.sub(r"[^\w\-]+", "-", (name or "").strip().lower()).strip("-") return s or "agent" def new_agent(name: str, task_kind: str = "cowork", prompt: str = "", provider: str = "", model: str = "", updated_by: str = "") -> AdminAgent: + """Tạo một agent quản trị mới; loại việc lạ thì rơi về 'cowork'. + + Id ghép slug với 6 ký tự ngẫu nhiên để hai agent trùng tên không đè file nhau. + """ return AdminAgent( agent_id=f"{_slug(name)}-{uuid.uuid4().hex[:6]}", name=name.strip(), task_kind=task_kind if task_kind in TASK_KINDS else "cowork", @@ -113,6 +122,7 @@ def new_agent(name: str, task_kind: str = "cowork", prompt: str = "", def save_agent(agent: AdminAgent, directory: Path) -> Path: + """Ghi một agent ra ``.json``.""" directory.mkdir(parents=True, exist_ok=True) path = directory / f"{agent.agent_id}.json" path.write_text(json.dumps(asdict(agent), ensure_ascii=False, indent=2), encoding="utf-8") @@ -120,6 +130,7 @@ def save_agent(agent: AdminAgent, directory: Path) -> Path: def load_agent(agent_id: str, directory: Path) -> Optional[AdminAgent]: + """Đọc một agent theo id; không có thì trả ``None``.""" path = directory / f"{agent_id}.json" if not path.exists(): return None @@ -132,6 +143,7 @@ def load_agent(agent_id: str, directory: Path) -> Optional[AdminAgent]: def list_agents(directory: Path, enabled_only: bool = False) -> List[AdminAgent]: + """Liệt kê agent trong thư mục; ``enabled_only`` chỉ lấy agent đang bật.""" if not directory.exists(): return [] out: List[AdminAgent] = [] @@ -165,6 +177,7 @@ def ensure_help_agent(directory: Path) -> AdminAgent: def delete_agent(agent_id: str, directory: Path) -> bool: + """Xoá file agent; trả về ``True`` nếu có file để xoá.""" try: (directory / f"{agent_id}.json").unlink() return True diff --git a/core/agent_command.py b/core/agent_command.py index d3271b2..5da822f 100644 --- a/core/agent_command.py +++ b/core/agent_command.py @@ -31,6 +31,7 @@ _CMD = re.compile(r"(? str: + """Định danh an toàn suy từ tên agent (dùng chung hàm với Co4E).""" from .co4e import slugify return slugify(name) @@ -45,6 +46,11 @@ def collect_agents(shared_dir: str = "") -> List[dict]: seen: set[str] = set() def _add(slug: str, name: str, desc: str, persona: str, source: str) -> None: + """Thêm một agent vào danh sách gộp; bỏ qua nếu trùng slug hoặc thiếu persona. + + Agent không có persona thì không dùng được — thêm vào chỉ làm bảng gợi ý dài + ra mà chọn vào lại không chạy. + """ if not slug or slug in seen or not persona.strip(): return seen.add(slug) @@ -69,6 +75,7 @@ def collect_agents(shared_dir: str = "") -> List[dict]: def _persona_block(agent: dict) -> str: + """Khối prompt mô tả một agent, chèn vào đầu lượt chat khi người dùng gõ ``/agent:``.""" return f"## Agent: {agent['name']}\n{agent['persona']}" diff --git a/core/agent_roles.py b/core/agent_roles.py index 7f605b4..4e2d6c6 100644 --- a/core/agent_roles.py +++ b/core/agent_roles.py @@ -37,6 +37,7 @@ HELP = "help" class AgentRole(NamedTuple): + """Một vai trò agent: khoá, nhãn hiển thị và prompt mặc định.""" key: str label: str description: str @@ -61,5 +62,6 @@ ROLES: Dict[str, AgentRole] = { def label_for(role_key: str) -> str: + """Nhãn của một vai trò; khoá lạ thì trả về chính khoá, rỗng thì trả về "—".""" role = ROLES.get(role_key) return role.label if role else (role_key or "—") diff --git a/core/agent_security.py b/core/agent_security.py index 917c771..8877301 100644 --- a/core/agent_security.py +++ b/core/agent_security.py @@ -149,6 +149,10 @@ def _ai_verdict(provider: Provider, system_prompt: str, content: str, layer: str def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> SecurityVerdict: + """Nhờ model xét prompt người dùng theo bộ luật an toàn. + + Prompt rỗng thì cho qua ngay, khỏi tốn một lượt gọi. + """ if not (user_text or "").strip(): return SecurityVerdict(True, "", "prompt") system = _PROMPT_SYSTEM.format(rules=rules_text or "(no additional rules configured)") @@ -157,6 +161,7 @@ def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> Secu def validate_attachment(provider: Provider, filename: str, content: str, rules_text: str) -> SecurityVerdict: + """Nhờ model xét nội dung một tệp đính kèm theo bộ luật an toàn.""" if not (content or "").strip(): return SecurityVerdict(True, "", "attachment") system = _ATTACHMENT_SYSTEM.format(rules=rules_text or "(no additional rules configured)") @@ -165,6 +170,11 @@ def validate_attachment(provider: Provider, filename: str, content: str, def validate_command(provider: Provider, command: str, rules_text: str, ai_enabled: bool) -> SecurityVerdict: + """Nhờ model xét một lệnh shell theo bộ luật an toàn. + + ``ai_enabled=False`` thì cho qua — người dùng đã tắt lớp xét bằng AI, bộ luật + tĩnh vẫn chạy ở chỗ khác. + """ if not ai_enabled: return SecurityVerdict(True, "", "command") system = _COMMAND_SYSTEM.format(rules=rules_text or "(no additional rules configured)") @@ -173,6 +183,7 @@ def validate_command(provider: Provider, command: str, # ---- call-site convenience wrappers (used by chat_agent.py / code_agent.py) -- def _security_conf(config) -> dict: + """Nhóm cấu hình ``agent_security``; không có config thì trả dict rỗng.""" return (config.data.get("agent_security", {}) if config is not None else {}) diff --git a/core/agent_security_types.py b/core/agent_security_types.py index 044d01d..af28aab 100644 --- a/core/agent_security_types.py +++ b/core/agent_security_types.py @@ -19,6 +19,7 @@ from dataclasses import dataclass @dataclass class SecurityVerdict: + """Kết quả một lớp kiểm an toàn: cho qua hay không, lý do, và lớp nào ra phán quyết.""" allowed: bool reason: str = "" layer: str = "" # "prompt" | "attachment" | "command" @@ -29,5 +30,6 @@ class SecurityBlocked(RuntimeError): the admin alert; ``str(exc)`` is the short, user-facing reason.""" def __init__(self, verdict: SecurityVerdict): + """Lấy lý do trong phán quyết làm thông điệp; không có lý do thì ghi rõ lớp nào chặn.""" super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).") self.verdict = verdict diff --git a/core/ai_task_planner.py b/core/ai_task_planner.py index fe325b1..22bd0a6 100644 --- a/core/ai_task_planner.py +++ b/core/ai_task_planner.py @@ -54,6 +54,10 @@ def _extract_json(text: str) -> Optional[dict]: def _clamp(value, allowed, default): + """Ép một giá trị về tập hợp lệ; ngoài tập thì lấy mặc định. + + Cần vì model hay trả về giá trị gần đúng ('High' thay vì 'high'). + """ return value if value in allowed else default diff --git a/core/appcontainer_sandbox.py b/core/appcontainer_sandbox.py index b872867..3b14ba5 100644 --- a/core/appcontainer_sandbox.py +++ b/core/appcontainer_sandbox.py @@ -48,6 +48,7 @@ class AppContainerSandbox: display_name: str = "CoworkLocal Sandbox", description: str = "Isolated execution environment for Cowork Local agent", ): + """Đặt tên và mô tả cho hồ sơ AppContainer; chưa tạo gì trên máy.""" self.profile_name = profile_name self.display_name = display_name self.description = description diff --git a/core/chat_agent.py b/core/chat_agent.py index eaa0917..3b41cb9 100644 --- a/core/chat_agent.py +++ b/core/chat_agent.py @@ -135,6 +135,12 @@ _UNSAFE = re.compile(r'[\\/:*?"<>|\x00-\x1f]+') def _safe_filename(name: str) -> str: + """Làm sạch tên tệp do model đề xuất: bỏ đường dẫn, thay ký tự cấm, không bao + giờ trả về chuỗi rỗng. + + Model hay trả về tên có dấu ``/`` hoặc ``..`` — ghi thẳng là thoát khỏi thư + mục làm việc. + """ base = Path(str(name)).name.strip() base = _UNSAFE.sub("_", base).strip(" _.") or "output.txt" if "." not in base: @@ -307,17 +313,23 @@ def run_chat( emit: EmitFn, cancel: Optional[CancelFn] = None, ) -> Dict[str, Any]: + """Chạy một lượt chat thuần (không có tool) và phát nội dung dần ra ngoài. + + Tự chèn prompt hệ thống nếu tin nhắn đầu chưa phải ``system``. + """ if not messages or messages[0].get("role") != "system": messages.insert(0, {"role": "system", "content": COWORK_SYSTEM_PROMPT}) # Rulebase: always attach security rules so the agent follows them every turn _apply_security_rules(messages, load_rules()) def on_text(piece: str) -> None: + """Đẩy từng mẩu câu trả lời ra ngoài.""" emit({"type": "text", "delta": piece}) def on_reasoning(piece: str) -> None: # Stream the model's reasoning so the UI can show a live, collapsible # "Thinking" box (and keep the indicator active). + """Đẩy từng mẩu suy luận nội bộ ra ngoài, để giao diện hiện hộp "Đang nghĩ".""" emit({"type": "reasoning", "delta": piece}) assistant = provider.chat(messages, tools=None, on_text=on_text, cancel=cancel, diff --git a/core/co4e.py b/core/co4e.py index 46e3682..ff64646 100644 --- a/core/co4e.py +++ b/core/co4e.py @@ -54,6 +54,9 @@ RUN_MODES = ("auto", "plan", "manual") def slugify(value: str) -> str: + """Chuyển một chuỗi thành slug an toàn cho tên file: chỉ chữ/số/gạch, gộp gạch + liên tiếp. Rỗng thì trả về 'step' để không bao giờ sinh ra tên file trống. + """ s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (value or "").strip().lower()) return "-".join(filter(None, s.split("-"))) or "step" @@ -88,11 +91,13 @@ class Step: @property def is_parallel(self) -> bool: + """Bước này có chạy nhiều sub-agent song song hay không.""" return self.variant == "parallel" @dataclass class Node: + """Một node trên khung vẽ: id, toạ độ, và bước (:class:`Step`) mà nó đại diện.""" id: str x: float = 0.0 y: float = 0.0 @@ -101,6 +106,7 @@ class Node: @dataclass class Edge: + """Một cạnh nối hai node, quy định thứ tự chạy giữa chúng.""" id: str source: str target: str @@ -108,6 +114,7 @@ class Edge: @dataclass class Workflow: + """Một luồng Co4E: danh sách node, cạnh, và cờ đánh dấu đây có phải mẫu không.""" id: str name: str = "Untitled flow" is_template: bool = False @@ -132,6 +139,10 @@ class CustomAgent: # ---- (de)serialization --------------------------------------------------- def step_from_dict(d: dict) -> Step: + """Dựng :class:`Step` từ dict đọc trên đĩa. + + Lọc bỏ khoá lạ để file luồng của phiên bản mới hơn không làm vỡ bản cũ. + """ d = dict(d or {}) subs = d.pop("sub_agents", None) or [] known = Step().__dict__.keys() @@ -145,11 +156,13 @@ def step_from_dict(d: dict) -> Step: def node_from_dict(d: dict) -> Node: + """Dựng :class:`Node` từ dict đọc trên đĩa.""" return Node(id=str(d.get("id", "")), x=float(d.get("x", 0) or 0), y=float(d.get("y", 0) or 0), data=step_from_dict(d.get("data", {}))) def workflow_from_dict(d: dict) -> Workflow: + """Dựng :class:`Workflow` từ dict đọc trên đĩa.""" return Workflow( id=str(d.get("id", "")), name=d.get("name", "Untitled flow"), @@ -161,6 +174,7 @@ def workflow_from_dict(d: dict) -> Workflow: def workflow_to_dict(wf: Workflow) -> dict: + """Chuyển một luồng thành dict để ghi JSON.""" return { "id": wf.id, "name": wf.name, "is_template": wf.is_template, "nodes": [{"id": n.id, "x": n.x, "y": n.y, "data": _step_dict(n.data)} for n in wf.nodes], @@ -169,16 +183,19 @@ def workflow_to_dict(wf: Workflow) -> dict: def _step_dict(step: Step) -> dict: + """Chuyển một bước thành dict; ``asdict`` đã tự chuyển ``sub_agents`` thành list dict.""" d = asdict(step) # asdict already turns sub_agents into list[dict] return d def agent_to_dict(a: CustomAgent) -> dict: + """Chuyển một agent tự tạo thành dict để ghi JSON.""" return asdict(a) def agent_from_dict(d: dict) -> CustomAgent: + """Dựng :class:`CustomAgent` từ dict, lọc bỏ khoá lạ.""" known = CustomAgent(id="").__dict__.keys() d = {k: v for k, v in (d or {}).items() if k in known} d.setdefault("id", "") @@ -193,32 +210,43 @@ _counter = {"n": 0} def _mint_id(prefix: str) -> str: + """Sinh id tăng dần dạng ``_000001``.""" _counter["n"] += 1 return f"{prefix}_{_counter['n']:06d}" def new_node_id() -> str: + """Id mới cho một node.""" return _mint_id("node") def new_edge_id(source: str, target: str) -> str: + """Id cạnh suy ra TỪ cặp nguồn/đích. + + Cố ý không ngẫu nhiên: nhờ vậy nối lại đúng cặp node đó luôn cho ra cùng + một id, và không thể sinh ra hai cạnh trùng nhau. + """ return f"e_{source}__{target}" def new_workflow(name: str = "Untitled flow") -> Workflow: + """Tạo một luồng rỗng với id mới.""" return Workflow(id=_mint_id("wf"), name=name) def new_custom_agent(name: str = "") -> CustomAgent: + """Tạo một agent tự tạo rỗng với id mới.""" return CustomAgent(id=_mint_id("agent"), name=name) # ---- workflow store ------------------------------------------------------ def workflows_dir() -> Path: + """Thư mục chứa file luồng.""" return WORKFLOWS_DIR def list_workflows(directory: Optional[Path] = None) -> List[Workflow]: + """Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng.""" directory = directory or WORKFLOWS_DIR if not directory.exists(): return [] @@ -232,6 +260,7 @@ def list_workflows(directory: Optional[Path] = None) -> List[Workflow]: def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path: + """Ghi một luồng ra ``.json``, tự tạo thư mục nếu chưa có.""" directory = directory or WORKFLOWS_DIR directory.mkdir(parents=True, exist_ok=True) path = directory / f"{wf.id}.json" @@ -242,6 +271,7 @@ def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path: def get_workflow(wf_id: str, directory: Optional[Path] = None) -> Optional[Workflow]: + """Đọc một luồng theo id; ``None`` nếu không có.""" directory = directory or WORKFLOWS_DIR path = directory / f"{wf_id}.json" if not path.exists(): @@ -280,6 +310,7 @@ def tr_copy_suffix() -> str: def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None: + """Xoá file luồng theo id; không có thì bỏ qua.""" directory = directory or WORKFLOWS_DIR path = directory / f"{wf_id}.json" if path.exists(): @@ -291,10 +322,12 @@ def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None: # ---- custom-agent store -------------------------------------------------- def agents_dir() -> Path: + """Thư mục chứa file agent tự tạo.""" return AGENTS_DIR def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]: + """Liệt kê mọi agent tự tạo; thư mục chưa có thì trả list rỗng.""" directory = directory or AGENTS_DIR if not directory.exists(): return [] @@ -308,6 +341,7 @@ def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]: def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> Path: + """Ghi một agent tự tạo ra ``.json``.""" directory = directory or AGENTS_DIR directory.mkdir(parents=True, exist_ok=True) path = directory / f"{agent.id}.json" @@ -316,6 +350,7 @@ def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> P def delete_custom_agent(agent_id: str, directory: Optional[Path] = None) -> None: + """Xoá file agent tự tạo theo id; không có thì bỏ qua.""" directory = directory or AGENTS_DIR path = directory / f"{agent_id}.json" if path.exists(): @@ -340,6 +375,11 @@ def compute_waves(nodes: List[Node], edges: List[Edge]) -> Dict[str, int]: limit = len(nodes) + 1 def depth(nid: str, seen: frozenset) -> int: + """Độ sâu của một node = lớp chạy của nó. + + Có nhớ kết quả và chặn theo ``limit``: đồ thị có vòng sẽ khiến đệ quy chạy + mãi, nên gặp node đã thấy trong nhánh hiện tại thì dừng. + """ if nid in wave: return wave[nid] if nid in seen or len(seen) > limit: @@ -360,12 +400,14 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int: parent = {n.id: n.id for n in nodes} def find(x): + """Tìm gốc của một phần tử, kèm nén đường đi (union-find).""" while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(a, b): + """Gộp hai tập hợp lại làm một (union-find).""" ra, rb = find(a), find(b) if ra != rb: parent[ra] = rb @@ -379,6 +421,7 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int: # ---- run-stage compilation ---------------------------------------------- @dataclass class RunStage: + """Một chặng chạy: ứng với một node, hoặc một nhánh song song / bước gộp của nó.""" id: str # node id, or "__p" / "__pjoin" node_id: str # which canvas node this stage maps back onto wave: int @@ -395,6 +438,7 @@ PLAN_MODE_PREAMBLE = ( def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str: + """Ghép nội dung các skill được chọn thành một khối chèn vào prompt.""" parts = [] for name in skills or []: content = (skill_map.get(name) or "").strip() @@ -407,6 +451,9 @@ def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str: def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: str) -> str: + """Phần prompt dùng chung cho cả ba loại chặng: chỉ dẫn của bước, khối skill, + và ngữ cảnh thêm từ các bước trước. + """ parts = [] if step.instructions.strip(): parts.append(step.instructions.strip()) @@ -423,6 +470,7 @@ def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: s def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str: + """Prompt cho một bước chạy tuần tự bình thường.""" head = f'You are the {step.role} agent for the workflow step "{step.label}".' body = _shared_prompt_parts(step, skill_map, extra_context) return f"{head}\n{body}".strip() @@ -430,6 +478,11 @@ def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str], skill_map: Dict[str, str], extra_context: str = "") -> str: + """Prompt cho một sub-agent chạy song song. + + Nói rõ nó đang chạy CÙNG LÚC với những ai và phải ở trong phạm vi của mình — + không có câu đó, các sub-agent hay làm chồng việc của nhau. + """ peer_txt = ", ".join(p for p in peers if p) or "peers" head = (f'You are the "{sub.agent}" agent working concurrently (in parallel with ' f'{peer_txt}) on the workflow step "{step.label}". Stay within your own scope.') @@ -443,6 +496,7 @@ def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str], def build_join_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str: + """Prompt cho bước gộp: hợp nhất đầu ra của các sub-agent thành một kết quả.""" head = (f'You are the coordinator for the parallel step "{step.label}". Consolidate the ' f"outputs of the sub-agents (provided above as prior outputs) into one coherent result.") body = _shared_prompt_parts(step, skill_map, extra_context) @@ -461,6 +515,9 @@ def compile_run_stages(nodes: List[Node], edges: List[Edge], stages: List[RunStage] = [] def finalize(prompt: str, preset: str) -> tuple: + """Chốt prompt của một chặng: áp phạm vi theo preset, và thêm lời mở đầu chế + độ lập kế hoạch nếu đang chạy ở chế độ đó. + """ scope = PRESET_SCOPES.get(preset) if plan_mode: prompt = PLAN_MODE_PREAMBLE + prompt diff --git a/core/co4e_builtins.py b/core/co4e_builtins.py index 179f849..71b661d 100644 --- a/core/co4e_builtins.py +++ b/core/co4e_builtins.py @@ -15,6 +15,7 @@ from .co4e import ( @dataclass class BuiltinAgent: + """Một agent dựng sẵn của Co4E: slug, tên, vai trò và prompt mặc định.""" slug: str name: str role: str diff --git a/core/co4e_run_manager.py b/core/co4e_run_manager.py index 1695401..c253daf 100644 --- a/core/co4e_run_manager.py +++ b/core/co4e_run_manager.py @@ -28,6 +28,7 @@ _HISTORY_CAP = 500 # keep the most-recent N runs on disk def _now_str() -> str: + """Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' cho lịch sử run.""" from datetime import datetime return datetime.now().strftime("%Y-%m-%d %H:%M") @@ -44,6 +45,11 @@ class RunHandle: def __init__(self, run_id: str, wf_id: str, name: str, total: int, plan_mode: bool, manual: bool, created_by: str = "", created_at: str = "", project_id: str = ""): + """Một lượt chạy workflow đang sống trong bộ nhớ. + + ``total`` âm bị kẹp về 0 — số bước không thể âm, và để lọt xuống thì thanh + tiến độ vẽ ngược. + """ self.id = run_id self.wf_id = wf_id self.name = name @@ -64,9 +70,11 @@ class RunHandle: @property def running(self) -> bool: + """Lượt chạy này còn đang chạy hay không.""" return self.status == "running" def progress_text(self) -> str: + """Chuỗi tiến độ 'xong/tổng'; chưa biết tổng thì hiện trạng thái.""" return f"{self.done}/{self.total}" if self.total else self.status # ---- persistence ------------------------------------------------------ @@ -87,6 +95,7 @@ class RunHandle: @classmethod def from_record(cls, rec: dict) -> "RunHandle": + """Dựng lại một ``RunHandle`` từ bản ghi đọc trong lịch sử trên đĩa.""" from .co4e import workflow_from_dict rec = dict(rec or {}) h = cls(str(rec.get("id", "")), str(rec.get("wf_id", "")), @@ -109,10 +118,18 @@ class RunHandle: class Co4ERunManager(QObject): + """Quản lý vòng đời nhiều lượt chạy luồng Co4E cùng lúc. + + Flow Status lọc theo project, nên hầu hết truy vấn ở đây chỉ tính run thuộc + workspace ĐANG chọn — xem ``_belongs``. + """ changed = Signal() # any run's status/progress changed → refresh views event = Signal(str, dict) # (run_id, ev) — node-level events, for mirroring def __init__(self, ctx): + """Dựng bộ quản lý run và khôi phục lịch sử cũ ngay, để tab Flow Status có nội + dung ngay khi mở chứ không trống cho tới lần chạy đầu tiên. + """ super().__init__() self.ctx = ctx self._runs: Dict[str, RunHandle] = {} @@ -126,10 +143,12 @@ class Co4ERunManager(QObject): # ---- persistence ------------------------------------------------------ def _history_path(self) -> Path: + """Đường dẫn file lịch sử run.""" from .co4e import CO4E_DIR return CO4E_DIR / "run_history.json" def _load_history(self) -> None: + """Khôi phục lịch sử run từ đĩa lúc khởi động; file hỏng thì bỏ qua lặng lẽ.""" path = self._history_path() try: data = json.loads(path.read_text(encoding="utf-8")) @@ -149,6 +168,7 @@ class Co4ERunManager(QObject): self._seq = max_seq # avoid minting ids that collide with history def _save_history(self) -> None: + """Ghi ``_HISTORY_CAP`` run gần nhất xuống đĩa.""" path = self._history_path() runs = list(self._runs.values())[-_HISTORY_CAP:] payload = {"runs": [h.to_record() for h in runs]} @@ -163,6 +183,7 @@ class Co4ERunManager(QObject): # ---- lifecycle -------------------------------------------------------- def _next_id(self) -> str: + """Sinh id run kế tiếp dạng 'runN'.""" self._seq += 1 return f"run{self._seq}" @@ -198,6 +219,7 @@ class Co4ERunManager(QObject): run_label = handle.name def job(worker: AgentWorker): + """Chạy nền: thực thi luồng, chuyển tiếp sự kiện tiến độ và cờ huỷ.""" return co4e_runner.run_workflow( ctx, nodes, edges, out_dir, worker.emit_event, worker.is_cancelled, plan_mode=plan_mode, skill_map=sk, only_nodes=only, seed_outputs=seed, @@ -215,6 +237,7 @@ class Co4ERunManager(QObject): # ---- worker callbacks ------------------------------------------------- def _on_event(self, run_id: str, ev: dict) -> None: + """Nhận sự kiện từ luồng đang chạy và cập nhật trạng thái/tiến độ của run.""" handle = self._runs.get(run_id) if handle is not None and isinstance(ev, dict): t = ev.get("type") @@ -229,6 +252,10 @@ class Co4ERunManager(QObject): self.event.emit(run_id, ev) def _on_finished(self, run_id: str) -> None: + """Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'. + + Lẽ ra không xảy ra, nhưng thiếu bước này thì run kẹt ở 'running' mãi. + """ handle = self._runs.get(run_id) if handle is not None and handle.status == "running": # job returned without a run_done event (shouldn't happen) — settle it @@ -236,6 +263,7 @@ class Co4ERunManager(QObject): self.changed.emit() def _on_failed(self, run_id: str, err: str) -> None: + """Job ném lỗi: ghi lỗi vào bản ghi run và báo ra ngoài.""" handle = self._runs.get(run_id) if handle is not None: handle.status = "error" @@ -245,6 +273,7 @@ class Co4ERunManager(QObject): # ---- control ---------------------------------------------------------- def stop(self, run_id: str) -> None: + """Yêu cầu dừng một run đang chạy.""" handle = self._runs.get(run_id) if handle is not None and handle.worker is not None and handle.running: handle.worker.request_stop() @@ -253,6 +282,7 @@ class Co4ERunManager(QObject): def stop_all(self) -> None: # Only the CURRENT workspace's runs (Flow Status is per-project). + """Dừng mọi run của workspace đang chọn.""" for run_id in [r for r, h in self._runs.items() if self._belongs(h)]: self.stop(run_id) @@ -269,6 +299,7 @@ class Co4ERunManager(QObject): self.changed.emit() def remove(self, run_id: str) -> None: + """Xoá một run khỏi lịch sử; đang chạy thì dừng trước.""" handle = self._runs.get(run_id) if handle is not None and handle.running: self.stop(run_id) @@ -277,6 +308,7 @@ class Co4ERunManager(QObject): def clear_finished(self) -> None: # Only clear finished runs of the CURRENT workspace. + """Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy.""" for run_id in [r for r, h in self._runs.items() if not h.running and self._belongs(h)]: self._runs.pop(run_id, None) self.changed.emit() @@ -295,9 +327,11 @@ class Co4ERunManager(QObject): return list(self._runs.values()) def get(self, run_id: str) -> Optional[RunHandle]: + """Bản ghi của một run theo id; ``None`` nếu không có.""" return self._runs.get(run_id) def active_count(self) -> int: + """Số run đang chạy của workspace đang chọn.""" return sum(1 for h in self._runs.values() if h.running and self._belongs(h)) def set_current_project(self, project_id: str) -> None: @@ -320,6 +354,11 @@ class Co4ERunManager(QObject): # tab), not in the config/install folder. One subfolder per flow keeps # runs tidy. Falls back to the global Cowork output dir when no workspace # is selected. + """Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có. + + Ưu tiên thư mục của workspace đang chọn để file rơi đúng chỗ người dùng làm + việc (màn Thư mục), không rơi vào thư mục cài đặt. + """ from .co4e import slugify base = self._output_root if base is None: diff --git a/core/co4e_runner.py b/core/co4e_runner.py index 5308700..96acad9 100644 --- a/core/co4e_runner.py +++ b/core/co4e_runner.py @@ -29,6 +29,9 @@ CancelFn = Callable[[], bool] def _predecessors(nodes: List[Node], edges: List[Edge]) -> Dict[str, List[str]]: + """Bảng ``{node: các node đứng trước}`` — dùng để gom đầu ra của bước trước làm + ngữ cảnh cho bước sau. + """ ids = {n.id for n in nodes} preds: Dict[str, List[str]] = {n.id: [] for n in nodes} for e in edges: @@ -38,6 +41,7 @@ def _predecessors(nodes: List[Node], edges: List[Edge]) -> Dict[str, List[str]]: def _label_of(nodes: List[Node], node_id: str) -> str: + """Nhãn hiển thị của một node; trả về chính id nếu không tìm thấy.""" for n in nodes: if n.id == node_id: return n.data.label @@ -62,6 +66,10 @@ def _attachments_text(node, out_dir=None) -> str: parts, budget = [], _MAX_ATTACH_CHARS def _read_into(path, label, indent=""): + """Đọc một tệp đính kèm vào phần ngữ cảnh, trừ dần vào hạn mức ký tự chung. + + Có hạn mức vì vài tệp lớn là đủ đẩy cả lượt chạy vượt cửa sổ ngữ cảnh. + """ nonlocal budget name = _P(path).name if is_image(path): @@ -97,6 +105,7 @@ def _attachments_text(node, out_dir=None) -> str: def _last_assistant_text(messages: List[dict]) -> str: + """Nội dung trả lời cuối cùng của assistant; '' nếu không có.""" for m in reversed(messages): if m.get("role") == "assistant" and m.get("content"): return str(m["content"]) @@ -245,6 +254,7 @@ def run_workflow(ctx, nodes: List[Node], edges: List[Edge], out_dir: Path, # Group compiled stages by wave, preserving per-node context threading. def extra_context_for(node_id: str) -> Dict[str, str]: + """Ngữ cảnh thêm cho một bước: tệp đính kèm của nó cộng đầu ra của các bước đứng trước.""" parts = [] att = _attachments_text(by_id.get(node_id), out_dir) if att: diff --git a/core/code_agent.py b/core/code_agent.py index 9cb47c6..088df89 100644 --- a/core/code_agent.py +++ b/core/code_agent.py @@ -31,6 +31,11 @@ _TOOL_LINE = re.compile(r"@@TOOL\s+(\w+)\s+(\{.*\})", re.DOTALL) def code_system_prompt(workdir: Path, has_memory: bool = False, plan: bool = False, has_plan_tool: bool = False, has_ms365: bool = False) -> str: + """Prompt hệ thống cho Code agent, ghép theo năng lực thật của lượt chạy. + + Chỉ liệt kê những tool đang BẬT, và thêm ghi chú chế độ lập kế hoạch khi cần — + nói với model về một tool nó không có sẽ khiến nó gọi rồi báo lỗi. + """ names = ", ".join(t.name for t in TOOL_SPECS) plan_note = ("PLAN MODE: only analyze and propose a detailed plan; do NOT write files or run " "commands. When the user asks to gencode/implement, the app switches to ACT.\n" diff --git a/core/codebase_memory.py b/core/codebase_memory.py index 1684908..7c2acd3 100644 --- a/core/codebase_memory.py +++ b/core/codebase_memory.py @@ -26,6 +26,7 @@ _INDEX_TIMEOUT = 900 class CodebaseMemoryError(RuntimeError): + """Lỗi khi gọi công cụ codebase-memory-mcp bên ngoài.""" pass @@ -75,14 +76,24 @@ def _extract_json(text: str): class CodebaseMemory: + """Vỏ bọc quanh CLI ``codebase-memory-mcp``: đánh chỉ mục và tra cứu mã nguồn. + + Đây là phần mềm ngoài, có thể không được cài — luôn kiểm :meth:`available` + trước khi dùng. + """ def __init__(self, binary_path: str = ""): + """Tìm file thực thi codebase-memory; không có thì ``available`` là False và + mọi lượt gọi về sau tự bỏ qua. + """ self.binary = resolve_binary(binary_path) @property def available(self) -> bool: + """Đã tìm thấy CLI trên máy chưa.""" return self.binary is not None def _run(self, tool: str, args: Dict[str, Any], timeout: int) -> Dict[str, Any]: + """Gọi một tool của CLI và trả kết quả JSON; chưa cài thì báo lỗi kèm hướng dẫn.""" if not self.binary: raise CodebaseMemoryError( "codebase-memory-mcp is not installed. See the instructions in Settings." @@ -107,12 +118,15 @@ class CodebaseMemory: # ---- high level ops --------------------------------------------- def index_repository(self, repo_path: str) -> Dict[str, Any]: + """Đánh chỉ mục một repository (chạy lâu — dùng hạn giờ dài hơn).""" return self._run("index_repository", {"repo_path": str(repo_path)}, _INDEX_TIMEOUT) def list_projects(self) -> Dict[str, Any]: + """Danh sách project đã được đánh chỉ mục.""" return self._run("list_projects", {}, _QUERY_TIMEOUT) def call(self, tool: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Gọi một tool bất kỳ, tự chọn hạn giờ theo loại việc.""" timeout = _INDEX_TIMEOUT if tool == "index_repository" else _QUERY_TIMEOUT return self._run(tool, args, timeout) @@ -187,6 +201,9 @@ def make_executor(mem: CodebaseMemory): """Return an executor(name, args) -> {ok, output} for cmem_* tools.""" def execute(name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Bộ thực thi tool codebase-memory cho agent; tên tool lạ thì trả về lỗi thay + vì ném ngoại lệ. + """ cli_tool = _CLI_NAME.get(name) if not cli_tool: return {"ok": False, "output": f"Unsupported codebase-memory tool: {name}"} diff --git a/core/codebase_memory_ui.py b/core/codebase_memory_ui.py index 446f350..dc4024f 100644 --- a/core/codebase_memory_ui.py +++ b/core/codebase_memory_ui.py @@ -33,6 +33,9 @@ class CmemUiError(RuntimeError): asset) — a different remedy than a generic startup/timeout failure.""" def __init__(self, message: str, no_ui_build: bool = False): + """``no_ui_build`` đánh dấu trường hợp riêng: chạy được nhưng bản cài không kèm + phần giao diện — thông báo cho người dùng phải khác hẳn lỗi chạy thường. + """ super().__init__(message) self.no_ui_build = no_ui_build @@ -41,16 +44,21 @@ class CodebaseMemoryUiServer: """One ``codebase-memory-mcp --ui`` process, started on demand.""" def __init__(self, binary_path: str = "", port: int = DEFAULT_PORT): + """Chuẩn bị chỗ chạy máy chủ giao diện; chưa khởi động tiến trình nào.""" self.binary = resolve_binary(binary_path) self.port = port self._proc: Optional[subprocess.Popen] = None @property def url(self) -> str: + """Địa chỉ để mở giao diện. Chỉ nghe trên 127.0.0.1 — đây là công cụ cục bộ, + không mở ra mạng. + """ return f"http://127.0.0.1:{self.port}/" @property def running(self) -> bool: + """Tiến trình máy chủ còn sống không.""" return self._proc is not None and self._proc.poll() is None def start(self, repo_path: str = "") -> str: @@ -75,6 +83,9 @@ class CodebaseMemoryUiServer: no_ui_event = threading.Event() def _reader() -> None: + """Chạy nền: đọc đầu ra của tiến trình, giữ lại để báo lỗi và bật cờ khi thấy + dấu hiệu bản cài không có phần giao diện. + """ try: stream = self._proc.stdout if stream is None: @@ -111,6 +122,11 @@ class CodebaseMemoryUiServer: raise CmemUiError(f"Hết thời gian chờ UI trên cổng {self.port}.") def stop(self) -> None: + """Dừng máy chủ. Xin dừng tử tế trước, quá 3 giây thì buộc tắt. + + Mọi lỗi đều bị nuốt có chủ ý: đây là dọn dẹp lúc thoát, ném lỗi ở đây chỉ + làm kẹt đường thoát của cả ứng dụng. + """ proc, self._proc = self._proc, None if proc is not None and proc.poll() is None: try: diff --git a/core/context_budget.py b/core/context_budget.py index 6f4301c..b26b737 100644 --- a/core/context_budget.py +++ b/core/context_budget.py @@ -33,6 +33,9 @@ _MODEL_LIMITS = { def model_context_limit(model: str) -> int: + """Cửa sổ ngữ cảnh (token) của một model, dò theo tiền tố tên dài nhất khớp + trong bảng; không khớp gì thì lấy ``DEFAULT_LIMIT``. + """ m = (model or "").lower() best = 0 limit = DEFAULT_LIMIT @@ -43,6 +46,7 @@ def model_context_limit(model: str) -> int: def _ctx_conf(config) -> Dict[str, Any]: + """Nhóm cấu hình ``context``; không có config thì trả dict rỗng.""" if config is None: return {} try: @@ -59,11 +63,13 @@ def context_limit(config, model: str = "") -> int: def auto_compact_enabled(config) -> bool: + """Có tự nén lịch sử khi gần đầy ngữ cảnh không (mặc định bật).""" conf = _ctx_conf(config) return bool(conf.get("auto_compact", True)) def threshold(config) -> float: + """Ngưỡng nén, tính theo tỉ lệ cửa sổ ngữ cảnh đã dùng (mặc định 0,8).""" conf = _ctx_conf(config) try: t = float(conf.get("compact_threshold", DEFAULT_THRESHOLD)) @@ -73,6 +79,9 @@ def threshold(config) -> float: def _msg_text(m: Dict[str, Any]) -> str: + """Rút phần văn bản của một tin nhắn, kể cả khi nội dung là danh sách block + (tin nhắn có ảnh). + """ c = m.get("content", "") if isinstance(c, str): return c @@ -81,11 +90,17 @@ def _msg_text(m: Dict[str, Any]) -> str: def estimate_messages_tokens(messages: List[Dict[str, Any]]) -> int: + """Ước lượng tổng token của cả danh sách tin nhắn.""" return sum(estimate_tokens(_msg_text(m)) for m in messages) def should_compact(messages: List[Dict[str, Any]], limit: int, thresh: float = DEFAULT_THRESHOLD) -> bool: + """Đã đến lúc nén lịch sử chưa. + + Không nén khi hội thoại còn quá ngắn: nén một cuộc mới vài lượt thì mất nội + dung mà chẳng tiết kiệm được bao nhiêu. + """ if limit <= 0 or len(messages) <= _KEEP_RECENT + 2: return False return estimate_messages_tokens(messages) > limit * thresh @@ -99,6 +114,7 @@ _SUMMARY_PROMPT = ( def _summarize(provider, middle: List[Dict[str, Any]], cancel=None) -> str: + """Nhờ model tóm tắt phần giữa của hội thoại thành một đoạn ngắn.""" convo = "\n\n".join(f"[{m.get('role', '?')}] {_msg_text(m)}" for m in middle) try: a = provider.chat([{"role": "system", "content": _SUMMARY_PROMPT}, diff --git a/core/cron.py b/core/cron.py index 9d59cc1..4084011 100644 --- a/core/cron.py +++ b/core/cron.py @@ -15,10 +15,14 @@ _SEARCH_DAYS = 366 * 2 # give up after two years (an expression that never fir class CronError(ValueError): + """Biểu thức cron sai cú pháp.""" pass def _parse_field(spec: str, lo: int, hi: int) -> Set[int]: + """Đọc một trường cron thành tập giá trị: hỗ trợ ``*``, danh sách ``a,b``, + khoảng ``a-b`` và bước ``*/n``. + """ values: Set[int] = set() for part in spec.split(","): part = part.strip() @@ -55,7 +59,13 @@ def _parse_field(spec: str, lo: int, hi: int) -> Set[int]: class Cron: + """Biểu thức cron 5 trường (phút, giờ, ngày, tháng, thứ).""" def __init__(self, expression: str): + """Phân tích một biểu thức cron 5 trường. + + Sai số trường là ném ``CronError`` ngay tại đây chứ không đợi tới lúc chạy: + lịch sai giờ khó phát hiện hơn nhiều so với một lỗi lúc nhập. + """ fields = (expression or "").split() if len(fields) != 5: raise CronError("Cron expression needs exactly 5 fields: " @@ -69,6 +79,11 @@ class Cron: self._dow_star = fields[4].strip() == "*" def _day_matches(self, dt: datetime) -> bool: + """Ngày này có khớp biểu thức không. + + Theo chuẩn cron: khi cả trường NGÀY và trường THỨ đều được đặt cụ thể thì + khớp một trong hai là đủ (OR), chứ không phải cả hai (AND). + """ if dt.month not in self.months: return False cron_dow = (dt.weekday() + 1) % 7 # Python Mon=0 → cron Sun=0 diff --git a/core/custom_agents.py b/core/custom_agents.py index 34cfc6f..c07a2f2 100644 --- a/core/custom_agents.py +++ b/core/custom_agents.py @@ -21,6 +21,14 @@ AGENTS_DIR = CONFIG_DIR / "agents" @dataclass class CustomAgent: + """Một agent do người dùng tự tạo: tên, mô tả, prompt mặc định và tuỳ chọn + provider/model riêng. + + Bỏ trống ``provider``/``model`` nghĩa là dùng theo bước gọi nó hoặc theo cấu + hình chung — nhờ vậy một agent viết một lần chạy được với mọi provider. + + Đã được ``core/co4e.py`` thay thế; giữ lại làm bản đối chiếu. + """ name: str description: str = "" prompt: str = "" # default task; a Flow sub-agent can still override it @@ -29,16 +37,25 @@ class CustomAgent: @property def slug(self) -> str: + """Tên rút gọn an toàn để đặt tên file, ví dụ "Trợ lý Code" -> "tro-ly-code". + Tên không còn ký tự hợp lệ nào thì rơi về "agent". + """ keep = "-_" s = "".join(c if (c.isalnum() or c in keep) else "-" for c in self.name.strip().lower()) return "-".join(filter(None, s.split("-"))) or "agent" def agents_dir() -> Path: + """Thư mục chứa file agent tự tạo.""" return AGENTS_DIR def list_agents(directory: Path = AGENTS_DIR) -> List[CustomAgent]: + """Đọc mọi agent trong thư mục, sắp theo tên file. + + File hỏng bị bỏ riêng lẻ chứ không làm hỏng cả danh sách — một file sai + không được phép làm mất hết agent còn lại. + """ if not directory.exists(): return [] agents: List[CustomAgent] = [] @@ -58,6 +75,11 @@ def list_agents(directory: Path = AGENTS_DIR) -> List[CustomAgent]: def save_agent(agent: CustomAgent, directory: Path = AGENTS_DIR, old_name: str = "") -> Path: + """Ghi một agent xuống đĩa. + + Truyền ``old_name`` khi đổi tên: file cũ bị xoá trước, nếu không sẽ có hai + file cùng nội dung với hai tên khác nhau. + """ directory.mkdir(parents=True, exist_ok=True) if old_name and old_name != agent.name: delete_agent(old_name, directory) @@ -67,6 +89,9 @@ def save_agent(agent: CustomAgent, directory: Path = AGENTS_DIR, old_name: str = def delete_agent(name: str, directory: Path = AGENTS_DIR) -> None: + """Xoá file của một agent theo tên. Không có file thì thôi; lỗi xoá bị nuốt, + không chặn giao diện. + """ path = directory / f"{CustomAgent(name=name).slug}.json" if path.exists(): try: diff --git a/core/custom_icons.py b/core/custom_icons.py index 6ccc69f..84dd629 100644 --- a/core/custom_icons.py +++ b/core/custom_icons.py @@ -18,15 +18,18 @@ _MAX_BYTES = 200_000 def icons_dir() -> Path: + """Thư mục chứa icon do người dùng thêm.""" return ICONS_DIR def slugify(name: str) -> str: + """Định danh an toàn cho tên file icon; rỗng thì trả về 'icon'.""" s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (name or "").strip().lower()) return "-".join(filter(None, s.split("-"))) or "icon" def list_custom(directory: Optional[Path] = None) -> List[str]: + """Tên các icon tự thêm; thư mục chưa có thì trả list rỗng.""" directory = directory or ICONS_DIR if not directory.exists(): return [] @@ -69,6 +72,7 @@ def add_from_file(path, name: str = "", directory: Optional[Path] = None) -> str def delete_custom(name: str, directory: Optional[Path] = None) -> None: + """Xoá một icon tự thêm; không có thì bỏ qua.""" directory = directory or ICONS_DIR path = directory / f"{slugify(name)}.svg" if path.exists(): diff --git a/core/d3_graph.py b/core/d3_graph.py index f7340f3..20b30c2 100644 --- a/core/d3_graph.py +++ b/core/d3_graph.py @@ -18,6 +18,7 @@ _CDN_D3 = '