refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%

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 <ngày>` 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:41:45 +09:00
co-authored by Claude Opus 5
parent d20306be08
commit e29a0ccdbd
264 changed files with 4593 additions and 359 deletions
+7
View File
@@ -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:
+7
View File
@@ -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"))
@@ -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.
@@ -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,
@@ -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
@@ -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:
@@ -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
@@ -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)
+3
View File
@@ -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.
"""
@@ -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,
@@ -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:
@@ -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."""
@@ -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]
@@ -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()
@@ -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)
+99
View File
@@ -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 ``<tên>.bad-<mốc>`` 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"]
+62 -53
View File
@@ -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
# "<ten>.bad-<timestamp>" (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
@@ -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)
@@ -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]:
+10
View File
@@ -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
+9
View File
@@ -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 ``<username>.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)
+13
View File
@@ -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 ``<agent_id>.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
+7
View File
@@ -31,6 +31,7 @@ _CMD = re.compile(r"(?<!\S)/agent(?::([\w\-.]+))?(?=$|[\s.,;:!?)\]}»”’'\"
def _slug(name: str) -> 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']}"
+2
View File
@@ -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 "—")
+11
View File
@@ -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 {})
+2
View File
@@ -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
+4
View File
@@ -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
+1
View File
@@ -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
+12
View File
@@ -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,
+57
View File
@@ -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 ``<prefix>_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 ``<id>.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 ``<id>.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 "<node>__p<i>" / "<node>__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
+1
View File
@@ -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
+39
View File
@@ -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:
+10
View File
@@ -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:
+5
View File
@@ -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"
+17
View File
@@ -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}"}
+16
View File
@@ -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:
+16
View File
@@ -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},
+15
View File
@@ -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
+25
View File
@@ -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:
+4
View File
@@ -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():
+1
View File
@@ -18,6 +18,7 @@ _CDN_D3 = '<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.j
def build_html(graph) -> str:
"""Dựng trang HTML D3 cho đồ thị: nhét dữ liệu node/cạnh vào bản mẫu."""
html = TEMPLATE.read_text(encoding="utf-8")
# Inline a bundled d3 (offline) if present; else keep the CDN reference.
+13
View File
@@ -29,6 +29,7 @@ _ACTIVE_PIDS: set[int] = set()
def active_pids() -> List[int]:
"""Pid của các tiến trình con đang chạy — dùng để dọn sạch khi thoát app."""
with _active_pids_lock:
return sorted(_ACTIVE_PIDS)
@@ -142,6 +143,11 @@ def _run_cancellable_body(
proc: "subprocess.Popen", cancel: CancelFn, timeout: Optional[float],
on_output: Optional[Callable[[str], None]], limits: Optional[Dict[str, float]],
) -> Tuple[Optional[int], str, bool, bool, bool]:
"""Chạy một tiến trình con có thể huỷ giữa chừng, có hạn giờ và có giới hạn tài nguyên.
Trên Windows gắn tiến trình vào một Job Object để khi giết là giết cả cây
tiến trình con — giết mỗi tiến trình cha sẽ để lại đám con mồ côi.
"""
job_handle = None
if sys.platform == "win32":
from .win_job import assign_process, create_job_object
@@ -158,6 +164,11 @@ def _run_cancellable_body(
collected: Dict[str, list] = {"out": [], "err": []}
def _read_stream(stream, key: str) -> None:
"""Đọc một luồng đầu ra theo từng dòng ở luồng riêng.
Phải đọc song song stdout và stderr: đọc lần lượt sẽ kẹt khi tiến trình con
làm đầy bộ đệm của luồng còn lại.
"""
try:
for line in iter(stream.readline, ""):
collected[key].append(line)
@@ -239,6 +250,7 @@ def network_blocked_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str,
def _can_pip() -> bool:
# A PyInstaller/py2exe build has no usable pip; don't attempt installs there.
"""Bản đóng gói (PyInstaller) không có pip dùng được — đừng thử cài gì ở đó."""
return not getattr(sys, "frozen", False)
@@ -267,6 +279,7 @@ def ensure_module(module: str, package: str | None = None):
def venv_python_path(venv_dir: Path) -> Path:
"""Đường dẫn tới ``python`` trong một virtualenv, khác nhau giữa Windows và POSIX."""
return venv_dir / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
+21
View File
@@ -23,6 +23,7 @@ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif",
def is_image(path) -> bool:
"""Đuôi tệp này có phải ảnh không."""
return Path(path).suffix.lower() in IMAGE_EXTS
@@ -164,6 +165,10 @@ def extract_text(path, progress=None) -> tuple[str | None, str]:
# Office Open XML (docx / xlsx / pptx)
# --------------------------------------------------------------------------
def _docx(p: Path) -> str:
"""Trích văn bản từ .docx bằng cách đọc thẳng XML trong gói zip.
Không cần thư viện ngoài — .docx vốn là một file zip chứa XML.
"""
with zipfile.ZipFile(p) as z:
xml = z.read("word/document.xml").decode("utf-8", "replace")
out: list[str] = []
@@ -179,6 +184,7 @@ def _docx(p: Path) -> str:
def _pptx(p: Path) -> str:
"""Trích văn bản từ .pptx, đi theo đúng thứ tự slide."""
out: list[str] = []
with zipfile.ZipFile(p) as z:
slides = [n for n in z.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", n)]
@@ -192,6 +198,11 @@ def _pptx(p: Path) -> str:
def _xlsx(p: Path) -> str:
"""Trích văn bản từ .xlsx, có phân giải bảng chuỗi dùng chung.
Excel lưu chuỗi trong một bảng riêng và ô chỉ giữ chỉ số — đọc thẳng ô sẽ ra
toàn số.
"""
with zipfile.ZipFile(p) as z:
names = z.namelist()
shared: list[str] = []
@@ -235,6 +246,7 @@ def _xlsx(p: Path) -> str:
# OpenDocument (odt / ods / odp)
# --------------------------------------------------------------------------
def _odf(p: Path) -> str:
"""Trích văn bản từ tài liệu OpenDocument (.odt/.ods/.odp)."""
with zipfile.ZipFile(p) as z:
xml = z.read("content.xml").decode("utf-8", "replace")
xml = re.sub(r"<text:line-break\s*/>", "\n", xml)
@@ -249,6 +261,10 @@ def _odf(p: Path) -> str:
# PDF + LibreOffice fallback
# --------------------------------------------------------------------------
def _pdf(p: Path, progress=None) -> tuple[str | None, str]:
"""Trích văn bản từ PDF bằng ``pypdf``, tự cài nếu thiếu.
Trả về (văn bản, ghi chú); văn bản là ``None`` khi không trích được.
"""
from .deps import ensure_module
# Auto-install pypdf when missing (no manual install needed); fall back to
@@ -369,6 +385,11 @@ def _office_com_to_pdf(src: Path, pdf: Path) -> str | None:
def _soffice_to_text(p: Path) -> tuple[str | None, str]:
"""Cách dự phòng cuối: nhờ LibreOffice chuyển tài liệu sang văn bản.
Dùng cho định dạng không có bộ đọc riêng; không cài LibreOffice thì trả về
lý do để chỗ gọi hiện ra.
"""
soffice = find_soffice()
if not soffice:
return None, "no extractor available (install LibreOffice)"
+4
View File
@@ -21,6 +21,10 @@ _RUN_PREVIEW_CHARS = 40
def _run_style(font) -> str:
"""Mô tả định dạng một đoạn chữ (đậm, nghiêng, cỡ, màu) thành chuỗi ngắn.
Dùng để AI sửa tài liệu mà vẫn giữ được định dạng gốc.
"""
bits: list[str] = []
try:
if font.name:
+11
View File
@@ -79,6 +79,7 @@ def new_connector(category: str, preset_id: str = "", name: str = "") -> Dict[st
def _redact(entry: Dict[str, Any]) -> Dict[str, Any]:
"""Bản sao đã che các trường nhạy cảm (khoá, token) — dùng khi ghi log/kiểm toán."""
out = dict(entry)
for k in _SENSITIVE_KEYS:
if out.get(k):
@@ -92,6 +93,11 @@ class RestApiConnector:
vendor documents without this app knowing that vendor's API shape."""
def __init__(self, entry: Dict[str, Any]):
"""Đọc một khai báo connector REST.
``base_url`` luôn được chuẩn hoá thành có đúng một dấu ``/`` ở cuối, để ghép
đường dẫn về sau không sinh ra ``//`` hay dính liền.
"""
self.id = entry.get("id") or entry.get("name", "")
self.display_name = entry.get("name") or self.id
self.base_url = (entry.get("base_url") or "").rstrip("/") + "/"
@@ -100,6 +106,9 @@ class RestApiConnector:
self.auth_scheme = entry.get("auth_scheme") or "Bearer"
def tool_spec(self) -> ToolSpec:
"""Khai báo tool để đưa cho model; tên tool có tiền tố là id connector nên hai
connector không đụng tên nhau.
"""
return ToolSpec(
name=f"{self.id}{_SEP}http_request",
description=(
@@ -122,6 +131,7 @@ class RestApiConnector:
)
def call(self, args: Dict[str, Any]) -> Dict[str, Any]:
"""Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ."""
from .tls_trust import request_any_method as tls_request
method = str(args.get("method", "GET")).upper()
@@ -153,6 +163,7 @@ class RestApiConnector:
return {"ok": ok, "output": f"HTTP {resp.status_code}\n{text}"}
def test_connection(self) -> Tuple[bool, str]:
"""Thử kết nối tới endpoint; trả về (thành công, thông điệp)."""
from .tls_trust import request as tls_request
if not self.base_url.strip("/"):
+24
View File
@@ -30,6 +30,7 @@ class SubAgent:
@dataclass
class FlowStep:
"""Một bước trong luồng cũ: prompt, skill áp dụng, và danh sách agent chạy song song."""
name: str
prompt: str = ""
skill: str = "" # skill name to apply on this step ("" = none)
@@ -44,11 +45,13 @@ class FlowStep:
@property
def is_parallel(self) -> bool:
"""Bước này có chạy nhiều agent song song hay không."""
return bool(self.parallel_agents)
@dataclass
class Flow:
"""Một luồng cũ: tên, mô tả, và danh sách bước chạy tuần tự."""
name: str
description: str = ""
steps: List[FlowStep] = field(default_factory=list)
@@ -77,6 +80,11 @@ class FlowRunStatus:
substeps: List[dict] = field(default_factory=list)
def state_of(self, i: int) -> str:
"""Trạng thái hiển thị của bước thứ ``i``: xong, đang chạy, lỗi hay còn chờ.
Chỉ bước ngay TRƯỚC con trỏ mới được đánh dấu lỗi — các bước xong trước đó
vẫn là xong.
"""
if i < self.done:
if self.last_error and i == self.done - 1:
return STEP_ERROR
@@ -97,11 +105,13 @@ class FlowRunStatus:
def _slug(name: str) -> str:
"""Định danh an toàn cho tên file, suy từ tên luồng."""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in name.strip().lower())
return "-".join(filter(None, s.split("-"))) or "flow"
def flows_dir() -> Path:
"""Thư mục chứa file luồng cũ."""
return FLOWS_DIR
@@ -126,11 +136,13 @@ def default_req_to_demo() -> Flow:
def to_dict(flow: Flow) -> dict:
"""Chuyển một luồng thành dict để ghi JSON."""
return {"name": flow.name, "description": flow.description,
"steps": [asdict(s) for s in flow.steps]}
def from_dict(data: dict) -> Flow:
"""Dựng :class:`Flow` từ dict đọc trên đĩa, lọc bỏ khoá lạ."""
steps = []
for raw in data.get("steps", []):
raw = dict(raw)
@@ -142,6 +154,7 @@ def from_dict(data: dict) -> Flow:
def list_flows(directory: Path = FLOWS_DIR) -> List[Flow]:
"""Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng."""
if not directory.exists():
return []
flows: List[Flow] = []
@@ -154,6 +167,11 @@ def list_flows(directory: Path = FLOWS_DIR) -> List[Flow]:
def save_flow(flow: Flow, directory: Path = FLOWS_DIR, old_name: str = "") -> Path:
"""Ghi một luồng xuống đĩa.
Đổi tên thì XOÁ file cũ trước — tên file suy từ tên luồng, không xoá sẽ để
lại một bản sao dưới tên cũ.
"""
directory.mkdir(parents=True, exist_ok=True)
if old_name and old_name != flow.name:
delete_flow(old_name, directory)
@@ -163,6 +181,7 @@ def save_flow(flow: Flow, directory: Path = FLOWS_DIR, old_name: str = "") -> Pa
def delete_flow(name: str, directory: Path = FLOWS_DIR) -> None:
"""Xoá file luồng theo tên; không có thì bỏ qua."""
path = directory / f"{_slug(name)}.json"
if path.exists():
try:
@@ -269,19 +288,23 @@ class FlowRunner:
@property
def step_index(self) -> int:
"""Chỉ số bước đang chạy."""
return self._index
def current_step(self) -> Optional[FlowStep]:
"""Bước đang chạy; ``None`` khi đã hết bước."""
if 0 <= self._index < len(self.flow.steps):
return self.flow.steps[self._index]
return None
def start(self) -> FlowAction:
"""Bắt đầu chạy luồng và trả về hành động đầu tiên cần thực hiện."""
if self.current_step() is None:
return FlowAction(kind="done")
return self._step_action()
def _step_action(self) -> FlowAction:
"""Hành động cho bước hiện tại: chạy một agent, hay chia ra nhiều agent song song."""
step = self.current_step()
self._phase = "step"
if step.is_parallel:
@@ -326,6 +349,7 @@ class FlowRunner:
return self._advance(compact=compact)
def _advance(self, compact: bool) -> FlowAction:
"""Sang bước kế tiếp; hết bước thì báo luồng đã xong."""
self._index += 1
if self.current_step() is None:
return FlowAction(kind="done", compact=compact)
+18
View File
@@ -28,6 +28,12 @@ class GraphServer:
"""Lazy singleton-per-instance localhost server for the D3 graph page."""
def __init__(self) -> None:
"""Chuẩn bị máy chủ; chưa mở cổng nào.
Một token ngẫu nhiên được sinh ngay lúc này và mọi yêu cầu đều phải mang
nó: máy chủ nghe trên localhost, nhưng mọi tiến trình khác trên cùng máy đều
gọi được localhost.
"""
self._html = _PLACEHOLDER
self._token = secrets.token_urlsafe(16)
self._lock = threading.Lock()
@@ -37,6 +43,7 @@ class GraphServer:
# ---- content / callbacks ----------------------------------------
def set_html(self, html: str) -> None:
"""Đặt nội dung HTML sẽ phục vụ; có khoá vì luồng nền ghi còn luồng HTTP đọc."""
with self._lock:
self._html = html
@@ -47,10 +54,12 @@ class GraphServer:
# ---- lifecycle ----------------------------------------------------
@property
def running(self) -> bool:
"""Máy chủ có đang chạy không."""
return self._httpd is not None
@property
def url(self) -> str:
"""URL đầy đủ kèm token; '' nếu chưa chạy."""
if self._httpd is None:
return ""
port = self._httpd.server_address[1]
@@ -63,14 +72,22 @@ class GraphServer:
server = self
class Handler(BaseHTTPRequestHandler):
"""Handler HTTP: chỉ phục vụ đúng trang đồ thị, và chỉ khi token khớp."""
def log_message(self, *_a) -> None: # keep the GUI console silent
"""Tắt log của thư viện chuẩn — nếu không, console GUI bị ngập request."""
pass
def _authorized(self, query: dict) -> bool:
"""Kiểm token trong query, so sánh theo kiểu chống dò thời gian.
Máy chủ này nghe trên localhost nhưng vẫn cần token: mọi tiến trình khác
trên cùng máy đều gọi được nó.
"""
supplied = (query.get("t") or [""])[0]
return secrets.compare_digest(supplied, server._token)
def do_GET(self) -> None: # noqa: N802 - stdlib naming
"""Trả trang đồ thị khi token đúng; sai token thì trả 403."""
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
if not self._authorized(query):
@@ -108,6 +125,7 @@ class GraphServer:
return self.url
def stop(self) -> None:
"""Dừng máy chủ và giải phóng cổng."""
httpd, self._httpd = self._httpd, None
if httpd is not None:
httpd.shutdown()
+7
View File
@@ -15,6 +15,7 @@ from typing import List, Optional
@dataclass
class Group:
"""Một nhóm người dùng: id, tên, và tài khoản quản trị nhóm."""
group_id: str
name: str
subadmin_username: str = ""
@@ -23,16 +24,19 @@ class Group:
def groups_dir(shared_dir: str) -> Path:
"""Thư mục chứa nhóm, nằm trong thư mục chia sẻ của đội."""
return Path(shared_dir).expanduser() / "groups"
def new_group(name: str, subadmin_username: str = "") -> Group:
"""Tạo một nhóm mới với id ngẫu nhiên và mốc thời gian tạo."""
return Group(group_id=uuid.uuid4().hex, name=name.strip() or "Group",
subadmin_username=subadmin_username,
created=datetime.now().isoformat(timespec="seconds"))
def save_group(group: Group, directory: Path) -> Path:
"""Ghi một nhóm ra ``<group_id>.json``."""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{group.group_id}.json"
path.write_text(json.dumps(asdict(group), ensure_ascii=False, indent=2), encoding="utf-8")
@@ -40,6 +44,7 @@ def save_group(group: Group, directory: Path) -> Path:
def load_group(group_id: str, directory: Path) -> Optional[Group]:
"""Đọc một nhóm theo id; id được làm sạch trước để không thoát khỏi thư mục."""
safe_id = re.sub(r"[^\w\-]", "", group_id or "")
path = directory / f"{safe_id}.json"
if not path.exists():
@@ -53,6 +58,7 @@ def load_group(group_id: str, directory: Path) -> Optional[Group]:
def list_groups(directory: Path) -> List[Group]:
"""Liệt kê mọi nhóm trong thư mục; thư mục chưa có thì trả list rỗng."""
if not directory.exists():
return []
out: List[Group] = []
@@ -65,6 +71,7 @@ def list_groups(directory: Path) -> List[Group]:
def delete_group(group_id: str, directory: Path) -> bool:
"""Xoá file nhóm; id rỗng hoặc không có file thì trả ``False``."""
safe_id = re.sub(r"[^\w\-]", "", group_id or "")
if not safe_id:
return False
+17
View File
@@ -18,10 +18,15 @@ from typing import Any, Dict, List
def new_session_id() -> str:
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
def derive_title(messages: List[Dict[str, Any]]) -> str:
"""Suy tiêu đề hội thoại từ tin nhắn đầu tiên của người dùng.
Dùng khi người dùng chưa tự đặt tên — cắt gọn cho vừa một dòng danh sách.
"""
for m in messages:
if m.get("role") == "user" and m.get("content"):
text = " ".join(m["content"].split())
@@ -40,6 +45,12 @@ def save_conversation(
outputs: List[str] | None = None,
project_id: str = "",
) -> Path:
"""Ghi một hội thoại xuống ``<kind>__<session_id>.json``.
Ghi nguyên tử (R06-T02). Cờ ghim và project_id của lần lưu trước được GIỮ
LẠI: hàm này bị gọi tự động sau mỗi lượt chat, ghi đè chúng sẽ âm thầm bỏ
ghim và đẩy hội thoại ra khỏi project của nó.
"""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{kind}__{session_id}.json"
pinned = False # preserve pin flag + project across autosaves
@@ -71,6 +82,7 @@ def save_conversation(
def delete_conversation(path) -> None:
"""Xoá file hội thoại; không có thì bỏ qua."""
try:
Path(path).unlink()
except OSError:
@@ -78,6 +90,7 @@ def delete_conversation(path) -> None:
def rename_conversation(path, new_title: str) -> None:
"""Đổi tiêu đề một hội thoại và ghi lại (nguyên tử)."""
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
@@ -86,6 +99,7 @@ def rename_conversation(path, new_title: str) -> None:
def set_pinned(path, pinned: bool) -> None:
"""Ghim/bỏ ghim một hội thoại để nó nằm trên đầu danh sách."""
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
@@ -94,6 +108,9 @@ def set_pinned(path, pinned: bool) -> None:
def load_conversation(path: Path) -> Dict[str, Any]:
"""Đọc một hội thoại; file hỏng hoặc không đọc được thì trả về dict rỗng thay
vì ném lỗi — một file hỏng không được phép làm chết cả danh sách lịch sử.
"""
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
+5
View File
@@ -44,6 +44,11 @@ def _package_calendar(country: str, year: int):
def is_holiday(d: date, country: Optional[str]) -> bool:
"""Ngày này có phải ngày nghỉ của một quốc gia không.
Không đặt quốc gia thì luôn trả ``False`` — không suy đoán lịch nghỉ thay
người dùng.
"""
country = (country or "").strip().upper()
if not country:
return False
+4
View File
@@ -28,6 +28,10 @@ _IMAGE_MODEL_MARKERS = (
def looks_like_image_model(name: str) -> bool:
"""Đoán một model có sinh ảnh được không, dựa trên dấu hiệu trong tên.
Đoán theo tên vì không provider nào khai báo năng lực này qua API.
"""
n = (name or "").lower()
return any(m in n for m in _IMAGE_MODEL_MARKERS)
+4
View File
@@ -20,11 +20,13 @@ _KEY_RE = re.compile(r"\b([A-Z][A-Z0-9]+-\d+)\b")
def _conf(config: Dict[str, Any] | None) -> Dict[str, str]:
"""Ba trường cấu hình Jira đã cắt khoảng trắng: base_url, email, api_token."""
return {k: str((config or {}).get(k, "") or "").strip()
for k in ("base_url", "email", "api_token")}
def configured(config: Dict[str, Any] | None) -> bool:
"""Đã cấu hình đủ ba trường để gọi Jira chưa."""
c = _conf(config)
return bool(c["base_url"] and c["email"] and c["api_token"])
@@ -82,6 +84,7 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str:
def _get(config: Dict[str, Any], path: str, params: dict = None):
"""Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ."""
from . import tls_trust
c = _conf(config)
@@ -97,6 +100,7 @@ def _get(config: Dict[str, Any], path: str, params: dict = None):
def _fmt_issue(it: dict) -> str:
"""Một dòng tóm tắt issue: mã, trạng thái và tiêu đề."""
f = it.get("fields", {}) or {}
status = (f.get("status") or {}).get("name", "?")
assignee = (f.get("assignee") or {}).get("displayName", "unassigned")
+7
View File
@@ -48,6 +48,9 @@ _DOC_SUFFIXES = {".pdf", ".doc", ".docx", ".docm", ".xls", ".xlsx", ".xlsm",
def _html_to_text(html: str) -> str:
"""Rút văn bản đọc được từ HTML: bỏ script/style, đổi thẻ thành xuống dòng rồi
gộp khoảng trắng thừa.
"""
text = _SCRIPT_STYLE_RE.sub(" ", html)
text = _TAG_RE.sub("\n", text)
text = _WS_RE.sub(" ", text)
@@ -81,6 +84,10 @@ _ONEDRIVE_HOSTS = {"1drv.ms", "onedrive.live.com"}
def _is_share_link(url: str) -> bool:
"""Link này có phải link chia sẻ SharePoint/OneDrive không.
Loại link đó cần đi qua đường xác thực MS365 thay vì tải HTTP thường.
"""
host = (urlparse(url).hostname or "").lower()
return bool(_SHAREPOINT_HOST_RE.search(host)) or host in _ONEDRIVE_HOSTS
+18
View File
@@ -27,6 +27,7 @@ _SEP = "__"
class McpServerError(RuntimeError):
"""Lỗi khi nối hoặc gọi một MCP server."""
pass
@@ -35,6 +36,9 @@ class McpServerConnection:
def __init__(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None):
"""Ghi nhận cách khởi động một máy chủ MCP; chưa chạy tiến trình nào cho tới
lần dùng đầu tiên.
"""
self.name = name
self.command = command
self.args = list(args or [])
@@ -59,6 +63,7 @@ class McpServerConnection:
raise McpServerError(f"MCP server '{self.name}' failed to start: {self._start_error}")
def _run_loop(self) -> None:
"""Thân luồng nền: dựng vòng lặp asyncio riêng và giữ nó chạy."""
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
@@ -79,6 +84,7 @@ class McpServerConnection:
loop.close()
async def _connect(self) -> None:
"""Khởi động tiến trình con và bắt tay phiên MCP."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
@@ -93,6 +99,10 @@ class McpServerConnection:
self._session = session
async def _aclose(self) -> None:
"""Đóng các context đã mở theo THỨ TỰ NGƯỢC.
Đóng xuôi sẽ đóng transport trước phiên và treo ở bước dọn dẹp.
"""
for cm in reversed(self._cm_stack):
try:
await cm.__aexit__(None, None, None)
@@ -101,6 +111,7 @@ class McpServerConnection:
self._cm_stack.clear()
def stop(self) -> None:
"""Dừng kết nối: tắt vòng lặp asyncio và chờ luồng nền kết thúc."""
if self._loop is not None and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
if self._thread is not None:
@@ -141,6 +152,10 @@ class McpServerConnection:
return {"ok": ok, "output": output}
def _run_coro(self, coro):
"""Chạy một coroutine trên vòng lặp của kết nối và chờ kết quả.
Đây là cầu nối duy nhất giữa mã đồng bộ của app và phiên MCP bất đồng bộ.
"""
if self._loop is None:
raise McpServerError(f"MCP server '{self.name}' is not connected")
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
@@ -166,6 +181,9 @@ def build_mcp_tools(servers: List[McpServerConnection]) -> Tuple[List[ToolSpec],
return [], None
def executor(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Bộ thực thi cho tool MCP: định tuyến theo tên về đúng server và ghi nhật ký
kiểm toán cho mỗi lần gọi.
"""
from . import audit_log
server = routing.get(name)
+14
View File
@@ -65,6 +65,9 @@ _DIGITS = {"VND": 0, "JPY": 1, "USD": 4}
def format_price(amount: float, ccy: str) -> str:
"""Định dạng số tiền kèm ký hiệu tiền tệ, số chữ số thập phân theo từng loại
tiền (VND 0, JPY 1, USD 4).
"""
ccy = (ccy or "USD").upper()
return f"{amount:,.{_DIGITS.get(ccy, 2)}f} {_SYMBOLS.get(ccy, '')}".strip()
@@ -99,14 +102,21 @@ def parse_price(text: Any) -> tuple:
# ---- store ---------------------------------------------------------------
def _bucket(config) -> Dict[str, Any]:
"""Nhóm cấu hình ``model_pricing``; tự tạo nếu chưa có."""
return config.data.setdefault("model_pricing", {})
def list_entries(config) -> List[Dict[str, Any]]:
"""Danh sách dòng đơn giá đã lưu (bản sao, sửa không ảnh hưởng cấu hình)."""
return list(_bucket(config).get("entries", []) or [])
def save_entries(config, entries: List[Dict[str, Any]]) -> None:
"""Ghi lại toàn bộ bảng đơn giá và đồng bộ sang bộ tính chi phí.
Đồng bộ ngay tại đây để Tổng quan và Dashboard không hiện số tiền tính theo
bảng giá cũ.
"""
_bucket(config)["entries"] = [dict(e) for e in entries]
sync_to_usage(config) # keep the cost engine (Overview + Dashboard) in sync
@@ -115,6 +125,7 @@ def _norm_entry(model: str, ctx_len: str = "", max_out: str = "",
in_price=0.0, in_ccy: Optional[str] = None, in_unit: str = _DEFAULT_UNIT,
out_price=0.0, out_ccy: Optional[str] = None, out_unit: str = _DEFAULT_UNIT,
default_ccy: str = "USD") -> Dict[str, Any]:
"""Chuẩn hoá một dòng đơn giá về đúng khuôn lưu trữ, điền mặc định cho ô trống."""
return {
"model": str(model).strip(),
"context_length": str(ctx_len).strip(),
@@ -189,6 +200,7 @@ def format_tokens(n: int) -> str:
def add_entry(config, entry: Dict[str, Any]) -> None:
"""Thêm một dòng đơn giá; đã có model đó thì THAY THẾ chứ không thêm trùng."""
entries = list_entries(config)
entries = [e for e in entries if e.get("model") != entry.get("model")] # replace same model
entries.append(entry)
@@ -253,6 +265,7 @@ def import_table(path: str | Path, default_ccy: str = "USD") -> List[Dict[str, A
def _rows_from_xlsx(path: Path) -> List[List[Any]]:
"""Đọc các dòng từ file Excel (lấy giá trị đã tính, không lấy công thức)."""
from openpyxl import load_workbook
try:
wb = load_workbook(str(path), data_only=True)
@@ -263,6 +276,7 @@ def _rows_from_xlsx(path: Path) -> List[List[Any]]:
def _rows_from_csv(path: Path) -> List[List[Any]]:
"""Đọc các dòng từ file CSV, chấp nhận BOM của Excel."""
try:
text = path.read_text(encoding="utf-8-sig")
except OSError as exc:
+10
View File
@@ -57,10 +57,12 @@ SCOPES: List[str] = [
class Ms365AuthError(Exception):
"""Lỗi khi đăng nhập hoặc lấy token Microsoft 365."""
pass
def _load_cache():
"""Nạp kho token đã lưu từ đĩa (nếu có)."""
import msal
cache = msal.SerializableTokenCache()
@@ -84,6 +86,7 @@ def _load_cache():
def _save_cache(cache) -> None:
"""Ghi kho token xuống đĩa, chỉ khi nó thật sự thay đổi."""
if not cache.has_state_changed:
return
serialized = cache.serialize()
@@ -105,6 +108,9 @@ def _save_cache(cache) -> None:
def _app(tenant_id: str, client_id: str):
"""Dựng ứng dụng MSAL cho tenant/client đã cấu hình; thiếu ``msal`` thì báo lỗi
kèm hướng dẫn cài.
"""
try:
import msal
except ImportError as exc:
@@ -192,6 +198,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str:
# The UI calls these with no args for the "connect like Claude" flow; they read
# the optional config overrides so a custom Azure app still works.
def _ids(config=None):
"""Cặp (tenant_id, client_id) đọc từ cấu hình MS365."""
ms365 = (config.ms365 if config is not None else {}) or {}
return ms365.get("tenant_id", ""), ms365.get("client_id", "")
@@ -203,6 +210,7 @@ def current_identity(config=None) -> str:
def is_signed_in(config=None) -> bool:
"""Đã có tài khoản MS365 đăng nhập sẵn hay chưa."""
return signed_in_account(*_ids(config)) is not None
@@ -213,10 +221,12 @@ def sign_in(on_code: Callable[[dict], None], config=None) -> dict:
def sign_out_default(config=None) -> None:
"""Đăng xuất tài khoản MS365 theo cấu hình hiện tại."""
sign_out(*_ids(config))
def sign_out(tenant_id: str, client_id: str) -> None:
"""Đăng xuất và xoá token của một tenant/client khỏi kho."""
try:
app, cache = _app(tenant_id, client_id)
for acc in app.get_accounts():
+24
View File
@@ -22,14 +22,17 @@ TIMEOUT = 30
class Ms365GraphError(Exception):
"""Lỗi khi gọi Microsoft Graph API."""
pass
class TeamsLinkError(Exception):
"""Link Teams không phân giải được thành team/channel/chat hợp lệ."""
pass
def _headers(token: str, extra: Optional[dict] = None) -> Dict[str, str]:
"""Header cho một lượt gọi Graph: Bearer token cộng phần thêm (nếu có)."""
h = {"Authorization": f"Bearer {token}"}
if extra:
h.update(extra)
@@ -37,6 +40,9 @@ def _headers(token: str, extra: Optional[dict] = None) -> Dict[str, str]:
def _request(method: str, url: str, token: str, **kwargs) -> requests.Response:
"""Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP
thành :class:`Ms365GraphError` kèm thông điệp đọc được.
"""
if not url.startswith("http"):
url = f"{GRAPH_BASE}{url}"
headers = _headers(token, kwargs.pop("headers", None))
@@ -73,6 +79,7 @@ def _path_segment(path: str) -> str:
# ---- Outlook ---------------------------------------------------------------
def list_mail(token: str, top: int = 10, folder: str = "inbox") -> List[dict]:
"""Danh sách thư trong một thư mục hộp thư (mặc định Inbox)."""
resp = _request("GET", f"/me/mailFolders/{quote(folder)}/messages"
f"?$top={int(top)}&$select=subject,from,receivedDateTime,bodyPreview,webLink",
token)
@@ -80,6 +87,7 @@ def list_mail(token: str, top: int = 10, folder: str = "inbox") -> List[dict]:
def send_mail(token: str, to: str, subject: str, body: str) -> None:
"""Gửi một email qua tài khoản đang đăng nhập."""
payload = {
"message": {
"subject": subject,
@@ -91,6 +99,7 @@ def send_mail(token: str, to: str, subject: str, body: str) -> None:
def list_calendar_events(token: str, top: int = 10) -> List[dict]:
"""Danh sách sự kiện lịch sắp tới, xếp theo thời gian bắt đầu."""
resp = _request("GET", f"/me/events?$top={int(top)}"
"&$select=subject,start,end,organizer,location&$orderby=start/dateTime",
token)
@@ -99,38 +108,45 @@ def list_calendar_events(token: str, top: int = 10) -> List[dict]:
# ---- Teams ------------------------------------------------------------------
def list_teams(token: str) -> List[dict]:
"""Các team mà người dùng đang tham gia."""
resp = _request("GET", "/me/joinedTeams", token)
return resp.json().get("value", [])
def list_channels(token: str, team_id: str) -> List[dict]:
"""Các kênh trong một team."""
resp = _request("GET", f"/teams/{quote(team_id)}/channels", token)
return resp.json().get("value", [])
def list_channel_messages(token: str, team_id: str, channel_id: str, top: int = 20) -> List[dict]:
"""Tin nhắn gần đây trong một kênh."""
resp = _request("GET", f"/teams/{quote(team_id)}/channels/{quote(channel_id)}/messages"
f"?$top={int(top)}", token)
return resp.json().get("value", [])
def send_channel_message(token: str, team_id: str, channel_id: str, text: str) -> None:
"""Gửi tin nhắn vào một kênh Teams."""
payload = {"body": {"content": text}}
_request("POST", f"/teams/{quote(team_id)}/channels/{quote(channel_id)}/messages", token,
json=payload)
def get_channel(token: str, team_id: str, channel_id: str) -> dict:
"""Thông tin một kênh Teams."""
resp = _request("GET", f"/teams/{quote(team_id)}/channels/{quote(channel_id)}", token)
return resp.json()
def get_chat(token: str, chat_id: str) -> dict:
"""Thông tin một cuộc trò chuyện Teams."""
resp = _request("GET", f"/chats/{quote(chat_id)}", token)
return resp.json()
def send_chat_message(token: str, chat_id: str, text: str) -> None:
"""Gửi tin nhắn vào một cuộc trò chuyện Teams."""
_request("POST", f"/chats/{quote(chat_id)}/messages", token, json={"body": {"content": text}})
@@ -160,17 +176,20 @@ def parse_teams_link(url: str) -> Dict[str, str]:
# ---- OneDrive -----------------------------------------------------------
def list_onedrive_files(token: str, path: str = "") -> List[dict]:
"""Liệt kê tệp/thư mục trong OneDrive; ``path`` rỗng là thư mục gốc."""
url = "/me/drive/root/children" if not path else f"/me/drive/root:/{_path_segment(path)}:/children"
resp = _request("GET", url, token)
return resp.json().get("value", [])
def read_onedrive_file(token: str, path: str, max_chars: int = 50_000) -> str:
"""Đọc nội dung một tệp OneDrive dưới dạng văn bản, cắt ở ``max_chars``."""
resp = _request("GET", f"/me/drive/root:/{_path_segment(path)}:/content", token)
return resp.content.decode("utf-8", errors="replace")[:max_chars]
def write_onedrive_file(token: str, path: str, content: str) -> dict:
"""Ghi nội dung văn bản vào một tệp OneDrive (tạo mới hoặc ghi đè)."""
resp = _request("PUT", f"/me/drive/root:/{_path_segment(path)}:/content", token,
data=content.encode("utf-8"),
headers={"Content-Type": "text/plain"})
@@ -197,11 +216,13 @@ def read_shared_file(token: str, share_url: str, max_chars: int = 50_000) -> str
# ---- SharePoint --------------------------------------------------------
def list_sharepoint_sites(token: str, query: str) -> List[dict]:
"""Tìm site SharePoint theo từ khoá."""
resp = _request("GET", f"/sites?search={quote(query)}", token)
return resp.json().get("value", [])
def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict]:
"""Liệt kê tệp/thư mục trong thư viện tài liệu của một site SharePoint."""
url = (f"/sites/{quote(site_id)}/drive/root/children" if not path
else f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/children")
resp = _request("GET", url, token)
@@ -210,18 +231,21 @@ def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict
# ---- Teams meeting transcripts ------------------------------------------
def find_online_meeting(token: str, join_url: str) -> List[dict]:
"""Tìm cuộc họp online theo link tham gia."""
resp = _request("GET", f"/me/onlineMeetings?$filter=JoinWebUrl eq '{quote(join_url, safe='')}'",
token)
return resp.json().get("value", [])
def list_meeting_transcripts(token: str, meeting_id: str) -> List[dict]:
"""Danh sách bản ghi lời thoại của một cuộc họp."""
resp = _request("GET", f"/me/onlineMeetings/{quote(meeting_id)}/transcripts", token)
return resp.json().get("value", [])
def get_meeting_transcript_content(token: str, meeting_id: str, transcript_id: str,
max_chars: int = 50_000) -> str:
"""Nội dung một bản ghi lời thoại, cắt ở ``max_chars``."""
resp = _request(
"GET",
f"/me/onlineMeetings/{quote(meeting_id)}/transcripts/{quote(transcript_id)}/content"
+8
View File
@@ -28,10 +28,12 @@ _PREFIX = "ms365_local"
def _roots() -> List[Path]:
"""Mọi thư mục OneDrive tìm thấy trên máy."""
return paths.detect_onedrive_roots()
def _primary_root() -> Optional[Path]:
"""Thư mục OneDrive chính; ``None`` nếu không có."""
return paths.primary_onedrive_root()
@@ -45,6 +47,7 @@ def _resolve_under(root: Path, rel: str) -> Path:
def _list_dir(base: Path, rel: str) -> dict:
"""Liệt kê nội dung một thư mục con của OneDrive, chặn thoát ra ngoài gốc."""
target = _resolve_under(base, rel)
if not target.exists():
raise FileNotFoundError(f"Not found: {rel or '.'}")
@@ -60,6 +63,7 @@ def _list_dir(base: Path, rel: str) -> dict:
def _read_file(base: Path, rel: str) -> str:
"""Đọc một tệp trong OneDrive dưới dạng văn bản, chặn thoát ra ngoài gốc."""
target = _resolve_under(base, rel)
if not target.is_file():
raise FileNotFoundError(f"Not a file: {rel}")
@@ -68,6 +72,7 @@ def _read_file(base: Path, rel: str) -> str:
def _write_file(base: Path, rel: str, content: str) -> dict:
"""Ghi một tệp trong OneDrive, tự tạo thư mục cha, chặn thoát ra ngoài gốc."""
target = _resolve_under(base, rel)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content or "", encoding="utf-8")
@@ -125,6 +130,9 @@ def build_ms365_local_tools(config) -> Tuple[List[ToolSpec], Optional[Callable[[
]
def executor(name: str, args: dict) -> dict:
"""Bộ thực thi tool MS365 cục bộ (đọc/ghi thẳng thư mục OneDrive đồng bộ trên
máy, không cần đăng nhập Graph), ghi nhật ký kiểm toán cho mỗi lần gọi.
"""
ok = False
detail = ""
try:
+6
View File
@@ -168,6 +168,9 @@ _MAX_OUTPUT_CHARS = 20_000
def _dump(data: Any) -> str:
"""Kết quả tool dưới dạng JSON đã cắt ở ``_MAX_OUTPUT_CHARS`` — một hộp thư đầy
sẽ nuốt trọn cửa sổ ngữ cảnh nếu trả về nguyên vẹn.
"""
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
if len(text) > _MAX_OUTPUT_CHARS:
text = text[:_MAX_OUTPUT_CHARS] + f"\n…(truncated to {_MAX_OUTPUT_CHARS} chars)…"
@@ -197,6 +200,9 @@ def build_ms365_tools(config) -> Tuple[List[ToolSpec], Optional[Callable[[str, d
return [], None
def executor(name: str, args: dict) -> dict:
"""Bộ thực thi các tool MS365 (mail, lịch, Teams, OneDrive, SharePoint), gói lỗi
thành kết quả thay vì ném ra.
"""
args = args or {}
try:
token = get_access_token(tenant_id, client_id)
+13
View File
@@ -13,8 +13,19 @@ RequestFn = Callable[[Dict[str, Any]], None]
class PermissionGate:
"""Cổng phê duyệt tool: chặn lượt chạy lại và chờ người dùng đồng ý.
Ba chế độ: 'auto' cho qua hết, 'confirm' hỏi trước mỗi lệnh có rủi ro, và
'deny' chặn thẳng. Dùng ``threading.Event`` để luồng nền đứng chờ trong khi
luồng giao diện hiện hộp thoại.
"""
def __init__(self, mode: str = "confirm", on_request: Optional[RequestFn] = None,
agent_role: str = ""):
"""``mode`` quyết định cách xử: hỏi, cho qua hết, hay chặn hết.
``on_request`` là hàm hiện hộp thoại; để None (không có giao diện) thì cổng
rơi về quyết định mặc định của ``mode`` thay vì treo mãi.
"""
self.mode = mode
self.on_request = on_request
self.agent_role = agent_role
@@ -22,6 +33,7 @@ class PermissionGate:
self._approved = False
def set_mode(self, mode: str) -> None:
"""Đổi chế độ phê duyệt giữa chừng."""
self.mode = mode
def request(self, action: Dict[str, Any]) -> bool:
@@ -43,6 +55,7 @@ class PermissionGate:
return self._approved
def resolve(self, approved: bool) -> None:
"""Người dùng đã trả lời: ghi kết quả và đánh thức luồng đang chờ."""
self._approved = approved
self._event.set()
+10
View File
@@ -37,6 +37,7 @@ _KEEP_PREFIX = "(" # image values like "(keep …)" mean "don't change"
def is_available() -> bool:
"""Máy đã cài ``python-pptx`` chưa — không có thì mọi tính năng PowerPoint tắt."""
try:
import pptx # noqa: F401
return True
@@ -45,10 +46,12 @@ def is_available() -> bool:
def _in(emu) -> float:
"""Đổi đơn vị EMU của Office sang inch, làm tròn 2 chữ số."""
return round((emu or 0) / _EMU_PER_IN, 2)
def _kind(shape) -> str:
"""Loại hình khối trong slide: ảnh, bảng, biểu đồ hay hộp văn bản."""
from pptx.enum.shapes import MSO_SHAPE_TYPE
try:
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
@@ -182,6 +185,11 @@ def _apply_font(shape, spec: str) -> bool:
def _parse(text: str) -> Dict[Tuple[int, int], dict]:
"""Đọc dạng văn bản đánh dấu của slide trở lại thành cấu trúc.
Đây là khuôn trung gian giữa PowerPoint và ô soạn thảo: người dùng (và AI)
sửa văn bản, hàm này dựng lại thành thao tác trên deck.
"""
blocks: Dict[Tuple[int, int], dict] = {}
cur: Tuple[int, int] | None = None
fields: dict = {}
@@ -189,6 +197,7 @@ def _parse(text: str) -> Dict[Tuple[int, int], dict]:
textbuf: List[str] = []
def _flush():
"""Chốt khối đang đọc dở và đưa vào kết quả."""
if cur is not None:
if in_text:
fields["text"] = "\n".join(textbuf).strip("\n")
@@ -221,6 +230,7 @@ def _parse(text: str) -> Dict[Tuple[int, int], dict]:
def _pair(val: str):
"""Đọc chuỗi 'a, b' thành cặp số (dùng cho toạ độ và kích thước)."""
try:
a, b = (x.strip() for x in val.split(",", 1))
return float(a), float(b)
+7
View File
@@ -44,6 +44,11 @@ STARTER_PROJECT_NAME = "My Workspace"
@dataclass
class Project:
"""Một project: id, tên, mô tả, chỉ dẫn chung và thư mục sandbox.
Chỉ dẫn chung được chèn vào MỌI lượt chat thuộc project, nên đây là chỗ đặt
bối cảnh dùng lại thay vì gõ lại ở từng tin nhắn.
"""
project_id: str
name: str
description: str = ""
@@ -92,6 +97,7 @@ def ensure_starter_project(directory: Path = None) -> Project:
def _slugify(name: str) -> str:
"""Định danh an toàn cho tên file, suy từ tên project."""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in name.strip().lower())
s = "-".join(filter(None, s.split("-")))
return s or "project"
@@ -115,6 +121,7 @@ def new_project(name: str, description: str = "", instructions: str = "",
def save_project(project: Project, directory: Path = None) -> Path:
"""Ghi một project ra ``<project_id>.json`` (ghi nguyên tử)."""
directory = directory or PROJECTS_DIR
path = directory / f"{project.project_id}.json"
# R06-T02: atomic write — a crash/kill between truncate and write used to
+5
View File
@@ -73,6 +73,11 @@ LLMClassifier = Callable[[str], str]
def _heuristic_scores(text: str) -> dict[TaskType, int]:
"""Chấm điểm loại việc bằng từ khoá, không cần gọi model.
Bước lọc rẻ đứng trước bộ phân loại bằng AI: phần lớn câu hỏi phân loại được
ngay tại đây mà không tốn lượt gọi nào.
"""
low = (text or "").lower()
scores: dict[TaskType, int] = {tt: 0 for tt in TaskType}
for tt, entries in _COMPILED.items():
+6
View File
@@ -25,6 +25,7 @@ class CompletionResult:
@property
def ok(self) -> bool:
"""Lượt dò có thành công không (không có lỗi)."""
return self.error is None
@@ -37,6 +38,7 @@ class ProbeClient(Protocol):
model_id: str,
messages: List[Dict[str, Any]],
) -> CompletionResult:
"""Gọi một model và trả về kết quả kèm số token, độ trễ và lỗi (nếu có)."""
...
@@ -59,6 +61,7 @@ class AppProbeClient:
"""
def __init__(self, ctx: Any) -> None:
"""Giữ ``AppContext`` để dựng provider lúc cần thăm dò."""
self.ctx = ctx
def complete(
@@ -67,6 +70,9 @@ class AppProbeClient:
model_id: str,
messages: List[Dict[str, Any]],
) -> CompletionResult:
"""Gọi model qua provider thật; lỗi được gói vào kết quả chứ không ném ra —
một model hỏng không được làm dừng cả lượt chấm điểm danh mục.
"""
try:
prov = self.ctx.build_provider_for(provider, model_id or None)
# Non-streaming: no on_text/on_reasoning callbacks. cancel=None.
+1
View File
@@ -138,6 +138,7 @@ class ModelAssessment(BaseModel):
@property
def key(self) -> str:
"""Khoá định danh của model được chấm điểm (provider + model id)."""
return self.metadata.key
def fit_for(self, task_type: TaskType) -> float:
+3
View File
@@ -126,6 +126,9 @@ def check_and_update(
call_count = {"n": 0}
def _tick() -> None:
"""Một nhịp đếm trong lúc chờ người dùng xác nhận đổi model — đếm lùi và tự
quyết định khi hết giờ.
"""
call_count["n"] += 1
pairs = [(p, m) for (p, m, _tier) in candidates]
+12
View File
@@ -70,6 +70,7 @@ _SCORE_RE = re.compile(r'"score"\s*:\s*([0-9]*\.?[0-9]+)')
def _clamp01(x: float) -> float:
"""Chặn một số về khoảng 0..1."""
return min(1.0, max(0.0, float(x)))
@@ -108,6 +109,10 @@ def make_judge(
"""
def judge(task_type: TaskType, prompt: str, answer: str) -> float:
"""Chấm điểm câu trả lời của một model theo rubric, trả về điểm 0..1.
Cắt câu trả lời ở 4000 ký tự để một lượt chấm không tự nó tràn ngữ cảnh.
"""
rubric = _JUDGE_RUBRIC.format(
task=task_type.value, prompt=prompt, answer=(answer or "")[:4000]
)
@@ -160,11 +165,17 @@ class _PerProviderSemaphores:
"""Lazily-created, per-provider bounded semaphores for rate-limit safety."""
def __init__(self, limit: int) -> None:
"""Giới hạn số lượt thăm dò song song TRÊN MỖI provider.
Đếm riêng từng provider chứ không đếm chung: một provider chậm không được
phép chiếm hết suất của những provider còn lại.
"""
self._limit = max(1, int(limit))
self._sems: Dict[str, threading.Semaphore] = {}
self._lock = threading.Lock()
def get(self, provider: str) -> threading.Semaphore:
"""Semaphore của một provider, tạo lười ở lần dùng đầu."""
with self._lock:
sem = self._sems.get(provider)
if sem is None:
@@ -201,6 +212,7 @@ def probe_candidates(
results_lock = threading.Lock()
def _one(provider: str, model_id: str, task_type: TaskType) -> None:
"""Dò một cặp (provider, model) cho một loại việc, tôn trọng giới hạn song song."""
sem = sems.get(provider)
with sem:
if call_counter is not None:
+10
View File
@@ -33,6 +33,7 @@ class RoutingScheduler(QObject):
reassess_finished = Signal(int) # number of models assessed
def __init__(self, ctx: Any, service: Any, parent: Optional[QObject] = None) -> None:
"""Dựng bộ hẹn giờ chạy thăm dò định kỳ. Chưa chạy cho tới khi gọi ``start()``."""
super().__init__(parent)
self.ctx = ctx
self.service = service
@@ -49,16 +50,19 @@ class RoutingScheduler(QObject):
self._timer.start()
def stop(self) -> None:
"""Dừng hẹn giờ."""
self._timer.stop()
# -- tick ----------------------------------------------------------- #
def _interval_hours(self) -> float:
"""Chu kỳ chấm điểm lại, tính bằng giờ; giá trị lạ thì coi như tắt."""
try:
return float(self.ctx.config.routing.get("reassess_interval_hours", 24) or 0)
except Exception: # noqa: BLE001
return 24.0
def _hours_since_last(self) -> Optional[float]:
"""Số giờ kể từ lần chấm điểm gần nhất; ``None`` nếu chưa chấm lần nào."""
last = self.service.store.last_updated()
if not last:
return None # never assessed
@@ -87,6 +91,11 @@ class RoutingScheduler(QObject):
return False
def is_due(self) -> bool:
"""Đã đến lúc chấm điểm lại chưa.
Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí,
không được tiêu tiền cho một tính năng người dùng đã tắt.
"""
if not self._routing_enabled_anywhere():
return False # routing off everywhere → don't probe (would be wasted cost)
interval = self._interval_hours()
@@ -111,6 +120,7 @@ class RoutingScheduler(QObject):
self.reassess_started.emit()
def _done(result) -> None:
"""Chấm điểm xong: báo ra ngoài số model đã đánh giá."""
self.reassess_finished.emit(len(result or {}))
self.service.reassess_background(on_done=_done)
+7
View File
@@ -27,6 +27,7 @@ class RankedCandidate:
@property
def key(self) -> str:
"""Khoá định danh của ứng viên (provider + model)."""
return self.assessment.key
@@ -40,6 +41,7 @@ class Ranking:
@property
def best(self) -> Optional[RankedCandidate]:
"""Ứng viên đứng đầu; ``None`` nếu không có ứng viên nào."""
return self.ranked[0] if self.ranked else None
def score_of(self, key: str) -> float:
@@ -66,6 +68,7 @@ class Ranking:
def _has_capabilities(assessment: ModelAssessment, required: Set[str]) -> bool:
"""Model này có đủ mọi năng lực mà lượt chạy đòi hỏi không."""
return required.issubset(assessment.metadata.capabilities)
@@ -99,6 +102,10 @@ def rank_models(
scored.append(RankedCandidate(assessment=a, score=score))
def _sort_key(c: RankedCandidate):
"""Khoá sắp xếp ứng viên: điểm cao trước, cùng điểm thì rẻ hơn trước.
Model chưa biết giá bị xếp cuối (coi như vô cùng đắt) chứ không phải miễn phí.
"""
cost = c.assessment.metadata.avg_cost_per_1k
cost = cost if cost is not None else float("inf")
# score desc, then cheaper, then model id for determinism.
+18
View File
@@ -64,6 +64,7 @@ class RouteResult:
@property
def should_switch(self) -> bool:
"""Có nên đổi sang model khác cho lượt này không."""
return self.decision.should_switch
@property
@@ -89,6 +90,9 @@ class RoutingService:
client: Optional[ProbeClient] = None,
clock: Optional[Callable[[], float]] = None,
) -> None:
"""``store``/``client``/``clock`` đều tiêm được: test thay đồng hồ để tua thời
gian mà không phải chờ thật, và thay client để không gọi mạng.
"""
self.ctx = ctx
self.store = store or AssessmentStore()
self._client = client # None → lazily build AppProbeClient(ctx)
@@ -100,6 +104,7 @@ class RoutingService:
# -- config helpers ------------------------------------------------- #
@property
def _routing_cfg(self) -> Dict[str, Any]:
"""Nhóm cấu hình định tuyến hiện tại."""
return self.ctx.config.routing
def get_routing_config(self) -> Dict[str, Any]:
@@ -125,6 +130,7 @@ class RoutingService:
return dict(cfg)
def _policy(self) -> Policy:
"""Chính sách chấm điểm đang chọn; giá trị lạ thì rơi về 'balanced'."""
raw = (self._routing_cfg.get("policy") or "balanced").lower()
try:
return Policy(raw)
@@ -132,6 +138,7 @@ class RoutingService:
return Policy.BALANCED
def _client_or_build(self) -> ProbeClient:
"""Client dò model, dựng lười để chưa bật định tuyến thì không tốn gì."""
if self._client is None:
self._client = AppProbeClient(self.ctx)
return self._client
@@ -160,6 +167,9 @@ class RoutingService:
seen = set()
def _add(provider: str, model_id: str, tier: Optional[str]) -> None:
"""Thêm một ứng viên (provider, model) vào danh sách, bỏ qua mục thiếu thông tin
hoặc trùng.
"""
if not provider or not model_id:
return
key = candidate_key(provider, model_id)
@@ -246,6 +256,11 @@ class RoutingService:
) -> threading.Thread:
"""Run :meth:`reassess` on a daemon thread (non-Qt, headless-safe)."""
def _run() -> None:
"""Chạy nền: chấm điểm lại danh mục model.
Nuốt mọi ngoại lệ có chủ ý — một lần chấm điểm hỏng không được phép làm
chết ứng dụng, vì đây là việc chạy ngầm người dùng không yêu cầu.
"""
try:
result = self.reassess(policy)
except Exception: # noqa: BLE001 — never let a reassess crash the app
@@ -262,10 +277,12 @@ class RoutingService:
return t
def is_reassessing(self) -> bool:
"""Có đang chấm điểm lại danh mục model hay không."""
return self._reassessing
# -- query ---------------------------------------------------------- #
def assessments(self) -> Dict[str, ModelAssessment]:
"""Bảng điểm model đã lưu, đọc từ kho đánh giá."""
return self.store.load()
def status(self) -> Dict[str, Any]:
@@ -347,6 +364,7 @@ class RoutingService:
return self.pending.resolve(request_id, approve, run)
def get_pending(self, request_id: str) -> Optional[PendingSwitch]:
"""Đề nghị đổi model đang chờ người dùng xác nhận; ``None`` nếu không có."""
return self.pending.get(request_id)
def sweep_pending(self) -> List[str]:
+7
View File
@@ -68,6 +68,11 @@ class AssessmentStore:
store_path: Optional[Path] = None,
history_dir: Optional[Path] = None,
) -> None:
"""``store_path`` để None thì dùng file mặc định trong thư mục cấu hình.
Import ``CONFIG_DIR`` muộn ngay trong thân hàm: nạp nó lúc import module sẽ
kéo theo cả cây cấu hình vào mọi test dùng lớp này.
"""
if store_path is None:
from ...config import CONFIG_DIR # lazy: avoids import cost in tests
store_path = CONFIG_DIR / _DEFAULT_STORE_NAME
@@ -107,9 +112,11 @@ class AssessmentStore:
return out
def last_updated(self) -> Optional[str]:
"""Mốc thời gian lần chấm điểm gần nhất; ``None`` nếu chưa chấm lần nào."""
return self.load_raw().get("last_updated")
def policy(self) -> str:
"""Chính sách chấm điểm đã lưu; chưa có thì mặc định 'balanced'."""
return self.load_raw().get("policy") or Policy.BALANCED.value
# -- write ---------------------------------------------------------- #
+3
View File
@@ -141,6 +141,7 @@ class PendingSwitchRegistry:
_RESOLVE_WAIT_SEC = 600.0
def __init__(self, clock: Callable[[], float] = time.time) -> None:
"""``clock`` tiêm được để test kiểm hết hạn mà không phải chờ thật."""
self._items: Dict[str, PendingSwitch] = {}
self._events: Dict[str, threading.Event] = {}
self._running: Set[str] = set()
@@ -180,6 +181,7 @@ class PendingSwitchRegistry:
return ps
def _maybe_expire_locked(self, ps: PendingSwitch) -> None:
"""Đánh dấu hết hạn nếu đã quá hạn chờ. Gọi trong lúc đang giữ khoá."""
if ps.status == SwitchStatus.PENDING and self._clock() >= ps.expires_at:
ps.status = SwitchStatus.EXPIRED
@@ -270,6 +272,7 @@ class PendingSwitchRegistry:
return removed
def pending_ids(self) -> List[str]:
"""Id các đề nghị còn đang chờ (đã loại những cái vừa hết hạn)."""
with self._lock:
return [
rid for rid, ps in self._items.items()
+1
View File
@@ -44,6 +44,7 @@ class SandboxManager:
"""Central sandbox manager that selects and routes to the right backend."""
def __init__(self, config: Optional[ExecutionConfig] = None):
"""Chưa dựng backend nào — chúng được tạo muộn, lúc thật sự cần chạy lệnh."""
self.config = config or ExecutionConfig()
self._backends: Dict[str, Any] = {}
+15
View File
@@ -36,6 +36,7 @@ LIBRARY_DIR = Path(__file__).resolve().parent.parent / "skill_library"
@dataclass
class Skill:
"""Một Skill: tên, mô tả, phần chỉ dẫn chèn vào prompt, và cờ bật/tắt."""
name: str
description: str = ""
instructions: str = ""
@@ -43,12 +44,14 @@ class Skill:
@property
def slug(self) -> str:
"""Định danh an toàn cho tên file, suy từ tên skill."""
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 "skill"
def skills_dir() -> Path:
"""Thư mục chứa skill của người dùng."""
return SKILLS_DIR
@@ -180,6 +183,9 @@ def builtin_skills() -> List[Skill]:
def _builtin_slugs() -> set[str]:
"""Tập slug của các skill dựng sẵn — dùng để không gieo trùng và không cho sửa
chúng như skill thường.
"""
return {s.slug for s in builtin_skills()}
@@ -277,6 +283,11 @@ def list_skills(directory: Path | None = None) -> List[Skill]:
def save_skill(skill: Skill, directory: Path | None = None, old_name: str = "") -> Path:
"""Ghi một skill xuống đĩa.
Đổi tên thì XOÁ file cũ trước — tên file suy từ tên skill, không xoá sẽ để
lại một bản sao dưới tên cũ.
"""
directory = directory or SKILLS_DIR
directory.mkdir(parents=True, exist_ok=True)
if old_name and old_name != skill.name:
@@ -287,6 +298,7 @@ def save_skill(skill: Skill, directory: Path | None = None, old_name: str = "")
def delete_skill(name: str, directory: Path | None = None) -> None:
"""Xoá file skill theo tên; không có thì bỏ qua."""
directory = directory or SKILLS_DIR
path = directory / f"{Skill(name=name).slug}.json"
if path.exists():
@@ -304,6 +316,9 @@ def _load_skill_from_zip(path: Path) -> "Skill | None":
import zipfile
def _rank(n: str) -> int:
"""Thứ tự ưu tiên khi gói zip có nhiều file Markdown: ``skill.md`` trước, rồi
tới file ở gốc gói, cuối cùng mới tới file nằm sâu.
"""
low = n.lower()
if low.endswith("skill.md"):
return 0
+30
View File
@@ -35,6 +35,7 @@ MAX_JSON_KEYS_PER_LEVEL = 200 # cap per object, so one huge JSON can't flood th
@dataclass
class GNode:
"""Một node trong đồ thị cấu trúc: thư mục, tệp, lớp, hàm, phương thức hay mục tài liệu."""
id: str
label: str
kind: str # dir | file | class | function | method | module | section
@@ -44,6 +45,7 @@ class GNode:
@dataclass
class GEdge:
"""Một cạnh trong đồ thị cấu trúc, kèm LOẠI quan hệ (chứa / định nghĩa / import…)."""
source: str
target: str
type: str = "" # contains | defines | method | imports | subsection
@@ -51,6 +53,11 @@ class GEdge:
@dataclass
class StructureGraph:
"""Đồ thị cấu trúc mã nguồn, có trần số node/cạnh.
Chạm trần thì bật cờ ``truncated`` và ngừng thêm — đồ thị quá lớn làm treo
khung vẽ, thà hiện một phần kèm cảnh báo còn hơn đứng hình.
"""
nodes: List[GNode] = field(default_factory=list)
edges: List[GEdge] = field(default_factory=list)
truncated: bool = False
@@ -58,9 +65,13 @@ class StructureGraph:
max_edges: int = 0 # 0 = unlimited
def __post_init__(self):
"""Dựng sẵn tập id node để kiểm tra một cạnh có hợp lệ không trong thời gian
hằng số, thay vì quét cả danh sách node cho từng cạnh.
"""
self._ids = {n.id for n in self.nodes}
def add_node(self, node: GNode) -> bool:
"""Thêm một node; trả về ``False`` nếu trùng id hoặc đã chạm trần."""
if node.id in self._ids:
return False
if self.max_nodes and len(self.nodes) >= self.max_nodes:
@@ -71,6 +82,7 @@ class StructureGraph:
return True
def add_edge(self, source: str, target: str, type_: str = "") -> None:
"""Thêm một cạnh; bỏ qua nếu một trong hai đầu chưa có node, hoặc đã chạm trần."""
if source in self._ids and target in self._ids:
if self.max_edges and len(self.edges) >= self.max_edges:
self.truncated = True
@@ -78,6 +90,7 @@ class StructureGraph:
self.edges.append(GEdge(source, target, type_))
def has(self, node_id: str) -> bool:
"""Đồ thị đã có node với id này chưa."""
return node_id in self._ids
@@ -144,6 +157,7 @@ def _add_generic_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Pat
def _rel(path: Path, root: Path) -> str:
"""Đường dẫn tương đối so với thư mục gốc; nằm ngoài gốc thì trả nguyên đường dẫn."""
try:
return str(path.relative_to(root))
except ValueError:
@@ -151,6 +165,7 @@ def _rel(path: Path, root: Path) -> str:
def _add_python_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Thêm một tệp Python vào đồ thị: node tệp, các lớp, hàm, phương thức và import."""
file_id = f"file:{fpath}"
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), str(fpath))):
return
@@ -184,6 +199,7 @@ def _add_python_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path
def _module_imports(tree: ast.AST) -> List[str]:
"""Tên các module mà một cây AST import vào."""
mods: List[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
@@ -199,6 +215,7 @@ def _module_imports(tree: ast.AST) -> List[str]:
def _add_doc_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Thêm một tệp tài liệu (Markdown…) vào đồ thị, tách theo cấp tiêu đề."""
file_id = f"file:{fpath}"
fp = str(fpath)
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), fp)):
@@ -254,6 +271,7 @@ def _add_json_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path)
def _json_scalar_preview(value) -> str:
"""Chuỗi xem trước ngắn cho một giá trị JSON, để nhãn node không quá dài."""
if isinstance(value, dict):
return f"{{…}} ({len(value)} keys)"
if isinstance(value, list):
@@ -262,6 +280,11 @@ def _json_scalar_preview(value) -> str:
def _add_json_value(graph: StructureGraph, parent_id: str, fp: str, value, depth: int) -> None:
"""Thêm cấu trúc một giá trị JSON vào đồ thị, chặn ở ``MAX_JSON_DEPTH``.
Có trần độ sâu vì JSON lồng sâu sẽ sinh ra hàng nghìn node mà chẳng nói lên
điều gì về cấu trúc dự án.
"""
if depth >= MAX_JSON_DEPTH:
return
if isinstance(value, dict):
@@ -325,6 +348,9 @@ def build_from_codebase_memory(mem, repo_path, mode: str = "all",
def _iter_results(res):
"""Duyệt kết quả trả về từ bộ nhớ mã nguồn, chấp nhận nhiều khuôn khoá khác nhau
(``results`` / ``nodes`` / ``items`` / ``data``).
"""
if isinstance(res, dict):
for key in ("results", "nodes", "items", "data"):
val = res.get(key)
@@ -339,6 +365,10 @@ def _iter_results(res):
# Layout (layered by distance from roots)
# --------------------------------------------------------------------------
def layered_layout(graph: StructureGraph, col_w: int = 280, row_h: int = 64) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, int]]:
"""Xếp đồ thị thành các cột theo bậc phụ thuộc, trong cột xếp dọc.
Trả về (toạ độ từng node, lớp của từng node).
"""
indeg = {n.id: 0 for n in graph.nodes}
adj = defaultdict(list)
for e in graph.edges:
+2
View File
@@ -42,10 +42,12 @@ _TRUE = {"yes", "y", "true", "1", "x", "có", "co"}
def _bool(value) -> bool:
"""Đọc giá trị đúng/sai từ ô Excel, chấp nhận nhiều cách ghi."""
return str(value or "").strip().lower() in _TRUE
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."""
v = str(value or "").strip().lower()
return v if v in allowed else default
+13
View File
@@ -38,16 +38,21 @@ CancelFn = Callable[[], bool]
def new_run_id() -> str:
"""Id lượt chạy mới: mốc thời gian cộng 6 ký tự ngẫu nhiên (chống trùng khi hai
task khởi động cùng giây).
"""
return datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
def artifact_dir(task_id: str, run_id: str) -> Path:
"""Thư mục hiện vật của một lượt chạy, tạo sẵn cả thư mục con ``generated_files``."""
d = ARTIFACTS_DIR / task_id / run_id
(d / "generated_files").mkdir(parents=True, exist_ok=True)
return d
def _last_assistant_text(messages) -> str:
"""Nội dung trả lời cuối cùng của assistant trong hội thoại; '' nếu không có."""
for m in reversed(messages or []):
if m.get("role") == "assistant" and (m.get("content") or "").strip():
return m["content"]
@@ -64,6 +69,7 @@ _OUTPUT_MODE_HINTS = {
def _output_mode_hint(task: Dict[str, Any]) -> str:
"""Câu hướng dẫn định dạng đầu ra tương ứng chế độ output của task."""
return _OUTPUT_MODE_HINTS.get(task.get("output", {}).get("output_mode", "text"), "")
@@ -104,6 +110,9 @@ def _project_folder_input_text(project: Optional[projects.Project], max_files: i
def _build_prompt(task: Dict[str, Any], tasks_dir: Path = None,
project: Optional[projects.Project] = None,
max_files: int = 10) -> str:
"""Ghép prompt cho một task: mô tả, dữ liệu vào đã phân giải, chỉ dẫn chung của
project, và gợi ý định dạng đầu ra.
"""
parts = [task.get("description") or task.get("title") or ""]
extra = resolve_input_text(task, tasks_dir)
if extra:
@@ -202,6 +211,7 @@ def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[
state = {"timed_out": False}
def wrapped() -> bool:
"""Cờ huỷ có thêm hạn giờ: người dùng bấm Dừng HOẶC quá thời gian cho phép."""
if cancel():
return True
if time.monotonic() >= deadline:
@@ -280,6 +290,7 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
last_plan_steps: List[Dict[str, str]] = []
def emit_and_autosave(ev):
"""Chuyển tiếp sự kiện tiến độ và tự lưu hội thoại tại các mốc an toàn."""
emit(ev)
if not isinstance(ev, dict):
return
@@ -348,6 +359,7 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
if not command.strip():
raise RuntimeError("Script task has no command configured.")
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
@@ -456,6 +468,7 @@ def _run_co4e_flow(ctx, task: Dict[str, Any], wf, gen_dir: Path,
outputs: Dict[str, str] = {}
def _emit(ev):
"""Chuyển tiếp sự kiện của luồng Co4E về dạng sự kiện task."""
if not isinstance(ev, dict):
return
t = ev.get("type")
+9
View File
@@ -73,6 +73,7 @@ def auto_chain_in_order(tasks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# ---- CSV -----------------------------------------------------------------
def _import_csv(path: Path) -> List[Dict[str, Any]]:
"""Đọc danh sách task từ file CSV (chấp nhận BOM của Excel)."""
try:
text = path.read_text(encoding="utf-8-sig")
except OSError as exc:
@@ -111,6 +112,7 @@ def _import_csv(path: Path) -> List[Dict[str, Any]]:
# ---- JSON ----------------------------------------------------------------
def _import_json(path: Path) -> List[Dict[str, Any]]:
"""Đọc danh sách task từ file JSON."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
@@ -138,6 +140,11 @@ def _import_json(path: Path) -> List[Dict[str, Any]]:
def _pick(d: Dict[str, Any], *keys, default=""):
"""Lấy giá trị đầu tiên khác rỗng trong các khoá được nêu.
File nhập từ nhiều nguồn đặt tên cột khác nhau (``title``/``name``/``Tiêu đề``),
nên phải thử lần lượt.
"""
for k in keys:
if k in d and d[k] not in (None, ""):
return d[k]
@@ -145,6 +152,7 @@ def _pick(d: Dict[str, Any], *keys, default=""):
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."""
v = str(value or "").strip().lower()
return v if v in allowed else default
@@ -213,6 +221,7 @@ _TRUE = {"yes", "y", "true", "1", "x", "có", "co"}
def _truthy(value) -> bool:
"""Đọc giá trị đúng/sai từ nhiều kiểu ghi khác nhau (bool, 'yes', '1', 'có'…)."""
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in _TRUE
+29 -1
View File
@@ -31,6 +31,13 @@ STOP_WAIT_SECS = 10.0
class TaskScheduler(QObject):
"""Bộ chạy task theo lịch: cứ mỗi nhịp lại tìm task tới hạn và chạy chúng ở
luồng nền.
Đồng hồ được tiêm qua ``clock=`` (R07-T03) nên test chạy được mà không cần
``QTimer`` thật, và các checker UI vô hiệu hoá được nó để việc dựng cửa sổ
không vô tình chạy task thật của người dùng.
"""
tasks_changed = Signal() # any status/log change → UI refresh
task_started = Signal(str) # task_id
task_finished = Signal(str, bool) # task_id, ok
@@ -40,6 +47,9 @@ class TaskScheduler(QObject):
history_ready = Signal(str) # task_id
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None, clock=None):
"""``clock`` để None thì tự dựng ``QtSchedulerClock`` thật, nên mọi chỗ gọi cũ
không phải sửa; test tiêm ``FakeClock`` để điều khiển nhịp bằng tay.
"""
super().__init__(parent)
self.ctx = ctx
self.tasks_dir = tasks_dir # None → default TASKS_DIR
@@ -48,7 +58,7 @@ class TaskScheduler(QObject):
self._session_ids: Dict[str, str] = {} # task_id → its run's History session id
# R07-T03: the QTimer this class used to own directly is now behind a
# small clock interface (start/stop/pump) — see
# platform/qt/qt_scheduler_clock.py::QtSchedulerClock. Defaulting to a
# infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock. Defaulting to a
# real one here keeps every existing production call site (which
# never passes `clock=`) unchanged; tests inject
# tests/fakes/fake_clock.py::FakeClock to control ticks by hand with
@@ -62,6 +72,9 @@ class TaskScheduler(QObject):
# ---- lifecycle ----------------------------------------------------
def start(self) -> None:
"""Bắt đầu chạy: thu dọn task còn kẹt từ lần chạy trước, đuổi kịp task đã quá
hạn, rồi bật nhịp đếm.
"""
self._recover_orphans()
self.tick() # catch up overdue tasks right at app start
self._clock.start(TICK_MS, self.tick)
@@ -111,6 +124,7 @@ class TaskScheduler(QObject):
# ---- tick / dispatch ----------------------------------------------
def tick(self) -> None:
"""Một nhịp: chạy mọi task đã tới hạn tại thời điểm này."""
now = datetime.now()
changed = False
for task in due_tasks(list_tasks(self.tasks_dir), now):
@@ -147,6 +161,7 @@ class TaskScheduler(QObject):
return True
def is_running(self, task_id: str) -> bool:
"""Task này có đang chạy không."""
return task_id in self._workers
def running_count(self) -> int:
@@ -163,6 +178,7 @@ class TaskScheduler(QObject):
# ---- internals -----------------------------------------------------
def _start(self, task: dict) -> None:
"""Khởi động một task ở luồng nền và đánh dấu trạng thái 'running'."""
tid = task["task_id"]
run_id = new_run_id()
task["status"] = "running"
@@ -170,6 +186,7 @@ class TaskScheduler(QObject):
self.task_started.emit(tid)
def job(worker: AgentWorker):
"""Chạy nền: thực thi task, chuyển tiếp sự kiện tiến độ và cờ huỷ."""
return execute_task(self.ctx, task, run_id,
emit=worker.emit_event, cancel=worker.is_cancelled,
tasks_dir=self.tasks_dir)
@@ -197,6 +214,7 @@ class TaskScheduler(QObject):
self.history_ready.emit(task_id)
def _on_done(self, task_id: str, run_id: str, result: dict) -> None:
"""Task chạy xong: ghi kết quả, tính lần chạy kế tiếp, và kích hoạt task nối tiếp."""
self._workers.pop(task_id, None)
self._session_ids.pop(task_id, None)
task = load_task(task_id, self.tasks_dir)
@@ -249,6 +267,11 @@ class TaskScheduler(QObject):
self._start(task)
def _apply_chain(self, task: dict, verb: str, next_id: str) -> None:
"""Kích hoạt task nối tiếp theo luật ``run_next``.
Task kế đang tạm dừng thì BỎ QUA — trình sửa task có cảnh báo trước về
điều này.
"""
nxt = load_task(next_id, self.tasks_dir)
if not nxt or nxt.get("status") == "paused":
return # paused next task is skipped (warned about in the editor)
@@ -265,6 +288,11 @@ class TaskScheduler(QObject):
save_task(nxt, self.tasks_dir)
def _notify(self, task: dict, ok: bool, error: str) -> None:
"""Gửi nhắc việc qua Teams hoặc Outlook khi task kết thúc.
Đã chọn kênh thì báo cả khi chạy xong LẪN khi lỗi — im lặng lúc lỗi là
kiểu hỏng tệ nhất của một tác vụ chạy nền.
"""
ex = task["execution"]
channel = ex.get("notify_channel", "none")
# A chosen channel notifies on BOTH completion and error; the legacy
+13
View File
@@ -98,10 +98,12 @@ DEFAULT_TASK: Dict[str, Any] = {
def _now_str() -> str:
"""Mốc thời gian hiện tại theo đúng định dạng lưu trong file task."""
return datetime.now().strftime(_TIME_FMT)
def parse_run_at(value: Optional[str]) -> Optional[datetime]:
"""Đọc chuỗi thời gian chạy thành ``datetime``; sai định dạng thì trả ``None``."""
if not value:
return None
try:
@@ -111,6 +113,7 @@ def parse_run_at(value: Optional[str]) -> Optional[datetime]:
def format_run_at(dt: datetime) -> str:
"""Ghi ``datetime`` thành chuỗi thời gian chạy."""
return dt.strftime(_TIME_FMT)
@@ -143,10 +146,12 @@ def _normalize(task: Dict[str, Any]) -> Dict[str, Any]:
# ---- repository ----------------------------------------------------------
def task_path(task_id: str, directory: Path = None) -> Path:
"""Đường dẫn file JSON của một task."""
return (directory or TASKS_DIR) / f"{task_id}.json"
def save_task(task: Dict[str, Any], directory: Path = None) -> Path:
"""Ghi task xuống đĩa (ghi nguyên tử) và cập nhật ``updated_at``."""
directory = directory or TASKS_DIR
directory.mkdir(parents=True, exist_ok=True)
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
@@ -163,6 +168,7 @@ def save_task(task: Dict[str, Any], directory: Path = None) -> Path:
def load_task(task_id: str, directory: Path = None) -> Optional[Dict[str, Any]]:
"""Đọc một task theo id và chuẩn hoá; không có hoặc hỏng thì trả ``None``."""
path = task_path(task_id, directory)
try:
return _normalize(json.loads(path.read_text(encoding="utf-8")))
@@ -171,6 +177,7 @@ def load_task(task_id: str, directory: Path = None) -> Optional[Dict[str, Any]]:
def list_tasks(directory: Path = None) -> List[Dict[str, Any]]:
"""Liệt kê mọi task trong thư mục; thư mục chưa có thì trả list rỗng."""
directory = directory or TASKS_DIR
if not directory.exists():
return []
@@ -185,6 +192,7 @@ def list_tasks(directory: Path = None) -> List[Dict[str, Any]]:
def delete_task(task_id: str, directory: Path = None) -> None:
"""Xoá file task; không có thì bỏ qua."""
try:
task_path(task_id, directory).unlink()
except OSError:
@@ -306,6 +314,11 @@ _calculator: Optional[Any] = None
def _get_calculator():
"""Bộ tính lịch (:class:`ScheduleCalculator`), dựng một lần rồi dùng lại.
Dựng lười để ``core/tasks.py`` không kéo theo cả module cron ở mỗi lần
import.
"""
global _calculator
if _calculator is None:
from .cron import Cron
+12
View File
@@ -17,12 +17,21 @@ ACCENT = "F37021"
class TeamsNotifier:
"""Gửi thông báo lên Microsoft Teams qua webhook.
Tự thử hai khuôn thẻ: Adaptive Card (webhook Workflows mới) rồi tới
MessageCard (webhook Connector cũ) — hai loại webhook không nhận chung một khuôn.
"""
def __init__(self, webhook_url: str = "", ca_bundle: str = ""):
"""``webhook_url`` rỗng nghĩa là chưa cấu hình — mọi lượt gửi về sau lặng lẽ bỏ
qua thay vì lỗi.
"""
self.webhook_url = (webhook_url or "").strip()
self.ca_bundle = (ca_bundle or "").strip()
@property
def configured(self) -> bool:
"""Đã cấu hình webhook hợp lệ chưa."""
return self.webhook_url.startswith("http")
def send(
@@ -113,6 +122,7 @@ class TeamsNotifier:
@staticmethod
def _explain(resp) -> str:
"""Đổi phản hồi lỗi của Teams thành câu đọc được, kèm mã HTTP và 200 ký tự thân."""
code = resp.status_code
body = (getattr(resp, "text", "") or "")[:200]
if code == 405:
@@ -127,6 +137,7 @@ class TeamsNotifier:
@staticmethod
def _message_card(title: str, text: str, facts: Optional[Dict[str, str]]) -> Dict:
"""Dựng payload khuôn MessageCard (webhook Connector cũ)."""
section: Dict = {"activityTitle": title, "text": text}
if facts:
section["facts"] = [{"name": k, "value": v} for k, v in facts.items()]
@@ -140,6 +151,7 @@ class TeamsNotifier:
@staticmethod
def _adaptive_card(title: str, text: str, facts: Optional[Dict[str, str]]) -> Dict:
"""Dựng payload khuôn Adaptive Card (webhook Workflows mới)."""
body: List[Dict] = [
{"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Medium"},
{"type": "TextBlock", "text": text, "wrap": True},
+5
View File
@@ -19,6 +19,11 @@ from typing import Any, Dict, List, Optional
def _load_events(shared_dir: str, subdir: str, start: Optional[date],
end: Optional[date]) -> List[Dict[str, Any]]:
"""Đọc sự kiện telemetry từ thư mục chia sẻ của đội, lọc theo khoảng ngày.
Thư mục chưa có thì trả list rỗng — máy chưa đồng bộ xong không được làm vỡ
màn Giám sát.
"""
directory = Path(shared_dir).expanduser() / "telemetry" / subdir
if not directory.exists():
return []
+7
View File
@@ -53,15 +53,22 @@ def looks_like_cert_trust_error(exc: BaseException) -> bool:
def _host_port(url: str) -> tuple[str, int]:
"""Cặp (host, port) rút từ URL; không có port thì mặc định 443."""
parsed = urlparse(url)
return parsed.hostname or "", parsed.port or 443
def _slug(host: str) -> str:
"""Tên file an toàn suy từ host."""
return re.sub(r"[^a-zA-Z0-9.-]", "_", host) or "host"
def trusted_cert_path(url: str) -> Path:
"""Đường dẫn file PEM ghim chứng chỉ cho một host.
Mỗi host một file: gateway nội bộ dùng chứng chỉ tự ký, ghim đúng chứng chỉ
đã thấy lần đầu (trust on first use) thay vì tắt kiểm chứng chỉ.
"""
host, _port = _host_port(url)
return TRUST_DIR / f"{_slug(host)}.pem"
+4
View File
@@ -188,6 +188,7 @@ def combine_tool_sources(*sources):
return [], None
def combined_executor(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Định tuyến một lời gọi tool về đúng nguồn của nó (dựng sẵn, MCP hay connector)."""
executor = routing.get(name)
if executor is None:
return {"ok": False, "output": f"Unknown tool: {name}"}
@@ -286,6 +287,9 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
def _short_json(obj: Any, limit: int = 500) -> str:
"""Chuỗi JSON đã cắt ngắn để đưa vào log hoặc bong bóng chat, tránh nhấn chìm
màn hình bằng một kết quả dài.
"""
import json
text = json.dumps(obj, ensure_ascii=False, indent=2)
return text if len(text) <= limit else text[:limit] + " …"
+1
View File
@@ -36,6 +36,7 @@ _CURRENCY_FMT = {"USD": ("$", 4), "VND": ("₫", 0), "JPY": ("¥", 1)}
SUPPORTED_CURRENCIES = tuple(_CURRENCY_FMT)
def cost_usd(summary: Dict[str, Any], pricing: Dict[str, Any]) -> Dict[str, float]:
"""Quy số token thành tiền (USD) theo bảng đơn giá, tách riêng vào/ra/cache."""
p = {**DEFAULT_PRICING, **(pricing or {})}
return {
"in": summary.get("in", 0) / 1e6 * float(p["price_per_mtok_in_usd"]),
+3
View File
@@ -24,6 +24,7 @@ def bucketed_series(events: List[Dict[str, Any]], granularity: str = "day",
pricing = pricing or {}
def _key(ts: Any) -> str:
"""Khoá gom nhóm của một mốc thời gian theo độ mịn (tuần/tháng/năm)."""
s = str(ts or "")[:10]
if granularity == "year":
return s[:4]
@@ -65,6 +66,7 @@ def period_bounds(gran: str, offset: int, today: Optional[date] = None) -> tuple
return date(y, m + 1, 1), date(y2, m2 + 1, 1)
def _period_label(gran: str, start: date) -> str:
"""Nhãn hiển thị của một kỳ: thứ Hai đầu tuần, YYYY-MM, hoặc năm."""
if gran == "week":
return start.isoformat() # the week's Monday (YYYY-MM-DD)
if gran == "year":
@@ -73,6 +75,7 @@ def _period_label(gran: str, start: date) -> str:
def _sum_between(events: List[Dict[str, Any]], start: date, end: date,
pricing: Dict[str, Any]) -> tuple:
"""Tổng token và chi phí của các sự kiện trong khoảng ``[start, end)``."""
lo, hi = start.isoformat(), end.isoformat()
evs = [e for e in events if lo <= str(e.get("ts", ""))[:10] < hi]
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
+5
View File
@@ -103,6 +103,11 @@ def end_accumulation() -> None:
def estimate_tokens(text: str) -> int:
"""Ước lượng số token của một đoạn văn bản theo tỉ lệ 4 ký tự ≈ 1 token.
Ước lượng thô là đủ: con số này chỉ dùng để quyết định khi nào nén lịch sử,
không dùng để tính tiền (tiền lấy từ số token thật provider trả về).
"""
return max(0, len(text or "") // 4)
+1
View File
@@ -158,6 +158,7 @@ class WindowsSandboxVM:
pass
def _error(self, message: str) -> Dict[str, Any]:
"""Kết quả lỗi theo đúng khuôn chung của bộ chạy sandbox."""
return {
"ok": False,
"stdout": "",
+24
View File
@@ -18,12 +18,23 @@ Job = Callable[["AgentWorker"], Optional[Dict[str, Any]]]
class AgentWorker(QThread):
"""Luồng nền chạy một lượt agent, nối kết quả về giao diện qua signal Qt.
Mọi việc chậm (gọi model, chạy tool, trích tệp) đều phải nằm trong đây —
chạy ở luồng giao diện là cả cửa sổ đứng hình.
"""
event = Signal(dict) # streaming/agent events
permission_requested = Signal(dict) # confirm-mode tool action awaiting approval
finished_ok = Signal(dict) # job completed
failed = Signal(str) # job raised
def __init__(self, job: Job, parent=None):
"""Bọc một hàm thành luồng nền.
``stop_event`` để công khai vì provider cần truyền thẳng nó vào
``Event.wait()`` — nhờ vậy bấm Dừng là dừng ngay, không phải đợi hết lượt
chờ mạng hiện tại.
"""
super().__init__(parent)
self._job = job
self.stop_event = threading.Event() # public for provider Event.wait() — immediate Stop
@@ -31,12 +42,15 @@ class AgentWorker(QThread):
# -- helpers used from inside the job (worker thread) --------------
def is_cancelled(self) -> bool:
"""``True`` khi người dùng đã bấm Dừng — job phải tự thoát sớm."""
return self.stop_event.is_set()
def emit_event(self, ev: Dict[str, Any]) -> None:
"""Đẩy một sự kiện tiến độ về giao diện."""
self.event.emit(ev)
def new_gate(self, mode: str, agent_role: str = "") -> PermissionGate:
"""Dựng cổng phê duyệt cho lượt này (chế độ hỏi trước khi chạy tool)."""
self.gate = PermissionGate(
mode, on_request=lambda action: self.permission_requested.emit(action),
agent_role=agent_role,
@@ -45,16 +59,26 @@ class AgentWorker(QThread):
# -- control from the UI thread -----------------------------------
def request_stop(self) -> None:
"""Yêu cầu dừng: bật cờ huỷ và giải phóng cổng phê duyệt đang chờ.
Phải huỷ cả cổng, nếu không job sẽ kẹt mãi ở chỗ chờ người dùng bấm Đồng ý.
"""
self.stop_event.set()
if self.gate:
self.gate.cancel()
def resolve_permission(self, approved: bool) -> None:
"""Trả lời một yêu cầu phê duyệt tool đang chờ."""
if self.gate:
self.gate.resolve(approved)
# -- thread body ---------------------------------------------------
def run(self) -> None: # noqa: D401
"""Thân luồng: chạy job rồi phát ``finished_ok``, lỗi thì phát ``failed``.
Bắt mọi ngoại lệ: một lỗi lọt ra khỏi đây sẽ giết luồng mà giao diện không
nhận được tín hiệu nào — người dùng thấy nút Dừng quay mãi.
"""
try:
result = self._job(self)
self.finished_ok.emit(result or {})
+1
View File
@@ -33,6 +33,7 @@ def _openpyxl():
def is_available() -> bool:
"""Máy đã cài ``openpyxl`` chưa — không có thì mọi tính năng Excel tắt."""
return _openpyxl() is not None
+31
View File
@@ -55,6 +55,7 @@ class ToolPreview:
text: str = ""
def to_dict(self) -> Dict[str, str]:
"""Ba khoá đúng như ``core/tools.py::describe_action`` trả về — không thêm không bớt."""
return {"kind": self.kind, "title": self.title, "text": self.text}
@classmethod
@@ -84,6 +85,7 @@ class PlanStep:
status: str = "pending"
def to_dict(self) -> Dict[str, str]:
"""Một bước kế hoạch dưới dạng dict, để đưa vào payload sự kiện."""
return {"title": self.title, "status": self.status}
@@ -129,6 +131,7 @@ class TextChunkEvent(AgentEvent):
delta: str = ""
def _payload(self) -> Dict[str, Any]:
"""``{delta}`` — một mẩu câu trả lời đang phát dần."""
return {"delta": self.delta}
@@ -140,6 +143,7 @@ class ReasoningChunkEvent(AgentEvent):
delta: str = ""
def _payload(self) -> Dict[str, Any]:
"""``{delta}`` — một mẩu suy luận nội bộ của model."""
return {"delta": self.delta}
@@ -156,6 +160,7 @@ class AssistantMessageCompletedEvent(AgentEvent):
content: str = ""
def _payload(self) -> Dict[str, Any]:
"""``{content}`` — nội dung trọn vẹn của một lượt gọi provider."""
return {"content": self.content}
@@ -178,6 +183,11 @@ class ToolCallStartedEvent(AgentEvent):
preview: Optional[ToolPreview] = None
def _payload(self) -> Dict[str, Any]:
"""``{id, name, args}``, kèm ``preview`` chỉ khi thật sự có.
Không có xem trước thì BỎ HẲN khoá thay vì gửi ``None``: widget đang viết
``ev.get("preview") or {}``, tức nó đã quen với việc khoá vắng mặt.
"""
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
"args": dict(self.arguments)}
# Omitted rather than sent as None: the widget does
@@ -198,6 +208,7 @@ class ToolOutputChunkEvent(AgentEvent):
delta: str = ""
def _payload(self) -> Dict[str, Any]:
"""``{id, name, delta}`` — một mẩu đầu ra tool đang chạy."""
return {"id": self.call_id, "name": self.name, "delta": self.delta}
@@ -215,9 +226,19 @@ class ToolCallFinishedEvent(AgentEvent):
def __post_init__(self) -> None:
# Callers pass a live list; freeze it so the event cannot change later.
"""Đóng băng danh sách tệp đầu ra thành tuple.
Bên gọi truyền vào một list đang sống; không sao chép thì sự kiện đã phát đi
vẫn đổi nội dung được về sau — sự kiện phải là ảnh chụp bất biến.
"""
object.__setattr__(self, "produced", _as_str_tuple(self.produced))
def _payload(self) -> Dict[str, Any]:
"""``{id, name, ok, output}``, kèm ``error``/``produced`` chỉ khi có.
Hai khoá sau vắng mặt khi rỗng, đúng như ``chat_agent`` vẫn phát: phía
nhận kiểm bằng ``ev.get(...)`` nên thêm giá trị rỗng là đổi hành vi.
"""
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
"ok": self.ok, "output": self.output}
# Both keys stay ABSENT when empty, matching what chat_agent emits today:
@@ -241,9 +262,11 @@ class PlanUpdatedEvent(AgentEvent):
steps: Tuple[PlanStep, ...] = ()
def __post_init__(self) -> None:
"""Đóng băng danh sách bước kế hoạch thành tuple."""
object.__setattr__(self, "steps", tuple(self.steps or ()))
def _payload(self) -> Dict[str, Any]:
"""``{steps}`` — cả danh sách bước, vì agent gửi lại trọn kế hoạch mỗi lần cập nhật."""
return {"steps": [s.to_dict() for s in self.steps]}
@@ -260,6 +283,7 @@ class NoticeEvent(AgentEvent):
level: str = NOTICE_INFO
def _payload(self) -> Dict[str, Any]:
"""``{level, text}`` — một dòng thông báo (info / cảnh báo / tiến độ)."""
return {"level": self.level, "text": self.text}
@@ -271,9 +295,11 @@ class OutputsAddedEvent(AgentEvent):
paths: Tuple[str, ...] = ()
def __post_init__(self) -> None:
"""Đóng băng danh sách đường dẫn thành tuple."""
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
def _payload(self) -> Dict[str, Any]:
"""``{paths}`` — các tệp vừa sinh ra, đổi về list cho JSON hoá được."""
return {"paths": list(self.paths)}
@@ -285,9 +311,11 @@ class OutputsRemovedEvent(AgentEvent):
paths: Tuple[str, ...] = ()
def __post_init__(self) -> None:
"""Đóng băng danh sách đường dẫn thành tuple."""
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
def _payload(self) -> Dict[str, Any]:
"""``{paths}`` — các tệp trung gian vừa được dọn."""
return {"paths": list(self.paths)}
@@ -303,6 +331,7 @@ class HistoryReadyEvent(AgentEvent):
session_id: str = ""
def _payload(self) -> Dict[str, Any]:
"""``{session_id}`` — lịch sử đã ghi xong, kèm id phiên để mở lại."""
return {"session_id": self.session_id}
@@ -327,6 +356,7 @@ class TurnCompletedEvent(AgentEvent):
budget_exhausted: bool = False # stopped at effective_max_steps
def _payload(self) -> Dict[str, Any]:
"""``{final_text, steps_used, cancelled, budget_exhausted}`` — tổng kết cả lượt."""
return {"final_text": self.final_text, "steps_used": self.steps_used,
"cancelled": self.cancelled, "budget_exhausted": self.budget_exhausted}
@@ -345,6 +375,7 @@ class ErrorEvent(AgentEvent):
recoverable: bool = False
def _payload(self) -> Dict[str, Any]:
"""``{message, recoverable}`` — lỗi kèm cờ có thể thử lại hay không."""
return {"message": self.message, "recoverable": self.recoverable}
+10
View File
@@ -11,6 +11,16 @@ declares the vocabulary, the other bridges it to the old wire format.
Serialisation the other way lives on the events themselves
(``AgentEvent.to_legacy_dict``), because an event has to be emittable without
anyone importing a codec.
SEAM · dựng 2026-08-23 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ``presentation/chat`` dùng thẳng sự kiện có kiểu, không còn đọc dict cũ nữa.
Để dormant thì sao: Shim này để xoá, không để giữ. Còn nó thì khuôn dict cũ
vẫn là một hợp đồng phải duy trì.
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
+18
View File
@@ -20,6 +20,16 @@ Mô hình bám theo code đang chạy, không bịa:
Điểm khác biệt duy nhất so với hôm nay: gộp hai thứ đó thành **một câu trả lời
ba trạng thái**, thay vì code gọi phải tự nhớ hỏi cả hai nơi.
SEAM · dựng 2026-08-21 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ``application/conversations/tool_policy_gateway.py`` trả về ``PolicyDecision`` thay cho ``bool``.
Để dormant thì sao: Hiện gateway chỉ trả đúng/sai nên lý do chặn bị mất —
đúng thứ kiểu dữ liệu này sinh ra để mang theo.
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
@@ -79,20 +89,28 @@ class PolicyDecision:
return self.outcome is PolicyOutcome.ALLOW
def __post_init__(self):
"""Ép mọi quyết định DENY/ASK phải kèm lý do.
Người dùng thấy lý do trên hộp thoại, và nhật ký kiểm toán ghi lại nó — một
quyết định chặn không lý do là không truy được về sau.
"""
if self.outcome is not PolicyOutcome.ALLOW and not self.reason:
raise ValueError("DENY và ASK bắt buộc có reason — người dùng và "
"audit log đều cần biết vì sao")
def allow() -> PolicyDecision:
"""Quyết định cho phép. Không cần lý do: đây là đường đi bình thường."""
return PolicyDecision(PolicyOutcome.ALLOW)
def deny(reason: str, layer: str = "policy") -> PolicyDecision:
"""Quyết định chặn hẳn, kèm lý do và tên lớp đã ra quyết định."""
return PolicyDecision(PolicyOutcome.DENY, reason, layer)
def ask(reason: str, layer: str = "policy") -> PolicyDecision:
"""Quyết định phải hỏi người dùng, kèm lý do và tên lớp đã ra quyết định."""
return PolicyDecision(PolicyOutcome.ASK, reason, layer)
+5
View File
@@ -41,10 +41,12 @@ class _CronLike(Protocol):
``core/cron.py::Cron`` without this module importing it."""
def next_after(self, after: datetime) -> Optional[datetime]:
"""Lần chạy kế tiếp sau một mốc thời gian; ``None`` nếu không bao giờ."""
...
def _parse_run_at(value: Optional[str]) -> Optional[datetime]:
"""Đọc chuỗi thời gian chạy thành ``datetime``; sai định dạng thì trả ``None``."""
if not value:
return None
try:
@@ -69,6 +71,9 @@ class ScheduleCalculator:
def __init__(self,
is_holiday: Optional[Callable[[Any, str], bool]] = None,
make_cron: Optional[Callable[[str], _CronLike]] = None) -> None:
"""``is_holiday``/``make_cron`` tiêm được nên lớp này không phụ thuộc vào lịch
nghỉ hay bộ phân tích cron nào cụ thể — test truyền hàm giả vào.
"""
self._is_holiday = is_holiday
self._make_cron = make_cron
+8
View File
@@ -37,17 +37,23 @@ class ToolRegistry:
"""
def __init__(self, descriptors: Optional[Iterable[ToolDescriptor]] = None) -> None:
"""Đăng ký sẵn một loạt tool. Đi qua ``register`` chứ không gán thẳng dict để
mọi kiểm tra trùng tên đều chạy.
"""
self._by_name: Dict[str, ToolDescriptor] = {}
for descriptor in descriptors or ():
self.register(descriptor)
def register(self, descriptor: ToolDescriptor) -> None:
"""Đăng ký (hoặc thay thế) một tool theo tên."""
self._by_name[descriptor.name] = descriptor
def get(self, name: str) -> Optional[ToolDescriptor]:
"""Mô tả của một tool; ``None`` nếu chưa đăng ký."""
return self._by_name.get(name)
def all(self) -> List[ToolDescriptor]:
"""Danh sách mọi tool đã đăng ký."""
return list(self._by_name.values())
def specs(self) -> List[ToolSpec]:
@@ -67,9 +73,11 @@ class ToolRegistry:
return descriptor.capabilities if descriptor is not None else ToolCapability.NONE
def __contains__(self, name: str) -> bool:
"""``"tên" in registry`` — tra theo tên tool."""
return name in self._by_name
def __len__(self) -> int:
"""Số tool đã đăng ký."""
return len(self._by_name)
+1
View File
@@ -0,0 +1 @@
"""Thực thể và DTO thuần Python của phân hệ luồng công việc (Co4E)."""
+22
View File
@@ -32,6 +32,16 @@ là hành vi đã được test khẳng định:
* ``from_dict({})``/``from_dict(None)`` mặc định ``status`` là ``"done"``
(không phải ``"running"``) — nên KHÔNG bị nhánh phía trên đổi thành
"stopped".
SEAM · dựng 2026-08-25 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ``Co4EWorkflowService`` được nối dây — cùng điều kiện với seam ấy.
Để dormant thì sao: DTO này và ``core/co4e_run_manager.py::RunHandle`` là
hai bản của cùng một thứ; chỉ một bản được phép ở lại.
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
@@ -50,6 +60,9 @@ class RunRecord:
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 = ""):
"""Dựng một bản ghi run. ``total`` âm bị kẹp về 0 — quirk cố ý giữ nguyên từ
``Co4ERunManager`` cũ, xem docstring đầu file.
"""
self.id = run_id
self.wf_id = wf_id
self.name = name
@@ -68,9 +81,13 @@ class RunRecord:
@property
def running(self) -> bool:
"""Run này còn đang chạy không."""
return self.status == "running"
def progress_text(self) -> str:
"""Chuỗi tiến độ để hiện lên bảng: "3/7" khi biết tổng số bước, còn không thì
hiện trạng thái.
"""
return f"{self.done}/{self.total}" if self.total else self.status
# ---- (de)serialization --------------------------------------------
@@ -93,6 +110,11 @@ class RunRecord:
@classmethod
def from_dict(cls, rec: dict) -> "RunRecord":
"""Dựng lại một run từ dict đọc ở file lịch sử.
Mọi trường đều có mặc định và được ép kiểu: file lịch sử là dữ liệu cũ có
thể thiếu trường mà bản mới đã thêm.
"""
rec = dict(rec or {})
r = cls(str(rec.get("id", "")), str(rec.get("wf_id", "")),
rec.get("name", ""), int(rec.get("total", 0) or 0),
+3
View File
@@ -53,6 +53,9 @@ class WorkspaceSession:
allowed_paths: Tuple[Path, ...] = field(default_factory=tuple)
def __post_init__(self) -> None:
"""Không khai đường dẫn cho phép thì mặc định đúng một đường: thư mục gốc của
phiên. Để rỗng nghĩa là KHÔNG cho phép gì cả, không phải cho phép tất.
"""
if not self.allowed_paths:
object.__setattr__(self, "allowed_paths", (self.workspace_root,))
+7
View File
@@ -74,10 +74,17 @@ def set_language(lang: str) -> None:
def get_language() -> str:
"""Mã ngôn ngữ đang dùng."""
return _current
def tr(key: str, **kwargs) -> str:
"""Chuỗi đã dịch cho một khoá.
Thiếu khoá thì trả về CHÍNH khoá đó — hiện ra một chuỗi lạ trên giao diện
vẫn tốt hơn là làm vỡ màn hình. Thiếu bản dịch của ngôn ngữ hiện tại thì rơi
về tiếng Anh.
"""
entry = STRINGS.get(key)
if not entry:
return key
@@ -12,6 +12,17 @@ thật, xếp theo số lần gọi.
Một chỗ cố ý KHÔNG đưa vào: ``config.data`` (36 lần gọi, nhiều nhất). Đó là
đống dict thô — cho nó vào interface là bê nguyên vấn đề cũ sang kiến trúc mới.
Ai đang cần ``data`` thì mở issue để bổ sung một thuộc tính có kiểu rõ ràng.
SEAM · dựng 2026-08-21 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: một chỗ chú thích kiểu thật sự nhận ``ConfigRepository`` thay vì ``AppConfig``.
Để dormant thì sao: Protocol không ai chú thích tới thì không có bộ kiểm
kiểu nào đối chiếu nó với ``JsonConfigRepository``, nên hai bên lệch nhau
lúc nào không hay.
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
@@ -30,6 +41,7 @@ class ConfigRepository(Protocol):
...
def set_active_provider(self, name: str) -> None:
"""Đổi provider đang dùng."""
...
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
@@ -62,6 +74,7 @@ class ConfigRepository(Protocol):
...
def set_theme(self, value: str) -> None:
"""Đổi giao diện sáng/tối."""
...
@property
@@ -70,6 +83,7 @@ class ConfigRepository(Protocol):
...
def set_language(self, value: str) -> None:
"""Đổi ngôn ngữ hiển thị."""
...
# ---- các nhóm cấu hình còn lại -------------------------------------
@@ -94,6 +108,7 @@ class ConfigRepository(Protocol):
...
def set_tool_enabled(self, name: str, enabled: bool) -> None:
"""Bật/tắt một tool theo tên."""
...
# ---- ghi ------------------------------------------------------------

Some files were not shown because too many files have changed in this diff Show More