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>
389 lines
19 KiB
Python
389 lines
19 KiB
Python
"""Application configuration.
|
|
|
|
Stored as JSON at ``~/.cowork_local/config.json``. Environment variables
|
|
override stored values so the app can run immediately in locked-down setups:
|
|
|
|
OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL
|
|
ANTHROPIC_API_KEY, ANTHROPIC_MODEL
|
|
COWORK_TEAMS_WEBHOOK
|
|
COWORK_ACTIVE_PROVIDER
|
|
COWORK_CA_BUNDLE
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from .infrastructure.config.json_config_repository import JsonConfigRepository
|
|
from typing import Any, Dict, List
|
|
|
|
CONFIG_DIR = Path.home() / ".cowork_local"
|
|
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
HISTORY_DIR = CONFIG_DIR / "history"
|
|
|
|
DEFAULT_CONFIG: Dict[str, Any] = {
|
|
"active_provider": "openai_compat",
|
|
"theme": "dark",
|
|
"language": "vi", # "en" | "ja" | "vi" — UI display language
|
|
# Advanced/IT-managed override only (no Settings UI): path to a PEM file
|
|
# with a corporate/internal gateway's certificate (or its issuing CA), set
|
|
# via the COWORK_CA_BUNDLE env var. Normally unnecessary — a self-signed
|
|
# gateway certificate (e.g. "SSLCertVerificationError: self-signed
|
|
# certificate in certificate chain") is detected and trusted automatically
|
|
# per-host on first contact; see core/tls_trust.py.
|
|
"tls_ca_bundle": "",
|
|
"providers": {
|
|
"openai_compat": {
|
|
"base_url": "https://your-internal-gateway/v1",
|
|
"api_key": "",
|
|
"model": "gpt-4o-mini",
|
|
},
|
|
"anthropic": {
|
|
"base_url": "https://api.anthropic.com",
|
|
"api_key": "",
|
|
"model": "claude-sonnet-4-6",
|
|
},
|
|
# Local models via Ollama's OpenAI-compatible server (no key needed).
|
|
"ollama": {
|
|
"base_url": "http://localhost:11434/v1",
|
|
"api_key": "ollama", # Ollama ignores it, but some clients require a value
|
|
"model": "llama3.1",
|
|
},
|
|
# GitHub Copilot chat (OpenAI-compatible endpoint; paste a Copilot token).
|
|
"github_copilot": {
|
|
"base_url": "https://api.githubcopilot.com",
|
|
"api_key": "",
|
|
"model": "gpt-4o",
|
|
},
|
|
# OpenAI (Codex / GPT models) — OpenAI-compatible; paste an OpenAI API key.
|
|
"codex": {
|
|
"base_url": "https://api.openai.com/v1",
|
|
"api_key": "",
|
|
"model": "gpt-4o-mini",
|
|
},
|
|
},
|
|
"code": {
|
|
"mode": "confirm", # "confirm" | "auto"
|
|
"default_workdir": "",
|
|
},
|
|
"teams": {
|
|
"webhook_url": "",
|
|
"notify_on_complete": True,
|
|
},
|
|
"history": {
|
|
"location": "local", # "local" | "onedrive"
|
|
"custom_dir": "", # optional explicit folder; overrides location
|
|
"autosave": True,
|
|
},
|
|
"codebase_memory": {
|
|
"enabled": False,
|
|
"binary_path": "", # empty -> resolved from PATH (codebase-memory-mcp)
|
|
"auto_index": True, # index the workdir automatically before the first turn
|
|
},
|
|
# AI-assisted agent security guardrails — configured in its own Settings
|
|
# group next to Microsoft 365 (same screen area, but never touches the
|
|
# ms365 dict/rules above). Each layer is independently toggleable; a
|
|
# blocked action always notifies the admin (see core/agent_security_alert.py)
|
|
# via the SAME signed-in Microsoft 365 account as everything else.
|
|
"agent_security": {
|
|
"enabled": True, # master switch — ON by default ("chọn hết"); editing the Settings group requires an admin-account unlock
|
|
"validate_prompt": True, # AI reviews the user's own request against the rules below
|
|
"validate_attachments": True, # AI scans attachment/file content for malicious payloads
|
|
"validate_commands": True, # whitelist + optional AI control-agent gate on run_command/install_package
|
|
"command_ai_check": False, # extra AI judgement for commands not covered by the whitelist (default: off)
|
|
"rules_onedrive_url": "", # optional OneDrive/SharePoint SHARE LINK to a .md rules doc (admin-authored)
|
|
"admin_email": "", # violation alerts are emailed here via the signed-in MS365 account
|
|
# ---- Sandbox Security Layer ----
|
|
"cowork_confirm_commands": False, # show the Approve/Reject dialog before Cowork runs a command (default: off)
|
|
"resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU%
|
|
"resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB)
|
|
"resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB)
|
|
"block_network": True, # strip proxy env / point at a black-hole address for agent-run commands
|
|
# Allow the agent's fetch_url tool to read web pages / online documents /
|
|
# SharePoint-OneDrive share links. SEPARATE from block_network (that only
|
|
# sandboxes agent-run shell commands) — reading a URL for info is safe and
|
|
# useful, so this defaults ON. Toggle in Settings → Security.
|
|
"allow_url_fetch": True,
|
|
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
|
|
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
|
|
},
|
|
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
|
|
# the unified "Connectors (MCP)" section — kept here only so config.load()
|
|
# can migrate any pre-existing entries; the UI no longer writes it.
|
|
"mcp_servers": [],
|
|
# Unified "Connectors (MCP)" (Settings). One system for every external tool
|
|
# source — grouped by category CAD / CAE / MS365 / Other. Each entry:
|
|
# {"id", "name", "category", "enabled", "mode": "mcp_stdio"|"rest_api", plus
|
|
# mode-specific fields — see core/ext_connectors.py}. No vendor SDK bundled:
|
|
# a mcp_stdio entry points at a real MCP server the user/IT already has; a
|
|
# rest_api entry calls a REST endpoint the app/vendor exposes. "Other" is
|
|
# the home for generic MCP servers (what used to be the separate "MCP
|
|
# Servers" section); MS365 additionally auto-wires the built-in MS365 MCP
|
|
# server (see state.py::_ms365_builtin_connection).
|
|
"ext_connectors": {
|
|
"cad": [],
|
|
"cae": [],
|
|
"ms365": [],
|
|
"other": [],
|
|
},
|
|
"cowork": {
|
|
"output_dir": "", # where Cowork saves generated files; empty -> OneDrive/CoworkLocal/output
|
|
"max_parallel": 5, # max messages running at once per tab; extras wait in the queue
|
|
},
|
|
"context": { # auto-compress long conversations (Cowork + Co4E)
|
|
"auto_compact": True, # summarize old turns when near the memory quota
|
|
"compact_threshold": 0.8, # trigger at 80% of the context window
|
|
"limit_tokens": 0, # 0 = auto per model; else a fixed token budget
|
|
},
|
|
"jira": { # Jira read connector (agent tool: jira_search / jira_get_issue)
|
|
"base_url": "", # e.g. https://your-domain.atlassian.net
|
|
"email": "", # Atlassian account email (Basic auth user)
|
|
"api_token": "", # Atlassian API token (id.atlassian.com → Security → API tokens)
|
|
},
|
|
"attachments": {
|
|
"max_tokens": 500000, # per attached file; content beyond this is truncated (~4 chars/token)
|
|
"max_files": 10, # max number of files attachable to one message
|
|
},
|
|
"structure": { # Structure (RAG) graph performance caps (0 = unlimited)
|
|
"max_nodes": 400,
|
|
"max_edges": 400,
|
|
},
|
|
# Dashboard tab: unit prices (USD per 1M tokens) + display currency.
|
|
# Editable right on the Dashboard; rates are static conversions.
|
|
"usage": {
|
|
"price_per_mtok_in_usd": 0.5,
|
|
"price_per_mtok_out_usd": 1.5,
|
|
"price_per_mtok_cache_usd": 0.1,
|
|
"currency": "USD", # USD | VND | JPY
|
|
"usd_to_vnd": 25000.0,
|
|
"usd_to_jpy": 150.0,
|
|
"model_prices": {}, # per-model USD/1M rates: {model: {"in","out","cache"}}
|
|
"pricing_url": "", # reference price-list link (informational)
|
|
},
|
|
"auth": {
|
|
"shared_dir": "", # shared folder path (network share or synced OneDrive folder) holding
|
|
# accounts/groups + cross-machine telemetry — plain file I/O, no Graph API
|
|
"last_account": "", # last successfully logged-in username, for prefill only — never the code
|
|
"last_department": "", # last-typed optional Department at login, for prefill only
|
|
},
|
|
# Microsoft 365 connections (Settings → "Kết nối Microsoft 365"). This gate
|
|
# (unlock_code) is a LOCAL SETTINGS-PANEL LOCK ONLY — it stops someone from
|
|
# casually flipping these switches, it is NOT how the app authenticates to
|
|
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
|
|
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
|
|
"ms365": {
|
|
"unlock_code": "", # set through COWORK_MS365_UNLOCK_CODE
|
|
"unlocked": False, # runtime-only — never persisted as True, see save()
|
|
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
|
|
# launches automatically once the user is signed in (OAuth tenant/client
|
|
# is still required for real Graph access — this only pre-arms the wiring
|
|
# so it "just works" after sign-in, per the unified Connectors design).
|
|
"allow_external_internet": True,
|
|
# TEMPORARY: only OneDrive + SharePoint are enabled, and they connect via
|
|
# the LOCALLY-SYNCED OneDrive folders (core/ms365_local.py) — no OAuth /
|
|
# tenant / sign-in. Outlook / Teams / Meeting-transcript are OFF for now
|
|
# because they need cloud Graph access (OAuth); re-enable them once the
|
|
# cloud sign-in flow is turned back on.
|
|
"connectors": {
|
|
"outlook": False,
|
|
"teams": False,
|
|
"onedrive": True,
|
|
"sharepoint": True,
|
|
"meeting_transcript": False,
|
|
},
|
|
"tenant_id": "",
|
|
"client_id": "",
|
|
# "Paste a Teams link" convenience (Settings): a channel/chat link the
|
|
# user connected once, so the agent can post to it without ever
|
|
# needing a team_id/channel_id/chat_id — see ms365_graph.parse_teams_link.
|
|
"teams_link": "",
|
|
"teams_target": None, # {"kind": "channel", "team_id", "channel_id"} | {"kind": "chat", "chat_id"}
|
|
"teams_introduced": False, # has the "Hi, I'm Co4E" self-intro already been sent for this target?
|
|
},
|
|
"last_session": { # restored on next launch (crash-resilient)
|
|
"cowork": "",
|
|
"code": "",
|
|
},
|
|
"tray": {
|
|
"minimize_on_close": True, # closing the window keeps running in the tray
|
|
"notify_on_done": True, # tray notification when a task finishes/fails
|
|
},
|
|
# Which Monitoring tabs a Sub-admin may see (Admin always sees every tab;
|
|
# "user" never sees Monitoring at all — unaffected by this). All default
|
|
# True so behavior is unchanged until an Admin explicitly restricts one.
|
|
"monitoring_visibility": {
|
|
"security_events": True,
|
|
"mcp_history": True,
|
|
"action_logs": True,
|
|
"agent_status": True,
|
|
},
|
|
# Agent tool governance (Monitoring → Tools). Built-in agent tools whose
|
|
# NAME is listed here are withheld from the agent (filtered out of the tool
|
|
# list at run time). Empty = every built-in tool available (default).
|
|
"tools": {
|
|
"disabled": [],
|
|
},
|
|
# Auto Model Assessment & Routing (core/routing/). The app periodically
|
|
# assesses each configured model (static metadata + dynamic probes graded
|
|
# by a fixed judge), scores them per task type, and can route each chat/
|
|
# agent turn to the best-fit model. Assessment RESULTS live in their own
|
|
# file (~/.cowork_local/assessments.json + assessments_history/), not here —
|
|
# this section is only the behaviour config the user edits.
|
|
"routing": {
|
|
"switch_mode": "off", # global default: "off" | "auto" | "manual"
|
|
"policy": "balanced", # "quality" | "cost" | "latency" | "balanced"
|
|
"min_score_gain": 0.05, # only switch if the new model beats current by ≥ this
|
|
"confirm_timeout_sec": 60, # (manual) auto-keep current if the user doesn't confirm in time
|
|
"reassess_interval_hours": 24, # periodic reassess cadence; 0 disables the schedule
|
|
"per_provider_concurrency": 2, # max concurrent probe calls per provider (rate-limit safety)
|
|
"judge_provider": "", # judge model's provider ("" → the active provider)
|
|
"judge_model": "", # fixed cheap judge model ("" → a per-provider default)
|
|
"candidates": [], # explicit [{provider, model_id, tier}]; empty → discover from providers
|
|
"auto_reassess_on_add": True, # reassess a newly-added model as soon as it's added
|
|
# Per-surface Off/Auto/Manual toggle state (the chat-screen toggle). An
|
|
# empty string means "follow the global switch_mode above".
|
|
"surface_modes": {
|
|
"cowork": "",
|
|
"co4e": "",
|
|
"ai_edit": "",
|
|
},
|
|
},
|
|
}
|
|
|
|
# Friendly labels used across the UI.
|
|
PROVIDER_LABELS = {
|
|
"openai_compat": "OpenAI-compatible (Internal Gateway)",
|
|
"anthropic": "Anthropic Claude",
|
|
"ollama": "Ollama (local models)",
|
|
"github_copilot": "GitHub Copilot",
|
|
"codex": "OpenAI (Codex / GPT)",
|
|
}
|
|
|
|
|
|
def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Recursively merge ``override`` into a copy of ``base``."""
|
|
out = copy.deepcopy(base)
|
|
for key, value in (override or {}).items():
|
|
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
|
out[key] = _deep_merge(out[key], value)
|
|
else:
|
|
out[key] = value
|
|
return out
|
|
|
|
|
|
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"):
|
|
oc["api_key"] = os.environ["OPENAI_API_KEY"]
|
|
if os.getenv("OPENAI_BASE_URL"):
|
|
oc["base_url"] = os.environ["OPENAI_BASE_URL"]
|
|
if os.getenv("OPENAI_MODEL"):
|
|
oc["model"] = os.environ["OPENAI_MODEL"]
|
|
|
|
an = data["providers"]["anthropic"]
|
|
if os.getenv("ANTHROPIC_API_KEY"):
|
|
an["api_key"] = os.environ["ANTHROPIC_API_KEY"]
|
|
if os.getenv("ANTHROPIC_MODEL"):
|
|
an["model"] = os.environ["ANTHROPIC_MODEL"]
|
|
|
|
if os.getenv("COWORK_TEAMS_WEBHOOK"):
|
|
data["teams"]["webhook_url"] = os.environ["COWORK_TEAMS_WEBHOOK"]
|
|
if os.getenv("COWORK_ACTIVE_PROVIDER"):
|
|
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
|
|
if os.getenv("COWORK_CA_BUNDLE"):
|
|
data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"]
|
|
if os.getenv("COWORK_SANDBOX_PASSWORD"):
|
|
data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"]
|
|
if os.getenv("COWORK_MS365_UNLOCK_CODE"):
|
|
data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"]
|
|
return data
|
|
|
|
|
|
def _migrate_connectors(data: Dict[str, Any]) -> None:
|
|
"""One-way migration into the unified Connectors (MCP) model, in place:
|
|
* ext_connectors["office"] → ext_connectors["ms365"] (renamed category)
|
|
* legacy top-level mcp_servers → ext_connectors["other"] as mcp_stdio
|
|
connectors (the old standalone "MCP Servers" section was merged in).
|
|
Idempotent: re-running does nothing once migrated. Never raises."""
|
|
import uuid
|
|
|
|
ext = data.setdefault("ext_connectors", {})
|
|
for cat in ("cad", "cae", "ms365", "other"):
|
|
ext.setdefault(cat, [])
|
|
|
|
# office → ms365 (only migrate non-empty legacy bucket; then drop it)
|
|
legacy_office = ext.pop("office", None)
|
|
if legacy_office:
|
|
seen = {c.get("id") for c in ext["ms365"]}
|
|
for c in legacy_office:
|
|
c["category"] = "ms365"
|
|
if c.get("id") not in seen:
|
|
ext["ms365"].append(c)
|
|
|
|
# legacy generic mcp_servers → ext_connectors["other"] (mcp_stdio)
|
|
servers = data.get("mcp_servers") or []
|
|
if servers:
|
|
existing = {c.get("name") for c in ext["other"]}
|
|
for s in servers:
|
|
name = s.get("name", "")
|
|
if not name or name in existing:
|
|
continue
|
|
ext["other"].append({
|
|
"id": f"other-{uuid.uuid4().hex[:6]}",
|
|
"name": name,
|
|
"category": "other",
|
|
"enabled": bool(s.get("enabled", True)),
|
|
"mode": "mcp_stdio",
|
|
"command": s.get("command", ""),
|
|
"args": s.get("args") or [],
|
|
"env": s.get("env") or {},
|
|
})
|
|
data["mcp_servers"] = [] # migrated — the UI no longer manages this
|
|
|
|
|
|
class AppConfig(JsonConfigRepository):
|
|
"""Vỏ tương thích — R02 đã thay lớp này bằng :class:`JsonConfigRepository`.
|
|
|
|
Ngày 25/08 app chuyển hẳn sang repository (ghi nguyên tử, khoá nằm trong
|
|
kho bí mật của hệ điều hành). Nhưng cái tên ``AppConfig`` còn nằm ở 41 file
|
|
— 23 checker trong ``tools/`` và 18 file test, trong đó có test của cả ba
|
|
người. Sửa hết 41 chỗ trong một commit là đổi thứ không cần đổi và làm
|
|
review không đọc nổi.
|
|
|
|
Nên giữ tên, đổi ruột: mọi lối vào đều dẫn tới repository.
|
|
|
|
Bỏ hẳn được khi ``tools/`` và ``tests/`` chuyển sang gọi
|
|
``presentation.shell.bootstrap.build_context()``.
|
|
"""
|
|
|
|
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
|
|
# Dạng AppConfig(data=..., path=...) mà 13 file test đang dùng: dựng
|
|
# thẳng từ dict, không đụng đĩa.
|
|
built = JsonConfigRepository.from_data(data, Path(path))
|
|
self.__dict__.update(built.__dict__)
|
|
|
|
@classmethod
|
|
def load(cls, path: Path = CONFIG_PATH) -> "JsonConfigRepository":
|
|
"""Điểm vào cũ. Giờ đi qua Composition Root nên checker và app dùng
|
|
chung một đường dựng — kể cả phần ráp kho bí mật."""
|
|
from .presentation.shell.bootstrap import build_config
|
|
return build_config(Path(path))
|
|
|