Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af8a3712e2 | ||
|
|
6c68417103 | ||
|
|
0e00bf3c2f | ||
|
|
70a0c2fdcf | ||
|
|
bc282c71d0 | ||
|
|
72ed3b4147 | ||
|
|
40b12ecb15 | ||
|
|
4c3b097977 | ||
|
|
c77ce36191 | ||
|
|
2246d55286 | ||
|
|
e4ce9b2f5f | ||
|
|
2b90492994 |
@@ -0,0 +1,47 @@
|
||||
"""Application-layer view of an audit event — decoupled from the
|
||||
infrastructure ``CanonicalAuditEvent`` so ``application/`` doesn't need to
|
||||
share a concrete class with ``infrastructure/`` (only the shape). Field names
|
||||
match the canonical audit schema (see
|
||||
``infrastructure/telemetry/audit_logger.py``) 1:1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditEventDTO:
|
||||
ts: str
|
||||
kind: str
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str
|
||||
agent_role: str = ""
|
||||
account: str = ""
|
||||
role: str = ""
|
||||
machine: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, raw: Dict[str, Any]) -> "AuditEventDTO":
|
||||
"""Tolerant of missing keys — accepts both a
|
||||
``CanonicalAuditEvent.to_dict()`` result and any historical raw
|
||||
``.jsonl`` row."""
|
||||
return cls(
|
||||
ts=str(raw.get("ts", "")),
|
||||
kind=str(raw.get("kind", "")),
|
||||
name=str(raw.get("name", "")),
|
||||
ok=bool(raw.get("ok", False)),
|
||||
detail=str(raw.get("detail", "")),
|
||||
agent_role=str(raw.get("agent_role", "")),
|
||||
account=str(raw.get("account", "")),
|
||||
role=str(raw.get("role", "")),
|
||||
machine=str(raw.get("machine", "")),
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"ts": self.ts, "kind": self.kind, "agent_role": self.agent_role,
|
||||
"name": self.name, "ok": self.ok, "detail": self.detail,
|
||||
"account": self.account, "role": self.role, "machine": self.machine,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Read-only query service over audit events — filter + sort + pagination.
|
||||
|
||||
Pure Python: no PySide6 import, no UI code. Depends only on an injected
|
||||
``AuditEventRepository`` (see ``repository/audit_event_repository.py``), so it
|
||||
is fully unit-testable with ``InMemoryAuditEventRepository`` and independent
|
||||
of file I/O or Qt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from .dto.audit_event_dto import AuditEventDTO
|
||||
from .repository.audit_event_repository import AuditEventRepository
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Page:
|
||||
items: List[AuditEventDTO]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
@property
|
||||
def has_more(self) -> bool:
|
||||
return self.page * self.page_size < self.total
|
||||
|
||||
|
||||
class MonitoringQueryService:
|
||||
"""Read-only. Callers ask for a filtered/sorted/paginated slice of the
|
||||
audit log; this service never writes anything."""
|
||||
|
||||
def __init__(self, repository: AuditEventRepository) -> None:
|
||||
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:
|
||||
events = self._repository.load(kind=kind)
|
||||
|
||||
if ok is not None:
|
||||
events = [e for e in events if e.ok == ok]
|
||||
if text:
|
||||
needle = text.lower()
|
||||
events = [e for e in events
|
||||
if needle in e.name.lower() or needle in e.detail.lower()]
|
||||
|
||||
events = sorted(events, key=lambda e: getattr(e, sort_by, ""), reverse=descending)
|
||||
|
||||
total = len(events)
|
||||
page = max(1, page)
|
||||
start = (page - 1) * page_size
|
||||
items = events[start:start + page_size] if page_size > 0 else events
|
||||
return Page(items=items, total=total, page=page, page_size=page_size)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Audit-event repository — the boundary between ``MonitoringQueryService``
|
||||
and where events actually live. ``CanonicalAuditEventRepository`` is the real
|
||||
adapter (wraps an injected ``CanonicalAuditLogger``); ``InMemoryAuditEventRepository``
|
||||
is a constructor-injected test double, following this repo's existing
|
||||
``Fake*``/``Recording*`` convention (see ``tests/routing/*``,
|
||||
``tests/test_project_context_mcp_template.py``) rather than ``unittest.mock``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Protocol
|
||||
|
||||
from ..dto.audit_event_dto import AuditEventDTO
|
||||
|
||||
|
||||
class AuditEventRepository(Protocol):
|
||||
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
|
||||
...
|
||||
|
||||
|
||||
class CanonicalAuditEventRepository:
|
||||
"""Adapter over ``infrastructure.telemetry.audit_logger.CanonicalAuditLogger``
|
||||
— the only place this application service reaches into infrastructure."""
|
||||
|
||||
def __init__(self, audit_logger) -> None:
|
||||
self._audit_logger = audit_logger
|
||||
|
||||
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
|
||||
events = self._audit_logger.load_events(kind=kind)
|
||||
return [AuditEventDTO.from_raw(e.to_dict()) for e in events]
|
||||
|
||||
|
||||
class InMemoryAuditEventRepository:
|
||||
"""Test double — holds a fixed list of events, no file I/O."""
|
||||
|
||||
def __init__(self, events: List[AuditEventDTO]) -> None:
|
||||
self._events = list(events)
|
||||
|
||||
def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]:
|
||||
if kind is None:
|
||||
return list(self._events)
|
||||
return [e for e in self._events if e.kind == kind]
|
||||
@@ -35,6 +35,8 @@ service này.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
@@ -174,10 +176,10 @@ class Co4EWorkflowService:
|
||||
payload = {"runs": [r.to_dict() for r in runs]}
|
||||
try:
|
||||
self._history_path_value.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._history_path_value.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
tmp.replace(self._history_path_value) # atomic — khong bao gio de lai file ghi do dang
|
||||
# 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
|
||||
|
||||
@@ -16,6 +16,8 @@ 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"
|
||||
@@ -343,281 +345,33 @@ def _migrate_connectors(data: Dict[str, Any]) -> None:
|
||||
data["mcp_servers"] = [] # migrated — the UI no longer manages this
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
"""In-memory view of the configuration with load/save helpers."""
|
||||
class AppConfig(JsonConfigRepository):
|
||||
"""Vỏ tương thích — R02 đã thay lớp này bằng :class:`JsonConfigRepository`.
|
||||
|
||||
data: Dict[str, Any] = field(default_factory=lambda: copy.deepcopy(DEFAULT_CONFIG))
|
||||
path: Path = CONFIG_PATH
|
||||
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):
|
||||
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__)
|
||||
|
||||
# ---- persistence -------------------------------------------------
|
||||
@classmethod
|
||||
def load(cls, path: Path = CONFIG_PATH) -> "AppConfig":
|
||||
merged = copy.deepcopy(DEFAULT_CONFIG)
|
||||
if path.exists():
|
||||
try:
|
||||
stored = json.loads(path.read_text(encoding="utf-8"))
|
||||
merged = _deep_merge(merged, stored)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
# Corrupt config should never block startup.
|
||||
merged = copy.deepcopy(DEFAULT_CONFIG)
|
||||
merged = _apply_env_overrides(merged)
|
||||
# "unlocked" is a runtime-only Settings-panel state (see the "ms365"
|
||||
# comment in DEFAULT_CONFIG) — never trust a stored/hand-edited value,
|
||||
# every launch starts locked.
|
||||
merged.setdefault("ms365", {})["unlocked"] = False
|
||||
_migrate_connectors(merged) # office→ms365 + legacy mcp_servers→other
|
||||
return cls(data=merged, path=path)
|
||||
|
||||
def save(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
to_write = self.data
|
||||
if self.data.get("ms365", {}).get("unlocked"):
|
||||
# Defense in depth: even if some caller saves without having gone
|
||||
# through the Settings dialog's own auto-lock-after-save flow, the
|
||||
# unlock state must never reach disk.
|
||||
to_write = copy.deepcopy(self.data)
|
||||
to_write["ms365"]["unlocked"] = False
|
||||
self.path.write_text(
|
||||
json.dumps(to_write, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
# ---- convenience accessors --------------------------------------
|
||||
@property
|
||||
def active_provider(self) -> str:
|
||||
# Migrate configs that still point at a removed provider (e.g. an older
|
||||
# install saved "ollama") to a supported one, so the app never tries to
|
||||
# build an unknown provider.
|
||||
val = self.data.get("active_provider", "openai_compat")
|
||||
return val if val in PROVIDER_LABELS else "openai_compat"
|
||||
|
||||
@active_provider.setter
|
||||
def active_provider(self, value: str) -> None:
|
||||
self.data["active_provider"] = value
|
||||
|
||||
def provider_conf(self, name: str | None = None) -> Dict[str, Any]:
|
||||
name = name or self.active_provider
|
||||
return self.data["providers"].get(name, {})
|
||||
|
||||
@property
|
||||
def ca_bundle(self) -> str:
|
||||
"""Path to a custom CA/certificate PEM file, or '' for normal validation.
|
||||
|
||||
Used as ``requests``' ``verify=`` argument for every outbound HTTPS call
|
||||
— see the "tls_ca_bundle" comment above for when this is needed."""
|
||||
return (self.data.get("tls_ca_bundle") or "").strip()
|
||||
|
||||
@ca_bundle.setter
|
||||
def ca_bundle(self, value: str) -> None:
|
||||
self.data["tls_ca_bundle"] = (value or "").strip()
|
||||
|
||||
# ---- Microsoft 365 connections (Settings-panel lock, see DEFAULT_CONFIG) --
|
||||
@property
|
||||
def ms365(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("ms365", copy.deepcopy(DEFAULT_CONFIG["ms365"]))
|
||||
|
||||
# ---- Login / RBAC / shared cross-machine store (see DEFAULT_CONFIG) ------
|
||||
@property
|
||||
def auth(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("auth", copy.deepcopy(DEFAULT_CONFIG["auth"]))
|
||||
|
||||
@property
|
||||
def shared_dir(self) -> str:
|
||||
return (self.auth.get("shared_dir") or "").strip()
|
||||
|
||||
def ms365_try_unlock(self, code: str) -> bool:
|
||||
"""Unlock the MS365 Settings group for this session if ``code`` matches.
|
||||
|
||||
This is a client-side UI lock (prevents casually toggling a sensitive
|
||||
section), NOT Microsoft authentication — see the DEFAULT_CONFIG
|
||||
comment. Never persisted as unlocked; see ``save()``."""
|
||||
if (code or "") and code == self.ms365.get("unlock_code", ""):
|
||||
self.data["ms365"]["unlocked"] = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def ms365_lock(self) -> None:
|
||||
self.data.setdefault("ms365", {})["unlocked"] = False
|
||||
|
||||
@property
|
||||
def theme(self) -> str:
|
||||
return self.data.get("theme", "dark")
|
||||
|
||||
@theme.setter
|
||||
def theme(self, value: str) -> None:
|
||||
self.data["theme"] = value
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
from .i18n import DEFAULT_LANGUAGE, LANGUAGES
|
||||
val = self.data.get("language", DEFAULT_LANGUAGE)
|
||||
return val if val in LANGUAGES else DEFAULT_LANGUAGE
|
||||
|
||||
@language.setter
|
||||
def language(self, value: str) -> None:
|
||||
self.data["language"] = value
|
||||
|
||||
@property
|
||||
def code(self) -> Dict[str, Any]:
|
||||
return self.data["code"]
|
||||
|
||||
@property
|
||||
def tools_disabled(self) -> list:
|
||||
"""Built-in agent tool names the admin has turned off (Monitoring → Tools)."""
|
||||
return self.data.setdefault("tools", {}).setdefault("disabled", [])
|
||||
|
||||
def set_tool_enabled(self, name: str, enabled: bool) -> None:
|
||||
"""Enable/disable a built-in agent tool by name and persist it."""
|
||||
disabled = set(self.tools_disabled)
|
||||
if enabled:
|
||||
disabled.discard(name)
|
||||
else:
|
||||
disabled.add(name)
|
||||
self.data.setdefault("tools", {})["disabled"] = sorted(disabled)
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def connect_external(self) -> bool:
|
||||
"""Master switch (Monitoring → Tools → Connector): when off, the agent
|
||||
connects to NO external connectors (CAD/CAE/MS365/Other MCP + REST).
|
||||
Defaults ON so existing setups keep working."""
|
||||
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
|
||||
|
||||
def set_connect_external(self, enabled: bool) -> None:
|
||||
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
|
||||
self.save()
|
||||
|
||||
# ---- one-time seeding bookkeeping (built-in skill library / flows) -------
|
||||
@property
|
||||
def seeded_library_skills(self) -> List[str]:
|
||||
"""Slugs of bundled library skills already seeded into the user's Skill
|
||||
Manager — so a user-deleted one is never silently re-seeded."""
|
||||
return list(self.data.setdefault("seeded_library_skills", []))
|
||||
|
||||
@seeded_library_skills.setter
|
||||
def seeded_library_skills(self, slugs) -> None:
|
||||
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
|
||||
|
||||
@property
|
||||
def seeded_builtin_flows(self) -> List[str]:
|
||||
"""Ids of built-in Co4E flows already seeded (same respect-user-deletion
|
||||
rule as seeded_library_skills)."""
|
||||
return list(self.data.setdefault("seeded_builtin_flows", []))
|
||||
|
||||
@seeded_builtin_flows.setter
|
||||
def seeded_builtin_flows(self, ids) -> None:
|
||||
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
|
||||
|
||||
@property
|
||||
def teams(self) -> Dict[str, Any]:
|
||||
return self.data["teams"]
|
||||
|
||||
@property
|
||||
def history(self) -> Dict[str, Any]:
|
||||
return self.data["history"]
|
||||
|
||||
@property
|
||||
def codebase_memory(self) -> Dict[str, Any]:
|
||||
return self.data["codebase_memory"]
|
||||
|
||||
@property
|
||||
def agent_security(self) -> Dict[str, Any]:
|
||||
return self.data["agent_security"]
|
||||
|
||||
@property
|
||||
def mcp_servers(self) -> List[Dict[str, Any]]:
|
||||
return self.data.setdefault("mcp_servers", [])
|
||||
|
||||
@property
|
||||
def ext_connectors(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Unified Connectors (MCP), grouped by category CAD/CAE/MS365/Other —
|
||||
see core/ext_connectors.py for the per-entry shape and CATEGORIES."""
|
||||
d = self.data.setdefault("ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
|
||||
for cat in ("cad", "cae", "ms365", "other"):
|
||||
d.setdefault(cat, [])
|
||||
return d
|
||||
|
||||
@property
|
||||
def cowork(self) -> Dict[str, Any]:
|
||||
return self.data["cowork"]
|
||||
|
||||
@property
|
||||
def routing(self) -> Dict[str, Any]:
|
||||
"""Auto Model Assessment & Routing behaviour config (see DEFAULT_CONFIG).
|
||||
|
||||
Always returns a dict with every expected key present, backfilling any
|
||||
missing sub-keys from the defaults so older configs upgrade seamlessly."""
|
||||
d = self.data.setdefault("routing", copy.deepcopy(DEFAULT_CONFIG["routing"]))
|
||||
for k, v in DEFAULT_CONFIG["routing"].items():
|
||||
d.setdefault(k, copy.deepcopy(v))
|
||||
d.setdefault("surface_modes", {})
|
||||
for surface in ("cowork", "co4e", "ai_edit"):
|
||||
d["surface_modes"].setdefault(surface, "")
|
||||
return d
|
||||
|
||||
# The routing modes a surface may be in. "fallback" joined the set in
|
||||
# R03-T03 (keep the selected model; re-route only when it cannot serve the
|
||||
# turn) — see application/model_routing/routing_models.py::RoutingMode,
|
||||
# which is the authority on what each mode means.
|
||||
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||
|
||||
def routing_mode_for(self, surface: str) -> str:
|
||||
"""Effective Off/Auto/Manual/Fallback mode for a chat surface.
|
||||
|
||||
A per-surface override wins; an empty override falls back to the global
|
||||
``switch_mode``. Anything unrecognised degrades to "off" so routing
|
||||
stays opt-in even with a hand-edited config."""
|
||||
routing = self.routing
|
||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||
mode = override or routing.get("switch_mode", "off")
|
||||
return mode if mode in self.ROUTING_MODES else "off"
|
||||
|
||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||
"""Persist a chat surface's routing toggle selection."""
|
||||
mode = mode if mode in self.ROUTING_MODES else "off"
|
||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||
self.save()
|
||||
|
||||
@property
|
||||
def structure(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
|
||||
|
||||
@property
|
||||
def monitoring_visibility(self) -> Dict[str, bool]:
|
||||
return self.data.setdefault(
|
||||
"monitoring_visibility", copy.deepcopy(DEFAULT_CONFIG["monitoring_visibility"]))
|
||||
|
||||
def cowork_output_dir(self) -> Path:
|
||||
"""Where Cowork saves generated files (OneDrive folder by default)."""
|
||||
custom = (self.cowork.get("output_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
from . import paths # local import avoids any import cycle
|
||||
root = paths.primary_onedrive_root()
|
||||
if root is not None:
|
||||
return root / "CoworkLocal" / "output"
|
||||
return CONFIG_DIR / "output" / "cowork"
|
||||
|
||||
def history_dir(self) -> Path:
|
||||
"""Resolve where conversation history is stored.
|
||||
|
||||
When a project is open, its history is stored INSIDE the project's
|
||||
workspace folder (``_project_history_dir``, set by the Workspace screen)
|
||||
so that sharing/syncing that folder shares the history — another machine
|
||||
opening the same folder sees the conversations and can continue them.
|
||||
Otherwise: Local (default) or OneDrive."""
|
||||
rt = getattr(self, "_project_history_dir", None)
|
||||
if rt:
|
||||
return Path(rt)
|
||||
custom = (self.history.get("custom_dir") or "").strip()
|
||||
if custom:
|
||||
return Path(custom).expanduser()
|
||||
if self.history.get("location") == "onedrive":
|
||||
from . import paths # local import avoids any import cycle
|
||||
root = paths.primary_onedrive_root()
|
||||
if root is not None:
|
||||
return root / "CoworkLocal" / "history"
|
||||
return HISTORY_DIR
|
||||
|
||||
def model_label(self) -> str:
|
||||
return str(self.provider_conf().get("model", "?"))
|
||||
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))
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
"""Root pytest conftest — loaded before ``tests/conftest.py``.
|
||||
|
||||
This checkout lives on disk as ``Refactor`` (not ``cowork_local``), while
|
||||
``tests/`` imports everything as ``from cowork_local... import ...`` and
|
||||
``tests/conftest.py`` makes that resolve by putting this repo's *parent*
|
||||
directory on ``sys.path`` (expecting the repo root itself to be named
|
||||
``cowork_local``). A sibling folder literally named ``cowork_local`` (an
|
||||
unrelated, older checkout) already exists next to this one, so without this
|
||||
file Python would silently import THAT folder instead of this repository
|
||||
whenever a test does ``import cowork_local``.
|
||||
|
||||
Registering the alias here — before ``tests/conftest.py`` touches
|
||||
``sys.path`` — caches this repository in ``sys.modules['cowork_local']``
|
||||
first, so the later ``sys.path`` mutation has nothing left to do (imports
|
||||
are cached by name; the first successful import of a given name wins for
|
||||
the rest of the process).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent
|
||||
|
||||
if "cowork_local" not in sys.modules:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"cowork_local", _ROOT / "__init__.py", submodule_search_locations=[str(_ROOT)],
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules["cowork_local"] = module
|
||||
spec.loader.exec_module(module)
|
||||
+2
-19
@@ -27,27 +27,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from ..providers.base import Provider
|
||||
from . import security_rules
|
||||
|
||||
|
||||
class SecurityBlocked(RuntimeError):
|
||||
"""A guardrail refused an action. ``verdict`` carries the full detail for
|
||||
the admin alert; ``str(exc)`` is the short, user-facing reason."""
|
||||
|
||||
def __init__(self, verdict: "SecurityVerdict"):
|
||||
super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).")
|
||||
self.verdict = verdict
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityVerdict:
|
||||
allowed: bool
|
||||
reason: str = ""
|
||||
layer: str = "" # "prompt" | "attachment" | "command"
|
||||
from .agent_security_alert import notify_admin
|
||||
from .agent_security_types import SecurityBlocked, SecurityVerdict
|
||||
|
||||
|
||||
def combined_rules_text(config, max_chars: int = 8000, agent_kind: str = "cowork") -> str:
|
||||
@@ -236,7 +221,6 @@ def enforce_prompt(provider: Provider, messages: List[dict], config, emit,
|
||||
emit({"type": "notice", "level": "warning",
|
||||
"text": f"🛡 Yêu cầu bị chặn bởi Agent Security: {verdict.reason}"})
|
||||
from . import audit_log
|
||||
from .agent_security_alert import notify_admin
|
||||
|
||||
audit_log.record("security_block", "prompt", False, verdict.reason)
|
||||
notify_admin(config, verdict, detail=user_text[:1000])
|
||||
@@ -266,7 +250,6 @@ def enforce_command(provider: Provider, name: str, args: dict, config, emit,
|
||||
emit({"type": "notice", "level": "warning",
|
||||
"text": f"🛡 Lệnh bị chặn bởi Agent Security ({verdict.layer}): {verdict.reason}"})
|
||||
from . import audit_log
|
||||
from .agent_security_alert import notify_admin
|
||||
|
||||
audit_log.record("security_block", name, False, f"{verdict.layer}: {verdict.reason}")
|
||||
notify_admin(config, verdict, detail=command)
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
from typing import Tuple
|
||||
|
||||
from . import ms365_graph
|
||||
from .agent_security import SecurityVerdict
|
||||
from .agent_security_types import SecurityVerdict
|
||||
from .ms365_auth import Ms365AuthError, get_access_token
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Shared value types for the Agent Security guardrails.
|
||||
|
||||
``SecurityVerdict``/``SecurityBlocked`` used to be defined in
|
||||
``agent_security.py``, which forced ``agent_security_alert.py`` (which only
|
||||
needs the *type*, to annotate/read ``notify_admin``'s ``verdict`` argument) to
|
||||
import from it — while ``agent_security.py`` itself needed to call
|
||||
``agent_security_alert.notify_admin()``, an architectural cycle only avoided
|
||||
at runtime by deferring that second import inside a function body.
|
||||
|
||||
Hoisting the shared type into this dependency-free leaf module lets both
|
||||
sides import it directly, so ``agent_security.py`` can import
|
||||
``agent_security_alert`` at module top level too — no cycle, no deferred
|
||||
imports needed for this pair.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityVerdict:
|
||||
allowed: bool
|
||||
reason: str = ""
|
||||
layer: str = "" # "prompt" | "attachment" | "command"
|
||||
|
||||
|
||||
class SecurityBlocked(RuntimeError):
|
||||
"""A guardrail refused an action. ``verdict`` carries the full detail for
|
||||
the admin alert; ``str(exc)`` is the short, user-facing reason."""
|
||||
|
||||
def __init__(self, verdict: SecurityVerdict):
|
||||
super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).")
|
||||
self.verdict = verdict
|
||||
+15
-72
@@ -6,15 +6,23 @@ storage systems).
|
||||
One JSON line per event, one file per day under ``~/.cowork_local/audit/`` —
|
||||
same on-disk shape as ``usage_tracker.py`` (day-sharded ``.jsonl``, append-only,
|
||||
``record()`` never raises so audit logging can never break a chat turn).
|
||||
|
||||
This module is now a thin, backward-compatible wrapper around
|
||||
:class:`infrastructure.telemetry.audit_logger.CanonicalAuditLogger` — every
|
||||
existing call site (``agent_security.py``, ``chat_agent.py``, ``tools.py``,
|
||||
``ext_connectors.py``, ``mcp_client.py``, ``ms365_local.py``,
|
||||
``permissions.py``, ``ui/structure_graph_view.py``, ``app.py``) keeps calling
|
||||
``audit_log.set_identity``/``record``/``load_events`` exactly as before; only
|
||||
the implementation moved.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..config import CONFIG_DIR
|
||||
from ..infrastructure.telemetry.audit_logger import CanonicalAuditLogger
|
||||
|
||||
AUDIT_DIR = CONFIG_DIR / "audit"
|
||||
|
||||
@@ -23,66 +31,21 @@ AUDIT_DIR = CONFIG_DIR / "audit"
|
||||
# action), "mcp_call" (a call to an external MCP server's tool).
|
||||
Kind = str
|
||||
|
||||
# Process-global identity — who's logged in, their role, and this machine's
|
||||
# name — set once right after login (app.py::run()), mirroring
|
||||
# usage_tracker.py's identical pattern. NOT thread-local: fixed per process.
|
||||
_identity_account = ""
|
||||
_identity_role = ""
|
||||
_identity_machine = ""
|
||||
_identity_shared_dir = ""
|
||||
_logger = CanonicalAuditLogger(AUDIT_DIR)
|
||||
|
||||
|
||||
def set_identity(account: str, machine: str, role: str = "", shared_dir: str = "") -> None:
|
||||
"""Called once after login succeeds. ``shared_dir``, when reachable,
|
||||
makes every subsequent :func:`record` ALSO best-effort-append to the
|
||||
shared cross-machine telemetry store (see :mod:`telemetry_shared`)."""
|
||||
global _identity_account, _identity_role, _identity_machine, _identity_shared_dir
|
||||
_identity_account = account or ""
|
||||
_identity_role = role or ""
|
||||
_identity_machine = machine or ""
|
||||
_identity_shared_dir = shared_dir or ""
|
||||
_logger.set_identity(account, machine, role=role, shared_dir=shared_dir)
|
||||
|
||||
|
||||
def record(kind: Kind, name: str, ok: bool, detail: str = "",
|
||||
agent_role: str = "") -> None:
|
||||
"""Append one audit event. Never raises — audit logging must never break
|
||||
a chat turn, a permission decision, or a tool call."""
|
||||
try:
|
||||
now = datetime.now()
|
||||
event = {
|
||||
"ts": now.isoformat(timespec="seconds"),
|
||||
"kind": kind,
|
||||
"agent_role": agent_role or "",
|
||||
"name": name or "",
|
||||
"ok": bool(ok),
|
||||
"detail": (detail or "")[:2000], # bounded — never let a huge blob bloat the log
|
||||
"account": _identity_account,
|
||||
"role": _identity_role,
|
||||
"machine": _identity_machine,
|
||||
}
|
||||
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = AUDIT_DIR / f"{now.strftime('%Y-%m-%d')}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||||
_write_shared(event, now)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _write_shared(event: Dict[str, Any], now: datetime) -> None:
|
||||
"""Best-effort mirror of ``event`` into the shared cross-machine store —
|
||||
one file PER MACHINE per day, so no two machines ever write the same
|
||||
file. Never raises."""
|
||||
if not _identity_shared_dir or not _identity_machine:
|
||||
return
|
||||
try:
|
||||
shared = Path(_identity_shared_dir).expanduser() / "telemetry" / "audit"
|
||||
shared.mkdir(parents=True, exist_ok=True)
|
||||
path = shared / f"{_identity_machine}-{now.strftime('%Y-%m-%d')}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
_logger.record(kind, name, ok, detail=detail, agent_role=agent_role)
|
||||
|
||||
|
||||
def load_events(start: Optional[date] = None, end: Optional[date] = None,
|
||||
@@ -91,25 +54,5 @@ def load_events(start: Optional[date] = None, end: Optional[date] = None,
|
||||
"""Events between ``start``/``end`` (inclusive; None = unbounded),
|
||||
optionally filtered to one ``kind`` — this IS how each Monitoring
|
||||
Dashboard panel gets its own slice of the same underlying log."""
|
||||
directory = directory or AUDIT_DIR
|
||||
if not directory.exists():
|
||||
return []
|
||||
events: List[Dict[str, Any]] = []
|
||||
for path in sorted(directory.glob("*.jsonl")):
|
||||
try:
|
||||
day = datetime.strptime(path.stem, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
if (start and day < start) or (end and day > end):
|
||||
continue
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
event = json.loads(line)
|
||||
if kind is not None and event.get("kind") != kind:
|
||||
continue
|
||||
events.append(event)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
return events
|
||||
events = _logger.load_events(start=start, end=end, kind=kind, directory=directory)
|
||||
return [e.to_dict() for e in events]
|
||||
|
||||
+6
-2
@@ -18,6 +18,8 @@ existing ``core/skills.py`` registry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -233,7 +235,9 @@ def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path:
|
||||
directory = directory or WORKFLOWS_DIR
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{wf.id}.json"
|
||||
path.write_text(json.dumps(workflow_to_dict(wf), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
# Tiêu chí nghiệm thu A: mọi thao tác ghi tệp đi qua AtomicJsonFile. Trước
|
||||
# đây ghi thẳng, nên tắt máy giữa lúc lưu là mất luôn workflow.
|
||||
AtomicJsonFile(path).write(workflow_to_dict(wf))
|
||||
return path
|
||||
|
||||
|
||||
@@ -307,7 +311,7 @@ def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> P
|
||||
directory = directory or AGENTS_DIR
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
path = directory / f"{agent.id}.json"
|
||||
path.write_text(json.dumps(agent_to_dict(agent), ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
AtomicJsonFile(path).write(agent_to_dict(agent))
|
||||
return path
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ the run that is currently open.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
@@ -152,10 +154,10 @@ class Co4ERunManager(QObject):
|
||||
payload = {"runs": [h.to_record() for h in runs]}
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
tmp.replace(path) # atomic — never leaves a half-written file
|
||||
# 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(path).write(payload)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
+13
-3
@@ -32,6 +32,14 @@ _SYMBOL_CCY = {"₫": "VND", "vnd": "VND", "đ": "VND",
|
||||
|
||||
_DEFAULT_UNIT = "Million tokens"
|
||||
|
||||
# Flat USD/1M-token fallback rates used by turn_cost_usd() when a model isn't
|
||||
# in the price table. Owned here (not usage_tracker.DEFAULT_PRICING) so this
|
||||
# module never needs to import usage_tracker — usage_tracker imports this
|
||||
# module instead, keeping the dependency one-directional. Values match
|
||||
# usage_tracker.DEFAULT_PRICING's price_per_mtok_in_usd/out_usd exactly.
|
||||
_FALLBACK_RATE_IN_USD = 0.5
|
||||
_FALLBACK_RATE_OUT_USD = 1.5
|
||||
|
||||
|
||||
# ---- currency ------------------------------------------------------------
|
||||
def _rates(config) -> Dict[str, float]:
|
||||
@@ -138,9 +146,11 @@ def turn_cost_usd(model: str, in_tok: int, out_tok: int, config) -> float:
|
||||
switches models (a different model → its own row / rates)."""
|
||||
rates = usd_rates_for(model, config)
|
||||
if rates is None:
|
||||
from . import usage_tracker as ut
|
||||
p = {**ut.DEFAULT_PRICING, **((getattr(config, "data", {}) or {}).get("usage") or {})}
|
||||
rates = {"in": float(p["price_per_mtok_in_usd"]), "out": float(p["price_per_mtok_out_usd"])}
|
||||
usage = (getattr(config, "data", {}) or {}).get("usage") or {}
|
||||
rates = {
|
||||
"in": float(usage.get("price_per_mtok_in_usd", _FALLBACK_RATE_IN_USD)),
|
||||
"out": float(usage.get("price_per_mtok_out_usd", _FALLBACK_RATE_OUT_USD)),
|
||||
}
|
||||
return (in_tok or 0) / 1e6 * rates["in"] + (out_tok or 0) / 1e6 * rates["out"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Dựng câu nhắc cho AI phân tích mức dùng — R09-T02.
|
||||
|
||||
Chỉ sinh văn bản. Tách riêng vì đây là phần dễ đổi nhất (câu chữ, cột hiển
|
||||
thị) và không liên quan tới việc ghi nhận hay tính tiền.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..config import CONFIG_DIR
|
||||
from . import model_pricing as mp
|
||||
from .usage_periods import period_breakdown, period_range_label
|
||||
|
||||
_AI_ANALYSIS_HEADERS = {
|
||||
"vi": ("Nhận xét thói quen", "Cách viết prompt tiết kiệm hơn", "Hành động giảm token"),
|
||||
"en": ("Usage habits", "Writing more efficient prompts", "Actions to cut token usage"),
|
||||
"ja": ("利用傾向", "より効率的なプロンプトの書き方", "トークン削減のためのアクション"),
|
||||
}
|
||||
|
||||
def build_ai_analysis_prompt(summary: Dict[str, Any], language: str = "vi") -> str:
|
||||
"""The prompt sent to the model for '✨ AI analyze my usage': aggregated
|
||||
numbers only — never raw prompt contents — asking for concrete habits
|
||||
feedback and token-saving recommendations, in the CURRENTLY SELECTED
|
||||
display language (headers included — not just the model's free-text reply,
|
||||
which would otherwise leave the section titles in Vietnamese regardless of
|
||||
the app's language setting)."""
|
||||
lang_names = {"vi": "Vietnamese", "ja": "Japanese", "en": "English"}
|
||||
h1, h2, h3 = _AI_ANALYSIS_HEADERS.get(language, _AI_ANALYSIS_HEADERS["vi"])
|
||||
top = "\n".join(f"- {label}: {tok:,} tokens"
|
||||
for label, tok in summary.get("top_labels", []))
|
||||
by_source = ", ".join(f"{k}={v:,}" for k, v in summary.get("by_source", []))
|
||||
return (
|
||||
"You are a token-efficiency coach for an AI desktop app (chat tabs + "
|
||||
"scheduled agent tasks). Analyze this usage summary and give the user "
|
||||
"practical advice, replying in "
|
||||
f"{lang_names.get(language, 'Vietnamese')}.\n\n"
|
||||
f"Period stats: {summary.get('turns', 0)} turns, "
|
||||
f"input={summary.get('in', 0):,} tokens, output={summary.get('out', 0):,}, "
|
||||
f"cache={summary.get('cache', 0):,}, "
|
||||
f"avg per prompt={summary.get('avg_per_turn', 0):,}.\n"
|
||||
f"Top consumers:\n{top or '- (none)'}\n"
|
||||
f"By area: {by_source or '(none)'}\n"
|
||||
f"Busiest day: {summary.get('busiest_day')} · busiest hour: {summary.get('busiest_hour')}\n\n"
|
||||
"Reply with EXACTLY these 3 short sections, in markdown, using THESE "
|
||||
f"section headers verbatim (already in {lang_names.get(language, 'Vietnamese')}):\n"
|
||||
f"1. **{h1}** — 2-3 bullet points about the usage pattern.\n"
|
||||
f"2. **{h2}** — 3 concrete prompt-writing tips "
|
||||
"tailored to the numbers above (e.g. long inputs → attach less / summarize "
|
||||
"first; many small turns → batch questions).\n"
|
||||
f"3. **{h3}** — 2-3 app-level actions (compact history, "
|
||||
"smaller model for simple tasks, reuse task outputs instead of re-asking).\n"
|
||||
"Keep the whole reply under 250 words."
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Bảng giá và quy đổi token thành tiền — R09-T02.
|
||||
|
||||
Tách khỏi ``usage_tracker.py``: ghi nhận mức dùng và tính tiền là hai việc
|
||||
khác nhau. Bảng giá đổi theo nhà cung cấp, cách ghi nhận thì không.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..config import CONFIG_DIR
|
||||
from . import model_pricing as mp
|
||||
|
||||
DEFAULT_PRICING = {
|
||||
"price_per_mtok_in_usd": 0.5, # USD per 1M input tokens (flat fallback rate)
|
||||
"price_per_mtok_out_usd": 1.5, # USD per 1M output tokens
|
||||
"price_per_mtok_cache_usd": 0.1, # USD per 1M cached tokens
|
||||
"currency": "USD", # display currency: USD | VND | JPY
|
||||
"usd_to_vnd": 25000.0,
|
||||
"usd_to_jpy": 150.0,
|
||||
# Per-model price table (USD / 1M tokens): {model: {"in","out","cache"}}.
|
||||
# Events whose model has an entry are costed with ITS rates; everything
|
||||
# else falls back to the flat price_per_mtok_* rates above. Edited in the
|
||||
# Monitoring Overview's pricing table.
|
||||
"model_prices": {},
|
||||
# Reference URL of the price list the table was filled from (set in
|
||||
# Settings; shown as a link beside the table — informational only, the
|
||||
# app never scrapes it).
|
||||
"pricing_url": "",
|
||||
}
|
||||
|
||||
_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]:
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
return {
|
||||
"in": summary.get("in", 0) / 1e6 * float(p["price_per_mtok_in_usd"]),
|
||||
"out": summary.get("out", 0) / 1e6 * float(p["price_per_mtok_out_usd"]),
|
||||
"cache": summary.get("cache", 0) / 1e6 * float(p["price_per_mtok_cache_usd"]),
|
||||
}
|
||||
|
||||
def cost_usd_events(events: List[Dict[str, Any]], pricing: Dict[str, Any]) -> Dict[str, float]:
|
||||
"""Per-bucket USD cost computed EVENT BY EVENT so the per-model price
|
||||
table applies: an event whose ``model`` has an entry in
|
||||
``pricing["model_prices"]`` is costed with that model's own rates; any
|
||||
other event uses the flat ``price_per_mtok_*`` rates. With an empty
|
||||
table this equals ``cost_usd(summarize(events), pricing)`` exactly."""
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
table = p.get("model_prices") or {}
|
||||
flat = {"in": float(p["price_per_mtok_in_usd"]),
|
||||
"out": float(p["price_per_mtok_out_usd"]),
|
||||
"cache": float(p["price_per_mtok_cache_usd"])}
|
||||
out = {"in": 0.0, "out": 0.0, "cache": 0.0}
|
||||
for e in events:
|
||||
rates = table.get(e.get("model", "")) or {}
|
||||
for bucket in ("in", "out", "cache"):
|
||||
try:
|
||||
rate = float(rates.get(bucket, flat[bucket]))
|
||||
except (TypeError, ValueError):
|
||||
rate = flat[bucket]
|
||||
out[bucket] += e.get(bucket, 0) / 1e6 * rate
|
||||
return out
|
||||
|
||||
def format_cost(usd: float, pricing: Dict[str, Any], digits: Optional[int] = None) -> str:
|
||||
"""Format a USD amount in the display currency. ``digits`` caps the number
|
||||
of decimal places (e.g. ``digits=2`` for the Total cost / Budget cards, so
|
||||
USD shows $1.23 not the default up-to-4 $1.2345) — never ADDS decimals to a
|
||||
currency that uses fewer (VND stays whole, JPY one place)."""
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
cur = p.get("currency", "USD")
|
||||
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
|
||||
symbol, cur_digits = _CURRENCY_FMT.get(cur, ("$", 2))
|
||||
if digits is not None:
|
||||
cur_digits = min(cur_digits, digits)
|
||||
value = usd * rate
|
||||
return f"{symbol}{value:,.{cur_digits}f}"
|
||||
|
||||
def format_cost_compact(usd: float, pricing: Dict[str, Any]) -> str:
|
||||
"""Compact cost format for the Dashboard chart's y-axis/endpoint labels —
|
||||
always 2 decimals (not format_cost's up-to-4 for USD) and abbreviated with
|
||||
K/M above 1,000/1,000,000, same convention as ``fmt_tokens``. The chart's
|
||||
y-axis label box is narrow; the longer full-precision string used to
|
||||
overflow it, visually clipping/obscuring the leading currency symbol."""
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
cur = p.get("currency", "USD")
|
||||
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
|
||||
symbol, _digits = _CURRENCY_FMT.get(cur, ("$", 2))
|
||||
value = usd * rate
|
||||
sign = "-" if value < 0 else ""
|
||||
value = abs(value)
|
||||
if value >= 1_000_000:
|
||||
body = f"{value / 1_000_000:,.2f}M"
|
||||
elif value >= 1_000:
|
||||
body = f"{value / 1_000:,.2f}K"
|
||||
else:
|
||||
body = f"{value:,.2f}"
|
||||
return f"{sign}{symbol}{body}"
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Gộp mức dùng theo khoảng thời gian — R09-T02.
|
||||
|
||||
Ngày / tuần / tháng / quý: ranh giới khoảng, nhãn hiển thị, chuỗi số vẽ biểu
|
||||
đồ. Thuần tính toán trên danh sách sự kiện, không đụng đĩa.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from ..config import CONFIG_DIR
|
||||
from . import model_pricing as mp
|
||||
from .usage_cost import cost_usd_events
|
||||
|
||||
def bucketed_series(events: List[Dict[str, Any]], granularity: str = "day",
|
||||
pricing: Dict[str, Any] = None, last: int = None) -> List[tuple]:
|
||||
"""Group usage events into time buckets → ordered ``[(label, tokens, cost_usd)]``.
|
||||
|
||||
``granularity``: ``day`` (YYYY-MM-DD) · ``month`` (YYYY-MM) · ``year`` (YYYY).
|
||||
``last`` keeps only the most recent N buckets (for the dashboard chart)."""
|
||||
from collections import OrderedDict
|
||||
pricing = pricing or {}
|
||||
|
||||
def _key(ts: Any) -> str:
|
||||
s = str(ts or "")[:10]
|
||||
if granularity == "year":
|
||||
return s[:4]
|
||||
if granularity == "month":
|
||||
return s[:7]
|
||||
return s
|
||||
|
||||
buckets: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
|
||||
for e in sorted(events, key=lambda ev: str(ev.get("ts", ""))):
|
||||
k = _key(e.get("ts"))
|
||||
if k:
|
||||
buckets.setdefault(k, []).append(e)
|
||||
out = []
|
||||
for k, evs in buckets.items():
|
||||
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
|
||||
+ int(e.get("cache", 0) or 0) for e in evs)
|
||||
cost = sum(cost_usd_events(evs, pricing).values())
|
||||
out.append((k, tokens, cost))
|
||||
if last and len(out) > last:
|
||||
out = out[-last:]
|
||||
return out
|
||||
|
||||
def period_bounds(gran: str, offset: int, today: Optional[date] = None) -> tuple:
|
||||
"""[start, end) dates of the period ``offset`` periods from the current one
|
||||
(0 = current, -1 = the previous week/month/year). Weeks run Mon→Sun."""
|
||||
from datetime import timedelta
|
||||
today = today or date.today()
|
||||
if gran == "week":
|
||||
monday = today - timedelta(days=today.weekday()) # Monday of this week
|
||||
start = monday + timedelta(weeks=offset)
|
||||
return start, start + timedelta(days=7)
|
||||
if gran == "year":
|
||||
y = today.year + offset
|
||||
return date(y, 1, 1), date(y + 1, 1, 1)
|
||||
# month (default)
|
||||
base = today.year * 12 + (today.month - 1) + offset
|
||||
y, m = divmod(base, 12)
|
||||
y2, m2 = divmod(base + 1, 12)
|
||||
return date(y, m + 1, 1), date(y2, m2 + 1, 1)
|
||||
|
||||
def _period_label(gran: str, start: date) -> str:
|
||||
if gran == "week":
|
||||
return start.isoformat() # the week's Monday (YYYY-MM-DD)
|
||||
if gran == "year":
|
||||
return str(start.year)
|
||||
return start.strftime("%Y-%m")
|
||||
|
||||
def _sum_between(events: List[Dict[str, Any]], start: date, end: date,
|
||||
pricing: Dict[str, Any]) -> tuple:
|
||||
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)
|
||||
+ int(e.get("cache", 0) or 0) for e in evs)
|
||||
cost = sum(cost_usd_events(evs, pricing).values()) if evs else 0.0
|
||||
return tokens, cost
|
||||
|
||||
def period_totals(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||||
offset: int = 0, today: Optional[date] = None) -> tuple:
|
||||
"""(tokens, cost_usd) for the single period ``offset`` periods from now."""
|
||||
start, end = period_bounds(gran, offset, today)
|
||||
return _sum_between(events, start, end, pricing)
|
||||
|
||||
def period_window(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||||
count: int, offset: int = 0, today: Optional[date] = None) -> List[tuple]:
|
||||
"""``count`` consecutive, ZERO-FILLED periods ending at (current + offset),
|
||||
ordered oldest→newest → ``[(label, tokens, cost_usd)]``. ``offset`` (≤ 0)
|
||||
pages the window into the past for the Dashboard's prev/next navigation."""
|
||||
out = []
|
||||
for i in range(count - 1, -1, -1):
|
||||
start, end = period_bounds(gran, offset - i, today)
|
||||
tok, cost = _sum_between(events, start, end, pricing)
|
||||
out.append((_period_label(gran, start), tok, cost))
|
||||
return out
|
||||
|
||||
def period_breakdown(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||||
offset: int = 0, today: Optional[date] = None) -> List[tuple]:
|
||||
"""Break the SELECTED period (``offset`` periods from now) into its sub-parts
|
||||
→ ``[(label, tokens, cost_usd)]``:
|
||||
· week → 7 days Mon→Sun (label ``MM/DD``)
|
||||
· month → weeks W1…Wn (7-day chunks from the 1st)
|
||||
· year → 12 months (label ``01``…``12``)."""
|
||||
from datetime import timedelta
|
||||
start, end = period_bounds(gran, offset, today)
|
||||
out = []
|
||||
if gran == "week":
|
||||
for i in range(7):
|
||||
d = start + timedelta(days=i)
|
||||
tok, cost = _sum_between(events, d, d + timedelta(days=1), pricing)
|
||||
out.append((d.strftime("%m/%d"), tok, cost))
|
||||
elif gran == "year":
|
||||
for m in range(1, 13):
|
||||
ms = date(start.year, m, 1)
|
||||
me = date(start.year + 1, 1, 1) if m == 12 else date(start.year, m + 1, 1)
|
||||
tok, cost = _sum_between(events, ms, me, pricing)
|
||||
out.append((f"{m:02d}", tok, cost))
|
||||
else: # month → weeks W1..Wn
|
||||
ndays = (end - start).days
|
||||
wk, day = 1, 1
|
||||
while day <= ndays:
|
||||
ws = date(start.year, start.month, day)
|
||||
we = date(start.year, start.month, day + 7) if day + 7 <= ndays else end
|
||||
tok, cost = _sum_between(events, ws, we, pricing)
|
||||
out.append((f"W{wk}", tok, cost))
|
||||
wk += 1
|
||||
day += 7
|
||||
return out
|
||||
|
||||
def period_range_label(gran: str, offset: int, today: Optional[date] = None) -> str:
|
||||
"""Human label for the selected period (shown in the Dashboard header) —
|
||||
week → MM/DD – MM/DD, month → YYYY/MM, year → YYYY."""
|
||||
from datetime import timedelta
|
||||
start, end = period_bounds(gran, offset, today)
|
||||
if gran == "week":
|
||||
last_day = end - timedelta(days=1)
|
||||
return f"{start.strftime('%m/%d')} – {last_day.strftime('%m/%d')}"
|
||||
if gran == "year":
|
||||
return str(start.year)
|
||||
return start.strftime("%Y/%m")
|
||||
+12
-241
@@ -12,6 +12,17 @@ The turn's source/label is set by the caller ON THE WORKER THREAD via
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Giữ đường vào cũ: nhiều nơi import mấy tên này thẳng từ usage_tracker.
|
||||
from .usage_ai_report import build_ai_analysis_prompt # noqa: F401
|
||||
from .usage_cost import ( # noqa: F401
|
||||
DEFAULT_PRICING, SUPPORTED_CURRENCIES, cost_usd, cost_usd_events,
|
||||
format_cost, format_cost_compact,
|
||||
)
|
||||
from .usage_periods import ( # noqa: F401
|
||||
bucketed_series, period_bounds, period_breakdown, period_range_label,
|
||||
period_totals, period_window,
|
||||
)
|
||||
|
||||
import json
|
||||
import threading
|
||||
from datetime import date, datetime
|
||||
@@ -19,6 +30,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..config import CONFIG_DIR
|
||||
from . import model_pricing as mp
|
||||
|
||||
USAGE_DIR = CONFIG_DIR / "usage"
|
||||
|
||||
@@ -203,198 +215,30 @@ def summarize(events: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
|
||||
|
||||
# ---- cost ------------------------------------------------------------------
|
||||
DEFAULT_PRICING = {
|
||||
"price_per_mtok_in_usd": 0.5, # USD per 1M input tokens (flat fallback rate)
|
||||
"price_per_mtok_out_usd": 1.5, # USD per 1M output tokens
|
||||
"price_per_mtok_cache_usd": 0.1, # USD per 1M cached tokens
|
||||
"currency": "USD", # display currency: USD | VND | JPY
|
||||
"usd_to_vnd": 25000.0,
|
||||
"usd_to_jpy": 150.0,
|
||||
# Per-model price table (USD / 1M tokens): {model: {"in","out","cache"}}.
|
||||
# Events whose model has an entry are costed with ITS rates; everything
|
||||
# else falls back to the flat price_per_mtok_* rates above. Edited in the
|
||||
# Monitoring Overview's pricing table.
|
||||
"model_prices": {},
|
||||
# Reference URL of the price list the table was filled from (set in
|
||||
# Settings; shown as a link beside the table — informational only, the
|
||||
# app never scrapes it).
|
||||
"pricing_url": "",
|
||||
}
|
||||
|
||||
_CURRENCY_FMT = {"USD": ("$", 4), "VND": ("₫", 0), "JPY": ("¥", 1)}
|
||||
|
||||
# Currencies the display picker offers — exactly the ones format_cost() can
|
||||
# actually convert to (symbol/precision above + a usd_to_* rate below).
|
||||
SUPPORTED_CURRENCIES = tuple(_CURRENCY_FMT)
|
||||
|
||||
|
||||
def cost_usd(summary: Dict[str, Any], pricing: Dict[str, Any]) -> Dict[str, float]:
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
return {
|
||||
"in": summary.get("in", 0) / 1e6 * float(p["price_per_mtok_in_usd"]),
|
||||
"out": summary.get("out", 0) / 1e6 * float(p["price_per_mtok_out_usd"]),
|
||||
"cache": summary.get("cache", 0) / 1e6 * float(p["price_per_mtok_cache_usd"]),
|
||||
}
|
||||
|
||||
|
||||
def cost_usd_events(events: List[Dict[str, Any]], pricing: Dict[str, Any]) -> Dict[str, float]:
|
||||
"""Per-bucket USD cost computed EVENT BY EVENT so the per-model price
|
||||
table applies: an event whose ``model`` has an entry in
|
||||
``pricing["model_prices"]`` is costed with that model's own rates; any
|
||||
other event uses the flat ``price_per_mtok_*`` rates. With an empty
|
||||
table this equals ``cost_usd(summarize(events), pricing)`` exactly."""
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
table = p.get("model_prices") or {}
|
||||
flat = {"in": float(p["price_per_mtok_in_usd"]),
|
||||
"out": float(p["price_per_mtok_out_usd"]),
|
||||
"cache": float(p["price_per_mtok_cache_usd"])}
|
||||
out = {"in": 0.0, "out": 0.0, "cache": 0.0}
|
||||
for e in events:
|
||||
rates = table.get(e.get("model", "")) or {}
|
||||
for bucket in ("in", "out", "cache"):
|
||||
try:
|
||||
rate = float(rates.get(bucket, flat[bucket]))
|
||||
except (TypeError, ValueError):
|
||||
rate = flat[bucket]
|
||||
out[bucket] += e.get(bucket, 0) / 1e6 * rate
|
||||
return out
|
||||
|
||||
|
||||
def bucketed_series(events: List[Dict[str, Any]], granularity: str = "day",
|
||||
pricing: Dict[str, Any] = None, last: int = None) -> List[tuple]:
|
||||
"""Group usage events into time buckets → ordered ``[(label, tokens, cost_usd)]``.
|
||||
|
||||
``granularity``: ``day`` (YYYY-MM-DD) · ``month`` (YYYY-MM) · ``year`` (YYYY).
|
||||
``last`` keeps only the most recent N buckets (for the dashboard chart)."""
|
||||
from collections import OrderedDict
|
||||
pricing = pricing or {}
|
||||
|
||||
def _key(ts: Any) -> str:
|
||||
s = str(ts or "")[:10]
|
||||
if granularity == "year":
|
||||
return s[:4]
|
||||
if granularity == "month":
|
||||
return s[:7]
|
||||
return s
|
||||
|
||||
buckets: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
|
||||
for e in sorted(events, key=lambda ev: str(ev.get("ts", ""))):
|
||||
k = _key(e.get("ts"))
|
||||
if k:
|
||||
buckets.setdefault(k, []).append(e)
|
||||
out = []
|
||||
for k, evs in buckets.items():
|
||||
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
|
||||
+ int(e.get("cache", 0) or 0) for e in evs)
|
||||
cost = sum(cost_usd_events(evs, pricing).values())
|
||||
out.append((k, tokens, cost))
|
||||
if last and len(out) > last:
|
||||
out = out[-last:]
|
||||
return out
|
||||
|
||||
|
||||
def period_bounds(gran: str, offset: int, today: Optional[date] = None) -> tuple:
|
||||
"""[start, end) dates of the period ``offset`` periods from the current one
|
||||
(0 = current, -1 = the previous week/month/year). Weeks run Mon→Sun."""
|
||||
from datetime import timedelta
|
||||
today = today or date.today()
|
||||
if gran == "week":
|
||||
monday = today - timedelta(days=today.weekday()) # Monday of this week
|
||||
start = monday + timedelta(weeks=offset)
|
||||
return start, start + timedelta(days=7)
|
||||
if gran == "year":
|
||||
y = today.year + offset
|
||||
return date(y, 1, 1), date(y + 1, 1, 1)
|
||||
# month (default)
|
||||
base = today.year * 12 + (today.month - 1) + offset
|
||||
y, m = divmod(base, 12)
|
||||
y2, m2 = divmod(base + 1, 12)
|
||||
return date(y, m + 1, 1), date(y2, m2 + 1, 1)
|
||||
|
||||
|
||||
def _period_label(gran: str, start: date) -> str:
|
||||
if gran == "week":
|
||||
return start.isoformat() # the week's Monday (YYYY-MM-DD)
|
||||
if gran == "year":
|
||||
return str(start.year)
|
||||
return start.strftime("%Y-%m")
|
||||
|
||||
|
||||
def _sum_between(events: List[Dict[str, Any]], start: date, end: date,
|
||||
pricing: Dict[str, Any]) -> tuple:
|
||||
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)
|
||||
+ int(e.get("cache", 0) or 0) for e in evs)
|
||||
cost = sum(cost_usd_events(evs, pricing).values()) if evs else 0.0
|
||||
return tokens, cost
|
||||
|
||||
|
||||
def period_totals(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||||
offset: int = 0, today: Optional[date] = None) -> tuple:
|
||||
"""(tokens, cost_usd) for the single period ``offset`` periods from now."""
|
||||
start, end = period_bounds(gran, offset, today)
|
||||
return _sum_between(events, start, end, pricing)
|
||||
|
||||
|
||||
def period_window(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||||
count: int, offset: int = 0, today: Optional[date] = None) -> List[tuple]:
|
||||
"""``count`` consecutive, ZERO-FILLED periods ending at (current + offset),
|
||||
ordered oldest→newest → ``[(label, tokens, cost_usd)]``. ``offset`` (≤ 0)
|
||||
pages the window into the past for the Dashboard's prev/next navigation."""
|
||||
out = []
|
||||
for i in range(count - 1, -1, -1):
|
||||
start, end = period_bounds(gran, offset - i, today)
|
||||
tok, cost = _sum_between(events, start, end, pricing)
|
||||
out.append((_period_label(gran, start), tok, cost))
|
||||
return out
|
||||
|
||||
|
||||
def period_breakdown(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||||
offset: int = 0, today: Optional[date] = None) -> List[tuple]:
|
||||
"""Break the SELECTED period (``offset`` periods from now) into its sub-parts
|
||||
→ ``[(label, tokens, cost_usd)]``:
|
||||
· week → 7 days Mon→Sun (label ``MM/DD``)
|
||||
· month → weeks W1…Wn (7-day chunks from the 1st)
|
||||
· year → 12 months (label ``01``…``12``)."""
|
||||
from datetime import timedelta
|
||||
start, end = period_bounds(gran, offset, today)
|
||||
out = []
|
||||
if gran == "week":
|
||||
for i in range(7):
|
||||
d = start + timedelta(days=i)
|
||||
tok, cost = _sum_between(events, d, d + timedelta(days=1), pricing)
|
||||
out.append((d.strftime("%m/%d"), tok, cost))
|
||||
elif gran == "year":
|
||||
for m in range(1, 13):
|
||||
ms = date(start.year, m, 1)
|
||||
me = date(start.year + 1, 1, 1) if m == 12 else date(start.year, m + 1, 1)
|
||||
tok, cost = _sum_between(events, ms, me, pricing)
|
||||
out.append((f"{m:02d}", tok, cost))
|
||||
else: # month → weeks W1..Wn
|
||||
ndays = (end - start).days
|
||||
wk, day = 1, 1
|
||||
while day <= ndays:
|
||||
ws = date(start.year, start.month, day)
|
||||
we = date(start.year, start.month, day + 7) if day + 7 <= ndays else end
|
||||
tok, cost = _sum_between(events, ws, we, pricing)
|
||||
out.append((f"W{wk}", tok, cost))
|
||||
wk += 1
|
||||
day += 7
|
||||
return out
|
||||
|
||||
|
||||
def period_range_label(gran: str, offset: int, today: Optional[date] = None) -> str:
|
||||
"""Human label for the selected period (shown in the Dashboard header) —
|
||||
week → MM/DD – MM/DD, month → YYYY/MM, year → YYYY."""
|
||||
from datetime import timedelta
|
||||
start, end = period_bounds(gran, offset, today)
|
||||
if gran == "week":
|
||||
last_day = end - timedelta(days=1)
|
||||
return f"{start.strftime('%m/%d')} – {last_day.strftime('%m/%d')}"
|
||||
if gran == "year":
|
||||
return str(start.year)
|
||||
return start.strftime("%Y/%m")
|
||||
|
||||
|
||||
def set_budget(config, amount: float, currency: Optional[str] = None) -> None:
|
||||
@@ -409,7 +253,6 @@ def set_budget(config, amount: float, currency: Optional[str] = None) -> None:
|
||||
order and ``budget_set_at`` only has 1-second resolution, so a timestamp
|
||||
cutoff could mis-include/exclude an event recorded in that same second —
|
||||
the count baseline is exact regardless of timing."""
|
||||
from . import model_pricing as mp
|
||||
usage = config.data.setdefault("usage", {})
|
||||
ccy = (currency or usage.get("currency") or "USD").upper()
|
||||
usage["budget_amount_usd"] = mp.convert(float(amount or 0), ccy, "USD", config)
|
||||
@@ -454,83 +297,11 @@ def budget_status(config) -> Optional[Dict[str, Any]]:
|
||||
}
|
||||
|
||||
|
||||
def format_cost(usd: float, pricing: Dict[str, Any], digits: Optional[int] = None) -> str:
|
||||
"""Format a USD amount in the display currency. ``digits`` caps the number
|
||||
of decimal places (e.g. ``digits=2`` for the Total cost / Budget cards, so
|
||||
USD shows $1.23 not the default up-to-4 $1.2345) — never ADDS decimals to a
|
||||
currency that uses fewer (VND stays whole, JPY one place)."""
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
cur = p.get("currency", "USD")
|
||||
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
|
||||
symbol, cur_digits = _CURRENCY_FMT.get(cur, ("$", 2))
|
||||
if digits is not None:
|
||||
cur_digits = min(cur_digits, digits)
|
||||
value = usd * rate
|
||||
return f"{symbol}{value:,.{cur_digits}f}"
|
||||
|
||||
|
||||
def format_cost_compact(usd: float, pricing: Dict[str, Any]) -> str:
|
||||
"""Compact cost format for the Dashboard chart's y-axis/endpoint labels —
|
||||
always 2 decimals (not format_cost's up-to-4 for USD) and abbreviated with
|
||||
K/M above 1,000/1,000,000, same convention as ``fmt_tokens``. The chart's
|
||||
y-axis label box is narrow; the longer full-precision string used to
|
||||
overflow it, visually clipping/obscuring the leading currency symbol."""
|
||||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||||
cur = p.get("currency", "USD")
|
||||
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
|
||||
symbol, _digits = _CURRENCY_FMT.get(cur, ("$", 2))
|
||||
value = usd * rate
|
||||
sign = "-" if value < 0 else ""
|
||||
value = abs(value)
|
||||
if value >= 1_000_000:
|
||||
body = f"{value / 1_000_000:,.2f}M"
|
||||
elif value >= 1_000:
|
||||
body = f"{value / 1_000:,.2f}K"
|
||||
else:
|
||||
body = f"{value:,.2f}"
|
||||
return f"{sign}{symbol}{body}"
|
||||
|
||||
|
||||
_AI_ANALYSIS_HEADERS = {
|
||||
"vi": ("Nhận xét thói quen", "Cách viết prompt tiết kiệm hơn", "Hành động giảm token"),
|
||||
"en": ("Usage habits", "Writing more efficient prompts", "Actions to cut token usage"),
|
||||
"ja": ("利用傾向", "より効率的なプロンプトの書き方", "トークン削減のためのアクション"),
|
||||
}
|
||||
|
||||
|
||||
def build_ai_analysis_prompt(summary: Dict[str, Any], language: str = "vi") -> str:
|
||||
"""The prompt sent to the model for '✨ AI analyze my usage': aggregated
|
||||
numbers only — never raw prompt contents — asking for concrete habits
|
||||
feedback and token-saving recommendations, in the CURRENTLY SELECTED
|
||||
display language (headers included — not just the model's free-text reply,
|
||||
which would otherwise leave the section titles in Vietnamese regardless of
|
||||
the app's language setting)."""
|
||||
lang_names = {"vi": "Vietnamese", "ja": "Japanese", "en": "English"}
|
||||
h1, h2, h3 = _AI_ANALYSIS_HEADERS.get(language, _AI_ANALYSIS_HEADERS["vi"])
|
||||
top = "\n".join(f"- {label}: {tok:,} tokens"
|
||||
for label, tok in summary.get("top_labels", []))
|
||||
by_source = ", ".join(f"{k}={v:,}" for k, v in summary.get("by_source", []))
|
||||
return (
|
||||
"You are a token-efficiency coach for an AI desktop app (chat tabs + "
|
||||
"scheduled agent tasks). Analyze this usage summary and give the user "
|
||||
"practical advice, replying in "
|
||||
f"{lang_names.get(language, 'Vietnamese')}.\n\n"
|
||||
f"Period stats: {summary.get('turns', 0)} turns, "
|
||||
f"input={summary.get('in', 0):,} tokens, output={summary.get('out', 0):,}, "
|
||||
f"cache={summary.get('cache', 0):,}, "
|
||||
f"avg per prompt={summary.get('avg_per_turn', 0):,}.\n"
|
||||
f"Top consumers:\n{top or '- (none)'}\n"
|
||||
f"By area: {by_source or '(none)'}\n"
|
||||
f"Busiest day: {summary.get('busiest_day')} · busiest hour: {summary.get('busiest_hour')}\n\n"
|
||||
"Reply with EXACTLY these 3 short sections, in markdown, using THESE "
|
||||
f"section headers verbatim (already in {lang_names.get(language, 'Vietnamese')}):\n"
|
||||
f"1. **{h1}** — 2-3 bullet points about the usage pattern.\n"
|
||||
f"2. **{h2}** — 3 concrete prompt-writing tips "
|
||||
"tailored to the numbers above (e.g. long inputs → attach less / summarize "
|
||||
"first; many small turns → batch questions).\n"
|
||||
f"3. **{h3}** — 2-3 app-level actions (compact history, "
|
||||
"smaller model for simple tasks, reuse task outputs instead of re-asking).\n"
|
||||
"Keep the whole reply under 250 words."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Tin nhắn gửi Team Hoa — 25/08/2026
|
||||
|
||||
*Nam (Team Gamma) soạn. Hai việc, không cần trả lời, chỉ cần đọc trước khi
|
||||
bắt đầu R07-T03 và R06.*
|
||||
|
||||
---
|
||||
|
||||
Chào team Hoa,
|
||||
|
||||
Có hai thứ trong nhánh `gamma/refactor` ảnh hưởng trực tiếp tới phần các bạn
|
||||
sắp làm. Gửi trước để khỏi mất thời gian truy lỗi.
|
||||
|
||||
## 1. `platform/` đã đổi tên thành `adapters/` — plan.md ghi tên cũ
|
||||
|
||||
Plan chỉ đích danh `platform/qt/qt_scheduler_clock.py` (R07-T03, dòng 407 và
|
||||
lịch 25/08 ở dòng 229). **Đừng tạo thư mục `platform/`.**
|
||||
|
||||
Lý do: `platform` là tên một module trong thư viện chuẩn của Python. Tạo thư
|
||||
mục `platform/` ở gốc repo là nó che mất module chuẩn khi chạy từ chính thư
|
||||
mục gốc — mà đó là cách toàn bộ script trong `tools/` và `scripts/` đang chạy.
|
||||
Triệu chứng không hề chỉ về đúng chỗ:
|
||||
|
||||
AttributeError: module 'platform' has no attribute 'system'
|
||||
|
||||
Ném ra từ `import keyring`, không liên quan gì tới file bạn vừa tạo.
|
||||
|
||||
Tôi đã mắc đúng lỗi này hôm 21/08. Lúc thử thì đứng ở thư mục cha nên không
|
||||
tái hiện được, tưởng an toàn. Đổi tên thành `adapters/` và thêm
|
||||
`tests/test_no_stdlib_shadow.py` để lần sau đỏ ngay.
|
||||
|
||||
**Việc cần làm**: tạo `adapters/qt/qt_scheduler_clock.py` thay vì
|
||||
`platform/qt/...`. Thư mục `adapters/qt/` đã có sẵn `__init__.py` trên nhánh
|
||||
`gamma/refactor`, kéo về là dùng được.
|
||||
|
||||
## 2. `AtomicJsonFile` vừa vá một lỗi Windows — lấy bản mới trước khi dựng lên
|
||||
|
||||
Plan giao các bạn hai repository ngồi trên `AtomicJsonFile`:
|
||||
|
||||
* `infrastructure/persistence/json/task_repository_impl.py` (R07-T01, dòng 403)
|
||||
* `infrastructure/persistence/json/workspace_repository_impl.py` (R06, dòng 387)
|
||||
|
||||
Và tiêu chí nghiệm thu **A** (dòng 244) bắt mọi thao tác ghi tệp phải đi qua nó.
|
||||
|
||||
Hôm nay tôi bắt được lỗi thật trong đó:
|
||||
|
||||
PermissionError: [WinError 5] Access is denied
|
||||
.dem.json.xxxxxxx.tmp -> dem.json
|
||||
|
||||
`os.replace` trên Windows bị từ chối khi Defender hoặc Search Indexer đang giữ
|
||||
handle lên file vừa tạo — vài chục mili-giây rồi nhả. Đo được: hỏng 1 trong 7
|
||||
lượt chạy 20 lần ghi, tức **khoảng 1 trên 140 lần lưu**. Người dùng thỉnh
|
||||
thoảng bấm Lưu là văng lỗi và không tài nào tái hiện để báo.
|
||||
|
||||
Đã thêm vòng thử lại (commit `9d6a7be`). Nếu các bạn dựng repository trên bản
|
||||
trước đó thì lưu task và lưu workspace cũng hỏng với tần suất y hệt — nhân lên
|
||||
ba nơi ghi file.
|
||||
|
||||
**Việc cần làm**: `git pull` nhánh `gamma/refactor` (hoặc chờ nó vào `main`)
|
||||
trước khi bắt đầu R06/R07-T01.
|
||||
|
||||
## Tiện thể
|
||||
|
||||
`domain/security/tool_policy.py` là bản đề xuất DTO `ToolPolicyGateway` tôi
|
||||
viết hộ cho R05 của các bạn — ba trạng thái ALLOW/DENY/ASK, kèm fake và test
|
||||
contract. Không có gì của Gamma phụ thuộc vào nó, nên các bạn cứ sửa hoặc bỏ
|
||||
thoải mái, không phải giữ ý.
|
||||
|
||||
Nam
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Chuỗi hiển thị — phần agents_admin_tab.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"co4e.tt_flow_name": {"en": "Flow name", "ja": "フロー名", "vi": "Tên flow"},
|
||||
"co4e.tt_add_step": {"en": "Add a step to the canvas", "ja": "キャンバスにステップを追加",
|
||||
"vi": "Thêm một bước vào canvas"},
|
||||
"co4e.tt_zoom_in": {"en": "Zoom in (Ctrl+wheel / Ctrl++)", "ja": "拡大(Ctrl+ホイール / Ctrl++)",
|
||||
"vi": "Phóng to (Ctrl+lăn chuột / Ctrl++)"},
|
||||
"co4e.tt_zoom_out": {"en": "Zoom out (Ctrl+wheel / Ctrl+-)", "ja": "縮小(Ctrl+ホイール / Ctrl+-)",
|
||||
"vi": "Thu nhỏ (Ctrl+lăn chuột / Ctrl+-)"},
|
||||
"co4e.run_bg": {"en": "Run", "ja": "実行", "vi": "Chạy"},
|
||||
"co4e.tt_run_bg": {
|
||||
"en": "Run the selected flow in the background — several flows run in parallel",
|
||||
"ja": "選択フローをバックグラウンド実行 — 複数フローを並列実行",
|
||||
"vi": "Chạy flow đã chọn ở nền — nhiều flow chạy song song"},
|
||||
"co4e.running_flows": {"en": "Running flows", "ja": "実行中のフロー", "vi": "Flow đang chạy"},
|
||||
"co4e.runs_tab": {"en": "Flow Status", "ja": "フロー状態", "vi": "Flow Status"},
|
||||
# The flow tab strip was removed, so its pinned Flow Status tab became a
|
||||
# toggle in the flow toolbar — and that page needs its own way back.
|
||||
"co4e.tt_runs_tab": {
|
||||
"en": "Show every flow run", "ja": "すべてのフロー実行を表示",
|
||||
"vi": "Xem toàn bộ lần chạy flow"},
|
||||
"co4e.back_to_flow": {"en": "Back to flow", "ja": "フローに戻る", "vi": "Về flow"},
|
||||
"co4e.new_flow_ready": {
|
||||
"en": "New flow — type a name, then drag agents onto the canvas",
|
||||
"ja": "新しいフロー — 名前を入力し、エージェントをキャンバスへ",
|
||||
"vi": "Flow mới — đặt tên rồi kéo agent vào canvas"},
|
||||
"co4e.tt_back_to_flow": {
|
||||
"en": "Back to the flow editor", "ja": "フローエディタに戻る",
|
||||
"vi": "Quay lại màn dựng flow"},
|
||||
"co4e.runs_tab_n": {"en": "Flow Status ({n})", "ja": "フロー状態 ({n})", "vi": "Flow Status ({n})"},
|
||||
"co4e.runs_col_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
|
||||
"co4e.runs_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
|
||||
"co4e.runs_col_steps": {"en": "Steps", "ja": "ステップ", "vi": "Bước"},
|
||||
"co4e.runs_col_by": {"en": "Created by", "ja": "作成者", "vi": "Người tạo"},
|
||||
"co4e.runs_col_at": {"en": "Created at", "ja": "作成日時", "vi": "Ngày tạo"},
|
||||
"co4e.tt_runs_list": {
|
||||
"en": "Live status of every running/finished flow — always up to date. Double-click a run to run that flow again.",
|
||||
"ja": "実行中/完了フローのライブ状態 — 常に最新。実行をダブルクリックでそのフローを再実行。",
|
||||
"vi": "Trạng thái trực tiếp của mọi flow đang chạy/đã xong — luôn mới nhất. Nhấp đúp để chạy lại flow đó."},
|
||||
"co4e.flow_gone": {"en": "That flow no longer exists.", "ja": "そのフローは存在しません。",
|
||||
"vi": "Flow đó không còn tồn tại."},
|
||||
"co4e.rename": {"en": "Rename", "ja": "名前を変更", "vi": "Đổi tên"},
|
||||
"co4e.rename_prompt": {"en": "New flow name (name it by its function / task):",
|
||||
"ja": "新しいフロー名(機能/タスクで命名):",
|
||||
"vi": "Tên flow mới (đặt theo chức năng / task):"},
|
||||
"co4e.renamed_msg": {"en": "Renamed to: {name}", "ja": "名前変更: {name}", "vi": "Đã đổi tên: {name}"},
|
||||
"co4e.duplicate": {"en": "Duplicate", "ja": "複製", "vi": "Nhân bản"},
|
||||
"co4e.viewing_flow": {"en": "Viewing flow: {name}", "ja": "フロー表示: {name}", "vi": "Đang xem flow: {name}"},
|
||||
"co4e.stop": {"en": "Stop", "ja": "停止", "vi": "Dừng"},
|
||||
"co4e.tt_stop_run": {"en": "Stop the selected run (or all runs if none selected)",
|
||||
"ja": "選択した実行を停止(未選択なら全実行)", "vi": "Dừng lần chạy đã chọn (hoặc tất cả nếu chưa chọn)"},
|
||||
"co4e.clear_done": {"en": "Clear done", "ja": "完了を消去", "vi": "Xóa đã xong"},
|
||||
"co4e.tt_clear_runs": {"en": "Remove finished/stopped runs from the list",
|
||||
"ja": "完了/停止した実行を一覧から削除", "vi": "Bỏ các lần chạy đã xong/đã dừng khỏi danh sách"},
|
||||
"co4e.select_flow": {"en": "Select a flow first.", "ja": "先にフローを選択してください。",
|
||||
"vi": "Hãy chọn một flow trước."},
|
||||
"co4e.delete_run": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"co4e.tt_delete_run": {"en": "Delete the selected run from the history",
|
||||
"ja": "選択した実行を履歴から削除", "vi": "Xóa lần chạy đang chọn khỏi lịch sử"},
|
||||
"co4e.open_run": {"en": "Open flow", "ja": "フローを開く", "vi": "Mở flow"},
|
||||
"co4e.open_output": {"en": "Open output folder", "ja": "出力フォルダを開く", "vi": "Mở thư mục output"},
|
||||
"co4e.open_output_link": {
|
||||
"en": "📂 Open output folder", "ja": "📂 出力フォルダを開く", "vi": "📂 Mở thư mục output"},
|
||||
"co4e.tt_open_workspace": {
|
||||
"en": "Open the workspace folder where flow outputs are saved:\n{path}",
|
||||
"ja": "フローの出力が保存されるワークスペースフォルダを開く:\n{path}",
|
||||
"vi": "Mở thư mục workspace nơi lưu output của flow:\n{path}"},
|
||||
"co4e.select_run": {"en": "Select a run first.", "ja": "先に実行を選択してください。",
|
||||
"vi": "Hãy chọn một lần chạy trước."},
|
||||
"co4e.rename_run": {"en": "Rename", "ja": "名前変更", "vi": "Đổi tên"},
|
||||
"co4e.tt_rename_run": {"en": "Rename the selected flow run",
|
||||
"ja": "選択した実行の名前を変更", "vi": "Đổi tên lần chạy đang chọn"},
|
||||
"co4e.rename_run_label": {"en": "New flow name:", "ja": "新しいフロー名:", "vi": "Tên flow mới:"},
|
||||
"co4e.run_done_title": {"en": "Flow finished", "ja": "フロー完了", "vi": "Flow đã xong"},
|
||||
"co4e.run_done_popup": {
|
||||
"en": "Flow \"{name}\" finished — {status}.",
|
||||
"ja": "フロー「{name}」が完了しました — {status}。",
|
||||
"vi": "Flow \"{name}\" đã chạy xong — {status}."},
|
||||
"co4e.duplicated_msg": {"en": "Duplicated: {name}", "ja": "複製しました: {name}", "vi": "Đã nhân bản: {name}"},
|
||||
"co4e.bg_started": {"en": "▶ Started in background: {name}", "ja": "▶ バックグラウンドで開始: {name}",
|
||||
"vi": "▶ Đã chạy nền: {name}"},
|
||||
"co4e.bg_done": {"en": "Flow '{name}': {status}", "ja": "フロー '{name}': {status}",
|
||||
"vi": "Flow '{name}': {status}"},
|
||||
"co4e.tool_failed": {"en": "⚠ tool failed: {name}", "ja": "⚠ ツール失敗: {name}", "vi": "⚠ tool lỗi: {name}"},
|
||||
"co4e.manual_started": {"en": "▶ Manual run: {name} — advance with Run/Next step.",
|
||||
"ja": "▶ 手動実行: {name} — 「実行/次へ」で進む。",
|
||||
"vi": "▶ Chạy thủ công: {name} — bấm Chạy/Bước tiếp để tiến."},
|
||||
"co4e.manual_step": {"en": "▶ Step {i}/{n}: {label}", "ja": "▶ ステップ {i}/{n}: {label}",
|
||||
"vi": "▶ Bước {i}/{n}: {label}"},
|
||||
"co4e.status.running": {"en": "running", "ja": "実行中", "vi": "đang chạy"},
|
||||
"co4e.status.done": {"en": "done", "ja": "完了", "vi": "xong"},
|
||||
"co4e.status.error": {"en": "error", "ja": "エラー", "vi": "lỗi"},
|
||||
"co4e.status.stopped": {"en": "stopped", "ja": "停止", "vi": "đã dừng"},
|
||||
"co4e.chat_placeholder": {
|
||||
"en": "Chat with the flow — use /agent:<name> or /skill:<name>",
|
||||
"ja": "フローとチャット — /agent:<name> または /skill:<name>",
|
||||
"vi": "Chat với flow — dùng /agent:<name> hoặc /skill:<name>"},
|
||||
"co4e.send": {"en": "Send", "ja": "送信", "vi": "Gửi"},
|
||||
"co4e.tab_basic": {"en": "Basic", "ja": "基本", "vi": "Cơ bản"},
|
||||
"co4e.tab_model_perm": {"en": "Model & Permission", "ja": "モデルと権限", "vi": "Model & Quyền"},
|
||||
"co4e.tab_skills_files": {"en": "Skills & Files", "ja": "スキルとファイル", "vi": "Skills & Tệp"},
|
||||
"co4e.f_label": {"en": "Label", "ja": "ラベル", "vi": "Nhãn"},
|
||||
"co4e.f_role": {"en": "Role", "ja": "ロール", "vi": "Vai trò"},
|
||||
"co4e.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
|
||||
"co4e.f_icon": {"en": "Icon", "ja": "アイコン", "vi": "Icon"},
|
||||
"co4e.f_icon_placeholder": {"en": "icon name (optional)", "ja": "アイコン名(任意)", "vi": "tên icon (tùy chọn)"},
|
||||
"co4e.f_instructions": {"en": "Instructions", "ja": "指示", "vi": "Hướng dẫn"},
|
||||
"co4e.f_context": {"en": "Context", "ja": "コンテキスト", "vi": "Ngữ cảnh"},
|
||||
"co4e.f_context_placeholder": {
|
||||
"en": "Extra background/info for this agent or step (added to its prompt at run time).",
|
||||
"ja": "このエージェント/ステップ用の追加情報(実行時にプロンプトへ追加されます)。",
|
||||
"vi": "Thông tin/ngữ cảnh bổ sung cho agent hoặc step này (thêm vào prompt khi chạy)."},
|
||||
"co4e.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
|
||||
"co4e.f_permission": {"en": "Permissions", "ja": "権限", "vi": "Quyền"},
|
||||
"co4e.f_self_verify": {"en": "Self-verify", "ja": "自己検証", "vi": "Tự kiểm tra"},
|
||||
"co4e.f_verify_rounds": {"en": "rounds", "ja": "回数", "vi": "vòng"},
|
||||
"co4e.f_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
|
||||
"co4e.f_attachments": {"en": "Attachments", "ja": "添付ファイル", "vi": "Tệp đính kèm"},
|
||||
"co4e.attach_add": {"en": "Attach files", "ja": "ファイル添付", "vi": "Đính kèm tệp"},
|
||||
"co4e.attach_remove": {"en": "Remove", "ja": "削除", "vi": "Bỏ"},
|
||||
"co4e.f_subagents": {"en": "Parallel agents", "ja": "並列エージェント", "vi": "Agent song song"},
|
||||
"co4e.perm.inherit": {"en": "Inherit", "ja": "継承", "vi": "Kế thừa"},
|
||||
"co4e.perm.read-only": {"en": "Read-only", "ja": "読み取り専用", "vi": "Chỉ đọc"},
|
||||
"co4e.perm.standard": {"en": "Standard", "ja": "標準", "vi": "Tiêu chuẩn"},
|
||||
"co4e.perm.full": {"en": "Full", "ja": "フル", "vi": "Toàn quyền"},
|
||||
"co4e.add_subagent": {"en": "Add", "ja": "追加", "vi": "Thêm"},
|
||||
"co4e.del_subagent": {"en": "Remove", "ja": "削除", "vi": "Bỏ"},
|
||||
"co4e.run_this_step": {"en": "Run this step", "ja": "このステップを実行", "vi": "Chạy bước này"},
|
||||
"co4e.run_from_here": {"en": "Run from here", "ja": "ここから実行", "vi": "Chạy từ đây"},
|
||||
"co4e.delete_step": {"en": "Delete step", "ja": "ステップ削除", "vi": "Xóa bước"},
|
||||
"co4e.load_models_tooltip": {
|
||||
"en": "Load available models", "ja": "利用可能なモデルを取得", "vi": "Tải danh sách model"},
|
||||
"co4e.agent_edit_title": {"en": "Edit agent", "ja": "エージェント編集", "vi": "Sửa agent"},
|
||||
"co4e.agent_new_title": {"en": "New agent", "ja": "新規エージェント", "vi": "Agent mới"},
|
||||
"co4e.saved_msg": {"en": "Saved flow: {name}", "ja": "フローを保存: {name}", "vi": "Đã lưu flow: {name}"},
|
||||
"co4e.select_custom_agent": {
|
||||
"en": "Select a custom agent first.", "ja": "先にカスタムエージェントを選択してください。",
|
||||
"vi": "Hãy chọn một agent tùy chỉnh trước."},
|
||||
"co4e.no_steps": {"en": "Add at least one step first.", "ja": "先にステップを追加してください。",
|
||||
"vi": "Hãy thêm ít nhất một bước."},
|
||||
"co4e.run_started": {"en": "▶ Running flow: {name}", "ja": "▶ フロー実行中: {name}",
|
||||
"vi": "▶ Đang chạy flow: {name}"},
|
||||
"co4e.run_done": {"en": "✓ Flow finished.", "ja": "✓ フロー完了。", "vi": "✓ Flow xong."},
|
||||
"co4e.run_execute_phase": {
|
||||
"en": "▶ Plan done — now executing…", "ja": "▶ 計画完了 — 実行中…",
|
||||
"vi": "▶ Xong plan — đang thực thi…"},
|
||||
"co4e.agent_not_found": {
|
||||
"en": "Agent '{name}' not found.", "ja": "エージェント '{name}' が見つかりません。",
|
||||
"vi": "Không tìm thấy agent '{name}'."},
|
||||
|
||||
# ---- agents_admin_tab.py — Admin-only agent catalog -------------------
|
||||
"agents_admin.page_title": {"en": "Agents Admin", "ja": "Agents Admin", "vi": "Agents Admin"},
|
||||
"agents_admin.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
|
||||
"agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"agents_admin.hint": {
|
||||
"en": "System-management agents shared across every machine (stored in the shared accounts folder): the help agent and Schedule Task executors. These are NOT the agents you pick in Cowork or Co4E.",
|
||||
"ja": "全マシンで共有されるシステム管理用エージェント(共有フォルダーに保存):ヘルプエージェントやスケジュールタスクの実行エージェントなど。CoworkやCo4Eで選択するエージェントではありません。",
|
||||
"vi": "Agent quản lý hệ thống, dùng chung mọi máy (lưu trong thư mục dùng chung): agent trợ giúp và agent chạy Schedule Task. Đây KHÔNG phải agent để chọn trong Cowork hay Co4E."},
|
||||
"agents_admin.add_title": {"en": "Add agent", "ja": "エージェント追加", "vi": "Thêm agent"},
|
||||
"agents_admin.edit_title": {"en": "Edit agent", "ja": "エージェント編集", "vi": "Sửa agent"},
|
||||
"agents_admin.delete_title": {"en": "Delete agent", "ja": "エージェント削除", "vi": "Xóa agent"},
|
||||
"agents_admin.delete_confirm": {
|
||||
"en": "Delete agent \"{name}\"?", "ja": "エージェント「{name}」を削除しますか?",
|
||||
"vi": "Xóa agent \"{name}\"?"},
|
||||
"agents_admin.add_btn": {"en": "Add", "ja": "追加", "vi": "Thêm"},
|
||||
"agents_admin.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
|
||||
"agents_admin.f_kind": {"en": "App function", "ja": "アプリ機能", "vi": "Chức năng App"},
|
||||
"agents_admin.f_prompt": {"en": "Instructions", "ja": "指示", "vi": "Chỉ dẫn"},
|
||||
"agents_admin.f_prompt_placeholder": {
|
||||
"en": "Extra instructions this agent always follows (optional)…",
|
||||
"ja": "このエージェントが常に従う追加指示(任意)…",
|
||||
"vi": "Chỉ dẫn bổ sung agent này luôn tuân theo (tùy chọn)…"},
|
||||
"agents_admin.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"},
|
||||
"agents_admin.provider_default": {
|
||||
"en": "(machine's active provider)", "ja": "(各マシンの現在のプロバイダー)",
|
||||
"vi": "(provider hiện tại của máy)"},
|
||||
"agents_admin.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
|
||||
"agents_admin.f_model_placeholder": {
|
||||
"en": "empty = each machine's Settings model (currently: {model})",
|
||||
"ja": "空欄 = 各マシンの設定モデル(現在: {model})",
|
||||
"vi": "để trống = model trong Settings của từng máy (hiện tại: {model})"},
|
||||
"agents_admin.load_models_tooltip": {
|
||||
"en": "Fetch this provider's real model list so you can pick a specific one from the dropdown.",
|
||||
"ja": "このプロバイダーの実際のモデル一覧を取得し、ドロップダウンから選択できるようにします。",
|
||||
"vi": "Lấy danh sách model thực tế của provider này để chọn từ dropdown."},
|
||||
"agents_admin.load_models_empty": {
|
||||
"en": "No models were returned — check the provider's settings/connection.",
|
||||
"ja": "モデルが取得できませんでした。プロバイダーの設定/接続を確認してください。",
|
||||
"vi": "Không lấy được model nào — kiểm tra lại cấu hình/kết nối provider."},
|
||||
"agents_admin.f_enabled": {"en": "Enabled", "ja": "有効", "vi": "Kích hoạt"},
|
||||
"agents_admin.col_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
|
||||
"agents_admin.col_kind": {"en": "Function", "ja": "機能", "vi": "Chức năng"},
|
||||
"agents_admin.col_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
|
||||
"agents_admin.col_enabled": {"en": "Enabled", "ja": "有効", "vi": "Kích hoạt"},
|
||||
"agents_admin.col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
|
||||
"agents_admin.col_updated": {"en": "Updated", "ja": "更新", "vi": "Cập nhật"},
|
||||
"agents_admin.check_btn": {"en": "Check all", "ja": "すべてチェック", "vi": "Kiểm tra tất cả"},
|
||||
"agents_admin.check_tooltip": {
|
||||
"en": "Check each agent's effective provider/model connectivity",
|
||||
"ja": "各エージェントの実効プロバイダ/モデルの接続性を確認",
|
||||
"vi": "Kiểm tra kết nối provider/model hiệu lực của từng agent"},
|
||||
"agents_admin.status_unchecked": {"en": "— (not checked)", "ja": "— (未チェック)", "vi": "— (chưa kiểm tra)"},
|
||||
"agents_admin.status_unchecked_tip": {
|
||||
"en": "Press Check to test whether this agent's provider/model is reachable",
|
||||
"ja": "「チェック」でこのエージェントのプロバイダ/モデルへの到達性をテスト",
|
||||
"vi": "Nhấn Kiểm tra để test agent này có kết nối được provider/model không"},
|
||||
"agents_admin.status_checking": {"en": "checking…", "ja": "確認中…", "vi": "đang kiểm tra…"},
|
||||
"agents_admin.status_ok": {"en": "Active", "ja": "稼働中", "vi": "Hoạt động"},
|
||||
"agents_admin.status_bad": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
|
||||
"agents_admin.default_model": {
|
||||
"en": "(Settings default: {model})", "ja": "(設定既定: {model})",
|
||||
"vi": "(mặc định Settings: {model})"},
|
||||
"agents_admin.kind.search": {"en": "Search", "ja": "検索", "vi": "Tìm kiếm"},
|
||||
"agents_admin.kind.monitor": {"en": "Monitoring", "ja": "監視", "vi": "Giám sát"},
|
||||
"agents_admin.kind.cowork": {"en": "Cowork chat", "ja": "Cowork チャット", "vi": "Cowork chat"},
|
||||
"agents_admin.kind.graphrag": {"en": "GraphRAG / Knowledge", "ja": "GraphRAG / ナレッジ", "vi": "GraphRAG / Tri thức"},
|
||||
"agents_admin.kind.schedule": {"en": "Schedule Task", "ja": "スケジュールタスク", "vi": "Schedule Task"},
|
||||
"agents_admin.kind.security": {"en": "Security", "ja": "セキュリティ", "vi": "Bảo mật"},
|
||||
"agents_admin.kind.help": {"en": "App Help", "ja": "アプリヘルプ", "vi": "Trợ giúp App"},
|
||||
|
||||
"monitoring.filter_placeholder": {
|
||||
"en": "Filter rows (or type a question and press )…",
|
||||
"ja": "行をフィルター(質問を入力しても可)…",
|
||||
"vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…"},
|
||||
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
|
||||
"monitoring.pricing_title": {
|
||||
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
|
||||
"vi": "Bảng giá model (USD / 1 triệu token)"},
|
||||
"monitoring.pricing_col_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
|
||||
"monitoring.pricing_col_in": {"en": "In", "ja": "入力", "vi": "In"},
|
||||
"monitoring.pricing_col_out": {"en": "Out", "ja": "出力", "vi": "Out"},
|
||||
"monitoring.pricing_col_cache": {"en": "Cache", "ja": "キャッシュ", "vi": "Cache"},
|
||||
"monitoring.pricing_add_btn": {"en": "Add model", "ja": "モデル追加", "vi": "Thêm model"},
|
||||
"monitoring.pricing_del_btn": {"en": "Remove", "ja": "削除", "vi": "Xóa"},
|
||||
"monitoring.pricing_link_label": {
|
||||
"en": "Reference:", "ja": "参考リンク:", "vi": "Link tham khảo:"},
|
||||
"monitoring.pricing_no_link": {
|
||||
"en": "No reference link set (Settings → Parameter → Pricing reference link).",
|
||||
"ja": "参考リンク未設定(設定 → Parameter)。",
|
||||
"vi": "Chưa đặt link tham khảo (Settings → Parameter → Link bảng giá)."},
|
||||
"monitoring.ai_filter_tooltip": {
|
||||
"en": "AI turns your question into a filter keyword (e.g. \"which commands failed today?\").",
|
||||
"ja": "質問をAIがフィルターキーワードに変換します。",
|
||||
"vi": "AI chuyển câu hỏi thành từ khóa lọc (vd: \"hôm nay lệnh nào bị lỗi?\")."},
|
||||
"monitoring.security_detail_title": {
|
||||
"en": "Event details", "ja": "イベント詳細", "vi": "Chi tiết sự kiện"},
|
||||
"monitoring.security_detail_close": {
|
||||
"en": "Close", "ja": "閉じる", "vi": "Đóng"},
|
||||
"monitoring.security_events_title": {
|
||||
"en": "Security Events", "ja": "セキュリティイベント", "vi": "Sự kiện bảo mật"},
|
||||
"monitoring.mcp_history_title": {
|
||||
"en": "MCP Call History", "ja": "MCP 呼び出し履歴", "vi": "Lịch sử gọi MCP"},
|
||||
"monitoring.action_logs_title": {
|
||||
"en": "Action Logs", "ja": "アクションログ", "vi": "Nhật ký hành động"},
|
||||
"monitoring.col_detail_block": {
|
||||
"en": "Block detail", "ja": "ブロック詳細", "vi": "Chi tiết chặn"},
|
||||
|
||||
# ---- event-detail panel (ui-audit_v2.html openDetail()) --------------
|
||||
"monitoring.detail_section_general": {
|
||||
"en": "General info", "ja": "基本情報", "vi": "Thông tin chung"},
|
||||
"monitoring.detail_section_action": {
|
||||
"en": "Action", "ja": "アクション", "vi": "Hành động"},
|
||||
"monitoring.detail_section_metadata": {
|
||||
"en": "Metadata", "ja": "メタデータ", "vi": "Metadata"},
|
||||
"monitoring.detail_type": {"en": "Type", "ja": "種類", "vi": "Loại"},
|
||||
"monitoring.detail_status": {"en": "Status", "ja": "ステータス", "vi": "Trạng thái"},
|
||||
"monitoring.detail_event_id": {"en": "Event ID", "ja": "イベントID", "vi": "Event ID"},
|
||||
"monitoring.detail_policy": {"en": "Policy", "ja": "ポリシー", "vi": "Policy"},
|
||||
"monitoring.detail_severity": {"en": "Severity", "ja": "重大度", "vi": "Severity"},
|
||||
"monitoring.detail_copy": {"en": "Copy", "ja": "コピー", "vi": "Copy"},
|
||||
"monitoring.detail_copied": {"en": "Copied", "ja": "コピー済み", "vi": "Đã copy"},
|
||||
|
||||
# Trạng thái pill — which rule fired, phrased as the enforcement outcome
|
||||
# (distinct wording from the Loại/action_* labels below, matching
|
||||
# ui-audit_v2.html's statusInfo() vs actionLabel).
|
||||
"monitoring.status_blocked": {"en": "Blocked", "ja": "ブロック済み", "vi": "Đã chặn"},
|
||||
"monitoring.status_path": {"en": "Path blocked", "ja": "パスをブロック", "vi": "Path chặn"},
|
||||
"monitoring.status_network": {"en": "Network blocked", "ja": "ネットワークをブロック", "vi": "Mạng chặn"},
|
||||
"monitoring.status_secret": {"en": "Secret leaked", "ja": "シークレット漏洩", "vi": "Bí mật lộ"},
|
||||
"monitoring.status_ok": {"en": "Succeeded", "ja": "成功", "vi": "Thành công"},
|
||||
"monitoring.status_failed": {"en": "Failed", "ja": "失敗", "vi": "Thất bại"},
|
||||
|
||||
"monitoring.severity_critical": {"en": "CRITICAL", "ja": "CRITICAL", "vi": "CRITICAL"},
|
||||
"monitoring.severity_medium": {"en": "MEDIUM", "ja": "MEDIUM", "vi": "MEDIUM"},
|
||||
"monitoring.severity_info": {"en": "INFO", "ja": "INFO", "vi": "INFO"},
|
||||
|
||||
# Loại field — a human label for the raw event name (audit_log ``name``).
|
||||
"monitoring.action_prompt": {"en": "Risky prompt", "ja": "危険なプロンプト", "vi": "Prompt rủi ro"},
|
||||
"monitoring.action_dangerous_command": {
|
||||
"en": "Dangerous command", "ja": "危険なコマンド", "vi": "Lệnh nguy hiểm"},
|
||||
"monitoring.action_install_package": {
|
||||
"en": "Package install", "ja": "パッケージインストール", "vi": "Cài đặt gói"},
|
||||
"monitoring.action_path_outside_sandbox": {
|
||||
"en": "Path outside sandbox", "ja": "サンドボックス外のパス", "vi": "Path ngoài sandbox"},
|
||||
"monitoring.action_network_blocked": {
|
||||
"en": "Network blocked", "ja": "ネットワークブロック", "vi": "Mạng bị chặn"},
|
||||
"monitoring.action_secret_in_output": {
|
||||
"en": "Secret disclosed", "ja": "シークレット漏洩", "vi": "Tiết lộ bí mật"},
|
||||
|
||||
"monitoring.overview_activity_title": {
|
||||
"en": "Recent log", "ja": "最近のログ", "vi": "Nhật ký gần đây"},
|
||||
"monitoring.overview_no_activity": {
|
||||
"en": "No activity yet.", "ja": "まだアクティビティはありません。", "vi": "Chưa có hoạt động nào."},
|
||||
"monitoring.overview_resource_title": {
|
||||
"en": "Resources", "ja": "リソース", "vi": "Tài nguyên"},
|
||||
"monitoring.overview_res_cpu": {"en": "CPU", "ja": "CPU", "vi": "CPU"},
|
||||
"monitoring.overview_res_mem": {"en": "Memory", "ja": "メモリ", "vi": "Bộ nhớ"},
|
||||
"monitoring.overview_res_disk": {"en": "Disk I/O", "ja": "ディスク I/O", "vi": "Disk I/O"},
|
||||
"monitoring.overview_res_network": {"en": "Network", "ja": "ネットワーク", "vi": "Mạng"},
|
||||
# ---- model pricing list (Overview, beside the resource group) ----
|
||||
"monitoring.pricing_title": {"en": "Model pricing", "ja": "モデル料金", "vi": "Bảng giá model"},
|
||||
"monitoring.pricing_currency": {"en": "Currency", "ja": "通貨", "vi": "Tiền tệ"},
|
||||
"monitoring.pricing_import": {"en": "Import", "ja": "取込", "vi": "Nhập"},
|
||||
"monitoring.pricing_export": {"en": "Template", "ja": "テンプレート", "vi": "Mẫu"},
|
||||
"monitoring.pricing_add": {"en": "Add", "ja": "追加", "vi": "Thêm"},
|
||||
"monitoring.pricing_autolink": {"en": "Auto-link", "ja": "自動取得", "vi": "Tự lấy"},
|
||||
"monitoring.pricing_delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"monitoring.pricing_add_prompt": {"en": "Model name:", "ja": "モデル名:", "vi": "Tên model:"},
|
||||
"monitoring.pricing_imported": {"en": "Imported {n} model prices.", "ja": "{n} 件の料金を取込。",
|
||||
"vi": "Đã nhập {n} dòng giá."},
|
||||
"monitoring.pricing_exported": {"en": "Price template exported.", "ja": "料金テンプレートを出力。",
|
||||
"vi": "Đã xuất mẫu bảng giá."},
|
||||
"monitoring.pricing_linked": {"en": "Linked {n} models from providers.",
|
||||
"ja": "プロバイダから {n} モデルを取得。",
|
||||
"vi": "Đã lấy {n} model từ provider."},
|
||||
"monitoring.pricing_col_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
|
||||
"monitoring.pricing_col_context": {"en": "Context", "ja": "コンテキスト", "vi": "Context"},
|
||||
"monitoring.pricing_col_maxout": {"en": "Max output", "ja": "最大出力", "vi": "Max output"},
|
||||
"monitoring.pricing_col_input": {"en": "Input price", "ja": "入力単価", "vi": "Giá input"},
|
||||
"monitoring.pricing_col_output": {"en": "Output price", "ja": "出力単価", "vi": "Giá output"},
|
||||
"monitoring.overview_sandbox_details_title": {
|
||||
# One section now, holding both the sandbox facts and the permissions.
|
||||
"en": "Sandbox & Permissions", "ja": "サンドボックスと権限",
|
||||
"vi": "Sandbox & Quyền"},
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Chuỗi hiển thị — phần composer.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"chatpanel.agent_tooltip": {
|
||||
"en": "Model/agent for THIS tab — independent of the other tab",
|
||||
"ja": "このタブ専用のモデル/エージェント(他のタブとは独立)",
|
||||
"vi": "Model/agent riêng cho tab này — độc lập với tab kia"},
|
||||
"chatpanel.agent_list_error": {
|
||||
"en": "Could not load the model list: {err}", "ja": "モデル一覧を読み込めませんでした: {err}",
|
||||
"vi": "Không tải được danh sách model: {err}"},
|
||||
"chatpanel.compress_btn": {"en": "Compress", "ja": "圧縮", "vi": "Nén"},
|
||||
"chatpanel.compress_tooltip": {
|
||||
"en": "Compress the conversation: trim old history to cut tokens (avoid exceeding the context limit)",
|
||||
"ja": "会話を圧縮:古い履歴を減らしてトークンを削減(コンテキスト上限超過を回避)",
|
||||
"vi": "Nén hội thoại: bỏ bớt lịch sử cũ để giảm token (tránh lỗi vượt giới hạn context)"},
|
||||
"chatpanel.files_header": {"en": "Files", "ja": "ファイル", "vi": "Files"},
|
||||
"chatpanel.collapse_files_tooltip": {
|
||||
"en": "Collapse the Files panel", "ja": "ファイルパネルを折りたたむ", "vi": "Thu gọn bảng Files"},
|
||||
"chatpanel.expand_files_tooltip": {
|
||||
"en": "Click to expand the Files panel", "ja": "クリックしてファイルパネルを展開",
|
||||
"vi": "Bấm để mở lại bảng Files"},
|
||||
"chatpanel.compress_busy": {
|
||||
"en": "Running — stop or wait before compressing.", "ja": "実行中です。停止するか完了を待ってから圧縮してください。",
|
||||
"vi": "Đang chạy — dừng hoặc đợi xong rồi hãy nén."},
|
||||
"chatpanel.compress_short": {
|
||||
"en": "Conversation is already short — no need to compress.",
|
||||
"ja": "会話はすでに短いため圧縮の必要はありません。",
|
||||
"vi": "Hội thoại đã ngắn — không cần nén."},
|
||||
"chatpanel.compress_done": {
|
||||
"en": "Compressed: dropped {cut} old messages, kept the last {keep} turns to cut tokens.",
|
||||
"ja": "圧縮しました:古いメッセージ{cut}件を削除し、直近{keep}ターンを保持してトークンを削減。",
|
||||
"vi": "Đã nén hội thoại: bỏ {cut} tin cũ, giữ {keep} lượt gần nhất để giảm token."},
|
||||
"chatpanel.compress_reduced": {
|
||||
"en": "Compressed to {pct}% of the original ({n} old messages digested).",
|
||||
"ja": "元の {pct}% まで圧縮(古いメッセージ {n} 件を要約)。",
|
||||
"vi": "Đã nén còn {pct}% so với ban đầu ({n} tin cũ được tóm gọn)."},
|
||||
"chatpanel.compress_digest_header": {
|
||||
"en": "Compressed summary of {n} earlier messages",
|
||||
"ja": "以前のメッセージ {n} 件の要約",
|
||||
"vi": "Tóm tắt nén của {n} tin nhắn trước đó"},
|
||||
"chatpanel.delete_confirm_title": {"en": "Delete message", "ja": "メッセージを削除", "vi": "Xóa tin nhắn"},
|
||||
"chatpanel.delete_confirm_files": {
|
||||
"en": "Delete this message and its {n} input/output file(s)?\n\n{preview}",
|
||||
"ja": "このメッセージと入出力ファイル{n}件を削除しますか?\n\n{preview}",
|
||||
"vi": "Xóa tin nhắn này và {n} tệp input/output của nó?\n\n{preview}"},
|
||||
"chatpanel.delete_confirm_plain": {"en": "Delete this message?", "ja": "このメッセージを削除しますか?", "vi": "Xóa tin nhắn này?"},
|
||||
"chatpanel.delete_done": {
|
||||
"en": "Message and its files deleted.", "ja": "メッセージとファイルを削除しました。",
|
||||
"vi": "Đã xóa tin nhắn và các tệp liên quan."},
|
||||
"chatpanel.working": {"en": "{name}: working…", "ja": "{name}: 処理中…", "vi": "{name}: đang xử lý…"},
|
||||
"chatpanel.done": {"en": "{name}: done.", "ja": "{name}: 完了。", "vi": "{name}: xong."},
|
||||
"chatpanel.failed": {"en": "{name}: error.", "ja": "{name}: エラー。", "vi": "{name}: lỗi."},
|
||||
"chatpanel.stopping": {"en": "{name}: stopping…", "ja": "{name}: 停止中…", "vi": "{name}: đang dừng…"},
|
||||
"chatpanel.attach_limit": {
|
||||
"en": "Max {n} attachments — extra files were skipped.",
|
||||
"ja": "添付は最大{n}件です。超過分はスキップされました。",
|
||||
"vi": "Tối đa {n} tệp đính kèm — bỏ qua phần dư."},
|
||||
"chatpanel.attached_hint": {"en": "Attached: {names}", "ja": "添付: {names}", "vi": "Đã đính kèm: {names}"},
|
||||
"chatpanel.skills_updated": {"en": "Skills updated.", "ja": "スキルを更新しました。", "vi": "Đã cập nhật skill."},
|
||||
"chatpanel.new_files_detected": {
|
||||
"en": "New file(s) detected in output folder: {names} ({n} file(s)). They will be auto-loaded as input on the next message.",
|
||||
"ja": "出力フォルダに新しいファイルを検出しました: {names} ({n}ファイル)。次のメッセージで自動的に入力として読み込まれます。",
|
||||
"vi": "Phát hiện tệp mới trong thư mục đầu ra: {names} ({n} tệp). Chúng sẽ được tự động tải làm dữ liệu đầu vào ở tin nhắn tiếp theo.",
|
||||
},
|
||||
# ---- composer.py -----------------------------------------------
|
||||
"composer.placeholder_default": {
|
||||
"en": "Type a message… (Enter to send, Shift+Enter for newline)",
|
||||
"ja": "メッセージを入力…(Enterで送信、Shift+Enterで改行)",
|
||||
"vi": "Nhập tin nhắn… (Enter để gửi, Shift+Enter xuống dòng)"},
|
||||
"composer.placeholder_cowork": {
|
||||
"en": "Type a request or attach a file to process… (Enter to send)",
|
||||
"ja": "依頼内容を入力するかファイルを添付…(Enterで送信)",
|
||||
"vi": "Nhập yêu cầu hoặc đính kèm tệp để xử lí… (Enter để gửi)"},
|
||||
"composer.placeholder_code": {
|
||||
"en": "Assign a task to the Code agent… (Enter to send)",
|
||||
"ja": "Code エージェントにタスクを指示…(Enterで送信)",
|
||||
"vi": "Giao việc cho Code agent… (Enter để gửi)"},
|
||||
"composer.queue_label": {"en": "Queue ({n})", "ja": "キュー ({n})", "vi": "Hàng đợi ({n})"},
|
||||
"composer.queue_tooltip": {
|
||||
"en": "Double-click to remove a queued message", "ja": "ダブルクリックでキューから削除",
|
||||
"vi": "Nhấp đúp để xoá một tin nhắn khỏi hàng đợi"},
|
||||
"composer.attachments_label": {"en": "Attachments ({n})", "ja": "添付ファイル ({n})", "vi": "Tệp đính kèm ({n})"},
|
||||
"composer.attachments_tooltip": {
|
||||
"en": "Click on a chip to remove a file added by mistake",
|
||||
"ja": "誤って追加したファイルは で削除できます",
|
||||
"vi": "Bấm trên thẻ để gỡ tệp đính kèm nhầm"},
|
||||
"composer.remove_tooltip": {
|
||||
"en": "Remove this file (added by mistake)", "ja": "このファイルを削除(誤って追加)",
|
||||
"vi": "Gỡ tệp này (đính kèm nhầm)"},
|
||||
"composer.attach_btn_tooltip": {
|
||||
"en": "Attach images or files (you can also paste or drag them in)",
|
||||
"ja": "画像やファイルを添付(貼り付け・ドラッグも可)",
|
||||
"vi": "Đính kèm ảnh hoặc tệp (có thể dán hoặc kéo-thả vào)"},
|
||||
"composer.send": {"en": "Send", "ja": "送信", "vi": "Gửi"},
|
||||
"composer.queue_btn": {"en": "Queue", "ja": "キューに追加", "vi": "Thêm vào hàng đợi"},
|
||||
"composer.stop": {"en": "Stop", "ja": "停止", "vi": "Dừng"},
|
||||
"composer.attach_dialog_title": {"en": "Attach files / images", "ja": "ファイル/画像を添付", "vi": "Đính kèm tệp / ảnh"},
|
||||
"composer.attach_dialog_filter": {
|
||||
"en": "Files (*.*);;Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp)",
|
||||
"ja": "ファイル (*.*);;画像 (*.png *.jpg *.jpeg *.gif *.bmp *.webp)",
|
||||
"vi": "Tệp (*.*);;Ảnh (*.png *.jpg *.jpeg *.gif *.bmp *.webp)"},
|
||||
"composer.no_skills": {"en": " (no skills yet)", "ja": " (スキルはまだありません)", "vi": " (chưa có skill nào)"},
|
||||
"composer.no_agents": {"en": " (no agents found)", "ja": " (エージェントが見つかりません)", "vi": " (không tìm thấy agent)"},
|
||||
"composer.manage_skills": {"en": "Manage skills…", "ja": "スキルを管理…", "vi": "Quản lý skill…"},
|
||||
|
||||
# ---- schedule_task_tab.py / task_editor_dialog.py -------------------
|
||||
"schedtask.title": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"},
|
||||
"schedtask.view.kanban": {"en": "Kanban", "ja": "Kanban", "vi": "Kanban"},
|
||||
"schedtask.view.calendar": {"en": "Calendar", "ja": "カレンダー", "vi": "Lịch"},
|
||||
"schedtask.no_title": {"en": "(untitled)", "ja": "(無題)", "vi": "(chưa có tên)"},
|
||||
"schedtask.cal_today": {"en": "Today", "ja": "今日", "vi": "Hôm nay"},
|
||||
"schedtask.cal_prev": {"en": "Previous", "ja": "前へ", "vi": "Trước"},
|
||||
"schedtask.cal_next": {"en": "Next", "ja": "次へ", "vi": "Sau"},
|
||||
"schedtask.cal_gran.week": {"en": "Week", "ja": "週", "vi": "Tuần"},
|
||||
"schedtask.cal_gran.month": {"en": "Month", "ja": "月", "vi": "Tháng"},
|
||||
"schedtask.cal_gran.year": {"en": "Year", "ja": "年", "vi": "Năm"},
|
||||
"schedtask.cal_weekday.mon": {"en": "Mon", "ja": "月", "vi": "T2"},
|
||||
"schedtask.cal_weekday.tue": {"en": "Tue", "ja": "火", "vi": "T3"},
|
||||
"schedtask.cal_weekday.wed": {"en": "Wed", "ja": "水", "vi": "T4"},
|
||||
"schedtask.cal_weekday.thu": {"en": "Thu", "ja": "木", "vi": "T5"},
|
||||
"schedtask.cal_weekday.fri": {"en": "Fri", "ja": "金", "vi": "T6"},
|
||||
"schedtask.cal_weekday.sat": {"en": "Sat", "ja": "土", "vi": "T7"},
|
||||
"schedtask.cal_weekday.sun": {"en": "Sun", "ja": "日", "vi": "CN"},
|
||||
"schedtask.cal_month_count": {"en": "{month} — {n} task(s)", "ja": "{month} — {n} 件",
|
||||
"vi": "{month} — {n} task"},
|
||||
"schedtask.search_ph": {"en": "Search tasks…", "ja": "タスクを検索…", "vi": "Tìm task…"},
|
||||
"schedtask.filter_all": {"en": "All types", "ja": "すべての種類", "vi": "Mọi loại"},
|
||||
"schedtask.add_btn": {"en": "Add Task", "ja": "タスク追加", "vi": "Thêm Task"},
|
||||
"schedtask.ai_btn": {"en": "AI Create Task", "ja": "AIでタスク作成", "vi": "AI tạo Task"},
|
||||
"schedtask.ai_tooltip": {
|
||||
"en": "Describe what you want in natural language — AI proposes tasks/schedule/chain, you confirm before anything is created.",
|
||||
"ja": "自然文で説明すると、AIがタスク・スケジュール・チェーンを提案します。確認後に作成されます。",
|
||||
"vi": "Mô tả bằng ngôn ngữ tự nhiên — AI đề xuất task/lịch/chuỗi, bạn xác nhận rồi mới tạo."},
|
||||
"schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "No tasks"},
|
||||
"schedtask.no_schedule": {"en": "No schedule", "ja": "スケジュールなし", "vi": "Chưa đặt lịch"},
|
||||
"schedtask.last_success": {"en": "Last: Success", "ja": "前回: 成功", "vi": "Lần cuối: Thành công"},
|
||||
"schedtask.last_failed": {"en": "Last: Failed", "ja": "前回: 失敗", "vi": "Lần cuối: Lỗi"},
|
||||
"schedtask.last_never": {"en": "Last: not run", "ja": "前回: 未実行", "vi": "Lần cuối: chưa chạy"},
|
||||
"schedtask.status.backlog": {"en": "Backlog", "ja": "Backlog", "vi": "Backlog"},
|
||||
"schedtask.status.scheduled": {"en": "Scheduled", "ja": "Scheduled", "vi": "Scheduled"},
|
||||
"schedtask.status.running": {"en": "Running", "ja": "Running", "vi": "Running"},
|
||||
"schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "Waiting Input", "vi": "Waiting Input"},
|
||||
"schedtask.status.done": {"en": "Done", "ja": "Done", "vi": "Done"},
|
||||
"schedtask.status.failed": {"en": "Failed", "ja": "Failed", "vi": "Failed"},
|
||||
"schedtask.status.paused": {"en": "Paused", "ja": "Paused", "vi": "Paused"},
|
||||
"schedtask.type.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"schedtask.type.co4e_code": {"en": "Code", "ja": "Code", "vi": "Code"},
|
||||
"schedtask.type.flow": {"en": "Flow", "ja": "Flow", "vi": "Flow"},
|
||||
"schedtask.type.script": {"en": "Script", "ja": "Script", "vi": "Script"},
|
||||
"schedtask.type.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"},
|
||||
"schedtask.priority.low": {"en": "Low", "ja": "低", "vi": "Thấp"},
|
||||
"schedtask.priority.medium": {"en": "Medium", "ja": "中", "vi": "Trung bình"},
|
||||
"schedtask.priority.high": {"en": "High", "ja": "高", "vi": "Cao"},
|
||||
"schedtask.priority.critical": {"en": "Critical", "ja": "最重要", "vi": "Khẩn cấp"},
|
||||
"schedtask.menu_run": {"en": "Run now", "ja": "今すぐ実行", "vi": "Chạy ngay"},
|
||||
"schedtask.menu_edit": {"en": "Edit task", "ja": "タスクを編集", "vi": "Sửa task"},
|
||||
"schedtask.menu_duplicate": {"en": "Duplicate task", "ja": "タスクを複製", "vi": "Nhân bản task"},
|
||||
"schedtask.menu_pause": {"en": "Pause", "ja": "一時停止", "vi": "Tạm dừng"},
|
||||
"schedtask.menu_resume": {"en": "Resume", "ja": "再開", "vi": "Tiếp tục"},
|
||||
"schedtask.menu_logs": {"en": "View logs", "ja": "ログを表示", "vi": "Xem log"},
|
||||
"schedtask.menu_history": {"en": "Run history…", "ja": "実行履歴…", "vi": "Lịch sử chạy…"},
|
||||
"schedtask.hist_hint": {
|
||||
"en": "Double-click a row to open that run's artifact folder.",
|
||||
"ja": "行をダブルクリックすると、その実行のフォルダを開きます。",
|
||||
"vi": "Double-click một dòng để mở thư mục artifact của lần chạy đó."},
|
||||
"schedtask.hist_col_time": {"en": "Finished at", "ja": "完了時刻", "vi": "Hoàn thành lúc"},
|
||||
"schedtask.hist_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
|
||||
"schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Run ID"},
|
||||
"schedtask.hist_col_error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
|
||||
"schedtask.menu_create_next": {
|
||||
"en": "Create next task from output", "ja": "出力から次タスクを作成",
|
||||
"vi": "Tạo task tiếp theo từ output"},
|
||||
"schedtask.menu_delete": {"en": "Delete task", "ja": "タスクを削除", "vi": "Xóa task"},
|
||||
"schedtask.delete_confirm": {"en": "Delete '{title}'?", "ja": "「{title}」を削除しますか?", "vi": "Xóa '{title}'?"},
|
||||
"schedtask.menu_delete_selected": {"en": "Delete {n} selected", "ja": "選択した{n}件を削除", "vi": "Xóa {n} task đã chọn"},
|
||||
"schedtask.delete_multi_confirm": {
|
||||
"en": "Delete {n} selected tasks? This cannot be undone.",
|
||||
"ja": "選択した{n}件のタスクを削除しますか?元に戻せません。",
|
||||
"vi": "Xóa {n} task đã chọn? Không thể hoàn tác."},
|
||||
"schedtask.msg_created": {"en": "Task created.", "ja": "タスクを作成しました。", "vi": "Đã tạo task."},
|
||||
"schedtask.msg_running": {"en": "Running: {title}", "ja": "実行中: {title}", "vi": "Đang chạy: {title}"},
|
||||
"schedtask.msg_manual_norun": {
|
||||
"en": "Manual tasks are for tracking only — they don't execute.",
|
||||
"ja": "Manualタスクは管理用のため実行されません。",
|
||||
"vi": "Task Manual chỉ để quản lý — không tự chạy."},
|
||||
"schedtask.msg_no_scheduler": {"en": "Scheduler not available.", "ja": "スケジューラーが利用できません。", "vi": "Scheduler chưa sẵn sàng."},
|
||||
"schedtask.msg_ai_created": {"en": "Created {n} task(s) from AI plan.", "ja": "AI提案から{n}件のタスクを作成しました。", "vi": "Đã tạo {n} task từ đề xuất AI."},
|
||||
"schedtask.no_runs_yet": {"en": "This task has not run yet.", "ja": "このタスクはまだ実行されていません。", "vi": "Task này chưa chạy lần nào."},
|
||||
"schedtask.next_of": {"en": "Next: {title}", "ja": "次: {title}", "vi": "Tiếp theo: {title}"},
|
||||
# editor
|
||||
"schedtask.editor_title_new": {"en": "Add Task", "ja": "タスク追加", "vi": "Thêm Task"},
|
||||
"schedtask.editor_title_edit": {"en": "Edit Task", "ja": "タスク編集", "vi": "Sửa Task"},
|
||||
"schedtask.f_title": {"en": "Title", "ja": "タイトル", "vi": "Tiêu đề"},
|
||||
"schedtask.f_desc": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
|
||||
"schedtask.f_type": {"en": "Task type", "ja": "タスク種別", "vi": "Loại task"},
|
||||
"schedtask.f_workspace": {"en": "Workspace", "ja": "ワークスペース", "vi": "Workspace"},
|
||||
"schedtask.no_workspace": {"en": "— No workspace —", "ja": "— ワークスペースなし —", "vi": "— Không có workspace —"},
|
||||
"schedtask.f_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
|
||||
"schedtask.no_agent": {"en": "— No agent preset —", "ja": "— エージェントなし —", "vi": "— Không dùng agent —"},
|
||||
"schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"},
|
||||
"schedtask.provider_default": {
|
||||
"en": "— Default (Settings) —", "ja": "— 既定(設定)—", "vi": "— Mặc định (Settings) —"},
|
||||
"schedtask.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
|
||||
"schedtask.model_placeholder": {
|
||||
"en": "Default model (leave blank to use Settings)",
|
||||
"ja": "既定のモデル(空欄で設定を使用)",
|
||||
"vi": "Model mặc định (để trống dùng Settings)"},
|
||||
"schedtask.load_models_tooltip": {
|
||||
"en": "Fetch this provider's available models",
|
||||
"ja": "このプロバイダーの利用可能なモデルを取得",
|
||||
"vi": "Tải danh sách model của provider này"},
|
||||
"schedtask.load_models_empty": {
|
||||
"en": "No models could be loaded. Check the provider/API key in Settings.",
|
||||
"ja": "モデルを取得できませんでした。設定のプロバイダー/APIキーを確認してください。",
|
||||
"vi": "Không tải được model nào. Kiểm tra provider/API key trong Settings."},
|
||||
"schedtask.f_skill": {"en": "Skill", "ja": "スキル", "vi": "Skill"},
|
||||
"schedtask.no_skill": {"en": "— No skill —", "ja": "— スキルなし —", "vi": "— Không dùng skill —"},
|
||||
"schedtask.hint_provider": {
|
||||
"en": "Which AI provider runs this task. Leave as Default to use the machine's Settings provider.",
|
||||
"ja": "このタスクを実行するAIプロバイダー。既定のままにすると設定のプロバイダーを使用します。",
|
||||
"vi": "Provider AI chạy task này. Để Mặc định để dùng provider trong Settings."},
|
||||
"schedtask.hint_model": {
|
||||
"en": "Model to run this task. Leave blank to use the provider's Settings model; click the button to load the real list.",
|
||||
"ja": "このタスクを実行するモデル。空欄で設定のモデルを使用。ボタンで実際の一覧を取得します。",
|
||||
"vi": "Model chạy task này. Để trống dùng model trong Settings; bấm nút để tải danh sách thực."},
|
||||
"schedtask.hint_skill": {
|
||||
"en": "Apply a saved skill's instructions to this task's run (its guidance is prepended to the prompt).",
|
||||
"ja": "保存済みスキルの指示をこのタスクの実行に適用します(プロンプトの先頭に追加されます)。",
|
||||
"vi": "Áp dụng hướng dẫn của một skill đã lưu vào lần chạy task này (được thêm vào đầu prompt)."},
|
||||
"schedtask.hint_workspace": {
|
||||
"en": "The project/workspace this task's agent runs in — its sandbox folder and shared instructions apply.",
|
||||
"ja": "このタスクのエージェントが実行されるプロジェクト/ワークスペース。そのサンドボックスフォルダと共有指示が適用されます。",
|
||||
"vi": "Project/workspace mà agent của task này sẽ chạy trong đó — áp dụng sandbox và hướng dẫn chung của project."},
|
||||
"schedtask.f_priority": {"en": "Priority", "ja": "優先度", "vi": "Độ ưu tiên"},
|
||||
"schedtask.f_status": {"en": "Status", "ja": "ステータス", "vi": "Trạng thái"},
|
||||
"schedtask.f_script": {"en": "Script command", "ja": "スクリプトコマンド", "vi": "Lệnh script"},
|
||||
"schedtask.script_placeholder": {
|
||||
"en": "(script tasks only) e.g. python report.py", "ja": "(Scriptタスクのみ)例: python report.py",
|
||||
"vi": "(chỉ task Script) vd: python report.py"},
|
||||
# The title/description block at the top of the Task editor had no name
|
||||
# either — needed once the index had to list it.
|
||||
"schedtask.g_basic": {"en": "Basics", "ja": "基本", "vi": "Thông tin chung"},
|
||||
# The three steps the editor is split into: what to do, when, and what it
|
||||
# connects to. Each holds the same group boxes as before.
|
||||
"schedtask.step_content": {"en": "Content", "ja": "内容", "vi": "Nội dung"},
|
||||
"schedtask.step_schedule": {"en": "Schedule", "ja": "スケジュール", "vi": "Lịch chạy"},
|
||||
"schedtask.step_link": {"en": "Links", "ja": "連携", "vi": "Liên kết"},
|
||||
"schedtask.g_schedule": {"en": "Schedule Setup", "ja": "スケジュール設定", "vi": "Thiết lập lịch chạy"},
|
||||
"schedtask.sched_enable": {"en": "Enable schedule", "ja": "スケジュールを有効化", "vi": "Bật lịch chạy"},
|
||||
"schedtask.f_run_at": {"en": "Run at", "ja": "実行日時", "vi": "Chạy lúc"},
|
||||
"schedtask.f_repeat": {"en": "Repeat", "ja": "繰り返し", "vi": "Lặp lại"},
|
||||
"schedtask.repeat.none": {"en": "None (one-time)", "ja": "なし(1回のみ)", "vi": "Không (chạy 1 lần)"},
|
||||
"schedtask.repeat.daily": {"en": "Daily", "ja": "毎日", "vi": "Hằng ngày"},
|
||||
"schedtask.repeat.weekly": {"en": "Weekly", "ja": "毎週", "vi": "Hằng tuần"},
|
||||
"schedtask.repeat.monthly": {"en": "Monthly", "ja": "毎月", "vi": "Hằng tháng"},
|
||||
"schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Cron expression"},
|
||||
"schedtask.f_task_mode": {"en": "Task type", "ja": "タスク種別", "vi": "Loại task"},
|
||||
# Run kind: an AI agent vs a saved Co4E flow + multi-format import
|
||||
"schedtask.f_run_kind": {"en": "Run", "ja": "実行対象", "vi": "Chạy"},
|
||||
"schedtask.kind_agent": {"en": "AI agent (Cowork)", "ja": "AIエージェント(Cowork)",
|
||||
"vi": "AI agent (Cowork)"},
|
||||
"schedtask.kind_flow": {"en": "Co4E flow", "ja": "Co4E フロー", "vi": "Co4E flow"},
|
||||
"schedtask.hint_run_kind": {
|
||||
"en": "AI agent = run one Cowork agent with the chosen model. Co4E flow = run a whole "
|
||||
"saved node-graph flow, step by step, in the sandbox.",
|
||||
"ja": "AIエージェント=選択モデルで Cowork エージェントを1つ実行。Co4E フロー=保存済みの"
|
||||
"ノードグラフ全体をサンドボックスで順に実行。",
|
||||
"vi": "AI agent = chạy một agent Cowork với model đã chọn. Co4E flow = chạy cả một flow "
|
||||
"node-graph đã lưu, tuần tự, trong sandbox."},
|
||||
"schedtask.f_flow": {"en": "Co4E flow", "ja": "Co4E フロー", "vi": "Co4E flow"},
|
||||
"schedtask.hint_flow": {
|
||||
"en": "Which saved Co4E flow this task runs (built-in or your own).",
|
||||
"ja": "このタスクが実行する保存済み Co4E フロー(組込み/自作)。",
|
||||
"vi": "Flow Co4E đã lưu mà task này sẽ chạy (có sẵn hoặc của bạn)."},
|
||||
"schedtask.flow_required": {
|
||||
"en": "Pick a Co4E flow to run (or switch Run to AI agent).",
|
||||
"ja": "実行する Co4E フローを選んでください(または実行対象を AI エージェントに)。",
|
||||
"vi": "Hãy chọn một flow Co4E để chạy (hoặc đổi Chạy sang AI agent)."},
|
||||
"schedtask.hint_task_mode": {
|
||||
"en": "Normal = runs once (or manually). Automation = a cronjob that repeats on a schedule "
|
||||
"(daily/weekly/monthly/cron). Switching to Automation reveals the recurrence options.",
|
||||
"ja": "通常=1回(または手動)実行。自動化=スケジュールで繰り返すCronジョブ(毎日/毎週/毎月/Cron)。"
|
||||
"自動化に切り替えると繰り返し設定が表示されます。",
|
||||
"vi": "Thông thường = chạy một lần (hoặc thủ công). Tự động = cronjob lặp theo lịch "
|
||||
"(ngày/tuần/tháng/cron). Chuyển sang Tự động sẽ hiện các tùy chọn lặp lại."},
|
||||
"schedtask.mode_normal": {
|
||||
"en": "Normal (one-time / manual)", "ja": "通常(1回 / 手動)",
|
||||
"vi": "Thông thường (một lần / thủ công)"},
|
||||
"schedtask.mode_automation": {
|
||||
"en": "Automation (cron / recurring)", "ja": "自動化(Cron / 繰り返し)",
|
||||
"vi": "Tự động (cronjob / lặp lại)"},
|
||||
"schedtask.f_cron": {"en": "Cron", "ja": "Cron", "vi": "Cron"},
|
||||
"schedtask.cron_sample_pick": {"en": "Sample ▾", "ja": "サンプル ▾", "vi": "Mẫu ▾"},
|
||||
"schedtask.cron_sample_tooltip": {
|
||||
"en": "Pick a ready-made schedule — it fills the cron box with correct syntax.",
|
||||
"ja": "定番スケジュールを選ぶと、正しい書式でCron欄に入力されます。",
|
||||
"vi": "Chọn một lịch mẫu — sẽ điền đúng cú pháp vào ô cron."},
|
||||
"schedtask.cron_s_weekday9": {"en": "Weekdays 9:00", "ja": "平日 9:00", "vi": "Ngày làm việc 9:00"},
|
||||
"schedtask.cron_s_daily8": {"en": "Every day 8:00", "ja": "毎日 8:00", "vi": "Mỗi ngày 8:00"},
|
||||
"schedtask.cron_s_weekly_mon": {"en": "Every Monday 9:00", "ja": "毎週月曜 9:00", "vi": "Thứ 2 hằng tuần 9:00"},
|
||||
"schedtask.cron_s_monthly1": {"en": "1st of month 9:00", "ja": "毎月1日 9:00", "vi": "Ngày 1 hằng tháng 9:00"},
|
||||
"schedtask.cron_s_every30m": {"en": "Every 30 minutes", "ja": "30分ごと", "vi": "Mỗi 30 phút"},
|
||||
"schedtask.cron_s_every2h": {"en": "Every 2 hours", "ja": "2時間ごと", "vi": "Mỗi 2 giờ"},
|
||||
"schedtask.cron_placeholder": {
|
||||
"en": "(repeat = Cron) e.g. 0 9 * * 1-5 — min hour day month weekday",
|
||||
"ja": "(繰り返し=Cron)例: 0 9 * * 1-5 — 分 時 日 月 曜日",
|
||||
"vi": "(khi lặp = Cron) vd: 0 9 * * 1-5 — phút giờ ngày tháng thứ"},
|
||||
"schedtask.cron_hint": {
|
||||
"en": "Only when Repeat = Cron. Fields: minute hour day-of-month month day-of-week "
|
||||
"(e.g. '0 9 * * 1-5' = 9:00 every weekday). Otherwise the run-time above is the "
|
||||
"daily/weekly/monthly notification time.",
|
||||
"ja": "繰り返し=Cronの場合のみ。書式: 分 時 日 月 曜日(例 '0 9 * * 1-5' = 平日9:00)。"
|
||||
"それ以外は上の実行時刻が毎日/毎週/毎月の通知時刻になります。",
|
||||
"vi": "Chỉ khi Lặp = Cron. Cú pháp: phút giờ ngày tháng thứ (vd '0 9 * * 1-5' = 9:00 các "
|
||||
"ngày trong tuần). Nếu không, giờ chạy ở trên là giờ thông báo hàng ngày/tuần/tháng."},
|
||||
"schedtask.cron_invalid": {
|
||||
"en": "Invalid cron expression: {err}", "ja": "Cron式が不正です: {err}",
|
||||
"vi": "Cron expression không hợp lệ: {err}"},
|
||||
"schedtask.cron_never_fires": {
|
||||
"en": "This cron expression never fires (within 2 years).",
|
||||
"ja": "このCron式は(2年以内に)一度も実行されません。",
|
||||
"vi": "Cron expression này không bao giờ chạy (trong vòng 2 năm)."},
|
||||
"schedtask.workdays_only": {"en": "Working days only (skip Sat/Sun)", "ja": "平日のみ(土日をスキップ)", "vi": "Chỉ ngày làm việc (bỏ T7/CN)"},
|
||||
"schedtask.skip_holidays": {
|
||||
"en": "Skip public holidays", "ja": "祝日をスキップ", "vi": "Bỏ qua ngày nghỉ lễ"},
|
||||
"schedtask.holiday_country": {"en": "Country:", "ja": "国:", "vi": "Quốc gia:"},
|
||||
"schedtask.f_notify": {"en": "Reminder", "ja": "リマインダー", "vi": "Nhắc nhở"},
|
||||
"schedtask.f_notify_email": {"en": "Send to", "ja": "送信先", "vi": "Gửi tới"},
|
||||
"schedtask.notify.none": {"en": "— No reminder —", "ja": "— リマインダーなし —", "vi": "— Không nhắc —"},
|
||||
"schedtask.notify.teams": {"en": "Teams (webhook)", "ja": "Teams(Webhook)", "vi": "Teams (webhook)"},
|
||||
"schedtask.notify.outlook": {
|
||||
"en": "Email via Outlook (this PC)", "ja": "Outlookでメール(このPC)",
|
||||
"vi": "Email qua Outlook (máy này)"},
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Chuỗi hiển thị — phần cowork_tab.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"dashboard.estimated_note": {
|
||||
"en": "~{pct}% of turns are estimated (~4 chars/token) — the gateway didn't report exact usage.",
|
||||
"ja": "約{pct}%のターンは推定値(約4文字/トークン)です。",
|
||||
"vi": "~{pct}% lượt là ước tính (~4 ký tự/token) — gateway không trả về usage chính xác."},
|
||||
"dashboard.ai_analyze_btn": {"en": "AI analyze", "ja": "AI分析", "vi": "AI phân tích"},
|
||||
"dashboard.ai_analyzing": {"en": "Analyzing…", "ja": "分析中…", "vi": "Đang phân tích…"},
|
||||
"dashboard.ai_analyze_tooltip": {
|
||||
"en": "AI reviews the aggregated numbers (never your prompt contents) and suggests how to prompt better and spend fewer tokens.",
|
||||
"ja": "集計値のみをAIがレビューし(プロンプト内容は送信しません)、トークン削減のコツを提案します。",
|
||||
"vi": "AI xem các con số tổng hợp (không gửi nội dung prompt) và gợi ý cách viết prompt tốt hơn, tốn ít token hơn."},
|
||||
"dashboard.ai_advice_title": {
|
||||
"en": "AI recommendations", "ja": "AIの提案", "vi": "Khuyến nghị từ AI"},
|
||||
"dashboard.period_tooltip": {
|
||||
"en": "Time range for all numbers on this page.", "ja": "このページ全体の集計期間。",
|
||||
"vi": "Khoảng thời gian tính mọi con số trên trang này."},
|
||||
"dashboard.source_tooltip": {
|
||||
"en": "Filter by one task/session, or all.", "ja": "タスク/セッション単位で絞り込み。",
|
||||
"vi": "Lọc theo 1 task/phiên, hoặc tất cả."},
|
||||
"dashboard.currency_tooltip": {
|
||||
"en": "Display currency (rates: fixed USD→VND/JPY, editable in config).",
|
||||
"ja": "表示通貨(USD→VND/JPYの固定レート、configで変更可)。",
|
||||
"vi": "Tiền tệ hiển thị (tỉ giá USD→VND/JPY cố định, sửa được trong config)."},
|
||||
"dashboard.price_in_tooltip": {
|
||||
"en": "USD per 1M input tokens (your gateway's price).",
|
||||
"ja": "入力100万トークンあたりのUSD単価。", "vi": "USD cho 1 triệu token input (giá của gateway bạn dùng)."},
|
||||
"dashboard.price_out_tooltip": {
|
||||
"en": "USD per 1M output tokens.", "ja": "出力100万トークンあたりのUSD単価。",
|
||||
"vi": "USD cho 1 triệu token output."},
|
||||
"dashboard.price_cache_tooltip": {
|
||||
"en": "USD per 1M cached tokens.", "ja": "キャッシュ100万トークンあたりのUSD単価。",
|
||||
"vi": "USD cho 1 triệu token cache."},
|
||||
# ---- cowork_tab.py -------------------------------------------------
|
||||
"cowork.title": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"cowork.skills_btn": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
|
||||
"cowork.skills_tooltip": {
|
||||
"en": "Add / manage skills the agent follows (or type /skill).",
|
||||
"ja": "エージェントが従うスキルを追加/管理(/skill と入力も可)。",
|
||||
"vi": "Thêm/quản lý skill mà agent tuân theo (hoặc gõ /skill)."},
|
||||
"cowork.new_chat": {"en": "New chat", "ja": "新しいチャット", "vi": "Cuộc trò chuyện mới"},
|
||||
"cowork.assistant_title": {"en": "Internal Agent", "ja": "内部エージェント", "vi": "Internal Agent"},
|
||||
"cowork.project_label": {"en": "{name}", "ja": "{name}", "vi": "{name}"},
|
||||
"cowork.project_tooltip": {
|
||||
"en": "This thread belongs to project “{name}” — its shared instructions and workspace apply. Manage projects in the Workspace screen.",
|
||||
"ja": "このスレッドはプロジェクト「{name}」に属します — 共有指示とワークスペースが適用されます。プロジェクトはワークスペース画面で管理できます。",
|
||||
"vi": "Thread này thuộc project “{name}” — instructions chung và workspace của project được áp dụng. Quản lý project trong màn hình Workspace.",
|
||||
},
|
||||
"cowork.pick_folder_btn": {"en": "Local folder…",
|
||||
"ja": "ローカルフォルダ…",
|
||||
"vi": "Thư mục Local…"},
|
||||
"cowork.pick_folder_tooltip": {
|
||||
"en": "Save Cowork's output directly into a folder you choose, instead of "
|
||||
"auto-creating a new session folder under Output.",
|
||||
"ja": "Output 配下に新しいセッションフォルダを自動作成する代わりに、選んだフォルダに直接保存します。",
|
||||
"vi": "Lưu output của Cowork trực tiếp vào thư mục bạn chọn, thay vì tự tạo "
|
||||
"folder phiên mới trong Output."},
|
||||
"cowork.pick_folder_title": {"en": "Choose the Cowork output folder",
|
||||
"ja": "Cowork の出力フォルダを選択",
|
||||
"vi": "Chọn thư mục output cho Cowork"},
|
||||
|
||||
# ---- code_tab.py -----------------------------------------------
|
||||
"code.title": {"en": "Code", "ja": "Code", "vi": "Code"},
|
||||
"code.files_header": {"en": "Files", "ja": "ファイル", "vi": "Files"},
|
||||
"code.collapse_file_panel": {
|
||||
"en": "Collapse the file panel", "ja": "ファイルパネルを折りたたむ", "vi": "Thu gọn bảng cây thư mục"},
|
||||
"code.expand_file_panel": {
|
||||
"en": "Click to expand the file panel", "ja": "クリックしてファイルパネルを展開",
|
||||
"vi": "Bấm để mở lại bảng cây thư mục"},
|
||||
"code.local_btn": {"en": "Local…", "ja": "ローカル…", "vi": "Local…"},
|
||||
"code.onedrive_btn": {"en": "OneDrive", "ja": "OneDrive", "vi": "OneDrive"},
|
||||
"code.onedrive_badge": {"en": "OneDrive", "ja": "OneDrive", "vi": "OneDrive"},
|
||||
"code.auto_run": {"en": "Auto-run", "ja": "自動実行", "vi": "Tự động"},
|
||||
"code.auto_run_tooltip": {
|
||||
"en": "On: agent writes files / runs commands automatically. Off: ask before each action.",
|
||||
"ja": "オン:エージェントが自動でファイル書き込み/コマンド実行。オフ:毎回確認します。",
|
||||
"vi": "Bật: agent tự ghi file/chạy lệnh. Tắt: hỏi xác nhận trước mỗi thao tác."},
|
||||
"code.skills_tooltip": {
|
||||
"en": "Add / set skills for the agent to follow (or type /skill).",
|
||||
"ja": "エージェントが従うスキルを追加/設定(/skill と入力も可)。",
|
||||
"vi": "Thêm/đặt skill mà agent tuân theo (hoặc gõ /skill)."},
|
||||
"code.flow_chk": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
|
||||
"code.flow_chk_tooltip": {
|
||||
"en": "Enable the predefined Req→Demo flow feature (off by default).",
|
||||
"ja": "定義済みの Req→Demo フロー機能を有効化(初期値はオフ)。",
|
||||
"vi": "Bật tính năng Flow Req→Demo dựng sẵn (mặc định tắt)."},
|
||||
"code.flow_btn": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"},
|
||||
"code.flow_btn_tooltip": {
|
||||
"en": "Build and run a multi-stage flow from requirement to demo.",
|
||||
"ja": "要件からデモまでの多段フローを作成・実行します。",
|
||||
"vi": "Xây dựng và chạy quy trình nhiều bước từ yêu cầu đến bản demo."},
|
||||
"code.new_session": {"en": "New session", "ja": "新しいセッション", "vi": "Phiên mới"},
|
||||
"code.cli_tooltip": {
|
||||
"en": "Open a terminal (CLI) at the current working folder",
|
||||
"ja": "現在の作業フォルダでターミナル(CLI)を開く",
|
||||
"vi": "Mở CLI (terminal) tại thư mục làm việc hiện tại"},
|
||||
"code.cli_not_found": {
|
||||
"en": "No terminal application was found on this system.",
|
||||
"ja": "このシステムにはターミナルアプリが見つかりませんでした。",
|
||||
"vi": "Không tìm thấy ứng dụng terminal nào trên máy này."},
|
||||
"code.skills_btn_count": {"en": "Skills ({n})", "ja": "スキル ({n})", "vi": "Skills ({n})"},
|
||||
"code.assistant_title": {"en": "Code agent", "ja": "Code エージェント", "vi": "Code agent"},
|
||||
"code.plan": {"en": "Plan", "ja": "プラン", "vi": "Plan"},
|
||||
"code.act": {"en": "Act", "ja": "実行", "vi": "Act"},
|
||||
"code.mode_toggle_tooltip": {
|
||||
"en": "Plan = analyze only (no file writes). Act = execute. Auto-switches to Act on gencode.",
|
||||
"ja": "Plan=分析のみ(書き込みなし)。Act=実行。コード生成指示で自動的に Act に切替。",
|
||||
"vi": "Plan = chỉ phân tích (không ghi file). Act = thực thi. Tự chuyển sang Act khi phát hiện yêu cầu sinh code."},
|
||||
"code.act_status": {"en": "Code: Act mode (executes).", "ja": "Code: Act モード(実行)。", "vi": "Code: chế độ Act (thực thi)."},
|
||||
"code.plan_status": {"en": "Code: Plan mode (analyze only).", "ja": "Code: Plan モード(分析のみ)。", "vi": "Code: chế độ Plan (chỉ phân tích)."},
|
||||
"code.cloud_sync_suffix": {"en": " — files sync to cloud", "ja": " — クラウドに同期", "vi": " — file sẽ đồng bộ lên cloud"},
|
||||
"code.mode_auto_status": {"en": "Code: Auto-run mode.", "ja": "Code: 自動実行モード。", "vi": "Code: chế độ Tự động."},
|
||||
"code.mode_confirm_status": {"en": "Code: Confirm mode.", "ja": "Code: 確認モード。", "vi": "Code: chế độ Xác nhận."},
|
||||
"code.pick_local_title": {"en": "Choose working folder (Local)", "ja": "作業フォルダを選択(ローカル)", "vi": "Chọn thư mục làm việc (Local)"},
|
||||
"code.pick_onedrive_title": {"en": "Choose a folder in OneDrive", "ja": "OneDrive 内のフォルダを選択", "vi": "Chọn thư mục trong OneDrive"},
|
||||
"code.onedrive_choose_folder": {
|
||||
"en": "Choose a folder in OneDrive…", "ja": "OneDrive 内のフォルダを選択…", "vi": "Chọn thư mục trong OneDrive…"},
|
||||
"code.no_onedrive": {"en": "No OneDrive detected", "ja": "OneDrive が見つかりません", "vi": "Không phát hiện OneDrive"},
|
||||
"code.flow_running": {
|
||||
"en": "Running flow '{name}' (Act) — {n} stages.",
|
||||
"ja": "フロー「{name}」を実行中(Act)— {n} ステージ。",
|
||||
"vi": "Đang chạy flow '{name}' (Act) — {n} bước."},
|
||||
|
||||
# ---- settings_dialog.py --------------------------------------------
|
||||
"settings.title": {"en": "Settings", "ja": "設定", "vi": "Cài đặt"},
|
||||
"settings.active_provider": {"en": "Active provider", "ja": "使用中のプロバイダー", "vi": "Nhà cung cấp đang dùng"},
|
||||
"settings.theme": {"en": "Theme", "ja": "テーマ", "vi": "Giao diện"},
|
||||
"settings.theme_dark": {"en": "Dark", "ja": "ダーク", "vi": "Tối"},
|
||||
"settings.theme_light": {"en": "Light", "ja": "ライト", "vi": "Sáng"},
|
||||
"settings.theme_system": {"en": "Auto (System)", "ja": "自動(システム)", "vi": "Tự động (theo hệ thống)"},
|
||||
"settings.language": {"en": "Language", "ja": "言語", "vi": "Ngôn ngữ"},
|
||||
"settings.tray_keep": {
|
||||
"en": "Keep running in the system tray when the window is closed",
|
||||
"ja": "ウィンドウを閉じてもシステムトレイで実行を継続",
|
||||
"vi": "Giữ chạy nền trong khay hệ thống khi đóng cửa sổ"},
|
||||
"settings.tray_notify": {
|
||||
"en": "Show a tray notification when a task finishes or fails",
|
||||
"ja": "タスク完了/失敗時にトレイ通知を表示",
|
||||
"vi": "Hiện thông báo khay hệ thống khi tác vụ xong hoặc lỗi"},
|
||||
"settings.group.openai": {"en": "OpenAI-compatible (Internal Gateway)", "ja": "OpenAI 互換(社内ゲートウェイ)", "vi": "OpenAI-compatible (Gateway nội bộ)"},
|
||||
"settings.group.anthropic": {"en": "Anthropic Claude", "ja": "Anthropic Claude", "vi": "Anthropic Claude"},
|
||||
# Name for the language/tray block at the top of Settings — it had none,
|
||||
# because until the index existed nothing had to refer to it.
|
||||
"settings.group.general": {"en": "General", "ja": "一般", "vi": "Chung"},
|
||||
"settings.group.provider": {"en": "AI Provider", "ja": "AI プロバイダー", "vi": "Nhà cung cấp AI"},
|
||||
"settings.group.parameter": {"en": "Parameter", "ja": "Parameter", "vi": "Parameter"},
|
||||
"settings.param_section_pricing": {
|
||||
"en": "Model pricing", "ja": "モデル価格", "vi": "Bảng giá model"},
|
||||
"settings.pricing_url_label": {
|
||||
"en": "Pricing reference link", "ja": "価格表の参考リンク", "vi": "Link bảng giá tham khảo"},
|
||||
"settings.pricing_url_placeholder": {
|
||||
"en": "https://… (the provider's public price list)",
|
||||
"ja": "https://…(プロバイダーの公開価格表)",
|
||||
"vi": "https://… (trang bảng giá công khai của provider)"},
|
||||
"settings.pricing_url_tooltip": {
|
||||
"en": "Shown as a reference link beside the Monitoring pricing table. Prices themselves are entered by hand in that table.",
|
||||
"ja": "監視画面の価格表の横に参考リンクとして表示されます。価格自体は表に手入力します。",
|
||||
"vi": "Hiển thị làm link tham khảo cạnh bảng giá trong Monitoring. Giá vẫn do Admin nhập tay vào bảng."},
|
||||
"settings.group.accounts": {
|
||||
"en": "Shared accounts folder", "ja": "共有アカウントフォルダー", "vi": "Thư mục tài khoản dùng chung"},
|
||||
"settings.accounts_dir_label": {"en": "Folder", "ja": "フォルダー", "vi": "Thư mục"},
|
||||
"settings.accounts_dir_placeholder": {
|
||||
"en": "OneDrive/network folder holding the shared accounts & groups",
|
||||
"ja": "アカウント/グループを保存する OneDrive・共有フォルダー",
|
||||
"vi": "Thư mục OneDrive/mạng chứa danh sách tài khoản & nhóm dùng chung"},
|
||||
"settings.accounts_dir_hint": {
|
||||
"en": "Where accounts, groups and shared telemetry live. Every machine must point at the SAME folder.",
|
||||
"ja": "アカウント・グループ・共有テレメトリの保存先。全マシンで同じフォルダーを指定してください。",
|
||||
"vi": "Nơi lưu tài khoản, nhóm và telemetry dùng chung. Mọi máy phải trỏ về CÙNG một thư mục."},
|
||||
"settings.accounts_dir_admin_only": {
|
||||
"en": "Only an Admin can change this folder.",
|
||||
"ja": "このフォルダーを変更できるのは管理者のみです。",
|
||||
"vi": "Chỉ Admin mới thay đổi được thư mục này."},
|
||||
"settings.group.monitoring_visibility": {
|
||||
"en": "Monitoring tab visibility (Sub-admin)", "ja": "モニタリングタブの表示(サブ管理者)",
|
||||
"vi": "Hiển thị tab Monitoring (Sub-admin)"},
|
||||
"settings.mv_security_events": {"en": "Security Events", "ja": "セキュリティイベント",
|
||||
"vi": "Security Events"},
|
||||
"settings.mv_mcp_history": {"en": "MCP Call History", "ja": "MCP 呼び出し履歴", "vi": "MCP Call History"},
|
||||
"settings.mv_action_logs": {"en": "Action Logs", "ja": "アクションログ", "vi": "Action Logs"},
|
||||
"settings.mv_agent_status": {"en": "Agent Status", "ja": "エージェント状態", "vi": "Trạng thái Agent"},
|
||||
"settings.mv_hint": {
|
||||
"en": "Admin always sees every Monitoring tab. Turn one off here to hide it from Sub-admin too (it stays available to Admin).",
|
||||
"ja": "管理者は常にすべてのタブを見られます。ここでオフにすると、そのタブはサブ管理者からも隠されます(管理者には影響しません)。",
|
||||
"vi": "Admin luôn thấy mọi tab Monitoring. Tắt một mục ở đây sẽ ẩn tab đó với Sub-admin (Admin vẫn thấy như thường)."},
|
||||
"settings.sec_unlock_user_placeholder": {
|
||||
"en": "Admin account", "ja": "管理者アカウント", "vi": "Tài khoản admin"},
|
||||
"settings.sec_unlock_code_placeholder": {
|
||||
"en": "Access code", "ja": "アクセスコード", "vi": "Mã truy cập"},
|
||||
"settings.sec_unlock_btn": {"en": "Unlock", "ja": "ロック解除", "vi": "Mở khóa"},
|
||||
"settings.sec_locked_hint": {
|
||||
"en": "Locked — enter an Admin account + access code to change these settings.",
|
||||
"ja": "ロック中 — 変更するには管理者アカウントとアクセスコードを入力してください。",
|
||||
"vi": "Đang khóa — nhập tài khoản Admin + mã truy cập để thay đổi các thiết lập này."},
|
||||
"settings.sec_unlocked_hint": {
|
||||
"en": "Unlocked — changes will be saved; the group locks again after Save.",
|
||||
"ja": "ロック解除中 — 保存後に再びロックされます。",
|
||||
"vi": "Đã mở khóa — thay đổi sẽ được lưu; nhóm sẽ tự khóa lại sau khi Save."},
|
||||
"settings.sec_unlock_failed": {
|
||||
"en": "Not an Admin account (or wrong code / accounts folder unreachable).",
|
||||
"ja": "管理者アカウントではありません(またはコード誤り・フォルダー未接続)。",
|
||||
"vi": "Không phải tài khoản Admin (hoặc sai mã / không truy cập được thư mục tài khoản)."},
|
||||
"settings.sec_no_lock_hint": {
|
||||
"en": "No shared accounts folder configured yet — the group is editable without an admin unlock.",
|
||||
"ja": "共有アカウントフォルダー未設定のため、ロックなしで編集できます。",
|
||||
"vi": "Chưa cấu hình thư mục tài khoản dùng chung — nhóm này đang chỉnh sửa được mà không cần mở khóa."},
|
||||
"settings.base_url": {"en": "Base URL", "ja": "ベース URL", "vi": "Base URL"},
|
||||
"settings.api_key": {"en": "API Key", "ja": "API キー", "vi": "API Key"},
|
||||
"settings.model": {"en": "Model", "ja": "モデル", "vi": "Model"},
|
||||
"settings.load": {"en": "Load", "ja": "読み込み", "vi": "Tải"},
|
||||
"settings.load_tooltip": {
|
||||
"en": "Fetch the available models/agents from this provider",
|
||||
"ja": "このプロバイダーから利用可能なモデル/エージェントを取得",
|
||||
"vi": "Lấy danh sách model/agent khả dụng từ nhà cung cấp này"},
|
||||
"settings.group.teams": {"en": "Microsoft Teams", "ja": "Microsoft Teams", "vi": "Microsoft Teams"},
|
||||
"settings.teams_webhook": {"en": "Webhook URL", "ja": "Webhook URL", "vi": "Webhook URL"},
|
||||
"settings.teams_webhook_placeholder": {
|
||||
"en": "https://… (Workflows or Incoming Webhook URL)",
|
||||
"ja": "https://…(Workflows または Incoming Webhook の URL)",
|
||||
"vi": "https://… (URL của Workflows hoặc Incoming Webhook)"},
|
||||
"settings.teams_test": {"en": "Test", "ja": "テスト", "vi": "Kiểm tra"},
|
||||
"settings.teams_notify": {
|
||||
"en": "Auto-send to Teams when a task completes", "ja": "タスク完了時に Teams へ自動送信",
|
||||
"vi": "Tự động gửi sang Teams khi tác vụ hoàn thành"},
|
||||
"settings.teams_hint": {
|
||||
"en": ("Get a webhook: Teams channel → ⋯ → Connectors → Incoming Webhook, "
|
||||
"OR Power Automate → 'When a HTTP request is received' → 'Post message in a chat or channel'. "
|
||||
"URL must contain logic.azure.com or webhook.office.com."),
|
||||
"ja": ("Webhook の取得: Teams チャンネル → ⋯ → コネクタ → Incoming Webhook、"
|
||||
"または Power Automate → 'HTTP要求の受信時' → 'チャットまたはチャネルにメッセージを投稿'。"
|
||||
"URL には logic.azure.com か webhook.office.com を含める必要があります。"),
|
||||
"vi": ("Lấy webhook: kênh Teams → ⋯ → Connectors → Incoming Webhook, "
|
||||
"HOẶC Power Automate → 'When a HTTP request is received' → 'Post message in a chat or channel'. "
|
||||
"URL phải chứa logic.azure.com hoặc webhook.office.com.")},
|
||||
"settings.group.ms365": {
|
||||
"en": "Microsoft 365 connections", "ja": "Microsoft 365 連携",
|
||||
"vi": "Kết nối Microsoft 365"},
|
||||
"settings.ms365_unlock_code": {"en": "Unlock code", "ja": "解除コード", "vi": "Mã mở khóa"},
|
||||
"settings.ms365_unlock_placeholder": {
|
||||
"en": "Enter the unlock code", "ja": "解除コードを入力",
|
||||
"vi": "Nhập mã để mở khóa"},
|
||||
"settings.ms365_unlock_btn": {"en": "Unlock", "ja": "解除", "vi": "Mở khóa"},
|
||||
"settings.ms365_locked_hint": {
|
||||
"en": "Locked — enter the unlock code above to edit this section.",
|
||||
"ja": "ロック中 — このセクションを編集するには上の解除コードを入力してください。",
|
||||
"vi": "Đang khóa — nhập mã ở trên để chỉnh sửa mục này."},
|
||||
"settings.ms365_unlocked_hint": {
|
||||
"en": "Unlocked — remember to click Save; this section re-locks automatically afterward.",
|
||||
"ja": "解除しました — 保存を忘れずに。保存後は自動的に再ロックされます。",
|
||||
"vi": "Đã mở khóa — nhớ bấm Save; mục này sẽ tự khóa lại ngay sau đó."},
|
||||
"settings.ms365_wrong_code": {
|
||||
"en": "Wrong code.", "ja": "コードが違います。", "vi": "Mã không đúng."},
|
||||
"settings.ms365_connector.outlook": {"en": "Outlook", "ja": "Outlook", "vi": "Outlook"},
|
||||
"settings.ms365_connector.teams": {"en": "Teams", "ja": "Teams", "vi": "Teams"},
|
||||
"settings.ms365_connector.onedrive": {"en": "OneDrive", "ja": "OneDrive", "vi": "OneDrive"},
|
||||
"settings.ms365_connector.sharepoint": {"en": "SharePoint", "ja": "SharePoint", "vi": "SharePoint"},
|
||||
"settings.ms365_connector.meeting_transcript": {
|
||||
"en": "Meeting transcript", "ja": "会議の文字起こし", "vi": "Meeting transcript"},
|
||||
"settings.ms365_allow_internet": {
|
||||
"en": "Allow external internet access", "ja": "外部インターネットアクセスを許可",
|
||||
"vi": "Cho phép truy cập Internet bên ngoài"},
|
||||
"settings.ms365_internet_off_hint": {
|
||||
"en": ("External internet access is OFF — every connector was turned off to avoid "
|
||||
"leaking data outside. Turn it back on, then re-tick the connectors you want."),
|
||||
"ja": ("外部インターネットアクセスがオフです — データが外部に漏れないよう、すべてのコネクタ"
|
||||
"がオフになりました。再度オンにしてから、必要なコネクタを選び直してください。"),
|
||||
"vi": ("Đã tắt truy cập Internet bên ngoài — mọi connector đã tự tắt để tránh rò rỉ "
|
||||
"thông tin ra ngoài. Bật lại rồi tick lại từng connector muốn dùng.")},
|
||||
"settings.ms365_signin_btn": {"en": "Sign in with Microsoft", "ja": "Microsoft でサインイン",
|
||||
"vi": "Đăng nhập Microsoft"},
|
||||
"settings.ms365_signout_btn": {"en": "Sign out", "ja": "サインアウト", "vi": "Đăng xuất"},
|
||||
"settings.ms365_not_signed_in": {
|
||||
"en": "Not signed in to Microsoft 365.", "ja": "Microsoft 365 にサインインしていません。",
|
||||
"vi": "Chưa đăng nhập Microsoft 365."},
|
||||
"settings.ms365_signed_in_as": {
|
||||
"en": "Signed in as {user}", "ja": "{user} としてサインイン中",
|
||||
"vi": "Đã đăng nhập với {user}"},
|
||||
"settings.ms365_missing_ids": {
|
||||
"en": "Enter the Tenant ID and Client ID first.", "ja": "先に Tenant ID と Client ID を入力してください。",
|
||||
"vi": "Hãy nhập Tenant ID và Client ID trước."},
|
||||
"settings.ms365_signing_in": {
|
||||
"en": "Starting sign-in…", "ja": "サインインを開始しています…", "vi": "Đang bắt đầu đăng nhập…"},
|
||||
"settings.ms365_signin_failed": {
|
||||
"en": "Sign-in failed: {err}", "ja": "サインインに失敗しました: {err}",
|
||||
"vi": "Đăng nhập thất bại: {err}"},
|
||||
"settings.ms365_teams_link_label": {
|
||||
"en": "Or just paste a Teams channel/chat link — no ID needed:",
|
||||
"ja": "または Teams のチャネル/チャットのリンクを貼り付けるだけ — ID は不要です:",
|
||||
"vi": "Hoặc chỉ cần paste link kênh/chat Teams — không cần ID:"},
|
||||
"settings.ms365_teams_link_placeholder": {
|
||||
"en": "Paste a link from Teams ('Get link to channel' or a message's 'Copy link')",
|
||||
"ja": "Teams のリンクを貼り付け(「チャネルへのリンクを取得」またはメッセージの「リンクをコピー」)",
|
||||
"vi": "Dán link từ Teams ('Get link to channel' hoặc 'Copy link' của 1 tin nhắn)"},
|
||||
"settings.ms365_teams_connect_btn": {"en": "Connect", "ja": "接続", "vi": "Kết nối"},
|
||||
"settings.ms365_teams_not_connected": {
|
||||
"en": "No Teams chat/channel connected yet.", "ja": "Teams のチャット/チャネルはまだ接続されていません。",
|
||||
"vi": "Chưa kết nối chat/kênh Teams nào."},
|
||||
"settings.ms365_teams_connected_channel": {
|
||||
"en": "Connected to a Teams channel.", "ja": "Teams のチャネルに接続済みです。",
|
||||
"vi": "Đã kết nối vào một kênh Teams."},
|
||||
"settings.ms365_teams_connected_chat": {
|
||||
"en": "Connected to a Teams chat.", "ja": "Teams のチャットに接続済みです。",
|
||||
"vi": "Đã kết nối vào một đoạn chat Teams."},
|
||||
"settings.ms365_teams_link_missing": {
|
||||
"en": "Paste a Teams link first.", "ja": "先に Teams のリンクを貼り付けてください。",
|
||||
"vi": "Hãy dán link Teams trước."},
|
||||
"settings.ms365_teams_connecting": {
|
||||
"en": "Connecting…", "ja": "接続しています…", "vi": "Đang kết nối…"},
|
||||
"settings.ms365_teams_connect_failed": {
|
||||
"en": "Connect failed: {err}", "ja": "接続に失敗しました: {err}",
|
||||
"vi": "Kết nối thất bại: {err}"},
|
||||
"settings.ms365_teams_intro_message": {
|
||||
"en": "Hi, I'm the Cowork agent — just connected to this chat/channel.",
|
||||
"ja": "こんにちは、Cowork エージェントです — このチャット/チャネルに接続しました。",
|
||||
"vi": "Xin chào, mình là Cowork agent — vừa kết nối vào chat/kênh này."},
|
||||
"settings.group.agent_security": {
|
||||
"en": "Agent Security (AI)", "ja": "エージェント セキュリティ(AI)",
|
||||
"vi": "Agent Security (AI)"},
|
||||
"settings.group.sandbox": {
|
||||
"en": "Sandbox Security Layer", "ja": "サンドボックス セキュリティ層",
|
||||
"vi": "Sandbox Security Layer"},
|
||||
"settings.sandbox_confirm_commands": {
|
||||
"en": "Confirm before Cowork runs a command",
|
||||
"ja": "Cowork がコマンドを実行する前に確認する",
|
||||
"vi": "Xác nhận trước khi Cowork chạy lệnh"},
|
||||
"settings.sandbox_confirm_commands_tooltip": {
|
||||
"en": ("Shows an Approve/Reject dialog before run_command/install_package "
|
||||
"executes in Cowork — off by default (auto-run), same as before."),
|
||||
"ja": "Cowork で run_command/install_package を実行する前に承認/拒否ダイアログを表示します — "
|
||||
"デフォルトはオフ(自動実行)で、これまでと同じです。",
|
||||
"vi": "Hiện hộp thoại Duyệt/Từ chối trước khi Cowork chạy run_command/install_package — "
|
||||
"mặc định tắt (tự chạy), giống hành vi cũ."},
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
"""Chuỗi hiển thị — phần hint.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"schedtask.notify_email_placeholder": {
|
||||
"en": "recipient@example.com (comma-separated)",
|
||||
"ja": "recipient@example.com(カンマ区切り)",
|
||||
"vi": "nguoinhan@example.com (cách nhau dấu phẩy)"},
|
||||
"schedtask.notify_hint": {
|
||||
"en": "When the scheduled/cron task finishes, send a reminder. Teams uses the webhook "
|
||||
"from Settings; Outlook sends from your signed-in Outlook desktop app — no login needed.",
|
||||
"ja": "スケジュール/Cronタスク完了時にリマインダーを送信。Teamsは設定のWebhookを使用、"
|
||||
"Outlookはサインイン済みのOutlookデスクトップから送信(ログイン不要)。",
|
||||
"vi": "Khi task theo lịch/cron chạy xong sẽ gửi nhắc. Teams dùng webhook trong Settings; "
|
||||
"Outlook gửi từ ứng dụng Outlook đã đăng nhập trên máy — không cần đăng nhập lại."},
|
||||
"schedtask.notify_need_email": {
|
||||
"en": "Enter a recipient address for the Outlook reminder.",
|
||||
"ja": "Outlookリマインダーの送信先アドレスを入力してください。",
|
||||
"vi": "Hãy nhập địa chỉ người nhận cho nhắc nhở qua Outlook."},
|
||||
"schedtask.notify_need_webhook": {
|
||||
"en": "Teams reminder needs a webhook URL — set it in Settings → Parameter first.",
|
||||
"ja": "TeamsリマインダーにはWebhook URLが必要です。先に設定→パラメータで設定してください。",
|
||||
"vi": "Nhắc qua Teams cần webhook URL — hãy đặt trong Settings → Parameter trước."},
|
||||
"schedtask.tz_local_note": {
|
||||
"en": "Times use this machine's local timezone.", "ja": "時刻はこのPCのローカルタイムゾーンです。",
|
||||
"vi": "Giờ dùng múi giờ local của máy này."},
|
||||
"schedtask.g_flow": {"en": "Flow Setup", "ja": "フロー設定", "vi": "Thiết lập Flow"},
|
||||
"schedtask.flow_hint": {
|
||||
"en": "(Flow tasks only) Steps run in order; each step's output feeds the next step's input.",
|
||||
"ja": "(Flowタスクのみ)ステップは順番に実行され、前ステップの出力が次の入力になります。",
|
||||
"vi": "(Chỉ task Flow) Các bước chạy tuần tự; output bước trước nối vào input bước sau."},
|
||||
"schedtask.flow_template": {"en": "Code template:", "ja": "Codeテンプレート:", "vi": "Template Code:"},
|
||||
"schedtask.import_flow_btn": {"en": "Import steps", "ja": "ステップ取込", "vi": "Nhập các bước"},
|
||||
"schedtask.flow_template_empty": {
|
||||
"en": "The selected template has no steps.", "ja": "選択したテンプレートにステップがありません。",
|
||||
"vi": "Template đã chọn không có bước nào."},
|
||||
"schedtask.step_name_ph": {"en": "Step name", "ja": "ステップ名", "vi": "Tên bước"},
|
||||
"schedtask.step_prompt_ph": {"en": "Prompt / command", "ja": "プロンプト/コマンド", "vi": "Prompt / lệnh"},
|
||||
"schedtask.stepexec.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"schedtask.stepexec.co4e": {"en": "Code", "ja": "Code", "vi": "Code"},
|
||||
"schedtask.stepexec.script": {"en": "Script", "ja": "Script", "vi": "Script"},
|
||||
"schedtask.stepexec.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"},
|
||||
"schedtask.del_step_tooltip": {"en": "Delete the selected step", "ja": "選択したステップを削除",
|
||||
"vi": "Xóa bước đang chọn"},
|
||||
"schedtask.guide_tooltip": {
|
||||
"en": "Open the Schedule Task user guide", "ja": "Schedule Task の使い方ガイドを開く",
|
||||
"vi": "Mở hướng dẫn sử dụng Schedule Task"},
|
||||
"schedtask.guide_missing": {
|
||||
"en": "Guide file not found (docs/schedule_task_user_guide.md).",
|
||||
"ja": "ガイドファイルが見つかりません (docs/schedule_task_user_guide.md)。",
|
||||
"vi": "Không tìm thấy file hướng dẫn (docs/schedule_task_user_guide.md)."},
|
||||
"schedtask.msg_set_schedule": {
|
||||
"en": "Set a run time so this task can actually run on schedule.",
|
||||
"ja": "実行時刻を設定するとスケジュール実行されます。",
|
||||
"vi": "Hãy đặt giờ chạy để task này thực sự chạy theo lịch."},
|
||||
# ---- hint tooltips (hover help) --------------------------------------
|
||||
"schedtask.add_tooltip": {
|
||||
"en": "Create a new task with full options (schedule, input, dependencies…).",
|
||||
"ja": "新しいタスクを作成(スケジュール・入力・依存など全設定)。",
|
||||
"vi": "Tạo task mới với đầy đủ tuỳ chọn (lịch, input, phụ thuộc…)."},
|
||||
"schedtask.search_tooltip": {
|
||||
"en": "Filter cards by title/description.", "ja": "タイトル/説明でカードを絞り込み。",
|
||||
"vi": "Lọc card theo tiêu đề/mô tả."},
|
||||
"schedtask.filter_tooltip": {
|
||||
"en": "Show only one task type.", "ja": "1つのタスク種別のみ表示。",
|
||||
"vi": "Chỉ hiện một loại task."},
|
||||
"schedtask.col_tip.backlog": {
|
||||
"en": "New tasks with no schedule yet. Drag a card here to shelve it.",
|
||||
"ja": "未スケジュールの新規タスク。", "vi": "Task mới, chưa đặt lịch. Kéo card vào đây để cất lại."},
|
||||
"schedtask.col_tip.scheduled": {
|
||||
"en": "On the calendar — runs automatically at its time. Drop a card here to schedule it.",
|
||||
"ja": "スケジュール済み — 時刻になると自動実行。", "vi": "Đã lên lịch — tự chạy khi đến giờ. Thả card vào đây để đặt lịch."},
|
||||
"schedtask.col_tip.running": {
|
||||
"en": "Currently executing. Drop a card here to RUN it immediately.",
|
||||
"ja": "実行中。ここにドロップすると即実行します。", "vi": "Đang chạy. Thả card vào đây để CHẠY NGAY."},
|
||||
"schedtask.col_tip.waiting_input": {
|
||||
"en": "Waiting: needs your Run-now approval, or its prerequisite tasks aren't Done yet.",
|
||||
"ja": "待機中: 手動承認待ち、または前提タスクが未完了。",
|
||||
"vi": "Đang chờ: cần bạn bấm Chạy ngay (phê duyệt), hoặc các task phụ thuộc chưa Done."},
|
||||
"schedtask.col_tip.done": {
|
||||
"en": "Finished successfully. Drop a card here to mark it done by hand.",
|
||||
"ja": "完了。ここにドロップすると手動で完了扱いにします。",
|
||||
"vi": "Đã xong. Thả card vào đây để tự đánh dấu hoàn thành."},
|
||||
"schedtask.col_tip.failed": {
|
||||
"en": "Last run errored — right-click → Run history to see why.",
|
||||
"ja": "前回失敗 — 右クリック→実行履歴で原因を確認。",
|
||||
"vi": "Lần chạy cuối bị lỗi — chuột phải → Lịch sử chạy để xem lý do."},
|
||||
"schedtask.col_tip.paused": {
|
||||
"en": "Paused: never auto-runs and is skipped by chains until resumed.",
|
||||
"ja": "一時停止中: 再開まで自動実行されず、チェーンでもスキップされます。",
|
||||
"vi": "Tạm dừng: không tự chạy và bị chuỗi bỏ qua cho tới khi tiếp tục."},
|
||||
"schedtask.hint_type": {
|
||||
"en": "Cowork = documents/answers · Code = coding agent · Script = shell command · Flow = multi-step · Manual = tracking only.",
|
||||
"ja": "Cowork=文書/回答 · Code=コーディング · Script=コマンド · Flow=複数ステップ · Manual=管理のみ。",
|
||||
"vi": "Cowork = tài liệu/trả lời · Code = agent code · Script = lệnh shell · Flow = nhiều bước · Manual = chỉ quản lý."},
|
||||
"schedtask.hint_status": {
|
||||
"en": "Current Kanban lane. Usually managed automatically by the scheduler.",
|
||||
"ja": "現在のKanbanレーン。通常はスケジューラーが自動管理。",
|
||||
"vi": "Cột Kanban hiện tại. Thường được scheduler tự quản lý."},
|
||||
"schedtask.hint_script": {
|
||||
"en": "Shell command to run (Script tasks). Runs in the task's artifact folder with a timeout.",
|
||||
"ja": "実行するシェルコマンド(Scriptタスク)。", "vi": "Lệnh shell sẽ chạy (task Script), trong thư mục artifact riêng, có timeout."},
|
||||
"schedtask.hint_sched_enable": {
|
||||
"en": "Off = the task never runs by itself.", "ja": "OFF = 自動実行されません。",
|
||||
"vi": "Tắt = task không bao giờ tự chạy."},
|
||||
"schedtask.hint_run_at": {
|
||||
"en": "First/next run time (this machine's local time).",
|
||||
"ja": "初回/次回の実行時刻(ローカル時刻)。", "vi": "Giờ chạy đầu/kế tiếp (giờ local của máy)."},
|
||||
"schedtask.hint_repeat": {
|
||||
"en": "After a successful run, the schedule rolls to the next occurrence automatically.",
|
||||
"ja": "成功後、次回分へ自動的に繰り越します。",
|
||||
"vi": "Sau khi chạy thành công, lịch tự dời sang kỳ kế tiếp."},
|
||||
"schedtask.hint_cron": {
|
||||
"en": "5 fields: minute hour day month weekday. E.g. '0 9 * * 1-5' = 9:00 on weekdays.",
|
||||
"ja": "5項目: 分 時 日 月 曜日。例 '0 9 * * 1-5' = 平日9時。",
|
||||
"vi": "5 trường: phút giờ ngày tháng thứ. VD '0 9 * * 1-5' = 9h các ngày thường."},
|
||||
"schedtask.hint_workdays": {
|
||||
"en": "Runs landing on Sat/Sun are pushed to the next working day.",
|
||||
"ja": "土日に当たる回は翌営業日に繰り越し。", "vi": "Lịch rơi vào T7/CN sẽ dời sang ngày làm việc kế."},
|
||||
"schedtask.hint_holidays": {
|
||||
"en": "Runs landing on a public holiday of the chosen country are pushed to the next allowed day.",
|
||||
"ja": "選択した国の祝日に当たる回は翌営業日に繰り越し。",
|
||||
"vi": "Lịch rơi vào ngày lễ của quốc gia đã chọn sẽ tự dời sang ngày hợp lệ kế."},
|
||||
"schedtask.hint_country": {
|
||||
"en": "ISO country code for the holiday calendar (VN, JP, US… — type any code).",
|
||||
"ja": "祝日カレンダーの国コード(VN, JP, US…)。", "vi": "Mã quốc gia cho lịch nghỉ lễ (VN, JP, US… — gõ được mã bất kỳ)."},
|
||||
"schedtask.hint_flow_template": {
|
||||
"en": "Import the stages of a saved Flow template as steps here.",
|
||||
"ja": "保存済みFlowテンプレートをステップとして取り込み。",
|
||||
"vi": "Nhập các stage của Flow template đã lưu thành các bước ở đây."},
|
||||
"schedtask.hint_input_mode": {
|
||||
"en": "What the agent receives besides the description: nothing, typed text, file contents, or the output of earlier tasks.",
|
||||
"ja": "説明に加えてエージェントへ渡す入力。", "vi": "Agent nhận gì ngoài mô tả: trống, văn bản gõ tay, nội dung tệp, hoặc output các task trước."},
|
||||
"schedtask.hint_prev_task": {
|
||||
"en": "Single explicit source task for 'previous task output' (leave (none) to use all waited-for tasks).",
|
||||
"ja": "「前タスクの出力」の明示的なソース。", "vi": "Task nguồn cụ thể cho 'output task trước' (để (không) sẽ dùng tất cả task đang chờ)."},
|
||||
"schedtask.hint_output_mode": {
|
||||
"en": "Expected output format — informational for now, files always land in the artifact folder.",
|
||||
"ja": "想定する出力形式(参考情報)。", "vi": "Định dạng output mong muốn — hiện mang tính thông tin, file luôn nằm trong thư mục artifact."},
|
||||
"schedtask.hint_next_task": {
|
||||
"en": "Task to trigger after this one finishes (chain).",
|
||||
"ja": "このタスク完了後に起動するタスク(チェーン)。", "vi": "Task được kích hoạt sau khi task này xong (chuỗi)."},
|
||||
"schedtask.hint_run_next": {
|
||||
"en": "When the next task fires: on success / always / only after you confirm.",
|
||||
"ja": "次タスクの起動条件: 成功時/常に/手動確認後。", "vi": "Khi nào task sau chạy: khi thành công / luôn / chờ bạn xác nhận."},
|
||||
"schedtask.hint_pass_output": {
|
||||
"en": "This task's output.md becomes the next task's input automatically.",
|
||||
"ja": "このタスクのoutput.mdを次タスクの入力に自動投入。",
|
||||
"vi": "output.md của task này tự thành input của task sau."},
|
||||
"schedtask.hint_depends": {
|
||||
"en": "Fan-in: this task waits until ALL ticked tasks are Done, then runs automatically with their outputs available.",
|
||||
"ja": "ファンイン: チェックした全タスクがDoneになるまで待機し、自動実行。",
|
||||
"vi": "Fan-in: task này đợi TẤT CẢ task được tick Done rồi mới tự chạy, kèm output của chúng."},
|
||||
"schedtask.hint_retry": {
|
||||
"en": "Auto-retry this many times when a run fails.", "ja": "失敗時の自動リトライ回数。",
|
||||
"vi": "Tự thử lại bấy nhiêu lần khi chạy lỗi."},
|
||||
"schedtask.hint_timeout": {
|
||||
"en": "Hard limit per run (Script tasks).", "ja": "1回あたりの上限時間(Script)。",
|
||||
"vi": "Giới hạn thời gian mỗi lần chạy (task Script)."},
|
||||
"schedtask.hint_approval": {
|
||||
"en": "Safety: the scheduler will NEVER auto-run this — it parks in Waiting Input until you right-click → Run now.",
|
||||
"ja": "安全: 自動実行されず、Run nowまで待機します。",
|
||||
"vi": "An toàn: scheduler KHÔNG BAO GIỜ tự chạy task này — nó nằm ở Waiting Input tới khi bạn chuột phải → Chạy ngay."},
|
||||
"schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "Input"},
|
||||
"schedtask.f_input_mode": {"en": "Input mode", "ja": "入力モード", "vi": "Chế độ input"},
|
||||
"schedtask.inmode.empty": {"en": "Empty (default)", "ja": "空(既定)", "vi": "Trống (mặc định)"},
|
||||
"schedtask.inmode.manual": {"en": "Manual text", "ja": "手入力テキスト", "vi": "Văn bản nhập tay"},
|
||||
"schedtask.inmode.file": {"en": "File(s)", "ja": "ファイル", "vi": "Tệp"},
|
||||
"schedtask.inmode.previous_task_output": {
|
||||
"en": "Previous task output", "ja": "前タスクの出力", "vi": "Output của task trước"},
|
||||
"schedtask.f_manual_text": {"en": "Prompt", "ja": "プロンプト", "vi": "Prompt"},
|
||||
"schedtask.gen_input_tooltip": {
|
||||
"en": "AI-draft the prompt from the title/description", "ja": "タイトル/説明からプロンプトをAI生成",
|
||||
"vi": "AI soạn prompt từ tiêu đề/mô tả"},
|
||||
"schedtask.f_files": {"en": "Attach files", "ja": "添付ファイル", "vi": "Đính kèm tệp"},
|
||||
"schedtask.f_links": {"en": "Attach links", "ja": "添付リンク", "vi": "Đính kèm link"},
|
||||
"schedtask.files_placeholder": {
|
||||
"en": "Local file paths, separated by ;", "ja": "ローカルファイルパス(;区切り)",
|
||||
"vi": "Đường dẫn tệp local, cách nhau bằng ;"},
|
||||
"schedtask.links_placeholder": {
|
||||
"en": "https://… URLs separated by ;", "ja": "https://… URL(;区切り)",
|
||||
"vi": "https://… các link, cách nhau bằng ;"},
|
||||
"schedtask.pick_files": {"en": "Browse…", "ja": "参照…", "vi": "Chọn tệp…"},
|
||||
"schedtask.add_link_title": {"en": "Add link", "ja": "リンクを追加", "vi": "Thêm link"},
|
||||
"schedtask.add_link_label": {"en": "URL:", "ja": "URL:", "vi": "URL:"},
|
||||
"schedtask.hint_files": {
|
||||
"en": "Attached files are always read and given to the agent as context, regardless of Input mode.",
|
||||
"ja": "添付ファイルはInputモードに関係なく常にエージェントへ渡されます。",
|
||||
"vi": "Tệp đính kèm luôn được đọc và đưa vào ngữ cảnh cho agent, bất kể chế độ Input."},
|
||||
"schedtask.hint_links": {
|
||||
"en": "Each URL is fetched (best-effort) and its text content given to the agent as context.",
|
||||
"ja": "各URLを取得し(ベストエフォート)、テキストをコンテキストとして渡します。",
|
||||
"vi": "Mỗi link được tải nội dung (khi có thể) và đưa vào ngữ cảnh cho agent."},
|
||||
"schedtask.f_prev_task": {"en": "Previous task", "ja": "前タスク", "vi": "Task trước"},
|
||||
"schedtask.g_output": {"en": "Output", "ja": "出力", "vi": "Output"},
|
||||
"schedtask.f_output_mode": {"en": "Output mode", "ja": "出力モード", "vi": "Chế độ output"},
|
||||
"schedtask.g_dependency": {"en": "Dependency / Next task", "ja": "依存 / 次タスク", "vi": "Phụ thuộc / Task tiếp theo"},
|
||||
"schedtask.f_next_task": {"en": "Next task", "ja": "次タスク", "vi": "Task tiếp theo"},
|
||||
"schedtask.f_run_next": {"en": "Run next task", "ja": "次タスクの実行", "vi": "Chạy task tiếp theo"},
|
||||
"schedtask.runnext.none": {"en": "Don't run next task", "ja": "実行しない", "vi": "Không chạy task sau"},
|
||||
"schedtask.runnext.run_after_success": {
|
||||
"en": "Run after success", "ja": "成功後に実行", "vi": "Chạy khi task này thành công"},
|
||||
"schedtask.runnext.run_always": {"en": "Always run", "ja": "常に実行", "vi": "Luôn chạy (kể cả lỗi)"},
|
||||
"schedtask.runnext.run_after_manual_confirm": {
|
||||
"en": "Wait for my confirmation", "ja": "手動確認後に実行", "vi": "Chờ tôi xác nhận rồi chạy"},
|
||||
"schedtask.pass_output": {
|
||||
"en": "Use this task's output as next task's input",
|
||||
"ja": "このタスクの出力を次タスクの入力にする",
|
||||
"vi": "Dùng output task này làm input task sau"},
|
||||
"schedtask.next_paused_warn": {
|
||||
"en": "The selected next task is paused — it will be skipped when this task finishes.",
|
||||
"ja": "選択した次タスクは一時停止中のため、完了時にスキップされます。",
|
||||
"vi": "Task tiếp theo đang tạm dừng — sẽ bị bỏ qua khi task này chạy xong."},
|
||||
"schedtask.none": {"en": "(none)", "ja": "(なし)", "vi": "(không)"},
|
||||
"schedtask.g_execution": {"en": "Execution", "ja": "実行設定", "vi": "Thực thi"},
|
||||
"schedtask.f_retry": {"en": "Max retry", "ja": "最大リトライ", "vi": "Số lần thử lại"},
|
||||
"schedtask.f_timeout": {"en": "Timeout", "ja": "タイムアウト", "vi": "Thời gian tối đa"},
|
||||
"schedtask.requires_approval": {
|
||||
"en": "Requires approval (scheduler will NOT auto-run; waits for Run now)",
|
||||
"ja": "承認必須(自動実行されず、手動のRun nowを待ちます)",
|
||||
"vi": "Cần phê duyệt (không tự chạy theo lịch; chờ bấm Chạy ngay)"},
|
||||
"schedtask.notify_ok": {"en": "Notify Teams on complete", "ja": "完了時にTeams通知", "vi": "Báo Teams khi xong"},
|
||||
"schedtask.notify_err": {"en": "Notify Teams on error", "ja": "エラー時にTeams通知", "vi": "Báo Teams khi lỗi"},
|
||||
"schedtask.title_required": {"en": "Please enter a title.", "ja": "タイトルを入力してください。", "vi": "Vui lòng nhập tiêu đề."},
|
||||
# AI create dialog
|
||||
"schedtask.ai_desc_label": {
|
||||
"en": "Describe what you want to automate:", "ja": "自動化したい内容を記述:",
|
||||
"vi": "Mô tả việc bạn muốn tự động hoá:"},
|
||||
"schedtask.ai_desc_ph": {
|
||||
"en": "e.g. Every Monday 9:00, use Code to read new CAE data and build a markdown report, then have Cowork draft a team email from it.",
|
||||
"ja": "例: 毎週月曜9時、CodeでCAEデータを読み込みレポート作成、その後Coworkでメール下書きを作成。",
|
||||
"vi": "vd: Mỗi thứ 2 lúc 9h, dùng Code đọc dữ liệu CAE mới tạo báo cáo markdown, sau đó Cowork soạn email draft gửi team."},
|
||||
"schedtask.ai_generate": {"en": "Generate plan", "ja": "プランを生成", "vi": "Tạo kế hoạch"},
|
||||
"schedtask.ai_generating": {"en": "Generating…", "ja": "生成中…", "vi": "Đang tạo…"},
|
||||
"schedtask.ai_preview_label": {
|
||||
"en": "Preview (nothing is created until you confirm):",
|
||||
"ja": "プレビュー(確認するまで作成されません):",
|
||||
"vi": "Xem trước (chưa tạo gì cho tới khi bạn xác nhận):"},
|
||||
"schedtask.ai_confirm": {"en": "Create tasks", "ja": "タスクを作成", "vi": "Tạo các task"},
|
||||
"schedtask.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "AI gen task"},
|
||||
"schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Import"},
|
||||
"schedtask.export_template_btn": {
|
||||
"en": "Create Excel template…", "ja": "Excelテンプレートを作成…",
|
||||
"vi": "Tạo template Excel…"},
|
||||
"schedtask.import_pick_btn": {"en": "Choose file…", "ja": "ファイルを選択…", "vi": "Chọn file…"},
|
||||
"schedtask.drop_hint": {
|
||||
"en": "…or drag & drop the filled .xlsx here",
|
||||
"ja": "…または記入済みの .xlsx をここにドラッグ&ドロップ",
|
||||
"vi": "…hoặc kéo-thả file .xlsx đã điền vào đây"},
|
||||
"schedtask.f_depends_on": {
|
||||
"en": "Wait for tasks (all must be Done)", "ja": "待機するタスク(全てDone必須)",
|
||||
"vi": "Chờ các task (tất cả phải Done)"},
|
||||
"schedtask.gen_desc_tooltip": {
|
||||
"en": "Generate the Prompt from this description (the title is not used)",
|
||||
"ja": "この説明からプロンプトを生成(タイトルは使用しません)",
|
||||
"vi": "Sinh Prompt từ mô tả này (không dùng tiêu đề)"},
|
||||
"schedtask.gen_needs_description": {
|
||||
"en": "Enter a description first — the Prompt is generated from it.",
|
||||
"ja": "先に説明を入力してください。プロンプトは説明から生成されます。",
|
||||
"vi": "Hãy nhập mô tả trước — Prompt được sinh ra từ mô tả."},
|
||||
# ---- dashboard_tab.py ------------------------------------------------
|
||||
"dashboard.title": {"en": "Dashboard — token usage & cost", "ja": "Dashboard — トークン使用量とコスト",
|
||||
"vi": "Dashboard — token & chi phí"},
|
||||
"dashboard.period.today": {"en": "Today", "ja": "今日", "vi": "Hôm nay"},
|
||||
"dashboard.period.week": {"en": "Last 7 days", "ja": "過去7日", "vi": "7 ngày qua"},
|
||||
"dashboard.period.month": {"en": "Last 30 days", "ja": "過去30日", "vi": "30 ngày qua"},
|
||||
"dashboard.period.all": {"en": "All time", "ja": "全期間", "vi": "Toàn bộ"},
|
||||
"dashboard.source_all": {"en": "All tasks/sessions", "ja": "全タスク/セッション", "vi": "Mọi task/phiên"},
|
||||
"dashboard.refresh_tooltip": {"en": "Refresh now", "ja": "今すぐ更新", "vi": "Làm mới ngay"},
|
||||
"dashboard.card_total": {"en": "Total tokens", "ja": "合計トークン", "vi": "Tổng token"},
|
||||
"dashboard.card_in": {"en": "Input", "ja": "入力", "vi": "Input"},
|
||||
"dashboard.card_out": {"en": "Output", "ja": "出力", "vi": "Output"},
|
||||
"dashboard.card_cache": {"en": "Cache", "ja": "キャッシュ", "vi": "Cache"},
|
||||
"dashboard.card_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"},
|
||||
"dashboard.card_turns": {"en": "{n} turns", "ja": "{n} ターン", "vi": "{n} lượt"},
|
||||
"dashboard.prices_label": {
|
||||
"en": "Unit price (USD / 1M tokens):", "ja": "単価 (USD / 100万トークン):",
|
||||
"vi": "Đơn giá (USD / 1 triệu token):"},
|
||||
"dashboard.price_in": {"en": "In", "ja": "入力", "vi": "In"},
|
||||
"dashboard.price_out": {"en": "Out", "ja": "出力", "vi": "Out"},
|
||||
"dashboard.price_cache": {"en": "Cache", "ja": "キャッシュ", "vi": "Cache"},
|
||||
"dashboard.habits_title": {
|
||||
"en": "Usage habits overview", "ja": "利用傾向の概要", "vi": "Tổng quan thói quen sử dụng"},
|
||||
"dashboard.chart_title": {"en": "Tokens / cost over time", "ja": "トークン/コスト推移",
|
||||
"vi": "Token / chi phí theo thời gian"},
|
||||
"dashboard.strategy_btn": {"en": "Apply saving strategy", "ja": "節約戦略を適用",
|
||||
"vi": "Áp dụng chiến lược tiết kiệm"},
|
||||
"dashboard.strategy_tooltip": {
|
||||
"en": "Apply the AI's cost-saving strategy: auto-compress earlier + digest context before each turn.",
|
||||
"ja": "AIの節約戦略を適用:早めに自動圧縮+各ターン前にコンテキストを要約。",
|
||||
"vi": "Áp dụng chiến lược tiết kiệm của AI: tự động nén sớm hơn + tóm gọn ngữ cảnh trước mỗi lượt."},
|
||||
"dashboard.strategy_title": {"en": "Apply saving strategy", "ja": "節約戦略の適用",
|
||||
"vi": "Áp dụng chiến lược tiết kiệm"},
|
||||
"dashboard.strategy_confirm": {
|
||||
"en": "Turn on auto-compress (earlier, at 60%) and compress context before each turn to cut tokens?",
|
||||
"ja": "自動圧縮(60%で早めに)とターン前のコンテキスト圧縮を有効にしてトークンを削減しますか?",
|
||||
"vi": "Bật tự động nén (sớm hơn, ở 60%) và nén ngữ cảnh trước mỗi lượt để giảm token?"},
|
||||
"dashboard.strategy_applied": {
|
||||
"en": "Saving strategy applied: auto-compress on, compress-before-send on.",
|
||||
"ja": "節約戦略を適用:自動圧縮ON、送信前圧縮ON。",
|
||||
"vi": "Đã áp dụng: bật tự động nén và nén trước khi gửi."},
|
||||
"dashboard.gran_day": {"en": "By day", "ja": "日別", "vi": "Theo ngày"},
|
||||
"dashboard.gran_week": {"en": "By week", "ja": "週別", "vi": "Theo tuần"},
|
||||
"dashboard.gran_month": {"en": "By month", "ja": "月別", "vi": "Theo tháng"},
|
||||
"dashboard.gran_year": {"en": "By year", "ja": "年別", "vi": "Theo năm"},
|
||||
"dashboard.ref_last_week": {"en": "Last week", "ja": "先週", "vi": "Tuần trước"},
|
||||
"dashboard.ref_last_month": {"en": "Last month", "ja": "先月", "vi": "Tháng trước"},
|
||||
"dashboard.ref_last_year": {"en": "Last year", "ja": "昨年", "vi": "Năm trước"},
|
||||
"usage.budget_title": {"en": "Budget", "ja": "予算", "vi": "Ngân sách"},
|
||||
"usage.budget_no_budget": {"en": "No budget set", "ja": "予算未設定", "vi": "Chưa đặt Budget"},
|
||||
"usage.budget_used_pct": {"en": "{pct}% used", "ja": "{pct}% 使用済み", "vi": "Đã dùng {pct}%"},
|
||||
"usage.budget_over_warning": {"en": "⚠ Over 85% of budget used",
|
||||
"ja": "⚠ 予算の85%以上を使用",
|
||||
"vi": "⚠ Đã dùng quá 85% Budget"},
|
||||
"usage.budget_apply_tooltip": {"en": "Set this as the budget (starts a fresh remaining-balance window)",
|
||||
"ja": "この金額を予算として設定(残高の計算を今から開始)",
|
||||
"vi": "Đặt số này làm Budget (tính số dư mới từ bây giờ)"},
|
||||
"usage.budget_spin_tooltip": {"en": "Enter the budget amount directly, then click ✓",
|
||||
"ja": "予算額を直接入力して ✓ をクリック",
|
||||
"vi": "Nhập Budget trực tiếp rồi bấm ✓"},
|
||||
"dashboard.chart_prev": {"en": "Previous period", "ja": "前の期間", "vi": "Kỳ trước"},
|
||||
"dashboard.chart_next": {"en": "Next period", "ja": "次の期間", "vi": "Kỳ sau"},
|
||||
"dashboard.metric_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"},
|
||||
"dashboard.metric_tokens": {"en": "Tokens", "ja": "トークン", "vi": "Token"},
|
||||
"dashboard.h_top": {"en": "Top token consumers (task/session)", "ja": "トークン消費上位(タスク/セッション)",
|
||||
"vi": "Tiêu tốn token nhiều nhất (task/phiên)"},
|
||||
"dashboard.h_by_source": {"en": "By area", "ja": "領域別", "vi": "Theo khu vực"},
|
||||
"dashboard.h_avg": {"en": "Average per prompt", "ja": "1プロンプト平均", "vi": "Trung bình mỗi prompt"},
|
||||
"dashboard.h_busiest_day": {"en": "Busiest day", "ja": "最も使った日", "vi": "Ngày dùng nhiều nhất"},
|
||||
"dashboard.h_busiest_hour": {"en": "Busiest hour", "ja": "最も使う時間帯", "vi": "Khung giờ hay dùng"},
|
||||
"dashboard.no_data": {
|
||||
"en": "No usage recorded in this period yet — run a chat or a task first.",
|
||||
"ja": "この期間の使用記録はまだありません。チャットやタスクを実行してください。",
|
||||
"vi": "Chưa có dữ liệu sử dụng trong giai đoạn này — hãy chạy chat hoặc task trước."},
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Chuỗi hiển thị — phần libreoffice_view.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"structure.export_title": {"en": "Export graph PNG", "ja": "グラフを PNG でエクスポート", "vi": "Xuất đồ thị ra PNG"},
|
||||
"structure.export_done": {"en": "Graph exported to {path}", "ja": "グラフを {path} にエクスポートしました", "vi": "Đã xuất đồ thị ra {path}"},
|
||||
"structure.export_failed": {"en": "Export failed: {err}", "ja": "エクスポート失敗: {err}", "vi": "Xuất thất bại: {err}"},
|
||||
"structure.scan_first": {"en": "Scan a graph first.", "ja": "先にグラフをスキャンしてください。", "vi": "Hãy Scan đồ thị trước."},
|
||||
"structure.related_sources": {
|
||||
"en": "Related files (click to open):",
|
||||
"ja": "関連ファイル(クリックで開く):",
|
||||
"vi": "Tệp liên quan (bấm để mở):"},
|
||||
"structure.legend.dir": {"en": "Folder", "ja": "フォルダ", "vi": "Thư mục"},
|
||||
"structure.legend.file": {"en": "File", "ja": "ファイル", "vi": "Tệp"},
|
||||
"structure.legend.class": {"en": "Class", "ja": "クラス", "vi": "Class"},
|
||||
"structure.legend.function": {"en": "Function", "ja": "関数", "vi": "Function"},
|
||||
"structure.legend.method": {"en": "Method", "ja": "メソッド", "vi": "Method"},
|
||||
"structure.legend.module": {"en": "Module", "ja": "モジュール", "vi": "Module"},
|
||||
"structure.legend.section": {"en": "Section", "ja": "セクション", "vi": "Mục"},
|
||||
"structure.legend.json_key": {"en": "JSON key", "ja": "JSONキー", "vi": "Khóa JSON"},
|
||||
"structure.legend.entities": {"en": "Entities", "ja": "エンティティ", "vi": "Thực thể"},
|
||||
"structure.legend.relationships": {"en": "Relationships", "ja": "関係", "vi": "Quan hệ"},
|
||||
"structure.show_label": {"en": "Show label", "ja": "ラベル表示", "vi": "Hiện nhãn"},
|
||||
"structure.show_relationship": {
|
||||
"en": "Show relationship", "ja": "関係を表示", "vi": "Hiện quan hệ"},
|
||||
"structure.edge.contains": {"en": "contains", "ja": "含む", "vi": "chứa"},
|
||||
"structure.edge.defines": {"en": "defines", "ja": "定義", "vi": "định nghĩa"},
|
||||
"structure.edge.method": {"en": "method", "ja": "メソッド", "vi": "phương thức"},
|
||||
"structure.edge.imports": {"en": "imports", "ja": "インポート", "vi": "import"},
|
||||
"structure.edge.subsection": {"en": "subsection", "ja": "サブセクション", "vi": "mục con"},
|
||||
|
||||
# ---- libreoffice_view.py -------------------------------------------
|
||||
"libreoffice.open_btn": {"en": "Open in LibreOffice", "ja": "LibreOffice で開く", "vi": "Mở bằng LibreOffice"},
|
||||
"libreoffice.not_found": {
|
||||
"en": ("LibreOffice was not found. Install LibreOffice (or set the "
|
||||
"SOFFICE_PATH environment variable) to view and edit documents here."),
|
||||
"ja": "LibreOffice が見つかりません。ここで文書を表示/編集するには LibreOffice をインストールするか、環境変数 SOFFICE_PATH を設定してください。",
|
||||
"vi": "Không tìm thấy LibreOffice. Hãy cài LibreOffice (hoặc đặt biến môi trường SOFFICE_PATH) để xem/sửa tài liệu tại đây."},
|
||||
"libreoffice.windows_only": {
|
||||
"en": "Embedding the editor is available on Windows. Click below to open this document in LibreOffice.",
|
||||
"ja": "エディタの埋め込みは Windows でのみ利用可能です。下のボタンで LibreOffice で開いてください。",
|
||||
"vi": "Nhúng trình soạn thảo chỉ khả dụng trên Windows. Bấm bên dưới để mở tài liệu bằng LibreOffice."},
|
||||
"libreoffice.start_failed": {"en": "Could not start LibreOffice ({err}).", "ja": "LibreOffice を起動できませんでした({err})。", "vi": "Không khởi động được LibreOffice ({err})."},
|
||||
"libreoffice.opening": {"en": "Opening the document in LibreOffice…", "ja": "LibreOffice で文書を開いています…", "vi": "Đang mở tài liệu bằng LibreOffice…"},
|
||||
"libreoffice.embed_failed": {
|
||||
"en": "Couldn't embed the LibreOffice window. You can open it in a separate window instead.",
|
||||
"ja": "LibreOffice ウィンドウを埋め込めませんでした。別ウィンドウで開くことができます。",
|
||||
"vi": "Không nhúng được cửa sổ LibreOffice. Bạn có thể mở nó ở cửa sổ riêng."},
|
||||
"libreoffice.embed_error": {"en": "Couldn't embed LibreOffice ({err}).", "ja": "LibreOffice を埋め込めませんでした({err})。", "vi": "Không nhúng được LibreOffice ({err})."},
|
||||
|
||||
# ---- monitoring_tab.py (📊 Monitoring Dashboard) --------------------
|
||||
"monitoring.title": {"en": "Monitoring Dashboard", "ja": "モニタリングダッシュボード", "vi": "Bảng giám sát"},
|
||||
"monitoring.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
|
||||
"monitoring.tab_security": {"en": "Security", "ja": "セキュリティ", "vi": "Bảo mật"},
|
||||
"monitoring.tab_mcp": {"en": "MCP", "ja": "MCP", "vi": "MCP"},
|
||||
"monitoring.tab_actions": {"en": "Actions", "ja": "アクション", "vi": "Hành động"},
|
||||
"monitoring.tab_agents": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
|
||||
"monitoring.tab_accounts": {"en": "Accounts", "ja": "アカウント", "vi": "Tài khoản"},
|
||||
"monitoring.col_time": {"en": "Time", "ja": "時刻", "vi": "Thời gian"},
|
||||
"monitoring.col_role": {"en": "Agent Role", "ja": "エージェント役割", "vi": "Vai trò Agent"},
|
||||
"monitoring.col_name": {"en": "Action", "ja": "アクション", "vi": "Hành động"},
|
||||
"monitoring.col_result": {"en": "Result", "ja": "結果", "vi": "Kết quả"},
|
||||
# Security Events shows WHICH rule fired instead of a result that is always
|
||||
# the same — every security_block is recorded with ok=False.
|
||||
"monitoring.col_action": {"en": "Action", "ja": "アクション", "vi": "Hành động"},
|
||||
# The fourth KPI tile on Overview, as the wireframe labels it.
|
||||
"monitoring.overview_calls": {"en": "Calls", "ja": "呼び出し", "vi": "Lượt gọi"},
|
||||
# The fold under the Sandbox summary line — the wireframe shows only the
|
||||
# summary, so the ID / created / uptime / limits rows live behind this.
|
||||
"monitoring.overview_disk_free": {
|
||||
"en": "{size} free", "ja": "空き {size}", "vi": "{size} trống"},
|
||||
"monitoring.overview_disk_label": {"en": "Disk", "ja": "ディスク", "vi": "Đĩa"},
|
||||
"monitoring.overview_sbx_detail": {
|
||||
"en": "Details", "ja": "詳細", "vi": "Chi tiết"},
|
||||
"monitoring.col_detail": {"en": "Detail", "ja": "詳細", "vi": "Chi tiết"},
|
||||
"monitoring.col_account": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
|
||||
"monitoring.col_machine": {"en": "Machine", "ja": "マシン", "vi": "Máy"},
|
||||
"monitoring.col_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
|
||||
"monitoring.col_source": {"en": "Source", "ja": "ソース", "vi": "Nguồn"},
|
||||
"monitoring.active_n": {"en": "{n} running", "ja": "{n} 件実行中", "vi": "{n} đang chạy"},
|
||||
"monitoring.idle": {"en": "Idle", "ja": "アイドル", "vi": "Rảnh"},
|
||||
"monitoring.agent_status_title": {
|
||||
"en": "Agent Status", "ja": "エージェント状態", "vi": "Trạng thái Agent"},
|
||||
"monitoring.source_cowork": {
|
||||
"en": "Cowork tab's active turns", "ja": "Cowork タブの実行中ターン",
|
||||
"vi": "Lượt đang chạy của tab Cowork"},
|
||||
"monitoring.source_task": {
|
||||
"en": "Schedule Task's running tasks", "ja": "Schedule Task の実行中タスク",
|
||||
"vi": "Task đang chạy trong Schedule Task"},
|
||||
"monitoring.source_knowledge": {
|
||||
"en": "GraphRAG's Ask box", "ja": "GraphRAG の Ask ボックス", "vi": "Ô hỏi của GraphRAG"},
|
||||
"monitoring.source_code": {
|
||||
"en": "Runs inside a Task Agent run when the task type is Code",
|
||||
"ja": "タスクタイプが Code の場合、Task Agent の実行内で動作します",
|
||||
"vi": "Chạy bên trong một lượt Task Agent khi loại task là Code"},
|
||||
"monitoring.source_planner": {
|
||||
"en": "A phase inside a running Cowork/Task turn (update_plan) — not tracked separately",
|
||||
"ja": "実行中の Cowork/Task ターン内の一段階(update_plan)— 個別には追跡されません",
|
||||
"vi": "Một giai đoạn trong lượt Cowork/Task đang chạy (update_plan) — không theo dõi riêng"},
|
||||
"monitoring.source_reasoning": {
|
||||
"en": "The model's streamed reasoning within a running turn — not tracked separately",
|
||||
"ja": "実行中のターン内でモデルがストリーミングする推論 — 個別には追跡されません",
|
||||
"vi": "Luồng suy luận (reasoning) của model trong lượt đang chạy — không theo dõi riêng"},
|
||||
"monitoring.source_security": {
|
||||
"en": "Agent Security's prompt/attachment/command validation — runs inline on the active turn",
|
||||
"ja": "エージェントセキュリティのプロンプト/添付/コマンド検証 — 実行中のターン内でインライン実行",
|
||||
"vi": "Kiểm duyệt prompt/đính kèm/lệnh của Agent Security — chạy inline trong lượt đang chạy"},
|
||||
"monitoring.on": {"en": "On", "ja": "オン", "vi": "Bật"},
|
||||
"monitoring.off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
|
||||
|
||||
# ---- monitoring_tab.py — Overview card dashboard ---------------------
|
||||
"monitoring.tab_overview": {"en": "Overview", "ja": "概要", "vi": "Tổng quan"},
|
||||
"monitoring.overview_usage_title": {
|
||||
"en": "Token & Cost", "ja": "トークンとコスト", "vi": "Token & Chi phí"},
|
||||
"monitoring.overview_currency": {"en": "Currency:", "ja": "通貨:", "vi": "Tiền tệ:"},
|
||||
"monitoring.tab_agents_admin": {
|
||||
"en": "Agents Admin", "ja": "エージェント管理", "vi": "Agents Admin"},
|
||||
"monitoring.tab_tools": {"en": "Tools", "ja": "ツール", "vi": "Công cụ"},
|
||||
|
||||
# ---- tools_admin_tab.py — govern built-in tools + Connectors/MCP -------
|
||||
"tools_admin.hint": {
|
||||
"en": "Enable or disable the built-in agent tools below. A tool toggled off is removed "
|
||||
"from the agent's toolset. MCP / REST-API connectors are set up in the Connector "
|
||||
"sub-tab.",
|
||||
"ja": "下の組み込みエージェントツールをオン/オフします。オフにしたツールはツールセットから除外"
|
||||
"されます。MCP / REST-APIコネクターは「Connector」サブタブで設定します。",
|
||||
"vi": "Bật/tắt các tool tích hợp bên dưới. Tool bị tắt sẽ bị loại khỏi bộ công cụ của agent. "
|
||||
"Connector MCP / REST-API được thiết lập ở tab con Connector."},
|
||||
"tools_admin.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
|
||||
"tools_admin.url_fetch_group": {
|
||||
"en": "Web access (fetch_url)", "ja": "Webアクセス (fetch_url)",
|
||||
"vi": "Truy cập web (fetch_url)"},
|
||||
"tools_admin.internet_disabled": {
|
||||
"en": "Web access is OFF — enable the fetch_url tool above to allow internet access.",
|
||||
"ja": "Web アクセスはオフです — 上の fetch_url ツールを有効にするとインターネットに接続できます。",
|
||||
"vi": "Truy cập web đang TẮT — bật tool fetch_url ở trên để cho phép truy cập internet."},
|
||||
"tools_admin.subtab_tool": {"en": "Tool", "ja": "ツール", "vi": "Tool"},
|
||||
"tools_admin.subtab_connector": {"en": "Connector", "ja": "コネクター", "vi": "Connector"},
|
||||
"monitoring.tab_icons": {"en": "Icons", "ja": "アイコン", "vi": "Icon"},
|
||||
"icons_admin.title": {"en": "Icons", "ja": "アイコン", "vi": "Icon"},
|
||||
"icons_admin.hint": {
|
||||
"en": "Icons you can use for agents and flows. Type a name into a step/agent's Icon field to "
|
||||
"use it. Add your own SVG icons below — they become usable by name immediately.",
|
||||
"ja": "エージェントやフローに使えるアイコン。ステップ/エージェントのアイコン欄に名前を入力すると使えます。"
|
||||
"下から独自のSVGアイコンを追加でき、名前ですぐ使えます。",
|
||||
"vi": "Các icon dùng cho agent và flow. Gõ tên vào ô Icon của step/agent để dùng. Thêm icon SVG "
|
||||
"của bạn ở dưới — dùng được ngay bằng tên."},
|
||||
"icons_admin.search": {"en": "Search icons by name…", "ja": "名前でアイコンを検索…", "vi": "Tìm icon theo tên…"},
|
||||
"icons_admin.builtin": {"en": "Built-in icons", "ja": "組込みアイコン", "vi": "Icon tích hợp"},
|
||||
"icons_admin.custom": {"en": "Custom icons", "ja": "カスタムアイコン", "vi": "Icon tùy chỉnh"},
|
||||
"icons_admin.add": {"en": "Add SVG file", "ja": "SVGファイルを追加", "vi": "Thêm tệp SVG"},
|
||||
"icons_admin.paste": {"en": "Paste SVG", "ja": "SVGを貼付", "vi": "Dán SVG"},
|
||||
"icons_admin.paste_prompt": {"en": "Paste the SVG markup:", "ja": "SVGマークアップを貼り付け:",
|
||||
"vi": "Dán mã SVG:"},
|
||||
"icons_admin.delete": {"en": "Delete custom", "ja": "カスタムを削除", "vi": "Xóa tùy chỉnh"},
|
||||
"icons_admin.name_prompt": {"en": "Icon name (used in the Icon field)", "ja": "アイコン名(アイコン欄で使用)",
|
||||
"vi": "Tên icon (dùng ở ô Icon)"},
|
||||
"icons_admin.select_custom": {"en": "Select a custom icon to delete.",
|
||||
"ja": "削除するカスタムアイコンを選択してください。",
|
||||
"vi": "Hãy chọn một icon tùy chỉnh để xóa."},
|
||||
"tools_admin.jira_note": {
|
||||
"en": "Jira connection setup moved to the Connector tab → set it up there; here you only turn "
|
||||
"the jira_search / jira_get_issue tools on or off.",
|
||||
"ja": "Jira接続の設定はConnectorタブに移動しました。設定はそちらで。ここでは jira_search / "
|
||||
"jira_get_issue ツールの有効/無効のみ切り替えます。",
|
||||
"vi": "Phần thiết lập kết nối Jira đã chuyển sang tab Connector → cài đặt ở đó; ở đây chỉ bật/tắt "
|
||||
"tool jira_search / jira_get_issue."},
|
||||
"connectors.jira_group": {"en": "Jira (read)", "ja": "Jira(読み取り)", "vi": "Jira (đọc)"},
|
||||
"connectors.jira_hint": {
|
||||
"en": "Connect once, then just paste a Jira link into Cowork or a Co4E step — the agent reads it "
|
||||
"automatically (no issue key needed). Read-only. A public Jira link works with no setup; "
|
||||
"a private one needs this connection. Create a token: id.atlassian.com → Security → API tokens.",
|
||||
"ja": "一度接続すれば、Cowork や Co4E ステップに Jira リンクを貼るだけで自動で読み取ります(課題キー不要)。"
|
||||
"読み取り専用。公開リンクは設定不要、非公開はこの接続が必要。トークン作成: id.atlassian.com → セキュリティ → APIトークン。",
|
||||
"vi": "Kết nối một lần, rồi chỉ cần dán link Jira vào Cowork hoặc bước Co4E — agent tự đọc (không cần "
|
||||
"issue key). Chỉ đọc. Link Jira công khai không cần cài đặt; link riêng tư cần kết nối này. "
|
||||
"Tạo token: id.atlassian.com → Security → API tokens."},
|
||||
"connectors.jira_paste": {"en": "Paste a link", "ja": "リンクを貼付", "vi": "Dán link"},
|
||||
"connectors.jira_paste_placeholder": {
|
||||
"en": "Paste any Jira link — fills the base URL for you",
|
||||
"ja": "Jiraのリンクを貼ると、ベースURLが自動入力されます",
|
||||
"vi": "Dán bất kỳ link Jira nào — tự điền Base URL"},
|
||||
"connectors.jira_url": {"en": "Base URL", "ja": "ベースURL", "vi": "Base URL"},
|
||||
"connectors.jira_email": {"en": "Email", "ja": "メール", "vi": "Email"},
|
||||
"connectors.jira_token": {"en": "API token", "ja": "APIトークン", "vi": "API token"},
|
||||
"connectors.jira_save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
|
||||
"connectors.jira_test": {"en": "Test connection", "ja": "接続テスト", "vi": "Kiểm tra kết nối"},
|
||||
"connectors.jira_saved": {"en": "Saved.", "ja": "保存しました。", "vi": "Đã lưu."},
|
||||
"connectors.jira_connected": {"en": "connected", "ja": "接続済み", "vi": "đã kết nối"},
|
||||
"connectors.jira_not_set": {"en": "not configured", "ja": "未設定", "vi": "chưa cấu hình"},
|
||||
"connectors.jira_setup_hint": {
|
||||
"en": "Double-click to connect Jira (paste any Jira link — no per-request setup after that).",
|
||||
"ja": "ダブルクリックで Jira に接続(Jira リンクを貼るだけ、以降は設定不要)。",
|
||||
"vi": "Nhấp đúp để kết nối Jira (dán bất kỳ link Jira nào — sau đó không cần thiết lập gì thêm)."},
|
||||
"connectors.builtin_auto": {
|
||||
"en": "Built-in, connects automatically", "ja": "組み込み、自動接続",
|
||||
"vi": "Tích hợp, tự kết nối"},
|
||||
"connectors.connect_external": {
|
||||
"en": "Connect to external connectors",
|
||||
"ja": "外部コネクタに接続する",
|
||||
"vi": "Kết nối tới connector bên ngoài"},
|
||||
"connectors.connect_external_tooltip": {
|
||||
"en": ("Master switch (default ON): when off, the agent connects to NO external "
|
||||
"connector or MCP server — the per-connector settings below are ignored."),
|
||||
"ja": "マスタースイッチ(既定オン): オフにすると、エージェントは外部コネクタ/MCPサーバーに"
|
||||
"一切接続しません(下の個別設定は無視されます)。",
|
||||
"vi": ("Công tắc tổng (mặc định BẬT): khi tắt, agent sẽ KHÔNG kết nối tới bất kỳ connector "
|
||||
"hay MCP server bên ngoài nào — các thiết lập từng connector bên dưới bị bỏ qua.")},
|
||||
"connectors.jira_testing": {"en": "Testing…", "ja": "テスト中…", "vi": "Đang kiểm tra…"},
|
||||
"connectors.jira_ok": {"en": "✓ Connected to Jira.", "ja": "✓ Jiraに接続できました。", "vi": "✓ Kết nối Jira thành công."},
|
||||
"connectors.jira_fail": {"en": "✗ {err}", "ja": "✗ {err}", "vi": "✗ {err}"},
|
||||
"connectors.jira_need_fields": {
|
||||
"en": "Enter base URL, email and API token first.",
|
||||
"ja": "先にベースURL・メール・APIトークンを入力してください。",
|
||||
"vi": "Hãy nhập Base URL, Email và API token trước."},
|
||||
"tools_admin.jira_group": {"en": "Jira connection", "ja": "Jira 接続", "vi": "Kết nối Jira"},
|
||||
"tools_admin.jira_hint": {
|
||||
"en": "Connect once, then just paste a Jira issue link into Cowork or a Co4E step — the agent "
|
||||
"reads it automatically (no issue key needed). Read-only. Private Jira needs this one-time "
|
||||
"connection; a public Jira link works with no setup. API token: id.atlassian.com → "
|
||||
"Security → API tokens. Turn the jira tools on/off in the list above.",
|
||||
"ja": "一度接続すれば、あとは Cowork や Co4E ステップに Jira のリンクを貼るだけで自動的に読み取ります"
|
||||
"(課題キー不要)。読み取り専用。非公開Jiraはこの一度の接続が必要、公開リンクは設定不要。"
|
||||
"APIトークン: id.atlassian.com → セキュリティ → APIトークン。ツールの有効/無効は上の一覧で。",
|
||||
"vi": "Kết nối một lần, sau đó chỉ cần dán link Jira vào Cowork hoặc bước Co4E — agent tự đọc "
|
||||
"(không cần nhập issue key). Chỉ đọc. Jira riêng tư cần kết nối một lần này; link Jira công "
|
||||
"khai thì không cần cài đặt. API token: id.atlassian.com → Security → API tokens. Bật/tắt "
|
||||
"tool jira ở danh sách phía trên."},
|
||||
"tools_admin.jira_url": {"en": "Base URL", "ja": "ベースURL", "vi": "Base URL"},
|
||||
"tools_admin.jira_email": {"en": "Email", "ja": "メール", "vi": "Email"},
|
||||
"tools_admin.jira_token": {"en": "API token", "ja": "APIトークン", "vi": "API token"},
|
||||
"tools_admin.jira_save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
|
||||
"tools_admin.jira_test": {"en": "Test connection", "ja": "接続テスト", "vi": "Kiểm tra kết nối"},
|
||||
"tools_admin.jira_saved": {"en": "Saved.", "ja": "保存しました。", "vi": "Đã lưu."},
|
||||
"tools_admin.jira_testing": {"en": "Testing…", "ja": "テスト中…", "vi": "Đang kiểm tra…"},
|
||||
"tools_admin.jira_ok": {"en": "✓ Connected to Jira.", "ja": "✓ Jiraに接続できました。", "vi": "✓ Kết nối Jira thành công."},
|
||||
"tools_admin.jira_fail": {"en": "✗ {err}", "ja": "✗ {err}", "vi": "✗ {err}"},
|
||||
"tools_admin.jira_need_fields": {
|
||||
"en": "Enter base URL, email and API token first.",
|
||||
"ja": "先にベースURL・メール・APIトークンを入力してください。",
|
||||
"vi": "Hãy nhập Base URL, Email và API token trước."},
|
||||
# ---- Co4E (node-graph workflow studio) --------------------------------
|
||||
"workspace.tab_co4e": {"en": "Co4E", "ja": "Co4E", "vi": "Co4E"},
|
||||
"workspace.tab_co4e_tooltip": {
|
||||
"en": "Co4E — Code for Everyone, Cowork for Everyone",
|
||||
"ja": "Co4E — Code for Everyone, Cowork for Everyone",
|
||||
"vi": "Co4E — Code for Everyone, Cowork for Everyone",
|
||||
},
|
||||
"co4e.untitled": {"en": "Untitled flow", "ja": "無題のフロー", "vi": "Flow chưa đặt tên"},
|
||||
"co4e.tab_workflows": {"en": "Workflows", "ja": "ワークフロー", "vi": "Workflows"},
|
||||
"co4e.tab_agents": {"en": "Agents", "ja": "エージェント", "vi": "Agents"},
|
||||
"co4e.tab_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
|
||||
"co4e.new": {"en": "New", "ja": "新規", "vi": "Mới"},
|
||||
"co4e.load": {"en": "Load", "ja": "読み込み", "vi": "Tải"},
|
||||
"co4e.delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"co4e.edit": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
|
||||
"co4e.new_agent": {"en": "New agent", "ja": "新規エージェント", "vi": "Agent mới"},
|
||||
"co4e.manage_skills": {"en": "Manage skills…", "ja": "スキル管理…", "vi": "Quản lý skill…"},
|
||||
"co4e.template": {"en": "template", "ja": "テンプレート", "vi": "mẫu"},
|
||||
"co4e.saved": {"en": "saved", "ja": "保存済み", "vi": "đã lưu"},
|
||||
"co4e.custom": {"en": "custom", "ja": "カスタム", "vi": "tùy chỉnh"},
|
||||
"co4e.parallel_node": {"en": "Parallel (fan-out)", "ja": "並列(ファンアウト)", "vi": "Song song (fan-out)"},
|
||||
"co4e.add_step": {"en": "Add step", "ja": "ステップ追加", "vi": "Thêm bước"},
|
||||
"co4e.fit": {"en": "Fit", "ja": "全体表示", "vi": "Vừa màn hình"},
|
||||
"co4e.fit_tooltip": {
|
||||
"en": "Auto-fit: zoom to show every step", "ja": "自動フィット:全ステップを表示",
|
||||
"vi": "Tự canh: thu phóng để thấy tất cả bước"},
|
||||
"co4e.drag_hint": {
|
||||
"en": "Drag a flow or agent onto the canvas (double-click a flow to load it).",
|
||||
"ja": "フローやエージェントをキャンバスにドラッグ(フローはダブルクリックで読み込み)。",
|
||||
"vi": "Kéo một flow hoặc agent vào canvas (double-click flow để tải)."},
|
||||
"co4e.blank_step": {"en": "Blank step", "ja": "空のステップ", "vi": "Bước trống"},
|
||||
"co4e.pick_agent": {"en": "Choose an agent", "ja": "エージェントを選択", "vi": "Chọn agent"},
|
||||
"co4e.ai_draft": {"en": "Draft with AI", "ja": "AIで下書き", "vi": "Soạn bằng AI"},
|
||||
"co4e.ai_draft_tooltip": {
|
||||
"en": "Let AI write this agent's instructions from its name and role (no skill needed).",
|
||||
"ja": "エージェントの名前と役割から指示文をAIが作成(スキル不要)。",
|
||||
"vi": "Để AI viết hướng dẫn cho agent từ tên và vai trò (không cần skill)."},
|
||||
"co4e.ai_draft_hint_title": {"en": "Draft with AI", "ja": "AIで下書き", "vi": "Soạn bằng AI"},
|
||||
"co4e.ai_draft_hint_label": {
|
||||
"en": "Describe what this agent should do (optional — leave blank to draft from just "
|
||||
"the name/role). More detail here → more detailed instructions.",
|
||||
"ja": "このエージェントが何をすべきか説明してください(任意 — 空欄なら名前/役割のみから"
|
||||
"下書き)。詳しく書くほど、生成される指示も詳細になります。",
|
||||
"vi": "Mô tả agent này nên làm gì (không bắt buộc — để trống sẽ soạn chỉ từ tên/vai trò). "
|
||||
"Mô tả chi tiết hơn → hướng dẫn được tạo ra chi tiết hơn."},
|
||||
"co4e.tt_add_step": {"en": "Add a blank step to the canvas", "ja": "空のステップをキャンバスに追加",
|
||||
"vi": "Thêm một bước trống vào canvas"},
|
||||
"co4e.tt_save": {"en": "Save this flow", "ja": "このフローを保存", "vi": "Lưu flow này"},
|
||||
"co4e.tt_save_template": {"en": "Save as a reusable template", "ja": "再利用テンプレートとして保存",
|
||||
"vi": "Lưu thành mẫu dùng lại"},
|
||||
"co4e.tt_run": {"en": "Run the flow (or Interrupt while running)", "ja": "フローを実行(実行中は中断)",
|
||||
"vi": "Chạy flow (hoặc Dừng khi đang chạy)"},
|
||||
"co4e.tt_mode": {
|
||||
"en": "Auto = each step plans then runs · Plan = dry-run a plan (read-only) · Manual = step-by-step (advance with Next step)",
|
||||
"ja": "Auto=各ステップが計画して実行 · Plan=計画のみ(読取専用)· Manual=1ステップずつ(「次へ」で進む)",
|
||||
"vi": "Auto = mỗi bước tự lập kế hoạch rồi chạy · Plan = chỉ lập kế hoạch (chỉ đọc) · Manual = từng bước (bấm Bước tiếp)"},
|
||||
"co4e.tt_new_wf": {"en": "Start a new empty flow", "ja": "新しい空のフロー", "vi": "Tạo flow mới trống"},
|
||||
"co4e.tt_load_wf": {"en": "Load the selected flow into the canvas",
|
||||
"ja": "選択したフローをキャンバスに読み込み", "vi": "Tải flow đã chọn vào canvas"},
|
||||
"co4e.tt_del_wf": {"en": "Delete the selected saved flow", "ja": "選択した保存フローを削除",
|
||||
"vi": "Xóa flow đã lưu đang chọn"},
|
||||
"co4e.tt_edit_wf": {"en": "Edit the selected flow", "ja": "選択したフローを編集",
|
||||
"vi": "Sửa flow đang chọn"},
|
||||
"co4e.tt_new_agent": {"en": "Create a custom agent persona", "ja": "カスタムエージェントを作成",
|
||||
"vi": "Tạo một agent tùy chỉnh"},
|
||||
"co4e.tt_edit_agent": {"en": "Edit the selected custom agent", "ja": "選択したカスタムエージェントを編集",
|
||||
"vi": "Sửa agent tùy chỉnh đang chọn"},
|
||||
"co4e.tt_del_agent": {"en": "Delete the selected custom agent", "ja": "選択したカスタムエージェントを削除",
|
||||
"vi": "Xóa agent tùy chỉnh đang chọn"},
|
||||
"co4e.tt_manage_skills": {"en": "Open the Skills manager", "ja": "スキル管理を開く",
|
||||
"vi": "Mở trình quản lý Skill"},
|
||||
"co4e.save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
|
||||
"co4e.save_template": {"en": "Save as Template", "ja": "テンプレートとして保存", "vi": "Lưu làm mẫu"},
|
||||
"co4e.flow_name": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
|
||||
"co4e.run": {"en": "Run", "ja": "実行", "vi": "Chạy"},
|
||||
"co4e.interrupt": {"en": "Interrupt", "ja": "中断", "vi": "Dừng"},
|
||||
"co4e.add": {"en": "Add", "ja": "追加", "vi": "Thêm"},
|
||||
"co4e.config_title": {"en": "Step config", "ja": "ステップ設定", "vi": "Cấu hình bước"},
|
||||
"co4e.tt_collapse_config": {"en": "Collapse the config panel", "ja": "設定パネルを折りたたむ",
|
||||
"vi": "Thu gọn bảng cấu hình"},
|
||||
"co4e.tt_expand_config": {"en": "Expand the config panel", "ja": "設定パネルを展開",
|
||||
"vi": "Mở rộng bảng cấu hình"},
|
||||
"co4e.messages": {"en": "Messages", "ja": "メッセージ", "vi": "Tin nhắn"},
|
||||
"co4e.tt_collapse_msgs": {"en": "Collapse the messages panel", "ja": "メッセージを折りたたむ",
|
||||
"vi": "Thu gọn khung tin nhắn"},
|
||||
"co4e.tt_expand_msgs": {"en": "Expand the messages panel", "ja": "メッセージを展開",
|
||||
"vi": "Mở rộng khung tin nhắn"},
|
||||
"co4e.mode.auto": {"en": "Auto", "ja": "自動", "vi": "Auto"},
|
||||
"co4e.mode.plan": {"en": "Plan", "ja": "計画", "vi": "Plan"},
|
||||
"co4e.mode.manual": {"en": "Manual", "ja": "手動", "vi": "Manual"},
|
||||
# --- Co4E run manager / duplicate / status / zoom (parallel flows) ---
|
||||
"co4e.copy_suffix": {"en": "copy", "ja": "コピー", "vi": "bản sao"},
|
||||
"co4e.tt_dup_wf": {"en": "Duplicate the selected flow (run copies in parallel)",
|
||||
"ja": "選択フローを複製(コピーを並列実行)", "vi": "Nhân bản flow đã chọn (chạy bản sao song song)"},
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Chuỗi hiển thị — phần login_dialog.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
# ---- login_dialog.py: startup login / bootstrap / offline ----
|
||||
"login.title": {"en": "Sign in", "ja": "サインイン", "vi": "Đăng nhập"},
|
||||
"login.header": {"en": "Cowork-Local BamBOO", "ja": "Cowork-Local BamBOO", "vi": "Cowork-Local BamBOO"},
|
||||
"login.account": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
|
||||
"login.code": {"en": "Access code", "ja": "アクセスコード", "vi": "Mã truy cập"},
|
||||
"login.department": {"en": "Department (optional)", "ja": "部署(任意)", "vi": "Phòng ban (không bắt buộc)"},
|
||||
"login.department_placeholder": {
|
||||
"en": "e.g. FA.PDS — groups you automatically", "ja": "例: FA.PDS — 自動でグループ分けされます",
|
||||
"vi": "vd: FA.PDS — sẽ tự động xếp vào nhóm tương ứng"},
|
||||
"login.login_btn": {"en": "Log in", "ja": "ログイン", "vi": "Đăng nhập"},
|
||||
"login.exit_btn": {"en": "Exit", "ja": "終了", "vi": "Thoát"},
|
||||
"login.err_invalid": {
|
||||
"en": "Invalid account or access code.", "ja": "アカウントまたはアクセスコードが無効です。",
|
||||
"vi": "Tài khoản hoặc mã truy cập không đúng."},
|
||||
"login.err_admin_exists": {
|
||||
"en": "An Admin account already exists for this shared folder — the app has exactly one. Log in with an account issued by the Admin instead.",
|
||||
"ja": "この共有フォルダには既に管理者アカウントが存在します(管理者は1人のみ)。管理者から発行されたアカウントでログインしてください。",
|
||||
"vi": "Thư mục dùng chung này đã có tài khoản Admin — app chỉ có duy nhất 1 Admin. Hãy đăng nhập bằng tài khoản do Admin cấp."},
|
||||
"login.err_missing_fields": {
|
||||
"en": "Enter both a shared folder path and an account name.",
|
||||
"ja": "共有フォルダのパスとアカウント名の両方を入力してください。",
|
||||
"vi": "Nhập đường dẫn thư mục chia sẻ và tên tài khoản."},
|
||||
"login.err_shared_dir": {
|
||||
"en": "Could not create the shared folder: {error}",
|
||||
"ja": "共有フォルダを作成できませんでした: {error}",
|
||||
"vi": "Không tạo được thư mục chia sẻ: {error}"},
|
||||
"login.bootstrap_hint": {
|
||||
"en": "No accounts exist yet. Choose a shared folder (a network share or a "
|
||||
"locally-synced OneDrive folder) and create the first Admin account.",
|
||||
"ja": "アカウントがまだありません。共有フォルダ(ネットワーク共有、または同期済みの "
|
||||
"OneDrive フォルダ)を選び、最初の管理者アカウントを作成してください。",
|
||||
"vi": "Chưa có tài khoản nào. Chọn một thư mục chia sẻ (network share hoặc thư mục "
|
||||
"OneDrive đã đồng bộ trên máy) và tạo tài khoản Admin đầu tiên."},
|
||||
"login.shared_dir": {"en": "Shared folder", "ja": "共有フォルダ", "vi": "Thư mục chia sẻ"},
|
||||
"login.browse": {"en": "Browse…", "ja": "参照…", "vi": "Chọn…"},
|
||||
"login.create_admin": {
|
||||
"en": "Create Admin account", "ja": "管理者アカウントを作成", "vi": "Tạo tài khoản Admin"},
|
||||
"login.code_shown_title": {"en": "Admin account created", "ja": "管理者アカウントを作成しました",
|
||||
"vi": "Đã tạo tài khoản Admin"},
|
||||
"login.code_shown_body": {
|
||||
"en": "Account: {username}\nAccess code: {code}\n\nSave this code now — it will "
|
||||
"not be shown again. You are now logged in.",
|
||||
"ja": "アカウント: {username}\nアクセスコード: {code}\n\n今すぐこのコードを保存してくださ"
|
||||
"い — 二度と表示されません。ログインしました。",
|
||||
"vi": "Tài khoản: {username}\nMã truy cập: {code}\n\nHãy lưu lại mã này ngay — mã sẽ "
|
||||
"không hiển thị lại lần nào nữa. Bạn đã đăng nhập."},
|
||||
"login.unreachable": {
|
||||
"en": "Can't reach the shared folder:\n{path}", "ja": "共有フォルダに到達できません:\n{path}",
|
||||
"vi": "Không truy cập được thư mục chia sẻ:\n{path}"},
|
||||
"login.offline_hint": {
|
||||
"en": "Last successful login on this machine: {username} ({role}).",
|
||||
"ja": "このマシンでの最後の正常なログイン: {username} ({role})。",
|
||||
"vi": "Lần đăng nhập thành công gần nhất trên máy này: {username} ({role})."},
|
||||
"login.offline_btn": {"en": "Continue offline as {role}", "ja": "{role} としてオフラインで続行",
|
||||
"vi": "Tiếp tục offline với vai trò {role}"},
|
||||
"login.no_offline_cache": {
|
||||
"en": "No previous successful login on this machine — contact your Admin.",
|
||||
"ja": "このマシンでの過去のログイン履歴がありません — 管理者に連絡してください。",
|
||||
"vi": "Chưa có lượt đăng nhập thành công nào trên máy này — liên hệ Admin."},
|
||||
"login.retry_btn": {"en": "Retry", "ja": "再試行", "vi": "Thử lại"},
|
||||
|
||||
# ---- accounts_tab.py: Monitoring -> Accounts panel (Admin/Sub-admin) --
|
||||
"accounts.edit_title": {"en": "Edit account", "ja": "アカウントを編集", "vi": "Sửa tài khoản"},
|
||||
"accounts.add_title": {"en": "Add account", "ja": "アカウントを追加", "vi": "Thêm tài khoản"},
|
||||
"accounts.f_username": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
|
||||
"accounts.f_display_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
|
||||
"accounts.f_email": {"en": "Email", "ja": "メール", "vi": "Email"},
|
||||
"accounts.f_email_placeholder": {
|
||||
"en": "name@company.com (optional)", "ja": "name@company.com(任意)",
|
||||
"vi": "name@company.com (không bắt buộc)"},
|
||||
"accounts.f_role": {"en": "Role", "ja": "役割", "vi": "Vai trò"},
|
||||
"accounts.f_department": {"en": "Department", "ja": "部門", "vi": "Bộ phận"},
|
||||
"accounts.f_group": {"en": "Group", "ja": "グループ", "vi": "Nhóm"},
|
||||
"accounts.f_group_name": {"en": "Group name", "ja": "グループ名", "vi": "Tên nhóm"},
|
||||
"accounts.no_group": {"en": "— No group —", "ja": "— グループなし —", "vi": "— Không có nhóm —"},
|
||||
"accounts.role.admin": {"en": "Admin", "ja": "管理者", "vi": "Admin"},
|
||||
"accounts.role.subadmin": {"en": "Sub-admin", "ja": "サブ管理者", "vi": "Sub-admin"},
|
||||
"accounts.role.user": {"en": "User", "ja": "ユーザー", "vi": "User"},
|
||||
"accounts.no_shared_dir": {
|
||||
"en": "No shared folder configured — set one in Settings to manage accounts.",
|
||||
"ja": "共有フォルダが設定されていません — 設定でアカウント管理用のフォルダを指定してください。",
|
||||
"vi": "Chưa cấu hình thư mục chia sẻ — thiết lập trong Settings để quản lý tài khoản."},
|
||||
"accounts.shared_dir_hint": {"en": "Shared folder: {path}", "ja": "共有フォルダ: {path}",
|
||||
"vi": "Thư mục chia sẻ: {path}"},
|
||||
"accounts.ungrouped": {"en": "Ungrouped", "ja": "未分類", "vi": "Chưa có nhóm"},
|
||||
"accounts.filter_all_groups": {"en": "All groups", "ja": "すべてのグループ", "vi": "Tất cả nhóm"},
|
||||
"accounts.delete_title": {"en": "Delete account", "ja": "アカウントを削除", "vi": "Xóa tài khoản"},
|
||||
"accounts.delete_confirm": {"en": "Delete account '{username}'?", "ja": "アカウント「{username}」を削"
|
||||
"除しますか?", "vi": "Xóa tài khoản '{username}'?"},
|
||||
"accounts.new_group_title": {"en": "New group", "ja": "新しいグループ", "vi": "Nhóm mới"},
|
||||
"accounts.add_btn": {"en": "Add", "ja": "追加", "vi": "Thêm"},
|
||||
"accounts.edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
|
||||
"accounts.delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"accounts.generate_code_btn": {"en": "Generate code", "ja": "コード発行", "vi": "Tạo mã"},
|
||||
"accounts.new_group_btn": {"en": "New group", "ja": "新しいグループ", "vi": "Nhóm mới"},
|
||||
"accounts.drag_move_hint": {
|
||||
"en": "Drag an account onto a group to move it there.",
|
||||
"ja": "アカウントをグループにドラッグすると移動できます。",
|
||||
"vi": "Kéo tài khoản thả vào một nhóm để di chuyển đến đó."},
|
||||
"accounts.err_admin_exists": {
|
||||
"en": "An Admin account already exists — the app has exactly one.",
|
||||
"ja": "管理者アカウントは既に存在します(1人のみ)。",
|
||||
"vi": "Đã có tài khoản Admin — app chỉ có duy nhất 1 Admin."},
|
||||
"accounts.search_placeholder": {
|
||||
"en": "Search accounts (or type a question and press )…",
|
||||
"ja": "アカウント検索(質問を入力しても可)…",
|
||||
"vi": "Tìm tài khoản (hoặc gõ câu hỏi rồi bấm )…"},
|
||||
"accounts.ai_search_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
|
||||
"accounts.ai_search_tooltip": {
|
||||
"en": "AI turns your question into a search keyword (e.g. \"who in CAE has no department?\").",
|
||||
"ja": "質問をAIが検索キーワードに変換します。",
|
||||
"vi": "AI chuyển câu hỏi của bạn thành từ khóa tìm kiếm (vd: \"ai trong CAE chưa có phòng ban?\")."},
|
||||
"accounts.excel_template_btn": {
|
||||
"en": "Excel template", "ja": "Excelテンプレート", "vi": "Mẫu Excel"},
|
||||
"accounts.excel_import_btn": {
|
||||
"en": "Import Excel", "ja": "Excel取り込み", "vi": "Nhập từ Excel"},
|
||||
"accounts.excel_imported": {
|
||||
"en": "Created {n} account(s).", "ja": "{n} 件のアカウントを作成しました。",
|
||||
"vi": "Đã tạo {n} tài khoản."},
|
||||
"accounts.excel_codes_saved": {
|
||||
"en": "Access codes saved to: {path}", "ja": "アクセスコードの保存先: {path}",
|
||||
"vi": "Mã truy cập đã lưu tại: {path}"},
|
||||
"accounts.usage_title": {"en": "Usage & Cost by account", "ja": "アカウント別の使用量とコスト",
|
||||
"vi": "Sử dụng & Chi phí theo tài khoản"},
|
||||
"accounts.period.day": {"en": "Day", "ja": "日", "vi": "Ngày"},
|
||||
"accounts.period.week": {"en": "Week", "ja": "週", "vi": "Tuần"},
|
||||
"accounts.period.month": {"en": "Month", "ja": "月", "vi": "Tháng"},
|
||||
"accounts.period.year": {"en": "Year", "ja": "年", "vi": "Năm"},
|
||||
"accounts.col_name": {"en": "Name", "ja": "名前", "vi": "Tên"},
|
||||
"accounts.col_account": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"},
|
||||
"accounts.col_machine": {"en": "Machine", "ja": "マシン", "vi": "Máy"},
|
||||
"accounts.col_department": {"en": "Department", "ja": "部門", "vi": "Bộ phận"},
|
||||
"accounts.col_tokens": {"en": "Tokens", "ja": "トークン", "vi": "Token"},
|
||||
"accounts.col_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"},
|
||||
|
||||
# ---- app.py: top bar, tabs, toasts, tray ------------------------
|
||||
"app.logo": {"en": "Cowork-Local BamBOO", "ja": "Cowork-Local BamBOO", "vi": "Cowork-Local BamBOO"},
|
||||
"app.provider": {"en": "Provider:", "ja": "プロバイダー:", "vi": "Nhà cung cấp:"},
|
||||
"app.language": {"en": "Language:", "ja": "言語:", "vi": "Ngôn ngữ:"},
|
||||
"app.settings": {"en": "Settings", "ja": "設定", "vi": "Cài đặt"},
|
||||
"app.tab.dashboard": {"en": "Dashboard", "ja": "Dashboard", "vi": "Dashboard"},
|
||||
"app.tab.schedule": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"},
|
||||
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"app.tab.code": {"en": "Code", "ja": "Code", "vi": "Code"},
|
||||
"app.tab.structure": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
|
||||
"app.tab.workspace": {"en": "Workspace", "ja": "ワークスペース", "vi": "Workspace"},
|
||||
"app.tab.monitoring": {"en": "Monitoring", "ja": "モニタリング", "vi": "Giám sát"},
|
||||
"app.nav.collapse_tooltip": {"en": "Collapse menu to icons only", "ja": "メニューをアイコンのみに折りたたむ", "vi": "Thu gọn menu về icon"},
|
||||
"app.nav.expand_tooltip": {"en": "Expand menu", "ja": "メニューを展開", "vi": "Mở rộng menu"},
|
||||
"app.nav.menu_label": {"en": "MENU", "ja": "MENU", "vi": "MENU"},
|
||||
# Shown on the rail rows the project gate disables (Cowork, GraphRAG) —
|
||||
# they stay listed and greyed instead of disappearing from the menu.
|
||||
"app.nav.needs_project": {
|
||||
"en": "Select a project first", "ja": "先にプロジェクトを選択してください",
|
||||
"vi": "Chọn project trước"},
|
||||
# Rail header: the project a new chat will be created in, and what to do
|
||||
# when there is no project yet.
|
||||
"app.nav.project_pick": {
|
||||
"en": "Project for new chats", "ja": "新しいチャットのプロジェクト",
|
||||
"vi": "Project cho đoạn chat mới"},
|
||||
"app.nav.no_project": {
|
||||
"en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"},
|
||||
"app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"},
|
||||
"app.nav.all_projects": {
|
||||
"en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"},
|
||||
"app.nav.create_project_first": {
|
||||
"en": "Create a project first", "ja": "先にプロジェクトを作成してください",
|
||||
"vi": "Tạo project trước"},
|
||||
|
||||
# ---- workspace_tab.py (Projects — Claude-Projects style) -----------
|
||||
"workspace.header": {"en": "Manage projects", "ja": "プロジェクト管理", "vi": "Quản lý project"},
|
||||
"workspace.tab_cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"workspace.tab_graphrag": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
|
||||
"workspace.tab_project": {"en": "Project", "ja": "プロジェクト", "vi": "Project"},
|
||||
"workspace.tab_folder": {"en": "Folder", "ja": "フォルダ", "vi": "Thư mục"},
|
||||
"folder.path_placeholder": {
|
||||
"en": "Folder path", "ja": "フォルダのパス", "vi": "Đường dẫn thư mục"},
|
||||
"folder.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
|
||||
"folder.save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
|
||||
"folder.open_external": {
|
||||
"en": "Open externally", "ja": "外部で開く", "vi": "Mở bằng app ngoài"},
|
||||
"folder.preview": {"en": "Preview", "ja": "プレビュー", "vi": "Xem trước"},
|
||||
"folder.edit": {"en": "Edit", "ja": "編集", "vi": "Chỉnh sửa"},
|
||||
"folder.select_file": {
|
||||
"en": "Select a file in the tree to view or edit it.",
|
||||
"ja": "ツリーでファイルを選択して表示・編集します。",
|
||||
"vi": "Chọn một tệp trong cây thư mục để xem hoặc chỉnh sửa."},
|
||||
"folder.binary_file": {
|
||||
"en": "Binary or very large file — open it externally to view.",
|
||||
"ja": "バイナリまたは非常に大きいファイルです — 外部で開いて表示してください。",
|
||||
"vi": "Tệp nhị phân hoặc quá lớn — mở bằng app ngoài để xem."},
|
||||
"folder.converting": {
|
||||
"en": "Rendering document… (converting to PDF via LibreOffice)",
|
||||
"ja": "ドキュメントを表示中…(LibreOffice で PDF に変換しています)",
|
||||
"vi": "Đang hiển thị tài liệu… (chuyển sang PDF bằng LibreOffice)"},
|
||||
"folder.doc_unreadable": {
|
||||
"en": "Could not extract text ({note}). Open it externally for the full document.",
|
||||
"ja": "テキストを抽出できませんでした ({note})。完全な文書は外部で開いてください。",
|
||||
"vi": "Không trích xuất được nội dung ({note}). Mở bằng app ngoài để xem đầy đủ."},
|
||||
"folder.saved": {"en": "Saved {name}", "ja": "{name} を保存しました", "vi": "Đã lưu {name}"},
|
||||
"folder.ai_edit": {"en": "AI Edit", "ja": "AI 編集", "vi": "AI Edit"},
|
||||
"folder.ai_edit_tooltip": {
|
||||
"en": "Edit the open file with AI (uses the Cowork conversation context)",
|
||||
"ja": "AI で開いているファイルを編集(Cowork の会話コンテキストを利用)",
|
||||
"vi": "Dùng AI chỉnh sửa file đang mở (dùng ngữ cảnh hội thoại Cowork)"},
|
||||
"folder.ai_placeholder": {
|
||||
"en": "Describe the edit… (e.g. add error handling)",
|
||||
"ja": "編集内容を入力…(例: エラー処理を追加)",
|
||||
"vi": "Mô tả chỉnh sửa… (vd: thêm xử lý lỗi)"},
|
||||
"folder.ai_send": {"en": "Send", "ja": "送信", "vi": "Gửi"},
|
||||
"folder.ai_no_file": {
|
||||
"en": "Open a text/code file in Edit mode first.",
|
||||
"ja": "先にテキスト/コードファイルを編集モードで開いてください。",
|
||||
"vi": "Hãy mở một file text/code ở chế độ Edit trước."},
|
||||
"folder.ai_applied": {
|
||||
"en": "✓ Applied the edit — review it and Save.",
|
||||
"ja": "✓ 編集を適用しました — 確認して保存してください。",
|
||||
"vi": "✓ Đã áp dụng chỉnh sửa — kiểm tra rồi Lưu."},
|
||||
"folder.ai_empty": {
|
||||
"en": "(the model didn't return an edited file)",
|
||||
"ja": "(モデルは編集後のファイルを返しませんでした)",
|
||||
"vi": "(model không trả về file đã chỉnh sửa)"},
|
||||
"folder.ai_error": {
|
||||
"en": "AI edit failed: {err}", "ja": "AI 編集に失敗しました: {err}",
|
||||
"vi": "AI edit thất bại: {err}"},
|
||||
"folder.ai_running": {
|
||||
"en": "AI is editing {name}… (keeps running while you do other things)",
|
||||
"ja": "AI が {name} を編集中…(他の作業をしていても継続します)",
|
||||
"vi": "AI đang chỉnh sửa {name}… (vẫn chạy tiếp khi bạn làm việc khác)"},
|
||||
"folder.ai_done": {
|
||||
"en": "AI edit finished for {name} — review it in the Folder tab.",
|
||||
"ja": "{name} の AI 編集が完了しました — Folder タブで確認してください。",
|
||||
"vi": "AI edit xong cho {name} — kiểm tra ở tab Folder."},
|
||||
"folder.ai_status_running": {
|
||||
"en": "processing…", "ja": "処理中…", "vi": "đang xử lí…"},
|
||||
"folder.ai_status_done": {
|
||||
"en": "done", "ja": "完了", "vi": "xong"},
|
||||
"folder.ai_planning": {
|
||||
"en": "Planning…", "ja": "計画中…", "vi": "Đang lập kế hoạch…"},
|
||||
"folder.ai_apply": {"en": "Apply", "ja": "適用", "vi": "Áp dụng"},
|
||||
"folder.ai_discard": {"en": "Discard", "ja": "破棄", "vi": "Hủy"},
|
||||
"folder.ai_proposed": {
|
||||
"en": "Proposed changes (review)", "ja": "変更案(確認)",
|
||||
"vi": "Thay đổi đề xuất (xem lại)"},
|
||||
"folder.ai_review_hint": {
|
||||
"en": "Review the diff, then Apply or Discard.",
|
||||
"ja": "差分を確認してから、適用または破棄してください。",
|
||||
"vi": "Xem lại diff rồi bấm Áp dụng hoặc Hủy."},
|
||||
"folder.ai_proposed_status": {
|
||||
"en": "AI proposed an edit for {name} — review & Apply.",
|
||||
"ja": "{name} の編集案が出ました — 確認して適用してください。",
|
||||
"vi": "AI đề xuất chỉnh sửa {name} — xem lại & Áp dụng."},
|
||||
"folder.ai_discarded": {
|
||||
"en": "Discarded — the file was not changed.",
|
||||
"ja": "破棄しました — ファイルは変更されていません。",
|
||||
"vi": "Đã hủy — file không bị thay đổi."},
|
||||
"folder.ai_new_file": {"en": "a new file", "ja": "新規ファイル", "vi": "file mới"},
|
||||
"folder.ai_proposed_new": {
|
||||
"en": "Proposed NEW file: {name} (review)",
|
||||
"ja": "新規ファイルの提案: {name}(確認)",
|
||||
"vi": "Đề xuất tạo file MỚI: {name} (xem lại)"},
|
||||
"folder.ai_created": {
|
||||
"en": "Created {name}", "ja": "{name} を作成しました", "vi": "Đã tạo {name}"},
|
||||
"folder.ai_image_confirm_title": {
|
||||
"en": "Confirm image change", "ja": "画像変更の確認", "vi": "Xác nhận sửa ảnh"},
|
||||
"folder.ai_image_confirm": {
|
||||
"en": "This edit replaces one or more images in the slide. Proceed?",
|
||||
"ja": "この編集はスライド内の画像を置き換えます。実行しますか?",
|
||||
"vi": "Chỉnh sửa này sẽ thay ảnh trong slide. Tiếp tục?"},
|
||||
"folder.ai_image_declined": {
|
||||
"en": "Image change cancelled.", "ja": "画像の変更をキャンセルしました。",
|
||||
"vi": "Đã hủy thay đổi ảnh."},
|
||||
"folder.ai_image_confirm_gen": {
|
||||
"en": "This will GENERATE image(s) with the AI model and save them into the folder. Proceed?",
|
||||
"ja": "AI モデルで画像を生成してフォルダに保存します。実行しますか?",
|
||||
"vi": "Sẽ TẠO ảnh bằng model AI và lưu vào thư mục. Tiếp tục?"},
|
||||
"folder.ai_image_plan": {
|
||||
"en": "Will generate these illustration image(s):",
|
||||
"ja": "以下のイラスト画像を生成します:",
|
||||
"vi": "Sẽ tạo các ảnh minh họa sau:"},
|
||||
"folder.ai_generating": {
|
||||
"en": "Generating image(s)…", "ja": "画像を生成中…", "vi": "Đang tạo ảnh…"},
|
||||
"folder.ai_image_created": {
|
||||
"en": "Generated image {name}", "ja": "画像 {name} を生成しました",
|
||||
"vi": "Đã tạo ảnh {name}"},
|
||||
"folder.ai_image_failed": {
|
||||
"en": "Image generation failed: {err}", "ja": "画像生成に失敗しました: {err}",
|
||||
"vi": "Tạo ảnh thất bại: {err}"},
|
||||
"folder.ai_model_label": {"en": "Model:", "ja": "モデル:", "vi": "Model:"},
|
||||
"folder.ai_model_auto": {
|
||||
"en": "(auto — provider default)", "ja": "(自動 — 既定モデル)",
|
||||
"vi": "(tự động — model mặc định)"},
|
||||
"folder.ai_image_suggest": {
|
||||
"en": "💡 Tip: pick model '{model}' above for image generation.",
|
||||
"ja": "💡 画像生成には上のモデル '{model}' を選ぶのがおすすめです。",
|
||||
"vi": "💡 Gợi ý: chọn model '{model}' ở trên để tạo ảnh."},
|
||||
"folder.ai_image_suggest_all": {
|
||||
"en": "💡 This request involves images. Image-capable models found on other providers:",
|
||||
"ja": "💡 このリクエストは画像を含みます。他プロバイダーで見つかった画像対応モデル:",
|
||||
"vi": "💡 Yêu cầu này liên quan đến ảnh. Model tạo ảnh tìm thấy ở các provider khác:"},
|
||||
"folder.ai_image_none": {
|
||||
"en": "💡 This request involves images, but no image-capable model was found on any configured provider.",
|
||||
"ja": "💡 このリクエストは画像を含みますが、設定済みのどのプロバイダーにも画像対応モデルが見つかりませんでした。",
|
||||
"vi": "💡 Yêu cầu này liên quan đến ảnh, nhưng không tìm thấy model tạo ảnh ở provider nào đã cấu hình."},
|
||||
"folder.ai_image_use_selected": {
|
||||
"en": "💡 No dedicated image model found — will use your selected model '{model}' to generate images.",
|
||||
"ja": "💡 専用の画像モデルが見つかりません — 選択中のモデル '{model}' で画像を生成します。",
|
||||
"vi": "💡 Không tìm thấy model tạo ảnh chuyên biệt — sẽ dùng model bạn đã chọn '{model}' để tạo ảnh."},
|
||||
"folder.ai_queued": {
|
||||
"en": "⏳ Queued (#{n}) — runs after the current edit.",
|
||||
"ja": "⏳ キューに追加 (#{n}) — 現在の編集の後に実行します。",
|
||||
"vi": "⏳ Đã thêm vào hàng đợi (#{n}) — chạy sau lệnh hiện tại."},
|
||||
"folder.ai_queue_count": {
|
||||
"en": "{n} queued", "ja": "{n} 件待機中", "vi": "{n} đang chờ"},
|
||||
"terminal.title": {"en": "Terminal", "ja": "ターミナル", "vi": "Terminal"},
|
||||
"terminal.run": {"en": "Run", "ja": "実行", "vi": "Chạy"},
|
||||
"terminal.placeholder": {
|
||||
"en": "Type a command and press Enter…", "ja": "コマンドを入力して Enter…",
|
||||
"vi": "Nhập lệnh rồi nhấn Enter…"},
|
||||
"terminal.expand_tooltip": {
|
||||
"en": "Expand terminal", "ja": "ターミナルを開く", "vi": "Mở terminal"},
|
||||
"terminal.collapse_tooltip": {
|
||||
"en": "Collapse terminal", "ja": "ターミナルを閉じる", "vi": "Thu gọn terminal"},
|
||||
"terminal.busy": {
|
||||
"en": "[a command is still running]", "ja": "[コマンドがまだ実行中です]",
|
||||
"vi": "[đang chạy một lệnh khác]"},
|
||||
"terminal.cd_error": {
|
||||
"en": "cd: no such directory: {path}", "ja": "cd: ディレクトリがありません: {path}",
|
||||
"vi": "cd: không có thư mục: {path}"},
|
||||
"terminal.launch_error": {
|
||||
"en": "[failed to launch the shell]", "ja": "[シェルの起動に失敗しました]",
|
||||
"vi": "[không khởi chạy được shell]"},
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Chuỗi hiển thị — phần monitoring_overview.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"monitoring.overview_sandbox_id": {"en": "Sandbox ID", "ja": "サンドボックス ID", "vi": "Sandbox ID"},
|
||||
"monitoring.overview_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
|
||||
"monitoring.overview_status_running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"},
|
||||
"monitoring.overview_created": {"en": "Created", "ja": "作成日時", "vi": "Tạo lúc"},
|
||||
"monitoring.overview_uptime": {"en": "Uptime", "ja": "稼働時間", "vi": "Thời gian hoạt động"},
|
||||
"monitoring.overview_resource_limits": {
|
||||
"en": "Resource Limits", "ja": "リソース制限", "vi": "Giới hạn tài nguyên"},
|
||||
"monitoring.overview_edit": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
|
||||
"monitoring.overview_network_label": {"en": "Network", "ja": "ネットワーク", "vi": "Mạng"},
|
||||
"monitoring.overview_network_disabled": {"en": "Disabled", "ja": "無効", "vi": "Đã tắt"},
|
||||
"monitoring.overview_network_enabled": {"en": "Enabled", "ja": "有効", "vi": "Đang mở"},
|
||||
"monitoring.overview_permissions_title": {"en": "Permissions", "ja": "権限", "vi": "Quyền"},
|
||||
"monitoring.overview_perm_fs": {"en": "File System", "ja": "ファイルシステム", "vi": "Hệ thống file"},
|
||||
"monitoring.overview_perm_fs_value": {"en": "Read/Write", "ja": "読み書き", "vi": "Đọc/Ghi"},
|
||||
"monitoring.overview_perm_network": {"en": "Network", "ja": "ネットワーク", "vi": "Mạng"},
|
||||
"monitoring.overview_perm_network_blocked": {"en": "Blocked", "ja": "ブロック", "vi": "Bị chặn"},
|
||||
"monitoring.overview_perm_network_allowed": {"en": "Allowed", "ja": "許可", "vi": "Cho phép"},
|
||||
"monitoring.overview_perm_process": {"en": "Process", "ja": "プロセス", "vi": "Tiến trình"},
|
||||
"monitoring.overview_perm_process_value": {"en": "Limited", "ja": "制限あり", "vi": "Bị hạn chế"},
|
||||
"monitoring.overview_perm_env": {"en": "Environment", "ja": "実行環境", "vi": "Môi trường"},
|
||||
"monitoring.overview_perm_env_value": {"en": "Restricted", "ja": "制限あり", "vi": "Bị giới hạn"},
|
||||
"monitoring.overview_audit_title": {"en": "Audit Log", "ja": "監査ログ", "vi": "Audit Log"},
|
||||
"monitoring.overview_view_all": {"en": "View all", "ja": "すべて表示", "vi": "Xem tất cả"},
|
||||
"monitoring.time_just_now": {"en": "just now", "ja": "たった今", "vi": "vừa xong"},
|
||||
"monitoring.time_minutes_ago": {"en": "{n}m ago", "ja": "{n}分前", "vi": "{n} phút trước"},
|
||||
"monitoring.time_hours_ago": {"en": "{n}h ago", "ja": "{n}時間前", "vi": "{n} giờ trước"},
|
||||
"monitoring.time_days_ago": {"en": "{n}d ago", "ja": "{n}日前", "vi": "{n} ngày trước"},
|
||||
"monitoring.na": {"en": "—", "ja": "—", "vi": "—"},
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Chuỗi hiển thị — phần settings_dialog.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"settings.sandbox_block_network": {
|
||||
"en": "Block network for agent-run commands",
|
||||
"ja": "エージェントが実行するコマンドのネットワークをブロック",
|
||||
"vi": "Chặn mạng cho lệnh do agent chạy"},
|
||||
"settings.allow_url_fetch": {
|
||||
"en": "Allow the agent to fetch URLs (web pages, SharePoint / OneDrive links)",
|
||||
"ja": "エージェントによるURL取得を許可(Webページ、SharePoint / OneDriveリンク)",
|
||||
"vi": "Cho phép agent lấy dữ liệu từ URL (trang web, link SharePoint / OneDrive)"},
|
||||
"settings.allow_url_fetch_tooltip": {
|
||||
"en": ("Lets the agent's fetch_url tool read web pages, online documents and "
|
||||
"SharePoint/OneDrive share links to search & process them. Separate from "
|
||||
"'Block network' (which only sandboxes shell commands). Default: on."),
|
||||
"ja": "エージェントのfetch_urlツールがWebページ・オンライン文書・SharePoint/OneDrive共有リンクを"
|
||||
"読み取れるようにします。「ネットワークをブロック」(シェルコマンド用)とは別です。既定: オン。",
|
||||
"vi": ("Cho phép tool fetch_url của agent đọc trang web, tài liệu online và link chia sẻ "
|
||||
"SharePoint/OneDrive để tìm kiếm & xử lý. Tách biệt với 'Chặn mạng' (chỉ áp cho lệnh "
|
||||
"shell). Mặc định: bật.")},
|
||||
"settings.test_internet": {
|
||||
"en": "Test Internet", "ja": "インターネット接続テスト", "vi": "Kiểm tra Internet"},
|
||||
"settings.test_internet_tooltip": {
|
||||
"en": ("Live-checks the app's own outbound HTTPS path (the same one fetch_url uses) "
|
||||
"and reports the concrete reason if it can't reach the internet."),
|
||||
"ja": "アプリ自身の送信HTTPS経路(fetch_urlと同じ)を実際にテストし、インターネットに到達できない"
|
||||
"場合は具体的な理由を表示します。",
|
||||
"vi": ("Kiểm tra trực tiếp đường HTTPS ra ngoài của app (đúng đường mà fetch_url dùng) và "
|
||||
"báo lý do cụ thể nếu không truy cập được internet.")},
|
||||
"settings.testing_internet": {
|
||||
"en": "Testing internet access…", "ja": "インターネット接続をテスト中…",
|
||||
"vi": "Đang kiểm tra truy cập internet…"},
|
||||
"settings.sandbox_block_network_tooltip": {
|
||||
"en": ("Policy-level control (proxy env vars point at a black hole) — not a kernel "
|
||||
"firewall. Combine with the command whitelist above for defense in depth."),
|
||||
"ja": "ポリシーレベルの制御です(プロキシ環境変数をブラックホールに向ける)— カーネルレベルの"
|
||||
"ファイアウォールではありません。上のコマンドホワイトリストと併用してください。",
|
||||
"vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải "
|
||||
"firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."},
|
||||
"settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
|
||||
"settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"},
|
||||
"settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"},
|
||||
"settings.sandbox_disk_label": {"en": "Disk I/O limit", "ja": "ディスク I/O 制限", "vi": "Giới hạn disk I/O"},
|
||||
"settings.sandbox_hint": {
|
||||
"en": ("Applies to every run_command/install_package the agent executes "
|
||||
"(Cowork, Code tab, and Schedule Task alike). 0 = unlimited. This layer is "
|
||||
"independent of \"Agent Security\" above — it still applies even while that "
|
||||
"toggle is off."),
|
||||
"ja": "エージェントが実行するすべての run_command/install_package に適用されます"
|
||||
"(Cowork、Code タブ、Schedule Task 共通)。0 = 無制限。この機能は上の「Agent "
|
||||
"Security」とは独立しており、そのトグルがオフの間も適用され続けます。",
|
||||
"vi": "Áp dụng cho mọi run_command/install_package mà agent chạy (Cowork, tab Code, "
|
||||
"và Schedule Task). 0 = không giới hạn. Lớp này độc lập với \"Agent Security\" "
|
||||
"ở trên — vẫn áp dụng ngay cả khi tắt Agent Security."},
|
||||
"settings.group.mcp": {"en": "MCP Servers", "ja": "MCP サーバー", "vi": "MCP Servers"},
|
||||
"settings.mcp_hint": {
|
||||
"en": ("Connect to external MCP (Model Context Protocol) servers — e.g. the official "
|
||||
"filesystem/GitHub/brave-search servers — and their tools become available to "
|
||||
"the agent alongside Microsoft 365 and the built-in file/command tools."),
|
||||
"ja": "外部の MCP(Model Context Protocol)サーバー(公式の filesystem/GitHub/brave-search "
|
||||
"サーバーなど)に接続すると、そのツールが Microsoft 365 や組み込みのファイル/コマンド"
|
||||
"ツールと並んでエージェントから利用できるようになります。",
|
||||
"vi": "Kết nối tới các MCP server bên ngoài (vd: server filesystem/GitHub/brave-search chính "
|
||||
"thức) — tool của chúng sẽ khả dụng cho agent cùng với Microsoft 365 và tool file/lệnh "
|
||||
"có sẵn."},
|
||||
"settings.mcp_add_btn": {"en": "Add server…", "ja": "サーバーを追加…", "vi": "Thêm server…"},
|
||||
"settings.mcp_edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
|
||||
"settings.mcp_delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"settings.mcp_no_servers": {
|
||||
"en": "(No MCP servers configured — click 'Add server…')",
|
||||
"ja": "(MCP サーバーが設定されていません。「サーバーを追加…」をクリック)",
|
||||
"vi": "(Chưa cấu hình MCP server nào — bấm 'Thêm server…')"},
|
||||
"settings.mcp_delete_confirm": {
|
||||
"en": "Remove MCP server \"{name}\"?", "ja": "MCP サーバー「{name}」を削除しますか?",
|
||||
"vi": "Xóa MCP server \"{name}\"?"},
|
||||
"mcp.add_title": {"en": "Add MCP server", "ja": "MCP サーバーを追加", "vi": "Thêm MCP server"},
|
||||
"mcp.edit_title": {"en": "Edit MCP server", "ja": "MCP サーバーを編集", "vi": "Sửa MCP server"},
|
||||
"mcp.name_label": {"en": "Name", "ja": "名前", "vi": "Tên"},
|
||||
"mcp.name_placeholder": {"en": "e.g. filesystem", "ja": "例: filesystem", "vi": "vd: filesystem"},
|
||||
"mcp.command_label": {"en": "Command", "ja": "コマンド", "vi": "Lệnh"},
|
||||
"mcp.command_placeholder": {"en": "e.g. npx", "ja": "例: npx", "vi": "vd: npx"},
|
||||
"mcp.args_label": {"en": "Arguments", "ja": "引数", "vi": "Tham số"},
|
||||
"mcp.args_placeholder": {
|
||||
"en": "e.g. -y @modelcontextprotocol/server-filesystem C:\\Data",
|
||||
"ja": "例: -y @modelcontextprotocol/server-filesystem C:\\Data",
|
||||
"vi": "vd: -y @modelcontextprotocol/server-filesystem C:\\Data"},
|
||||
"mcp.hint": {
|
||||
"en": ("The server is launched as a subprocess and talked to over stdio (the standard "
|
||||
"MCP transport) — the SAME way Claude Desktop/other MCP clients connect to it."),
|
||||
"ja": "サーバーはサブプロセスとして起動され、stdio(標準の MCP トランスポート)で通信します"
|
||||
"— Claude Desktop など他の MCP クライアントと同じ方式です。",
|
||||
"vi": "Server được khởi chạy như 1 subprocess và giao tiếp qua stdio (giao thức MCP chuẩn) "
|
||||
"— giống cách Claude Desktop hay các MCP client khác kết nối tới nó."},
|
||||
|
||||
# ---- settings_dialog.py / ext_connector_dialog.py: External Connectors (CAD/CAE/Office) ----
|
||||
"settings.group.ext": {
|
||||
"en": "Connectors (MCP)",
|
||||
"ja": "コネクタ(MCP)",
|
||||
"vi": "Connectors (MCP)"},
|
||||
"settings.ext_moved_hint": {
|
||||
"en": "Connector (MCP / REST-API) setup moved to Monitoring → Tools → Connector.",
|
||||
"ja": "コネクター(MCP / REST-API)の設定は「モニタリング → ツール → Connector」へ移動しました。",
|
||||
"vi": "Thiết lập Connector (MCP / REST-API) đã chuyển sang Monitoring → Công cụ → Connector."},
|
||||
"settings.ext_hint": {
|
||||
"en": ("One place for every external tool source — grouped as CAD (NX/CATIA/SolidWorks/"
|
||||
"AutoCAD), CAE (ANSA/ABAQUS/HyperWorks/ANSYS), MS365 (Microsoft 365/OneDrive/"
|
||||
"SharePoint) and Other (any generic MCP server). MS365 auto-connects via the built-in "
|
||||
"server once you sign in; for the rest, point each connector at an MCP server you "
|
||||
"already have or a REST API it exposes (no vendor SDK is bundled)."),
|
||||
"ja": "外部ツール接続を1か所に集約 — CAD(NX/CATIA/SolidWorks/AutoCAD)、CAE(ANSA/ABAQUS/"
|
||||
"HyperWorks/ANSYS)、MS365(Microsoft 365/OneDrive/SharePoint)、Other(汎用 MCP サーバー)。"
|
||||
"MS365 はサインインすると内蔵サーバーで自動接続。その他は既存の MCP サーバーまたは REST API を"
|
||||
"指定してください(ベンダー SDK は同梱しません)。",
|
||||
"vi": "Một nơi duy nhất cho mọi nguồn tool ngoài — nhóm theo CAD (NX/CATIA/SolidWorks/AutoCAD), "
|
||||
"CAE (ANSA/ABAQUS/HyperWorks/ANSYS), MS365 (Microsoft 365/OneDrive/SharePoint) và Other "
|
||||
"(MCP server bất kỳ). MS365 tự kết nối qua server tích hợp sau khi đăng nhập; còn lại bạn "
|
||||
"trỏ mỗi connector tới MCP server bạn đã có hoặc REST API nó cung cấp (không kèm SDK hãng nào)."},
|
||||
"settings.ext_add_btn": {"en": "Add connector…", "ja": "コネクタを追加…", "vi": "Thêm connector…"},
|
||||
"settings.ext_edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
|
||||
"settings.ext_delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"settings.ext_delete_confirm": {
|
||||
"en": "Remove connector \"{name}\"?", "ja": "コネクタ「{name}」を削除しますか?",
|
||||
"vi": "Xóa connector \"{name}\"?"},
|
||||
"ext.add_title": {"en": "Add connector", "ja": "コネクタを追加", "vi": "Thêm connector"},
|
||||
"ext.edit_title": {"en": "Edit connector", "ja": "コネクタを編集", "vi": "Sửa connector"},
|
||||
"ext.category_label": {"en": "Category", "ja": "カテゴリ", "vi": "Nhóm"},
|
||||
"ext.preset_label": {"en": "App", "ja": "アプリ", "vi": "Ứng dụng"},
|
||||
"ext.preset_custom": {"en": "(Custom…)", "ja": "(カスタム…)", "vi": "(Tuỳ chỉnh…)"},
|
||||
"ext.name_label": {"en": "Display name", "ja": "表示名", "vi": "Tên hiển thị"},
|
||||
"ext.name_placeholder": {"en": "e.g. NX (Site A)", "ja": "例: NX(サイトA)", "vi": "vd: NX (Site A)"},
|
||||
"ext.mode_label": {"en": "Connection type", "ja": "接続方式", "vi": "Kiểu kết nối"},
|
||||
"ext.mode_mcp": {"en": "MCP server (stdio)", "ja": "MCP サーバー(stdio)", "vi": "MCP server (stdio)"},
|
||||
"ext.mode_rest": {"en": "REST API", "ja": "REST API", "vi": "REST API"},
|
||||
"ext.mode_builtin": {"en": "built-in, auto-connect", "ja": "内蔵・自動接続", "vi": "tích hợp, tự kết nối"},
|
||||
"settings.ms365_signin_btn": {"en": "Sign in to Microsoft 365", "ja": "Microsoft 365 にサインイン",
|
||||
"vi": "Đăng nhập Microsoft 365"},
|
||||
"settings.ms365_signout_btn": {"en": "Sign out", "ja": "サインアウト", "vi": "Đăng xuất"},
|
||||
"settings.ms365_signed_in": {"en": "Microsoft 365: signed in as {who}",
|
||||
"ja": "Microsoft 365: {who} でサインイン中",
|
||||
"vi": "Microsoft 365: đã đăng nhập ({who})"},
|
||||
"settings.ms365_signed_out": {
|
||||
"en": "Microsoft 365: not signed in — one click, no Tenant/Client ID needed.",
|
||||
"ja": "Microsoft 365: 未サインイン — ワンクリック、テナント/クライアント ID 不要。",
|
||||
"vi": "Microsoft 365: chưa đăng nhập — 1 cú click, không cần Tenant/Client ID."},
|
||||
"settings.ms365_signing_in": {
|
||||
"en": "Microsoft 365: opening sign-in… follow the code prompt.",
|
||||
"ja": "Microsoft 365: サインインを開始中… コードの案内に従ってください。",
|
||||
"vi": "Microsoft 365: đang mở đăng nhập… làm theo hướng dẫn mã code."},
|
||||
"settings.ms365_code_hint": {
|
||||
"en": ("The sign-in page opened in your browser (<a href='{url}'>{url}</a>) and the code "
|
||||
"below was copied to your clipboard — just paste it, then sign in with your Microsoft "
|
||||
"account. This window closes automatically when sign-in completes."),
|
||||
"ja": ("ブラウザでサインインページ(<a href='{url}'>{url}</a>)を開きました。下のコードはクリップボードに"
|
||||
"コピー済みです — 貼り付けて Microsoft アカウントでサインインしてください。完了すると自動で閉じます。"),
|
||||
"vi": ("Trang đăng nhập đã mở trong trình duyệt (<a href='{url}'>{url}</a>) và mã bên dưới đã được "
|
||||
"copy vào clipboard — chỉ cần dán, rồi đăng nhập bằng tài khoản Microsoft. Cửa sổ này tự đóng "
|
||||
"khi đăng nhập xong.")},
|
||||
"settings.ms365_copy_code": {"en": "Copy code", "ja": "コードをコピー", "vi": "Copy mã"},
|
||||
"settings.ms365_open_link": {"en": "Open link", "ja": "リンクを開く", "vi": "Mở link"},
|
||||
"settings.ms365_local_connected": {
|
||||
"en": "OneDrive / SharePoint: auto-connected via local sync — no sign-in needed.\nSynced folder: {path}",
|
||||
"ja": "OneDrive / SharePoint: ローカル同期で自動接続 — サインイン不要。\n同期フォルダ: {path}",
|
||||
"vi": "OneDrive / SharePoint: tự động kết nối qua thư mục sync local — không cần đăng nhập.\nThư mục đã sync: {path}"},
|
||||
"settings.ms365_local_none": {
|
||||
"en": "OneDrive / SharePoint: no locally-synced OneDrive folder found. Install/sign in to "
|
||||
"the OneDrive desktop app and sync a folder, then reopen Settings.",
|
||||
"ja": "OneDrive / SharePoint: ローカル同期の OneDrive フォルダが見つかりません。OneDrive デスクトップ"
|
||||
"アプリでサインインしフォルダを同期してから、設定を開き直してください。",
|
||||
"vi": "OneDrive / SharePoint: chưa tìm thấy thư mục OneDrive sync trên máy. Cài/đăng nhập OneDrive "
|
||||
"desktop và sync một thư mục, rồi mở lại Settings."},
|
||||
"ext.command_label": {"en": "Command", "ja": "コマンド", "vi": "Lệnh"},
|
||||
"ext.command_placeholder": {"en": "e.g. python or npx", "ja": "例: python または npx", "vi": "vd: python hoặc npx"},
|
||||
"ext.args_label": {"en": "Arguments", "ja": "引数", "vi": "Tham số"},
|
||||
"ext.args_placeholder": {"en": "e.g. -m nx_mcp_server", "ja": "例: -m nx_mcp_server", "vi": "vd: -m nx_mcp_server"},
|
||||
"ext.base_url_label": {"en": "Base URL", "ja": "ベース URL", "vi": "Base URL"},
|
||||
"ext.base_url_placeholder": {
|
||||
"en": "e.g. https://cad-api.internal.company.com",
|
||||
"ja": "例: https://cad-api.internal.company.com",
|
||||
"vi": "vd: https://cad-api.internal.company.com"},
|
||||
"ext.api_key_label": {"en": "API key", "ja": "API キー", "vi": "API key"},
|
||||
"ext.auth_header_label": {"en": "Auth header name", "ja": "認証ヘッダー名", "vi": "Tên header xác thực"},
|
||||
"ext.auth_scheme_label": {"en": "Auth scheme", "ja": "認証スキーム", "vi": "Auth scheme"},
|
||||
"ext.test_btn": {"en": "Test connection", "ja": "接続テスト", "vi": "Kiểm tra kết nối"},
|
||||
"ext.err_no_command": {
|
||||
"en": "Enter a command first.", "ja": "先にコマンドを入力してください。", "vi": "Hãy nhập lệnh trước."},
|
||||
"ext.test_mcp_ok": {
|
||||
"en": "MCP server started and responded.", "ja": "MCP サーバーが起動し応答しました。",
|
||||
"vi": "MCP server đã khởi chạy và phản hồi."},
|
||||
|
||||
"settings.sec_enabled": {
|
||||
"en": "Enable AI-assisted agent security guardrails",
|
||||
"ja": "AI 支援のエージェント セキュリティ ガードレールを有効化",
|
||||
"vi": "Bật các lớp bảo mật agent có AI hỗ trợ"},
|
||||
"settings.sec_hint": {
|
||||
"en": "Three independent layers: an AI reviews the user's request and "
|
||||
"attachment content against the rules below before the agent "
|
||||
"acts, and a whitelist + AI control-agent checks every "
|
||||
"run_command/install_package call. A violation always blocks "
|
||||
"the action and emails the admin below. Each AI check fails "
|
||||
"OPEN (allows) if the model itself can't be reached — a gateway "
|
||||
"hiccup must never make the agent unusable.",
|
||||
"ja": "3つの独立した層があります:エージェントが行動する前に、AI がユーザーの"
|
||||
"リクエストと添付ファイルの内容を下記のルールと照合してチェックし、"
|
||||
"ホワイトリストと AI コントロールエージェントがすべての "
|
||||
"run_command/install_package 呼び出しをチェックします。違反時は常に"
|
||||
"操作をブロックし、下記の管理者にメールで通知します。各 AI チェックは"
|
||||
"モデルに到達できない場合は「許可」側に倒れます(フェイルオープン)— "
|
||||
"ゲートウェイの一時的な不調でエージェントが使えなくなることがあっては"
|
||||
"なりません。",
|
||||
"vi": "Ba lớp độc lập: AI kiểm tra yêu cầu của người dùng và nội dung file "
|
||||
"đính kèm theo các rule bên dưới TRƯỚC khi agent hành động, và một "
|
||||
"whitelist + AI control-agent kiểm tra mọi lệnh run_command/"
|
||||
"install_package. Vi phạm sẽ luôn CHẶN hành động và gửi email cho "
|
||||
"admin bên dưới. Mỗi lớp kiểm tra bằng AI sẽ MẶC ĐỊNH CHO PHÉP nếu "
|
||||
"không gọi được model — một sự cố gateway tạm thời không được phép "
|
||||
"làm agent ngừng hoạt động."},
|
||||
"settings.sec_validate_prompt": {
|
||||
"en": "Validate the user's request (prompt) before acting",
|
||||
"ja": "行動する前にユーザーのリクエスト(プロンプト)を検証",
|
||||
"vi": "Validate yêu cầu (prompt) của người dùng trước khi hành động"},
|
||||
"settings.sec_validate_attachments": {
|
||||
"en": "Scan attachment/file content for malicious payloads",
|
||||
"ja": "添付/ファイルの内容に悪意あるペイロードがないかスキャン",
|
||||
"vi": "Scan nội dung file đính kèm để phát hiện nội dung độc hại"},
|
||||
"settings.sec_validate_commands": {
|
||||
"en": "Check run_command / install_package against a whitelist",
|
||||
"ja": "run_command / install_package をホワイトリストと照合",
|
||||
"vi": "Kiểm tra run_command / install_package theo whitelist"},
|
||||
"settings.sec_command_ai_check": {
|
||||
"en": "Also let an AI control-agent judge commands not covered by the whitelist",
|
||||
"ja": "ホワイトリストに含まれないコマンドは AI コントロールエージェントにも判定させる",
|
||||
"vi": "Cho AI control-agent xét thêm các lệnh whitelist chưa liệt kê"},
|
||||
"settings.sec_whitelist_label": {"en": "Command whitelist", "ja": "コマンド ホワイトリスト", "vi": "Whitelist lệnh"},
|
||||
"settings.sec_whitelist_placeholder": {
|
||||
"en": "One regex pattern per line, e.g. ^pip install\\n^pytest\\n^git ",
|
||||
"ja": "1行に1つの正規表現、例: ^pip install\\n^pytest\\n^git ",
|
||||
"vi": "Mỗi dòng 1 regex, vd: ^pip install\\n^pytest\\n^git "},
|
||||
"settings.sec_whitelist_empty_warning": {
|
||||
"en": "Empty whitelist + AI check off = every command is BLOCKED "
|
||||
"(fail-closed) — add a pattern above or turn AI check back on.",
|
||||
"ja": "ホワイトリストが空でAIチェックも無効の場合、すべてのコマンドが"
|
||||
"ブロックされます(フェイルクローズ)。上にパターンを追加するか"
|
||||
"AIチェックを再度有効にしてください。",
|
||||
"vi": "Whitelist trống + tắt AI-check = MỌI lệnh sẽ bị CHẶN hết "
|
||||
"(fail-closed) — hãy thêm pattern ở trên hoặc bật lại AI-check."},
|
||||
"settings.sec_onedrive_label": {"en": "OneDrive rules link", "ja": "OneDrive ルールへのリンク", "vi": "Link OneDrive chứa rule"},
|
||||
"settings.sec_onedrive_placeholder": {
|
||||
"en": "(optional) sharing link to an admin-authored .md rules document",
|
||||
"ja": "(任意)管理者が作成した .md ルール文書への共有リンク",
|
||||
"vi": "(tuỳ chọn) link chia sẻ tới file .md rule do admin soạn"},
|
||||
"settings.sec_admin_email_label": {"en": "Admin email", "ja": "管理者メール", "vi": "Email admin"},
|
||||
"settings.sec_admin_email_placeholder": {
|
||||
"en": "admin@yourcompany.com — receives violation alerts via Microsoft 365",
|
||||
"ja": "admin@yourcompany.com — Microsoft 365 経由で違反アラートを受信",
|
||||
"vi": "admin@yourcompany.com — nhận cảnh báo vi phạm qua Microsoft 365"},
|
||||
"settings.sec_rules_path_hint": {
|
||||
"en": "Local admin rules file (optional, edited directly, always applied): {path}",
|
||||
"ja": "ローカルの管理者ルールファイル(任意・直接編集・常に適用): {path}",
|
||||
"vi": "File rule admin cục bộ (tuỳ chọn, sửa trực tiếp, luôn được áp dụng): {path}"},
|
||||
"settings.group.history": {"en": "Conversation history", "ja": "会話履歴", "vi": "Lịch sử hội thoại"},
|
||||
"settings.history_local": {"en": "Local (this PC)", "ja": "ローカル(このPC)", "vi": "Local (máy này)"},
|
||||
"settings.history_onedrive": {"en": "OneDrive", "ja": "OneDrive", "vi": "OneDrive"},
|
||||
"settings.location": {"en": "Location", "ja": "保存先", "vi": "Nơi lưu"},
|
||||
"settings.folder": {"en": "Folder", "ja": "フォルダ", "vi": "Thư mục"},
|
||||
"settings.folder_placeholder": {
|
||||
"en": "(optional) specific folder — leave empty for default",
|
||||
"ja": "(任意)特定のフォルダ ― 空欄で既定値",
|
||||
"vi": "(tuỳ chọn) thư mục cụ thể — để trống dùng mặc định"},
|
||||
"settings.browse": {"en": "Browse…", "ja": "参照…", "vi": "Duyệt…"},
|
||||
"settings.autosave": {"en": "Auto-save history after each turn", "ja": "各ターン後に履歴を自動保存", "vi": "Tự động lưu lịch sử sau mỗi lượt"},
|
||||
"settings.group.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"settings.max_parallel": {"en": "Max parallel conversations", "ja": "同時実行する会話数の上限", "vi": "Số hội thoại chạy song song tối đa"},
|
||||
"settings.parallel_suffix": {"en": " conversations at once", "ja": " 件を同時実行", "vi": " hội thoại cùng lúc"},
|
||||
"settings.parallel_tooltip": {
|
||||
"en": ("How many conversations run in parallel. Within one conversation messages always "
|
||||
"run one at a time (queued); only different conversations run in parallel."),
|
||||
"ja": ("並列実行する会話数です。1つの会話内のメッセージは常に1件ずつ(キュー)実行され、"
|
||||
"異なる会話同士のみ並列に実行されます。"),
|
||||
"vi": ("Số cuộc trò chuyện chạy song song. Trong MỘT cuộc trò chuyện, tin nhắn luôn "
|
||||
"chạy lần lượt (xếp hàng) để không bị trộn lẫn; chỉ các cuộc trò chuyện khác "
|
||||
"nhau mới chạy song song.")},
|
||||
"settings.group.attachments": {"en": "Attachments", "ja": "添付ファイル", "vi": "Tệp đính kèm"},
|
||||
"settings.max_files": {"en": "Max files", "ja": "最大ファイル数", "vi": "Số tệp tối đa"},
|
||||
"settings.max_files_suffix": {"en": " files / message", "ja": " 件 / メッセージ", "vi": " tệp / tin nhắn"},
|
||||
"settings.max_files_tooltip": {
|
||||
"en": "Maximum number of files attachable to one message.",
|
||||
"ja": "1メッセージに添付できるファイル数の上限。",
|
||||
"vi": "Số tệp tối đa đính kèm vào một tin nhắn."},
|
||||
"settings.max_per_file": {"en": "Max per file", "ja": "ファイルあたりの上限", "vi": "Giới hạn mỗi tệp"},
|
||||
"settings.max_per_file_suffix": {"en": " K tokens / file", "ja": " Kトークン / ファイル", "vi": " K tokens / tệp"},
|
||||
"settings.max_per_file_tooltip": {
|
||||
"en": ("Limits how much of each attached file's content is added to the prompt; anything "
|
||||
"beyond this is truncated (fewer tokens, avoids exceeding the context limit)."),
|
||||
"ja": "各添付ファイルの内容をプロンプトに含める量の上限。超過分は切り捨てられます(トークン削減、コンテキスト超過回避)。",
|
||||
"vi": ("Giới hạn nội dung mỗi tệp đính kèm đưa vào prompt; phần vượt sẽ bị cắt "
|
||||
"(giảm token, tránh lỗi vượt context).")},
|
||||
"settings.group.structure": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
|
||||
"settings.group.sandbox_limits": {"en": "Sandbox resource limits", "ja": "サンドボックスのリソース上限",
|
||||
"vi": "Giới hạn tài nguyên Sandbox"},
|
||||
"settings.max_nodes": {"en": "Max nodes", "ja": "最大ノード数", "vi": "Số node tối đa"},
|
||||
"settings.unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
|
||||
"settings.nodes_suffix": {"en": " nodes", "ja": " ノード", "vi": " node"},
|
||||
"settings.nodes_tooltip": {
|
||||
"en": ("Cap the number of nodes in the Structure graph (0 = unlimited). "
|
||||
"A lower cap speeds up scanning/layout for large folders."),
|
||||
"ja": "構造グラフのノード数上限(0=無制限)。大きなフォルダでは低い値の方が高速です。",
|
||||
"vi": "Giới hạn số node trong đồ thị Cấu trúc (0 = không giới hạn). Giá trị thấp hơn giúp quét/vẽ nhanh hơn với thư mục lớn."},
|
||||
"settings.max_edges": {"en": "Max edges", "ja": "最大エッジ数", "vi": "Số cạnh tối đa"},
|
||||
"settings.edges_suffix": {"en": " edges", "ja": " エッジ", "vi": " cạnh"},
|
||||
"settings.edges_tooltip": {
|
||||
"en": "Cap the number of edges in the Structure graph (0 = unlimited).",
|
||||
"ja": "構造グラフのエッジ数上限(0=無制限)。",
|
||||
"vi": "Giới hạn số cạnh trong đồ thị Cấu trúc (0 = không giới hạn)."},
|
||||
"settings.tip": {
|
||||
"en": "Tip: set your Internal Gateway URL + API key above, then pick a model.",
|
||||
"ja": "ヒント: 上で社内ゲートウェイの URL と API キーを設定してからモデルを選んでください。",
|
||||
"vi": "Mẹo: điền URL Gateway nội bộ + API key ở trên, rồi chọn model."},
|
||||
"settings.loading_models": {"en": "Loading models…", "ja": "モデルを読み込み中…", "vi": "Đang tải danh sách model…"},
|
||||
"settings.loaded_models": {
|
||||
"en": "Loaded {n} model(s) for {provider}.", "ja": "{provider} のモデルを {n} 件読み込みました。",
|
||||
"vi": "Đã tải {n} model cho {provider}."},
|
||||
"settings.load_failed": {"en": "Load failed: {err}", "ja": "読み込み失敗: {err}", "vi": "Tải thất bại: {err}"},
|
||||
"settings.load_models_error": {
|
||||
"en": "No models loaded — {err}", "ja": "モデルを読み込めませんでした — {err}",
|
||||
"vi": "Không tải được model nào — {err}"},
|
||||
"settings.load_models_error_unknown": {
|
||||
"en": "unknown error (check base URL / API key / network).",
|
||||
"ja": "不明なエラー(URL・APIキー・ネットワークを確認)。",
|
||||
"vi": "lỗi không xác định (kiểm tra base URL / API key / kết nối mạng)."},
|
||||
"settings.test_connection": {"en": "Test connection", "ja": "接続テスト", "vi": "Test kết nối"},
|
||||
"settings.test_connection_tooltip": {
|
||||
"en": "Check connectivity to this provider right now and show the real reason if it fails.",
|
||||
"ja": "このプロバイダーへの接続を今すぐ確認し、失敗した場合は本当の理由を表示します。",
|
||||
"vi": "Kiểm tra kết nối tới provider này ngay và hiện lý do thật nếu thất bại."},
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
"""Chuỗi hiển thị — phần sidebar.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"terminal.exit": {
|
||||
"en": "[process exited with code {code}]", "ja": "[プロセス終了 コード {code}]",
|
||||
"vi": "[tiến trình kết thúc, mã {code}]"},
|
||||
"folder.save_error": {
|
||||
"en": "Save failed: {err}", "ja": "保存に失敗しました: {err}", "vi": "Lưu thất bại: {err}"},
|
||||
|
||||
"workspace.hint": {
|
||||
"en": ("Group chats into projects. Every thread in a project follows the shared "
|
||||
"Instructions, works inside the project's own sandbox folder, and auto-reads "
|
||||
"files placed at that folder's root (project knowledge)."),
|
||||
"ja": ("チャットをプロジェクトにまとめます。プロジェクト内の各スレッドは共有の指示に従い、"
|
||||
"プロジェクト専用のサンドボックスフォルダ内で動作し、そのルートに置かれたファイル"
|
||||
"(プロジェクトナレッジ)を自動的に読み込みます。"),
|
||||
"vi": ("Gom các cuộc chat thành project. Mọi thread trong một project tuân theo phần "
|
||||
"Instructions chung, làm việc trong thư mục sandbox riêng của project, và tự đọc "
|
||||
"các file đặt ở gốc thư mục đó (project knowledge)."),
|
||||
},
|
||||
"workspace.new_project": {"en": "New project", "ja": "新規プロジェクト", "vi": "Project mới"},
|
||||
"workspace.projects_heading": {"en": "PROJECTS", "ja": "プロジェクト", "vi": "PROJECT"},
|
||||
"workspace.folder_label": {"en": "Workspace folder", "ja": "作業フォルダ", "vi": "Thư mục làm việc"},
|
||||
"workspace.counts": {
|
||||
"en": "{chats} chats · {tasks} tasks",
|
||||
"ja": "チャット {chats} · タスク {tasks}",
|
||||
"vi": "{chats} đoạn chat · {tasks} task"},
|
||||
"workspace.delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"workspace.delete_confirm": {
|
||||
"en": "Delete project “{name}”? Its conversations and files are kept (threads move to General).",
|
||||
"ja": "プロジェクト「{name}」を削除しますか?会話とファイルは保持されます(スレッドは General へ移動)。",
|
||||
"vi": "Xóa project “{name}”? Hội thoại và file vẫn được giữ (thread chuyển về General).",
|
||||
},
|
||||
"workspace.deleted": {"en": "Deleted project {name}.", "ja": "プロジェクト {name} を削除しました。", "vi": "Đã xóa project {name}."},
|
||||
"workspace.conversation_project_missing": {
|
||||
"en": "This conversation's project no longer exists — it can't be opened.",
|
||||
"ja": "この会話のプロジェクトは既に存在しないため開けません。",
|
||||
"vi": "Project của hội thoại này không còn tồn tại — không thể mở."},
|
||||
"workspace.name": {"en": "Name", "ja": "名前", "vi": "Tên"},
|
||||
"workspace.description": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
|
||||
"workspace.instructions": {"en": "Instructions", "ja": "Instructions", "vi": "Instructions"},
|
||||
"workspace.instructions_placeholder": {
|
||||
"en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"",
|
||||
"ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」",
|
||||
"vi": "vd: \"Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; luôn theo quy tắc đặt tên …\"",
|
||||
},
|
||||
"workspace.browse": {"en": "Change", "ja": "変更", "vi": "Đổi"},
|
||||
"workspace.browse_tooltip": {
|
||||
"en": "Choose the project's workspace folder (agent sandbox + shared knowledge root)",
|
||||
"ja": "プロジェクトのワークスペースフォルダを選択(エージェントのサンドボックス+共有ナレッジのルート)",
|
||||
"vi": "Chọn thư mục workspace của project (sandbox của agent + gốc chứa knowledge chung)",
|
||||
},
|
||||
"workspace.open_folder": {"en": "Open", "ja": "開く", "vi": "Mở"},
|
||||
"workspace.save": {"en": "Save project", "ja": "プロジェクトを保存", "vi": "Lưu project"},
|
||||
"workspace.saved": {"en": "Saved project {name}.", "ja": "プロジェクト {name} を保存しました。", "vi": "Đã lưu project {name}."},
|
||||
"workspace.threads": {"en": "Conversations in this project", "ja": "このプロジェクトの会話", "vi": "Hội thoại trong project này"},
|
||||
"workspace.new_chat": {"en": "New chat in this project", "ja": "このプロジェクトで新規チャット", "vi": "Chat mới trong project này"},
|
||||
"workspace.default_new_name": {"en": "New project", "ja": "新規プロジェクト", "vi": "Project mới"},
|
||||
"workspace.collapse_projects_tooltip": {"en": "Collapse the project list", "ja": "プロジェクト一覧を折りたたむ", "vi": "Thu gọn danh sách project"},
|
||||
"workspace.expand_projects_tooltip": {"en": "Click to expand the project list", "ja": "クリックしてプロジェクト一覧を展開", "vi": "Bấm để mở rộng danh sách project"},
|
||||
"app.status.ready": {"en": "Ready.", "ja": "準備完了。", "vi": "Sẵn sàng."},
|
||||
"app.status.using_provider": {"en": "Using {label}.", "ja": "{label} を使用中。", "vi": "Đang dùng {label}."},
|
||||
"app.status.settings_saved": {"en": "Settings saved.", "ja": "設定を保存しました。", "vi": "Đã lưu cài đặt."},
|
||||
"app.credit": {"en": "Made by QuanDH14", "ja": "Made by QuanDH14", "vi": "Made by QuanDH14"},
|
||||
"app.tray.open": {"en": "Open Cowork Local", "ja": "Cowork Local を開く", "vi": "Mở Cowork Local"},
|
||||
"app.tray.quit": {"en": "Quit", "ja": "終了", "vi": "Thoát"},
|
||||
"app.tray.running_body": {
|
||||
"en": "Running in the background — tasks keep working. Right-click the tray icon to Quit.",
|
||||
"ja": "バックグラウンドで実行中です。タスクは継続します。終了するにはトレイアイコンを右クリックしてください。",
|
||||
"vi": "Đang chạy nền — tác vụ vẫn tiếp tục. Chuột phải vào biểu tượng khay để Thoát.",
|
||||
},
|
||||
"app.toast.done": {"en": "{name}: done", "ja": "{name}: 完了", "vi": "{name}: hoàn thành"},
|
||||
"app.toast.error": {"en": "{name}: error", "ja": "{name}: エラー", "vi": "{name}: lỗi"},
|
||||
"app.toast.task_done": {"en": "Task done: {title}", "ja": "タスク完了: {title}",
|
||||
"vi": "Task hoàn thành: {title}"},
|
||||
"app.toast.task_failed": {"en": "Task failed: {title}", "ja": "タスク失敗: {title}",
|
||||
"vi": "Task lỗi: {title}"},
|
||||
|
||||
# ---- sidebar.py (History) ----------------------------------------
|
||||
"sidebar.header": {"en": "History", "ja": "履歴", "vi": "Lịch sử"},
|
||||
"sidebar.filter.all": {"en": "All", "ja": "すべて", "vi": "Tất cả"},
|
||||
"sidebar.filter.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"sidebar.filter.code": {"en": "Code", "ja": "Code", "vi": "Code"},
|
||||
"sidebar.search_placeholder": {
|
||||
"en": "Search by title or content…", "ja": "タイトルまたは内容で検索…",
|
||||
"vi": "Tìm theo tiêu đề hoặc nội dung…"},
|
||||
"sidebar.search_tooltip": {
|
||||
"en": "Search conversation history by title or message content.",
|
||||
"ja": "会話履歴をタイトルまたはメッセージ内容で検索します。",
|
||||
"vi": "Tìm kiếm lịch sử hội thoại theo tiêu đề hoặc nội dung tin nhắn."},
|
||||
"sidebar.no_matches": {"en": "(no matches)", "ja": "(一致なし)", "vi": "(không tìm thấy)"},
|
||||
"sidebar.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
|
||||
"sidebar.refresh_tooltip": {
|
||||
"en": "Update the list + this conversation's agent status",
|
||||
"ja": "一覧とこの会話のエージェント状態を更新",
|
||||
"vi": "Cập nhật danh sách + trạng thái agent của hội thoại đang xem",
|
||||
},
|
||||
"sidebar.empty": {"en": "(empty)", "ja": "(空)", "vi": "(trống)"},
|
||||
"sidebar.running_suffix": {"en": " · running", "ja": " · 実行中", "vi": " · đang chạy"},
|
||||
"sidebar.expand_tooltip": {
|
||||
"en": "Click to expand the History panel", "ja": "クリックして履歴パネルを展開",
|
||||
"vi": "Bấm để mở lại bảng Lịch sử"},
|
||||
"sidebar.collapse_tooltip": {
|
||||
"en": "Collapse the History panel", "ja": "履歴パネルを折りたたむ",
|
||||
"vi": "Thu gọn bảng Lịch sử"},
|
||||
"sidebar.menu.pin": {"en": "Pin", "ja": "ピン留め", "vi": "Ghim"},
|
||||
"sidebar.menu.unpin": {"en": "Unpin", "ja": "ピン留め解除", "vi": "Bỏ ghim"},
|
||||
"sidebar.menu.rename": {"en": "Rename…", "ja": "名前を変更…", "vi": "Đổi tên…"},
|
||||
"sidebar.menu.delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"sidebar.rename.title": {"en": "Rename conversation", "ja": "会話の名前を変更", "vi": "Đổi tên hội thoại"},
|
||||
"sidebar.rename.label": {"en": "New name:", "ja": "新しい名前:", "vi": "Tên mới:"},
|
||||
"sidebar.delete.title": {"en": "Delete conversation", "ja": "会話を削除", "vi": "Xóa hội thoại"},
|
||||
"sidebar.delete.confirm": {"en": "Delete '{title}'?", "ja": "「{title}」を削除しますか?", "vi": "Xóa '{title}'?"},
|
||||
"sidebar.menu.delete_selected": {"en": "Delete {n} selected", "ja": "選択した{n}件を削除", "vi": "Xóa {n} mục đã chọn"},
|
||||
"sidebar.delete_multi.confirm": {
|
||||
"en": "Delete {n} selected conversations? This cannot be undone.",
|
||||
"ja": "選択した{n}件の会話を削除しますか?元に戻せません。",
|
||||
"vi": "Xóa {n} hội thoại đã chọn? Không thể hoàn tác."},
|
||||
|
||||
# ---- widgets.py (Plan / Files sections, collapse strips) ---------
|
||||
"widgets.plan_title": {"en": "Plan", "ja": "プラン", "vi": "Plan"},
|
||||
"widgets.input_files": {"en": "Input files", "ja": "入力ファイル", "vi": "Tệp đầu vào"},
|
||||
"widgets.output_files": {"en": "Output files", "ja": "出力ファイル", "vi": "Tệp đầu ra"},
|
||||
|
||||
# ---- chat_view.py --------------------------------------------------
|
||||
"chat.running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"},
|
||||
"chat.thinking": {"en": "Thinking", "ja": "思考中", "vi": "Đang nghĩ"},
|
||||
"chat.creating": {"en": "Creating", "ja": "作成中", "vi": "Đang tạo"},
|
||||
"chat.editing": {"en": "Editing", "ja": "編集中", "vi": "Đang sửa"},
|
||||
"chat.installing": {"en": "Installing", "ja": "インストール中", "vi": "Đang cài đặt"},
|
||||
"chat.reading": {"en": "Reading", "ja": "読み込み中", "vi": "Đang đọc"},
|
||||
"chat.you": {"en": "You", "ja": "あなた", "vi": "Bạn"},
|
||||
"chat.assistant": {"en": "Assistant", "ja": "アシスタント", "vi": "Assistant"},
|
||||
"chat.error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
|
||||
"help_agent.title": {
|
||||
# The audit page names this AI Assistant, and keeps it the same in every
|
||||
# language — it is a product name, not a description.
|
||||
"en": "AI Assistant", "ja": "AI Assistant", "vi": "AI Assistant"},
|
||||
"help_agent.greeting": {
|
||||
"en": "Hello {name}, have a great working day! How can I help you use the app?",
|
||||
"ja": "こんにちは {name} さん、良い一日を!アプリの使い方について何かお手伝いできますか?",
|
||||
"vi": "Xin chào {name}, chúc bạn một ngày làm việc vui vẻ! Mình có thể giúp gì cho bạn khi dùng app?"},
|
||||
"help_agent.default_user": {"en": "Admin", "ja": "Admin", "vi": "Admin"},
|
||||
"help_agent.placeholder": {
|
||||
"en": "Ask how to use the app…", "ja": "アプリの使い方を質問…",
|
||||
"vi": "Hỏi cách sử dụng app…"},
|
||||
"help_agent.open_tooltip": {
|
||||
"en": "AI Assistant — help using the app",
|
||||
"ja": "AI Assistant — アプリの使い方をサポート",
|
||||
"vi": "AI Assistant — hỗ trợ sử dụng app"},
|
||||
"help_agent.collapse_tooltip": {
|
||||
"en": "Minimize", "ja": "最小化", "vi": "Thu nhỏ"},
|
||||
"help_agent.hide_tooltip": {
|
||||
"en": "Hide to the edge", "ja": "端に隠す", "vi": "Ẩn vào cạnh phải"},
|
||||
"help_agent.dot_hint": {
|
||||
"en": "right-click to hide",
|
||||
"ja": "右クリックで非表示",
|
||||
"vi": "chuột phải để ẩn"},
|
||||
# The name on the launcher pill. Deliberately the same in every language —
|
||||
# it is a product name, and it only shows on hover, so length is not a
|
||||
# constraint the way it was on a permanently visible badge.
|
||||
"help_agent.badge": {
|
||||
"en": "AI Assistant", "ja": "AI Assistant", "vi": "AI Assistant"},
|
||||
"help_agent.more_tooltip": {
|
||||
"en": "More", "ja": "その他", "vi": "Thêm"},
|
||||
"help_agent.show_tooltip": {
|
||||
"en": "Show the AI Assistant", "ja": "AI Assistant を表示",
|
||||
"vi": "Hiện AI Assistant"},
|
||||
"help_agent.empty_reply": {
|
||||
"en": "(no answer)", "ja": "(回答なし)", "vi": "(không có phản hồi)"},
|
||||
"help_agent.error": {
|
||||
"en": "Sorry, I couldn't answer right now: {error}",
|
||||
"ja": "申し訳ありません、今は回答できませんでした: {error}",
|
||||
"vi": "Xin lỗi, hiện chưa thể trả lời: {error}"},
|
||||
"chat.model_switched": {
|
||||
"en": "↻ Auto-switched to {model} — re-checking the previous step, then continuing.",
|
||||
"ja": "↻ {model} に自動切り替え — 直前のステップを確認してから続行します。",
|
||||
"vi": "↻ Đã tự động chuyển sang {model} — kiểm tra lại bước trước rồi tiếp tục."},
|
||||
"chat.provider_default_short": {
|
||||
"en": "the provider's default model", "ja": "プロバイダー既定のモデル",
|
||||
"vi": "model mặc định của provider"},
|
||||
"chat.delete_link": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"chat.delete_tooltip": {
|
||||
"en": "Delete this message and its input/output files",
|
||||
"ja": "このメッセージと入出力ファイルを削除",
|
||||
"vi": "Xóa tin nhắn này và các tệp input/output của nó"},
|
||||
"chat.open_workspace": {"en": "Open workspace", "ja": "作業フォルダを開く", "vi": "Mở thư mục làm việc"},
|
||||
"chat.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
|
||||
"chat.open_output_folder": {"en": "Open output folder", "ja": "出力フォルダを開く", "vi": "Mở thư mục output"},
|
||||
"chat.done_marker": {"en": "Done", "ja": "完了しました", "vi": "Đã hoàn thành"},
|
||||
"chat.session_folder_marker": {
|
||||
"en": "This conversation's output folder", "ja": "この会話の出力フォルダ",
|
||||
"vi": "Thư mục output của hội thoại này"},
|
||||
"chat.open_folder_short": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"},
|
||||
"chat.diff_before": {"en": "Before", "ja": "編集前", "vi": "Trước khi sửa"},
|
||||
"chat.diff_after": {"en": "After", "ja": "編集後", "vi": "Sau khi sửa"},
|
||||
"chat.diff_added": {"en": "Added", "ja": "追加", "vi": "Thêm mới"},
|
||||
"chat.diff_removed": {"en": "Removed", "ja": "削除", "vi": "Đã xóa"},
|
||||
"chat.attachment_warning_title": {
|
||||
"en": "Attachment", "ja": "添付ファイル", "vi": "Tệp đính kèm"},
|
||||
"chat.attachment_failed": {
|
||||
"en": "Could not read \"{name}\": {note}",
|
||||
"ja": "「{name}」を読み込めませんでした: {note}",
|
||||
"vi": "Không đọc được nội dung \"{name}\": {note}"},
|
||||
"chat.reading_progress": {
|
||||
"en": "Reading {name} — page {page}/{total}…",
|
||||
"ja": "{name} を読み込み中 — {page}/{total} ページ…",
|
||||
"vi": "Đang đọc {name} — trang {page}/{total}…"},
|
||||
"chat.workspace_files_capped": {
|
||||
"en": "Folder has more files than the per-message limit — loaded {shown}/{total} (raise it in Settings → Attachments)",
|
||||
"ja": "フォルダ内のファイル数が1メッセージあたりの上限を超えています — {shown}/{total} 件を読み込みました( 設定 → 添付ファイルで変更可)",
|
||||
"vi": "Thư mục có nhiều file hơn giới hạn mỗi tin nhắn — đã đọc {shown}/{total} file (đổi trong Settings → Attachments)"},
|
||||
|
||||
# ---- chat_panel.py (shared by Cowork & Code) ----------------------
|
||||
"chatpanel.agent_label": {"en": "Agent:", "ja": "エージェント:", "vi": "Agent:"},
|
||||
# ---- Auto Model Assessment & Routing (core/routing/) -----------------
|
||||
"routing.toggle_label": {"en": "Routing:", "ja": "ルーティング:", "vi": "Định tuyến:"},
|
||||
"routing.autorun_label": {"en": "Auto-run", "ja": "自動実行", "vi": "Tự chạy"},
|
||||
"routing.autorun_tooltip": {
|
||||
"en": "Auto-approve commands in THIS workspace (no confirm dialog).\nUnchecked: ask before each command. Each workspace keeps its own setting.",
|
||||
"ja": "このワークスペースでコマンドを自動承認(確認なし)。\nオフ: 実行前に確認。ワークスペースごとに設定を保持します。",
|
||||
"vi": "Tự động duyệt lệnh trong workspace NÀY (không hỏi xác nhận).\nBỏ chọn: hỏi trước mỗi lệnh. Mỗi workspace giữ thiết lập riêng.",
|
||||
},
|
||||
"routing.mode_off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
|
||||
"routing.mode_auto": {"en": "Auto", "ja": "自動", "vi": "Tự động"},
|
||||
"routing.mode_manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
|
||||
# Fallback (R03-T03): resilience mode -- never switches for a better
|
||||
# score, only to rescue a selected model that cannot serve the turn.
|
||||
"routing.mode_fallback": {"en": "Fallback", "ja": "フォールバック", "vi": "Dự phòng"},
|
||||
"routing.toggle_tooltip": {
|
||||
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.\nFallback: keep the selected model, switch only if it is unavailable.",
|
||||
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。\nフォールバック: 選択モデルを維持し、利用できない場合のみ切替。",
|
||||
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.\nDự phòng: giữ model đã chọn, chỉ chuyển khi model đó không dùng được.",
|
||||
},
|
||||
"routing.confirm_title": {
|
||||
"en": "Switch model?", "ja": "モデルを切り替えますか?", "vi": "Chuyển model?",
|
||||
},
|
||||
"routing.confirm_body": {
|
||||
"en": "A better-fit model was found for this {task} task:\n\n{from_model} → {to_model}\n(fit gain +{gain})\n\n{reason}\n\nSwitch to it for this message?",
|
||||
"ja": "この {task} タスクにより適したモデルが見つかりました:\n\n{from_model} → {to_model}\n(適合度 +{gain})\n\n{reason}\n\nこのメッセージで切り替えますか?",
|
||||
"vi": "Đã tìm thấy model phù hợp hơn cho tác vụ {task} này:\n\n{from_model} → {to_model}\n(điểm phù hợp +{gain})\n\n{reason}\n\nChuyển sang model đó cho tin nhắn này?",
|
||||
},
|
||||
"routing.confirm_yes": {"en": "Switch", "ja": "切り替える", "vi": "Chuyển"},
|
||||
"routing.confirm_no": {"en": "Keep current", "ja": "現状維持", "vi": "Giữ nguyên"},
|
||||
"routing.confirm_countdown": {
|
||||
"en": "Keep current ({secs}s)", "ja": "現状維持 ({secs}秒)", "vi": "Giữ nguyên ({secs}s)",
|
||||
},
|
||||
"routing.switched_notice": {
|
||||
"en": "↪ Auto-routed to {model} ({task}, fit +{gain})",
|
||||
"ja": "↪ {model} へ自動ルーティング ({task}, 適合度 +{gain})",
|
||||
"vi": "↪ Đã tự chuyển sang {model} ({task}, phù hợp +{gain})",
|
||||
},
|
||||
"routing.reassessing": {
|
||||
"en": "Assessing models…", "ja": "モデルを評価中…", "vi": "Đang đánh giá model…",
|
||||
},
|
||||
"routing.reassess_done": {
|
||||
"en": "Model assessment complete: {count} model(s) scored.",
|
||||
"ja": "モデル評価完了: {count} 件を採点しました。",
|
||||
"vi": "Đánh giá model xong: đã chấm {count} model.",
|
||||
},
|
||||
# ---- Routing settings group (settings_dialog.py) ---------------------
|
||||
"routing.settings_group": {
|
||||
"en": "Auto Model Routing", "ja": "自動モデルルーティング", "vi": "Tự động định tuyến Model",
|
||||
},
|
||||
"routing.settings_mode": {"en": "Default mode", "ja": "既定モード", "vi": "Chế độ mặc định"},
|
||||
"routing.settings_policy": {"en": "Policy", "ja": "ポリシー", "vi": "Chính sách"},
|
||||
"routing.policy_quality": {"en": "Quality", "ja": "品質", "vi": "Chất lượng"},
|
||||
"routing.policy_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"},
|
||||
"routing.policy_latency": {"en": "Latency", "ja": "レイテンシ", "vi": "Độ trễ"},
|
||||
"routing.policy_balanced": {"en": "Balanced", "ja": "バランス", "vi": "Cân bằng"},
|
||||
"routing.settings_min_gain": {
|
||||
"en": "Min score gain to switch", "ja": "切替に必要な最小スコア差", "vi": "Chênh điểm tối thiểu để chuyển",
|
||||
},
|
||||
"routing.settings_timeout": {
|
||||
"en": "Confirm timeout (sec)", "ja": "確認タイムアウト (秒)", "vi": "Thời gian chờ xác nhận (giây)",
|
||||
},
|
||||
"routing.settings_interval": {
|
||||
"en": "Reassess every (hours, 0=off)", "ja": "再評価間隔 (時間, 0=無効)", "vi": "Đánh giá lại mỗi (giờ, 0=tắt)",
|
||||
},
|
||||
"routing.settings_concurrency": {
|
||||
"en": "Max probe calls per provider", "ja": "プロバイダーごとの最大プローブ数", "vi": "Số lần probe tối đa mỗi provider",
|
||||
},
|
||||
"routing.settings_judge": {
|
||||
"en": "Judge model (blank = auto)", "ja": "ジャッジモデル (空欄=自動)", "vi": "Model chấm điểm (trống = tự động)",
|
||||
},
|
||||
"routing.settings_reassess_now": {
|
||||
"en": "Reassess now", "ja": "今すぐ再評価", "vi": "Đánh giá lại ngay",
|
||||
},
|
||||
"routing.settings_hint": {
|
||||
"en": "The app benchmarks each model and routes chats to the best-fit one. Probing spends tokens, so it runs on a schedule / when you add a model / when you click Reassess.",
|
||||
"ja": "各モデルをベンチマークし、最適なモデルへチャットを振り分けます。プローブはトークンを消費するため、スケジュール・モデル追加時・「再評価」押下時のみ実行されます。",
|
||||
"vi": "Ứng dụng benchmark từng model và định tuyến chat tới model phù hợp nhất. Probe tốn token nên chỉ chạy theo lịch / khi thêm model / khi bấm Đánh giá lại.",
|
||||
},
|
||||
"chatpanel.menu_open": {"en": "Open", "ja": "開く", "vi": "Mở"},
|
||||
"chatpanel.menu_ai_edit": {"en": "View & AI edit", "ja": "表示 & AI編集", "vi": "Xem & sửa bằng AI"},
|
||||
# ---- file_edit_dialog.py (view file + AI edit) -----------------------
|
||||
"fileedit.title": {"en": "View & edit file", "ja": "ファイル表示・編集", "vi": "Xem & sửa file"},
|
||||
"fileedit.browse_tooltip": {"en": "Open another file…", "ja": "別のファイルを開く…",
|
||||
"vi": "Mở file khác…"},
|
||||
"fileedit.reload_tooltip": {"en": "Reload from disk", "ja": "ディスクから再読み込み",
|
||||
"vi": "Tải lại từ đĩa"},
|
||||
"fileedit.pick_hint": {"en": "Open a file to view or edit it.",
|
||||
"ja": "表示・編集するファイルを開いてください。",
|
||||
"vi": "Mở một file để xem hoặc chỉnh sửa."},
|
||||
"fileedit.instruction_placeholder": {
|
||||
"en": "Tell the AI how to edit this file (e.g. 'fix typos', 'translate to English')…",
|
||||
"ja": "このファイルの編集内容をAIに指示(例:「誤字修正」「英語に翻訳」)…",
|
||||
"vi": "Nói cho AI cách sửa file này (vd: 'sửa lỗi chính tả', 'dịch sang tiếng Anh')…"},
|
||||
"fileedit.ai_btn": {"en": "AI Edit", "ja": "AI編集", "vi": "Sửa bằng AI"},
|
||||
"fileedit.save_btn": {"en": "Save", "ja": "保存", "vi": "Lưu"},
|
||||
"fileedit.close_btn": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
|
||||
"fileedit.loaded_editable": {"en": "Text file — editable.", "ja": "テキストファイル — 編集可能。",
|
||||
"vi": "File văn bản — có thể sửa."},
|
||||
"fileedit.loaded_readonly": {
|
||||
"en": "Binary/large document — extracted text shown, read-only (view & ask only).",
|
||||
"ja": "バイナリ/大きい文書 — 抽出テキストを表示(閲覧のみ、編集不可)。",
|
||||
"vi": "Tài liệu nhị phân/lớn — hiển thị text trích xuất, chỉ đọc (chỉ xem & hỏi)."},
|
||||
"fileedit.not_found": {"en": "File not found: {path}", "ja": "ファイルが見つかりません: {path}",
|
||||
"vi": "Không tìm thấy file: {path}"},
|
||||
"fileedit.needs_instruction": {"en": "Enter an edit instruction first.",
|
||||
"ja": "先に編集指示を入力してください。",
|
||||
"vi": "Hãy nhập yêu cầu chỉnh sửa trước."},
|
||||
"fileedit.ai_working": {"en": "AI is editing…", "ja": "AIが編集中…", "vi": "AI đang chỉnh sửa…"},
|
||||
"fileedit.ai_done": {"en": "AI edit applied — review, then Save.",
|
||||
"ja": "AI編集を適用 — 確認して保存してください。",
|
||||
"vi": "Đã áp dụng chỉnh sửa của AI — xem lại rồi Lưu."},
|
||||
"fileedit.ai_empty": {"en": "The AI returned no content.", "ja": "AIが内容を返しませんでした。",
|
||||
"vi": "AI không trả về nội dung."},
|
||||
"fileedit.ai_failed": {"en": "AI edit failed: {err}", "ja": "AI編集に失敗: {err}",
|
||||
"vi": "Sửa bằng AI thất bại: {err}"},
|
||||
"fileedit.saved": {"en": "Saved {path} (original backed up as .bak).",
|
||||
"ja": "{path} を保存(元は .bak にバックアップ)。",
|
||||
"vi": "Đã lưu {path} (bản gốc sao lưu thành .bak)."},
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Chuỗi hiển thị — phần skills_dialog.
|
||||
|
||||
Cắt ra từ ``i18n.py``: một dict 3.000 dòng không mở nổi để sửa một chữ.
|
||||
Cắt theo mốc phân đoạn có sẵn, nên mỗi cụm vẫn là các màn đi liền nhau.
|
||||
``i18n.py`` gộp tất cả lại thành ``STRINGS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"settings.testing_connection": {"en": "Testing connection…", "ja": "接続を確認中…", "vi": "Đang kiểm tra kết nối…"},
|
||||
"settings.sending_test": {"en": "Sending test…", "ja": "テスト送信中…", "vi": "Đang gửi thử…"},
|
||||
"settings.test_failed": {"en": "Test failed: {err}", "ja": "テスト失敗: {err}", "vi": "Kiểm tra thất bại: {err}"},
|
||||
"settings.pick_hist_dir": {"en": "Choose history folder", "ja": "履歴フォルダを選択", "vi": "Chọn thư mục lưu lịch sử"},
|
||||
|
||||
# ---- skills_dialog.py -----------------------------------------------
|
||||
"skills.edit_title": {"en": "Edit skill", "ja": "スキルを編集", "vi": "Sửa skill"},
|
||||
"skills.add_title": {"en": "Add skill", "ja": "スキルを追加", "vi": "Thêm skill"},
|
||||
"skills.name_label": {"en": "Skill name", "ja": "スキル名", "vi": "Tên skill"},
|
||||
"skills.name_placeholder": {
|
||||
"en": "e.g. Always write unit tests", "ja": "例:常に単体テストを書く", "vi": "vd. Luôn viết unit test"},
|
||||
"skills.desc_label": {"en": "Short description (optional)", "ja": "簡単な説明(任意)", "vi": "Mô tả ngắn (tuỳ chọn)"},
|
||||
"skills.instructions_label": {"en": "Instructions for the agent", "ja": "エージェントへの指示", "vi": "Hướng dẫn cho agent"},
|
||||
"skills.gen_from_desc": {"en": "Generate from description", "ja": "説明文から生成", "vi": "Tạo từ mô tả"},
|
||||
"skills.gen_from_desc_tooltip": {
|
||||
"en": "Use the AI agent to draft the instructions from the short description",
|
||||
"ja": "AI エージェントで短い説明から指示文の下書きを生成します",
|
||||
"vi": "Dùng AI để soạn hướng dẫn từ mô tả ngắn"},
|
||||
"skills.instructions_placeholder": {
|
||||
"en": "Describe the rules / guidance the agent must follow…",
|
||||
"ja": "エージェントが従うべきルール/ガイドラインを記述…",
|
||||
"vi": "Mô tả các quy tắc/hướng dẫn mà agent phải tuân theo…"},
|
||||
"skills.generating": {"en": "Generating…", "ja": "生成中…", "vi": "Đang tạo…"},
|
||||
"skills.title": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
|
||||
"skills.hint": {
|
||||
"en": "Tick to enable a skill. Enabled skills are followed by the agent.",
|
||||
"ja": "チェックでスキルを有効化。有効なスキルはエージェントが従います。",
|
||||
"vi": "Tick để bật skill. Skill đang bật sẽ được agent tuân theo."},
|
||||
"skills.auto_generate": {"en": "Auto-generate", "ja": "自動生成", "vi": "Tự động tạo"},
|
||||
"skills.auto_generate_tooltip": {
|
||||
"en": ("Describe a skill in one line and let the AI draft the whole skill "
|
||||
"(name, description and instructions) for you to review."),
|
||||
"ja": "1行でスキルを説明すると、AI が名前・説明・指示文をまとめて下書きします。",
|
||||
"vi": "Mô tả skill trong 1 dòng, AI sẽ tự soạn cả skill (tên, mô tả, hướng dẫn) để bạn xem lại."},
|
||||
"skills.import_btn": {"en": "Import…", "ja": "インポート…", "vi": "Nhập…"},
|
||||
"skills.import_tooltip": {
|
||||
"en": "Import an external skill from a .skill, .json, .md or .txt file",
|
||||
"ja": ".skill / .json / .md / .txt ファイルから外部スキルをインポート",
|
||||
"vi": "Nhập skill từ file .skill, .json, .md hoặc .txt"},
|
||||
"skills.edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
|
||||
"skills.delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"skills.close_btn": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
|
||||
"skills.no_skills": {
|
||||
"en": "(No skills yet — click ' Auto-generate' or 'Import…')",
|
||||
"ja": "(スキルはまだありません。「 自動生成」または「インポート…」をクリック)",
|
||||
"vi": "(Chưa có skill nào — bấm ' Tự động tạo' hoặc 'Nhập…')"},
|
||||
"skills.auto_generate_title": {"en": "Auto-generate skill", "ja": "スキルを自動生成", "vi": "Tự động tạo skill"},
|
||||
"skills.auto_generate_unavailable": {
|
||||
"en": "AI generation isn't available right now.", "ja": "現在 AI 生成は利用できません。",
|
||||
"vi": "Tính năng tạo bằng AI hiện chưa dùng được."},
|
||||
"skills.auto_generate_prompt": {
|
||||
"en": "Describe the skill you want (what should the agent do?):",
|
||||
"ja": "欲しいスキルを説明してください(エージェントに何をさせたいか):",
|
||||
"vi": "Mô tả skill bạn muốn (agent nên làm gì?):"},
|
||||
"skills.auto_generate_failed": {
|
||||
"en": "Couldn't generate a skill. Check the AI provider in Settings, or add one manually.",
|
||||
"ja": "スキルを生成できませんでした。設定の AI プロバイダーを確認するか、手動で追加してください。",
|
||||
"vi": "Không tạo được skill. Kiểm tra lại provider AI trong Settings, hoặc tự thêm thủ công."},
|
||||
"skills.import_dialog_title": {"en": "Import skill", "ja": "スキルをインポート", "vi": "Nhập skill"},
|
||||
"skills.import_dialog_filter": {
|
||||
"en": "Skills (*.skill *.json *.md *.txt *.yaml *.yml *.zip);;All files (*.*)",
|
||||
"ja": "スキル (*.skill *.json *.md *.txt *.yaml *.yml *.zip);;すべてのファイル (*.*)",
|
||||
"vi": "Skill (*.skill *.json *.md *.txt *.yaml *.yml *.zip);;Tất cả file (*.*)"},
|
||||
"skills.import_failed": {"en": "Could not import: {err}", "ja": "インポートできませんでした: {err}", "vi": "Không nhập được: {err}"},
|
||||
"skills.export_btn": {"en": "Export .md", "ja": ".md エクスポート", "vi": "Xuất .md"},
|
||||
"skills.export_tooltip": {
|
||||
"en": "Export the selected skill to a Markdown (.md) file",
|
||||
"ja": "選択したスキルを Markdown (.md) ファイルに書き出します",
|
||||
"vi": "Xuất skill đang chọn ra file Markdown (.md)"},
|
||||
"skills.export_pick": {
|
||||
"en": "Select a skill in the list first, then click Export .md.",
|
||||
"ja": "先にリストでスキルを選択してから「.md エクスポート」を押してください。",
|
||||
"vi": "Hãy chọn một skill trong danh sách trước, rồi bấm Xuất .md."},
|
||||
"skills.export_dialog_title": {
|
||||
"en": "Export skill to Markdown", "ja": "スキルを Markdown に書き出す",
|
||||
"vi": "Xuất skill ra Markdown"},
|
||||
"skills.export_dialog_filter": {
|
||||
"en": "Markdown (*.md);;All files (*.*)", "ja": "Markdown (*.md);;すべてのファイル (*.*)",
|
||||
"vi": "Markdown (*.md);;Tất cả file (*.*)"},
|
||||
"skills.export_done": {
|
||||
"en": "Exported to {path}", "ja": "{path} に書き出しました", "vi": "Đã xuất ra {path}"},
|
||||
"skills.export_failed": {
|
||||
"en": "Could not export: {err}", "ja": "書き出せませんでした: {err}", "vi": "Không xuất được: {err}"},
|
||||
"skills.duplicate_btn": {"en": "Duplicate", "ja": "複製", "vi": "Nhân bản"},
|
||||
"skills.duplicate_tooltip": {
|
||||
"en": "Duplicate the selected skill (a copy you can rename and edit)",
|
||||
"ja": "選択したスキルを複製します(名前を変更・編集できるコピー)",
|
||||
"vi": "Nhân bản skill đang chọn (bản sao có thể đổi tên và chỉnh sửa)"},
|
||||
"skills.copy_name": {"en": "{name} (copy)", "ja": "{name}(コピー)", "vi": "{name} (bản sao)"},
|
||||
"skills.from_template": {"en": "From template file…", "ja": "テンプレートファイルから…", "vi": "Từ file template…"},
|
||||
"skills.from_template_tooltip": {
|
||||
"en": ("Analyze a .pptx/.xlsx template's layout, fonts, colors and formatting "
|
||||
"and draft a skill so future generated files match it."),
|
||||
"ja": ".pptx/.xlsx テンプレートのレイアウト・フォント・色・書式を解析し、"
|
||||
"今後生成するファイルがそれに合うようスキルを下書きします。",
|
||||
"vi": "Phân tích layout/font/màu/định dạng của file template .pptx/.xlsx, soạn skill để các file tạo sau khớp với nó."},
|
||||
"skills.from_template_title": {
|
||||
"en": "Generate skill from template", "ja": "テンプレートからスキルを生成",
|
||||
"vi": "Tạo skill từ template"},
|
||||
"skills.from_template_dialog_title": {
|
||||
"en": "Select a template file", "ja": "テンプレートファイルを選択",
|
||||
"vi": "Chọn file template"},
|
||||
"skills.from_template_dialog_filter": {
|
||||
"en": "PowerPoint/Excel (*.pptx *.xlsx *.xlsm);;All files (*.*)",
|
||||
"ja": "PowerPoint/Excel (*.pptx *.xlsx *.xlsm);;すべてのファイル (*.*)",
|
||||
"vi": "PowerPoint/Excel (*.pptx *.xlsx *.xlsm);;Tất cả file (*.*)"},
|
||||
"skills.from_template_failed": {
|
||||
"en": ("Couldn't analyze this template. Make sure it's a valid .pptx/.xlsx file "
|
||||
"and the AI provider in Settings works, or add the skill manually."),
|
||||
"ja": "このテンプレートを解析できませんでした。有効な .pptx/.xlsx ファイルか、"
|
||||
"設定の AI プロバイダーが動作しているか確認するか、手動でスキルを追加してください。",
|
||||
"vi": "Không phân tích được template này. Kiểm tra file .pptx/.xlsx hợp lệ và provider AI trong Settings hoạt động tốt, hoặc tự thêm skill thủ công."},
|
||||
|
||||
# ---- flow_dialog.py -----------------------------------------------
|
||||
"flow.title": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"},
|
||||
"flow.tab_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
|
||||
"flow.tab_agents": {"en": "Agents", "ja": "エージェント", "vi": "Agents"},
|
||||
"flow.tab_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
|
||||
"flow.template": {"en": "Template:", "ja": "テンプレート:", "vi": "Template:"},
|
||||
"flow.load_builtin": {"en": "Load Req→Demo template", "ja": "Req→Demo テンプレートを読込", "vi": "Tải template Req→Demo"},
|
||||
"flow.new": {"en": "New", "ja": "新規", "vi": "Mới"},
|
||||
"flow.delete_template": {"en": "Delete template", "ja": "テンプレートを削除", "vi": "Xóa template"},
|
||||
"flow.name_label": {"en": "Flow name", "ja": "フロー名", "vi": "Tên flow"},
|
||||
"flow.description_label": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
|
||||
"flow.stages": {"en": "Stages", "ja": "ステージ", "vi": "Các bước"},
|
||||
"flow.remove_stage": {"en": "Remove stage", "ja": "ステージを削除", "vi": "Xóa bước"},
|
||||
"flow.stage_name": {"en": "Stage name", "ja": "ステージ名", "vi": "Tên bước"},
|
||||
"flow.hint": {"en": "Hint", "ja": "ヒント", "vi": "Gợi ý"},
|
||||
"flow.task_prompt": {"en": "Task (prompt)", "ja": "タスク(プロンプト)", "vi": "Nhiệm vụ (prompt)"},
|
||||
"flow.skill": {"en": "Skill", "ja": "スキル", "vi": "Skill"},
|
||||
"flow.agent": {"en": "AI provider", "ja": "AI プロバイダー", "vi": "AI provider"},
|
||||
"flow.model_label": {"en": "Agent:", "ja": "Agent:", "vi": "Agent:"},
|
||||
"flow.default_model": {"en": "(provider default)", "ja": "(プロバイダー既定)", "vi": "(mặc định của provider)"},
|
||||
"flow.gen_task_from_hint": {"en": "Generate task from hint", "ja": "ヒントからタスクを生成", "vi": "Tạo task từ gợi ý"},
|
||||
"flow.gen_task_tooltip": {
|
||||
"en": "Use the AI agent to expand the hint into a task prompt",
|
||||
"ja": "AI エージェントでヒントをタスクプロンプトに展開します",
|
||||
"vi": "Dùng AI để mở rộng gợi ý thành task prompt"},
|
||||
"flow.attachments": {"en": "Attachments", "ja": "添付ファイル", "vi": "Đính kèm"},
|
||||
"flow.attach_files": {"en": "Attach files…", "ja": "ファイルを添付…", "vi": "Đính kèm file…"},
|
||||
"flow.attach_files_count": {
|
||||
"en": "{n} file(s) attached", "ja": "{n} 件添付済み", "vi": "Đã đính kèm {n} file"},
|
||||
"flow.compact_after_run": {
|
||||
"en": "Compact after run", "ja": "実行後に圧縮", "vi": "Compact after run (nén sau khi chạy)"},
|
||||
"flow.compact_after_run_tooltip": {
|
||||
"en": "Trim older history right after this stage, freeing up token space for the next one",
|
||||
"ja": "このステージの直後に古い履歴を切り詰め、次のステージ用にトークン余裕を確保します",
|
||||
"vi": "Rút gọn lịch sử cũ ngay sau bước này để nhường chỗ token cho bước tiếp theo"},
|
||||
"flow.self_verify": {
|
||||
"en": "Self-verify before handoff", "ja": "引き渡し前に自己検証", "vi": "Self-verify trước khi bàn giao"},
|
||||
"flow.self_verify_tooltip": {
|
||||
"en": "Ask the agent to confirm the stage is actually complete before moving on",
|
||||
"ja": "次に進む前に、このステージが本当に完了しているかエージェントに確認させます",
|
||||
"vi": "Yêu cầu agent tự xác nhận đã hoàn thành đầy đủ trước khi qua bước sau"},
|
||||
"flow.review_retries": {
|
||||
"en": "Review-completeness retries", "ja": "完全性レビューの再試行回数", "vi": "Số lần review lại nếu chưa xong"},
|
||||
"flow.review_retries_tooltip": {
|
||||
"en": "If the self-check says the stage is incomplete, re-run it up to this many times (0 = off)",
|
||||
"ja": "自己チェックで未完了と判定された場合、この回数まで再実行します(0 = 無効)",
|
||||
"vi": "Nếu tự kiểm tra thấy chưa hoàn thành, chạy lại bước này tối đa số lần này (0 = tắt)"},
|
||||
"flow.parallel_agents": {
|
||||
"en": "Parallel sub-agents", "ja": "並列サブエージェント", "vi": "Sub-agent chạy song song"},
|
||||
"flow.subagent_name_placeholder": {"en": "Name (e.g. backend)", "ja": "名前(例: backend)", "vi": "Tên (vd backend)"},
|
||||
"flow.subagent_task_placeholder": {
|
||||
"en": "Task for this sub-agent (optional — falls back to the stage task)",
|
||||
"ja": "このサブエージェントのタスク(任意 — 未入力ならステージのタスクを使用)",
|
||||
"vi": "Nhiệm vụ của sub-agent này (tùy chọn — bỏ trống thì dùng task của bước)"},
|
||||
"flow.subagent_add": {"en": "Add", "ja": "追加", "vi": "Thêm"},
|
||||
"flow.subagent_remove": {"en": "Remove", "ja": "削除", "vi": "Xóa"},
|
||||
"flow.subagent_add_from_agent": {
|
||||
"en": "Add from Agent", "ja": "エージェントから追加", "vi": "Thêm từ Agent"},
|
||||
"flow.subagent_no_agents": {
|
||||
"en": "(no saved Agents — create one in the Agents tab)",
|
||||
"ja": "(保存済みのエージェントがありません — Agents タブで作成してください)",
|
||||
"vi": "(chưa có Agent nào — tạo ở tab Quản lý Agent)"},
|
||||
"flow.subagent_hint": {
|
||||
"en": ("Add 2+ sub-agents to make this a PARALLEL stage — they run concurrently, then "
|
||||
"the stage's own Task field is used to consolidate their results into one."),
|
||||
"ja": ("サブエージェントを2つ以上追加すると、このステージは並列ステージになります — "
|
||||
"同時に実行され、その後ステージ自体のタスク欄で結果を1つに統合します。"),
|
||||
"vi": ("Thêm từ 2 sub-agent trở lên để bước này chạy SONG SONG — chúng chạy đồng thời, "
|
||||
"sau đó ô Task của chính bước này dùng để gộp kết quả lại thành một.")},
|
||||
"flow.add_stage": {"en": "Add stage", "ja": "ステージを追加", "vi": "Thêm bước"},
|
||||
"flow.update_stage": {"en": "Update stage", "ja": "ステージを更新", "vi": "Cập nhật bước"},
|
||||
"flow.save_template": {"en": "Save as template", "ja": "テンプレートとして保存", "vi": "Lưu làm template"},
|
||||
"flow.run": {"en": "Run flow", "ja": "フローを実行", "vi": "Chạy flow"},
|
||||
"flow.close": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
|
||||
"flow.none": {"en": "(none)", "ja": "(なし)", "vi": "(không có)"},
|
||||
"flow.default_agent": {"en": "Default", "ja": "デフォルト", "vi": "Mặc định"},
|
||||
"flow.select_template": {"en": "— select template —", "ja": "— テンプレートを選択 —", "vi": "— chọn template —"},
|
||||
"flow.new_flow_name": {"en": "New flow", "ja": "新しいフロー", "vi": "Flow mới"},
|
||||
"flow.default_name": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
|
||||
|
||||
# ---- agent_manager_tab.py -------------------------------------------
|
||||
"agentmgr.hint": {
|
||||
"en": "Create reusable Agent presets (name + task + provider) — pick them as "
|
||||
"parallel sub-agents from any Flow stage in the Code tab.",
|
||||
"ja": "再利用できるエージェントのプリセット(名前・タスク・プロバイダー)を作成します — "
|
||||
"Code タブの任意のフローステージから並列サブエージェントとして選択できます。",
|
||||
"vi": "Tạo sẵn các Agent (tên + nhiệm vụ + provider) để tái sử dụng — chọn làm "
|
||||
"sub-agent chạy song song từ bất kỳ bước Flow nào ở tab Code."},
|
||||
"agentmgr.list_label": {"en": "Saved agents", "ja": "保存済みエージェント", "vi": "Agent đã lưu"},
|
||||
"agentmgr.name_label": {"en": "Agent name", "ja": "エージェント名", "vi": "Tên agent"},
|
||||
"agentmgr.desc_label": {"en": "Description", "ja": "説明", "vi": "Mô tả"},
|
||||
"agentmgr.prompt_label": {"en": "Task (prompt)", "ja": "タスク(プロンプト)", "vi": "Nhiệm vụ (prompt)"},
|
||||
"agentmgr.gen_prompt_btn": {"en": "Generate from description", "ja": "説明から生成",
|
||||
"vi": "Tạo prompt từ mô tả"},
|
||||
"agentmgr.gen_prompt_tooltip": {
|
||||
"en": "Use the AI agent to expand the name/description into a task prompt",
|
||||
"ja": "AI エージェントで名前・説明をタスクプロンプトに展開します",
|
||||
"vi": "Dùng AI để mở rộng tên/mô tả thành task prompt"},
|
||||
"agentmgr.provider_label": {"en": "AI provider", "ja": "AI プロバイダー", "vi": "Provider AI"},
|
||||
"agentmgr.new_btn": {"en": "New agent", "ja": "新規エージェント", "vi": "Agent mới"},
|
||||
"agentmgr.save_btn": {"en": "Save agent", "ja": "エージェントを保存", "vi": "Lưu agent"},
|
||||
"agentmgr.delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"agentmgr.delete_confirm": {
|
||||
"en": "Delete agent '{name}'?", "ja": "エージェント「{name}」を削除しますか?",
|
||||
"vi": "Xóa agent '{name}'?"},
|
||||
|
||||
# ---- permission_dialog.py ------------------------------------------
|
||||
"permission.title": {"en": "Confirm action", "ja": "操作を確認", "vi": "Xác nhận thao tác"},
|
||||
"permission.default_action": {"en": "Action", "ja": "操作", "vi": "Thao tác"},
|
||||
"permission.subtitle_command": {
|
||||
"en": "The agent wants to run this command in the working folder:",
|
||||
"ja": "エージェントが作業フォルダで次のコマンドを実行しようとしています:",
|
||||
"vi": "Agent muốn chạy lệnh này trong thư mục làm việc:"},
|
||||
"permission.subtitle_diff": {
|
||||
"en": "The agent wants to change a file (diff below):",
|
||||
"ja": "エージェントがファイルを変更しようとしています(差分は下記):",
|
||||
"vi": "Agent muốn thay đổi một tệp (xem diff bên dưới):"},
|
||||
"permission.subtitle_default": {
|
||||
"en": "The agent proposes an action:", "ja": "エージェントが操作を提案しています:",
|
||||
"vi": "Agent đề xuất một thao tác:"},
|
||||
"permission.approve": {"en": "Approve", "ja": "承認", "vi": "Duyệt"},
|
||||
"permission.reject": {"en": "Reject", "ja": "拒否", "vi": "Từ chối"},
|
||||
"permission.remember_whitelist": {
|
||||
"en": "Remember — add to the command whitelist",
|
||||
"ja": "記憶する — コマンドのホワイトリストに追加",
|
||||
"vi": "Ghi nhớ — thêm vào whitelist lệnh"},
|
||||
"permission.remember_whitelist_tooltip": {
|
||||
"en": "Future commands starting the same way will be auto-approved without asking again.",
|
||||
"ja": "同じように始まる今後のコマンドは、再確認なしで自動承認されます。",
|
||||
"vi": "Các lệnh sau bắt đầu giống vậy sẽ được tự động duyệt, không hỏi lại."},
|
||||
|
||||
|
||||
# ---- structure_graph_view.py ---------------------------------------
|
||||
"structure.path_placeholder": {"en": "Source / document folder", "ja": "ソース/ドキュメントフォルダ", "vi": "Thư mục source/tài liệu"},
|
||||
"structure.browse": {"en": "Browse…", "ja": "参照…", "vi": "Browse…"},
|
||||
"structure.mode_all": {"en": "All files", "ja": "すべてのファイル", "vi": "All files"},
|
||||
"structure.mode_code": {"en": "Code only", "ja": "コードのみ", "vi": "Code only"},
|
||||
"structure.mode_doc": {"en": "Docs only", "ja": "ドキュメントのみ", "vi": "Docs only"},
|
||||
"structure.project_none": {"en": "(no project — free path)", "ja": "(プロジェクトなし — 自由パス)", "vi": "(không gán project — path tự do)"},
|
||||
"structure.project_tooltip": {
|
||||
"en": "Lock the scan to a project's sandbox workspace — path becomes read-only and the "
|
||||
"Agent Q&A below follows that project's shared Instructions (safer, grounded answers).",
|
||||
"ja": "スキャン対象をプロジェクトのサンドボックスワークスペースに固定します — パスは読み取り専用になり、"
|
||||
"下のエージェントQ&Aはそのプロジェクトの共有指示に従います(より安全で根拠のある回答)。",
|
||||
"vi": "Khóa phạm vi quét vào đúng thư mục sandbox của 1 project — path chuyển sang chỉ đọc và "
|
||||
"khung hỏi-đáp Agent bên dưới sẽ theo Instructions chung của project đó (an toàn hơn, "
|
||||
"câu trả lời bám sát ngữ cảnh, giảm bịa đặt).",
|
||||
},
|
||||
"structure.scan": {"en": "Scan", "ja": "スキャン", "vi": "Scan"},
|
||||
"structure.export_png": {"en": "Export PNG", "ja": "PNG エクスポート", "vi": "Xuất PNG"},
|
||||
"structure.msgs_btn": {"en": "Messages", "ja": "メッセージ", "vi": "Tin nhắn"},
|
||||
"structure.graph_btn": {"en": "Graph", "ja": "グラフ", "vi": "Đồ thị"},
|
||||
"structure.msgs_tooltip": {
|
||||
"en": "Show all conversation messages grouped by day (as JSON).",
|
||||
"ja": "会話メッセージを日別にJSONで表示。",
|
||||
"vi": "Xem mọi message hội thoại nhóm theo ngày (dạng JSON)."},
|
||||
"structure.msgs_none": {"en": "No messages yet.", "ja": "メッセージがありません。",
|
||||
"vi": "Chưa có message nào."},
|
||||
"structure.open_browser": {"en": "Open in browser", "ja": "ブラウザで開く", "vi": "Mở trong trình duyệt"},
|
||||
"structure.open_browser_tooltip": {
|
||||
"en": "Open the full interactive D3 graph in your default browser (works in every build, including the standalone .exe)",
|
||||
"ja": "既定のブラウザでフル機能の D3 グラフを開きます(スタンドアロン .exe を含むすべてのビルドで利用可能)",
|
||||
"vi": "Mở đồ thị D3 đầy đủ tính năng trong trình duyệt mặc định (dùng được ở mọi bản build, kể cả file .exe độc lập)",
|
||||
},
|
||||
"structure.opened_browser": {
|
||||
"en": "D3 graph opened in your browser at {url}",
|
||||
"ja": "ブラウザで D3 グラフを開きました: {url}",
|
||||
"vi": "Đã mở đồ thị D3 trong trình duyệt tại {url}",
|
||||
},
|
||||
"structure.cmem_ui_open": {"en": "Codebase Memory UI", "ja": "Codebase Memory UI", "vi": "Codebase Memory UI"},
|
||||
"structure.cmem_ui_back": {"en": "Back to D3 view", "ja": "D3 表示に戻る", "vi": "Về đồ thị D3"},
|
||||
"structure.cmem_ui_tooltip": {
|
||||
"en": "Open codebase-memory-mcp's own graph UI (Graph/Projects/Control) for the current scan path.",
|
||||
"ja": "現在のスキャンパスに対して codebase-memory-mcp 独自のグラフ UI(Graph/Projects/Control)を開きます。",
|
||||
"vi": "Mở UI đồ thị riêng của codebase-memory-mcp (Graph/Projects/Control) cho đường dẫn đang quét."},
|
||||
"structure.cmem_ui_starting": {
|
||||
"en": "Starting codebase-memory-mcp UI…", "ja": "codebase-memory-mcp の UI を起動中…",
|
||||
"vi": "Đang khởi động UI của codebase-memory-mcp…"},
|
||||
"structure.cmem_ui_opened_embedded": {
|
||||
"en": "codebase-memory-mcp UI loaded.", "ja": "codebase-memory-mcp の UI を読み込みました。",
|
||||
"vi": "Đã tải UI của codebase-memory-mcp."},
|
||||
"structure.cmem_ui_opened_browser": {
|
||||
"en": "codebase-memory-mcp UI opened in your browser at {url}",
|
||||
"ja": "ブラウザで codebase-memory-mcp の UI を開きました: {url}",
|
||||
"vi": "Đã mở UI của codebase-memory-mcp trong trình duyệt tại {url}"},
|
||||
"structure.cmem_ui_not_built": {
|
||||
"en": "This codebase-memory-mcp build has no embedded UI. Install the "
|
||||
"'codebase-memory-mcp-ui' release asset from the project's GitHub "
|
||||
"releases to use this view. ({err})",
|
||||
"ja": "この codebase-memory-mcp ビルドには UI が組み込まれていません。このビューを使うには "
|
||||
"GitHub リリースから 'codebase-memory-mcp-ui' をインストールしてください。({err})",
|
||||
"vi": "Bản build codebase-memory-mcp này không có UI nhúng. Cần cài "
|
||||
"release asset 'codebase-memory-mcp-ui' từ trang GitHub Releases của "
|
||||
"dự án để dùng chức năng này. ({err})"},
|
||||
"structure.cmem_ui_failed": {
|
||||
"en": "Could not open codebase-memory-mcp UI: {err}",
|
||||
"ja": "codebase-memory-mcp の UI を開けませんでした: {err}",
|
||||
"vi": "Không mở được UI của codebase-memory-mcp: {err}"},
|
||||
"structure.collapse_agent_tooltip": {"en": "Collapse the Agent panel", "ja": "エージェントパネルを折りたたむ", "vi": "Thu gọn bảng Agent"},
|
||||
"structure.expand_agent_tooltip": {
|
||||
"en": "Click to expand the Agent panel", "ja": "クリックしてエージェントパネルを展開",
|
||||
"vi": "Bấm để mở lại bảng Agent"},
|
||||
"structure.agent_header": {"en": "Agent — ask about the graph", "ja": "エージェント ― グラフについて質問", "vi": "Agent — hỏi về đồ thị"},
|
||||
"structure.ask_placeholder": {
|
||||
"en": "e.g. what calls main? which files define classes?",
|
||||
"ja": "例: main を呼んでいるのは?クラスを定義しているファイルは?",
|
||||
"vi": "vd. cái gì gọi hàm main? file nào định nghĩa class?"},
|
||||
"structure.ask": {"en": "Ask", "ja": "質問", "vi": "Hỏi"},
|
||||
"structure.detail_placeholder": {
|
||||
"en": "Click a node to open its folder, or ask the agent about the graph.",
|
||||
"ja": "ノードをクリックするとフォルダを開きます。またはエージェントにグラフについて質問できます。",
|
||||
"vi": "Nhấp node để mở thư mục, hoặc hỏi agent về đồ thị."},
|
||||
"structure.pick_folder_title": {"en": "Choose folder", "ja": "フォルダを選択", "vi": "Chọn thư mục"},
|
||||
"structure.scanning": {"en": "Scanning structure…", "ja": "構造をスキャン中…", "vi": "Đang quét cấu trúc…"},
|
||||
"structure.scan_error": {"en": "Scan error: {err}", "ja": "スキャンエラー: {err}", "vi": "Lỗi khi quét: {err}"},
|
||||
"structure.graph_summary": {"en": "Graph: {nodes} nodes, {edges} edges.{note}", "ja": "グラフ: ノード {nodes} 個、エッジ {edges} 個。{note}", "vi": "Đồ thị: {nodes} node, {edges} cạnh.{note}"},
|
||||
"structure.truncated_note": {"en": " (truncated — too many nodes)", "ja": " (切り捨て:ノードが多すぎます)", "vi": " (đã cắt bớt — quá nhiều node)"},
|
||||
}
|
||||
@@ -47,6 +47,23 @@ class JsonConfigRepository:
|
||||
self._env_overrides = env_overrides
|
||||
self.data: Dict[str, Any] = self._load()
|
||||
|
||||
@classmethod
|
||||
def from_data(cls, data: Dict[str, Any], path: Path):
|
||||
"""Dựng từ dict có sẵn — KHÔNG đọc đĩa, KHÔNG nâng cấp schema.
|
||||
|
||||
Dành cho test: chúng dựng cấu hình trong bộ nhớ rồi mới ghi. Đi qua
|
||||
``__init__`` thường thì nó đọc file (chưa có) và có thể chạy migration
|
||||
trên dữ liệu test, tức là test đo nhầm thứ khác.
|
||||
"""
|
||||
obj = cls.__new__(cls)
|
||||
obj._file = AtomicJsonFile(Path(path))
|
||||
obj._secrets = None
|
||||
from ... import config as legacy
|
||||
obj._defaults = legacy.DEFAULT_CONFIG
|
||||
obj._env_overrides = legacy._apply_env_overrides
|
||||
obj.data = data
|
||||
return obj
|
||||
|
||||
# ---- nạp ------------------------------------------------------------
|
||||
def _load(self) -> Dict[str, Any]:
|
||||
merged = copy.deepcopy(self._defaults)
|
||||
@@ -74,6 +91,14 @@ class JsonConfigRepository:
|
||||
def active_provider(self) -> str:
|
||||
return self.data.get("active_provider", "")
|
||||
|
||||
@active_provider.setter
|
||||
def active_provider(self, name: str) -> None:
|
||||
"""``AppConfig`` cũ cho gán thẳng, và 3 chỗ trong app.py đang gán. Bỏ
|
||||
setter đi thì Qt nuốt AttributeError trong slot và triệu chứng là
|
||||
"bấm không ăn", không có lỗi nào hiện ra — mất hẳn một buổi mới truy
|
||||
ra. Refactor thì hành vi nhìn từ ngoài phải y hệt."""
|
||||
self.data["active_provider"] = name
|
||||
|
||||
def set_active_provider(self, name: str) -> None:
|
||||
self.data["active_provider"] = name
|
||||
|
||||
@@ -135,6 +160,10 @@ class JsonConfigRepository:
|
||||
def theme(self) -> str:
|
||||
return self.data.get("theme", "dark")
|
||||
|
||||
@theme.setter
|
||||
def theme(self, value: str) -> None:
|
||||
self.data["theme"] = value
|
||||
|
||||
def set_theme(self, value: str) -> None:
|
||||
self.data["theme"] = value
|
||||
|
||||
@@ -142,6 +171,10 @@ class JsonConfigRepository:
|
||||
def language(self) -> str:
|
||||
return self.data.get("language", "vi")
|
||||
|
||||
@language.setter
|
||||
def language(self, value: str) -> None:
|
||||
self.data["language"] = value
|
||||
|
||||
def set_language(self, value: str) -> None:
|
||||
self.data["language"] = value
|
||||
|
||||
@@ -170,6 +203,164 @@ class JsonConfigRepository:
|
||||
disabled.append(name)
|
||||
self.data["tools_disabled"] = disabled
|
||||
|
||||
|
||||
# ---- phần bù để thay được AppConfig ----------------------------------
|
||||
# 21 thành viên dưới đây chép nguyên ngữ nghĩa từ ``config.py::AppConfig``.
|
||||
# Không phải thiết kế mới: chừng nào 29 file còn gọi qua ``ctx.config`` thì
|
||||
# repository phải trả lời được đúng những câu hỏi cũ, nếu không thì không
|
||||
# tráo được. Dọn lại là việc của các R sau, không phải của R02.
|
||||
|
||||
#: Các chế độ định tuyến. Delta thêm "fallback" ở R03-T03. Định nghĩa ở đây
|
||||
#: là bản chính; ``tests/test_config_repository.py`` có bài đối chiếu với
|
||||
#: ``config.py`` để hai bên lệch nhau là đỏ ngay.
|
||||
ROUTING_MODES = ("off", "auto", "manual", "fallback")
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None, *, secrets: SecretStore | None = None):
|
||||
"""Dựng repository từ đường dẫn mặc định — thay ``AppConfig.load()``."""
|
||||
if path is None:
|
||||
from ... import config as legacy
|
||||
path = legacy.CONFIG_PATH
|
||||
return cls(Path(path), secrets=secrets)
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self._file.path
|
||||
|
||||
# ---- TLS -------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def ca_bundle(self) -> str:
|
||||
"""Đường dẫn file PEM riêng, hoặc '' để kiểm chứng chỉ như bình thường.
|
||||
|
||||
Dùng làm tham số ``verify=`` của ``requests`` cho mọi lượt gọi HTTPS."""
|
||||
return (self.data.get("tls_ca_bundle") or "").strip()
|
||||
|
||||
@ca_bundle.setter
|
||||
def ca_bundle(self, value: str) -> None:
|
||||
self.data["tls_ca_bundle"] = (value or "").strip()
|
||||
|
||||
# ---- MS365 -----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def ms365(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("ms365", copy.deepcopy(self._defaults["ms365"]))
|
||||
|
||||
def ms365_try_unlock(self, code: str) -> bool:
|
||||
"""Mở khoá nhóm MS365 trong Cài đặt cho phiên này.
|
||||
|
||||
Đây là khoá phía giao diện (chặn bấm nhầm vào một mục nhạy cảm), KHÔNG
|
||||
phải xác thực Microsoft. Không bao giờ được lưu ở trạng thái đã mở."""
|
||||
if (code or "") and code == self.ms365.get("unlock_code", ""):
|
||||
self.data["ms365"]["unlocked"] = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def ms365_lock(self) -> None:
|
||||
self.data.setdefault("ms365", {})["unlocked"] = False
|
||||
|
||||
# ---- nhóm cấu hình đọc thẳng ------------------------------------------
|
||||
|
||||
@property
|
||||
def code(self) -> Dict[str, Any]:
|
||||
return self.data["code"]
|
||||
|
||||
@property
|
||||
def teams(self) -> Dict[str, Any]:
|
||||
return self.data["teams"]
|
||||
|
||||
@property
|
||||
def history(self) -> Dict[str, Any]:
|
||||
return self.data["history"]
|
||||
|
||||
@property
|
||||
def codebase_memory(self) -> Dict[str, Any]:
|
||||
return self.data["codebase_memory"]
|
||||
|
||||
@property
|
||||
def cowork(self) -> Dict[str, Any]:
|
||||
return self.data["cowork"]
|
||||
|
||||
@property
|
||||
def mcp_servers(self) -> list:
|
||||
return self.data.setdefault("mcp_servers", [])
|
||||
|
||||
@property
|
||||
def structure(self) -> Dict[str, Any]:
|
||||
return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400})
|
||||
|
||||
@property
|
||||
def monitoring_visibility(self) -> Dict[str, bool]:
|
||||
return self.data.setdefault(
|
||||
"monitoring_visibility",
|
||||
copy.deepcopy(self._defaults["monitoring_visibility"]))
|
||||
|
||||
@property
|
||||
def ext_connectors(self) -> Dict[str, list]:
|
||||
"""Connector (MCP) gom theo nhóm CAD/CAE/MS365/Other."""
|
||||
d = self.data.setdefault(
|
||||
"ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []})
|
||||
for cat in ("cad", "cae", "ms365", "other"):
|
||||
d.setdefault(cat, [])
|
||||
return d
|
||||
|
||||
# ---- công tắc tổng cho connector -------------------------------------
|
||||
|
||||
@property
|
||||
def connect_external(self) -> bool:
|
||||
"""Tắt cái này là agent không nối tới connector ngoài nào cả. Mặc định
|
||||
BẬT để cấu hình đang chạy không đổi hành vi."""
|
||||
return bool(self.data.setdefault("tools", {}).get("connect_external", True))
|
||||
|
||||
def set_connect_external(self, enabled: bool) -> None:
|
||||
self.data.setdefault("tools", {})["connect_external"] = bool(enabled)
|
||||
self.save()
|
||||
|
||||
# ---- những thứ đã gieo sẵn -------------------------------------------
|
||||
|
||||
@property
|
||||
def seeded_library_skills(self) -> list:
|
||||
"""Slug của skill thư viện đã gieo — để cái người dùng xoá đi không bị
|
||||
lặng lẽ gieo lại."""
|
||||
return list(self.data.setdefault("seeded_library_skills", []))
|
||||
|
||||
@seeded_library_skills.setter
|
||||
def seeded_library_skills(self, slugs) -> None:
|
||||
self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or []))
|
||||
|
||||
@property
|
||||
def seeded_builtin_flows(self) -> list:
|
||||
"""Id của flow Co4E dựng sẵn đã gieo (cùng quy tắc tôn trọng việc người
|
||||
dùng đã xoá như seeded_library_skills)."""
|
||||
return list(self.data.setdefault("seeded_builtin_flows", []))
|
||||
|
||||
@seeded_builtin_flows.setter
|
||||
def seeded_builtin_flows(self, ids) -> None:
|
||||
self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or []))
|
||||
|
||||
# ---- định tuyến theo từng bề mặt chat --------------------------------
|
||||
|
||||
def routing_mode_for(self, surface: str) -> str:
|
||||
"""Chế độ có hiệu lực cho một bề mặt chat.
|
||||
|
||||
Đặt riêng cho bề mặt thì thắng; để trống thì lấy ``switch_mode`` chung.
|
||||
Giá trị lạ rơi về "off" — định tuyến luôn là thứ phải bật, kể cả khi
|
||||
có người sửa tay file cấu hình."""
|
||||
routing = self.routing
|
||||
override = (routing.get("surface_modes", {}) or {}).get(surface, "")
|
||||
mode = override or routing.get("switch_mode", "off")
|
||||
return mode if mode in self.ROUTING_MODES else "off"
|
||||
|
||||
def set_routing_mode_for(self, surface: str, mode: str) -> None:
|
||||
mode = mode if mode in self.ROUTING_MODES else "off"
|
||||
self.routing.setdefault("surface_modes", {})[surface] = mode
|
||||
self.save()
|
||||
|
||||
# ---- tiện ích --------------------------------------------------------
|
||||
|
||||
def model_label(self) -> str:
|
||||
return str(self.provider_conf().get("model", "?"))
|
||||
|
||||
# ---- ghi -------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
"""Ghi nguyên tử. Không bao giờ để lộ trạng thái mở khoá ms365."""
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Sandbox capability matrix — which isolation backends exist on which OS,
|
||||
and which one a given risk tier should prefer.
|
||||
|
||||
Pure policy/data: no subprocess execution, no PySide6, no dependency on
|
||||
``core/sandbox_manager.py`` (that module owns the actual execution and isn't
|
||||
in this task's editable scope — this matrix is a standalone, independently
|
||||
testable module ready for that module's owner to wire in later).
|
||||
|
||||
The Windows entries mirror what ``core/sandbox_manager.py`` +
|
||||
``core/appcontainer_sandbox.py``/``core/windows_sandbox_vm.py``/
|
||||
``core/integrity_sandbox.py`` already implement today. Linux/macOS entries
|
||||
are declared but marked ``implemented=False`` — today those platforms have no
|
||||
real isolation backend (confirmed: ``core/appcontainer_sandbox.py`` and
|
||||
``core/windows_sandbox_vm.py`` both hard-return ``False`` off Windows) — so
|
||||
this matrix reports that honestly instead of pretending capabilities that
|
||||
don't exist yet. Adding a real Linux/macOS backend later is a 1-line flip of
|
||||
``implemented`` plus whatever backend module implements it; adding a whole
|
||||
new OS is a call to :func:`register_profile`, no changes to
|
||||
:class:`SandboxCapabilityMatrix` itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
# Plain string constants (like core/audit_log.py's ``Kind``) rather than an
|
||||
# Enum, so a brand-new OS can be registered without editing a closed type.
|
||||
WINDOWS = "windows"
|
||||
LINUX = "linux"
|
||||
MACOS = "macos"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
# Risk tiers — same vocabulary as security/command_risk_classifier.RiskLevel,
|
||||
# kept as plain strings here so this module has zero dependency on the
|
||||
# ``security/`` package (out of scope for this task).
|
||||
SAFE = "SAFE"
|
||||
MODERATE = "MODERATE"
|
||||
HIGH = "HIGH"
|
||||
CRITICAL = "CRITICAL"
|
||||
|
||||
BLOCKED = "blocked"
|
||||
DIRECT = "direct"
|
||||
|
||||
|
||||
def detect_os(platform_name: Optional[str] = None) -> str:
|
||||
"""``platform_name`` defaults to ``sys.platform`` but can be injected for
|
||||
testing (e.g. ``detect_os("linux")``, ``detect_os("darwin")``)."""
|
||||
name = platform_name if platform_name is not None else sys.platform
|
||||
if name.startswith("win"):
|
||||
return WINDOWS
|
||||
if name.startswith("linux"):
|
||||
return LINUX
|
||||
if name.startswith("darwin"):
|
||||
return MACOS
|
||||
return UNKNOWN
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SandboxBackend:
|
||||
name: str
|
||||
isolation_level: str # "none" | "resource_limits" | "restricted_token" | "namespace" | "seatbelt" | "full_vm"
|
||||
implemented: bool # whether a real backend exists today, vs. a declared placeholder
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OsSandboxProfile:
|
||||
operating_system: str
|
||||
backends: Tuple[SandboxBackend, ...]
|
||||
# risk tier -> ordered list of preferred backend names (first available wins)
|
||||
routing: Dict[str, Tuple[str, ...]]
|
||||
|
||||
|
||||
def _profile(operating_system: str, backends: Tuple[SandboxBackend, ...],
|
||||
routing: Dict[str, Tuple[str, ...]]) -> OsSandboxProfile:
|
||||
return OsSandboxProfile(operating_system=operating_system, backends=backends, routing=routing)
|
||||
|
||||
|
||||
_WINDOWS_PROFILE = _profile(
|
||||
WINDOWS,
|
||||
backends=(
|
||||
SandboxBackend(DIRECT, "none", True),
|
||||
SandboxBackend("integrity_job_wfp", "resource_limits", True),
|
||||
SandboxBackend("appcontainer", "restricted_token", True),
|
||||
SandboxBackend("windows_sandbox", "full_vm", True),
|
||||
),
|
||||
routing={
|
||||
SAFE: ("integrity_job_wfp", DIRECT),
|
||||
MODERATE: ("integrity_job_wfp", DIRECT),
|
||||
HIGH: ("appcontainer", "integrity_job_wfp"),
|
||||
CRITICAL: ("windows_sandbox", "appcontainer", BLOCKED),
|
||||
},
|
||||
)
|
||||
|
||||
_LINUX_PROFILE = _profile(
|
||||
LINUX,
|
||||
backends=(
|
||||
SandboxBackend(DIRECT, "none", True),
|
||||
SandboxBackend("namespaces_bubblewrap", "namespace", False), # not implemented yet
|
||||
),
|
||||
routing={
|
||||
SAFE: (DIRECT,),
|
||||
MODERATE: (DIRECT,),
|
||||
HIGH: ("namespaces_bubblewrap", BLOCKED),
|
||||
CRITICAL: (BLOCKED,),
|
||||
},
|
||||
)
|
||||
|
||||
_MACOS_PROFILE = _profile(
|
||||
MACOS,
|
||||
backends=(
|
||||
SandboxBackend(DIRECT, "none", True),
|
||||
SandboxBackend("sandbox_exec", "seatbelt", False), # not implemented yet
|
||||
),
|
||||
routing={
|
||||
SAFE: (DIRECT,),
|
||||
MODERATE: (DIRECT,),
|
||||
HIGH: ("sandbox_exec", BLOCKED),
|
||||
CRITICAL: (BLOCKED,),
|
||||
},
|
||||
)
|
||||
|
||||
_UNKNOWN_PROFILE = _profile(
|
||||
UNKNOWN,
|
||||
backends=(),
|
||||
routing={SAFE: (BLOCKED,), MODERATE: (BLOCKED,), HIGH: (BLOCKED,), CRITICAL: (BLOCKED,)},
|
||||
)
|
||||
|
||||
_PROFILES: Dict[str, OsSandboxProfile] = {
|
||||
WINDOWS: _WINDOWS_PROFILE,
|
||||
LINUX: _LINUX_PROFILE,
|
||||
MACOS: _MACOS_PROFILE,
|
||||
UNKNOWN: _UNKNOWN_PROFILE,
|
||||
}
|
||||
|
||||
|
||||
def register_profile(profile: OsSandboxProfile) -> None:
|
||||
"""Extension point for a brand-new OS: build an :class:`OsSandboxProfile`
|
||||
and register it once — no change to :class:`SandboxCapabilityMatrix`
|
||||
needed. Overwrites any existing profile for the same
|
||||
``operating_system`` name (lets a caller override the built-in Windows/
|
||||
Linux/macOS profiles too, e.g. once a real Linux backend ships)."""
|
||||
_PROFILES[profile.operating_system] = profile
|
||||
|
||||
|
||||
class SandboxCapabilityMatrix:
|
||||
"""Answers, for one OS: which backends are actually available today, and
|
||||
which one a given risk tier should prefer. Read-only policy — does not
|
||||
execute anything."""
|
||||
|
||||
def __init__(self, operating_system: Optional[str] = None,
|
||||
allow_direct_fallback: bool = True) -> None:
|
||||
self.operating_system = operating_system if operating_system is not None else detect_os()
|
||||
self._profile = _PROFILES.get(self.operating_system, _UNKNOWN_PROFILE)
|
||||
self.allow_direct_fallback = allow_direct_fallback
|
||||
|
||||
def all_backends(self) -> Tuple[SandboxBackend, ...]:
|
||||
"""Every backend declared for this OS, implemented or not."""
|
||||
return self._profile.backends
|
||||
|
||||
def available_backends(self) -> Tuple[SandboxBackend, ...]:
|
||||
"""Only backends with a real implementation today."""
|
||||
return tuple(b for b in self._profile.backends if b.implemented)
|
||||
|
||||
def select_backend(self, risk_level: str) -> str:
|
||||
"""The backend name to use for ``risk_level`` on this OS — the first
|
||||
available (implemented) backend in that tier's preference order, else
|
||||
``"direct"`` when allowed for a non-CRITICAL tier, else ``"blocked"``."""
|
||||
available_names = {b.name for b in self.available_backends()}
|
||||
preferred = self._profile.routing.get(risk_level.upper(), ())
|
||||
for name in preferred:
|
||||
if name == BLOCKED:
|
||||
return BLOCKED
|
||||
if name in available_names:
|
||||
return name
|
||||
if (self.allow_direct_fallback and DIRECT in available_names
|
||||
and risk_level.upper() != CRITICAL):
|
||||
return DIRECT
|
||||
return BLOCKED
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Canonical audit event logging — the infrastructure behind
|
||||
``core/audit_log.py``'s ``set_identity``/``record``/``load_events`` free
|
||||
functions (kept as thin wrappers over a module-level singleton for backward
|
||||
compatibility with every existing call site).
|
||||
|
||||
Same on-disk shape as before: one JSON line per event, one file per day
|
||||
under ``~/.cowork_local/audit/`` (plus a best-effort mirror into a shared
|
||||
cross-machine folder when an identity's ``shared_dir`` is set). ``record()``
|
||||
never raises — audit logging must never break a chat turn, a permission
|
||||
decision, or a tool call.
|
||||
|
||||
The event schema is unchanged (same field names, same order) so every
|
||||
``.jsonl`` file written before this refactor remains fully readable. New
|
||||
event kinds can be added by defining another ``KIND_*`` constant — nothing
|
||||
about the schema itself needs to change to support one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Known kinds today. ``kind`` stays a plain str (not an enum) so a caller can
|
||||
# always pass a new value without editing this module — these constants are
|
||||
# just the documented, current vocabulary.
|
||||
KIND_TOOL_CALL = "tool_call"
|
||||
KIND_PERMISSION = "permission"
|
||||
KIND_SECURITY_BLOCK = "security_block"
|
||||
KIND_MCP_CALL = "mcp_call"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanonicalAuditEvent:
|
||||
"""One audit log entry. Field order matches the pre-refactor
|
||||
``core/audit_log.py`` schema exactly, for byte-compatible JSON output."""
|
||||
|
||||
ts: str
|
||||
kind: str
|
||||
agent_role: str
|
||||
name: str
|
||||
ok: bool
|
||||
detail: str
|
||||
account: str
|
||||
role: str
|
||||
machine: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"ts": self.ts,
|
||||
"kind": self.kind,
|
||||
"agent_role": self.agent_role,
|
||||
"name": self.name,
|
||||
"ok": self.ok,
|
||||
"detail": self.detail,
|
||||
"account": self.account,
|
||||
"role": self.role,
|
||||
"machine": self.machine,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: Dict[str, Any]) -> "CanonicalAuditEvent":
|
||||
"""Tolerant of missing keys, so old/partial rows never fail to load."""
|
||||
return cls(
|
||||
ts=str(raw.get("ts", "")),
|
||||
kind=str(raw.get("kind", "")),
|
||||
agent_role=str(raw.get("agent_role", "")),
|
||||
name=str(raw.get("name", "")),
|
||||
ok=bool(raw.get("ok", False)),
|
||||
detail=str(raw.get("detail", "")),
|
||||
account=str(raw.get("account", "")),
|
||||
role=str(raw.get("role", "")),
|
||||
machine=str(raw.get("machine", "")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Identity:
|
||||
account: str = ""
|
||||
role: str = ""
|
||||
machine: str = ""
|
||||
shared_dir: str = ""
|
||||
|
||||
|
||||
class CanonicalAuditLogger:
|
||||
"""Day-sharded JSONL audit writer/reader. Process identity (who's logged
|
||||
in, this machine's name) is set once via :meth:`set_identity`, mirroring
|
||||
the pre-refactor module-global pattern but held as instance state so this
|
||||
class can be constructed/injected instead of relying on globals."""
|
||||
|
||||
def __init__(self, audit_dir: Path):
|
||||
self.audit_dir = Path(audit_dir)
|
||||
self._identity = _Identity()
|
||||
|
||||
def set_identity(self, account: str, machine: str, role: str = "",
|
||||
shared_dir: str = "") -> None:
|
||||
"""Called once after login succeeds. ``shared_dir``, when reachable,
|
||||
makes every subsequent :meth:`record` ALSO best-effort-append to the
|
||||
shared cross-machine telemetry store."""
|
||||
self._identity = _Identity(account=account or "", role=role or "",
|
||||
machine=machine or "", shared_dir=shared_dir or "")
|
||||
|
||||
def record(self, kind: str, name: str, ok: bool, detail: str = "",
|
||||
agent_role: str = "") -> None:
|
||||
"""Append one audit event. Never raises."""
|
||||
try:
|
||||
now = datetime.now()
|
||||
event = CanonicalAuditEvent(
|
||||
ts=now.isoformat(timespec="seconds"),
|
||||
kind=kind,
|
||||
agent_role=agent_role or "",
|
||||
name=name or "",
|
||||
ok=bool(ok),
|
||||
detail=(detail or "")[:2000],
|
||||
account=self._identity.account,
|
||||
role=self._identity.role,
|
||||
machine=self._identity.machine,
|
||||
)
|
||||
self.audit_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = self.audit_dir / f"{now.strftime('%Y-%m-%d')}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event.to_dict(), ensure_ascii=False) + "\n")
|
||||
self._write_shared(event, now)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _write_shared(self, event: CanonicalAuditEvent, now: datetime) -> None:
|
||||
identity = self._identity
|
||||
if not identity.shared_dir or not identity.machine:
|
||||
return
|
||||
try:
|
||||
shared = Path(identity.shared_dir).expanduser() / "telemetry" / "audit"
|
||||
shared.mkdir(parents=True, exist_ok=True)
|
||||
path = shared / f"{identity.machine}-{now.strftime('%Y-%m-%d')}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event.to_dict(), ensure_ascii=False) + "\n")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def load_events(self, start: Optional[date] = None, end: Optional[date] = None,
|
||||
kind: Optional[str] = None,
|
||||
directory: Optional[Path] = None) -> List[CanonicalAuditEvent]:
|
||||
"""Events between ``start``/``end`` (inclusive; None = unbounded),
|
||||
optionally filtered to one ``kind``."""
|
||||
directory = directory or self.audit_dir
|
||||
if not directory.exists():
|
||||
return []
|
||||
events: List[CanonicalAuditEvent] = []
|
||||
for path in sorted(directory.glob("*.jsonl")):
|
||||
try:
|
||||
day = datetime.strptime(path.stem, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
continue
|
||||
if (start and day < start) or (end and day > end):
|
||||
continue
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
raw = json.loads(line)
|
||||
if kind is not None and raw.get("kind") != kind:
|
||||
continue
|
||||
events.append(CanonicalAuditEvent.from_dict(raw))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
return events
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Agent và skill dùng trong flow — R08-T09.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from ...core import co4e, skills as skills_mod
|
||||
from ...i18n import tr
|
||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||
|
||||
|
||||
class Co4EAgentsMixin:
|
||||
def _new_agent(self) -> None:
|
||||
self._edit_agent_dialog(co4e.new_custom_agent(""))
|
||||
def _edit_agent(self) -> None:
|
||||
item = self.agent_list.currentItem()
|
||||
cid = item.data(Qt.UserRole + 1) if item else None
|
||||
if not cid:
|
||||
self.status_message.emit(tr("co4e.select_custom_agent"))
|
||||
return
|
||||
agent = next((a for a in co4e.list_custom_agents() if a.id == cid), None)
|
||||
if agent is not None:
|
||||
self._edit_agent_dialog(agent)
|
||||
def _edit_agent_dialog(self, agent: co4e.CustomAgent) -> None:
|
||||
from ...ui.co4e_agent_dialog import Co4EAgentDialog
|
||||
|
||||
dlg = Co4EAgentDialog(self.ctx, agent, _skill_names(), self)
|
||||
if dlg.exec():
|
||||
co4e.save_custom_agent(dlg.result_agent())
|
||||
self._reload_sidebar()
|
||||
def _delete_agent(self) -> None:
|
||||
item = self.agent_list.currentItem()
|
||||
cid = item.data(Qt.UserRole + 1) if item else None
|
||||
if not cid:
|
||||
self.status_message.emit(tr("co4e.select_custom_agent"))
|
||||
return
|
||||
co4e.delete_custom_agent(cid)
|
||||
self._reload_sidebar()
|
||||
def _manage_skills(self) -> None:
|
||||
from ...ui.skills_dialog import SkillsDialog
|
||||
|
||||
SkillsDialog(self, self.ctx).exec()
|
||||
self._reload_sidebar()
|
||||
def _skill_map(self) -> Dict[str, str]:
|
||||
out = {}
|
||||
for name in _skill_names():
|
||||
block = skills_mod.skill_prefix_for(name)
|
||||
if block:
|
||||
out[name] = block.split("\n", 1)[1] if "\n" in block else block
|
||||
return out
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Khung chat của Co4E và việc đếm token — R08-T09.
|
||||
|
||||
Khác chat của Cowork ở một điểm: ở đây câu người dùng gõ có thể mang chỉ thị
|
||||
chọn agent (``_extract_agent_directive``), và mỗi lượt được định tuyến riêng
|
||||
theo cấu hình routing của Co4E.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QSplitter, QWidget
|
||||
from ...core import co4e, skills as skills_mod
|
||||
from ...core.co4e_builtins import BUILTIN_AGENTS
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...ui.chat_view import ChatView
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.co4e_chat_view import ChatPanel
|
||||
|
||||
|
||||
class Co4EChatMixin:
|
||||
def _build_chat(self) -> QWidget:
|
||||
"""Widget construction lives in ``ChatPanel`` (presentation/co4e/
|
||||
co4e_chat_view.py); this method just wires the panel's public
|
||||
attributes to the handler methods that know about ``self``
|
||||
(``_toggle_messages``, ``_chat_send``) and keeps the state that is
|
||||
NOT part of the panel's own construction (``_flow_logs`` — per-flow
|
||||
ChatView dict, ``_co4e_routed_provider`` — routing override, and
|
||||
``_vsplit_sizes``/``_msgs_collapsed`` — used by ``_toggle_messages``
|
||||
below to restore/collapse the splitter) — the panel itself stays
|
||||
ignorant of ``Co4ETab``.
|
||||
"""
|
||||
panel = ChatPanel(self.ctx)
|
||||
self._chat_widget = panel
|
||||
self.msgs_icon = panel.msgs_icon
|
||||
self.msgs_title = panel.msgs_title
|
||||
self.chat_toggle_btn = panel.chat_toggle_btn
|
||||
self.chat_toggle_btn.clicked.connect(self._toggle_messages)
|
||||
self._mhdr = panel.header
|
||||
self.chat_stack = panel.chat_stack
|
||||
self._flow_logs: Dict[str, ChatView] = {}
|
||||
self.chat_input_row = panel.chat_input_row
|
||||
self._usage_total_lbl = panel.usage_total_lbl
|
||||
self.chat_input = panel.chat_input
|
||||
self.chat_input.submit.connect(self._chat_send)
|
||||
self.chat_send_btn = panel.chat_send_btn
|
||||
self.chat_send_btn.clicked.connect(self._chat_send)
|
||||
self.co4e_routing_toggle = panel.co4e_routing_toggle
|
||||
self._co4e_routed_provider = None # routing provider override for the next turn
|
||||
self._vsplit_sizes = [540, 220] # sizes to restore when expanded
|
||||
self._msgs_collapsed = True
|
||||
return panel
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Show/hide the WHOLE chat box (message list + composer) below the
|
||||
header. Collapsing hands the freed height to the canvas.
|
||||
|
||||
A QSplitter's ``setMaximumHeight`` on one side does NOT automatically
|
||||
redistribute the freed space to the other side — it just shrinks the
|
||||
splitter's own total height, leaving the canvas frozen at its old size
|
||||
and blank space below it. So this explicitly calls ``setSizes`` on both
|
||||
the collapse AND the expand path, computed from the splitter's CURRENT
|
||||
total (not a hardcoded guess) — that total stays constant; only how
|
||||
it's split between canvas/chat changes."""
|
||||
self._msgs_collapsed = not self._msgs_collapsed
|
||||
collapsed_h = self._mhdr.sizeHint().height() + 6
|
||||
if self._msgs_collapsed:
|
||||
if hasattr(self, "_vsplit"):
|
||||
self._vsplit_sizes = self._vsplit.sizes() # remember to restore
|
||||
self.chat_stack.hide()
|
||||
self.chat_input_row.hide()
|
||||
self._chat_widget.setMaximumHeight(collapsed_h)
|
||||
self.chat_toggle_btn.setIcon(icon("chevron-up")) # collapsed → click to expand
|
||||
self.chat_toggle_btn.setToolTip(tr("co4e.tt_expand_msgs"))
|
||||
if hasattr(self, "_vsplit"):
|
||||
total = sum(self._vsplit.sizes()) or (self._vsplit_sizes and sum(self._vsplit_sizes)) or 760
|
||||
self._vsplit.setSizes([max(0, total - collapsed_h), collapsed_h])
|
||||
else:
|
||||
self._chat_widget.setMaximumHeight(16777215)
|
||||
self.chat_stack.show()
|
||||
self.chat_input_row.show()
|
||||
self.chat_toggle_btn.setIcon(icon("chevron-down")) # expanded → click to collapse
|
||||
self.chat_toggle_btn.setToolTip(tr("co4e.tt_collapse_msgs"))
|
||||
if getattr(self, "_vsplit_sizes", None) and hasattr(self, "_vsplit"):
|
||||
self._vsplit.setSizes(self._vsplit_sizes)
|
||||
return
|
||||
def _ensure_flow_log(self, wf_id: str) -> ChatView:
|
||||
"""The ChatView for a flow, created + added to the stack on first use so
|
||||
each flow tab keeps a SEPARATE conversation."""
|
||||
log = self._flow_logs.get(wf_id)
|
||||
if log is None:
|
||||
log = ChatView()
|
||||
log._co4e_plan_bubble = None # per-flow 'current plan' bubble
|
||||
self._flow_logs[wf_id] = log
|
||||
self.chat_stack.addWidget(log)
|
||||
return log
|
||||
def _active_log(self) -> ChatView:
|
||||
wf = getattr(self, "_wf", None)
|
||||
return self._ensure_flow_log(wf.id if wf is not None else "__none__")
|
||||
@property
|
||||
def chat_log(self) -> ChatView:
|
||||
"""The conversation of the CURRENTLY-shown flow (all append/stream calls
|
||||
go here). Assignment is not supported — logs are per-flow now."""
|
||||
return self._active_log()
|
||||
@property
|
||||
def _plan_bubble(self):
|
||||
return getattr(self._active_log(), "_co4e_plan_bubble", None)
|
||||
@_plan_bubble.setter
|
||||
def _plan_bubble(self, value) -> None:
|
||||
self._active_log()._co4e_plan_bubble = value
|
||||
def _chat_send(self) -> None:
|
||||
text = self.chat_input.text().strip()
|
||||
if not text or self._chat_worker is not None:
|
||||
return
|
||||
self.chat_input.clear()
|
||||
self._append_chat("user", text)
|
||||
skill_prefix, request, info = skills_mod.parse_skill_command(text)
|
||||
if info is not None:
|
||||
self._append_chat("system", info)
|
||||
return
|
||||
system_parts = []
|
||||
if skill_prefix:
|
||||
system_parts.append(skill_prefix)
|
||||
agent_name, request = self._extract_agent_directive(request)
|
||||
model = ""
|
||||
if agent_name:
|
||||
persona = self._resolve_agent(agent_name)
|
||||
if persona is None:
|
||||
self._append_chat("system", tr("co4e.agent_not_found", name=agent_name))
|
||||
return
|
||||
system_parts.append(persona[0])
|
||||
model = persona[1]
|
||||
# Auto Model Routing — only when the user hasn't pinned an agent's own
|
||||
# model (an explicit pin wins). May switch provider+model for this turn.
|
||||
if not model:
|
||||
model = self._apply_co4e_routing(request)
|
||||
self._run_chat_turn(system_parts, request, model)
|
||||
def _apply_co4e_routing(self, request: str) -> str:
|
||||
"""Route this Co4E turn to the best-fit model. Returns the model id to
|
||||
use ('' → provider default) and sets ``self._co4e_routed_provider`` when
|
||||
a cross-provider switch is chosen.
|
||||
|
||||
R03-T05: the Off/Auto/Manual/Fallback rules are no longer re-implemented
|
||||
here — they come from the shared ``RoutingApplicationService``, so Co4E,
|
||||
the Cowork chat and AI-Edit can never drift apart again. This method only
|
||||
adapts between Co4E's state and the service's DTOs. Never raises — falls
|
||||
back to the default model on any error.
|
||||
"""
|
||||
self._co4e_routed_provider = None
|
||||
try:
|
||||
from ...application.model_routing import (
|
||||
RoutingRequest,
|
||||
build_routing_application_service,
|
||||
)
|
||||
from ...ui.routing_toggle import confirm_switch
|
||||
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
outcome = build_routing_application_service(self.ctx).resolve(
|
||||
RoutingRequest(
|
||||
surface="co4e",
|
||||
prompt=request,
|
||||
current_provider=cur_provider,
|
||||
current_model=cur_model,
|
||||
),
|
||||
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
|
||||
)
|
||||
if not outcome.switched:
|
||||
return "" # '' keeps the provider's configured default model
|
||||
# Remembered so the worker's build_provider_for() can follow a
|
||||
# cross-provider switch, not just a model change.
|
||||
self._co4e_routed_provider = outcome.provider
|
||||
self._append_chat("system", tr(
|
||||
"routing.switched_notice",
|
||||
model=outcome.model, task=outcome.task_type,
|
||||
gain=f"{outcome.score_gain:.2f}"))
|
||||
return outcome.model
|
||||
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
|
||||
self._co4e_routed_provider = None
|
||||
return ""
|
||||
def _extract_agent_directive(self, text: str):
|
||||
m = re.search(r"(?<!\S)/agent:([\w\-.]+)", text)
|
||||
if not m:
|
||||
return "", text
|
||||
name = m.group(1)
|
||||
rest = (text[:m.start()] + " " + text[m.end():]).strip()
|
||||
return name, rest
|
||||
def _resolve_agent(self, name: str):
|
||||
low = name.lower()
|
||||
for a in BUILTIN_AGENTS:
|
||||
if a.slug == low or a.name.lower() == low:
|
||||
return (f"You are the {a.role} agent — {a.name}.\n{a.instructions}", "")
|
||||
for ca in co4e.list_custom_agents():
|
||||
if co4e.slugify(ca.name) == low or ca.name.lower() == low:
|
||||
return (f"You are the {ca.role} agent — {ca.name}.\n{ca.instructions}", ca.model)
|
||||
return None
|
||||
def _run_chat_turn(self, system_parts: List[str], request: str, model: str) -> None:
|
||||
self.chat_send_btn.setEnabled(False)
|
||||
log = self.chat_log # THIS flow's conversation (captured)
|
||||
log._co4e_plan_bubble = None # a fresh plan for this turn
|
||||
ctx = self.ctx
|
||||
out_dir = self._out_dir()
|
||||
sys_text = "\n\n".join(p for p in system_parts if p)
|
||||
prompt = f"{sys_text}\n\n{request}" if sys_text else request
|
||||
assistant = log.add_assistant() # stream into this live bubble
|
||||
state = {"text": ""}
|
||||
wf = getattr(self, "_wf", None)
|
||||
wf_id = wf.id if wf is not None else None
|
||||
flow_label = wf.name if wf is not None else "flow"
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ...core import agent_roles, usage_tracker as ut
|
||||
from ...core.chat_agent import run_cowork
|
||||
from ...core.co4e_runner import _usage_delta
|
||||
# An Auto/Manual routing switch may target a different provider.
|
||||
provider = ctx.build_provider_for(getattr(self, "_co4e_routed_provider", None), model or None)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
ut.set_context("co4e", flow_label) # attribute + measure this turn's usage
|
||||
ut.begin_accumulation()
|
||||
base = ut.accumulated()
|
||||
|
||||
def _emit(ev):
|
||||
if not isinstance(ev, dict):
|
||||
return
|
||||
t = ev.get("type")
|
||||
if t == "text":
|
||||
worker.emit_event({"type": "text", "delta": ev.get("delta", "")})
|
||||
elif t == "plan_set":
|
||||
worker.emit_event({"type": "plan_set", "steps": ev.get("steps") or []})
|
||||
try:
|
||||
run_cowork(provider, messages, out_dir, _emit, worker.is_cancelled,
|
||||
security_config=ctx.config, agent_role=agent_roles.COWORK,
|
||||
run_to_completion=True, enforce_rules=False)
|
||||
usage = _usage_delta(base, ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "assistant" and m.get("content"):
|
||||
return {"text": str(m["content"]), "usage": usage}
|
||||
return {"text": "", "usage": usage}
|
||||
|
||||
def on_event(ev):
|
||||
if ev.get("type") == "text":
|
||||
state["text"] += ev.get("delta", "")
|
||||
assistant.set_markdown(state["text"])
|
||||
log.scroll_to_bottom()
|
||||
elif ev.get("type") == "plan_set":
|
||||
self._append_plan(ev.get("steps") or [], log=log)
|
||||
|
||||
def done(result: dict):
|
||||
self._chat_worker = None
|
||||
self.chat_send_btn.setEnabled(True)
|
||||
final = result.get("text") or state["text"]
|
||||
assistant.set_markdown(final or "(no output)")
|
||||
self._apply_usage(assistant, wf_id, result.get("usage"))
|
||||
log.scroll_to_bottom()
|
||||
|
||||
def failed(err: str):
|
||||
self._chat_worker = None
|
||||
self.chat_send_btn.setEnabled(True)
|
||||
self._append_chat("error", f"[error: {err}]", log=log)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.event.connect(on_event)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._chat_worker = w
|
||||
w.start()
|
||||
def _append_chat(self, role: str, text: str, log: "ChatView" = None) -> None:
|
||||
"""Add one message bubble to a flow's conversation. ``log`` defaults to the
|
||||
active flow's log; a run/stream passes its OWN captured log so events land
|
||||
in the right flow even if the user switches tabs mid-run."""
|
||||
log = log or self.chat_log
|
||||
if role == "user":
|
||||
bub = log.add_user(text)
|
||||
elif role == "assistant":
|
||||
bub = log.add_assistant()
|
||||
bub.set_markdown(text)
|
||||
elif role == "error":
|
||||
bub = log.add_error(text)
|
||||
else: # system status marker
|
||||
bub = log.add_status(text)
|
||||
log.scroll_to_bottom()
|
||||
return bub
|
||||
def _fmt_usage(self, d_in: int, d_out: int, d_cache: int, cost_usd: float) -> str:
|
||||
"""The Cowork-style footer string: ↓in ↑out ▤(in+out+cache) $cost, priced
|
||||
with the Monitoring model-price table in the app's display currency."""
|
||||
from ...core import model_pricing as mp, usage_tracker as ut
|
||||
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
return (f"↓{mp.format_tokens(d_in)} ↑{mp.format_tokens(d_out)} "
|
||||
f"▤{mp.format_tokens(d_in + d_out + d_cache)} "
|
||||
f"{ut.format_cost(cost_usd, pricing)}")
|
||||
def _apply_usage(self, bub, wf_id, usage) -> None:
|
||||
"""Attach a token/cost footer to a step's bubble and add it to the flow's
|
||||
running total (mirrors Cowork's per-message + conversation-total display)."""
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
d_in = int(usage.get("in", 0) or 0)
|
||||
d_out = int(usage.get("out", 0) or 0)
|
||||
d_cache = int(usage.get("cache", 0) or 0)
|
||||
cost = float(usage.get("cost_usd", 0.0) or 0.0)
|
||||
if bub is not None and (d_in or d_out):
|
||||
try:
|
||||
bub.add_usage(self._fmt_usage(d_in, d_out, d_cache, cost))
|
||||
except Exception: # noqa: BLE001 - a usage footer must never break the run
|
||||
pass
|
||||
if wf_id is not None:
|
||||
tot = self._flow_usage.setdefault(wf_id, {"in": 0, "out": 0, "cache": 0, "cost": 0.0})
|
||||
tot["in"] += d_in; tot["out"] += d_out; tot["cache"] += d_cache; tot["cost"] += cost
|
||||
self._refresh_usage_total(wf_id)
|
||||
def _refresh_usage_total(self, only_wf: str = None) -> None:
|
||||
"""Update the bottom conversation total to the CURRENT flow's running
|
||||
usage (skip if the event is for a different, background flow)."""
|
||||
lbl = getattr(self, "_usage_total_lbl", None)
|
||||
if lbl is None:
|
||||
return
|
||||
wf = getattr(self, "_wf", None)
|
||||
wf_id = wf.id if wf is not None else None
|
||||
if only_wf is not None and only_wf != wf_id:
|
||||
return
|
||||
tot = self._flow_usage.get(wf_id) if wf_id else None
|
||||
if not tot or not (tot["in"] or tot["out"]):
|
||||
lbl.setText("")
|
||||
return
|
||||
lbl.setText(self._fmt_usage(int(tot["in"]), int(tot["out"]),
|
||||
int(tot["cache"]), float(tot["cost"])))
|
||||
def _append_diff(self, title: str, diff: str, log: "ChatView" = None) -> None:
|
||||
"""Render a before/after diff as a collapsible colored diff bubble."""
|
||||
log = log or self.chat_log
|
||||
log.add_diff(f"▤ {title}", diff)
|
||||
log.scroll_to_bottom()
|
||||
def _append_plan(self, steps, log: "ChatView" = None) -> None:
|
||||
"""Show the plan INLINE in the conversation as an expandable block; update
|
||||
the same (per-flow) bubble in place so steps tick off (✓) as they complete."""
|
||||
log = log or self.chat_log
|
||||
body = _fmt_plan(steps)
|
||||
if not body:
|
||||
return
|
||||
if getattr(log, "_co4e_plan_bubble", None) is None:
|
||||
log._co4e_plan_bubble = log.add_plan(body)
|
||||
else:
|
||||
log._co4e_plan_bubble.set_plain(body)
|
||||
log.scroll_to_bottom()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Dải tab các flow đang mở — R08-T09.
|
||||
|
||||
Mỗi flow người dùng mở là một tab. Đóng tab không đóng flow: nó chỉ rời khỏi
|
||||
dải, flow vẫn còn trong thư viện bên trái.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtWidgets import QPushButton, QTabBar
|
||||
from ...core import co4e
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon
|
||||
|
||||
|
||||
class Co4EFlowTabsMixin:
|
||||
def _open_flow(self, wf: co4e.Workflow) -> None:
|
||||
"""Open ``wf`` in a tab — reuse its tab if already open (like a browser),
|
||||
else add a new one and switch to it. Bar index 0 is the pinned Runs tab,
|
||||
so flow ``i`` lives at bar index ``i + 1``. If the flow has an active run,
|
||||
its live status is reflected on the canvas."""
|
||||
for i, f in enumerate(self._flows):
|
||||
if f.id == wf.id:
|
||||
self._flows[i] = wf
|
||||
bar_idx = i + 1
|
||||
self.flow_bar.setTabText(bar_idx, wf.name or tr("co4e.untitled"))
|
||||
if self.flow_bar.currentIndex() == bar_idx:
|
||||
self._active_flow_idx = -1 # force reload of same tab
|
||||
self._on_flow_tab_changed(bar_idx)
|
||||
else:
|
||||
self.flow_bar.setCurrentIndex(bar_idx)
|
||||
self._reflect_active_run(wf.id)
|
||||
return
|
||||
# Without the strip there is nowhere to switch between open flows, so
|
||||
# opening one REPLACES the one on the canvas (saved first, as the tab
|
||||
# switch used to do). Runs already in progress are unaffected — they are
|
||||
# tracked per flow id and keep going in the background.
|
||||
self._close_other_flows()
|
||||
self._flows.append(wf)
|
||||
self.flow_bar.blockSignals(True)
|
||||
bar_idx = self.flow_bar.addTab(icon("flow"), wf.name or tr("co4e.untitled"))
|
||||
self._add_tab_close_button(bar_idx)
|
||||
self.flow_bar.blockSignals(False)
|
||||
if self.flow_bar.currentIndex() == bar_idx:
|
||||
self._on_flow_tab_changed(bar_idx) # already current → load manually
|
||||
else:
|
||||
self.flow_bar.setCurrentIndex(bar_idx)
|
||||
self._reflect_active_run(wf.id)
|
||||
def _close_other_flows(self) -> None:
|
||||
"""Leave the canvas empty of flows, saving whatever was on it.
|
||||
|
||||
Called before opening a flow, because the tab strip that used to hold
|
||||
several at once is gone. Tab 0 (Runs) is never touched.
|
||||
"""
|
||||
if not self._flows:
|
||||
return
|
||||
if 0 <= self._active_flow_idx < len(self._flows):
|
||||
self._sync_wf_from_canvas()
|
||||
self.flow_bar.blockSignals(True)
|
||||
for idx in range(self.flow_bar.count() - 1, 0, -1):
|
||||
self.flow_bar.removeTab(idx)
|
||||
self.flow_bar.blockSignals(False)
|
||||
self._flows.clear()
|
||||
self._active_flow_idx = -1
|
||||
def _show_runs(self, on: bool) -> None:
|
||||
"""Swap the centre between the flow editor and the Runs table.
|
||||
|
||||
This is where the pinned "Runs" tab went when the strip was removed —
|
||||
same page, same table, reached from a toggle in the flow toolbar.
|
||||
"""
|
||||
target = 0 if on else min(1, self.flow_bar.count() - 1)
|
||||
if self.flow_bar.currentIndex() == target:
|
||||
self._on_flow_tab_changed(target) # already there → re-apply
|
||||
else:
|
||||
self.flow_bar.setCurrentIndex(target)
|
||||
def _on_flow_tab_changed(self, idx: int) -> None:
|
||||
# save the outgoing flow (active_flow_idx is a FLOWS-list index) first
|
||||
if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx:
|
||||
self._sync_wf_from_canvas()
|
||||
if idx <= 0: # the Runs page
|
||||
self._active_flow_idx = -1
|
||||
self.center_stack.setCurrentIndex(0)
|
||||
self._sync_runs_toggle(True)
|
||||
self._refresh_runs()
|
||||
return
|
||||
flow_idx = idx - 1
|
||||
if not (0 <= flow_idx < len(self._flows)):
|
||||
return
|
||||
self._active_flow_idx = flow_idx
|
||||
self.center_stack.setCurrentIndex(1)
|
||||
self._sync_runs_toggle(False)
|
||||
self._apply_workflow(self._flows[flow_idx])
|
||||
def _sync_runs_toggle(self, on: bool) -> None:
|
||||
"""Keep the Runs toggle showing which page is up, however it got there
|
||||
(a double-click in the runs table also switches pages)."""
|
||||
btn = getattr(self, "runs_btn", None)
|
||||
if btn is not None and btn.isChecked() != on:
|
||||
blocked = btn.blockSignals(True)
|
||||
btn.setChecked(on)
|
||||
btn.blockSignals(blocked)
|
||||
def _add_tab_close_button(self, idx: int) -> None:
|
||||
"""Give a flow tab its own close button — a small ✕ placed by QTabBar on
|
||||
the tab's right side, vertically centered and INSIDE the tab (reliable
|
||||
across themes, unlike the CSS-positioned default which looked detached)."""
|
||||
btn = QPushButton("×") # ×
|
||||
btn.setObjectName("flowTabClose")
|
||||
btn.setFlat(True)
|
||||
btn.setFixedSize(16, 16)
|
||||
btn.setCursor(Qt.PointingHandCursor)
|
||||
btn.clicked.connect(lambda: self._close_flow_tab_button(btn))
|
||||
self.flow_bar.setTabButton(idx, QTabBar.RightSide, btn)
|
||||
def _close_flow_tab_button(self, btn) -> None:
|
||||
for i in range(self.flow_bar.count()):
|
||||
if self.flow_bar.tabButton(i, QTabBar.RightSide) is btn:
|
||||
self._close_flow_tab(i)
|
||||
return
|
||||
def _close_flow_tab(self, idx: int) -> None:
|
||||
if idx <= 0: # Runs tab is pinned
|
||||
return
|
||||
flow_idx = idx - 1
|
||||
if not (0 <= flow_idx < len(self._flows)):
|
||||
return
|
||||
closing = self._flows[flow_idx]
|
||||
# Stop mirroring the closed flow's run onto the canvas — the run itself
|
||||
# keeps going in the background and stays in Flow Status. (Per-flow run
|
||||
# tracking: only this flow's entry is dropped; other flows keep running.)
|
||||
rid = self._flow_runs.pop(closing.id, None)
|
||||
if rid is not None:
|
||||
self._run_logs.pop(rid, None)
|
||||
if getattr(self, "_wf", None) is not None and self._wf.id == closing.id:
|
||||
self._manual_active = False
|
||||
self.run_btn.setText(tr("co4e.run"))
|
||||
self._flows.pop(flow_idx)
|
||||
self.flow_bar.blockSignals(True)
|
||||
self.flow_bar.removeTab(idx)
|
||||
self.flow_bar.blockSignals(False)
|
||||
self._active_flow_idx = -1
|
||||
if not self._flows:
|
||||
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
|
||||
else:
|
||||
new_bar = min(idx, len(self._flows)) # clamp to the last flow tab
|
||||
self.flow_bar.blockSignals(True)
|
||||
self.flow_bar.setCurrentIndex(new_bar)
|
||||
self.flow_bar.blockSignals(False)
|
||||
self._on_flow_tab_changed(new_bar)
|
||||
def _sync_active_flow_tab_text(self) -> None:
|
||||
i = self.flow_bar.currentIndex()
|
||||
if i >= 1: # never rename the Runs tab
|
||||
self.flow_bar.setTabText(i, self._wf.name or tr("co4e.untitled"))
|
||||
def _reflect_active_run(self, wf_id: str) -> None:
|
||||
"""If a run for this flow is active, mirror its live node statuses onto the
|
||||
canvas and keep tracking it so updates continue to show."""
|
||||
for h in self.manager.all_runs():
|
||||
if h.wf_id == wf_id and h.running:
|
||||
self._flow_runs[wf_id] = h.id
|
||||
for nid, st in h.node_status.items():
|
||||
self.canvas.update_node_status(nid, st)
|
||||
return
|
||||
def _cur_run_id(self) -> Optional[str]:
|
||||
"""The active canvas run of the CURRENTLY-shown flow, or None. Clears a
|
||||
stale entry if that run already finished."""
|
||||
wf = getattr(self, "_wf", None)
|
||||
if wf is None:
|
||||
return None
|
||||
rid = self._flow_runs.get(wf.id)
|
||||
if rid is None:
|
||||
return None
|
||||
h = self.manager.get(rid)
|
||||
if h is None or not h.running:
|
||||
self._flow_runs.pop(wf.id, None)
|
||||
return None
|
||||
return rid
|
||||
def _outputs_for(self, wf_id: str) -> Dict[str, str]:
|
||||
"""This flow's accumulated step outputs (kept separate per flow so parallel
|
||||
runs never seed each other's context)."""
|
||||
return self._flow_outputs.setdefault(wf_id, {})
|
||||
def _update_run_btn(self) -> None:
|
||||
self.run_btn.setText(tr("co4e.interrupt") if self._cur_run_id() is not None
|
||||
else tr("co4e.run"))
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Bố cục ba khung và bảng cấu hình node — R08-T09.
|
||||
|
||||
Co4E có bốn lớp điều hướng chồng nhau (dải flow, tab icon bên phải, bảng cấu
|
||||
hình, canvas). Phần quyết định cái nào hiện lúc nào nằm ở đây, tách khỏi phần
|
||||
hành vi để sửa bố cục không phải đọc logic chạy flow.
|
||||
|
||||
``_apply_narrow_layout`` là chỗ đáng chú ý: màn hẹp thì bảng cấu hình chuyển
|
||||
từ khung cố định sang lớp phủ, vì ba khung cạnh nhau không vừa 1280px.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTabWidget, QVBoxLayout, QWidget
|
||||
from ...core import co4e
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
from ...ui.co4e_canvas import Co4ECanvas
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.co4e_run_control_widget import RunsPagePanel
|
||||
|
||||
|
||||
class Co4ELayoutMixin:
|
||||
def _build_center(self) -> QWidget:
|
||||
from PySide6.QtWidgets import QStackedWidget, QTabBar
|
||||
page = QWidget()
|
||||
lay = QVBoxLayout(page)
|
||||
|
||||
# Flow tab bar: a pinned "Runs" tab first (manage every flow run), then a
|
||||
# browser-style tab per open flow — each keeps its own graph (no mixing).
|
||||
self.flow_bar = QTabBar()
|
||||
self.flow_bar.setObjectName("flowTabs")
|
||||
self.flow_bar.setTabsClosable(True)
|
||||
self.flow_bar.setMovable(True)
|
||||
self.flow_bar.setExpanding(False)
|
||||
self.flow_bar.setDrawBase(False)
|
||||
# No arrow scroll buttons — when the tabs overflow they scroll inside a
|
||||
# frameless horizontal scroller you drag left/right (see flow_row below).
|
||||
self.flow_bar.setUsesScrollButtons(False)
|
||||
# Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware,
|
||||
# flush, centred). Here we only style the per-tab close (✕) button, which
|
||||
# QTabBar places centred on the tab's right (see _add_tab_close_button).
|
||||
_fp = current_palette()
|
||||
self.flow_bar.setStyleSheet(
|
||||
"QPushButton#flowTabClose {"
|
||||
f" border: none; background: transparent; color: {_fp.text_muted};"
|
||||
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
|
||||
f" border-radius: {_fp.radius_sm}px; }}"
|
||||
"QPushButton#flowTabClose:hover {"
|
||||
f" background: {_fp.danger_soft}; color: {_fp.danger}; }}")
|
||||
runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs
|
||||
self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned
|
||||
self.flow_bar.currentChanged.connect(self._on_flow_tab_changed)
|
||||
self.flow_bar.tabCloseRequested.connect(self._close_flow_tab)
|
||||
# "+" new-flow button styled as the last tab in the strip (browser-style)
|
||||
# — the + glyph sits inside a tab-shaped button flush with the tabs.
|
||||
self.flow_add_btn = QPushButton("+")
|
||||
self.flow_add_btn.setObjectName("flowAddBtn")
|
||||
self.flow_add_btn.setFixedWidth(34)
|
||||
self.flow_add_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||||
self.flow_add_btn.clicked.connect(self._new_workflow)
|
||||
# Frameless horizontal scroller around the tab strip: overflowing tabs
|
||||
# scroll (drag) left/right instead of being boxed with arrow buttons.
|
||||
# The tab bar AND the "+" button are pinned to the SAME fixed height —
|
||||
# giving the scroll area extra height for its scrollbar (as a previous
|
||||
# version did) left the tabs top-anchored inside a taller box while the
|
||||
# "+" button centered across that whole (taller) box, so the two drifted
|
||||
# out of alignment. Same height on both = always aligned, no centering
|
||||
# math needed; the scrollbar only appears on overflow (rare) and briefly
|
||||
# overlaps the tab strip's bottom edge in that case.
|
||||
_tab_h = self.flow_bar.sizeHint().height()
|
||||
self.flow_bar.setFixedHeight(_tab_h)
|
||||
self.flow_add_btn.setFixedHeight(_tab_h)
|
||||
self.flow_scroll = QScrollArea()
|
||||
self.flow_scroll.setObjectName("flowTabScroll")
|
||||
self.flow_scroll.setWidget(self.flow_bar)
|
||||
self.flow_scroll.setWidgetResizable(True)
|
||||
self.flow_scroll.setFrameShape(QScrollArea.NoFrame) # no outer frame
|
||||
self.flow_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.flow_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.flow_scroll.setFixedHeight(_tab_h)
|
||||
self.flow_scroll.setStyleSheet(
|
||||
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
|
||||
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
|
||||
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
|
||||
f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}"
|
||||
"QScrollArea#flowTabScroll QScrollBar::add-line:horizontal,"
|
||||
"QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }")
|
||||
# The strip itself is NOT shown any more (see class docstring): flows are
|
||||
# picked from the WORKFLOWS list on the left, one open at a time. The
|
||||
# QTabBar stays alive off-screen as the index that maps flow ↔ canvas —
|
||||
# every open/close/rename path already goes through it — but the user
|
||||
# never sees or drives it.
|
||||
self.flow_scroll.setVisible(False)
|
||||
self.flow_add_btn.setVisible(False)
|
||||
|
||||
# Content switches between the Runs table (tab 0) and the flow editor.
|
||||
self.center_stack = QStackedWidget()
|
||||
lay.addWidget(self.center_stack, 1)
|
||||
self.center_stack.addWidget(self._build_runs_page()) # stack 0 = Runs
|
||||
|
||||
flow_page = QWidget()
|
||||
lay = QVBoxLayout(flow_page)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
bar = QHBoxLayout(); bar.setSpacing(5)
|
||||
self.name_edit = QLineEdit(self._wf.name)
|
||||
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
|
||||
self.name_edit.textChanged.connect(self._on_name_changed)
|
||||
# "Add" is a labelled button (not a "+" icon) so it isn't mistaken for
|
||||
# the zoom-in control, which now lives in the canvas's bottom-left overlay.
|
||||
self.add_step_btn = QPushButton(tr("co4e.add")); self.add_step_btn.setIcon(icon("plus"))
|
||||
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
|
||||
self.add_step_btn.clicked.connect(self._add_blank_step)
|
||||
self.save_btn = QPushButton(tr("co4e.save")); self.save_btn.setIcon(icon("save"))
|
||||
self.save_btn.setObjectName("primary")
|
||||
self.save_btn.setToolTip(tr("co4e.tt_save"))
|
||||
self.save_btn.clicked.connect(lambda: self._save(as_template=False))
|
||||
self.save_tpl_btn = self._icon_btn("star", "co4e.tt_save_template",
|
||||
lambda: self._save(as_template=True))
|
||||
self.mode_combo = QComboBox()
|
||||
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
|
||||
for m in co4e.RUN_MODES:
|
||||
self.mode_combo.addItem(tr(f"co4e.mode.{m}"), m)
|
||||
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
||||
self.run_btn = QPushButton(tr("co4e.run")); self.run_btn.setIcon(icon("play"))
|
||||
self.run_btn.setObjectName("primary")
|
||||
self.run_btn.setToolTip(tr("co4e.tt_run"))
|
||||
self.run_btn.clicked.connect(self._on_run_clicked)
|
||||
|
||||
# The pinned "Runs" tab lost its strip, so it becomes a toggle here —
|
||||
# one click to the run table and one click back, from either page.
|
||||
self.runs_btn = QPushButton(tr("co4e.runs_tab"))
|
||||
self.runs_btn.setIcon(icon("monitoring"))
|
||||
self.runs_btn.setCheckable(True)
|
||||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_btn.toggled.connect(self._show_runs)
|
||||
|
||||
bar.addWidget(QLabel(tr("co4e.flow_name")))
|
||||
bar.addWidget(self.name_edit, 1)
|
||||
bar.addWidget(self.add_step_btn)
|
||||
bar.addWidget(self.save_btn)
|
||||
bar.addWidget(self.save_tpl_btn)
|
||||
bar.addWidget(self.mode_combo)
|
||||
bar.addWidget(self.run_btn)
|
||||
bar.addWidget(self.runs_btn)
|
||||
lay.addLayout(bar)
|
||||
|
||||
self.canvas = Co4ECanvas()
|
||||
self._build_canvas_overlay()
|
||||
vsplit = QSplitter(Qt.Vertical)
|
||||
vsplit.addWidget(self.canvas)
|
||||
chat_widget = self._build_chat() # default-collapsed (see _build_chat)
|
||||
vsplit.addWidget(chat_widget)
|
||||
vsplit.setStretchFactor(0, 1)
|
||||
self._vsplit = vsplit # so the message panel can collapse/expand
|
||||
# Messages start collapsed — give the canvas the room from the start,
|
||||
# not the [540, 220] split that assumed an expanded chat box.
|
||||
collapsed_h = chat_widget.maximumHeight()
|
||||
vsplit.setSizes([max(0, 760 - collapsed_h), collapsed_h])
|
||||
lay.addWidget(vsplit, 1)
|
||||
self.center_stack.addWidget(flow_page) # stack 1 = flow editor
|
||||
self.center_stack.setCurrentIndex(1)
|
||||
return page
|
||||
def _build_runs_page(self) -> QWidget:
|
||||
"""The pinned 'Runs' tab: a table of every flow run (name · status · steps
|
||||
done/total · creator · created) for tracking. Double-click a run to open
|
||||
that flow's tab with its live status.
|
||||
|
||||
Widget construction lives in ``RunsPagePanel`` (presentation/co4e/
|
||||
co4e_run_control_widget.py); this method just wires the panel's public
|
||||
attributes to the handler methods that know about ``self`` (``_show_runs``,
|
||||
``_stop_selected_run``, ...) — the panel itself stays ignorant of ``Co4ETab``.
|
||||
"""
|
||||
panel = RunsPagePanel()
|
||||
self.runs_back_btn = panel.back_btn
|
||||
self.runs_back_btn.clicked.connect(lambda: self._show_runs(False))
|
||||
self.runs_title = panel.title_label
|
||||
self.ws_folder_btn = panel.ws_folder_btn
|
||||
self.ws_folder_btn.clicked.connect(self._open_workspace_folder)
|
||||
self._refresh_ws_folder_btn()
|
||||
self.run_stop_btn = panel.stop_btn
|
||||
self.run_stop_btn.clicked.connect(self._stop_selected_run)
|
||||
self.run_rename_btn = panel.rename_btn
|
||||
self.run_rename_btn.clicked.connect(self._rename_selected_run)
|
||||
self.run_del_btn = panel.del_btn
|
||||
self.run_del_btn.clicked.connect(self._delete_selected_run)
|
||||
self.run_clear_btn = panel.clear_btn
|
||||
self.run_clear_btn.clicked.connect(lambda: self.manager.clear_finished())
|
||||
self.runs_table = panel.table
|
||||
self.runs_table.itemDoubleClicked.connect(self._open_run_from_table)
|
||||
self.runs_table.customContextMenuRequested.connect(self._runs_context_menu)
|
||||
return panel
|
||||
def _wrap_config(self) -> QWidget:
|
||||
"""Wrap the step-config panel with a header that has an expand/collapse
|
||||
toggle, so it can be folded away to give the canvas more room."""
|
||||
container = QWidget()
|
||||
container.setObjectName("configContainer")
|
||||
v = QVBoxLayout(container)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
v.setSpacing(0)
|
||||
header = QWidget()
|
||||
hb = QHBoxLayout(header)
|
||||
hb.setContentsMargins(4, 3, 4, 3)
|
||||
hb.setSpacing(4)
|
||||
self.config_toggle_btn = QPushButton()
|
||||
self.config_toggle_btn.setIcon(icon("chevron-right"))
|
||||
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
|
||||
self.config_toggle_btn.setFixedSize(26, 24)
|
||||
self.config_toggle_btn.clicked.connect(self._toggle_config)
|
||||
self.config_title = QLabel(tr("co4e.config_title"))
|
||||
self.config_title.setObjectName("hint")
|
||||
hb.addWidget(self.config_toggle_btn)
|
||||
hb.addWidget(self.config_title, 1)
|
||||
v.addWidget(header)
|
||||
v.addWidget(self.config, 1)
|
||||
self._cfg_vlayout = v
|
||||
# Spacers used ONLY while collapsed, to keep the lone toggle icon
|
||||
# vertically CENTERED in the thin strip (its position no longer jumps to
|
||||
# the top after collapsing).
|
||||
self._cfg_top_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
|
||||
self._cfg_bot_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
|
||||
self.config_container = container
|
||||
return container
|
||||
def _apply_narrow_layout(self, narrow: bool) -> None: # noqa: D401
|
||||
"""Fold the step-config panel on a narrow window, restore it when there
|
||||
is room again.
|
||||
|
||||
Attached from __init__ rather than only on show: this page sits inside a
|
||||
QTabWidget, whose minimum width is the MAXIMUM over all its pages —
|
||||
including hidden ones. While Co4E sat unfolded in the background it was
|
||||
forcing Project and Cowork to be ~1180px wide too.
|
||||
"""
|
||||
if narrow != self._config_collapsed:
|
||||
self._toggle_config()
|
||||
def _toggle_config(self) -> None:
|
||||
self._config_collapsed = not self._config_collapsed
|
||||
v = self._cfg_vlayout
|
||||
if self._config_collapsed:
|
||||
w = self.config_container.width()
|
||||
if w > 60:
|
||||
self._config_expanded_w = w
|
||||
self.config.hide()
|
||||
self.config_title.hide()
|
||||
self.config_container.setMaximumWidth(34)
|
||||
self.config_toggle_btn.setIcon(icon("chevron-left"))
|
||||
self.config_toggle_btn.setToolTip(tr("co4e.tt_expand_config"))
|
||||
# center the toggle vertically in the collapsed strip
|
||||
v.insertItem(0, self._cfg_top_spacer)
|
||||
v.addItem(self._cfg_bot_spacer)
|
||||
# A maximumWidth alone doesn't make the splitter hand the freed width
|
||||
# to the canvas — set sizes explicitly so the panel folds to the right.
|
||||
sizes = self._split.sizes()
|
||||
if len(sizes) == 3:
|
||||
freed = sizes[2] - 34
|
||||
sizes[2] = 34
|
||||
sizes[1] = max(200, sizes[1] + freed)
|
||||
self._split.setSizes(sizes)
|
||||
# Without this the splitter keeps reporting the OLD minimum width,
|
||||
# and since a QTabWidget's minimum is the maximum over all its pages
|
||||
# — hidden ones included — Co4E would go on forcing Project and
|
||||
# Cowork to be 1180px wide even while folded here.
|
||||
self._refresh_min_width()
|
||||
else:
|
||||
v.removeItem(self._cfg_top_spacer)
|
||||
v.removeItem(self._cfg_bot_spacer)
|
||||
self.config_container.setMaximumWidth(16777215)
|
||||
self.config.show()
|
||||
self.config_title.show()
|
||||
self.config_toggle_btn.setIcon(icon("chevron-right"))
|
||||
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
|
||||
sizes = self._split.sizes()
|
||||
if len(sizes) == 3:
|
||||
want = self._config_expanded_w
|
||||
delta = want - sizes[2]
|
||||
sizes[2] = want
|
||||
sizes[1] = max(200, sizes[1] - delta)
|
||||
self._split.setSizes(sizes)
|
||||
self._refresh_min_width()
|
||||
def _refresh_min_width(self) -> None:
|
||||
"""Make the splitter (and everything above it) re-read its minimum."""
|
||||
self.config_container.updateGeometry()
|
||||
self._split.refresh()
|
||||
self._split.updateGeometry()
|
||||
self.updateGeometry()
|
||||
def _build_canvas_overlay(self) -> None:
|
||||
"""Zoom +/− and Fit as a small floating control at the canvas's
|
||||
bottom-left, stacked vertically. The frame is transparent (so it follows
|
||||
the dark/light theme — only the buttons carry a themed background) and the
|
||||
buttons are half-size."""
|
||||
from PySide6.QtCore import QSize
|
||||
|
||||
bar = QFrame()
|
||||
bar.setObjectName("canvasOverlay")
|
||||
bar.setStyleSheet("QFrame#canvasOverlay { background: transparent; border: none; }")
|
||||
v = QVBoxLayout(bar)
|
||||
v.setContentsMargins(2, 2, 2, 2)
|
||||
v.setSpacing(3)
|
||||
self.zoom_in_btn = self._icon_btn("plus", "co4e.tt_zoom_in", lambda: self.canvas.zoom_in())
|
||||
self.zoom_out_btn = self._icon_btn("minus", "co4e.tt_zoom_out", lambda: self.canvas.zoom_out())
|
||||
self.fit_btn = self._icon_btn("search", "co4e.fit_tooltip", lambda: self.canvas.fit_view())
|
||||
for b in (self.zoom_in_btn, self.zoom_out_btn, self.fit_btn):
|
||||
b.setFixedSize(16, 16) # ~half the previous size
|
||||
b.setIconSize(QSize(11, 11))
|
||||
b.setStyleSheet("QPushButton { padding: 0px; }") # keep themed bg, drop padding
|
||||
v.addWidget(b)
|
||||
self.canvas.add_overlay(bar)
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Chạy flow và bảng lịch sử lượt chạy — R08-T09.
|
||||
|
||||
Ba chế độ chạy: cả flow, một node, hoặc từng bước thủ công. ``_topo_order`` và
|
||||
``_downstream`` là phần đồ thị — chạy node nào trước, node nào phụ thuộc node
|
||||
nào.
|
||||
|
||||
``_on_manager_event`` là nơi mọi tín hiệu từ bộ chạy nền đổ về; nó dài vì phải
|
||||
phân nhánh theo loại sự kiện, không tách nhỏ được mà không làm khó đọc hơn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem
|
||||
from ...core import co4e
|
||||
from ...i18n import tr
|
||||
from ...theme import current_palette
|
||||
|
||||
|
||||
class Co4ERunsMixin:
|
||||
def _current_mode(self) -> str:
|
||||
return self.mode_combo.currentData() or "auto"
|
||||
def _on_mode_changed(self, *_a) -> None:
|
||||
# switching mode resets any in-progress manual sequence
|
||||
self._manual_active = False
|
||||
self._manual_order = []
|
||||
self._manual_idx = 0
|
||||
if self._cur_run_id() is None:
|
||||
self.run_btn.setText(tr("co4e.run"))
|
||||
def _on_run_clicked(self) -> None:
|
||||
# THIS flow's run is active → interrupt it (other flows keep running).
|
||||
cur = self._cur_run_id()
|
||||
if cur is not None:
|
||||
self.manager.stop(cur)
|
||||
return
|
||||
mode = self._current_mode()
|
||||
if mode == "manual":
|
||||
self._manual_run_or_advance()
|
||||
else:
|
||||
self._start_canvas_run(plan_mode=(mode == "plan"))
|
||||
def _start_canvas_run(self, *, plan_mode: bool, only: Optional[set] = None,
|
||||
seed: Optional[Dict[str, str]] = None) -> None:
|
||||
self._sync_wf_from_canvas()
|
||||
if not self._wf.nodes:
|
||||
self.status_message.emit(tr("co4e.no_steps"))
|
||||
return
|
||||
wf_id = self._wf.id
|
||||
if only is None:
|
||||
self.canvas.reset_statuses()
|
||||
self._outputs_for(wf_id).clear()
|
||||
self._plan_bubble = None
|
||||
self._append_chat("system", tr("co4e.run_started", name=self._wf.name))
|
||||
run_id = self.manager.start(
|
||||
self._wf, skill_map=self._skill_map(), plan_mode=plan_mode,
|
||||
only_nodes=only, seed_outputs=seed or dict(self._outputs_for(wf_id)))
|
||||
self._flow_runs[wf_id] = run_id # track THIS flow's run (parallel-safe)
|
||||
self._run_logs[run_id] = self.chat_log # route its events to THIS flow's log
|
||||
self.run_btn.setText(tr("co4e.interrupt"))
|
||||
def _run_single(self, node_id: str) -> None:
|
||||
"""Run one step (config panel "Run this step") with upstream context."""
|
||||
if self._cur_run_id() is not None:
|
||||
return
|
||||
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
|
||||
only={node_id}, seed=dict(self._outputs_for(self._wf.id)))
|
||||
def _run_from(self, node_id: str) -> None:
|
||||
if self._cur_run_id() is not None:
|
||||
return
|
||||
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
|
||||
only=self._downstream(node_id), seed=dict(self._outputs_for(self._wf.id)))
|
||||
def _downstream(self, node_id: str) -> set:
|
||||
adj: Dict[str, List[str]] = {}
|
||||
for e in self.canvas.edges():
|
||||
adj.setdefault(e.source, []).append(e.target)
|
||||
seen, stack = set(), [node_id]
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
if cur in seen:
|
||||
continue
|
||||
seen.add(cur)
|
||||
stack.extend(adj.get(cur, []))
|
||||
return seen
|
||||
def _manual_run_or_advance(self) -> None:
|
||||
if not self._manual_active:
|
||||
self._sync_wf_from_canvas()
|
||||
if not self._wf.nodes:
|
||||
self.status_message.emit(tr("co4e.no_steps"))
|
||||
return
|
||||
self.canvas.reset_statuses()
|
||||
self._outputs_for(self._wf.id).clear()
|
||||
self._plan_bubble = None
|
||||
self._manual_order = self._topo_order()
|
||||
self._manual_idx = 0
|
||||
self._manual_active = True
|
||||
self._append_chat("system", tr("co4e.manual_started", name=self._wf.name))
|
||||
self._manual_step()
|
||||
def _manual_step(self) -> None:
|
||||
if self._manual_idx >= len(self._manual_order):
|
||||
self._manual_active = False
|
||||
self.run_btn.setText(tr("co4e.run"))
|
||||
self._append_chat("system", tr("co4e.run_done"))
|
||||
return
|
||||
nid = self._manual_order[self._manual_idx]
|
||||
label = next((n.data.label for n in self.canvas.nodes() if n.id == nid), nid)
|
||||
self._append_chat("system", tr("co4e.manual_step",
|
||||
i=self._manual_idx + 1, n=len(self._manual_order), label=label))
|
||||
run_id = self.manager.start(
|
||||
self._wf, skill_map=self._skill_map(),
|
||||
plan_mode=False, only_nodes={nid}, seed_outputs=dict(self._outputs_for(self._wf.id)),
|
||||
manual=True)
|
||||
self._flow_runs[self._wf.id] = run_id
|
||||
self._run_logs[run_id] = self.chat_log
|
||||
self.run_btn.setText(tr("co4e.interrupt"))
|
||||
def _topo_order(self) -> List[str]:
|
||||
nodes = self.canvas.nodes()
|
||||
edges = self.canvas.edges()
|
||||
waves = co4e.compute_waves(nodes, edges)
|
||||
y = {n.id: n.y for n in nodes}
|
||||
return sorted((n.id for n in nodes), key=lambda nid: (waves.get(nid, 0), y.get(nid, 0)))
|
||||
def _on_manager_event(self, run_id: str, ev: dict) -> None:
|
||||
# Per-flow routing: every run's events go to ITS OWN flow log (so parallel
|
||||
# runs never mix), and the canvas mirrors ONLY the run whose flow is the
|
||||
# one currently shown. Flow Status refreshes on its own via `changed`.
|
||||
h = self.manager.get(run_id)
|
||||
run_wf = h.wf_id if h is not None else None
|
||||
log = self._run_logs.get(run_id) or self.chat_log
|
||||
shown = getattr(self, "_wf", None) is not None and run_wf == self._wf.id
|
||||
t = ev.get("type")
|
||||
if t == "node_status":
|
||||
if shown:
|
||||
self.canvas.update_node_status(ev.get("node_id"), ev.get("status"))
|
||||
elif t == "node_output":
|
||||
if run_wf is not None:
|
||||
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
|
||||
label = ev["node_id"]
|
||||
if shown:
|
||||
label = next((n.data.label for n in self.canvas.nodes() if n.id == ev["node_id"]),
|
||||
ev["node_id"])
|
||||
elif h is not None and h.wf is not None:
|
||||
label = next((n.data.label for n in h.wf.nodes if n.id == ev["node_id"]), ev["node_id"])
|
||||
if ev.get("output"):
|
||||
bub = self._append_chat("assistant", f"**{label}**\n\n{ev['output']}", log=log)
|
||||
# Per-message token/cost footer (↓in ↑out ▤ctx $cost), like Cowork.
|
||||
self._apply_usage(bub, run_wf, ev.get("usage"))
|
||||
elif t == "node_diff":
|
||||
self._append_diff(ev.get("title", ""), ev.get("diff", ""), log=log)
|
||||
elif t == "node_plan":
|
||||
self._append_plan(ev.get("steps") or [], log=log)
|
||||
elif t == "node_tool":
|
||||
if not ev.get("ok", True):
|
||||
# A single failed tool call isn't a step failure — the agent is told
|
||||
# to recover and continue, so show it as a neutral notice (not a red
|
||||
# "Error" that reads like the whole flow crashed).
|
||||
self._append_chat("system", "⚠ " + tr("co4e.tool_failed", name=ev.get("name", "")), log=log)
|
||||
elif t in ("run_done", "run_error"):
|
||||
# Drop THIS flow's run tracking (other flows keep running in parallel).
|
||||
if run_wf is not None and self._flow_runs.get(run_wf) == run_id:
|
||||
self._flow_runs.pop(run_wf, None)
|
||||
self._run_logs.pop(run_id, None)
|
||||
if self._manual_active and shown:
|
||||
self._manual_idx += 1
|
||||
self._manual_step()
|
||||
else:
|
||||
if shown:
|
||||
self.run_btn.setText(tr("co4e.run"))
|
||||
self._append_chat("system", tr("co4e.run_done"), log=log)
|
||||
# Clickable link to the output folder so files are one click away.
|
||||
out = (h.out_dir if h is not None and h.out_dir else "") or str(self._flow_output_root())
|
||||
try:
|
||||
log.add_folder_link(out, tr("co4e.open_output_link"))
|
||||
log.scroll_to_bottom()
|
||||
except Exception: # noqa: BLE001 - link is a nicety, never fatal
|
||||
pass
|
||||
self._notify_run_finished(run_id) # popup: the flow finished
|
||||
if not shown and h is not None:
|
||||
self.status_message.emit(tr("co4e.bg_done", name=h.name, status=h.status))
|
||||
def _notify_run_finished(self, run_id: str) -> None:
|
||||
"""Show a non-blocking popup when a flow finishes (done / error / stopped),
|
||||
so the user is notified even if they're on another screen."""
|
||||
h = self.manager.get(run_id)
|
||||
if h is None:
|
||||
return
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
if not hasattr(self, "_run_popups"):
|
||||
self._run_popups = []
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Warning if h.status == "error" else QMessageBox.Information)
|
||||
box.setWindowTitle(tr("co4e.run_done_title"))
|
||||
box.setText(tr("co4e.run_done_popup", name=h.name,
|
||||
status=tr("co4e.status." + h.status)))
|
||||
box.setStandardButtons(QMessageBox.Ok)
|
||||
box.setModal(False) # non-blocking notification
|
||||
box.setAttribute(Qt.WA_DeleteOnClose, True)
|
||||
box.finished.connect(
|
||||
lambda _r=0, b=box: self._run_popups.remove(b) if b in self._run_popups else None)
|
||||
self._run_popups.append(box) # keep a ref so it isn't GC'd
|
||||
box.show()
|
||||
def _refresh_runs(self) -> None:
|
||||
# Rebuild the always-fresh Runs table from the manager (single source of truth).
|
||||
if not hasattr(self, "runs_table"):
|
||||
return
|
||||
p = current_palette()
|
||||
color = {"running": p.accent, "done": p.success, "error": p.danger,
|
||||
"stopped": p.text_muted}
|
||||
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
|
||||
# Most-recent run at the TOP, oldest at the bottom (manager keeps runs in
|
||||
# chronological insertion order, so reverse it for display).
|
||||
runs = list(reversed(self.manager.runs()))
|
||||
t = self.runs_table
|
||||
# Preserve the selected run across the rebuild by its id (row indices shift
|
||||
# as runs are added/deleted, so a row-index restore would jump).
|
||||
sel_item = t.item(t.currentRow(), 0) if t.currentRow() >= 0 else None
|
||||
sel_id = sel_item.data(Qt.UserRole) if sel_item is not None else None
|
||||
t.setRowCount(len(runs))
|
||||
sel_row = -1
|
||||
for r, h in enumerate(runs):
|
||||
vals = [f"{dots.get(h.status, '•')} {h.name}", tr("co4e.status." + h.status),
|
||||
h.progress_text(), h.created_by or "-", h.created_at or "-"]
|
||||
for c, val in enumerate(vals):
|
||||
it = QTableWidgetItem(str(val))
|
||||
if c == 0:
|
||||
it.setData(Qt.UserRole, h.id)
|
||||
if c == 1:
|
||||
it.setForeground(_qcolor(color.get(h.status, p.text)))
|
||||
t.setItem(r, c, it)
|
||||
if h.id == sel_id:
|
||||
sel_row = r
|
||||
if sel_row >= 0:
|
||||
t.setCurrentCell(sel_row, 0)
|
||||
# The sidebar's short run list is the same data — refresh it together.
|
||||
self._refresh_side_runs()
|
||||
# Active-run count, on the sidebar heading now that the tab strip is gone.
|
||||
n = self.manager.active_count()
|
||||
label = tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab")
|
||||
if hasattr(self, "flow_bar"):
|
||||
self.flow_bar.setTabText(0, label)
|
||||
head = (self._sections.get("co4e.runs_tab") or (None,))[0]
|
||||
if head is not None:
|
||||
head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper())
|
||||
def _stop_selected_run(self) -> None:
|
||||
row = self.runs_table.currentRow()
|
||||
it = self.runs_table.item(row, 0) if row >= 0 else None
|
||||
if it is None:
|
||||
self.manager.stop_all()
|
||||
return
|
||||
self.manager.stop(it.data(Qt.UserRole))
|
||||
def _delete_selected_run(self) -> None:
|
||||
"""Delete the selected run from the Flow Status history (a running one is
|
||||
stopped first). Removes just that single entry."""
|
||||
row = self.runs_table.currentRow()
|
||||
it = self.runs_table.item(row, 0) if row >= 0 else None
|
||||
if it is None:
|
||||
self.status_message.emit(tr("co4e.select_run"))
|
||||
return
|
||||
run_id = it.data(Qt.UserRole)
|
||||
h = self.manager.get(run_id) # stop tracking it per-flow if we were
|
||||
if h is not None and self._flow_runs.get(h.wf_id) == run_id:
|
||||
self._flow_runs.pop(h.wf_id, None)
|
||||
self._run_logs.pop(run_id, None)
|
||||
self.manager.remove(run_id) # emits `changed` → _refresh_runs
|
||||
def _runs_context_menu(self, pos) -> None:
|
||||
from PySide6.QtWidgets import QMenu
|
||||
item = self.runs_table.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
self.runs_table.selectRow(item.row())
|
||||
menu = QMenu(self)
|
||||
menu.addAction(tr("co4e.open_run"),
|
||||
lambda: self._open_run_from_table(self.runs_table.item(item.row(), 0)))
|
||||
it0 = self.runs_table.item(item.row(), 0)
|
||||
rid = it0.data(Qt.UserRole) if it0 is not None else None
|
||||
menu.addAction(tr("co4e.open_output"), lambda: self._open_run_output_folder(rid))
|
||||
menu.addAction(tr("co4e.rename_run"), self._rename_selected_run)
|
||||
menu.addAction(tr("co4e.delete_run"), self._delete_selected_run)
|
||||
menu.exec(self.runs_table.viewport().mapToGlobal(pos))
|
||||
def _open_run_output_folder(self, run_id) -> None:
|
||||
"""Open the workspace folder a specific run wrote its files into."""
|
||||
from ...ui.osutil import open_location
|
||||
h = self.manager.get(run_id) if run_id else None
|
||||
path = Path(h.out_dir) if (h is not None and h.out_dir) else self._flow_output_root()
|
||||
if not path.exists():
|
||||
path = self._flow_output_root()
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
open_location(str(path))
|
||||
def _rename_selected_run(self) -> None:
|
||||
"""Rename the selected run in Flow Status — updates the run entry AND its
|
||||
underlying saved flow / open tab so the name stays consistent everywhere."""
|
||||
row = self.runs_table.currentRow()
|
||||
it = self.runs_table.item(row, 0) if row >= 0 else None
|
||||
if it is None:
|
||||
self.status_message.emit(tr("co4e.select_run"))
|
||||
return
|
||||
run_id = it.data(Qt.UserRole)
|
||||
h = self.manager.get(run_id)
|
||||
if h is None:
|
||||
return
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
|
||||
tr("co4e.rename_run_label"), text=h.name)
|
||||
new = (new or "").strip()
|
||||
if not ok or not new or new == h.name:
|
||||
return
|
||||
self.manager.rename(run_id, new) # run entry + snapshot (→ refresh)
|
||||
# Keep the underlying saved flow + any open tab in sync.
|
||||
wf = co4e.get_workflow(h.wf_id)
|
||||
if wf is not None:
|
||||
wf.name = new
|
||||
co4e.save_workflow(wf)
|
||||
self._reload_sidebar()
|
||||
for i, f in enumerate(self._flows):
|
||||
if f.id == h.wf_id:
|
||||
f.name = new
|
||||
self.flow_bar.setTabText(i + 1, new)
|
||||
break
|
||||
if self._wf.id == h.wf_id and self.name_edit.text() != new:
|
||||
self.name_edit.setText(new) # updates _wf.name + active tab text
|
||||
def _run_selected_in_background(self) -> None:
|
||||
wf = self._selected_wf()
|
||||
if wf is None:
|
||||
self.status_message.emit(tr("co4e.select_flow"))
|
||||
return
|
||||
self.manager.start(wf, skill_map=self._skill_map(),
|
||||
plan_mode=(self._current_mode() == "plan"))
|
||||
# Used to jump the sidebar back to the Workflows tab; with one column
|
||||
# there is nothing to jump to — show the run that just started instead.
|
||||
self._refresh_side_runs()
|
||||
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
|
||||
def _rerun_run_item(self, item) -> None:
|
||||
"""Double-click a run in the history → run that flow again (in background)."""
|
||||
h = self.manager.get(item.data(Qt.UserRole))
|
||||
if h is None:
|
||||
return
|
||||
wf = self._wf_by_id(h.wf_id)
|
||||
if wf is None:
|
||||
self.status_message.emit(tr("co4e.flow_gone"))
|
||||
return
|
||||
self.manager.start(wf, skill_map=self._skill_map(),
|
||||
plan_mode=(self._current_mode() == "plan"))
|
||||
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
|
||||
def _open_run_from_table(self, item) -> None:
|
||||
"""Double-click a run row in the Runs tab → open that flow's tab and show
|
||||
its live status (opens/focuses the tab; _open_flow reflects the run)."""
|
||||
id_item = self.runs_table.item(item.row(), 0)
|
||||
if id_item is None:
|
||||
return
|
||||
h = self.manager.get(id_item.data(Qt.UserRole))
|
||||
if h is None:
|
||||
return
|
||||
# Prefer the flow the run kept a reference to (works even after its tab was
|
||||
# closed or if it was never saved); fall back to resolving by id.
|
||||
wf = getattr(h, "wf", None) or self._wf_by_id(h.wf_id)
|
||||
if wf is None:
|
||||
self.status_message.emit(tr("co4e.flow_gone"))
|
||||
return
|
||||
self._open_flow(wf)
|
||||
# reflect this run's step statuses (done/error/running) on the canvas
|
||||
for nid, st in h.node_status.items():
|
||||
self.canvas.update_node_status(nid, st)
|
||||
self.status_message.emit(tr("co4e.viewing_flow", name=wf.name))
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Cột trái: thư viện workflow, agent, skill — R08-T09.
|
||||
|
||||
Bốn mục gập được (WORKFLOWS / AGENTS / SKILLS / FLOW STATUS). Trạng thái gập
|
||||
của từng mục là thứ người dùng đặt rồi mong nó giữ nguyên, nên nó nằm trong
|
||||
cấu hình chứ không phải trong widget.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget
|
||||
from ...core import co4e, skills as skills_mod
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.agent_list_panel import AgentListPanel
|
||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||
from ...presentation.co4e.palette_list import _PaletteList
|
||||
from ...presentation.co4e.skills_list_panel import SkillsListPanel
|
||||
|
||||
|
||||
class Co4ESidebarMixin:
|
||||
def _build_sidebar(self) -> QWidget:
|
||||
# ONE COLUMN, four named sections — no icon tabs. Every list is on screen
|
||||
# at once, so "what can I drag onto the canvas" is answered by looking
|
||||
# rather than by clicking through three unlabeled tabs.
|
||||
# A vertical splitter, not a fixed stack: on a short window four stacked
|
||||
# lists otherwise squeeze down to one visible row each. The splitter
|
||||
# hands out the available height by weight and lets the user re-balance
|
||||
# it by dragging; each list keeps a small minimum so none disappears.
|
||||
self._sections: dict = {}
|
||||
self.sidebar = QWidget()
|
||||
outer_col = QVBoxLayout(self.sidebar)
|
||||
outer_col.setContentsMargins(6, 6, 6, 6)
|
||||
outer_col.setSpacing(0)
|
||||
self.side_split = QSplitter(Qt.Vertical)
|
||||
self.side_split.setChildrenCollapsible(False)
|
||||
self.side_split.setHandleWidth(8)
|
||||
outer_col.addWidget(self.side_split, 1)
|
||||
|
||||
class _Col:
|
||||
"""Adapter so the section builders below read the same as before."""
|
||||
|
||||
def __init__(self, split):
|
||||
self._split = split
|
||||
|
||||
def addWidget(self, w, stretch=1):
|
||||
self._split.addWidget(w)
|
||||
self._split.setStretchFactor(self._split.count() - 1, stretch)
|
||||
|
||||
col = _Col(self.side_split)
|
||||
|
||||
# --- WORKFLOWS ---------------------------------------------------
|
||||
self.wf_new_btn = QPushButton(tr("co4e.new"))
|
||||
self.wf_new_btn.setIcon(icon("plus"))
|
||||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||||
self.wf_new_btn.setObjectName("co4eSectionAction")
|
||||
self.wf_new_btn.setFlat(True)
|
||||
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.wf_new_btn.clicked.connect(self._new_workflow)
|
||||
wf_body = QWidget(); wl = QVBoxLayout(wf_body)
|
||||
wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4)
|
||||
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
|
||||
# double-click loads it onto the canvas.
|
||||
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
|
||||
self.wf_list.setToolTip(tr("co4e.drag_hint"))
|
||||
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
|
||||
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
|
||||
wl.addWidget(self.wf_list, 1)
|
||||
wf_btns = QHBoxLayout(); wf_btns.setSpacing(4)
|
||||
self.wf_edit_btn = self._icon_btn("edit", "co4e.tt_edit_wf", self._edit_selected_workflow)
|
||||
self.wf_dup_btn = self._icon_btn("branch", "co4e.tt_dup_wf", self._duplicate_selected_workflow)
|
||||
self.wf_del_btn = self._icon_btn("trash", "co4e.tt_del_wf", self._delete_selected_workflow)
|
||||
for b in (self.wf_edit_btn, self.wf_dup_btn, self.wf_del_btn):
|
||||
wf_btns.addWidget(b)
|
||||
wf_btns.addStretch(1)
|
||||
wl.addLayout(wf_btns)
|
||||
# Its own row: sharing one line with the three icon buttons cut "Chạy
|
||||
# nền" down to "Chạ" as soon as the sidebar hit its narrow width.
|
||||
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play"))
|
||||
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg"))
|
||||
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
|
||||
wl.addWidget(self.wf_runbg_btn)
|
||||
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
|
||||
|
||||
# --- AGENTS ------------------------------------------------------
|
||||
# Widget cua khu vuc nay da doi sang AgentListPanel (xem
|
||||
# presentation/co4e/agent_list_panel.py); o day chi con giu
|
||||
# ag_new_btn/agent_list/ag_edit_btn/ag_del_btn nhu 4 ten thuoc tinh cu
|
||||
# va tu noi signal - dung nguyen tac panel khong tu wire, Co4ETab moi
|
||||
# biet _new_agent/_edit_agent/_delete_agent.
|
||||
self._agent_panel = AgentListPanel()
|
||||
self.ag_new_btn = self._agent_panel.new_btn
|
||||
self.ag_new_btn.clicked.connect(self._new_agent)
|
||||
self.agent_list = self._agent_panel.list_widget
|
||||
self.ag_edit_btn = self._agent_panel.edit_btn
|
||||
self.ag_edit_btn.clicked.connect(self._edit_agent)
|
||||
self.ag_del_btn = self._agent_panel.del_btn
|
||||
self.ag_del_btn.clicked.connect(self._delete_agent)
|
||||
col.addWidget(self._section("co4e.tab_agents", self._agent_panel, self.ag_new_btn), 3)
|
||||
|
||||
# --- SKILLS ------------------------------------------------------
|
||||
# Widget cua khu vuc nay da doi sang SkillsListPanel (xem
|
||||
# presentation/co4e/skills_list_panel.py); o day chi con giu
|
||||
# sk_manage_btn/skill_list nhu 2 ten thuoc tinh cu va tu noi signal -
|
||||
# dung nguyen tac panel khong tu wire, Co4ETab moi biet _manage_skills.
|
||||
self._skills_panel = SkillsListPanel()
|
||||
self.sk_manage_btn = self._skills_panel.manage_btn
|
||||
self.sk_manage_btn.clicked.connect(self._manage_skills)
|
||||
self.skill_list = self._skills_panel.list_widget
|
||||
col.addWidget(self._section("co4e.tab_skills", self._skills_panel, self.sk_manage_btn), 2)
|
||||
|
||||
# --- RUNS --------------------------------------------------------
|
||||
# A short, always-visible view of the same runs the Flow Status page
|
||||
# tables in full. Clicking one opens that page with the run selected.
|
||||
# Icon only: the heading beside it already reads FLOW STATUS, and the
|
||||
# label was long enough to be cut in half in a narrow sidebar.
|
||||
self.runs_more_btn = QPushButton()
|
||||
self.runs_more_btn.setIcon(icon("chevron-right"))
|
||||
self.runs_more_btn.setFixedWidth(30)
|
||||
self.runs_more_btn.setFlat(True)
|
||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
|
||||
runs_body = QWidget(); rl = QVBoxLayout(runs_body)
|
||||
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
|
||||
self.runs_side_list = QListWidget()
|
||||
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
|
||||
rl.addWidget(self.runs_side_list, 1)
|
||||
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
|
||||
# Small enough that all four still fit on a laptop screen, large enough
|
||||
# that each shows more than a single row.
|
||||
for lst in (self.wf_list, self.agent_list, self.skill_list, self.runs_side_list):
|
||||
lst.setMinimumHeight(56)
|
||||
return self.sidebar
|
||||
def _refresh_side_runs(self) -> None:
|
||||
"""Mirror the newest runs into the sidebar's short list."""
|
||||
lst = getattr(self, "runs_side_list", None)
|
||||
if lst is None:
|
||||
return
|
||||
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
|
||||
lst.clear()
|
||||
for h in list(reversed(self.manager.runs()))[:self._SIDE_RUNS]:
|
||||
it = QListWidgetItem(f"{dots.get(h.status, '•')} {h.name}"
|
||||
f" {h.progress_text()}")
|
||||
it.setData(Qt.UserRole, h.id)
|
||||
it.setToolTip(f"{h.name} · {tr('co4e.status.' + h.status)} · {h.created_at or '-'}")
|
||||
lst.addItem(it)
|
||||
def _on_side_run_clicked(self, item) -> None:
|
||||
"""Open the full Flow Status page with this run selected."""
|
||||
run_id = item.data(Qt.UserRole)
|
||||
self._show_runs(True)
|
||||
for r in range(self.runs_table.rowCount()):
|
||||
cell = self.runs_table.item(r, 0)
|
||||
if cell is not None and cell.data(Qt.UserRole) == run_id:
|
||||
self.runs_table.setCurrentCell(r, 0)
|
||||
break
|
||||
def _section(self, key: str, body: QWidget, action: QPushButton | None = None,
|
||||
stretch: int = 1) -> QWidget:
|
||||
"""One named, foldable section of the sidebar column.
|
||||
|
||||
Replaces the three icon-only tabs: all the lists are visible at once
|
||||
(WORKFLOWS · AGENTS · SKILLS · runs), each under its own heading with the
|
||||
action that belongs to it. Clicking the heading folds the section, so a
|
||||
narrow window can still get to everything.
|
||||
"""
|
||||
box = QWidget()
|
||||
v = QVBoxLayout(box)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
v.setSpacing(2)
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
head = QPushButton()
|
||||
head.setObjectName("co4eSectionHdr")
|
||||
head.setCheckable(True)
|
||||
head.setChecked(True)
|
||||
head.setCursor(Qt.PointingHandCursor)
|
||||
head.setFlat(True)
|
||||
head.toggled.connect(lambda on, w=body, b=box: self._fold_section(key, w, b, on))
|
||||
row.addWidget(head, 1)
|
||||
if action is not None:
|
||||
row.addWidget(action, 0)
|
||||
v.addLayout(row)
|
||||
v.addWidget(body, 1)
|
||||
|
||||
self._sections[key] = (head, body, stretch)
|
||||
self._sync_section_arrow(key)
|
||||
return box
|
||||
def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None:
|
||||
"""Fold/unfold a section AND give its height back to the others.
|
||||
|
||||
Inside a splitter, hiding the body is not enough — the pane keeps its
|
||||
share of the height, so folding would free nothing. Clamping the whole
|
||||
section to its header height makes the splitter re-deal the space.
|
||||
"""
|
||||
body.setVisible(on)
|
||||
if on:
|
||||
box.setMaximumHeight(16777215)
|
||||
else:
|
||||
box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4)
|
||||
self._sync_section_arrow(key)
|
||||
def _sync_section_arrow(self, key: str) -> None:
|
||||
head, _body, _s = self._sections[key]
|
||||
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
|
||||
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
|
||||
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
|
||||
b.setFixedWidth(34)
|
||||
b.clicked.connect(slot)
|
||||
return b
|
||||
def _reload_sidebar(self) -> None:
|
||||
self.wf_list.clear()
|
||||
for wf in co4e.list_workflows():
|
||||
tag = tr("co4e.template") if wf.is_template else tr("co4e.saved")
|
||||
it = QListWidgetItem(icon("workspaces"), f"{wf.name} · {tag}")
|
||||
it.setData(Qt.UserRole, ("saved", wf.id))
|
||||
it.setData(Qt.UserRole + 2, {"kind": "workflow", "workflow": co4e.workflow_to_dict(wf)})
|
||||
self.wf_list.addItem(it)
|
||||
# Agents: only the Parallel fan-out node + the user's own custom agents
|
||||
# (create your own with "+ New agent"; drag onto the canvas). The blank
|
||||
# "New Step" palette entry was removed — use the toolbar "+ Add" instead.
|
||||
self.agent_list.clear()
|
||||
self.agent_list.addItem(self._palette_item(
|
||||
tr("co4e.parallel_node"), "server",
|
||||
{"variant": "parallel", "label": "Parallel", "role": "PARALLEL", "icon": "server",
|
||||
"sub_agents": []}))
|
||||
for ca in co4e.list_custom_agents():
|
||||
step = co4e.Step(label=ca.name, agent_slug=co4e.slugify(ca.name), role=ca.role or "AGENT",
|
||||
icon=ca.icon, instructions=ca.instructions,
|
||||
context=getattr(ca, "context", ""), model=ca.model,
|
||||
permission_preset=ca.permission_preset, skills=list(ca.skills),
|
||||
attachments=list(getattr(ca, "attachments", []) or []))
|
||||
it = self._palette_item(f"{ca.name} · {ca.role} · {tr('co4e.custom')}", ca.icon or "robot",
|
||||
co4e._step_dict(step))
|
||||
it.setData(Qt.UserRole + 1, ca.id)
|
||||
self.agent_list.addItem(it)
|
||||
# Skills
|
||||
self.skill_list.clear()
|
||||
for name in _skill_names():
|
||||
content = skills_mod.skill_prefix_for(name)
|
||||
payload = co4e._step_dict(co4e.Step(
|
||||
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
|
||||
instructions=content, skills=[name]))
|
||||
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
|
||||
@staticmethod
|
||||
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
|
||||
it = QListWidgetItem(icon(icon_name), text)
|
||||
it.setData(Qt.UserRole, payload)
|
||||
return it
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tạo, sửa, đổi tên, xoá, nhân bản workflow — R08-T09.
|
||||
|
||||
Chỉ thao tác trên danh sách. Phần chạy một workflow nằm ở ``co4e_runs.py``,
|
||||
phần vẽ node nằm ở canvas.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QInputDialog, QMenu
|
||||
from ...core import co4e
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||
|
||||
|
||||
class Co4EWorkflowCrudMixin:
|
||||
def _apply_workflow(self, wf: co4e.Workflow) -> None:
|
||||
self._wf = wf
|
||||
# Per-flow outputs are kept in self._flow_outputs[wf.id] — do NOT clear
|
||||
# here (switching tabs must not wipe another flow's accumulated context).
|
||||
# Switch the visible conversation to THIS flow's own log.
|
||||
self.chat_stack.setCurrentWidget(self._ensure_flow_log(wf.id))
|
||||
self.name_edit.setText(wf.name)
|
||||
self.canvas.load(wf.nodes, wf.edges)
|
||||
self.config.clear_step()
|
||||
if wf.nodes:
|
||||
self.canvas.relayout_if_vertical() # convert old top-down flows to left→right
|
||||
self.canvas.fit_view()
|
||||
self._update_run_btn() # reflect THIS flow's run state
|
||||
self._refresh_usage_total() # show THIS flow's token/cost total
|
||||
def _new_workflow(self) -> None:
|
||||
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
|
||||
# Pressing this while the canvas already holds an empty untitled flow
|
||||
# produced an identical empty untitled flow — correct, and completely
|
||||
# invisible, so the button read as broken. Say what happened and put the
|
||||
# cursor where the next thing to do is: naming it.
|
||||
self.name_edit.setFocus()
|
||||
self.name_edit.selectAll()
|
||||
self.status_message.emit(tr("co4e.new_flow_ready"))
|
||||
def _selected_wf(self) -> Optional[co4e.Workflow]:
|
||||
"""Materialise the selected saved-flow row into a Workflow."""
|
||||
item = self.wf_list.currentItem()
|
||||
if item is None:
|
||||
return None
|
||||
_kind, ident = item.data(Qt.UserRole)
|
||||
return co4e.get_workflow(ident)
|
||||
def _load_selected_workflow(self, *_a) -> None:
|
||||
wf = self._selected_wf()
|
||||
if wf is not None:
|
||||
self._open_flow(wf) # open (or focus) its browser-style tab
|
||||
def _edit_selected_workflow(self) -> None:
|
||||
wf = self._selected_wf()
|
||||
if wf is None:
|
||||
self.status_message.emit(tr("co4e.select_flow"))
|
||||
return
|
||||
self._open_flow(wf)
|
||||
def _duplicate_selected_workflow(self) -> None:
|
||||
wf = self._selected_wf()
|
||||
if wf is None:
|
||||
self.status_message.emit(tr("co4e.select_flow"))
|
||||
return
|
||||
dup = co4e.duplicate_workflow(wf)
|
||||
self._reload_sidebar()
|
||||
self.status_message.emit(tr("co4e.duplicated_msg", name=dup.name))
|
||||
def _wf_context_menu(self, pos) -> None:
|
||||
lw = self.wf_list
|
||||
item = lw.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
lw.setCurrentItem(item)
|
||||
_kind, ident = item.data(Qt.UserRole)
|
||||
menu = QMenu(lw)
|
||||
act_edit = menu.addAction(icon("edit"), tr("co4e.edit"))
|
||||
act_rename = menu.addAction(icon("edit"), tr("co4e.rename"))
|
||||
act_dup = menu.addAction(icon("branch"), tr("co4e.duplicate"))
|
||||
act_run = menu.addAction(icon("play"), tr("co4e.run_bg"))
|
||||
act_del = menu.addAction(icon("trash"), tr("co4e.delete"))
|
||||
chosen = menu.exec(lw.viewport().mapToGlobal(pos))
|
||||
if chosen is act_edit:
|
||||
self._edit_selected_workflow()
|
||||
elif chosen is act_rename:
|
||||
self._rename_workflow(ident)
|
||||
elif chosen is act_dup:
|
||||
self._duplicate_selected_workflow()
|
||||
elif chosen is act_run:
|
||||
self._run_selected_in_background()
|
||||
elif chosen is act_del:
|
||||
self._delete_selected_workflow()
|
||||
def _rename_workflow(self, ident: str) -> None:
|
||||
"""Rename a saved flow in place (e.g. to match its function/task)."""
|
||||
wf = co4e.get_workflow(ident)
|
||||
if wf is None:
|
||||
return
|
||||
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
|
||||
text=wf.name)
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
wf.name = name
|
||||
co4e.save_workflow(wf)
|
||||
if self._wf.id == ident:
|
||||
self.name_edit.setText(name)
|
||||
self._wf.name = name
|
||||
self._reload_sidebar()
|
||||
self.status_message.emit(tr("co4e.renamed_msg", name=name))
|
||||
def _delete_selected_workflow(self) -> None:
|
||||
item = self.wf_list.currentItem()
|
||||
if item is None:
|
||||
return
|
||||
_kind, ident = item.data(Qt.UserRole)
|
||||
co4e.delete_workflow(ident)
|
||||
self._reload_sidebar()
|
||||
def _sync_wf_from_canvas(self) -> None:
|
||||
self._wf.nodes = self.canvas.nodes()
|
||||
self._wf.edges = self.canvas.edges()
|
||||
self._wf.name = self.name_edit.text().strip() or tr("co4e.untitled")
|
||||
def _save(self, as_template: bool) -> None:
|
||||
self._sync_wf_from_canvas()
|
||||
self._wf.is_template = as_template
|
||||
co4e.save_workflow(self._wf)
|
||||
self._reload_sidebar()
|
||||
self.status_message.emit(tr("co4e.saved_msg", name=self._wf.name))
|
||||
def _autosave(self) -> None:
|
||||
if co4e.get_workflow(self._wf.id) is not None:
|
||||
self._sync_wf_from_canvas()
|
||||
co4e.save_workflow(self._wf)
|
||||
def _on_name_changed(self, text: str) -> None:
|
||||
self._wf.name = text.strip() or tr("co4e.untitled")
|
||||
self._sync_active_flow_tab_text()
|
||||
def _add_blank_step(self) -> None:
|
||||
self.canvas.add_palette_step(co4e.Step(label="New Step"),
|
||||
self.canvas.mapToScene(self.canvas.rect().center()))
|
||||
def _on_node_selected(self, node_id: str) -> None:
|
||||
for n in self.canvas.nodes():
|
||||
if n.id == node_id:
|
||||
self.config.load_step(node_id, n.data, _skill_names())
|
||||
if self._config_collapsed:
|
||||
self._toggle_config()
|
||||
return
|
||||
def _on_config_changed(self) -> None:
|
||||
for n in self.canvas.nodes():
|
||||
self.canvas.refresh_node(n.id)
|
||||
self._autosave()
|
||||
def _wf_by_id(self, wf_id: str) -> Optional[co4e.Workflow]:
|
||||
"""Resolve a flow id to a Workflow — saved, or the open canvas."""
|
||||
wf = co4e.get_workflow(wf_id)
|
||||
if wf is not None:
|
||||
return wf
|
||||
if self._wf.id == wf_id:
|
||||
self._sync_wf_from_canvas()
|
||||
return self._wf
|
||||
return None
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Monitoring Dashboard — container only. Builds the tab strip, wires the
|
||||
auto-refresh timer and language-change retranslation, and forwards nav-rail
|
||||
sub-tab selection. Each sub-tab is its own class under ``tabs/``; this class
|
||||
owns no display logic of its own beyond assembling and refreshing them.
|
||||
|
||||
Public API preserved exactly for ``app.py`` (which cannot be modified):
|
||||
``MonitoringTab(ctx, cowork=None, structure=None, task_scheduler=None)``,
|
||||
the ``status_message`` signal, ``select_subtab(index)``, ``nav_subtabs()``,
|
||||
``hide_tab_bar()``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWidget
|
||||
|
||||
from ...core import audit_log
|
||||
from ...i18n import on_language_changed, tr
|
||||
from ...state import AppContext
|
||||
from .tabs.action_logs_tab import ActionLogsTab
|
||||
from .tabs.agent_status_tab import AgentStatusTab
|
||||
from .tabs.mcp_tab import McpTab
|
||||
from .tabs.overview_tab import OverviewTab
|
||||
from .tabs.security_events_tab import SecurityEventsTab
|
||||
|
||||
_REFRESH_MS = 3000
|
||||
# Comfortably larger than any realistic audit-log size — the event tables
|
||||
# have never had pagination controls, so every tab still shows "all matching
|
||||
# events" exactly like before; MonitoringQueryService's pagination support
|
||||
# is exercised for real here, just not surfaced as UI (yet).
|
||||
_UNBOUNDED_PAGE_SIZE = 100_000
|
||||
|
||||
|
||||
class MonitoringTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
# ---- cầu tương thích sau khi tách 8 tab (R08-T08) --------------------
|
||||
# Trước khi tách, 55 control của Tổng quan treo thẳng trên MonitoringTab với
|
||||
# tiền tố ov_. Tách xong mỗi cái về đúng tab/thẻ của nó và rụng tiền tố.
|
||||
#
|
||||
# tools/check_controls_alive.py so với mốc git 291a611 và đòi cả 55 cái phải
|
||||
# còn tới được — đó chính là việc của nó: bắt control biến mất trong lúc bóc
|
||||
# tách. Lần này control còn đủ, chỉ đổi chỗ ở, nên bắc cầu theo tiền tố.
|
||||
#
|
||||
# Định tuyến theo tiền tố chứ không dò mờ: overview_tab và permissions_card
|
||||
# có những tên trùng nhau (network_lbl nằm ở cả hai — một cái là mức dùng
|
||||
# mạng, một cái là quyền truy cập mạng). Dò mờ vớ nhầm cái đầu tiên tìm thấy.
|
||||
_OV_TIEN_TO = (
|
||||
("ov_perm_", lambda s: s.overview_tab.sandbox_card.permissions_card),
|
||||
("ov_sbx_", lambda s: s.overview_tab.sandbox_card),
|
||||
("ov_price_", lambda s: s.overview_tab.pricing_panel),
|
||||
("ov_pricing_", lambda s: s.overview_tab.pricing_panel),
|
||||
("ov_", lambda s: s.overview_tab),
|
||||
)
|
||||
#: Ba hộp nhóm: bản thân widget con CHÍNH LÀ hộp đó, không phải thuộc tính.
|
||||
_OV_CHINH_NO = {
|
||||
"ov_pricing_group": lambda s: s.overview_tab.pricing_panel,
|
||||
"ov_sandbox_details_group": lambda s: s.overview_tab.sandbox_card,
|
||||
"ov_permissions_group": lambda s: s.overview_tab.sandbox_card.permissions_card,
|
||||
}
|
||||
|
||||
#: Vài control không mang tiền tố ov_ nhưng cũng đã dời đi.
|
||||
_KHAC = {
|
||||
"status_table": lambda s: s.status_tab.table,
|
||||
}
|
||||
|
||||
def __getattr__(self, name):
|
||||
lay_khac = self._KHAC.get(name)
|
||||
if lay_khac is not None:
|
||||
return lay_khac(self)
|
||||
# Qt gọi __getattr__ rất nhiều lúc khởi tạo; chặn sớm cho rẻ.
|
||||
if not name.startswith("ov_"):
|
||||
raise AttributeError(name)
|
||||
lay = self._OV_CHINH_NO.get(name)
|
||||
if lay is not None:
|
||||
return lay(self)
|
||||
for tien_to, chu in self._OV_TIEN_TO:
|
||||
if name.startswith(tien_to):
|
||||
try:
|
||||
return getattr(chu(self), name[len(tien_to):])
|
||||
except AttributeError:
|
||||
continue
|
||||
raise AttributeError(name)
|
||||
|
||||
def __init__(self, ctx: AppContext, cowork=None, structure=None, task_scheduler=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._cowork = cowork
|
||||
self._structure = structure
|
||||
self._task_scheduler = task_scheduler
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
head = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
head.addWidget(self._title)
|
||||
head.addStretch(1)
|
||||
root.addLayout(head)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
root.addWidget(self.tabs, 1)
|
||||
|
||||
visible = self._tab_visible
|
||||
|
||||
self.overview_tab = OverviewTab(
|
||||
ctx, on_status_message=self.status_message.emit,
|
||||
on_settings_changed=self.refresh,
|
||||
on_view_all_action_logs=self._show_action_logs_tab,
|
||||
action_logs_tab_visible=visible("action_logs"))
|
||||
self.tabs.addTab(self.overview_tab, "")
|
||||
|
||||
self.security_tab = SecurityEventsTab(ctx, on_refresh_all=self.refresh)
|
||||
if visible("security_events"):
|
||||
self.tabs.addTab(self.security_tab, "")
|
||||
self.mcp_tab = McpTab(ctx, on_refresh_all=self.refresh)
|
||||
if visible("mcp_history"):
|
||||
self.tabs.addTab(self.mcp_tab, "")
|
||||
self.action_tab = ActionLogsTab(ctx, on_refresh_all=self.refresh)
|
||||
if visible("action_logs"):
|
||||
self.tabs.addTab(self.action_tab, "")
|
||||
|
||||
self.status_tab = AgentStatusTab(
|
||||
ctx, on_refresh_all=self.refresh,
|
||||
cowork=cowork, structure=structure, task_scheduler=task_scheduler)
|
||||
if visible("agent_status"):
|
||||
self.tabs.addTab(self.status_tab, "")
|
||||
|
||||
# ---- Agents Admin (catalog: assign a role + pinned model per agent) --
|
||||
from ...ui.agents_admin_tab import AgentsAdminTab
|
||||
self.agents_admin_tab = AgentsAdminTab(ctx)
|
||||
if visible("agents_admin"):
|
||||
self.tabs.addTab(self.agents_admin_tab, "")
|
||||
|
||||
# ---- Tools (govern built-in tools + Connectors/MCP in one place) -----
|
||||
from ...ui.tools_admin_tab import ToolsAdminTab
|
||||
self.tools_admin_tab = ToolsAdminTab(ctx)
|
||||
if visible("tools_admin"):
|
||||
self.tabs.addTab(self.tools_admin_tab, "")
|
||||
|
||||
# ---- Icons (browse built-in icons + add custom icons for agents/flows) --
|
||||
from ...ui.icons_admin_tab import IconsAdminTab
|
||||
self.icons_admin_tab = IconsAdminTab(ctx)
|
||||
self.tabs.addTab(self.icons_admin_tab, "")
|
||||
|
||||
self.tabs.setCurrentIndex(0)
|
||||
|
||||
self._timer = QTimer(self)
|
||||
# Only the Overview cards auto-refresh on this tick — Security, MCP,
|
||||
# Action Logs, Agent Status (and the admin tabs) are read-only tables
|
||||
# a background re-sort would otherwise disturb mid-interaction; the
|
||||
# user refreshes them explicitly via a "Refresh" button.
|
||||
self._timer.setInterval(_REFRESH_MS)
|
||||
self._timer.timeout.connect(self._auto_refresh)
|
||||
self._timer.start()
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self.refresh()
|
||||
|
||||
# ---- nav integration: sub-tabs driven from the left nav rail ------------
|
||||
def nav_subtabs(self):
|
||||
"""(label, index, icon_name) for each sub-tab — the left nav lists
|
||||
these as children under 'Monitoring'."""
|
||||
by_widget = {self.agents_admin_tab: "robot", self.tools_admin_tab: "wrench",
|
||||
self.icons_admin_tab: "star"}
|
||||
for attr, name in (("security_tab", "shield"), ("mcp_tab", "plug"),
|
||||
("action_tab", "bolt"), ("status_tab", "monitor")):
|
||||
w = getattr(self, attr, None)
|
||||
if w is not None:
|
||||
by_widget[w] = name
|
||||
out = []
|
||||
for i in range(self.tabs.count()):
|
||||
out.append((self.tabs.tabText(i), i, by_widget.get(self.tabs.widget(i), "dashboard")))
|
||||
return out
|
||||
|
||||
def select_subtab(self, index: int) -> None:
|
||||
if 0 <= index < self.tabs.count():
|
||||
self.tabs.setCurrentIndex(index)
|
||||
|
||||
def hide_tab_bar(self) -> None:
|
||||
"""Hide the in-content tab strip; the nav rail drives the sub-tabs."""
|
||||
self.tabs.tabBar().hide()
|
||||
|
||||
def _show_action_logs_tab(self) -> None:
|
||||
self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_tab))
|
||||
|
||||
def _tab_visible(self, key: str) -> bool:
|
||||
return self.ctx.role == "admin" or bool(self.ctx.config.monitoring_visibility.get(key, True))
|
||||
|
||||
def _set_tab_text_if_present(self, widget, text: str) -> None:
|
||||
idx = self.tabs.indexOf(widget)
|
||||
if idx >= 0:
|
||||
self.tabs.setTabText(idx, text)
|
||||
|
||||
# ---- i18n ---------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("monitoring.title"))
|
||||
if self.tabs.count():
|
||||
self.tabs.setTabText(0, tr("monitoring.tab_overview"))
|
||||
self._set_tab_text_if_present(self.security_tab, tr("monitoring.tab_security"))
|
||||
self._set_tab_text_if_present(self.mcp_tab, tr("monitoring.tab_mcp"))
|
||||
self._set_tab_text_if_present(self.action_tab, tr("monitoring.tab_actions"))
|
||||
self._set_tab_text_if_present(self.status_tab, tr("monitoring.tab_agents"))
|
||||
self._set_tab_text_if_present(self.agents_admin_tab, tr("monitoring.tab_agents_admin"))
|
||||
self._set_tab_text_if_present(self.tools_admin_tab, tr("monitoring.tab_tools"))
|
||||
self._set_tab_text_if_present(self.icons_admin_tab, tr("monitoring.tab_icons"))
|
||||
|
||||
self.overview_tab.retranslate()
|
||||
self.security_tab.retranslate()
|
||||
self.mcp_tab.retranslate()
|
||||
self.action_tab.retranslate()
|
||||
self.status_tab.retranslate()
|
||||
|
||||
self.refresh()
|
||||
|
||||
# ---- refresh -------------------------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
"""Full refresh — Overview cards plus every table. Wired to the
|
||||
top-of-page and per-section "Refresh" buttons, called once at
|
||||
startup/language-change, but NOT to the auto-refresh timer (see
|
||||
``_auto_refresh``)."""
|
||||
events = self._load_events()
|
||||
self._apply_events_to_event_tabs(events)
|
||||
self.status_tab.refresh()
|
||||
self.overview_tab.refresh(events)
|
||||
|
||||
def _auto_refresh(self) -> None:
|
||||
"""3-second timer tick — Overview cards only (see ``refresh``)."""
|
||||
self.overview_tab.refresh(self._load_events())
|
||||
|
||||
def _load_events(self) -> List[dict]:
|
||||
shared_dir = self.ctx.config.shared_dir
|
||||
if shared_dir:
|
||||
from ...core import telemetry_shared
|
||||
shared_events = telemetry_shared.load_shared_audit_events(shared_dir)
|
||||
if shared_events:
|
||||
return shared_events
|
||||
return audit_log.load_events()
|
||||
|
||||
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
|
||||
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
|
||||
shared local-or-shared decision per refresh, not one per tab) three
|
||||
ways via :class:`MonitoringQueryService`, mirroring what each
|
||||
``EventTable.set_events`` used to receive directly."""
|
||||
from ...application.monitoring.dto.audit_event_dto import AuditEventDTO
|
||||
from ...application.monitoring.monitoring_query_service import MonitoringQueryService
|
||||
from ...application.monitoring.repository.audit_event_repository import (
|
||||
InMemoryAuditEventRepository,
|
||||
)
|
||||
|
||||
repository = InMemoryAuditEventRepository([AuditEventDTO.from_raw(e) for e in events])
|
||||
service = MonitoringQueryService(repository)
|
||||
|
||||
def _events_for(kind) -> List[dict]:
|
||||
page = service.query(kind=kind, page_size=_UNBOUNDED_PAGE_SIZE)
|
||||
return [e.to_dict() for e in page.items]
|
||||
|
||||
self.security_tab.set_events(_events_for("security_block"))
|
||||
self.mcp_tab.set_events(_events_for("mcp_call"))
|
||||
self.action_tab.set_events(_events_for(None))
|
||||
@@ -0,0 +1,45 @@
|
||||
"""AI-assisted search-keyword filter — shared by the Security Events / MCP
|
||||
Call History / Action Logs tabs' search box. Extracted verbatim from
|
||||
``ui/monitoring_tab.py``'s ``MonitoringTab._ai_filter``.
|
||||
|
||||
Each caller owns its own ``state`` dict (just ``{}`` at construction) so a
|
||||
repeat click is a no-op while a request is already in flight — mirrors the
|
||||
original single ``self._ai_filter_worker`` attribute, without needing a
|
||||
shared base class.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QLineEdit, QPushButton
|
||||
|
||||
|
||||
def start_ai_filter(ctx, search: QLineEdit, ai_btn: QPushButton, state: dict) -> None:
|
||||
query = search.text().strip()
|
||||
if not query or state.get("worker") is not None:
|
||||
return
|
||||
ai_btn.setEnabled(False)
|
||||
|
||||
def job(worker):
|
||||
provider = ctx.build_active_provider()
|
||||
reply = provider.chat([
|
||||
{"role": "system", "content":
|
||||
"Turn the user's natural-language question about an audit/security event "
|
||||
"log into ONE short search keyword. Reply with ONLY the keyword."},
|
||||
{"role": "user", "content": query},
|
||||
], cancel=worker.is_cancelled)
|
||||
return {"keyword": (reply.get("content") or "").strip().splitlines()[0][:60]}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
state["worker"] = None
|
||||
ai_btn.setEnabled(True)
|
||||
search.setText(result.get("keyword") or query)
|
||||
|
||||
def failed(_err: str) -> None:
|
||||
state["worker"] = None
|
||||
ai_btn.setEnabled(True)
|
||||
|
||||
from ....core.worker import AgentWorker
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
state["worker"] = w
|
||||
w.start()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Badge/label vocabulary shared by the Monitoring event tables and detail
|
||||
panel — human labels for a raw audit ``name``, and the (QSS object name,
|
||||
i18n key) pair for the "Trạng thái"/"Mức độ" pills.
|
||||
|
||||
``apply_badge`` replaces the two byte-identical
|
||||
``_EventDetailPanel._apply_badge`` / ``MonitoringTab._set_badge`` static
|
||||
methods the original file duplicated.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from ....i18n import tr
|
||||
|
||||
# Human label for the raw ``name`` an audit event is recorded under — the
|
||||
# "Loại" field in the detail panel. Anything not in this map (custom tool
|
||||
# names, etc.) just shows its raw name, same as the table's Hành động column.
|
||||
_ACTION_LABEL_KEYS = {
|
||||
"prompt": "monitoring.action_prompt",
|
||||
"dangerous_command": "monitoring.action_dangerous_command",
|
||||
"run_command": "monitoring.action_dangerous_command",
|
||||
"install_package": "monitoring.action_install_package",
|
||||
"path_outside_sandbox": "monitoring.action_path_outside_sandbox",
|
||||
"network_blocked": "monitoring.action_network_blocked",
|
||||
"secret_in_output": "monitoring.action_secret_in_output",
|
||||
}
|
||||
|
||||
|
||||
def action_label(name: str) -> str:
|
||||
key = _ACTION_LABEL_KEYS.get(name)
|
||||
return tr(key) if key else name
|
||||
|
||||
|
||||
# (badge QSS object name, i18n key) for the "Trạng thái" pill, mapped onto
|
||||
# the app's existing badge* tones (theme.py).
|
||||
_STATUS_INFO = {
|
||||
"path_outside_sandbox": ("badgeSuccess", "monitoring.status_path"),
|
||||
"network_blocked": ("badge", "monitoring.status_network"),
|
||||
"secret_in_output": ("badgeWarn", "monitoring.status_secret"),
|
||||
}
|
||||
_STATUS_DEFAULT = ("badgePurple", "monitoring.status_blocked")
|
||||
|
||||
|
||||
def status_info(name: str, kind: str = "security_block", ok: bool = False) -> Tuple[str, str]:
|
||||
if name in _STATUS_INFO:
|
||||
return _STATUS_INFO[name]
|
||||
if kind == "security_block":
|
||||
# Security Events rows are always ok=False — an unmapped name here
|
||||
# still means "blocked by some rule", never a plain failure.
|
||||
return _STATUS_DEFAULT
|
||||
# MCP calls / generic Action Logs rows: no fixed enforcement-rule
|
||||
# vocabulary applies, so fall back to the event's own ok/fail outcome.
|
||||
return ("badgeSuccess", "monitoring.status_ok") if ok else ("badgeDanger", "monitoring.status_failed")
|
||||
|
||||
|
||||
def severity_info(name: str, kind: str = "security_block", ok: bool = False) -> Tuple[str, str]:
|
||||
# An unapproved shell command is the one CRITICAL case; everything else
|
||||
# blocked is MEDIUM.
|
||||
if name in ("dangerous_command", "run_command"):
|
||||
return "badgeDanger", "monitoring.severity_critical"
|
||||
if kind == "security_block":
|
||||
return "badgeWarn", "monitoring.severity_medium"
|
||||
# A successful MCP call / action is routine (INFO); a failed one still
|
||||
# deserves the same MEDIUM tone Security Events uses for a blocked rule.
|
||||
return ("badge", "monitoring.severity_info") if ok else ("badgeWarn", "monitoring.severity_medium")
|
||||
|
||||
|
||||
def agent_badge_name(name: str) -> str:
|
||||
"""Badge tone for the Agent field's pill — the same identity-colour
|
||||
mapping ``formatters.agent_avatar_colour`` uses, expressed as one of the
|
||||
shared badge* QSS classes (theme.py) instead of a literal hex."""
|
||||
if "Security" in name:
|
||||
return "badgeDanger"
|
||||
if "Cowork" in name:
|
||||
return "badge"
|
||||
if name == "schedule":
|
||||
return "badgeWarn"
|
||||
if name == "graphrag":
|
||||
return "badgePurple"
|
||||
if "Code" in name:
|
||||
return "badgeSuccess"
|
||||
return "badge"
|
||||
|
||||
|
||||
def apply_badge(label: QLabel, object_name: str) -> None:
|
||||
label.setObjectName(object_name)
|
||||
label.style().unpolish(label)
|
||||
label.style().polish(label)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Right-hand "Chi tiet su kien" detail panel shared by the Security Events /
|
||||
MCP Call History / Action Logs tabs — the full record behind whichever row is
|
||||
selected in an :class:`~.event_table.EventTable`. Extracted verbatim from
|
||||
``ui/monitoring_tab.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....core import agent_roles
|
||||
from ....i18n import tr
|
||||
from ....ui.icons import icon
|
||||
from .badges import agent_badge_name, action_label, apply_badge, severity_info, status_info
|
||||
from .formatters import event_id, fmt_event_time_full
|
||||
from .layout_helpers import kv_row
|
||||
|
||||
# Cosmetic label only — no real policy-versioning system exists yet.
|
||||
_STATIC_POLICY_LABEL = "security_policy_v2"
|
||||
|
||||
|
||||
class EventDetailPanel(QWidget):
|
||||
"""Laid out as three labelled sections, a terminal-style block quote for
|
||||
the detail text, and a metadata footer, closed by the header close
|
||||
button, the footer button, Esc, or a click outside the table/panel (see
|
||||
:class:`~.event_table.ClickOutsideCloser`)."""
|
||||
|
||||
closed = Signal()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("monSection")
|
||||
self._detail_text = ""
|
||||
outer = QVBoxLayout(self)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
self._title_lbl = QLabel()
|
||||
self._title_lbl.setStyleSheet("font-weight:700;")
|
||||
hdr.addWidget(self._title_lbl, 1)
|
||||
self._close_btn = QPushButton()
|
||||
self._close_btn.setIcon(icon("close"))
|
||||
self._close_btn.setFlat(True)
|
||||
self._close_btn.setFixedWidth(28)
|
||||
self._close_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._close_btn.clicked.connect(self.closed.emit)
|
||||
hdr.addWidget(self._close_btn)
|
||||
outer.addLayout(hdr)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
body = QWidget()
|
||||
self._body_lay = QVBoxLayout(body)
|
||||
self._body_lay.setContentsMargins(0, 0, 4, 0)
|
||||
scroll.setWidget(body)
|
||||
outer.addWidget(scroll, 1)
|
||||
|
||||
self._section_hdrs: List[Tuple[str, QLabel]] = []
|
||||
self._rows: Dict[str, Tuple[QLabel, QLabel]] = {}
|
||||
|
||||
def _section(key: str) -> None:
|
||||
lbl = QLabel()
|
||||
lbl.setObjectName("detailSectionHdr")
|
||||
self._body_lay.addWidget(lbl)
|
||||
self._section_hdrs.append((key, lbl))
|
||||
|
||||
def _field(key: str) -> QLabel:
|
||||
lbl, val = kv_row(self._body_lay)
|
||||
self._rows[key] = (lbl, val)
|
||||
return val
|
||||
|
||||
_section("general")
|
||||
_field("time")
|
||||
self._agent_val = _field("agent")
|
||||
_field("account")
|
||||
self._machine_val = _field("machine")
|
||||
self._machine_val.setObjectName("monoChip")
|
||||
|
||||
_section("action")
|
||||
self._type_val = _field("type")
|
||||
self._type_val.setObjectName("neutralTag")
|
||||
self._status_val = _field("status")
|
||||
|
||||
_section("block")
|
||||
code_box = QWidget()
|
||||
code_box.setObjectName("detailCodeBlock")
|
||||
code_lay = QHBoxLayout(code_box)
|
||||
code_lay.setContentsMargins(8, 6, 8, 6)
|
||||
self._code_text = QLabel()
|
||||
self._code_text.setObjectName("detailCodeText")
|
||||
self._code_text.setWordWrap(True)
|
||||
self._code_text.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
code_lay.addWidget(self._code_text, 1)
|
||||
self._copy_btn = QPushButton()
|
||||
self._copy_btn.setObjectName("detailCopyBtn")
|
||||
self._copy_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._copy_btn.clicked.connect(self._copy_detail)
|
||||
code_lay.addWidget(self._copy_btn, 0, Qt.AlignVCenter)
|
||||
self._body_lay.addWidget(code_box)
|
||||
|
||||
_section("metadata")
|
||||
self._event_id_val = _field("event_id")
|
||||
self._event_id_val.setObjectName("monoChip")
|
||||
self._policy_val = _field("policy")
|
||||
self._severity_val = _field("severity")
|
||||
|
||||
self._body_lay.addStretch(1)
|
||||
|
||||
footer = QHBoxLayout()
|
||||
footer.setContentsMargins(0, 6, 0, 0)
|
||||
self._footer_close_btn = QPushButton()
|
||||
self._footer_close_btn.setObjectName("primary")
|
||||
self._footer_close_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._footer_close_btn.clicked.connect(self.closed.emit)
|
||||
footer.addWidget(self._footer_close_btn, 1)
|
||||
outer.addLayout(footer)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._title_lbl.setText(tr("monitoring.security_detail_title"))
|
||||
self._close_btn.setToolTip(tr("monitoring.security_detail_close"))
|
||||
self._footer_close_btn.setText(tr("monitoring.security_detail_close"))
|
||||
self._footer_close_btn.setIcon(icon("close"))
|
||||
section_keys = {
|
||||
"general": "monitoring.detail_section_general",
|
||||
"action": "monitoring.detail_section_action",
|
||||
"block": "monitoring.col_detail_block",
|
||||
"metadata": "monitoring.detail_section_metadata",
|
||||
}
|
||||
for key, lbl in self._section_hdrs:
|
||||
lbl.setText(tr(section_keys[key]).upper())
|
||||
self._rows["time"][0].setText(tr("monitoring.col_time"))
|
||||
self._rows["agent"][0].setText(tr("monitoring.col_agent"))
|
||||
self._rows["account"][0].setText(tr("monitoring.col_account"))
|
||||
self._rows["machine"][0].setText(tr("monitoring.col_machine"))
|
||||
self._rows["type"][0].setText(tr("monitoring.detail_type"))
|
||||
self._rows["status"][0].setText(tr("monitoring.detail_status"))
|
||||
self._rows["event_id"][0].setText(tr("monitoring.detail_event_id"))
|
||||
self._rows["policy"][0].setText(tr("monitoring.detail_policy"))
|
||||
self._rows["severity"][0].setText(tr("monitoring.detail_severity"))
|
||||
if not self._copy_btn.text() or self._copy_btn.text() != tr("monitoring.detail_copied"):
|
||||
self._reset_copy_btn()
|
||||
|
||||
def show_event(self, ev: dict, row: int) -> None:
|
||||
na = "—"
|
||||
self._rows["time"][1].setText(fmt_event_time_full(ev.get("ts", "")) or na)
|
||||
|
||||
agent_label = agent_roles.label_for(ev.get("agent_role", "")) or na
|
||||
self._agent_val.setText(agent_label)
|
||||
apply_badge(self._agent_val,
|
||||
agent_badge_name(agent_label) if agent_label != na else "badge")
|
||||
|
||||
self._rows["account"][1].setText(ev.get("account", "") or na)
|
||||
self._machine_val.setText(ev.get("machine", "") or na)
|
||||
|
||||
name = ev.get("name", "")
|
||||
kind = ev.get("kind", "security_block")
|
||||
ok = ev.get("ok", False)
|
||||
self._type_val.setText(action_label(name) or na)
|
||||
status_badge, status_key = status_info(name, kind, ok)
|
||||
self._status_val.setText(tr(status_key))
|
||||
apply_badge(self._status_val, status_badge)
|
||||
|
||||
self._detail_text = ev.get("detail", "") or na
|
||||
self._code_text.setText(self._detail_text)
|
||||
self._reset_copy_btn()
|
||||
|
||||
self._event_id_val.setText(event_id(ev.get("ts", ""), row))
|
||||
# The static policy label names a real enforcement ruleset — only
|
||||
# meaningful for a Security Events row; MCP calls/generic actions
|
||||
# were never evaluated against it.
|
||||
self._policy_val.setText(_STATIC_POLICY_LABEL if kind == "security_block" else na)
|
||||
severity_badge, severity_key = severity_info(name, kind, ok)
|
||||
self._severity_val.setText(tr(severity_key))
|
||||
apply_badge(self._severity_val, severity_badge)
|
||||
|
||||
def _copy_detail(self) -> None:
|
||||
QGuiApplication.clipboard().setText(self._detail_text)
|
||||
self._copy_btn.setText(tr("monitoring.detail_copied"))
|
||||
self._copy_btn.setIcon(icon("check"))
|
||||
QTimer.singleShot(1500, self._reset_copy_btn)
|
||||
|
||||
def _reset_copy_btn(self) -> None:
|
||||
self._copy_btn.setText(tr("monitoring.detail_copy"))
|
||||
self._copy_btn.setIcon(icon("document"))
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Read-only audit-event table shared by the Security Events / MCP Call
|
||||
History / Action Logs tabs, plus the click-outside-closes-detail-panel event
|
||||
filter. Extracted verbatim from ``ui/monitoring_tab.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
|
||||
from PySide6.QtGui import QBrush, QColor
|
||||
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
from ....core import agent_roles
|
||||
from ....i18n import tr
|
||||
from ....theme import current_palette
|
||||
from ....ui.icons import DOT_GREEN, DOT_RED, icon
|
||||
from .badges import action_label
|
||||
from .formatters import agent_avatar_icon, fmt_event_time
|
||||
|
||||
_MAX_ROWS = 300
|
||||
|
||||
|
||||
class _TimeItem(QTableWidgetItem):
|
||||
"""The Time column shows "dd/MM hh:mm", which does not sort correctly as
|
||||
text (day-of-month leads, not year/month) — so sorting compares the raw
|
||||
ISO ``ts`` each item is built from instead of its displayed text."""
|
||||
|
||||
def __init__(self, raw_ts: str, display: str):
|
||||
super().__init__(display)
|
||||
self._raw_ts = raw_ts
|
||||
|
||||
def __lt__(self, other):
|
||||
if isinstance(other, _TimeItem):
|
||||
return self._raw_ts < other._raw_ts
|
||||
return super().__lt__(other)
|
||||
|
||||
|
||||
class EventTable(QTableWidget):
|
||||
"""A read-only table of audit-log events — newest-first by default, and
|
||||
every column header is click-to-sort (ascending/descending toggle; the
|
||||
Time column sorts by the underlying ISO timestamp, not its "dd/MM hh:mm"
|
||||
display text — see :class:`_TimeItem`)."""
|
||||
|
||||
# What each blocked action is, as a colour. Security events all record
|
||||
# ok=False, so the tick/cross column said the same thing on every row; the
|
||||
# useful distinction is WHICH rule fired.
|
||||
_ACTION_TINTS = {
|
||||
"prompt": "accent",
|
||||
"dangerous_command": "danger",
|
||||
"run_command": "danger",
|
||||
"install_package": "warning",
|
||||
"path_outside_sandbox": "success",
|
||||
"network_blocked": "accent",
|
||||
"secret_in_output": "warning",
|
||||
}
|
||||
|
||||
def __init__(self, show_result: bool = True):
|
||||
# Security Events drops the result column entirely (see _ACTION_TINTS).
|
||||
self._show_result = show_result
|
||||
super().__init__(0, 7 if show_result else 6)
|
||||
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.setIconSize(QSize(20, 20))
|
||||
self.verticalHeader().setVisible(False)
|
||||
# Fixed row height — letting Qt auto-size rows from content fought with
|
||||
# the action column's cell widget geometry settling stale/oversized on
|
||||
# an intermediate sizing pass, clipping the pill's text.
|
||||
self.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
|
||||
self.verticalHeader().setDefaultSectionSize(32)
|
||||
self.setSortingEnabled(True)
|
||||
header = self.horizontalHeader()
|
||||
header.setStretchLastSection(True)
|
||||
for col in range(self.columnCount() - 1):
|
||||
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
cols = [tr("monitoring.col_time"),
|
||||
tr("monitoring.col_agent") if not self._show_result else tr("monitoring.col_role"),
|
||||
tr("monitoring.col_account"), tr("monitoring.col_machine")]
|
||||
if self._show_result:
|
||||
cols += [tr("monitoring.col_name"), tr("monitoring.col_result")]
|
||||
else:
|
||||
cols += [tr("monitoring.col_action")]
|
||||
cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")]
|
||||
self.setHorizontalHeaderLabels(cols)
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS]
|
||||
self.setSortingEnabled(False)
|
||||
self.setRowCount(len(events))
|
||||
for row, ev in enumerate(events):
|
||||
is_admin_violation = ev.get("role") == "admin" and not ev.get("ok", True)
|
||||
cells = [
|
||||
ev.get("ts", ""), agent_roles.label_for(ev.get("agent_role", "")),
|
||||
ev.get("account", "") or "—", ev.get("machine", "") or "—",
|
||||
ev.get("name", ""),
|
||||
]
|
||||
if self._show_result:
|
||||
cells.append("")
|
||||
cells.append((ev.get("detail") or "")[:300])
|
||||
pal = current_palette()
|
||||
for col, text in enumerate(cells):
|
||||
item = (_TimeItem(str(text), fmt_event_time(str(text))) if col == 0
|
||||
else QTableWidgetItem(str(text)))
|
||||
if col == 0:
|
||||
# Stash the full event (untruncated detail included) on the
|
||||
# Time cell, so a click-to-open detail panel survives the
|
||||
# user re-sorting the table by any column.
|
||||
item.setData(Qt.UserRole, ev)
|
||||
if col == 1:
|
||||
item.setIcon(agent_avatar_icon(str(text)))
|
||||
if self._show_result and col == 5:
|
||||
item.setIcon(icon("check", color=DOT_GREEN) if ev.get("ok")
|
||||
else icon("close", color=DOT_RED))
|
||||
if not self._show_result and col == 4:
|
||||
# Human-readable label, tinted by which rule fired, via the
|
||||
# ITEM's own colours — NOT a setCellWidget() pill, which is
|
||||
# pinned to a screen position rather than travelling with
|
||||
# the item across a re-sort.
|
||||
tint = getattr(pal, self._ACTION_TINTS.get(
|
||||
ev.get("name", ""), "text_muted"), pal.text_muted)
|
||||
item.setText(action_label(str(text)))
|
||||
colour = QColor(tint)
|
||||
item.setForeground(QBrush(colour))
|
||||
soft = QColor(colour)
|
||||
soft.setAlpha(38)
|
||||
item.setBackground(QBrush(soft))
|
||||
if is_admin_violation:
|
||||
item.setBackground(QBrush(QColor(229, 72, 77, 60)))
|
||||
self.setItem(row, col, item)
|
||||
self.setSortingEnabled(True)
|
||||
self.apply_filter(getattr(self, "_filter_needle", ""))
|
||||
|
||||
def apply_filter(self, needle: str) -> None:
|
||||
self._filter_needle = (needle or "").strip().lower()
|
||||
for row in range(self.rowCount()):
|
||||
if not self._filter_needle:
|
||||
self.setRowHidden(row, False)
|
||||
continue
|
||||
match = any(
|
||||
self._filter_needle in (self.item(row, col).text().lower()
|
||||
if self.item(row, col) else "")
|
||||
for col in range(self.columnCount()))
|
||||
self.setRowHidden(row, not match)
|
||||
|
||||
def event_at_row(self, row: int) -> Optional[dict]:
|
||||
item = self.item(row, 0)
|
||||
return item.data(Qt.UserRole) if item else None
|
||||
|
||||
|
||||
class ClickOutsideCloser(QObject):
|
||||
"""Closes the event-detail panel on a click anywhere outside the
|
||||
table/panel splitter — judged by screen-space geometry (is the click's
|
||||
global position inside the splitter's on-screen rectangle), not by which
|
||||
exact widget object received the event (unreliable mid-drag on the
|
||||
splitter's handle)."""
|
||||
|
||||
def __init__(self, table: "EventTable", panel: QWidget, container: QWidget):
|
||||
super().__init__(container)
|
||||
self._table = table
|
||||
self._panel = panel
|
||||
self._container = container
|
||||
|
||||
def eventFilter(self, obj, event) -> bool:
|
||||
if event.type() == QEvent.MouseButtonPress and self._panel.isVisible():
|
||||
global_pos = event.globalPosition().toPoint()
|
||||
top_left = self._container.mapToGlobal(self._container.rect().topLeft())
|
||||
rect = QRect(top_left, self._container.size())
|
||||
if not rect.contains(global_pos):
|
||||
self._table.clearSelection()
|
||||
return False
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Page scaffolding shared by the Security Events / MCP Call History / Action
|
||||
Logs / Agent Status tabs: title + refresh button, optional search box +
|
||||
AI-filter button, optional table+detail-panel split. Extracted from
|
||||
``ui/monitoring_tab.py``'s ``MonitoringTab._wrap_with_filter``/
|
||||
``_sync_event_detail`` — those used to be rebuilt 3 times almost identically
|
||||
for the 3 event-table pages; this is the one shared implementation.
|
||||
|
||||
Builds directly into a caller-supplied ``page`` widget (which must not have a
|
||||
layout yet) and returns a dict of the sub-widgets the caller needs to keep
|
||||
(e.g. to implement its own ``retranslate()``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter,
|
||||
QTableWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....i18n import tr
|
||||
from ....ui.icons import icon
|
||||
from .event_table import ClickOutsideCloser, EventTable
|
||||
from .event_detail_panel import EventDetailPanel
|
||||
|
||||
|
||||
def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None:
|
||||
# currentRow() alone is not enough: clearSelection() (used by the panel's
|
||||
# close button) drops the selection but leaves the current cell in
|
||||
# place, so a stale currentRow() would keep the panel open.
|
||||
row = table.currentRow()
|
||||
has_selection = bool(table.selectedItems())
|
||||
ev = table.event_at_row(row) if (has_selection and row >= 0) else None
|
||||
if ev:
|
||||
panel.show_event(ev, row)
|
||||
panel.setVisible(bool(ev))
|
||||
|
||||
|
||||
def build_filter_scaffold(
|
||||
page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None],
|
||||
title_key: Optional[str] = None, with_search: bool = True,
|
||||
with_detail: bool = False,
|
||||
on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None,
|
||||
) -> Dict[str, object]:
|
||||
lay = QVBoxLayout(page)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
parts: Dict[str, object] = {}
|
||||
|
||||
if title_key:
|
||||
hdr = QHBoxLayout()
|
||||
title_lbl = QLabel(tr(title_key))
|
||||
title_lbl.setStyleSheet("font-weight:700; font-size:14px;")
|
||||
hdr.addWidget(title_lbl)
|
||||
hdr.addStretch(1)
|
||||
refresh_btn = QPushButton(tr("monitoring.refresh"))
|
||||
refresh_btn.setIcon(icon("refresh"))
|
||||
refresh_btn.setObjectName("primary")
|
||||
refresh_btn.setCursor(Qt.PointingHandCursor)
|
||||
refresh_btn.clicked.connect(on_refresh)
|
||||
hdr.addWidget(refresh_btn)
|
||||
lay.addLayout(hdr)
|
||||
parts.update(title_lbl=title_lbl, title_key=title_key, title_refresh_btn=refresh_btn)
|
||||
|
||||
if with_search:
|
||||
row = QHBoxLayout()
|
||||
search = QLineEdit()
|
||||
search.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
||||
search.textChanged.connect(table.apply_filter)
|
||||
ai_btn = QPushButton(tr("monitoring.ai_filter_btn"))
|
||||
ai_btn.setIcon(icon("sparkle"))
|
||||
ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip"))
|
||||
ai_btn.setCursor(Qt.PointingHandCursor)
|
||||
if on_ai_filter is not None:
|
||||
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
|
||||
row.addWidget(search, 1)
|
||||
row.addWidget(ai_btn)
|
||||
lay.addLayout(row)
|
||||
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
|
||||
|
||||
if with_detail:
|
||||
# Click a row -> its full record opens in a detail panel on the
|
||||
# right (the table itself clips the detail text to 300 chars). A
|
||||
# pointing-hand cursor over the rows signals that they're clickable.
|
||||
table.setCursor(Qt.PointingHandCursor)
|
||||
detail = EventDetailPanel()
|
||||
detail.setVisible(False)
|
||||
detail.closed.connect(table.clearSelection)
|
||||
table.itemSelectionChanged.connect(lambda: _sync_event_detail(table, detail))
|
||||
split = QSplitter(Qt.Horizontal)
|
||||
split.addWidget(table)
|
||||
split.addWidget(detail)
|
||||
split.setStretchFactor(0, 1)
|
||||
split.setStretchFactor(1, 0)
|
||||
split.setChildrenCollapsible(False)
|
||||
split.setSizes([700, 320])
|
||||
lay.addWidget(split, 1)
|
||||
parts["detail_panel"] = detail
|
||||
|
||||
# Esc, anywhere focus is inside this page, closes the panel the same
|
||||
# way the close button does.
|
||||
esc = QShortcut(QKeySequence(Qt.Key_Escape), page)
|
||||
esc.setContext(Qt.WidgetWithChildrenShortcut)
|
||||
esc.activated.connect(table.clearSelection)
|
||||
parts["detail_esc_shortcut"] = esc
|
||||
|
||||
# A click outside both the table and the panel also closes it.
|
||||
click_filter = ClickOutsideCloser(table, detail, split)
|
||||
QApplication.instance().installEventFilter(click_filter)
|
||||
parts["detail_click_filter"] = click_filter
|
||||
else:
|
||||
lay.addWidget(table, 1)
|
||||
|
||||
return parts
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Formatting helpers shared by the Monitoring tabs — timestamps, byte
|
||||
counts, and the per-agent colour-coded initials avatar.
|
||||
|
||||
Extracted from ``ui/monitoring_tab.py`` verbatim (same output for the same
|
||||
input); the three timestamp formatters used to each repeat their own
|
||||
``datetime.fromisoformat`` + ``try/except (TypeError, ValueError)`` guard —
|
||||
that parse step is now a single shared ``_parse_iso`` helper.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
|
||||
|
||||
from ....i18n import tr
|
||||
|
||||
|
||||
def _parse_iso(ts: str) -> Optional[datetime]:
|
||||
try:
|
||||
return datetime.fromisoformat(ts)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def fmt_bytes(n: float) -> str:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.0f} {unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
|
||||
def fmt_event_time(ts: str) -> str:
|
||||
""""dd/MM hh:mm" for the Time column — e.g. 25/05 15:03."""
|
||||
dt = _parse_iso(ts)
|
||||
return dt.strftime("%d/%m %H:%M") if dt else ts
|
||||
|
||||
|
||||
_MIDDLE_DOT = chr(0xB7)
|
||||
|
||||
|
||||
def fmt_event_time_full(ts: str) -> str:
|
||||
""""dd/MM/yyyy [middle dot] HH:mm:ss" — the detail panel's Thoi gian field."""
|
||||
dt = _parse_iso(ts)
|
||||
return dt.strftime(f"%d/%m/%Y {_MIDDLE_DOT} %H:%M:%S") if dt else ts
|
||||
|
||||
|
||||
def relative_time(ts: str) -> str:
|
||||
"""A short "Xm ago"-style string for an audit-log ``ts``; "" if
|
||||
unparsable."""
|
||||
dt = _parse_iso(ts)
|
||||
if dt is None:
|
||||
return ""
|
||||
delta = (datetime.now() - dt).total_seconds()
|
||||
if delta < 60:
|
||||
return tr("monitoring.time_just_now")
|
||||
if delta < 3600:
|
||||
return tr("monitoring.time_minutes_ago", n=int(delta // 60))
|
||||
if delta < 86400:
|
||||
return tr("monitoring.time_hours_ago", n=int(delta // 3600))
|
||||
return tr("monitoring.time_days_ago", n=int(delta // 86400))
|
||||
|
||||
|
||||
def event_id(ts: str, row: int) -> str:
|
||||
"""A display-only id in the ``evt_<timestamp digits>_<row>`` shape the
|
||||
mockup uses — the real audit log has no native event id, so this is
|
||||
derived from the timestamp and the row's position in the currently
|
||||
displayed (sorted) table, not persisted anywhere."""
|
||||
digits = "".join(ch for ch in ts if ch.isdigit())[:12]
|
||||
return f"evt_{digits}_{row:03d}"
|
||||
|
||||
|
||||
def agent_initials(name: str) -> str:
|
||||
"""First letter of each word, max 2."""
|
||||
return "".join(w[0] for w in name.split() if w)[:2].upper()
|
||||
|
||||
|
||||
def agent_avatar_colour(name: str) -> str:
|
||||
"""A fixed identity colour per agent kind, unchanged by theme."""
|
||||
if "Security" in name:
|
||||
return "#D13438"
|
||||
if "Cowork" in name:
|
||||
return "#0078D4"
|
||||
if name == "schedule" or "Task" in name:
|
||||
return "#FFB900"
|
||||
if name == "graphrag" or "Knowledge" in name:
|
||||
return "#8764B8"
|
||||
if "Code" in name:
|
||||
return "#107C10"
|
||||
if "Planner" in name or "Reasoning" in name:
|
||||
return "#8A8886"
|
||||
return "#0078D4"
|
||||
|
||||
|
||||
def agent_avatar_icon(name: str, size: int = 20) -> QIcon:
|
||||
"""A small round initials badge for the Agent column."""
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(agent_avatar_colour(name)))
|
||||
p.drawEllipse(0, 0, size, size)
|
||||
font = QFont()
|
||||
font.setPixelSize(max(7, size // 2))
|
||||
font.setBold(True)
|
||||
p.setFont(font)
|
||||
p.setPen(QColor("#FFFFFF"))
|
||||
p.drawText(pm.rect(), Qt.AlignCenter, agent_initials(name))
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""``kv_row`` — the "label — stretch — value" row shape that
|
||||
``ui/monitoring_tab.py`` used to redefine as 3 byte-identical local closures
|
||||
(Sandbox Details' ``_kv``, Permissions' ``_pkv``, the detail panel's
|
||||
``_field``). Overview's Resource Usage row (``_pair``) is a genuinely
|
||||
different layout (inline on one horizontal line with "·" separators, no
|
||||
stretch) and is intentionally NOT folded into this helper — unifying it would
|
||||
risk a visible layout change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout
|
||||
|
||||
|
||||
def kv_row(outer_layout: QVBoxLayout, hint_object_name: str = "hint") -> Tuple[QLabel, QLabel]:
|
||||
"""Appends a new ``label — stretch — value`` row to ``outer_layout``.
|
||||
Returns ``(label, value)``."""
|
||||
row = QHBoxLayout()
|
||||
label = QLabel()
|
||||
label.setObjectName(hint_object_name)
|
||||
value = QLabel()
|
||||
row.addWidget(label)
|
||||
row.addStretch(1)
|
||||
row.addWidget(value)
|
||||
outer_layout.addLayout(row)
|
||||
return label, value
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Opens the Settings dialog and runs a callback afterward — shared by the
|
||||
Sandbox Details and Permissions cards' "Edit" buttons, both of which used to
|
||||
call the identical ``MonitoringTab._open_settings_and_refresh``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
|
||||
def open_settings_and_notify(ctx, parent: QWidget, on_changed: Callable[[], None]) -> None:
|
||||
from ....ui.settings_dialog import SettingsDialog
|
||||
|
||||
dlg = SettingsDialog(ctx, parent)
|
||||
dlg.exec()
|
||||
on_changed()
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Action Logs tab — the full audit log, newest first. Extracted from
|
||||
``ui/monitoring_tab.py``'s action-table wiring inside
|
||||
``MonitoringTab.__init__``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, List
|
||||
|
||||
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
|
||||
|
||||
from ....i18n import tr
|
||||
from ..shared.ai_filter import start_ai_filter
|
||||
from ..shared.event_table import EventTable
|
||||
from ..shared.filter_scaffold import build_filter_scaffold
|
||||
|
||||
|
||||
class ActionLogsTab(QWidget):
|
||||
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._ai_state: dict = {}
|
||||
self.table = EventTable()
|
||||
parts = build_filter_scaffold(
|
||||
self, self.table, on_refresh=on_refresh_all,
|
||||
title_key="monitoring.action_logs_title",
|
||||
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
|
||||
self.title_lbl = parts["title_lbl"]
|
||||
self.title_key = parts["title_key"]
|
||||
self.title_refresh_btn = parts["title_refresh_btn"]
|
||||
self.filter_edit = parts["filter_edit"]
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
self.table.set_events(events)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.table.retranslate()
|
||||
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
||||
self.detail_panel.retranslate()
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Agent Status tab — which agent roles are currently running, read from the
|
||||
existing ``ChatPanel``/``TaskScheduler``/GraphRAG-ask-worker state (no new
|
||||
runtime tracking of its own). Extracted from ``ui/monitoring_tab.py``'s
|
||||
status-table wiring + ``_refresh_agent_status``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QSize
|
||||
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||
|
||||
from ....core import agent_roles
|
||||
from ....i18n import tr
|
||||
from ....ui.widgets import badge_pill_widget
|
||||
from ..shared.filter_scaffold import build_filter_scaffold
|
||||
from ..shared.formatters import agent_avatar_icon
|
||||
|
||||
|
||||
class AgentStatusTab(QWidget):
|
||||
def __init__(self, ctx, on_refresh_all: Callable[[], None],
|
||||
cowork=None, structure=None, task_scheduler=None):
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._cowork = cowork
|
||||
self._structure = structure
|
||||
self._task_scheduler = task_scheduler
|
||||
|
||||
self.table = QTableWidget(0, 3)
|
||||
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
# No detail to open on click, no filtering — a plain read-only table,
|
||||
# so selection is off rather than left dangling with no effect.
|
||||
self.table.setSelectionMode(QTableWidget.NoSelection)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.table.setColumnWidth(1, 130) # status is a cell widget — size it explicitly
|
||||
self.table.setIconSize(QSize(20, 20))
|
||||
|
||||
parts = build_filter_scaffold(
|
||||
self, self.table, on_refresh=on_refresh_all,
|
||||
title_key="monitoring.agent_status_title", with_search=False, with_detail=False)
|
||||
self.title_lbl = parts["title_lbl"]
|
||||
self.title_key = parts["title_key"]
|
||||
self.title_refresh_btn = parts["title_refresh_btn"]
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("monitoring.col_agent"), tr("monitoring.detail_status"), tr("monitoring.col_source"),
|
||||
])
|
||||
|
||||
def refresh(self) -> None:
|
||||
cowork_n = len(self._cowork.active_workers()) if self._cowork is not None else 0
|
||||
task_n = self._task_scheduler.running_count() if self._task_scheduler is not None else 0
|
||||
ask_worker = getattr(self._structure, "_ask_worker", None)
|
||||
knowledge_n = 1 if (ask_worker is not None and ask_worker.isRunning()) else 0
|
||||
|
||||
# Security is a system-management agent that runs INLINE on the
|
||||
# active turn (agent_security prompt/command validation) — there is
|
||||
# no separate worker to count, so its "active" cell shows On/Off
|
||||
# from Settings instead of a live count.
|
||||
sec_on = bool(self._ctx.config.agent_security.get("enabled"))
|
||||
|
||||
rows = [
|
||||
(agent_roles.COWORK, cowork_n, tr("monitoring.source_cowork")),
|
||||
(agent_roles.TASK, task_n, tr("monitoring.source_task")),
|
||||
(agent_roles.KNOWLEDGE, knowledge_n, tr("monitoring.source_knowledge")),
|
||||
(agent_roles.PLANNER, None, tr("monitoring.source_planner")),
|
||||
(agent_roles.REASONING, None, tr("monitoring.source_reasoning")),
|
||||
(agent_roles.SECURITY, None, tr("monitoring.source_security")),
|
||||
]
|
||||
self.table.setRowCount(len(rows))
|
||||
for row, (role_key, count, source) in enumerate(rows):
|
||||
label = agent_roles.label_for(role_key)
|
||||
name_item = QTableWidgetItem(label)
|
||||
name_item.setIcon(agent_avatar_icon(label))
|
||||
self.table.setItem(row, 0, name_item)
|
||||
|
||||
if role_key == agent_roles.SECURITY:
|
||||
running = sec_on
|
||||
status_text = tr("monitoring.on") if sec_on else tr("monitoring.off")
|
||||
elif count is not None:
|
||||
running = count > 0
|
||||
status_text = tr("monitoring.active_n", n=count) if running else tr("monitoring.idle")
|
||||
else:
|
||||
running = False
|
||||
status_text = "—"
|
||||
badge_tone = "badgeSuccess" if running else "badgeNeutral"
|
||||
self.table.setCellWidget(row, 1, badge_pill_widget(status_text, badge_tone))
|
||||
self.table.setItem(row, 2, QTableWidgetItem(source))
|
||||
@@ -0,0 +1,45 @@
|
||||
"""MCP Call History tab — the audit log filtered to ``kind="mcp_call"``.
|
||||
Extracted from ``ui/monitoring_tab.py``'s MCP-table wiring inside
|
||||
``MonitoringTab.__init__``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, List
|
||||
|
||||
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
|
||||
|
||||
from ....i18n import tr
|
||||
from ..shared.ai_filter import start_ai_filter
|
||||
from ..shared.event_table import EventTable
|
||||
from ..shared.filter_scaffold import build_filter_scaffold
|
||||
|
||||
|
||||
class McpTab(QWidget):
|
||||
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._ai_state: dict = {}
|
||||
self.table = EventTable()
|
||||
parts = build_filter_scaffold(
|
||||
self, self.table, on_refresh=on_refresh_all,
|
||||
title_key="monitoring.mcp_history_title",
|
||||
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
|
||||
self.title_lbl = parts["title_lbl"]
|
||||
self.title_key = parts["title_key"]
|
||||
self.title_refresh_btn = parts["title_refresh_btn"]
|
||||
self.filter_edit = parts["filter_edit"]
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
self.table.set_events(events)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.table.retranslate()
|
||||
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
||||
self.detail_panel.retranslate()
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Overview tab — the card-based dashboard (Token Usage & Cost, Resource
|
||||
Usage, Recent Activity, Sandbox Details + nested Permissions, Model Pricing,
|
||||
Audit Log preview). Extracted from ``ui/monitoring_tab.py``'s
|
||||
``_build_overview_page`` and the refresh/pricing/budget methods it wires to.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Callable, List
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout, QGroupBox, QHBoxLayout, QLabel, QProgressBar, QPushButton,
|
||||
QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....core import usage_tracker as ut
|
||||
from ....i18n import tr
|
||||
from ....theme import current_palette
|
||||
from ....ui.icons import DOT_AMBER, DOT_GREEN, DOT_RED, icon
|
||||
from ....ui.widgets import BudgetCard, StatCard, fmt_tokens
|
||||
from ..shared.formatters import fmt_bytes, relative_time
|
||||
from .pricing_panel import PricingPanel
|
||||
from .sandbox_tab import SandboxDetailsCard
|
||||
|
||||
|
||||
class OverviewTab(QWidget):
|
||||
def __init__(self, ctx, *, on_status_message: Callable[[str], None],
|
||||
on_settings_changed: Callable[[], None],
|
||||
on_view_all_action_logs: Callable[[], None],
|
||||
action_logs_tab_visible: bool):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._on_status_message = on_status_message
|
||||
self._on_view_all_action_logs = on_view_all_action_logs
|
||||
self._last_io_sample = None
|
||||
self._res_first = True
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll)
|
||||
|
||||
# ONE main column, scrolled vertically, sections in a fixed order.
|
||||
root = QVBoxLayout(content)
|
||||
root.setSpacing(12)
|
||||
|
||||
self._build_usage_section(root)
|
||||
self._build_activity_section() # added to `root` further down, beside the audit log
|
||||
self._build_resource_section(root)
|
||||
|
||||
self.sandbox_card = SandboxDetailsCard(ctx, on_settings_changed)
|
||||
root.addWidget(self.sandbox_card)
|
||||
|
||||
self.pricing_panel = PricingPanel(ctx, on_status_message)
|
||||
root.addWidget(self.pricing_panel)
|
||||
root.addWidget(self.activity_group)
|
||||
self._build_audit_section(root, action_logs_tab_visible)
|
||||
root.addStretch(1)
|
||||
|
||||
# ---- Token Usage & Cost ------------------------------------------------
|
||||
def _build_usage_section(self, root: QVBoxLayout) -> None:
|
||||
self.usage_group = QGroupBox()
|
||||
self.usage_group.setObjectName("monSection")
|
||||
usage_lay = QGridLayout(self.usage_group)
|
||||
usage_lay.setSpacing(8)
|
||||
self.usage_total = StatCard()
|
||||
self.usage_in = StatCard()
|
||||
self.usage_out = StatCard()
|
||||
self.usage_cache = StatCard()
|
||||
self.usage_cost = StatCard()
|
||||
self.usage_calls = StatCard()
|
||||
for i, card in enumerate((self.usage_cost, self.usage_total,
|
||||
self.usage_in, self.usage_out, self.usage_cache)):
|
||||
usage_lay.addWidget(card, 0, i)
|
||||
self.usage_calls.setVisible(False) # rides on the cost tile's label
|
||||
self.budget_card = BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
usage_lay.addWidget(self.budget_card, 0, 3)
|
||||
for col in range(4):
|
||||
usage_lay.setColumnStretch(col, 1)
|
||||
root.addWidget(self.usage_group)
|
||||
|
||||
# ---- Recent Activity ----------------------------------------------------
|
||||
def _build_activity_section(self) -> None:
|
||||
self.activity_group = QGroupBox()
|
||||
self.activity_group.setObjectName("monSection")
|
||||
act_lay = QVBoxLayout(self.activity_group)
|
||||
self.activity_lbl = QLabel()
|
||||
self.activity_lbl.setWordWrap(True)
|
||||
self.activity_lbl.setTextFormat(Qt.RichText)
|
||||
act_lay.addWidget(self.activity_lbl)
|
||||
|
||||
# ---- Resource Usage -------------------------------------------------------
|
||||
def _build_resource_section(self, root: QVBoxLayout) -> None:
|
||||
self.resource_group = QGroupBox()
|
||||
self.resource_group.setObjectName("monSection")
|
||||
res_lay = QHBoxLayout(self.resource_group)
|
||||
res_lay.setSpacing(6)
|
||||
|
||||
def _pair():
|
||||
if not self._res_first:
|
||||
sep = QLabel(chr(0xB7))
|
||||
sep.setObjectName("hint")
|
||||
res_lay.addWidget(sep)
|
||||
self._res_first = False
|
||||
lbl = QLabel()
|
||||
lbl.setObjectName("hint")
|
||||
val = QLabel()
|
||||
res_lay.addWidget(lbl)
|
||||
res_lay.addWidget(val)
|
||||
return lbl, val
|
||||
|
||||
def _bar_row():
|
||||
lbl, val = _pair()
|
||||
bar = QProgressBar()
|
||||
bar.setVisible(False)
|
||||
return lbl, bar, val
|
||||
|
||||
self.cpu_lbl, self.cpu_bar, self.cpu_val = _bar_row()
|
||||
self.mem_lbl, self.mem_bar, self.mem_val = _bar_row()
|
||||
self.diskfree_lbl, self.diskfree_val = _pair()
|
||||
self.disk_lbl, self.disk_val = QLabel(), QLabel()
|
||||
self.network_lbl, self.network_val = QLabel(), QLabel()
|
||||
res_lay.addStretch(1)
|
||||
root.addWidget(self.resource_group)
|
||||
|
||||
# ---- Audit Log preview --------------------------------------------------
|
||||
def _build_audit_section(self, root: QVBoxLayout, action_logs_tab_visible: bool) -> None:
|
||||
self.audit_group = QGroupBox()
|
||||
self.audit_group.setObjectName("monSection")
|
||||
audit_lay = QVBoxLayout(self.audit_group)
|
||||
self.audit_lbl = QLabel()
|
||||
self.audit_lbl.setWordWrap(True)
|
||||
self.audit_lbl.setTextFormat(Qt.RichText)
|
||||
audit_lay.addWidget(self.audit_lbl)
|
||||
self.view_all_btn = QPushButton()
|
||||
self.view_all_btn.setFlat(True)
|
||||
self.view_all_btn.clicked.connect(lambda: self._on_view_all_action_logs())
|
||||
audit_lay.addWidget(self.view_all_btn, 0, Qt.AlignRight)
|
||||
self.audit_group.setVisible(action_logs_tab_visible)
|
||||
root.addWidget(self.audit_group)
|
||||
|
||||
# ---- budget --------------------------------------------------------------
|
||||
def _apply_budget(self) -> None:
|
||||
"""Persist the spin box's value as the new budget — starts a fresh
|
||||
remaining-balance window (spend before now is no longer counted)."""
|
||||
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
||||
ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy)
|
||||
self.ctx.save()
|
||||
self._refresh_budget()
|
||||
|
||||
def _refresh_budget(self) -> None:
|
||||
from ....core import model_pricing as mp
|
||||
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
status = ut.budget_status(self.ctx.config)
|
||||
if status is None:
|
||||
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
||||
self.budget_card.budget_spin.setValue(0.0)
|
||||
return
|
||||
amount_disp = mp.convert(status["amount_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
||||
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
||||
pct = int(round(status["pct_used"] * 100))
|
||||
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
||||
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
||||
if not self.budget_card.budget_spin.hasFocus():
|
||||
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
||||
|
||||
# ---- resource usage --------------------------------------------------------
|
||||
def _refresh_resource_usage(self) -> None:
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
self._set_resource_na()
|
||||
return
|
||||
|
||||
try:
|
||||
own = psutil.Process()
|
||||
own_cpu = own.cpu_percent(interval=None)
|
||||
own_mem = own.memory_info().rss
|
||||
except Exception:
|
||||
own, own_cpu, own_mem = None, 0.0, 0
|
||||
|
||||
self.cpu_bar.setValue(int(min(own_cpu, 100)))
|
||||
self.cpu_val.setText(f"{own_cpu:.0f}%")
|
||||
try:
|
||||
total_mem = psutil.virtual_memory().total
|
||||
mem_pct = int(own_mem * 100 / total_mem) if total_mem else 0
|
||||
except Exception:
|
||||
mem_pct = 0
|
||||
self.mem_bar.setValue(min(mem_pct, 100))
|
||||
try:
|
||||
self.mem_val.setText(f"{fmt_bytes(own_mem)}/{fmt_bytes(total_mem)}")
|
||||
except Exception: # noqa: BLE001
|
||||
self.mem_val.setText(fmt_bytes(own_mem))
|
||||
try:
|
||||
free = psutil.disk_usage(str(self.ctx.config.cowork_output_dir())).free
|
||||
self.diskfree_val.setText(tr("monitoring.overview_disk_free", size=fmt_bytes(free)))
|
||||
except Exception: # noqa: BLE001
|
||||
self.diskfree_val.setText(tr("monitoring.na"))
|
||||
|
||||
now = time.monotonic()
|
||||
try:
|
||||
io = own.io_counters() if own is not None else None
|
||||
disk_bytes = (io.read_bytes + io.write_bytes) if io is not None else None
|
||||
except Exception:
|
||||
disk_bytes = None
|
||||
try:
|
||||
net = psutil.net_io_counters()
|
||||
net_bytes = net.bytes_sent + net.bytes_recv
|
||||
except Exception:
|
||||
net_bytes = None
|
||||
|
||||
prev = self._last_io_sample
|
||||
self._last_io_sample = (now, disk_bytes, net_bytes)
|
||||
na = tr("monitoring.na")
|
||||
if prev and disk_bytes is not None and prev[1] is not None and now > prev[0]:
|
||||
rate = max(0.0, (disk_bytes - prev[1]) / (now - prev[0]))
|
||||
self.disk_val.setText(f"{fmt_bytes(rate)}/s")
|
||||
else:
|
||||
self.disk_val.setText(na)
|
||||
if prev and net_bytes is not None and prev[2] is not None and now > prev[0]:
|
||||
rate = max(0.0, (net_bytes - prev[2]) / (now - prev[0]))
|
||||
self.network_val.setText(f"{fmt_bytes(rate)}/s")
|
||||
else:
|
||||
self.network_val.setText(na)
|
||||
|
||||
def _set_resource_na(self) -> None:
|
||||
na = tr("monitoring.na")
|
||||
self.cpu_bar.setValue(0)
|
||||
self.cpu_val.setText(na)
|
||||
self.mem_bar.setValue(0)
|
||||
self.mem_val.setText(na)
|
||||
self.disk_val.setText(na)
|
||||
self.network_val.setText(na)
|
||||
|
||||
# ---- usage cards -----------------------------------------------------------
|
||||
def _activity_line(self, event: dict) -> str:
|
||||
ok = event.get("ok", True)
|
||||
if ok:
|
||||
mark = f"<span style='color:{DOT_GREEN};'>✓</span>"
|
||||
elif event.get("kind") == "security_block":
|
||||
mark = f"<span style='color:{DOT_AMBER};'>!</span>"
|
||||
else:
|
||||
mark = f"<span style='color:{DOT_RED};'>✗</span>"
|
||||
name = event.get("name", "") or event.get("kind", "")
|
||||
rel = relative_time(event.get("ts", ""))
|
||||
muted = current_palette().text_muted
|
||||
suffix = f" <span style='color:{muted};'>— {rel}</span>" if rel else ""
|
||||
return f"{mark} {name}{suffix}"
|
||||
|
||||
def _refresh_usage_cards(self) -> None:
|
||||
from ....core import model_pricing as mp
|
||||
mp.sync_to_usage(self.ctx.config) # cost total comes straight from the price table
|
||||
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
events = ut.load_events()
|
||||
s = ut.summarize(events)
|
||||
costs = ut.cost_usd_events(events, pricing)
|
||||
self.usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]))
|
||||
self.usage_calls.set(tr("monitoring.overview_calls"), str(s["turns"]), "")
|
||||
self.usage_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
|
||||
ut.format_cost(costs["in"], pricing))
|
||||
self.usage_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
|
||||
ut.format_cost(costs["out"], pricing))
|
||||
self.usage_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
|
||||
ut.format_cost(costs["cache"], pricing))
|
||||
self.usage_cost.set(
|
||||
f'{tr("dashboard.card_cost")} · {s["turns"]} {tr("monitoring.overview_calls")}',
|
||||
ut.format_cost(sum(costs.values()), pricing, digits=2))
|
||||
self._refresh_budget()
|
||||
|
||||
# ---- public API used by the container ---------------------------------
|
||||
def refresh(self, events: List[dict]) -> None:
|
||||
"""Full refresh — resource usage + usage cards + sandbox/permissions
|
||||
+ recent activity + audit preview. ``events`` is the already-loaded
|
||||
(local-or-shared) audit log, shared with the event-table tabs so the
|
||||
decision of which source to read from is made exactly once per
|
||||
refresh tick."""
|
||||
self._refresh_resource_usage()
|
||||
self._refresh_usage_cards()
|
||||
self.sandbox_card.refresh()
|
||||
|
||||
recent = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
|
||||
if recent:
|
||||
self.activity_lbl.setText("<br>".join(self._activity_line(e) for e in recent[:6]))
|
||||
self.audit_lbl.setText("<br>".join(
|
||||
f"{e.get('ts', '')} — {e.get('name', '')}" for e in recent[:4]))
|
||||
else:
|
||||
self.activity_lbl.setText(tr("monitoring.overview_no_activity"))
|
||||
self.audit_lbl.setText(tr("monitoring.overview_no_activity"))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.usage_group.setTitle(tr("monitoring.overview_usage_title").upper().replace("&", "&&"))
|
||||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
self.activity_group.setTitle(tr("monitoring.overview_activity_title").upper())
|
||||
self.resource_group.setTitle(tr("monitoring.overview_resource_title").upper())
|
||||
self.cpu_lbl.setText(tr("monitoring.overview_res_cpu"))
|
||||
self.mem_lbl.setText(tr("monitoring.overview_res_mem"))
|
||||
self.diskfree_lbl.setText(tr("monitoring.overview_disk_label"))
|
||||
self.disk_lbl.setText(tr("monitoring.overview_res_disk"))
|
||||
self.network_lbl.setText(tr("monitoring.overview_res_network"))
|
||||
self.pricing_panel.retranslate()
|
||||
self.sandbox_card.retranslate()
|
||||
self.audit_group.setTitle(tr("monitoring.overview_audit_title").upper())
|
||||
self.view_all_btn.setText(tr("monitoring.overview_view_all"))
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Model Pricing panel — the editable price-table card on the Overview page
|
||||
(currency picker, import/export/add/auto-link/delete, and the table itself).
|
||||
Extracted from ``ui/monitoring_tab.py``'s pricing-table construction and
|
||||
``_reload_pricing_table``/``_import_pricing``/``_export_pricing``/
|
||||
``_add_pricing_row``/``_autolink_pricing``/``_delete_pricing_row``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout,
|
||||
)
|
||||
|
||||
from ....core import usage_tracker as ut
|
||||
from ....i18n import tr
|
||||
from ....ui.icons import icon
|
||||
|
||||
|
||||
class PricingPanel(QGroupBox):
|
||||
def __init__(self, ctx, on_status_message: Callable[[str], None]):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._on_status_message = on_status_message
|
||||
self._worker = None
|
||||
self.setObjectName("monSection")
|
||||
|
||||
pg = QVBoxLayout(self)
|
||||
phdr = QHBoxLayout()
|
||||
self.ccy_lbl = QLabel()
|
||||
self.ccy_lbl.setObjectName("hint")
|
||||
self.ccy = QComboBox()
|
||||
for cur in ut.SUPPORTED_CURRENCIES:
|
||||
self.ccy.addItem(cur, cur)
|
||||
pidx = self.ccy.findData((self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
||||
self.ccy.setCurrentIndex(max(0, pidx))
|
||||
self.ccy.currentIndexChanged.connect(self._reload_table)
|
||||
phdr.addWidget(self.ccy_lbl)
|
||||
phdr.addWidget(self.ccy)
|
||||
phdr.addStretch(1)
|
||||
self.import_btn = QPushButton()
|
||||
self.import_btn.setIcon(icon("download"))
|
||||
self.import_btn.clicked.connect(self._import_pricing)
|
||||
self.export_btn = QPushButton()
|
||||
self.export_btn.setIcon(icon("upload"))
|
||||
self.export_btn.clicked.connect(self._export_pricing)
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.clicked.connect(self._add_pricing_row)
|
||||
self.link_btn = QPushButton()
|
||||
self.link_btn.setIcon(icon("refresh"))
|
||||
self.link_btn.clicked.connect(self._autolink_pricing)
|
||||
self.del_btn = QPushButton()
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.clicked.connect(self._delete_pricing_row)
|
||||
for b in (self.import_btn, self.export_btn, self.add_btn, self.link_btn, self.del_btn):
|
||||
phdr.addWidget(b)
|
||||
pg.addLayout(phdr)
|
||||
|
||||
self.table = QTableWidget(0, 5)
|
||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
pg.addWidget(self.table, 1)
|
||||
|
||||
self._reload_table()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(tr("monitoring.pricing_title").upper())
|
||||
self.ccy_lbl.setText(tr("monitoring.pricing_currency"))
|
||||
self.import_btn.setText(tr("monitoring.pricing_import"))
|
||||
self.export_btn.setText(tr("monitoring.pricing_export"))
|
||||
self.add_btn.setText(tr("monitoring.pricing_add"))
|
||||
self.link_btn.setText(tr("monitoring.pricing_autolink"))
|
||||
self.del_btn.setText(tr("monitoring.pricing_delete"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("monitoring.pricing_col_model"), tr("monitoring.pricing_col_context"),
|
||||
tr("monitoring.pricing_col_maxout"), tr("monitoring.pricing_col_input"),
|
||||
tr("monitoring.pricing_col_output")])
|
||||
|
||||
def _reload_table(self, *_a) -> None:
|
||||
from ....core import model_pricing as mp
|
||||
to_ccy = self.ccy.currentData() or "USD"
|
||||
entries = mp.list_entries(self.ctx.config)
|
||||
self.table.setRowCount(len(entries))
|
||||
for r, e in enumerate(entries):
|
||||
in_v = mp.convert(e.get("input_price", 0), e.get("input_ccy", "USD"), to_ccy, self.ctx.config)
|
||||
out_v = mp.convert(e.get("output_price", 0), e.get("output_ccy", "USD"), to_ccy, self.ctx.config)
|
||||
vals = [e.get("model", ""), e.get("context_length", ""), e.get("max_output", ""),
|
||||
f"{mp.format_price(in_v, to_ccy)} / {e.get('input_unit', '')}",
|
||||
f"{mp.format_price(out_v, to_ccy)} / {e.get('output_unit', '')}"]
|
||||
for c, v in enumerate(vals):
|
||||
self.table.setItem(r, c, QTableWidgetItem(str(v)))
|
||||
|
||||
def _import_pricing(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog, QMessageBox
|
||||
|
||||
from ....core import model_pricing as mp
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, tr("monitoring.pricing_import"), "", "Pricing (*.xlsx *.csv)")
|
||||
if not path:
|
||||
return
|
||||
default_ccy = self.ccy.currentData() or "USD"
|
||||
try:
|
||||
imported = mp.import_table(path, default_ccy=default_ccy)
|
||||
except ValueError as exc:
|
||||
QMessageBox.warning(self, tr("monitoring.pricing_title"), str(exc))
|
||||
return
|
||||
merged = {e["model"]: e for e in mp.list_entries(self.ctx.config)}
|
||||
for e in imported:
|
||||
merged[e["model"]] = e
|
||||
mp.save_entries(self.ctx.config, list(merged.values()))
|
||||
self.ctx.save()
|
||||
self._reload_table()
|
||||
self._on_status_message(tr("monitoring.pricing_imported", n=len(imported)))
|
||||
|
||||
def _export_pricing(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from ....core import model_pricing as mp
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("monitoring.pricing_export"), "model_pricing_template.xlsx", "Excel (*.xlsx)")
|
||||
if not path:
|
||||
return
|
||||
mp.export_template(path)
|
||||
self._on_status_message(tr("monitoring.pricing_exported"))
|
||||
|
||||
def _add_pricing_row(self) -> None:
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
from ....core import model_pricing as mp
|
||||
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"),
|
||||
tr("monitoring.pricing_add_prompt"))
|
||||
name = (name or "").strip()
|
||||
if not ok or not name:
|
||||
return
|
||||
ccy = self.ccy.currentData() or "USD"
|
||||
mp.add_entry(self.ctx.config, mp.entry_from_row(
|
||||
[name, "", "", "0", "Million tokens", "0", "Million tokens"], default_ccy=ccy))
|
||||
self.ctx.save()
|
||||
self._reload_table()
|
||||
|
||||
def _autolink_pricing(self) -> None:
|
||||
from ....core import model_pricing as mp
|
||||
from ....core.worker import AgentWorker
|
||||
if self._worker is not None:
|
||||
return
|
||||
self.link_btn.setEnabled(False)
|
||||
ctx = self.ctx
|
||||
ccy = self.ccy.currentData() or "USD"
|
||||
|
||||
def job(_w):
|
||||
return {"entries": mp.auto_link(ctx, default_ccy=ccy)}
|
||||
|
||||
def done(r):
|
||||
self._worker = None
|
||||
self.link_btn.setEnabled(True)
|
||||
self.ctx.save()
|
||||
self._reload_table()
|
||||
self._on_status_message(tr("monitoring.pricing_linked", n=len(r.get("entries", []))))
|
||||
|
||||
def failed(_e):
|
||||
self._worker = None
|
||||
self.link_btn.setEnabled(True)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _delete_pricing_row(self) -> None:
|
||||
from ....core import model_pricing as mp
|
||||
row = self.table.currentRow()
|
||||
entries = mp.list_entries(self.ctx.config)
|
||||
if 0 <= row < len(entries):
|
||||
del entries[row]
|
||||
mp.save_entries(self.ctx.config, entries)
|
||||
self.ctx.save()
|
||||
self._reload_table()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Sandbox Details card — the current sandbox id/status/uptime/resource
|
||||
limits/network state, with a collapsible fold that ALSO nests the
|
||||
Permissions card inside it (see ``security_settings_tab.PermissionsCard``),
|
||||
exactly matching the pre-refactor ``ui/monitoring_tab.py`` layout: Sandbox
|
||||
and Permissions answer the same question ("what is the agent allowed to
|
||||
touch?"), so they share one fold rather than being two independent
|
||||
top-level sections. This card is embedded inside ``overview_tab.OverviewTab``
|
||||
at the same position the original ``QGroupBox`` occupied — no new top-level
|
||||
tab is added, so the visible UI is unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from ....i18n import tr
|
||||
from ..shared.badges import apply_badge
|
||||
from ..shared.layout_helpers import kv_row
|
||||
from ..shared.open_settings import open_settings_and_notify
|
||||
from .security_settings_tab import PermissionsCard
|
||||
|
||||
|
||||
class SandboxDetailsCard(QGroupBox):
|
||||
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._on_settings_changed = on_settings_changed
|
||||
self.setObjectName("monSection")
|
||||
sbx_lay = QVBoxLayout(self)
|
||||
|
||||
self.summary_lbl = QLabel()
|
||||
self.summary_lbl.setWordWrap(True)
|
||||
sbx_lay.addWidget(self.summary_lbl)
|
||||
|
||||
self.more_btn = QPushButton()
|
||||
self.more_btn.setObjectName("co4eSectionAction")
|
||||
self.more_btn.setFlat(True)
|
||||
self.more_btn.setCheckable(True)
|
||||
self.more_btn.setCursor(Qt.PointingHandCursor)
|
||||
sbx_lay.addWidget(self.more_btn, 0, Qt.AlignLeft)
|
||||
|
||||
self._detail = QWidget()
|
||||
self._detail.setVisible(False)
|
||||
self.more_btn.toggled.connect(self._detail.setVisible)
|
||||
self.more_btn.toggled.connect(self._sync_more_label)
|
||||
sbx_lay.addWidget(self._detail)
|
||||
detail_lay = QVBoxLayout(self._detail)
|
||||
detail_lay.setContentsMargins(0, 4, 0, 0)
|
||||
|
||||
self.id_lbl, self.id_val = kv_row(detail_lay)
|
||||
self.status_lbl, self.status_val = kv_row(detail_lay)
|
||||
self.status_val.setObjectName("badgeSuccess")
|
||||
self.created_lbl, self.created_val = kv_row(detail_lay)
|
||||
self.uptime_lbl, self.uptime_val = kv_row(detail_lay)
|
||||
|
||||
limits_row = QHBoxLayout()
|
||||
self.limits_lbl = QLabel()
|
||||
self.limits_lbl.setObjectName("hint")
|
||||
self.limits_lbl.setWordWrap(True)
|
||||
self.edit_btn = QPushButton()
|
||||
self.edit_btn.setFlat(True)
|
||||
self.edit_btn.clicked.connect(self._open_settings)
|
||||
limits_row.addWidget(self.limits_lbl, 1)
|
||||
limits_row.addWidget(self.edit_btn)
|
||||
detail_lay.addLayout(limits_row)
|
||||
|
||||
self.net_lbl, self.net_val = kv_row(detail_lay)
|
||||
|
||||
# The Permissions card is nested inside THIS fold, not a sibling
|
||||
# section — matches the original layout exactly.
|
||||
self.permissions_card = PermissionsCard(ctx, on_settings_changed)
|
||||
detail_lay.addWidget(self.permissions_card)
|
||||
|
||||
def _open_settings(self) -> None:
|
||||
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
|
||||
|
||||
def _sync_more_label(self, *_a) -> None:
|
||||
"""Label the fold with what it will do next."""
|
||||
open_ = self.more_btn.isChecked()
|
||||
self.more_btn.setText(("▾ " if open_ else "▸ ") + tr("monitoring.overview_sbx_detail"))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(tr("monitoring.overview_sandbox_details_title").upper().replace("&", "&&"))
|
||||
self.id_lbl.setText(tr("monitoring.overview_sandbox_id"))
|
||||
self.status_lbl.setText(tr("monitoring.overview_status"))
|
||||
self.created_lbl.setText(tr("monitoring.overview_created"))
|
||||
self.uptime_lbl.setText(tr("monitoring.overview_uptime"))
|
||||
self.edit_btn.setText(tr("monitoring.overview_edit"))
|
||||
self.net_lbl.setText(tr("monitoring.overview_network_label"))
|
||||
self.permissions_card.retranslate()
|
||||
self._sync_more_label()
|
||||
|
||||
def refresh(self) -> None:
|
||||
sec = self._ctx.config.agent_security
|
||||
net_blocked = bool(sec.get("block_network"))
|
||||
|
||||
self.id_val.setText(f"sbx_{os.getpid():x}")
|
||||
self.status_val.setText(tr("monitoring.overview_status_running"))
|
||||
self.created_val.setText(datetime.fromtimestamp(self._ctx.started_at).strftime("%H:%M:%S"))
|
||||
uptime_s = max(0, int(time.time() - self._ctx.started_at))
|
||||
h, rem = divmod(uptime_s, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
self.uptime_val.setText(f"{h}h {m}m {s}s" if h else f"{m}m {s}s")
|
||||
|
||||
limit_parts = []
|
||||
if sec.get("resource_limit_cpu_percent"):
|
||||
limit_parts.append(f"CPU {sec['resource_limit_cpu_percent']}%")
|
||||
if sec.get("resource_limit_memory_mb"):
|
||||
limit_parts.append(f"MEM {sec['resource_limit_memory_mb']}MB")
|
||||
if sec.get("resource_limit_disk_mb"):
|
||||
limit_parts.append(f"DISK {sec['resource_limit_disk_mb']}MB")
|
||||
limits_text = ", ".join(limit_parts) if limit_parts else tr("monitoring.na")
|
||||
self.limits_lbl.setText(tr("monitoring.overview_resource_limits") + ": " + limits_text)
|
||||
|
||||
self.net_val.setText(
|
||||
tr("monitoring.overview_network_disabled") if net_blocked
|
||||
else tr("monitoring.overview_network_enabled"))
|
||||
apply_badge(self.net_val, "badgeWarn" if net_blocked else "badgeSuccess")
|
||||
|
||||
# The one line the wireframe shows; the detail above stays a fold away.
|
||||
self.summary_lbl.setText(" · ".join([
|
||||
f'{tr("monitoring.overview_perm_fs")}: {tr("monitoring.overview_perm_fs_value")}',
|
||||
f'{tr("monitoring.overview_perm_network")}: '
|
||||
f'{tr("monitoring.overview_network_disabled") if net_blocked else tr("monitoring.overview_network_enabled")}',
|
||||
f'{tr("monitoring.overview_perm_process")}: {tr("monitoring.overview_perm_process_value")}',
|
||||
f'{tr("monitoring.overview_resource_limits")}: {limits_text}',
|
||||
]))
|
||||
self._sync_more_label()
|
||||
|
||||
self.permissions_card.refresh()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Security Events tab — the audit log filtered to ``kind="security_block"``.
|
||||
Extracted from ``ui/monitoring_tab.py``'s security-events wiring inside
|
||||
``MonitoringTab.__init__``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, List
|
||||
|
||||
from PySide6.QtWidgets import QLineEdit, QPushButton, QWidget
|
||||
|
||||
from ....i18n import tr
|
||||
from ..shared.ai_filter import start_ai_filter
|
||||
from ..shared.event_table import EventTable
|
||||
from ..shared.filter_scaffold import build_filter_scaffold
|
||||
|
||||
|
||||
class SecurityEventsTab(QWidget):
|
||||
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._ai_state: dict = {}
|
||||
# Security events are always ok=False, so this table trades the
|
||||
# tick/cross column for a tinted Action column (see EventTable).
|
||||
self.table = EventTable(show_result=False)
|
||||
parts = build_filter_scaffold(
|
||||
self, self.table, on_refresh=on_refresh_all,
|
||||
title_key="monitoring.security_events_title",
|
||||
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter)
|
||||
self.title_lbl = parts["title_lbl"]
|
||||
self.title_key = parts["title_key"]
|
||||
self.title_refresh_btn = parts["title_refresh_btn"]
|
||||
self.filter_edit = parts["filter_edit"]
|
||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||
self.detail_panel = parts["detail_panel"]
|
||||
|
||||
def set_events(self, events: List[dict]) -> None:
|
||||
self.table.set_events(events)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.table.retranslate()
|
||||
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
|
||||
self.detail_panel.retranslate()
|
||||
self.title_lbl.setText(tr(self.title_key))
|
||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||
|
||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Permissions card — "what is the agent allowed to touch?", displayed
|
||||
nested inside the Sandbox Details card's expandable fold (see
|
||||
``sandbox_tab.SandboxDetailsCard``), exactly as in the pre-refactor
|
||||
``ui/monitoring_tab.py`` (``self._sbx_detail.layout().addWidget(self.ov_permissions_group)``).
|
||||
Editing still opens the same Settings dialog as the Sandbox card's own
|
||||
"Edit" button — this card only DISPLAYS ``ctx.config.agent_security``, it
|
||||
does not host its own settings-editing UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QGroupBox, QPushButton, QVBoxLayout
|
||||
|
||||
from ....i18n import tr
|
||||
from ..shared.badges import apply_badge
|
||||
from ..shared.layout_helpers import kv_row
|
||||
from ..shared.open_settings import open_settings_and_notify
|
||||
|
||||
|
||||
class PermissionsCard(QGroupBox):
|
||||
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
self._on_settings_changed = on_settings_changed
|
||||
self.setObjectName("monSection")
|
||||
perm_lay = QVBoxLayout(self)
|
||||
|
||||
self.fs_lbl, self.fs_val = kv_row(perm_lay)
|
||||
self.network_lbl, self.network_val = kv_row(perm_lay)
|
||||
self.process_lbl, self.process_val = kv_row(perm_lay)
|
||||
self.env_lbl, self.env_val = kv_row(perm_lay)
|
||||
|
||||
self.edit_btn = QPushButton()
|
||||
self.edit_btn.setFlat(True)
|
||||
self.edit_btn.clicked.connect(self._open_settings)
|
||||
perm_lay.addWidget(self.edit_btn, 0, Qt.AlignLeft)
|
||||
|
||||
def _open_settings(self) -> None:
|
||||
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setTitle(tr("monitoring.overview_permissions_title").upper())
|
||||
self.fs_lbl.setText(tr("monitoring.overview_perm_fs"))
|
||||
self.fs_val.setText(tr("monitoring.overview_perm_fs_value"))
|
||||
self.network_lbl.setText(tr("monitoring.overview_perm_network"))
|
||||
self.process_lbl.setText(tr("monitoring.overview_perm_process"))
|
||||
self.process_val.setText(tr("monitoring.overview_perm_process_value"))
|
||||
self.env_lbl.setText(tr("monitoring.overview_perm_env"))
|
||||
self.env_val.setText(tr("monitoring.overview_perm_env_value"))
|
||||
self.edit_btn.setText(tr("monitoring.overview_edit"))
|
||||
|
||||
def refresh(self) -> None:
|
||||
net_blocked = bool(self._ctx.config.agent_security.get("block_network"))
|
||||
self.network_val.setText(
|
||||
tr("monitoring.overview_perm_network_blocked") if net_blocked
|
||||
else tr("monitoring.overview_perm_network_allowed"))
|
||||
apply_badge(self.network_val, "badgeWarn" if net_blocked else "badgeSuccess")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Mục Chung trong Cài đặt — R08-T07.
|
||||
|
||||
Ngôn ngữ, giao diện, khay hệ thống, và dòng gợi ý cuối trang.
|
||||
|
||||
Bản trước khi tách dựng mục này thành một ``QFormLayout`` rời, gắn vào layout
|
||||
gốc, rồi ở đoạn lắp ráp lại gỡ ra để nhét vào hộp riêng — kèm một widget cao
|
||||
0 pixel làm mốc cuộn. Vòng vo đó chỉ tồn tại vì mục này không phải group box
|
||||
như bốn mục kia. Gói thành widget là hết: nó tự là một trang, không cần gỡ ra
|
||||
gắn vào, không cần mốc giả.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from ...i18n import LANGUAGES, tr
|
||||
from ...ui.widgets import SegmentedControl, ToggleSwitch
|
||||
|
||||
|
||||
class GeneralSettingsWidget(QWidget):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(parent)
|
||||
data = ctx.config.data
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
form = QFormLayout()
|
||||
|
||||
self.language_combo = SegmentedControl()
|
||||
for key, label in LANGUAGES.items():
|
||||
self.language_combo.addItem(label, key)
|
||||
_select(self.language_combo, ctx.config.language)
|
||||
form.addRow(tr("settings.language"), self.language_combo)
|
||||
|
||||
# Giao diện cũng có trên hàng tài khoản ở thanh bên (một cú bấm để lật
|
||||
# nhanh); đây là cùng một giá trị, nhưng có tên và có giải thích, cho
|
||||
# người đi tìm nó trong Cài đặt.
|
||||
self.theme_combo = SegmentedControl()
|
||||
for key in ("system", "dark", "light"):
|
||||
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
|
||||
_select(self.theme_combo, getattr(ctx.config, "theme", "system"))
|
||||
form.addRow(tr("settings.theme"), self.theme_combo)
|
||||
|
||||
tray = data.get("tray", {})
|
||||
self.tray_chk = ToggleSwitch(tr("settings.tray_keep"))
|
||||
self.tray_chk.setChecked(bool(tray.get("minimize_on_close", True)))
|
||||
form.addRow("", self.tray_chk)
|
||||
self.notify_chk = ToggleSwitch(tr("settings.tray_notify"))
|
||||
self.notify_chk.setChecked(bool(tray.get("notify_on_done", True)))
|
||||
form.addRow("", self.notify_chk)
|
||||
|
||||
outer.addLayout(form)
|
||||
|
||||
note = QLabel(tr("settings.tip"))
|
||||
note.setObjectName("hint")
|
||||
note.setWordWrap(True) # không thì đúng một dòng này quyết định bề ngang dialog
|
||||
outer.addWidget(note)
|
||||
outer.addStretch(1)
|
||||
|
||||
# ---- lưu ------------------------------------------------------------
|
||||
|
||||
def apply_to(self, data: dict) -> None:
|
||||
data["language"] = self.language_combo.currentData()
|
||||
# MainWindow._open_settings áp lại giao diện sau khi dialog đóng, nên
|
||||
# ghi giá trị ở đây là đủ để nó có hiệu lực.
|
||||
data["theme"] = self.theme_combo.currentData()
|
||||
tray = data.setdefault("tray", {})
|
||||
tray["minimize_on_close"] = self.tray_chk.isChecked()
|
||||
tray["notify_on_done"] = self.notify_chk.isChecked()
|
||||
|
||||
|
||||
def _select(combo, value: str) -> None:
|
||||
i = combo.findData(value)
|
||||
if i >= 0:
|
||||
combo.setCurrentIndex(i)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Mục Tham số trong Cài đặt — R08-T07.
|
||||
|
||||
Bóc từ ``ui/settings_dialog.py``. Ba nhóm con: đính kèm, cấu trúc/GraphRAG,
|
||||
và giới hạn tài nguyên sandbox.
|
||||
|
||||
Lưu ý khi đọc: nhóm thứ ba **hiện** ở đây nhưng **lưu** vào ``agent_security``
|
||||
chứ không phải một khoá riêng — nó vốn nằm ở mục Bảo mật sandbox rồi được dời
|
||||
sang đây cho gần các con số khác. Chỗ hiện và chỗ lưu khác nhau, đừng gộp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QFormLayout, QGroupBox, QLabel, QSpinBox
|
||||
|
||||
from ...i18n import tr
|
||||
|
||||
|
||||
class ParameterSettingsWidget(QGroupBox):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(tr("settings.group.parameter"), parent)
|
||||
data = ctx.config.data
|
||||
sec = ctx.config.agent_security
|
||||
form = QFormLayout(self)
|
||||
|
||||
def tieu_de(key: str) -> None:
|
||||
lbl = QLabel(tr(key))
|
||||
lbl.setStyleSheet("font-weight:600; margin-top:6px;")
|
||||
form.addRow(lbl)
|
||||
|
||||
# --- đính kèm ---
|
||||
att = data.get("attachments", {})
|
||||
tieu_de("settings.group.attachments")
|
||||
self.attach_files = QSpinBox()
|
||||
self.attach_files.setRange(1, 50)
|
||||
self.attach_files.setSuffix(tr("settings.max_files_suffix"))
|
||||
self.attach_files.setValue(max(1, int(att.get("max_files", 20))))
|
||||
self.attach_files.setToolTip(tr("settings.max_files_tooltip"))
|
||||
self.attach_tokens = QSpinBox()
|
||||
self.attach_tokens.setRange(1, 1000)
|
||||
self.attach_tokens.setSingleStep(5)
|
||||
self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix"))
|
||||
# Lưu theo token, hiện theo nghìn token.
|
||||
self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000))
|
||||
self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip"))
|
||||
form.addRow(tr("settings.max_files"), self.attach_files)
|
||||
form.addRow(tr("settings.max_per_file"), self.attach_tokens)
|
||||
|
||||
# --- cấu trúc / GraphRAG ---
|
||||
st = data.get("structure", {})
|
||||
tieu_de("settings.group.structure")
|
||||
self.struct_nodes = QSpinBox()
|
||||
self.struct_nodes.setRange(0, 100000)
|
||||
self.struct_nodes.setSpecialValueText(tr("settings.unlimited"))
|
||||
self.struct_nodes.setSuffix(tr("settings.nodes_suffix"))
|
||||
self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500))))
|
||||
self.struct_nodes.setToolTip(tr("settings.nodes_tooltip"))
|
||||
self.struct_edges = QSpinBox()
|
||||
self.struct_edges.setRange(0, 200000)
|
||||
self.struct_edges.setSpecialValueText(tr("settings.unlimited"))
|
||||
self.struct_edges.setSuffix(tr("settings.edges_suffix"))
|
||||
self.struct_edges.setValue(max(0, int(st.get("max_edges", 500))))
|
||||
self.struct_edges.setToolTip(tr("settings.edges_tooltip"))
|
||||
form.addRow(tr("settings.max_nodes"), self.struct_nodes)
|
||||
form.addRow(tr("settings.max_edges"), self.struct_edges)
|
||||
|
||||
# --- giới hạn sandbox (lưu vào agent_security) ---
|
||||
tieu_de("settings.group.sandbox_limits")
|
||||
self.sandbox_cpu = _gioi_han(" %", int(sec.get("resource_limit_cpu_percent", 0) or 0),
|
||||
hi=100_000)
|
||||
form.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu)
|
||||
self.sandbox_memory = _gioi_han(" MB", int(sec.get("resource_limit_memory_mb", 2048) or 2048))
|
||||
form.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory)
|
||||
self.sandbox_disk = _gioi_han(" MB", int(sec.get("resource_limit_disk_mb", 2048) or 2048))
|
||||
form.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk)
|
||||
|
||||
# ---- lưu ------------------------------------------------------------
|
||||
|
||||
def apply_to(self, data: dict) -> None:
|
||||
att = data.setdefault("attachments", {})
|
||||
att["max_tokens"] = self.attach_tokens.value() * 1000
|
||||
att["max_files"] = self.attach_files.value()
|
||||
st = data.setdefault("structure", {})
|
||||
st["max_nodes"] = self.struct_nodes.value()
|
||||
st["max_edges"] = self.struct_edges.value()
|
||||
|
||||
def apply_limits_to(self, sec: dict) -> None:
|
||||
"""Ba con số này thuộc ``agent_security``, không thuộc ``structure``."""
|
||||
sec["resource_limit_cpu_percent"] = self.sandbox_cpu.value()
|
||||
sec["resource_limit_memory_mb"] = self.sandbox_memory.value()
|
||||
sec["resource_limit_disk_mb"] = self.sandbox_disk.value()
|
||||
|
||||
|
||||
def _gioi_han(suffix: str, value: int, hi: int = 1_000_000) -> QSpinBox:
|
||||
box = QSpinBox()
|
||||
box.setRange(0, hi)
|
||||
box.setSuffix(suffix)
|
||||
box.setSpecialValueText(tr("settings.sandbox_unlimited")) # 0 = không giới hạn
|
||||
box.setValue(value)
|
||||
return box
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Mục AI Provider trong Cài đặt — R08-T07.
|
||||
|
||||
Chọn nhà cung cấp, base URL, API key, model — kèm hai nút Tải model và Test
|
||||
kết nối chạy ở luồng nền.
|
||||
|
||||
Điểm cần biết khi sửa: widget giữ **bản nháp cho từng provider**
|
||||
(``_staging``). Người dùng đổi sang provider khác rồi quay lại thì thấy đúng
|
||||
những gì mình vừa gõ, dù chưa bấm Lưu. Nếu đọc thẳng từ config thay vì từ bản
|
||||
nháp là mất phần đang gõ dở.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QSizePolicy, QWidget,
|
||||
)
|
||||
|
||||
from ...config import PROVIDER_LABELS
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon
|
||||
|
||||
|
||||
class ProviderSettingsWidget(QGroupBox):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(tr("settings.group.provider"), parent)
|
||||
self.ctx = ctx
|
||||
data = ctx.config.data
|
||||
self._workers = []
|
||||
|
||||
self._staging: Dict[str, dict] = {
|
||||
key: dict(conf) for key, conf in data["providers"].items()
|
||||
}
|
||||
self.provider_combo = QComboBox()
|
||||
for key, label in PROVIDER_LABELS.items():
|
||||
self.provider_combo.addItem(label, key)
|
||||
_select(self.provider_combo, ctx.config.active_provider)
|
||||
self._current_key = self.provider_combo.currentData()
|
||||
|
||||
conf = self._staging.get(self._current_key, {})
|
||||
self.prov_base = QLineEdit(conf.get("base_url", ""))
|
||||
self.prov_key = QLineEdit(conf.get("api_key", ""))
|
||||
self.prov_key.setEchoMode(QLineEdit.Password)
|
||||
self.prov_model = _model_combo(conf.get("model", ""))
|
||||
self.prov_status = QLabel("")
|
||||
self.prov_status.setObjectName("hint")
|
||||
self.prov_status.setWordWrap(True)
|
||||
|
||||
form = QFormLayout(self)
|
||||
form.addRow(tr("settings.active_provider"), self.provider_combo)
|
||||
form.addRow(tr("settings.base_url"), self.prov_base)
|
||||
form.addRow(tr("settings.api_key"), self.prov_key)
|
||||
form.addRow(tr("settings.model"), self._hang_model())
|
||||
form.addRow("", self.prov_status)
|
||||
|
||||
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
|
||||
|
||||
# ---- lưu ------------------------------------------------------------
|
||||
|
||||
def apply_to(self, data: dict) -> None:
|
||||
data["active_provider"] = self.provider_combo.currentData()
|
||||
self._stash()
|
||||
for key, staged in self._staging.items():
|
||||
data["providers"].setdefault(key, {}).update({
|
||||
"base_url": staged.get("base_url", ""),
|
||||
"api_key": staged.get("api_key", ""),
|
||||
"model": staged.get("model", ""),
|
||||
})
|
||||
|
||||
# ---- bản nháp từng provider -----------------------------------------
|
||||
|
||||
def _stash(self) -> None:
|
||||
self._staging.setdefault(self._current_key, {}).update({
|
||||
"base_url": self.prov_base.text().strip(),
|
||||
"api_key": self.prov_key.text(),
|
||||
"model": self.prov_model.currentText().strip(),
|
||||
})
|
||||
|
||||
def _on_provider_changed(self) -> None:
|
||||
self._stash()
|
||||
self._current_key = self.provider_combo.currentData()
|
||||
conf = self._staging.get(self._current_key, {})
|
||||
self.prov_base.setText(conf.get("base_url", ""))
|
||||
self.prov_key.setText(conf.get("api_key", ""))
|
||||
self.prov_model.clear()
|
||||
if conf.get("model"):
|
||||
self.prov_model.addItem(conf["model"])
|
||||
self.prov_model.setCurrentText(conf["model"])
|
||||
else:
|
||||
self.prov_model.setCurrentText("")
|
||||
self.prov_status.setText("")
|
||||
|
||||
def _conf_hien_tai(self, provider: str) -> dict:
|
||||
if provider == self._current_key:
|
||||
return {"base_url": self.prov_base.text().strip(),
|
||||
"api_key": self.prov_key.text(),
|
||||
"model": self.prov_model.currentText().strip()}
|
||||
conf = self._staging.get(provider, {})
|
||||
return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""),
|
||||
"model": conf.get("model", "")}
|
||||
|
||||
# ---- hàng model + hai nút -------------------------------------------
|
||||
|
||||
def _hang_model(self) -> QWidget:
|
||||
row = QWidget()
|
||||
lay = QHBoxLayout(row)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(self.prov_model, 1)
|
||||
|
||||
btn = QPushButton(tr("settings.load"))
|
||||
btn.setIcon(icon("download"))
|
||||
btn.setToolTip(tr("settings.load_tooltip"))
|
||||
btn.clicked.connect(lambda: self._load_models(self.provider_combo.currentData()))
|
||||
lay.addWidget(btn)
|
||||
|
||||
test_btn = QPushButton(tr("settings.test_connection"))
|
||||
test_btn.setIcon(icon("flask"))
|
||||
test_btn.setToolTip(tr("settings.test_connection_tooltip"))
|
||||
test_btn.clicked.connect(lambda: self._test_connection(self.provider_combo.currentData()))
|
||||
lay.addWidget(test_btn)
|
||||
|
||||
# Hai nút giữ kích thước tự nhiên, combo là thứ phải nhường. Không có
|
||||
# dòng này thì bề rộng tối thiểu của hàng bằng combo cộng cả hai nút,
|
||||
# không co lại được, và dialog sinh ra thanh cuộn ngang.
|
||||
for b in (btn, test_btn):
|
||||
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
row.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
return row
|
||||
|
||||
# ---- việc chạy nền ---------------------------------------------------
|
||||
|
||||
def _load_models(self, provider: str) -> None:
|
||||
conf = self._conf_hien_tai(provider)
|
||||
combo, status = self.prov_model, self.prov_status
|
||||
|
||||
def job(worker):
|
||||
from ...providers import build_provider
|
||||
prov = build_provider(provider, conf)
|
||||
return {"models": prov.list_models(), "error": getattr(prov, "last_error", "")}
|
||||
|
||||
def done(result):
|
||||
models = result.get("models") or []
|
||||
current = combo.currentText().strip()
|
||||
combo.clear()
|
||||
if current:
|
||||
combo.addItem(current)
|
||||
for m in models:
|
||||
if m != current:
|
||||
combo.addItem(m)
|
||||
combo.setCurrentText(current)
|
||||
if models:
|
||||
status.setText(tr("settings.loaded_models", n=len(models),
|
||||
provider=PROVIDER_LABELS.get(provider, provider)))
|
||||
else:
|
||||
status.setText(tr("settings.load_models_error",
|
||||
err=result.get("error", "")
|
||||
or tr("settings.load_models_error_unknown")))
|
||||
|
||||
self._chay_nen(job, done,
|
||||
lambda e: status.setText(tr("settings.load_failed", err=e)),
|
||||
tr("settings.loading_models"))
|
||||
|
||||
def _test_connection(self, provider: str) -> None:
|
||||
conf = self._conf_hien_tai(provider)
|
||||
status = self.prov_status
|
||||
|
||||
def job(worker):
|
||||
from ...providers import build_provider
|
||||
ok, message = build_provider(provider, conf).test_connection()
|
||||
return {"ok": ok, "message": message}
|
||||
|
||||
def done(result):
|
||||
status.setText(result.get("message", ""))
|
||||
status.setStyleSheet("color: #090;" if result.get("ok") else "color: #c00;")
|
||||
|
||||
def failed(e):
|
||||
status.setText(str(e))
|
||||
status.setStyleSheet("color: #c00;")
|
||||
|
||||
self._chay_nen(job, done, failed, tr("settings.testing_connection"))
|
||||
|
||||
def _chay_nen(self, job, done, failed, dang_lam: str) -> None:
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
# Giữ tham chiếu: worker bị thu gom giữa chừng là luồng chết lặng lẽ.
|
||||
self._workers.append(w)
|
||||
self.prov_status.setText(dang_lam)
|
||||
w.start()
|
||||
|
||||
|
||||
def _model_combo(value: str) -> QComboBox:
|
||||
combo = QComboBox()
|
||||
combo.setEditable(True)
|
||||
# Mặc định combo rộng bằng mục dài nhất; id model thì dài, nên hàng này
|
||||
# tràn ra ngoài dialog và đẻ ra thanh cuộn ngang (tệ hơn ở màn 125%/150%).
|
||||
# Cho nó co lại, phần bung ra để popup lo.
|
||||
combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
|
||||
combo.setMinimumContentsLength(8)
|
||||
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
if value:
|
||||
combo.addItem(value)
|
||||
combo.setCurrentText(value)
|
||||
return combo
|
||||
|
||||
|
||||
def _select(combo: QComboBox, value: str) -> None:
|
||||
i = combo.findData(value)
|
||||
if i >= 0:
|
||||
combo.setCurrentIndex(i)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Mục Auto Model Routing trong Cài đặt — R08-T07.
|
||||
|
||||
Bóc từ ``ui/settings_dialog.py`` (khối dòng 254-309 của bản trước khi tách).
|
||||
Widget tự dựng control, tự nạp giá trị, tự ghi trả về dict cấu hình. Dialog
|
||||
chỉ còn việc đặt nó vào chỗ và gọi ``apply_to`` lúc lưu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QFormLayout, QGroupBox, QLabel, QLineEdit, QPushButton, QSpinBox,
|
||||
)
|
||||
|
||||
from ...i18n import tr
|
||||
|
||||
#: Các chế độ định tuyến. Danh sách này phải khớp ``config.py::AppConfig
|
||||
#: .ROUTING_MODES`` — Delta thêm "fallback" ở R03-T03 và nếu quên đồng bộ
|
||||
#: chỗ này thì người dùng không chọn được chế độ đó, mà không có lỗi nào báo.
|
||||
MODE_KEYS = (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
|
||||
("manual", "routing.mode_manual"))
|
||||
|
||||
POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
|
||||
("latency", "routing.policy_latency"),
|
||||
("balanced", "routing.policy_balanced"))
|
||||
|
||||
|
||||
class RoutingSettingsWidget(QGroupBox):
|
||||
def __init__(self, ctx, parent=None):
|
||||
super().__init__(tr("routing.settings_group"), parent)
|
||||
self.ctx = ctx
|
||||
routing = ctx.config.routing
|
||||
form = QFormLayout(self)
|
||||
|
||||
self.mode = QComboBox()
|
||||
for value, key in MODE_KEYS:
|
||||
self.mode.addItem(tr(key), value)
|
||||
_select(self.mode, routing.get("switch_mode", "off"))
|
||||
form.addRow(tr("routing.settings_mode"), self.mode)
|
||||
|
||||
self.policy = QComboBox()
|
||||
for value, key in POLICY_KEYS:
|
||||
self.policy.addItem(tr(key), value)
|
||||
_select(self.policy, routing.get("policy", "balanced"))
|
||||
form.addRow(tr("routing.settings_policy"), self.policy)
|
||||
|
||||
# Lưu dạng phân lẻ (0..1) nhưng hiện dạng phần trăm.
|
||||
self.min_gain = QSpinBox()
|
||||
self.min_gain.setRange(0, 100)
|
||||
self.min_gain.setSuffix(" %")
|
||||
self.min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100)))
|
||||
form.addRow(tr("routing.settings_min_gain"), self.min_gain)
|
||||
|
||||
self.timeout = QSpinBox()
|
||||
self.timeout.setRange(5, 600)
|
||||
self.timeout.setSuffix(" s")
|
||||
self.timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60))
|
||||
form.addRow(tr("routing.settings_timeout"), self.timeout)
|
||||
|
||||
self.interval = QSpinBox()
|
||||
self.interval.setRange(0, 720)
|
||||
self.interval.setSpecialValueText(tr("routing.mode_off")) # 0 = tắt
|
||||
self.interval.setSuffix(" h")
|
||||
self.interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0))
|
||||
form.addRow(tr("routing.settings_interval"), self.interval)
|
||||
|
||||
self.concurrency = QSpinBox()
|
||||
self.concurrency.setRange(1, 16)
|
||||
self.concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2))
|
||||
form.addRow(tr("routing.settings_concurrency"), self.concurrency)
|
||||
|
||||
self.judge = QLineEdit(routing.get("judge_model", ""))
|
||||
form.addRow(tr("routing.settings_judge"), self.judge)
|
||||
|
||||
self.reassess_btn = QPushButton(tr("routing.settings_reassess_now"))
|
||||
self.reassess_btn.clicked.connect(self._reassess_now)
|
||||
form.addRow("", self.reassess_btn)
|
||||
|
||||
hint = QLabel(tr("routing.settings_hint"))
|
||||
hint.setObjectName("hint")
|
||||
hint.setWordWrap(True)
|
||||
form.addRow(hint)
|
||||
|
||||
# ---- lưu ------------------------------------------------------------
|
||||
|
||||
def apply_to(self, data: dict) -> None:
|
||||
r = data.setdefault("routing", {})
|
||||
r["switch_mode"] = self.mode.currentData()
|
||||
r["policy"] = self.policy.currentData()
|
||||
r["min_score_gain"] = self.min_gain.value() / 100.0
|
||||
r["confirm_timeout_sec"] = self.timeout.value()
|
||||
r["reassess_interval_hours"] = self.interval.value()
|
||||
r["per_provider_concurrency"] = self.concurrency.value()
|
||||
r["judge_model"] = self.judge.text().strip()
|
||||
|
||||
# ---- đánh giá lại ngay ----------------------------------------------
|
||||
|
||||
def _reassess_now(self) -> None:
|
||||
"""Chạy đánh giá lại model ở nền."""
|
||||
try:
|
||||
service = self.ctx.routing()
|
||||
if service.is_reassessing():
|
||||
return
|
||||
self.reassess_btn.setEnabled(False)
|
||||
self.reassess_btn.setText(tr("routing.reassessing"))
|
||||
|
||||
def _done(result) -> None:
|
||||
self.reassess_btn.setEnabled(True)
|
||||
self.reassess_btn.setText(
|
||||
tr("routing.reassess_done", count=len(result or {})))
|
||||
|
||||
service.reassess_background(on_done=_done)
|
||||
except Exception: # noqa: BLE001 — bấm đánh giá lại không được làm sập Cài đặt
|
||||
self.reassess_btn.setEnabled(True)
|
||||
self.reassess_btn.setText(tr("routing.settings_reassess_now"))
|
||||
|
||||
|
||||
def _select(combo: QComboBox, value: str) -> None:
|
||||
i = combo.findData(value)
|
||||
if i >= 0:
|
||||
combo.setCurrentIndex(i)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Composition Root — R08-T10.
|
||||
|
||||
Một chỗ duy nhất quyết định app chạy bằng những mảnh nào. Trước đây quyết định
|
||||
đó nằm rải trong ``app.py::run``, lẫn với việc dựng cửa sổ; tách ra để đổi một
|
||||
mảnh (ví dụ thay kho bí mật) không phải đụng vào mã giao diện.
|
||||
|
||||
Đây cũng là chỗ hoàn tất R02: từ đây app chạy bằng :class:`JsonConfigRepository`
|
||||
chứ không còn ``config.py::AppConfig``. Hai thứ đổi thật sự:
|
||||
|
||||
* ghi cấu hình qua ``AtomicJsonFile`` — mất điện giữa lúc lưu không làm hỏng file
|
||||
* API key nằm trong kho bí mật của hệ điều hành, không nằm trong ``config.json``
|
||||
|
||||
Máy không có kho bí mật (Linux headless, CI, hoặc keyring hỏng) vẫn chạy bình
|
||||
thường: repository nhận ``secrets=None`` và đọc khoá thẳng từ file như cũ. Thà
|
||||
để khoá trong file còn hơn xoá đi rồi người dùng mất khoá mà không hiểu vì sao.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ...infrastructure.config.json_config_repository import JsonConfigRepository
|
||||
from ...infrastructure.secrets.keyring_adapter import KeyringAdapter
|
||||
from ...state import AppContext
|
||||
|
||||
|
||||
def build_secret_store():
|
||||
"""Kho bí mật của hệ điều hành, hoặc None nếu máy này không có.
|
||||
|
||||
``KeyringAdapter`` không bao giờ ném lỗi — nó tự báo ``available``. Trả về
|
||||
None thay vì một adapter chết để chỗ gọi khỏi phải đoán.
|
||||
"""
|
||||
store = KeyringAdapter()
|
||||
return store if store.available else None
|
||||
|
||||
|
||||
def build_config(path: Path | None = None) -> JsonConfigRepository:
|
||||
return JsonConfigRepository.load(path, secrets=build_secret_store())
|
||||
|
||||
|
||||
def build_context(path: Path | None = None) -> AppContext:
|
||||
"""Dựng AppContext hoàn chỉnh — điểm vào cho ``app.run()`` và cho checker."""
|
||||
return AppContext(build_config(path))
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tên gọi và biểu tượng của ứng dụng — R08-T10.
|
||||
|
||||
Tách riêng vì cả cửa sổ chính lẫn thanh trên cùng đều cần, mà để ở một trong
|
||||
hai thì file kia phải import ngược lại — vòng import.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
#: branding.py nằm sâu 2 cấp nên phải trỏ ngược lên gốc gói.
|
||||
ASSETS = Path(__file__).resolve().parents[2] / "assets"
|
||||
|
||||
|
||||
def app_icon() -> QIcon:
|
||||
"""The buffalo app icon, used everywhere (window title bar, Windows taskbar and
|
||||
the tray). The multi-size ``.ico`` is loaded FIRST so Windows has the right
|
||||
pixmap for the taskbar; the high-res ``.png`` is added so the icon stays crisp
|
||||
at large sizes. This keeps the taskbar icon identical to the app's icon."""
|
||||
icon = QIcon()
|
||||
for name in ("icon.ico", "icon.png"):
|
||||
path = ASSETS / name
|
||||
if path.exists():
|
||||
icon.addFile(str(path))
|
||||
return icon
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Vòng đời cửa sổ chính — R08-T10.
|
||||
|
||||
Bóc từ ``app.py::MainWindow``. Hai việc, đều không phải việc của giao diện:
|
||||
|
||||
1. **Canh cửa sổ theo màn hình đang đứng.** Người dùng có hai màn khác độ phân
|
||||
giải và khác tỉ lệ phóng; kéo cửa sổ sang màn kia là vùng làm việc đổi. Đây
|
||||
là số học thuần, không đụng widget nào ngoài chính cửa sổ.
|
||||
2. **Tắt cho sạch.** Dừng bộ lập lịch, dừng mọi lượt chạy còn dở, ngắt tiến
|
||||
trình MCP. Thiếu một bước là để lại tiến trình con chạy mồ côi sau khi
|
||||
người dùng đã thoát.
|
||||
|
||||
Các hàm ``closeEvent``/``moveEvent``/``resizeEvent`` vẫn phải nằm ở lớp cửa sổ
|
||||
— Qt gọi thẳng vào đó — nhưng phần quyết định thì ở đây.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
|
||||
#: Cửa sổ chiếm bao nhiêu phần màn hình khi mở lần đầu.
|
||||
SCREEN_SHARE_W, SCREEN_SHARE_H = 0.80, 0.85
|
||||
|
||||
#: Chừa mép để cửa sổ không đụng thanh tác vụ.
|
||||
MARGIN = 60
|
||||
|
||||
#: Kích thước tối thiểu mong muốn — vẫn phải nhỏ hơn màn hình thật.
|
||||
MIN_W, MIN_H = 820, 520
|
||||
|
||||
|
||||
class LifecycleCoordinator:
|
||||
def __init__(self, window):
|
||||
self.window = window
|
||||
self._last_screen = None
|
||||
|
||||
# ---- canh theo màn hình ----------------------------------------------
|
||||
|
||||
def fit_to_screen(self, want_w: int, want_h: int) -> None:
|
||||
w = self.window
|
||||
screen = w.screen() or QGuiApplication.primaryScreen()
|
||||
avail = screen.availableGeometry() if screen else None
|
||||
if avail is None:
|
||||
w.resize(want_w, want_h)
|
||||
return
|
||||
|
||||
# Lấy một phần màn hình: không bao giờ nhỏ hơn kích thước yêu cầu, cũng
|
||||
# không bao giờ lớn hơn thứ màn hình hiển thị nổi.
|
||||
width = min(max(want_w, int(avail.width() * SCREEN_SHARE_W)),
|
||||
avail.width() - MARGIN)
|
||||
height = min(max(want_h, int(avail.height() * SCREEN_SHARE_H)),
|
||||
avail.height() - MARGIN)
|
||||
self._apply_minimum(avail)
|
||||
w.resize(max(width, 1), max(height, 1))
|
||||
|
||||
frame = w.frameGeometry()
|
||||
frame.moveCenter(avail.center())
|
||||
w.move(frame.topLeft())
|
||||
|
||||
def screen_maybe_changed(self) -> bool:
|
||||
"""Gọi khi cửa sổ bị di chuyển. Trả True nếu đúng là đã đổi màn hình.
|
||||
|
||||
Trả về bool để chỗ gọi biết có cần xếp lại mấy thứ nổi hay không —
|
||||
kéo cửa sổ trong cùng một màn thì không cần làm gì cả.
|
||||
"""
|
||||
w = self.window
|
||||
screen = w.screen()
|
||||
if screen is self._last_screen:
|
||||
return False
|
||||
self._last_screen = screen
|
||||
avail = screen.availableGeometry() if screen else None
|
||||
if avail is not None:
|
||||
self._apply_minimum(avail)
|
||||
return True
|
||||
|
||||
def _apply_minimum(self, avail) -> None:
|
||||
# Kích thước tối thiểu không bao giờ được vượt quá thứ màn hình hiển
|
||||
# thị nổi — nếu không thì cửa sổ không thu nhỏ vừa màn được nữa.
|
||||
self.window.setMinimumSize(min(MIN_W, avail.width() - MARGIN),
|
||||
min(MIN_H, avail.height() - MARGIN))
|
||||
|
||||
# ---- đóng và tắt ------------------------------------------------------
|
||||
|
||||
def should_keep_running(self) -> bool:
|
||||
"""Đóng cửa sổ có nghĩa là chạy nền tiếp, hay là thoát hẳn?
|
||||
|
||||
Chạy nền tiếp chỉ khi có khay hệ thống để quay lại — không có khay mà
|
||||
vẫn ẩn đi thì người dùng mất luôn đường vào app.
|
||||
"""
|
||||
w = self.window
|
||||
if w.tray is None or w._really_quit:
|
||||
return False
|
||||
return bool(w.ctx.config.data.get("tray", {}).get("minimize_on_close", True))
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Dừng mọi thứ đang chạy. Thứ tự có ý nghĩa: bộ lập lịch trước, để nó
|
||||
không kịp khởi động thêm việc mới trong lúc ta đang dừng việc cũ."""
|
||||
w = self.window
|
||||
w.task_scheduler.stop() # dừng luôn các task đã lên lịch
|
||||
if getattr(w, "routing_scheduler", None) is not None:
|
||||
w.routing_scheduler.stop()
|
||||
|
||||
for tab in (w.cowork,):
|
||||
for worker in tab.active_workers():
|
||||
if worker.isRunning():
|
||||
worker.request_stop()
|
||||
worker.wait(1500)
|
||||
|
||||
if hasattr(w.structure, "stop_cmem_ui"):
|
||||
w.structure.stop_cmem_ui()
|
||||
|
||||
# Không bao giờ để lại tiến trình MCP đã kết nối chạy mồ côi.
|
||||
w.ctx.stop_mcp_connections()
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Cửa sổ chính — R08-T10.
|
||||
|
||||
Bóc nguyên khối ra khỏi ``app.py``. ``app.py`` giờ chỉ còn điểm vào của chương
|
||||
trình: dựng QApplication, gọi Composition Root, mở cửa sổ.
|
||||
|
||||
Vì sao tách: ``app.py`` là nơi mọi thứ đổ về — nó vừa là điểm vào, vừa giữ cửa
|
||||
sổ, vừa giữ thanh điều hướng, vừa giữ thanh trên cùng. Ai sửa bất cứ mảng nào
|
||||
cũng phải mở đúng một file 1.293 dòng, và ba người sửa ba mảng khác nhau thì
|
||||
đụng nhau ở cùng một chỗ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
pages (Dashboard / Schedule / Workspace / Cowork / Structure) and top bar."""
|
||||
|
||||
import sys
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow, QStackedWidget, QVBoxLayout, QWidget
|
||||
|
||||
from ... import DISPLAY_NAME, __version__
|
||||
from ...i18n import on_language_changed, tr
|
||||
from .branding import app_icon
|
||||
from .lifecycle_coordinator import LifecycleCoordinator
|
||||
from .page_registry import PageRegistryMixin
|
||||
from .rail_project import RailProjectMixin
|
||||
from .session_events import SessionEventsMixin
|
||||
from .toast import Toast
|
||||
from .top_bar import TopBarMixin
|
||||
from .nav_rail import NavRailMixin
|
||||
from .rail_metrics import _NAV_MIN_WIDTH
|
||||
from .tray_manager import TrayManager
|
||||
from ...state import AppContext
|
||||
from ...core.task_scheduler import TaskScheduler
|
||||
from ...ui.cowork_tab import CoworkTab
|
||||
from ...ui.sidebar import HistorySidebar
|
||||
from ...ui.structure_graph_view import StructureGraphView
|
||||
from ...ui.workspace_tab import WorkspaceTab
|
||||
|
||||
|
||||
|
||||
|
||||
# Nav rail (sidebar navigation) widths — expanded shows icon+label, collapsed
|
||||
# shows icon-only (still fully clickable, just narrower).
|
||||
# The splitter between rail and content draws a drag handle. It only means
|
||||
# something if the rail can actually take a width from it, so the expanded rail
|
||||
# is a range rather than one number; long project and thread names in RECENTS
|
||||
# are the reason someone would widen it.
|
||||
#
|
||||
# The ceiling is a SHARE of the window, not a pixel count: 360px is a quarter
|
||||
# of a 1440 screen and more than a quarter of a 1280 one, where it left the
|
||||
# seven Kanban lanes 920px of the 1067 they need. A share behaves the same on
|
||||
# every monitor.
|
||||
# Where a rail row starts, and how much air sits between its icon and its
|
||||
# label. The tree rows get these from the style; anything laid out by hand
|
||||
# beside them has to use the same two numbers or it will not line up.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
PageRegistryMixin, SessionEventsMixin, QMainWindow):
|
||||
#: Biểu tượng khay, hoặc None nếu máy không có khay. Vẫn giữ tên cũ vì
|
||||
#: còn vài chỗ đọc thẳng self.tray; bản thân việc dựng/ẩn/thông báo đã
|
||||
#: chuyển sang self._tray (TrayManager).
|
||||
tray = property(lambda self: self._tray.icon)
|
||||
|
||||
# Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page).
|
||||
_ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3
|
||||
|
||||
def __init__(self, ctx: AppContext, user_name: str = ""):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._user_name = user_name
|
||||
self._really_quit = False
|
||||
self._life = LifecycleCoordinator(self)
|
||||
# Khay hệ thống: presentation/shell/tray_manager.py (R08-T10).
|
||||
self._tray = TrayManager(self, icon=app_icon, tooltip=DISPLAY_NAME, tr=tr)
|
||||
self._nav_collapsed = False # icon-only nav rail toggle (Task: collapsible nav)
|
||||
self._history_collapsed = False # remembers History's own collapse-to-strip state
|
||||
self.setWindowTitle(f"{DISPLAY_NAME} v{__version__}")
|
||||
self.setWindowIcon(app_icon())
|
||||
# Fit to the available screen so the window never opens larger than the
|
||||
# monitor (auto-fit). Keep a modest minimum that still fits small laptops.
|
||||
self._fit_to_screen(1180, 760)
|
||||
|
||||
self.sidebar = HistorySidebar(ctx)
|
||||
# Task scheduler ENGINE runs in the background whether or not its Kanban
|
||||
# UI (built lazily) is on screen — scheduled tasks must fire regardless.
|
||||
self.task_scheduler = TaskScheduler(ctx, parent=self)
|
||||
# Desktop notification when a scheduled task finishes; also refresh
|
||||
# History — a cowork/code task run saves itself as a new session there.
|
||||
self.task_scheduler.task_finished.connect(self._on_scheduled_task_done)
|
||||
# NOTE: task_started fires BEFORE the worker thread even begins, so its
|
||||
# session doesn't exist on disk yet — refreshing History here would
|
||||
# find nothing. history_ready fires once the session is actually
|
||||
# saved (right as the run starts, then again after each turn), which
|
||||
# is what really makes a Running task's session show up live.
|
||||
self.task_scheduler.history_ready.connect(lambda _tid: self._refresh_history())
|
||||
|
||||
# Cowork chat + GraphRAG view are embedded as sub-tabs INSIDE the
|
||||
# Workspace screen (per selected project). GraphRAG's heavy
|
||||
# QtWebEngine is still built lazily on first display
|
||||
# (StructureGraphView._ensure_web).
|
||||
self.cowork = CoworkTab(ctx)
|
||||
self.structure = StructureGraphView(ctx)
|
||||
self.structure.status_message.connect(self.statusBar().showMessage)
|
||||
self.cowork.output_changed.connect(self.structure.schedule_rescan)
|
||||
self.cowork.status_message.connect(self.statusBar().showMessage)
|
||||
# Refresh History (list + running markers + current highlight) whenever a
|
||||
# conversation is created/updated or a turn finishes.
|
||||
self.cowork.turn_finished.connect(lambda *_: self._refresh_history())
|
||||
self.cowork.history_changed.connect(self._refresh_history)
|
||||
self.cowork.turn_finished.connect(
|
||||
lambda result: self._notify_task(self.cowork, "cowork", result))
|
||||
|
||||
# Workspace screen — the app HOME: project management + the per-project
|
||||
# Cowork / GraphRAG sub-tabs and History.
|
||||
self.workspace = WorkspaceTab(ctx, cowork=self.cowork, structure=self.structure,
|
||||
sidebar=self.sidebar)
|
||||
self.workspace.status_message.connect(self.statusBar().showMessage)
|
||||
self.workspace.projects_changed.connect(self._on_projects_changed)
|
||||
self.workspace.open_chat.connect(lambda *_: self._refresh_history())
|
||||
self.workspace.new_chat.connect(lambda *_: self._refresh_history())
|
||||
|
||||
# Dashboard + Schedule pages are built lazily on first visit (lazy page
|
||||
# creation — keeps startup light); None until then.
|
||||
self.dashboard = None
|
||||
self.schedule = None
|
||||
self.monitoring = None
|
||||
|
||||
# --- right side: top bar + pages (nav rail drives the stack) ---
|
||||
right = QWidget()
|
||||
right.setObjectName("contentArea")
|
||||
rlay = QVBoxLayout(right)
|
||||
rlay.setContentsMargins(10, 10, 10, 10)
|
||||
rlay.setSpacing(10)
|
||||
rlay.addWidget(self._build_topbar())
|
||||
|
||||
self.pages = QStackedWidget()
|
||||
# (i18n key, icon, builder-or-None, eager-widget-or-None) — page index == list index
|
||||
self._nav_defs = [
|
||||
("app.tab.dashboard", "dashboard", self._build_dashboard, None),
|
||||
("app.tab.schedule", "schedule", self._build_schedule, None),
|
||||
("app.tab.workspace", "workspaces", None, self.workspace),
|
||||
("app.tab.monitoring", "monitoring", self._build_monitoring, None),
|
||||
]
|
||||
self._page_widgets = [] # page index → widget (placeholder until lazily built)
|
||||
self._built = []
|
||||
for _key, _icon_name, _builder, widget in self._nav_defs:
|
||||
page = widget if widget is not None else QWidget()
|
||||
self.pages.addWidget(page)
|
||||
self._page_widgets.append(page)
|
||||
self._built.append(widget is not None)
|
||||
|
||||
self._build_nav_rail(right, rlay)
|
||||
# Landing stays Workspace ▸ Project, exactly as before. Go through _goto
|
||||
# so the page is actually shown — selecting the row alone only moves the
|
||||
# highlight (its signals are blocked to avoid rebuild loops).
|
||||
self._goto(self._ROW_WORKSPACE, self.workspace.current_subtab())
|
||||
self.toast = Toast(self) # top-left "task done" popup
|
||||
# Floating in-app Help assistant — a robot icon pinned bottom-right on
|
||||
# every screen; expands into a small help-only chat (see
|
||||
# ui/help_agent_widget.py). Managed in Monitoring → Agents Admin.
|
||||
from ...ui.help_agent_widget import HelpAgentWidget
|
||||
self.help_agent = HelpAgentWidget(ctx, self, user_name=self._user_name)
|
||||
self.help_agent.status_message.connect(self.statusBar().showMessage)
|
||||
|
||||
self.statusBar().showMessage(tr("app.status.ready"))
|
||||
# Author credit, pinned to the bottom-right corner. A permanent status-bar
|
||||
# widget sits at the right end and is never cleared by showMessage (which
|
||||
# writes on the left).
|
||||
self._credit = QLabel(tr("app.credit"))
|
||||
self._credit.setObjectName("faint")
|
||||
self._credit.setStyleSheet("padding: 0 10px;")
|
||||
self.statusBar().addPermanentWidget(self._credit)
|
||||
self._restore_sessions()
|
||||
self._tray.setup()
|
||||
# Start the task scheduler last, once the whole window exists — it
|
||||
# catches up any overdue tasks right away (first tick runs inline).
|
||||
self.task_scheduler.start()
|
||||
# Auto Model Routing: periodic reassess + pending-switch expiry. Runs
|
||||
# background probes only when genuinely due (never a burst at launch).
|
||||
try:
|
||||
from ...core.routing.scheduler import RoutingScheduler
|
||||
self.routing_scheduler = RoutingScheduler(self.ctx, self.ctx.routing(), parent=self)
|
||||
self.routing_scheduler.start()
|
||||
except Exception: # noqa: BLE001 — routing must never block app startup
|
||||
self.routing_scheduler = None
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def resizeEvent(self, event): # noqa: N802 - Qt override
|
||||
super().resizeEvent(event)
|
||||
# The rail's ceiling is a share of the window, so it moves with the
|
||||
# window. Computed once at construction it was read off a not-yet-sized
|
||||
# window and stuck at 162px on every monitor.
|
||||
if getattr(self, "_nav_wrap", None) is not None and not self._nav_collapsed:
|
||||
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
|
||||
# Keep the floating Help assistant pinned to the bottom-right corner.
|
||||
if getattr(self, "help_agent", None) is not None:
|
||||
self.help_agent.reposition()
|
||||
|
||||
def showEvent(self, event): # noqa: N802 - Qt override
|
||||
super().showEvent(event)
|
||||
if getattr(self, "help_agent", None) is not None:
|
||||
self._update_dock_guard()
|
||||
self.help_agent.reposition()
|
||||
self.help_agent.raise_()
|
||||
# Build GraphRAG's browser view and first graph once the window is up
|
||||
# and idle, so clicking GraphRAG does not sit on an empty view while
|
||||
# both happen. 3s is after the first paint and any startup refresh.
|
||||
if not getattr(self, "_graph_prewarmed", False):
|
||||
self._graph_prewarmed = True
|
||||
QTimer.singleShot(3000, self._prewarm_graph)
|
||||
|
||||
def _prewarm_graph(self) -> None:
|
||||
view = getattr(self, "structure", None)
|
||||
if view is None or not hasattr(view, "prewarm"):
|
||||
return
|
||||
try:
|
||||
view.prewarm()
|
||||
except Exception: # noqa: BLE001 — a warm-up must never break the app
|
||||
pass
|
||||
|
||||
# ---- i18n ----------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
"""Re-apply the current language to this window's own static chrome
|
||||
(tabs are the only long-lived text here; the tabs/dialogs retranslate
|
||||
themselves)."""
|
||||
self._apply_nav_labels()
|
||||
self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label"))
|
||||
self._nav_toggle_btn.setToolTip(
|
||||
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
|
||||
self._credit.setText(tr("app.credit"))
|
||||
if hasattr(self, "provider_lbl"):
|
||||
self.provider_lbl.setText(tr("app.provider"))
|
||||
if hasattr(self, "settings_btn"):
|
||||
self.settings_btn.setText(tr("app.settings"))
|
||||
if hasattr(self, "theme_btn"):
|
||||
self.theme_btn.setToolTip(tr("settings.theme"))
|
||||
for value, act in self._theme_actions.items():
|
||||
act.setText(tr(f"settings.theme_{value}"))
|
||||
if hasattr(self, "logo_lbl"):
|
||||
self.logo_lbl.setText(tr("app.logo"))
|
||||
if getattr(self, "help_agent", None) is not None:
|
||||
self.help_agent.retranslate()
|
||||
self._tray.retranslate()
|
||||
|
||||
# ---- system tray (run in background when the window is closed) ---
|
||||
|
||||
|
||||
# ---- lazy page building -------------------------------------------
|
||||
|
||||
|
||||
|
||||
# Monitoring KEEPS its own tab strip: its eight sub-views live in the
|
||||
# page, not in the rail. Workspace is the one that hides its strip,
|
||||
# because the rail lists its sub-views directly.
|
||||
|
||||
|
||||
# ---- flat nav rail -------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _show_window(self) -> None:
|
||||
self.showNormal()
|
||||
self.raise_()
|
||||
self.activateWindow()
|
||||
|
||||
def _quit_app(self) -> None:
|
||||
self._really_quit = True
|
||||
self.close()
|
||||
|
||||
|
||||
# ---- top bar -----------------------------------------------------
|
||||
|
||||
|
||||
_BRAND_LOGO_NAMES = ("fpt_logo.png", "fpt-logo.png", "logo_fpt.png", "fpt_logo.jpg")
|
||||
_BRAND_LOGO_HEIGHT = 22
|
||||
|
||||
|
||||
_THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"}
|
||||
|
||||
|
||||
|
||||
# ---- handlers ----------------------------------------------------
|
||||
|
||||
|
||||
|
||||
|
||||
def _update_dock_guard(self) -> None:
|
||||
"""Keep the floating assistant clear of a screen's own bottom bar.
|
||||
|
||||
Only Cowork has one (the composer). Everywhere else the dock sits in
|
||||
the corner as before.
|
||||
"""
|
||||
dock = getattr(self, "help_agent", None)
|
||||
if dock is None:
|
||||
return
|
||||
guard = 0
|
||||
on_cowork = (self.pages.currentIndex() == self._ROW_WORKSPACE
|
||||
and self.workspace.current_subtab() == self.workspace._cowork_tab_idx)
|
||||
if on_cowork:
|
||||
comp = getattr(self.cowork, "composer", None)
|
||||
if comp is not None and not comp.isHidden():
|
||||
# Measured from the composer's TOP edge in window coordinates:
|
||||
# its own height misses the extra row of controls laid out under
|
||||
# it, which left the dot still overlapping by ~25px.
|
||||
origin = comp.mapTo(self, comp.rect().topLeft())
|
||||
# ...but only lift the dot if the composer is actually beneath
|
||||
# it. The composer stops at the chat column's right edge, well
|
||||
# short of the dot, so lifting it there raised the dot 156px for
|
||||
# nothing — on Cowork alone it sat off the corner every other
|
||||
# screen keeps it in.
|
||||
dock_left = dock.x() - self.mapToGlobal(self.rect().topLeft()).x()
|
||||
dock_right = dock_left + dock.width()
|
||||
if dock_right > origin.x() and dock_left < origin.x() + comp.width():
|
||||
guard = max(0, self.height() - origin.y() + 8)
|
||||
dock.set_bottom_guard(guard)
|
||||
|
||||
|
||||
|
||||
# ---- sizing ------------------------------------------------------
|
||||
# Share of the available screen the window takes when it has room to. Fixed
|
||||
# pixels do not travel: 1180×760 fills a laptop and looks lost on a 4K
|
||||
# panel. `want_*` stays the floor so a small screen behaves as before.
|
||||
# Canh cửa sổ và tắt sạch: presentation/shell/lifecycle_coordinator.py
|
||||
def _fit_to_screen(self, want_w: int, want_h: int) -> None:
|
||||
self._life.fit_to_screen(want_w, want_h)
|
||||
|
||||
def _on_screen_maybe_changed(self) -> None:
|
||||
if not self._life.screen_maybe_changed():
|
||||
return
|
||||
if getattr(self, "help_agent", None) is not None:
|
||||
self._update_dock_guard()
|
||||
self.help_agent.reposition()
|
||||
|
||||
|
||||
def moveEvent(self, event): # noqa: N802 - Qt override
|
||||
super().moveEvent(event)
|
||||
# Dragged to another monitor: its work area (and scaling) may differ, so
|
||||
# the floating assistant re-pins and the panes re-decide if they fit.
|
||||
self._on_screen_maybe_changed()
|
||||
|
||||
|
||||
# ---- lifecycle ---------------------------------------------------
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
if self._life.should_keep_running():
|
||||
# Chạy nền tiếp: task vẫn chạy và vẫn tự lưu.
|
||||
event.ignore()
|
||||
self.hide()
|
||||
self._tray.show_message(DISPLAY_NAME, tr("app.tray.running_body"), msec=4000)
|
||||
return
|
||||
# Real quit: stop every running turn (a tab may have several), then close.
|
||||
self._life.shutdown()
|
||||
self._tray.hide()
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Thanh điều hướng bên trái — R08-T10.
|
||||
|
||||
Bóc từ ``MainWindow``: 18 phương thức dựng và điều khiển thanh rail, cộng danh
|
||||
sách RECENTS, bộ chọn project, và việc thu gọn về dải icon 54px.
|
||||
|
||||
Đây là **mixin**, không phải widget rời — nói thẳng để khỏi hiểu nhầm. Cả 18
|
||||
phương thức đọc/ghi state của cửa sổ (``self._page_widgets``, ``self.workspace``,
|
||||
``self.splitter``…). Biến thành đối tượng cộng tác thì phải viết lại từng chỗ
|
||||
``self.X`` thành ``self.window.X``, tức sửa gần 300 dòng chỉ để đổi cách gọi —
|
||||
rủi ro cao mà không đổi hành vi. Mixin cho được thứ đang cần: mỗi mảng nằm ở
|
||||
một file, ai sửa rail thì mở file rail.
|
||||
|
||||
Chuyển thành widget thật khi thanh rail cần dùng lại ở cửa sổ khác — hiện chưa.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QMenu, QPushButton, QScrollArea, QSizePolicy, QSplitter, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
from ...i18n import tr
|
||||
from .rail_metrics import _NAV_COLLAPSED_WIDTH, _NAV_EXPANDED_WIDTH, _NAV_MAX_CEILING, _NAV_MAX_SHARE, _NAV_MIN_WIDTH, _NavItemDelegate
|
||||
from ...ui.widgets import tidy_popup
|
||||
|
||||
|
||||
|
||||
|
||||
class NavRailMixin:
|
||||
"""18 phương thức thanh rail. Trộn vào MainWindow."""
|
||||
|
||||
def _build_nav_rail(self, right, rlay) -> None:
|
||||
"""Dựng toàn bộ thanh rail và ghép với vùng nội dung.
|
||||
|
||||
Bóc khỏi ``MainWindow.__init__`` — 162 dòng dựng rail nằm lẫn giữa
|
||||
phần dựng trang và phần khởi động scheduler, nên đọc ``__init__`` là
|
||||
phải lội qua cả rail mới tới được thứ mình cần.
|
||||
"""
|
||||
# Left nav rail — ONE FLAT LIST, no accordion. Every screen the user
|
||||
# works in is one click away: the Workspace sub-views are listed
|
||||
# directly instead of hiding behind an expandable parent. The two
|
||||
# occasional admin destinations sit in a second, bottom-pinned list.
|
||||
#
|
||||
# Monitoring is the exception that keeps its sub-views OUT of the rail:
|
||||
# it has eight, which would double the rail's length for screens opened
|
||||
# once a week. Its own tab strip is left visible instead (it was hidden
|
||||
# while the rail carried its children), so all eight stay reachable.
|
||||
self.nav = self._new_nav_tree("navrail")
|
||||
self.nav_bottom = self._new_nav_tree("navrailBottom")
|
||||
self._nav_building = False # guards the rebuild → select → rebuild loop
|
||||
self.workspace.hide_tab_bar()
|
||||
self._rebuild_nav()
|
||||
self.workspace.subtabs_changed.connect(self._rebuild_nav)
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
tree.currentItemChanged.connect(
|
||||
lambda cur, _prev, t=tree: self._on_nav_current(t, cur))
|
||||
rlay.addWidget(self.pages, 1)
|
||||
|
||||
# Nav rail wrapper: a small toggle button ABOVE the page list so the
|
||||
# whole rail can collapse to icon-only (still fully clickable). Same
|
||||
# collapse/expand chevron iconography as every other collapsible panel.
|
||||
from ...ui.icons import collapse_left_icon, collapse_right_icon
|
||||
from ...ui.icons import icon as _icon
|
||||
self._collapse_left_icon = collapse_left_icon
|
||||
self._collapse_right_icon = collapse_right_icon
|
||||
self._nav_wrap = QWidget()
|
||||
self._nav_wrap.setObjectName("navWrap")
|
||||
self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses
|
||||
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
|
||||
nvl = QVBoxLayout(self._nav_wrap)
|
||||
nvl.setContentsMargins(0, 0, 0, 0)
|
||||
nvl.setSpacing(0)
|
||||
# Small, left-aligned "MENU" button (icon + label) instead of a
|
||||
# full-width centered icon — sits flush with the rail's left edge,
|
||||
# matching how the nav items themselves align their icon+label.
|
||||
self._nav_toggle_btn = QPushButton(tr("app.nav.menu_label"))
|
||||
self._nav_toggle_btn.setIcon(collapse_left_icon())
|
||||
self._nav_toggle_btn.setObjectName("navMenuBtn")
|
||||
self._nav_toggle_btn.setFlat(True)
|
||||
self._nav_toggle_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._nav_toggle_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self._nav_toggle_btn.clicked.connect(self._toggle_nav)
|
||||
# Zero left margin: the button's own QSS padding (6px) then lines its
|
||||
# 16px icon up with the nav items' icons below (1px list frame + item
|
||||
# padding) — same indent level, same icon size as e.g. Dashboard.
|
||||
toggle_row = QHBoxLayout()
|
||||
toggle_row.setContentsMargins(0, 8, 10, 8)
|
||||
toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft)
|
||||
toggle_row.addStretch(1)
|
||||
nvl.addLayout(toggle_row)
|
||||
# Primary action at the top of the rail, with the project it will land
|
||||
# in named right above it. Before, starting a chat in another project
|
||||
# meant leaving Cowork → Project tab → click a row → come back.
|
||||
self.nav_project = QComboBox()
|
||||
self.nav_project.setObjectName("navProjectPick")
|
||||
self.nav_project.setToolTip(tr("app.nav.project_pick"))
|
||||
self.nav_project.currentIndexChanged.connect(self._on_rail_project_pick)
|
||||
tidy_popup(self.nav_project)
|
||||
self.nav_new_chat = QPushButton(tr("cowork.new_chat"))
|
||||
self.nav_new_chat.setObjectName("navNewChatBtn")
|
||||
self.nav_new_chat.setIcon(_icon("plus"))
|
||||
self.nav_new_chat.setCursor(Qt.PointingHandCursor)
|
||||
self.nav_new_chat.clicked.connect(self._on_rail_new_chat)
|
||||
# At 54px the picker cannot show a name, but dropping it altogether left
|
||||
# the collapsed rail with no way to change project at all. This stands in
|
||||
# for it: same list, same handler, just the folder icon and a tooltip.
|
||||
self.nav_project_btn = QToolButton()
|
||||
self.nav_project_btn.setObjectName("navProjectPickMini")
|
||||
self.nav_project_btn.setIcon(_icon("folder"))
|
||||
self.nav_project_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.nav_project_btn.setPopupMode(QToolButton.InstantPopup)
|
||||
self.nav_project_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
self.nav_project_btn.setMenu(QMenu(self.nav_project_btn))
|
||||
self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu)
|
||||
self.nav_project_btn.setVisible(False)
|
||||
head = QVBoxLayout()
|
||||
head.setContentsMargins(6, 0, 6, 6)
|
||||
head.setSpacing(6)
|
||||
head.addWidget(self.nav_project)
|
||||
head.addWidget(self.nav_project_btn)
|
||||
head.addWidget(self.nav_new_chat)
|
||||
nvl.addLayout(head)
|
||||
self.workspace.project_selected.connect(self._sync_rail_project)
|
||||
self.workspace.projects_changed.connect(self._sync_rail_project)
|
||||
self._syncing_rail_project = False
|
||||
self._sync_rail_project()
|
||||
# The destinations and RECENTS scroll together; the bottom group, the
|
||||
# Settings button and the account row stay pinned below them.
|
||||
#
|
||||
# Without this the rail simply ran out of room on a short window (a
|
||||
# 1280×720 laptop leaves ~570px here): nav and the bottom group have
|
||||
# fixed heights, so the squeeze fell entirely on RECENTS, and once that
|
||||
# hit zero the layout drew the "GẦN ĐÂY" heading straight over the last
|
||||
# nav row.
|
||||
self._nav_scroll = QScrollArea()
|
||||
self._nav_scroll.setObjectName("navScroll")
|
||||
self._nav_scroll.setWidgetResizable(True)
|
||||
self._nav_scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll_body = QWidget()
|
||||
sv = QVBoxLayout(scroll_body)
|
||||
sv.setContentsMargins(0, 0, 0, 0)
|
||||
sv.setSpacing(0)
|
||||
sv.addWidget(self.nav, 0)
|
||||
# RECENTS — the threads of the project named in the picker above, right
|
||||
# where Claude puts them. A shortcut only: the full History panel (search,
|
||||
# filters, pin, bulk delete, context menu) stays exactly where it is, and
|
||||
# "all projects…" at the end of this list opens it.
|
||||
self.nav_recents_hdr = QLabel(tr("app.nav.recents"))
|
||||
self.nav_recents_hdr.setObjectName("navSectionHdr")
|
||||
sv.addWidget(self.nav_recents_hdr)
|
||||
self.nav_recents = self._new_nav_tree("navRecents")
|
||||
self.nav_recents.itemClicked.connect(self._on_rail_recent)
|
||||
sv.addWidget(self.nav_recents, 1)
|
||||
# Collapsing hides RECENTS, and with it the only item carrying a stretch
|
||||
# factor. A box layout with nothing left to expand centres what remains,
|
||||
# so the destinations dropped ~300px down the rail — "thu gọn menu lại
|
||||
# ra giữa". This spacer takes the slack instead, and takes none of it
|
||||
# while RECENTS is visible (stretch 0 against its 1).
|
||||
sv.addStretch(0)
|
||||
self._nav_scroll.setWidget(scroll_body)
|
||||
nvl.addWidget(self._nav_scroll, 1)
|
||||
self._build_rail_bottom(nvl)
|
||||
nvl.addWidget(self._account_row)
|
||||
|
||||
self.split = QSplitter(Qt.Horizontal)
|
||||
self.split.addWidget(self._nav_wrap)
|
||||
self.split.addWidget(right)
|
||||
self.split.setStretchFactor(0, 0)
|
||||
self.split.setStretchFactor(1, 1)
|
||||
self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000])
|
||||
self.split.splitterMoved.connect(self._on_split_moved)
|
||||
self.setCentralWidget(self.split)
|
||||
|
||||
def _new_nav_tree(self, name: str) -> QTreeWidget:
|
||||
"""One flat, single-column list. No indentation and no expand arrows —
|
||||
every row is a destination, nothing is a container."""
|
||||
tree = QTreeWidget()
|
||||
tree.setObjectName(name)
|
||||
tree.setHeaderHidden(True)
|
||||
tree.setIndentation(0)
|
||||
tree.setRootIsDecorated(False)
|
||||
tree.setUniformRowHeights(True)
|
||||
# The column follows the viewport instead of the widest label. Left
|
||||
# to size itself it stayed ~100px wide inside the 54px collapsed
|
||||
# rail, so a horizontal scrollbar appeared and slid the icons out of
|
||||
# the position they hold while the rail is open.
|
||||
from PySide6.QtWidgets import QHeaderView
|
||||
tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
tree.setItemDelegate(_NavItemDelegate(tree))
|
||||
return tree
|
||||
|
||||
def _nav_rows(self):
|
||||
"""(tree, page, sub, label, icon, enabled) for every row, rail order.
|
||||
|
||||
Workspace contributes all five of its sub-views — including the two the
|
||||
project gate currently disables — so the rail never changes shape while
|
||||
the user is looking at it.
|
||||
"""
|
||||
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
|
||||
for label, sub, ic, on in self.workspace.nav_entries()]
|
||||
rows.append((self.nav, self._ROW_SCHEDULE, None,
|
||||
tr("app.tab.schedule"), "schedule", True))
|
||||
rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,
|
||||
tr("app.tab.dashboard"), "dashboard", True))
|
||||
rows.append((self.nav_bottom, self._ROW_MONITORING, None,
|
||||
tr("app.tab.monitoring"), "monitoring", True))
|
||||
return rows
|
||||
|
||||
def _rebuild_nav(self, force: bool = False) -> None:
|
||||
"""Re-fill both lists from _nav_rows(), keeping the current selection.
|
||||
|
||||
Rebuilding changes the current item, which would fire navigation and can
|
||||
loop back here via subtabs_changed — hence the guard and the blocked
|
||||
signals.
|
||||
"""
|
||||
if self._nav_building:
|
||||
return
|
||||
spec = self._nav_rows()
|
||||
# Rebuilding deletes the QTreeWidgetItems, including the one a signal is
|
||||
# currently being delivered for. subtabs_changed fires on every visit to
|
||||
# Workspace, so skip the rebuild unless the rows really differ.
|
||||
sig = [(label, page, sub, enabled)
|
||||
for _t, page, sub, label, _ic, enabled in spec]
|
||||
if not force and sig == getattr(self, "_nav_sig", None):
|
||||
return
|
||||
self._nav_sig = sig
|
||||
self._nav_building = True
|
||||
try:
|
||||
from ...ui.icons import icon as _icon
|
||||
keep = self._current_nav_key()
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
blocked = tree.blockSignals(True)
|
||||
tree.clear()
|
||||
tree.blockSignals(blocked)
|
||||
for tree, page, sub, label, icon_name, enabled in spec:
|
||||
it = QTreeWidgetItem([""] if self._nav_collapsed else [label])
|
||||
it.setIcon(0, _icon(icon_name))
|
||||
it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
|
||||
if not enabled:
|
||||
# Same gate as before, shown instead of hidden: the row stays
|
||||
# in place, greyed, and says why it cannot be opened.
|
||||
it.setDisabled(True)
|
||||
it.setToolTip(0, tr("app.nav.needs_project"))
|
||||
elif self._nav_collapsed:
|
||||
it.setToolTip(0, label)
|
||||
blocked = tree.blockSignals(True)
|
||||
tree.addTopLevelItem(it)
|
||||
tree.blockSignals(blocked)
|
||||
# Both destination lists are exactly as tall as their rows; the
|
||||
# stretch in between belongs to RECENTS.
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
n = tree.topLevelItemCount()
|
||||
row_h = tree.sizeHintForRow(0) if n else 0
|
||||
tree.setFixedHeight(n * row_h + 8)
|
||||
if keep:
|
||||
self._select_nav_row(*keep)
|
||||
finally:
|
||||
self._nav_building = False
|
||||
|
||||
def _current_nav_key(self):
|
||||
"""(page, sub) of the highlighted row, or None."""
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
it = tree.currentItem()
|
||||
if it is not None and it.isSelected():
|
||||
data = it.data(0, Qt.UserRole) or {}
|
||||
if "page" in data:
|
||||
return data["page"], data.get("sub")
|
||||
return None
|
||||
|
||||
def _select_nav_row(self, page: int, sub) -> None:
|
||||
"""Highlight the row for (page, sub) without triggering navigation.
|
||||
|
||||
Called both when the user clicks (to keep the two lists mutually
|
||||
exclusive) and from _goto, so programmatic navigation moves the
|
||||
highlight too — it used to stay behind on whatever was clicked last.
|
||||
"""
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
blocked = tree.blockSignals(True)
|
||||
match = None
|
||||
for i in range(tree.topLevelItemCount()):
|
||||
it = tree.topLevelItem(i)
|
||||
data = it.data(0, Qt.UserRole) or {}
|
||||
if data.get("page") == page and (
|
||||
data.get("sub") == sub or data.get("sub") is None):
|
||||
match = it
|
||||
break
|
||||
if match is not None:
|
||||
tree.setCurrentItem(match)
|
||||
else:
|
||||
tree.setCurrentItem(None)
|
||||
tree.clearSelection()
|
||||
tree.blockSignals(blocked)
|
||||
|
||||
def _on_nav_current(self, tree: QTreeWidget, item) -> None:
|
||||
"""A row was picked: clear the other list so only one row looks active."""
|
||||
if item is None or self._nav_building:
|
||||
return
|
||||
data = item.data(0, Qt.UserRole) or {}
|
||||
other = self.nav_bottom if tree is self.nav else self.nav
|
||||
blocked = other.blockSignals(True)
|
||||
other.setCurrentItem(None)
|
||||
other.clearSelection()
|
||||
other.blockSignals(blocked)
|
||||
self._goto(data.get("page", 0), data.get("sub"))
|
||||
|
||||
# ---- rail header: project picker + new chat ------------------------
|
||||
|
||||
|
||||
|
||||
# ---- rail RECENTS --------------------------------------------------
|
||||
_RAIL_RECENTS = 5
|
||||
|
||||
|
||||
|
||||
|
||||
def _apply_nav_labels(self) -> None:
|
||||
"""Re-label every row for the current language and collapse state
|
||||
(collapsed = icon only, label moves to the tooltip)."""
|
||||
# force: collapsing leaves the row spec identical, only the text changes.
|
||||
self._rebuild_nav(force=True)
|
||||
self._nav_settings_text.setText(tr("app.settings"))
|
||||
self._nav_settings_text.setVisible(not self._nav_collapsed)
|
||||
self._nav_settings_btn.setToolTip(tr("app.settings"))
|
||||
# Collapsed to 54px there is no room for either control's label; the
|
||||
# picker would be a stub of a name, so it steps aside entirely and the
|
||||
# button keeps just its + icon.
|
||||
self.nav_project.setVisible(not self._nav_collapsed)
|
||||
self.nav_project_btn.setVisible(self._nav_collapsed)
|
||||
self._refresh_rail_recents()
|
||||
# Collapsed to 54px only the theme toggle still fits; the rest of the
|
||||
# account row would be clipped, so it steps aside (Settings, which opens
|
||||
# the same values in a dialog, stays reachable as an icon).
|
||||
self.account_lbl.setVisible(not self._nav_collapsed)
|
||||
self.language_combo.setVisible(not self._nav_collapsed)
|
||||
self.provider_combo.setVisible(not self._nav_collapsed)
|
||||
self.nav_new_chat.setText("" if self._nav_collapsed else tr("cowork.new_chat"))
|
||||
if self._nav_new_chat_enabled():
|
||||
self.nav_new_chat.setToolTip(
|
||||
tr("cowork.new_chat") if self._nav_collapsed else "")
|
||||
self._sync_rail_project()
|
||||
|
||||
|
||||
def _nav_max_width(self) -> int:
|
||||
"""The rail's ceiling for THIS window, as a share of it."""
|
||||
return max(_NAV_MIN_WIDTH,
|
||||
min(_NAV_MAX_CEILING, int(self.width() * _NAV_MAX_SHARE)))
|
||||
|
||||
def _set_nav_width_range(self, lo: int, hi: int) -> None:
|
||||
"""setFixedWidth would leave the splitter handle inert — visible, and
|
||||
doing nothing when dragged."""
|
||||
self._nav_wrap.setMinimumWidth(lo)
|
||||
self._nav_wrap.setMaximumWidth(hi)
|
||||
|
||||
def _on_split_moved(self, _pos: int, _index: int) -> None:
|
||||
if not self._nav_collapsed:
|
||||
self._nav_width = max(_NAV_MIN_WIDTH,
|
||||
min(self._nav_max_width(), self._nav_wrap.width()))
|
||||
|
||||
def _toggle_nav(self) -> None:
|
||||
if not self._nav_collapsed:
|
||||
self._nav_width = max(_NAV_MIN_WIDTH,
|
||||
min(self._nav_max_width(), self._nav_wrap.width()))
|
||||
self._nav_collapsed = not self._nav_collapsed
|
||||
if self._nav_collapsed:
|
||||
width = _NAV_COLLAPSED_WIDTH
|
||||
self._set_nav_width_range(width, width)
|
||||
else:
|
||||
width = self._nav_width
|
||||
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
|
||||
self._apply_nav_labels()
|
||||
# Same chevron convention as every other collapsible panel: right-
|
||||
# pointing (fill-right) means "click to expand", left means "collapse".
|
||||
self._nav_toggle_btn.setIcon(
|
||||
self._collapse_right_icon() if self._nav_collapsed else self._collapse_left_icon())
|
||||
# Collapsed rail is icon-only (54px) — the "MENU" label wouldn't fit
|
||||
# next to the icon, same rule the nav items themselves follow.
|
||||
self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label"))
|
||||
self._nav_toggle_btn.setToolTip(
|
||||
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
|
||||
# Give/reclaim the width difference to the main content pane.
|
||||
sizes = self.split.sizes()
|
||||
if len(sizes) == 2:
|
||||
diff = sizes[0] - width
|
||||
sizes[0] = width
|
||||
sizes[1] = max(1, sizes[1] + diff)
|
||||
self.split.setSizes(sizes)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Bốn màn chính và cách chuyển giữa chúng — R08-T10.
|
||||
|
||||
Dashboard và Lịch chỉ được dựng ở lần mở đầu tiên (dựng lười) — mở app không
|
||||
phải trả giá cho hai màn có thể cả phiên không ai vào. ``_ensure_page`` là chỗ
|
||||
duy nhất biết điều đó, nên mọi đường tới một trang đều phải đi qua ``_goto``.
|
||||
|
||||
Cùng kiểu mixin, xem ghi chú ở đầu ``nav_rail.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from ...i18n import tr
|
||||
from ...ui.dashboard_tab import DashboardTab
|
||||
from ...ui.monitoring_tab import MonitoringTab
|
||||
from ...ui.schedule_task_tab import ScheduleTaskTab
|
||||
|
||||
|
||||
class PageRegistryMixin:
|
||||
def _page_index(self, widget) -> int:
|
||||
return self.pages.indexOf(widget)
|
||||
def _build_dashboard(self):
|
||||
d = DashboardTab(self.ctx)
|
||||
d.status_message.connect(self.statusBar().showMessage)
|
||||
self.dashboard = d
|
||||
return d
|
||||
def _build_schedule(self):
|
||||
s = ScheduleTaskTab(self.ctx, self.task_scheduler)
|
||||
s.status_message.connect(self.statusBar().showMessage)
|
||||
self.schedule = s
|
||||
return s
|
||||
def _build_monitoring(self):
|
||||
m = MonitoringTab(self.ctx, cowork=self.cowork, structure=self.structure,
|
||||
task_scheduler=self.task_scheduler)
|
||||
m.status_message.connect(self.statusBar().showMessage)
|
||||
self.monitoring = m
|
||||
return m
|
||||
def _ensure_page(self, row: int) -> None:
|
||||
"""Build a lazy nav page on first visit and swap it in for its placeholder."""
|
||||
if not (0 <= row < len(self._built)) or self._built[row]:
|
||||
return
|
||||
builder = self._nav_defs[row][2]
|
||||
if builder is None:
|
||||
return
|
||||
real = builder()
|
||||
placeholder = self._page_widgets[row]
|
||||
self.pages.insertWidget(row, real) # placeholder shifts to row+1
|
||||
self.pages.removeWidget(placeholder)
|
||||
placeholder.deleteLater()
|
||||
self._page_widgets[row] = real
|
||||
self._built[row] = True
|
||||
def _page_index(self, widget) -> int:
|
||||
if widget is self.workspace:
|
||||
return self._ROW_WORKSPACE
|
||||
if self.dashboard is not None and widget is self.dashboard:
|
||||
return self._ROW_DASHBOARD
|
||||
if self.schedule is not None and widget is self.schedule:
|
||||
return self._ROW_SCHEDULE
|
||||
if self.monitoring is not None and widget is self.monitoring:
|
||||
return self._ROW_MONITORING
|
||||
return self.pages.indexOf(widget)
|
||||
def _goto(self, page: int, sub) -> None:
|
||||
self._ensure_page(page) # build lazy page on first visit
|
||||
self.pages.setCurrentIndex(page)
|
||||
if page == self._ROW_WORKSPACE:
|
||||
self.workspace.refresh() # re-list projects + threads on entry
|
||||
widget = self._page_widgets[page]
|
||||
if sub is not None and hasattr(widget, "select_subtab"):
|
||||
# Enforce the project gate here rather than at each entry point. A
|
||||
# greyed rail row cannot be clicked, but _goto is also reached from
|
||||
# RECENTS and from startup restore, and it used to open a sub-tab
|
||||
# the gate was holding shut — page shown, tab strip still hiding it.
|
||||
if hasattr(widget, "subtab_available") and not widget.subtab_available(sub):
|
||||
self.statusBar().showMessage(tr("app.nav.needs_project"), 4000)
|
||||
else:
|
||||
widget.select_subtab(sub)
|
||||
# Move the highlight with the content, however navigation was triggered —
|
||||
# a programmatic _goto used to leave it on whatever was clicked last.
|
||||
if not self._nav_building:
|
||||
self._select_nav_row(page, sub)
|
||||
self._update_dock_guard()
|
||||
# Switching pages updates which conversation is "current".
|
||||
self._refresh_history()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Kích thước và cách vẽ một hàng trên thanh rail — R08-T10.
|
||||
|
||||
Chỉ số và cách vẽ, không có hành vi. Tách riêng vì ``theme.py`` cũng phải biết
|
||||
mấy con số này (nó style ``#navrailBottom`` theo cùng lề), và vì thứ hay phải
|
||||
tra lại nhất khi chỉnh giao diện là chúng — không nên nằm lẫn trong 400 dòng
|
||||
dựng widget.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QStyledItemDelegate
|
||||
|
||||
# ---- kích thước ---------------------------------------------------------
|
||||
_NAV_EXPANDED_WIDTH = 150
|
||||
_NAV_COLLAPSED_WIDTH = 54
|
||||
_NAV_ROW_INSET = 4
|
||||
_NAV_ROW_GAP = 6
|
||||
_NAV_MIN_WIDTH = 132
|
||||
_NAV_MAX_SHARE = 0.22
|
||||
_NAV_MAX_CEILING = 360
|
||||
|
||||
|
||||
class _NavItemDelegate(QStyledItemDelegate):
|
||||
"""Keep a rail row's icon on the left edge, whatever the column is doing.
|
||||
|
||||
QStyledItemDelegate hands the style decorationAlignment = AlignHCenter, so
|
||||
a row with no label — every row once the rail collapses to 54px — has its
|
||||
icon centred inside whatever box the column happens to give it. That box
|
||||
tracks the column width, which is not stable: stretched to the viewport the
|
||||
icons land in the middle of the rail, while a column left wider than the
|
||||
view leaves them at the left. Same code, two different pictures, which is
|
||||
why a test render disagreed with the running app.
|
||||
"""
|
||||
|
||||
def initStyleOption(self, option, index):
|
||||
super().initStyleOption(option, index)
|
||||
option.decorationAlignment = Qt.AlignLeft | Qt.AlignVCenter
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Bộ chọn project và danh sách RECENTS trên thanh rail — R08-T10.
|
||||
|
||||
Tách khỏi ``nav_rail.py``: thanh rail có hai phần đời sống khác hẳn nhau.
|
||||
|
||||
Phần điểm đến (Dashboard, Workspace, Giám sát…) là **tĩnh** — dựng một lần,
|
||||
đổi khi đổi ngôn ngữ. Phần này thì **động**: đổi mỗi lần người dùng chọn
|
||||
project khác, mỗi lần một cuộc trò chuyện được tạo hay kết thúc.
|
||||
|
||||
Trộn chung một file thì mỗi lần sửa danh sách gần đây lại phải cuộn qua toàn
|
||||
bộ phần dựng rail. Cùng kiểu mixin, xem ghi chú ở đầu ``nav_rail.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem
|
||||
from ...i18n import tr
|
||||
from ...ui.widgets import tidy_popup
|
||||
from ...theme import current_palette
|
||||
|
||||
|
||||
|
||||
class RailProjectMixin:
|
||||
"""Bộ chọn project + RECENTS. Trộn vào MainWindow."""
|
||||
|
||||
def _sync_rail_project(self, *_a) -> None:
|
||||
"""Mirror the workspace's project list/selection into the rail picker.
|
||||
|
||||
One-way on purpose: the project list stays the source of truth, this is
|
||||
only a second place to see and change it.
|
||||
"""
|
||||
if self._syncing_rail_project:
|
||||
return
|
||||
self._syncing_rail_project = True
|
||||
try:
|
||||
choices = self.workspace.project_choices()
|
||||
current = self.workspace.selected_project_id()
|
||||
self.nav_project.clear()
|
||||
for name, pid in choices:
|
||||
self.nav_project.addItem(f"📁 {name}", pid)
|
||||
if not choices:
|
||||
# No project yet: say so, and say what to do about it, instead of
|
||||
# leaving an empty box and a button that silently does nothing.
|
||||
self.nav_project.addItem(tr("app.nav.no_project"), "")
|
||||
idx = self.nav_project.findData(current)
|
||||
if idx >= 0:
|
||||
self.nav_project.setCurrentIndex(idx)
|
||||
has = bool(choices)
|
||||
tidy_popup(self.nav_project)
|
||||
self.nav_project.setEnabled(has)
|
||||
self.nav_project_btn.setEnabled(has)
|
||||
self.nav_project_btn.setToolTip(
|
||||
self.nav_project.currentText().replace("📁 ", "")
|
||||
if has else tr("app.nav.create_project_first"))
|
||||
self.nav_new_chat.setEnabled(has)
|
||||
self.nav_new_chat.setToolTip(
|
||||
"" if has else tr("app.nav.create_project_first"))
|
||||
finally:
|
||||
self._syncing_rail_project = False
|
||||
def _fill_rail_project_menu(self) -> None:
|
||||
"""Mirror the picker's items. Choosing one moves the picker, which runs
|
||||
_on_rail_project_pick — the collapsed rail adds no second code path."""
|
||||
menu = self.nav_project_btn.menu()
|
||||
menu.clear()
|
||||
for i in range(self.nav_project.count()):
|
||||
act = menu.addAction(self.nav_project.itemText(i))
|
||||
act.setCheckable(True)
|
||||
act.setChecked(i == self.nav_project.currentIndex())
|
||||
act.triggered.connect(
|
||||
lambda _checked=False, row=i: self.nav_project.setCurrentIndex(row))
|
||||
def _on_rail_project_pick(self, _idx: int) -> None:
|
||||
if self._syncing_rail_project:
|
||||
return
|
||||
pid = self.nav_project.currentData()
|
||||
if pid:
|
||||
self.workspace.choose_project(pid)
|
||||
def _refresh_rail_recents(self) -> None:
|
||||
"""Re-fill the rail's recents from the active project's history."""
|
||||
from ...ui.icons import DOT_BLUE, dot_icon
|
||||
from ...ui.icons import icon as _icon
|
||||
|
||||
tree = self.nav_recents
|
||||
blocked = tree.blockSignals(True)
|
||||
tree.clear()
|
||||
running = self._running_session_ids()
|
||||
threads = self.workspace.recent_threads(self._RAIL_RECENTS)
|
||||
for t in threads:
|
||||
it = QTreeWidgetItem([t["title"]])
|
||||
it.setToolTip(0, t["title"])
|
||||
if t["session_id"] in running:
|
||||
it.setIcon(0, dot_icon(DOT_BLUE)) # same marker as History
|
||||
elif t["pinned"]:
|
||||
it.setIcon(0, _icon("pin"))
|
||||
it.setData(0, Qt.UserRole, {"path": t["path"], "kind": t["kind"]})
|
||||
tree.addTopLevelItem(it)
|
||||
if not threads:
|
||||
it = QTreeWidgetItem([tr("sidebar.empty")])
|
||||
it.setDisabled(True)
|
||||
tree.addTopLevelItem(it)
|
||||
# The way back to everything the rail cannot show — styled as a link
|
||||
# (italic, accent-colored) so it reads as "go elsewhere", not another row.
|
||||
more = QTreeWidgetItem([tr("app.nav.all_projects")])
|
||||
more.setData(0, Qt.UserRole, {"all": True})
|
||||
more_font = more.font(0)
|
||||
more_font.setItalic(True)
|
||||
more.setFont(0, more_font)
|
||||
more.setForeground(0, QColor(current_palette().accent))
|
||||
tree.addTopLevelItem(more)
|
||||
tree.blockSignals(blocked)
|
||||
self.nav_recents_hdr.setVisible(not self._nav_collapsed)
|
||||
self.nav_recents.setVisible(not self._nav_collapsed)
|
||||
def _on_rail_recent(self, item, _col: int = 0) -> None:
|
||||
data = item.data(0, Qt.UserRole) or {}
|
||||
if data.get("all"):
|
||||
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
|
||||
self.workspace.show_history_pane()
|
||||
return
|
||||
path = data.get("path")
|
||||
if path:
|
||||
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
|
||||
self.workspace.open_thread(path, data.get("kind", "cowork"))
|
||||
def _on_rail_new_chat(self) -> None:
|
||||
"""Start a new chat, from any screen.
|
||||
|
||||
Same call the Cowork toolbar button makes — that button stays exactly
|
||||
where it was; this is a second entry point, not a replacement.
|
||||
"""
|
||||
self._goto(self._ROW_WORKSPACE, None)
|
||||
self.workspace.start_new_chat()
|
||||
self._select_nav_row(self._ROW_WORKSPACE, self.workspace.current_subtab())
|
||||
def _nav_new_chat_enabled(self) -> bool:
|
||||
return bool(self.workspace.project_choices())
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Lịch sử hội thoại và thông báo khi task chạy xong — R08-T10.
|
||||
|
||||
Gom những gì phản ứng với việc **có chuyện xảy ra ở nơi khác**: một task đã lên
|
||||
lịch chạy xong, một phiên được lưu, danh sách project đổi.
|
||||
|
||||
Điểm dễ sai đã ghi lại trong ``_on_scheduled_task_done``: tín hiệu
|
||||
``task_started`` bắn TRƯỚC khi luồng chạy bắt đầu, lúc đó phiên chưa có trên
|
||||
đĩa — làm mới Lịch sử ở đó thì không thấy gì. Phải bám ``history_ready``.
|
||||
|
||||
Cùng kiểu mixin, xem ghi chú ở đầu ``nav_rail.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from ... import DISPLAY_NAME
|
||||
from ...i18n import tr
|
||||
from ...ui.workspace_tab import WorkspaceTab
|
||||
|
||||
|
||||
class SessionEventsMixin:
|
||||
def _running_session_ids(self):
|
||||
"""All conversation ids currently running — interactive Cowork/Code
|
||||
chat tab AgentWorkers, plus Schedule Task runs (their own session,
|
||||
tracked by the scheduler), so a task's live run gets the same
|
||||
"running" marker in History an interactive chat gets."""
|
||||
return set(self.cowork.running_session_ids()) | self.task_scheduler.running_session_ids()
|
||||
def _refresh_history(self) -> None:
|
||||
"""Rebuild the History list with the current conversation highlighted and
|
||||
the running ones marked. Deferred to the next event-loop tick: this is often
|
||||
triggered (via load_conversation) from inside the sidebar's own item-click
|
||||
handler, and clearing the tree there would delete the item mid-click."""
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
def _do() -> None:
|
||||
current = self.cowork.session_id
|
||||
self.sidebar.set_view_state(current, self._running_session_ids())
|
||||
self.sidebar.refresh()
|
||||
self._refresh_rail_recents() # the rail shortcut follows the panel
|
||||
|
||||
QTimer.singleShot(0, _do)
|
||||
def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None:
|
||||
"""Desktop notification for a finished scheduled task (toast always,
|
||||
tray balloon when the window isn't focused), then refresh History —
|
||||
cowork/co4e task runs just saved themselves as new sessions there."""
|
||||
from ...core.tasks import load_task
|
||||
|
||||
task = load_task(task_id) or {}
|
||||
title = task.get("title", "")
|
||||
msg = (tr("app.toast.task_done", title=title) if ok
|
||||
else tr("app.toast.task_failed", title=title))
|
||||
self.toast.show_message(msg, ok=ok)
|
||||
if (self.tray is not None
|
||||
and self.ctx.config.data.get("tray", {}).get("notify_on_done", True)
|
||||
and not self.isActiveWindow()):
|
||||
self._tray.show_message(DISPLAY_NAME, msg, error=not ok)
|
||||
self._refresh_history()
|
||||
def _notify_task(self, tab, kind: str, result: dict) -> None:
|
||||
"""Notify when a task finishes/fails (skip if more stages queued)."""
|
||||
if tab.composer.has_queue():
|
||||
return # a flow / queue is still running — notify only at the end
|
||||
name = tr(f"app.tab.{kind}")
|
||||
err = (result or {}).get("error")
|
||||
# In-app popup at the top-left (shown whether or not the window is focused).
|
||||
self.toast.show_message(
|
||||
tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name), ok=not err)
|
||||
# System-tray balloon only when the window isn't the active one.
|
||||
if self.tray is None:
|
||||
return
|
||||
if not self.ctx.config.data.get("tray", {}).get("notify_on_done", True):
|
||||
return
|
||||
if self.isActiveWindow():
|
||||
return # user is looking at the window already
|
||||
err = (result or {}).get("error")
|
||||
title = tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name)
|
||||
body = (err if err else (tab._last_assistant_text() or "Task completed."))[:140]
|
||||
self._tray.show_message(title, body, error=bool(err))
|
||||
def _restore_sessions(self) -> None:
|
||||
"""Reopen the last conversation per tab (recover after a crash/abrupt exit)."""
|
||||
from pathlib import Path
|
||||
|
||||
from ...core.history import load_conversation
|
||||
|
||||
last = self.ctx.config.data.get("last_session", {})
|
||||
path = last.get("cowork", "")
|
||||
if path and Path(path).exists():
|
||||
try:
|
||||
self.cowork.load_conversation(load_conversation(path))
|
||||
# Reflect the restored thread's project in the Workspace home
|
||||
# (selecting the matching row won't wipe it — the project id
|
||||
# already matches, so _bind_project starts no new session).
|
||||
# Skip forcing the Cowork tab open for a project that no
|
||||
# longer exists (deleted since this session was saved) — that
|
||||
# would show the Cowork page while the tab strip still says
|
||||
# "no project selected" (see WorkspaceTab._on_sidebar_open).
|
||||
pid = self.cowork.project_id
|
||||
if pid in ("", "default") or self.workspace._select_project_row(pid):
|
||||
self.workspace._show_cowork_tab()
|
||||
except Exception:
|
||||
pass
|
||||
def _on_projects_changed(self) -> None:
|
||||
self.sidebar.refresh() # History regroups by project
|
||||
self.cowork._apply_output_folder_label() # project may have been renamed
|
||||
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Thông báo nhỏ tự ẩn ở góc trên trái cửa sổ — R08-T10.
|
||||
|
||||
Hiện ngay trong app, khác với bong bóng khay hệ thống ở ``tray_manager.py``:
|
||||
cái này hiện dù cửa sổ có đang được focus hay không, cái kia chỉ hiện khi
|
||||
người dùng đang nhìn chỗ khác.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from ...theme import current_palette
|
||||
|
||||
|
||||
class Toast(QLabel):
|
||||
"""A small auto-hiding notification shown at the window's top-left."""
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("toast")
|
||||
self.setWordWrap(True)
|
||||
self.setMaximumWidth(380)
|
||||
self.setVisible(False)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self.hide)
|
||||
|
||||
def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None:
|
||||
p = current_palette()
|
||||
bg = p.success_soft if ok else p.danger_soft
|
||||
fg = p.success if ok else p.danger
|
||||
self.setStyleSheet(
|
||||
f"#toast {{ background:{bg}; color:{fg}; border:1px solid {fg};"
|
||||
f" border-radius:{p.radius}px; padding:10px 16px; font-weight:600; }}")
|
||||
self.setText(text)
|
||||
self.adjustSize()
|
||||
self.move(14, 14) # top-left of the window
|
||||
self.raise_()
|
||||
self.setVisible(True)
|
||||
self._timer.start(ms)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Thanh trên cùng và hàng tài khoản — R08-T10.
|
||||
|
||||
Bóc từ ``MainWindow``: logo, chọn provider, chọn ngôn ngữ, nút đổi giao diện,
|
||||
và lối mở hộp thoại Cài đặt.
|
||||
|
||||
Cùng lý do mixin như ``nav_rail.py``: các phương thức này đọc/ghi state của cửa
|
||||
sổ. Xem ghi chú ở đầu file đó.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QComboBox, QHBoxLayout, QLabel, QMenu, QPushButton, QToolButton, QVBoxLayout, QWidget
|
||||
from ...config import PROVIDER_LABELS
|
||||
from ...i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr
|
||||
from .branding import ASSETS
|
||||
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET
|
||||
from ...ui.widgets import tidy_popup
|
||||
from ...theme import set_active_theme, stylesheet
|
||||
from ...ui.settings_dialog import SettingsDialog
|
||||
|
||||
|
||||
|
||||
|
||||
class TopBarMixin:
|
||||
"""Thanh trên cùng. Trộn vào MainWindow."""
|
||||
|
||||
def _build_rail_bottom(self, nvl) -> None:
|
||||
"""Đáy thanh rail: nhóm ghim dưới, nút Cài đặt, hàng tài khoản.
|
||||
|
||||
Nằm ở file thanh trên cùng chứ không phải file rail, vì ba thứ này
|
||||
đều là "tài khoản và thiết lập" — cùng mối quan tâm với
|
||||
``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình.
|
||||
"""
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton
|
||||
from ...i18n import tr
|
||||
from ...ui.icons import icon as _icon
|
||||
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET
|
||||
|
||||
# Bottom-pinned group: the places you visit occasionally, kept out of the
|
||||
# way of the ones you live in. A hairline (styled via #navrailBottom in
|
||||
# theme.py) separates the two lists.
|
||||
nvl.addWidget(self.nav_bottom, 0)
|
||||
# Settings reads as one more row under Dashboard / Giám sát, so its icon
|
||||
# and label must start exactly where theirs do. Letting QPushButton place
|
||||
# them does not achieve that: the gap it leaves between icon and text is
|
||||
# the platform style's, and on macOS it is visibly tighter than the tree
|
||||
# rows above — a Windows-tuned nudge only moved the mismatch. So the row
|
||||
# is laid out here, in the same two numbers the tree uses: 4px in, 6px
|
||||
# between.
|
||||
self._nav_settings_btn = QPushButton()
|
||||
self._nav_settings_btn.setObjectName("navSettingsBtn")
|
||||
self._nav_settings_btn.setFlat(True)
|
||||
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._nav_settings_btn.clicked.connect(self._open_settings)
|
||||
srow = QHBoxLayout(self._nav_settings_btn)
|
||||
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
|
||||
srow.setSpacing(_NAV_ROW_GAP)
|
||||
self._nav_settings_icon = QLabel()
|
||||
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
|
||||
self._nav_settings_icon.setFixedSize(16, 16)
|
||||
self._nav_settings_text = QLabel(tr("app.settings"))
|
||||
srow.addWidget(self._nav_settings_icon)
|
||||
srow.addWidget(self._nav_settings_text)
|
||||
srow.addStretch(1)
|
||||
nvl.addWidget(self._nav_settings_btn)
|
||||
self._account_row = self._build_account_row()
|
||||
|
||||
def _build_topbar(self) -> QWidget:
|
||||
bar = QWidget()
|
||||
bar.setObjectName("topbar")
|
||||
# Styled centrally (see theme._TEMPLATE): flat, with a single hairline
|
||||
# separating it from the content below — no card box behind it.
|
||||
h = QHBoxLayout(bar)
|
||||
h.setContentsMargins(16, 10, 12, 10)
|
||||
h.setSpacing(10)
|
||||
# FPT logo slot in front of the brand text: shown only when a logo
|
||||
# image has been dropped into assets/ (see _brand_logo_pixmap) — the
|
||||
# brand works text-only until the real artwork is supplied.
|
||||
self.logo_img = QLabel()
|
||||
logo_pm = self._brand_logo_pixmap()
|
||||
if logo_pm is not None:
|
||||
self.logo_img.setPixmap(logo_pm)
|
||||
else:
|
||||
self.logo_img.setVisible(False)
|
||||
h.addWidget(self.logo_img)
|
||||
self.logo_lbl = QLabel(tr("app.logo"))
|
||||
self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE
|
||||
h.addWidget(self.logo_lbl)
|
||||
h.addStretch(1)
|
||||
# Provider / language / theme / Settings used to live here, five controls
|
||||
# wide across the top of every screen. They are per-account settings, not
|
||||
# per-screen ones, so they moved to the account row at the foot of the
|
||||
# rail (_build_account_row) — same widgets, same handlers, new home.
|
||||
return bar
|
||||
def _build_account_row(self) -> QWidget:
|
||||
"""The rail's foot: who you are, and the settings that follow you.
|
||||
|
||||
Nothing new is introduced here — these are the exact widgets the top bar
|
||||
used to hold, moved as-is so every existing signal still lands.
|
||||
"""
|
||||
box = QWidget()
|
||||
box.setObjectName("navAccount")
|
||||
v = QVBoxLayout(box)
|
||||
v.setContentsMargins(6, 4, 6, 4)
|
||||
v.setSpacing(4)
|
||||
|
||||
who = QHBoxLayout()
|
||||
who.setSpacing(4)
|
||||
self.account_lbl = QLabel(f"👤 {self._user_name}" if self._user_name else "👤")
|
||||
self.account_lbl.setObjectName("hint")
|
||||
who.addWidget(self.account_lbl, 1)
|
||||
self.language_combo = QComboBox()
|
||||
for key in LANGUAGES:
|
||||
self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key)
|
||||
self.language_combo.setItemData(
|
||||
self.language_combo.count() - 1, LANGUAGES[key], Qt.ToolTipRole)
|
||||
idx = self.language_combo.findData(get_language())
|
||||
if idx >= 0:
|
||||
self.language_combo.setCurrentIndex(idx)
|
||||
tidy_popup(self.language_combo)
|
||||
self.language_combo.currentIndexChanged.connect(self._on_language_changed)
|
||||
who.addWidget(self.language_combo)
|
||||
self.theme_btn = self._build_theme_button()
|
||||
who.addWidget(self.theme_btn)
|
||||
v.addLayout(who)
|
||||
|
||||
self.provider_lbl = QLabel(tr("app.provider"))
|
||||
self.provider_lbl.setObjectName("hint")
|
||||
self.provider_lbl.setVisible(False) # the combo names itself in the rail
|
||||
self.provider_combo = QComboBox()
|
||||
self.provider_combo.setToolTip(tr("app.provider"))
|
||||
for key, label in PROVIDER_LABELS.items():
|
||||
self.provider_combo.addItem(label, key)
|
||||
tidy_popup(self.provider_combo)
|
||||
idx = self.provider_combo.findData(self.ctx.config.active_provider)
|
||||
if idx >= 0:
|
||||
self.provider_combo.setCurrentIndex(idx)
|
||||
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
|
||||
v.addWidget(self.provider_lbl)
|
||||
v.addWidget(self.provider_combo)
|
||||
return box
|
||||
def _brand_logo_pixmap(self):
|
||||
"""The FPT logo scaled to top-bar height, or None while no logo file
|
||||
exists yet — drop the artwork into src/cowork_local/assets/ under one
|
||||
of the _BRAND_LOGO_NAMES and it appears on next launch."""
|
||||
from PySide6.QtGui import QPixmap
|
||||
|
||||
for name in self._BRAND_LOGO_NAMES:
|
||||
path = ASSETS / name
|
||||
if not path.exists():
|
||||
continue
|
||||
pm = QPixmap(str(path))
|
||||
if pm.isNull():
|
||||
continue
|
||||
return pm.scaledToHeight(self._BRAND_LOGO_HEIGHT, Qt.SmoothTransformation)
|
||||
return None
|
||||
def _build_theme_button(self) -> QToolButton:
|
||||
"""A single icon button (System/Dark/Light) replacing the old
|
||||
Settings-only theme dropdown — one click applies the choice
|
||||
immediately via the existing _apply_theme(), no dialog round-trip."""
|
||||
from ...ui.icons import icon as _icon
|
||||
|
||||
btn = QToolButton()
|
||||
btn.setPopupMode(QToolButton.InstantPopup)
|
||||
menu = QMenu(btn)
|
||||
self._theme_actions = {}
|
||||
for value, icon_name in self._THEME_ICONS.items():
|
||||
act = menu.addAction(_icon(icon_name), tr(f"settings.theme_{value}"))
|
||||
act.triggered.connect(lambda _checked=False, v=value: self._set_theme(v))
|
||||
self._theme_actions[value] = act
|
||||
btn.setMenu(menu)
|
||||
btn.setIcon(_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
|
||||
return btn
|
||||
def _set_theme(self, value: str) -> None:
|
||||
from ...ui.icons import icon as _icon
|
||||
|
||||
self.ctx.config.theme = value
|
||||
self.ctx.save()
|
||||
self._apply_theme()
|
||||
self.theme_btn.setIcon(_icon(self._THEME_ICONS.get(value, "monitor")))
|
||||
def _on_provider_changed(self, _idx: int) -> None:
|
||||
self.ctx.config.active_provider = self.provider_combo.currentData()
|
||||
self.ctx.save()
|
||||
self.cowork.refresh_header()
|
||||
# Reload the Cowork tab's Agent (Model) list for the newly selected provider.
|
||||
self.cowork.refresh_agents()
|
||||
self.workspace.refresh_ai_models() # + the Folder AI-edit model picker
|
||||
self.statusBar().showMessage(
|
||||
tr("app.status.using_provider",
|
||||
label=PROVIDER_LABELS.get(self.ctx.config.active_provider))
|
||||
)
|
||||
def _on_language_changed(self, _idx: int) -> None:
|
||||
lang = self.language_combo.currentData()
|
||||
if not lang or lang == get_language():
|
||||
return
|
||||
self.ctx.config.language = lang
|
||||
self.ctx.save()
|
||||
set_language(lang) # notifies every registered persistent widget
|
||||
def _open_settings(self) -> None:
|
||||
dlg = SettingsDialog(self.ctx, self)
|
||||
if dlg.exec():
|
||||
self._apply_theme()
|
||||
# Settings can change the theme too — keep the rail's toggle icon
|
||||
# showing the value that is actually in effect.
|
||||
from ...ui.icons import icon as _theme_icon
|
||||
self.theme_btn.setIcon(
|
||||
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
|
||||
set_language(self.ctx.config.language) # apply if changed in Settings
|
||||
# reflect provider/theme/language changes
|
||||
i = self.provider_combo.findData(self.ctx.config.active_provider)
|
||||
if i >= 0:
|
||||
self.provider_combo.setCurrentIndex(i)
|
||||
li = self.language_combo.findData(get_language())
|
||||
if li >= 0:
|
||||
self.language_combo.blockSignals(True)
|
||||
self.language_combo.setCurrentIndex(li)
|
||||
self.language_combo.blockSignals(False)
|
||||
self.cowork.refresh_header()
|
||||
self.cowork.refresh_agents()
|
||||
self.workspace.refresh_ai_models() # + the Folder AI-edit model picker
|
||||
max_files = int(self.ctx.config.data.get("attachments", {}).get("max_files", 10) or 0)
|
||||
self.cowork.composer.set_max_attachments(max_files)
|
||||
self.sidebar.refresh()
|
||||
self.statusBar().showMessage(tr("app.status.settings_saved"))
|
||||
def _apply_theme(self) -> None:
|
||||
app = QApplication.instance()
|
||||
if app:
|
||||
set_active_theme(self.ctx.config.theme)
|
||||
app.setStyleSheet(stylesheet(self.ctx.config.theme))
|
||||
# Re-apply theme styles to chat bubbles so they adapt to the new theme.
|
||||
self.cowork.apply_theme()
|
||||
if getattr(self, "help_agent", None) is not None:
|
||||
self.help_agent.apply_theme() # chat body follows theme (header stays fixed)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Biểu tượng khay hệ thống — R08-T10.
|
||||
|
||||
Bóc từ ``app.py::MainWindow``. Giữ biểu tượng khay, menu chuột phải của nó, và
|
||||
việc bắn thông báo bong bóng.
|
||||
|
||||
Vì sao tách: khay là thứ **có thể không tồn tại**. Máy không có khay hệ thống
|
||||
(một số môi trường Linux, phiên RDP) thì ``isSystemTrayAvailable()`` trả False
|
||||
và mọi thứ ở đây phải im lặng chấp nhận. Trộn lẫn trong MainWindow thì mỗi chỗ
|
||||
dùng đều phải tự nhớ kiểm ``if self.tray is not None`` — đã có 6 chỗ như thế.
|
||||
Gói lại thì chỗ gọi cứ gọi, không có khay thì không có gì xảy ra.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import QMenu, QSystemTrayIcon
|
||||
|
||||
|
||||
class TrayManager:
|
||||
"""Khay hệ thống của một cửa sổ. An toàn khi máy không có khay."""
|
||||
|
||||
def __init__(self, window, *, icon, tooltip: str, tr):
|
||||
self.window = window
|
||||
self._tr = tr
|
||||
self.icon: QSystemTrayIcon | None = None
|
||||
self._open_act: QAction | None = None
|
||||
self._quit_act: QAction | None = None
|
||||
self._tooltip = tooltip
|
||||
self._app_icon = icon
|
||||
|
||||
# ---- dựng ------------------------------------------------------------
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Dựng biểu tượng khay. Không có khay thì lặng lẽ bỏ qua."""
|
||||
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||
return
|
||||
w = self.window
|
||||
self.icon = QSystemTrayIcon(self._app_icon(), w)
|
||||
self.icon.setToolTip(self._tooltip)
|
||||
|
||||
menu = QMenu()
|
||||
self._open_act = QAction(self._tr("app.tray.open"), w)
|
||||
self._open_act.triggered.connect(w._show_window)
|
||||
self._quit_act = QAction(self._tr("app.tray.quit"), w)
|
||||
self._quit_act.triggered.connect(w._quit_app)
|
||||
menu.addAction(self._open_act)
|
||||
menu.addAction(self._quit_act)
|
||||
self.icon.setContextMenu(menu)
|
||||
|
||||
self.icon.activated.connect(
|
||||
lambda reason: w._show_window() if reason == QSystemTrayIcon.Trigger else None)
|
||||
self.icon.show()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
if self.icon is not None:
|
||||
self.icon.setToolTip(self._tooltip)
|
||||
if self._open_act is not None:
|
||||
self._open_act.setText(self._tr("app.tray.open"))
|
||||
self._quit_act.setText(self._tr("app.tray.quit"))
|
||||
|
||||
def hide(self) -> None:
|
||||
if self.icon is not None:
|
||||
self.icon.hide()
|
||||
|
||||
# ---- thông báo -------------------------------------------------------
|
||||
|
||||
def show_message(self, title: str, body: str, *, error: bool = False,
|
||||
msec: int = 5000) -> None:
|
||||
"""Bắn bong bóng khay. Không có khay, hoặc hệ điều hành từ chối, thì
|
||||
thôi — một thông báo không hiện được không đáng làm hỏng lượt chạy."""
|
||||
if self.icon is None:
|
||||
return
|
||||
kind = QSystemTrayIcon.Critical if error else QSystemTrayIcon.Information
|
||||
try:
|
||||
self.icon.showMessage(title, body, kind, msec)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Task 4b — agent_security <-> agent_security_alert circular dependency is gone.
|
||||
|
||||
Before this fix, ``agent_security_alert.py`` imported ``SecurityVerdict`` from
|
||||
``agent_security.py`` at module level (for the ``notify_admin`` type
|
||||
annotation), while ``agent_security.py`` deferred-imported
|
||||
``agent_security_alert.notify_admin`` inside ``enforce_prompt``/
|
||||
``enforce_command`` — an architectural cycle only avoided at runtime by
|
||||
pushing that second import inside a function body.
|
||||
|
||||
``SecurityVerdict``/``SecurityBlocked`` now live in the dependency-free leaf
|
||||
module ``agent_security_types.py``. ``agent_security_alert.py`` imports the
|
||||
type from there instead of from ``agent_security.py``, which lets
|
||||
``agent_security.py`` import ``agent_security_alert.notify_admin`` at module
|
||||
top level with no cycle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.core import (
|
||||
agent_security,
|
||||
agent_security_alert,
|
||||
agent_security_types,
|
||||
)
|
||||
|
||||
|
||||
def test_shared_types_live_in_the_leaf_module() -> None:
|
||||
assert agent_security.SecurityVerdict is agent_security_types.SecurityVerdict
|
||||
assert agent_security.SecurityBlocked is agent_security_types.SecurityBlocked
|
||||
assert agent_security_alert.SecurityVerdict is agent_security_types.SecurityVerdict
|
||||
|
||||
|
||||
def test_agent_security_alert_no_longer_imports_agent_security() -> None:
|
||||
assert "agent_security" not in agent_security_alert.__dict__
|
||||
|
||||
|
||||
def test_notify_admin_imported_at_module_top_level_in_agent_security() -> None:
|
||||
assert agent_security.notify_admin is agent_security_alert.notify_admin
|
||||
|
||||
|
||||
def test_enforce_command_still_blocks_and_alerts_like_before(monkeypatch) -> None:
|
||||
class _FakeProvider:
|
||||
def chat(self, messages, tools=None):
|
||||
return {"content": '{"allowed": false, "reason": "destructive"}'}
|
||||
|
||||
class _Config:
|
||||
data = {"agent_security": {"enabled": True, "validate_commands": True,
|
||||
"command_ai_check": True}}
|
||||
ms365 = {}
|
||||
|
||||
@property
|
||||
def agent_security(self):
|
||||
return self.data["agent_security"]
|
||||
|
||||
notify_calls = []
|
||||
record_calls = []
|
||||
monkeypatch.setattr(agent_security, "notify_admin",
|
||||
lambda config, verdict, detail="": notify_calls.append((verdict, detail)))
|
||||
from cowork_local.core import audit_log
|
||||
monkeypatch.setattr(audit_log, "record",
|
||||
lambda *a, **k: record_calls.append((a, k)))
|
||||
|
||||
emitted = []
|
||||
raised = False
|
||||
try:
|
||||
agent_security.enforce_command(
|
||||
_FakeProvider(), "run_command", {"command": "rm -rf /"}, _Config(),
|
||||
emit=emitted.append,
|
||||
)
|
||||
except agent_security.SecurityBlocked as exc:
|
||||
raised = True
|
||||
assert exc.verdict.layer == "command"
|
||||
|
||||
assert raised is True
|
||||
assert notify_calls
|
||||
assert record_calls
|
||||
assert emitted and emitted[0]["type"] == "notice"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Task 2 — CanonicalAuditLogger.
|
||||
|
||||
Verifies: (1) the infrastructure class itself round-trips events correctly
|
||||
and mirrors to a shared dir, (2) ``core/audit_log.py``'s wrapper functions
|
||||
still behave exactly as before (same signatures, same dict schema, same
|
||||
never-raise guarantee), and (3) old-format raw dicts (as written by the
|
||||
pre-refactor ``core/audit_log.py``) still load correctly for backward
|
||||
compatibility.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from cowork_local.core import audit_log
|
||||
from cowork_local.infrastructure.telemetry.audit_logger import (
|
||||
KIND_MCP_CALL,
|
||||
KIND_SECURITY_BLOCK,
|
||||
CanonicalAuditEvent,
|
||||
CanonicalAuditLogger,
|
||||
)
|
||||
|
||||
|
||||
def test_record_and_load_round_trip(tmp_path) -> None:
|
||||
logger = CanonicalAuditLogger(tmp_path)
|
||||
logger.set_identity("alice", "machine-1", role="admin")
|
||||
logger.record(KIND_SECURITY_BLOCK, "run_command", False, detail="blocked it")
|
||||
|
||||
events = logger.load_events()
|
||||
assert len(events) == 1
|
||||
e = events[0]
|
||||
assert e.kind == KIND_SECURITY_BLOCK
|
||||
assert e.name == "run_command"
|
||||
assert e.ok is False
|
||||
assert e.detail == "blocked it"
|
||||
assert e.account == "alice"
|
||||
assert e.machine == "machine-1"
|
||||
assert e.role == "admin"
|
||||
|
||||
|
||||
def test_load_events_filters_by_kind(tmp_path) -> None:
|
||||
logger = CanonicalAuditLogger(tmp_path)
|
||||
logger.record(KIND_SECURITY_BLOCK, "a", False)
|
||||
logger.record(KIND_MCP_CALL, "b", True)
|
||||
|
||||
only_mcp = logger.load_events(kind=KIND_MCP_CALL)
|
||||
assert [e.name for e in only_mcp] == ["b"]
|
||||
|
||||
|
||||
def test_shared_dir_mirroring(tmp_path) -> None:
|
||||
shared = tmp_path / "shared"
|
||||
logger = CanonicalAuditLogger(tmp_path / "audit")
|
||||
logger.set_identity("bob", "machine-2", shared_dir=str(shared))
|
||||
logger.record(KIND_MCP_CALL, "tool_x", True)
|
||||
|
||||
mirrored_files = list((shared / "telemetry" / "audit").glob("machine-2-*.jsonl"))
|
||||
assert len(mirrored_files) == 1
|
||||
|
||||
|
||||
def test_from_dict_is_tolerant_of_old_partial_rows() -> None:
|
||||
old_row = {"ts": "2024-01-01T00:00:00", "kind": "tool_call", "name": "x", "ok": True}
|
||||
event = CanonicalAuditEvent.from_dict(old_row)
|
||||
assert event.detail == ""
|
||||
assert event.account == ""
|
||||
|
||||
|
||||
def test_record_never_raises_on_bad_directory(tmp_path) -> None:
|
||||
bad_dir = tmp_path / "some_file.txt"
|
||||
bad_dir.write_text("not a directory")
|
||||
logger = CanonicalAuditLogger(bad_dir / "audit")
|
||||
logger.record(KIND_SECURITY_BLOCK, "x", False) # must not raise
|
||||
|
||||
|
||||
def test_core_audit_log_wrapper_same_schema_as_before(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path)
|
||||
monkeypatch.setattr(audit_log, "_logger",
|
||||
audit_log.CanonicalAuditLogger(tmp_path))
|
||||
audit_log.set_identity("carol", "machine-3", role="user")
|
||||
audit_log.record("permission", "install_package", True, detail="ok", agent_role="cowork")
|
||||
|
||||
events = audit_log.load_events()
|
||||
assert len(events) == 1
|
||||
e = events[0]
|
||||
assert set(e.keys()) == {"ts", "kind", "agent_role", "name", "ok", "detail",
|
||||
"account", "role", "machine"}
|
||||
assert e["kind"] == "permission"
|
||||
assert e["name"] == "install_package"
|
||||
assert e["ok"] is True
|
||||
assert e["agent_role"] == "cowork"
|
||||
assert e["account"] == "carol"
|
||||
|
||||
# Raw file on disk still uses the exact pre-refactor schema/keys.
|
||||
raw_line = next((tmp_path).glob("*.jsonl")).read_text(encoding="utf-8").splitlines()[0]
|
||||
raw = json.loads(raw_line)
|
||||
assert list(raw.keys()) == ["ts", "kind", "agent_role", "name", "ok", "detail",
|
||||
"account", "role", "machine"]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Mọi chỗ trong repo gán ``config.X = ...`` thì repository phải nhận được.
|
||||
|
||||
Bài này sinh ra từ một lỗi thật ngày 26/08.
|
||||
|
||||
R02 tráo ``config.py::AppConfig`` bằng ``JsonConfigRepository``. Trước khi
|
||||
tráo tôi có đối chiếu API: đếm đủ 34/34 thành viên công khai, không thiếu cái
|
||||
nào, nên kết luận là thay được. Đối chiếu đó **chỉ so tên**, không so việc một
|
||||
``property`` có setter hay không.
|
||||
|
||||
``AppConfig`` cũ là dataclass, ``config.language = "vi"`` chạy bình thường.
|
||||
Repository để ``language`` là property chỉ đọc, gán vào là ``AttributeError``.
|
||||
Ba chỗ trong ``app.py`` đang gán như thế: đổi ngôn ngữ, đổi giao diện, đổi
|
||||
provider trên thanh bên.
|
||||
|
||||
Điều làm nó khó thấy: cả ba đều nằm trong slot của Qt, mà Qt **nuốt ngoại lệ
|
||||
trong slot**. Không có traceback, không có thông báo — người dùng bấm đổi ngôn
|
||||
ngữ thì không có gì xảy ra. Bộ test đơn vị vẫn 709 xanh; chỉ ``check_nav`` bắt
|
||||
được vì nó bấm thật vào combo rồi kiểm ngôn ngữ có đổi không.
|
||||
|
||||
Nên bài này đi ngược từ mã nguồn: tìm mọi chỗ gán, rồi thử gán thật.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: ``config.X = ...`` nhưng không phải ``==``.
|
||||
GAN = re.compile(r"\bconfig\.([a-z_][a-z_0-9]*)\s*=(?!=)")
|
||||
|
||||
#: Không phải thuộc tính cấu hình — là chỗ chứa chính đối tượng config.
|
||||
BO_QUA = {"data", "config"}
|
||||
|
||||
|
||||
def _cho_gan() -> set[str]:
|
||||
out = subprocess.run(["git", "ls-files", "*.py"], cwd=REPO,
|
||||
capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace").stdout.split()
|
||||
ten: set[str] = set()
|
||||
for f in out:
|
||||
p = REPO / f
|
||||
if not p.is_file():
|
||||
continue
|
||||
for m in GAN.finditer(p.read_text(encoding="utf-8", errors="replace")):
|
||||
if m.group(1) not in BO_QUA and not m.group(1).startswith("_"):
|
||||
ten.add(m.group(1))
|
||||
return ten
|
||||
|
||||
|
||||
def _repo(tmp_path):
|
||||
from cowork_local.config import DEFAULT_CONFIG
|
||||
from cowork_local.infrastructure.config.json_config_repository import (
|
||||
JsonConfigRepository,
|
||||
)
|
||||
return JsonConfigRepository.from_data(copy.deepcopy(DEFAULT_CONFIG),
|
||||
tmp_path / "config.json")
|
||||
|
||||
|
||||
def test_tim_duoc_cho_gan():
|
||||
"""Bảo vệ chính bài test: biểu thức tìm kiếm hỏng thì nó lặng lẽ xanh."""
|
||||
ten = _cho_gan()
|
||||
assert ten, "không tìm thấy chỗ nào gán config.X — kiểm lại GAN"
|
||||
assert "language" in ten, f"phải thấy config.language (thấy: {sorted(ten)})"
|
||||
|
||||
|
||||
def test_moi_thuoc_tinh_bi_gan_deu_gan_duoc(tmp_path):
|
||||
cfg = _repo(tmp_path)
|
||||
hong = []
|
||||
for ten in sorted(_cho_gan()):
|
||||
if not hasattr(type(cfg), ten) and not hasattr(cfg, ten):
|
||||
continue # thuộc tính của lớp khác, không phải config
|
||||
cu = getattr(cfg, ten, None)
|
||||
try:
|
||||
setattr(cfg, ten, cu)
|
||||
except AttributeError:
|
||||
hong.append(ten)
|
||||
|
||||
assert not hong, (
|
||||
"Repository không nhận gán, nhưng trong mã nguồn có chỗ gán:\n "
|
||||
+ "\n ".join("config.%s = ..." % t for t in hong)
|
||||
+ "\nQt nuốt AttributeError trong slot, nên chỗ đó sẽ im lặng không "
|
||||
"làm gì. Thêm @<tên>.setter vào JsonConfigRepository."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ten,gia_tri", [("language", "ja"), ("theme", "light"),
|
||||
("active_provider", "ollama")])
|
||||
def test_ba_cho_app_py_dang_gan(tmp_path, ten, gia_tri):
|
||||
"""Chốt riêng ba cái app.py gán, để bài trên có hỏng thì vẫn còn lưới."""
|
||||
cfg = _repo(tmp_path)
|
||||
setattr(cfg, ten, gia_tri)
|
||||
assert getattr(cfg, ten) == gia_tri
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Task 4a — model_pricing <-> usage_tracker circular dependency is gone.
|
||||
|
||||
Before this fix, ``model_pricing.turn_cost_usd`` deferred-imported
|
||||
``usage_tracker`` for its flat fallback rates, while ``usage_tracker.set_budget``
|
||||
deferred-imported ``model_pricing`` for currency conversion — a real
|
||||
architectural cycle, only avoided at runtime by pushing both imports inside
|
||||
function bodies. Now ``model_pricing`` is a leaf module (it owns its own
|
||||
fallback rates) and ``usage_tracker`` imports it at module top level.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.core import model_pricing, usage_tracker
|
||||
|
||||
|
||||
def test_model_pricing_does_not_depend_on_usage_tracker_module_level() -> None:
|
||||
assert "usage_tracker" not in model_pricing.__dict__
|
||||
assert "usage_tracker" not in getattr(model_pricing, "__all__", [])
|
||||
|
||||
|
||||
def test_usage_tracker_imports_model_pricing_at_top_level() -> None:
|
||||
assert usage_tracker.mp is model_pricing
|
||||
|
||||
|
||||
def test_turn_cost_usd_fallback_matches_pre_refactor_default_rates(tmp_path) -> None:
|
||||
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
|
||||
# No matching row in the price table and no override in config.data["usage"]
|
||||
# -> falls back to the flat rates that used to live in
|
||||
# usage_tracker.DEFAULT_PRICING (0.5 in / 1.5 out USD per 1M tokens).
|
||||
cost = model_pricing.turn_cost_usd("some-unknown-model", 1_000_000, 1_000_000, config)
|
||||
assert cost == 0.5 + 1.5
|
||||
|
||||
|
||||
def test_turn_cost_usd_honours_usage_override_like_before(tmp_path) -> None:
|
||||
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
|
||||
config.data.setdefault("usage", {})["price_per_mtok_in_usd"] = 2.0
|
||||
config.data["usage"]["price_per_mtok_out_usd"] = 4.0
|
||||
cost = model_pricing.turn_cost_usd("some-unknown-model", 1_000_000, 1_000_000, config)
|
||||
assert cost == 2.0 + 4.0
|
||||
|
||||
|
||||
def test_set_budget_still_converts_via_model_pricing(tmp_path) -> None:
|
||||
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
|
||||
usage_tracker.set_budget(config, 100.0, currency="USD")
|
||||
status = usage_tracker.budget_status(config)
|
||||
assert status is not None
|
||||
assert status["amount_usd"] == 100.0
|
||||
|
||||
|
||||
def test_no_import_time_cycle_when_loaded_fresh() -> None:
|
||||
for name in ("cowork_local.core.model_pricing", "cowork_local.core.usage_tracker"):
|
||||
sys.modules.pop(name, None)
|
||||
import importlib
|
||||
|
||||
mp = importlib.import_module("cowork_local.core.model_pricing")
|
||||
ut = importlib.import_module("cowork_local.core.usage_tracker")
|
||||
assert ut.mp is mp
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Task 1 (sub-step 6d) — AgentStatusTab widget smoke test."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.tabs.agent_status_tab import AgentStatusTab
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
class _FakeCowork:
|
||||
def active_workers(self):
|
||||
return [1, 2]
|
||||
|
||||
|
||||
class _FakeTaskScheduler:
|
||||
def running_count(self):
|
||||
return 3
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, tmp_path):
|
||||
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
|
||||
self.config.data["agent_security"]["enabled"] = True
|
||||
|
||||
|
||||
def test_agent_status_tab_has_no_search_or_detail(qapp, tmp_path) -> None:
|
||||
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None)
|
||||
assert not hasattr(tab, "filter_edit")
|
||||
assert not hasattr(tab, "detail_panel")
|
||||
|
||||
|
||||
def test_refresh_populates_six_rows_with_live_counts(qapp, tmp_path) -> None:
|
||||
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None,
|
||||
cowork=_FakeCowork(), task_scheduler=_FakeTaskScheduler())
|
||||
tab.refresh()
|
||||
assert tab.table.rowCount() == 6
|
||||
assert tab.table.item(0, 0).text() # Cowork row has a label
|
||||
assert tab.table.cellWidget(0, 1) is not None # badge pill widget
|
||||
|
||||
|
||||
def test_retranslate_does_not_raise(qapp, tmp_path) -> None:
|
||||
tab = AgentStatusTab(_FakeCtx(tmp_path), on_refresh_all=lambda: None)
|
||||
tab.retranslate()
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Task 1 (sub-step 6c) — SecurityEventsTab / McpTab / ActionLogsTab.
|
||||
|
||||
Widget smoke tests against the offscreen QPA platform (see
|
||||
test_monitoring_event_widgets.py for why no pytest-qt is needed). Verifies
|
||||
each tab wires build_filter_scaffold correctly, exposes the attributes the
|
||||
container's retranslate loop needs, and forwards set_events to its table.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.presentation.monitoring.tabs.action_logs_tab import ActionLogsTab
|
||||
from cowork_local.presentation.monitoring.tabs.mcp_tab import McpTab
|
||||
from cowork_local.presentation.monitoring.tabs.security_events_tab import SecurityEventsTab
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls,expected_title_key", [
|
||||
(SecurityEventsTab, "monitoring.security_events_title"),
|
||||
(McpTab, "monitoring.mcp_history_title"),
|
||||
(ActionLogsTab, "monitoring.action_logs_title"),
|
||||
])
|
||||
def test_tab_exposes_container_facing_attributes(qapp, cls, expected_title_key) -> None:
|
||||
refresh_calls = []
|
||||
tab = cls(ctx=None, on_refresh_all=lambda: refresh_calls.append(1))
|
||||
assert tab.title_key == expected_title_key
|
||||
assert tab.filter_edit is not None
|
||||
assert tab.detail_panel is not None
|
||||
|
||||
tab.title_refresh_btn.click()
|
||||
assert refresh_calls == [1]
|
||||
|
||||
|
||||
def test_set_events_forwards_to_table(qapp) -> None:
|
||||
tab = SecurityEventsTab(ctx=None, on_refresh_all=lambda: None)
|
||||
tab.set_events([{"ts": "2026-01-01T00:00:00", "kind": "security_block", "name": "x",
|
||||
"ok": False, "detail": "d", "account": "a", "machine": "m"}])
|
||||
assert tab.table.rowCount() == 1
|
||||
|
||||
|
||||
def test_retranslate_does_not_raise(qapp) -> None:
|
||||
tab = McpTab(ctx=None, on_refresh_all=lambda: None)
|
||||
tab.retranslate() # must not raise
|
||||
|
||||
|
||||
def test_ai_filter_noop_when_search_box_empty(qapp) -> None:
|
||||
tab = ActionLogsTab(ctx=None, on_refresh_all=lambda: None)
|
||||
tab.ai_filter_btn.click() # empty search text -> start_ai_filter no-ops, must not raise
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Task 1 (sub-step 6b) — EventTable / EventDetailPanel widget smoke tests.
|
||||
|
||||
These instantiate real PySide6 widgets against the offscreen QPA platform
|
||||
(no display needed, no pytest-qt dependency — a plain QApplication instance
|
||||
is enough to construct/query widgets, only an actual event loop would need
|
||||
more). Verifies the extraction into presentation/monitoring/shared/ wires up
|
||||
without error and preserves the pre-refactor row/column behaviour.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.presentation.monitoring.shared.event_detail_panel import EventDetailPanel
|
||||
from cowork_local.presentation.monitoring.shared.event_table import EventTable
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
def _sample_event(**overrides):
|
||||
ev = {"ts": "2026-05-25T15:03:00", "kind": "security_block", "name": "run_command",
|
||||
"ok": False, "detail": "blocked it", "account": "alice", "machine": "m1",
|
||||
"agent_role": "cowork"}
|
||||
ev.update(overrides)
|
||||
return ev
|
||||
|
||||
|
||||
def test_event_table_security_events_hides_result_column(qapp) -> None:
|
||||
table = EventTable(show_result=False)
|
||||
table.retranslate()
|
||||
assert table.columnCount() == 6
|
||||
|
||||
|
||||
def test_event_table_generic_shows_result_column(qapp) -> None:
|
||||
table = EventTable(show_result=True)
|
||||
table.retranslate()
|
||||
assert table.columnCount() == 7
|
||||
|
||||
|
||||
def test_set_events_populates_rows_newest_first(qapp) -> None:
|
||||
table = EventTable(show_result=True)
|
||||
table.set_events([
|
||||
_sample_event(ts="2026-05-25T10:00:00", name="first"),
|
||||
_sample_event(ts="2026-05-25T12:00:00", name="second"),
|
||||
])
|
||||
assert table.rowCount() == 2
|
||||
assert table.item(0, 4).text() == "second"
|
||||
assert table.item(1, 4).text() == "first"
|
||||
|
||||
|
||||
def test_event_at_row_round_trips_full_event(qapp) -> None:
|
||||
table = EventTable(show_result=False)
|
||||
ev = _sample_event()
|
||||
table.set_events([ev])
|
||||
assert table.event_at_row(0) == ev
|
||||
|
||||
|
||||
def test_apply_filter_hides_non_matching_rows(qapp) -> None:
|
||||
table = EventTable(show_result=True)
|
||||
table.set_events([_sample_event(name="run_command"), _sample_event(name="fetch_url")])
|
||||
table.apply_filter("fetch")
|
||||
hidden = [table.isRowHidden(r) for r in range(table.rowCount())]
|
||||
assert hidden.count(True) == 1
|
||||
|
||||
|
||||
def test_detail_panel_shows_event_without_error(qapp) -> None:
|
||||
panel = EventDetailPanel()
|
||||
panel.retranslate()
|
||||
panel.show_event(_sample_event(), 0)
|
||||
assert panel._detail_text == "blocked it"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Task 1 (sub-step 6f) — OverviewTab widget smoke test."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import time
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.tabs.overview_tab import OverviewTab
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, tmp_path):
|
||||
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
|
||||
self.started_at = time.time() - 30
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
def cowork_output_dir(self):
|
||||
return "."
|
||||
|
||||
|
||||
def _make_tab(tmp_path):
|
||||
return OverviewTab(
|
||||
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
|
||||
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: None,
|
||||
action_logs_tab_visible=True)
|
||||
|
||||
|
||||
def test_construction_and_retranslate(qapp, tmp_path) -> None:
|
||||
tab = _make_tab(tmp_path)
|
||||
tab.retranslate() # must not raise
|
||||
|
||||
|
||||
def test_refresh_with_no_events_shows_no_activity_text(qapp, tmp_path) -> None:
|
||||
tab = _make_tab(tmp_path)
|
||||
tab.retranslate()
|
||||
tab.refresh(events=[])
|
||||
assert tab.activity_lbl.text() != ""
|
||||
|
||||
|
||||
def test_refresh_with_events_renders_activity_lines(qapp, tmp_path) -> None:
|
||||
tab = _make_tab(tmp_path)
|
||||
tab.retranslate()
|
||||
tab.refresh(events=[
|
||||
{"ts": "2026-01-01T00:00:00", "kind": "tool_call", "name": "run_command", "ok": True},
|
||||
{"ts": "2026-01-01T00:00:05", "kind": "security_block", "name": "blocked", "ok": False},
|
||||
])
|
||||
assert "run_command" in tab.activity_lbl.text() or "blocked" in tab.activity_lbl.text()
|
||||
|
||||
|
||||
def test_view_all_button_invokes_callback(qapp, tmp_path) -> None:
|
||||
calls = []
|
||||
tab = OverviewTab(
|
||||
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
|
||||
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: calls.append(1),
|
||||
action_logs_tab_visible=True)
|
||||
tab.view_all_btn.click()
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
def test_audit_group_visibility_follows_constructor_flag(qapp, tmp_path) -> None:
|
||||
hidden_tab = OverviewTab(
|
||||
_FakeCtx(tmp_path), on_status_message=lambda _m: None,
|
||||
on_settings_changed=lambda: None, on_view_all_action_logs=lambda: None,
|
||||
action_logs_tab_visible=False)
|
||||
assert hidden_tab.audit_group.isHidden() is True
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Task 1 (sub-step 6f, follow-up) — PricingPanel, split out of overview_tab.py
|
||||
to keep that file under the 400-line quality rule."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.tabs.pricing_panel import PricingPanel
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, tmp_path):
|
||||
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def test_construction_and_retranslate(qapp, tmp_path) -> None:
|
||||
panel = PricingPanel(_FakeCtx(tmp_path), on_status_message=lambda _m: None)
|
||||
panel.retranslate()
|
||||
|
||||
|
||||
def test_add_and_delete_pricing_row_round_trip(qapp, tmp_path, monkeypatch) -> None:
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
ctx = _FakeCtx(tmp_path)
|
||||
panel = PricingPanel(ctx, on_status_message=lambda _m: None)
|
||||
monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("gpt-test", True)))
|
||||
panel._add_pricing_row()
|
||||
assert panel.table.rowCount() == 1
|
||||
assert panel.table.item(0, 0).text() == "gpt-test"
|
||||
|
||||
panel.table.selectRow(0)
|
||||
panel._delete_pricing_row()
|
||||
assert panel.table.rowCount() == 0
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Task 3 — MonitoringQueryService: read-only filter/sort/pagination over
|
||||
audit events, fully testable without file I/O (InMemoryAuditEventRepository)
|
||||
and with a real CanonicalAuditLogger wired through CanonicalAuditEventRepository.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.monitoring.dto.audit_event_dto import AuditEventDTO
|
||||
from cowork_local.application.monitoring.monitoring_query_service import (
|
||||
MonitoringQueryService,
|
||||
)
|
||||
from cowork_local.application.monitoring.repository.audit_event_repository import (
|
||||
CanonicalAuditEventRepository,
|
||||
InMemoryAuditEventRepository,
|
||||
)
|
||||
from cowork_local.infrastructure.telemetry.audit_logger import CanonicalAuditLogger
|
||||
|
||||
|
||||
def _event(ts, kind="tool_call", name="x", ok=True, detail="") -> AuditEventDTO:
|
||||
return AuditEventDTO(ts=ts, kind=kind, name=name, ok=ok, detail=detail)
|
||||
|
||||
|
||||
def test_query_filters_by_kind() -> None:
|
||||
repo = InMemoryAuditEventRepository([
|
||||
_event("2026-01-01T00:00:00", kind="mcp_call", name="a"),
|
||||
_event("2026-01-01T00:00:01", kind="security_block", name="b"),
|
||||
])
|
||||
service = MonitoringQueryService(repo)
|
||||
page = service.query(kind="mcp_call")
|
||||
assert [e.name for e in page.items] == ["a"]
|
||||
|
||||
|
||||
def test_query_filters_by_ok_and_text() -> None:
|
||||
repo = InMemoryAuditEventRepository([
|
||||
_event("2026-01-01T00:00:00", name="run_command", ok=False, detail="blocked"),
|
||||
_event("2026-01-01T00:00:01", name="run_command", ok=True, detail="fine"),
|
||||
_event("2026-01-01T00:00:02", name="fetch_url", ok=False, detail="blocked"),
|
||||
])
|
||||
service = MonitoringQueryService(repo)
|
||||
page = service.query(ok=False, text="run_command")
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].detail == "blocked"
|
||||
|
||||
|
||||
def test_query_sorts_newest_first_by_default() -> None:
|
||||
repo = InMemoryAuditEventRepository([
|
||||
_event("2026-01-01T00:00:00", name="first"),
|
||||
_event("2026-01-02T00:00:00", name="second"),
|
||||
])
|
||||
service = MonitoringQueryService(repo)
|
||||
page = service.query()
|
||||
assert [e.name for e in page.items] == ["second", "first"]
|
||||
|
||||
|
||||
def test_query_paginates() -> None:
|
||||
events = [_event(f"2026-01-{i:02d}T00:00:00", name=str(i)) for i in range(1, 11)]
|
||||
repo = InMemoryAuditEventRepository(events)
|
||||
service = MonitoringQueryService(repo)
|
||||
|
||||
page1 = service.query(sort_by="ts", descending=False, page=1, page_size=4)
|
||||
page2 = service.query(sort_by="ts", descending=False, page=2, page_size=4)
|
||||
|
||||
assert page1.total == 10
|
||||
assert [e.name for e in page1.items] == ["1", "2", "3", "4"]
|
||||
assert [e.name for e in page2.items] == ["5", "6", "7", "8"]
|
||||
assert page1.has_more is True
|
||||
|
||||
|
||||
def test_large_page_size_returns_everything_matching_current_ui_behaviour() -> None:
|
||||
events = [_event(f"2026-01-{i:02d}T00:00:00", name=str(i)) for i in range(1, 6)]
|
||||
repo = InMemoryAuditEventRepository(events)
|
||||
service = MonitoringQueryService(repo)
|
||||
page = service.query(page_size=10_000)
|
||||
assert len(page.items) == 5
|
||||
assert page.has_more is False
|
||||
|
||||
|
||||
def test_repository_is_read_only_no_pyside6_import() -> None:
|
||||
import cowork_local.application.monitoring.monitoring_query_service as mod
|
||||
import cowork_local.application.monitoring.repository.audit_event_repository as repo_mod
|
||||
assert "PySide6" not in mod.__dict__
|
||||
assert "PySide6" not in repo_mod.__dict__
|
||||
assert not hasattr(mod.MonitoringQueryService, "record")
|
||||
|
||||
|
||||
def test_canonical_repository_wires_to_real_logger(tmp_path) -> None:
|
||||
logger = CanonicalAuditLogger(tmp_path)
|
||||
logger.record("security_block", "run_command", False, detail="nope")
|
||||
logger.record("mcp_call", "search", True)
|
||||
|
||||
repo = CanonicalAuditEventRepository(logger)
|
||||
service = MonitoringQueryService(repo)
|
||||
|
||||
page = service.query(kind="security_block")
|
||||
assert len(page.items) == 1
|
||||
assert page.items[0].name == "run_command"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Task 1 (sub-step 6e) — SandboxDetailsCard / PermissionsCard.
|
||||
|
||||
Verifies the Permissions card is nested INSIDE the Sandbox card's fold
|
||||
(matching the pre-refactor layout exactly) and that refresh()/retranslate()
|
||||
compute the same values the original MonitoringTab methods did.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.tabs.sandbox_tab import SandboxDetailsCard
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
class _FakeCtx:
|
||||
def __init__(self, tmp_path, block_network=False):
|
||||
self.config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "c.json")
|
||||
self.config.data["agent_security"]["block_network"] = block_network
|
||||
self.config.data["agent_security"]["resource_limit_cpu_percent"] = 50
|
||||
import time
|
||||
self.started_at = time.time() - 65 # ~1m5s uptime
|
||||
|
||||
|
||||
def test_permissions_card_nested_inside_sandbox_fold(qapp, tmp_path) -> None:
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
|
||||
assert card.permissions_card.parent() is card._detail
|
||||
|
||||
|
||||
def test_refresh_computes_uptime_and_resource_limits(qapp, tmp_path) -> None:
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
|
||||
card.retranslate()
|
||||
card.refresh()
|
||||
assert "CPU 50%" in card.limits_lbl.text()
|
||||
assert "m" in card.uptime_val.text()
|
||||
|
||||
|
||||
def test_refresh_reflects_network_blocked_on_both_cards(qapp, tmp_path) -> None:
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path, block_network=True), on_settings_changed=lambda: None)
|
||||
card.retranslate()
|
||||
card.refresh()
|
||||
assert card.net_val.objectName() == "badgeWarn"
|
||||
assert card.permissions_card.network_val.objectName() == "badgeWarn"
|
||||
|
||||
|
||||
def test_refresh_reflects_network_allowed(qapp, tmp_path) -> None:
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path, block_network=False), on_settings_changed=lambda: None)
|
||||
card.retranslate()
|
||||
card.refresh()
|
||||
assert card.net_val.objectName() == "badgeSuccess"
|
||||
assert card.permissions_card.network_val.objectName() == "badgeSuccess"
|
||||
|
||||
|
||||
def test_fold_starts_collapsed(qapp, tmp_path) -> None:
|
||||
# isVisible() alone can't tell (it also depends on ancestors actually
|
||||
# being shown on screen, which nothing here is) — isHidden() reflects
|
||||
# the explicit setVisible(False) call regardless of the parent chain.
|
||||
card = SandboxDetailsCard(_FakeCtx(tmp_path), on_settings_changed=lambda: None)
|
||||
assert card._detail.isHidden() is True
|
||||
card.more_btn.setChecked(True)
|
||||
assert card._detail.isHidden() is False
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Task 1 (sub-step 6a) — presentation/monitoring/shared helpers.
|
||||
|
||||
Only the pure-Python functions are covered here (no PySide6 widget
|
||||
instantiation needed, no pytest-qt required). Values are asserted against
|
||||
what ``ui/monitoring_tab.py``'s original, now-removed private functions
|
||||
produced, so this doubles as a characterization test proving the extraction
|
||||
didn't change output.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.presentation.monitoring.shared import badges, formatters
|
||||
|
||||
|
||||
def test_fmt_bytes() -> None:
|
||||
assert formatters.fmt_bytes(500) == "500 B"
|
||||
assert formatters.fmt_bytes(2048) == "2 KB"
|
||||
|
||||
|
||||
def test_fmt_event_time_and_full_and_relative_are_unparsable_safe() -> None:
|
||||
assert formatters.fmt_event_time("not-a-timestamp") == "not-a-timestamp"
|
||||
assert formatters.fmt_event_time_full("not-a-timestamp") == "not-a-timestamp"
|
||||
assert formatters.relative_time("not-a-timestamp") == ""
|
||||
|
||||
|
||||
def test_fmt_event_time_formats_valid_iso_timestamp() -> None:
|
||||
assert formatters.fmt_event_time("2026-05-25T15:03:00") == "25/05 15:03"
|
||||
# The separator is computed via datetime.strftime with a literal
|
||||
# non-ASCII character in the format string, same as the pre-refactor
|
||||
# ui/monitoring_tab.py code — on a Windows box whose locale ANSI codepage
|
||||
# has no direct mapping for U+00B7 (MIDDLE DOT), strftime's encode/decode
|
||||
# round trip through that codepage can substitute a different but
|
||||
# visually similar character (observed: U+30FB on a ja_JP/cp932 locale).
|
||||
# That behavior is unchanged by this refactor either way, so the test
|
||||
# computes the expected separator the exact same way the implementation
|
||||
# does, rather than assuming byte 0xB7 survives on every locale.
|
||||
import datetime as _dt
|
||||
expected_full = _dt.datetime(2026, 5, 25, 15, 3, 7).strftime(
|
||||
f"%d/%m/%Y {formatters._MIDDLE_DOT} %H:%M:%S")
|
||||
assert formatters.fmt_event_time_full("2026-05-25T15:03:07") == expected_full
|
||||
|
||||
|
||||
def test_event_id_shape() -> None:
|
||||
assert formatters.event_id("2026-05-25T15:03:00", 7) == "evt_202605251503_007"
|
||||
|
||||
|
||||
def test_agent_initials() -> None:
|
||||
assert formatters.agent_initials("Cowork Agent") == "CA"
|
||||
assert formatters.agent_initials("graphrag") == "G"
|
||||
|
||||
|
||||
def test_agent_avatar_colour_identity_mapping() -> None:
|
||||
assert formatters.agent_avatar_colour("Security Agent") == "#D13438"
|
||||
assert formatters.agent_avatar_colour("Cowork Agent") == "#0078D4"
|
||||
assert formatters.agent_avatar_colour("unknown-agent") == "#0078D4"
|
||||
|
||||
|
||||
def test_action_label_falls_back_to_raw_name_when_unmapped() -> None:
|
||||
assert badges.action_label("some_custom_tool") == "some_custom_tool"
|
||||
|
||||
|
||||
def test_status_info_security_block_unmapped_defaults_to_blocked() -> None:
|
||||
tone, key = badges.status_info("some_new_rule", kind="security_block", ok=False)
|
||||
assert (tone, key) == ("badgePurple", "monitoring.status_blocked")
|
||||
|
||||
|
||||
def test_status_info_mcp_call_falls_back_to_ok_flag() -> None:
|
||||
assert badges.status_info("search", kind="mcp_call", ok=True) == ("badgeSuccess", "monitoring.status_ok")
|
||||
assert badges.status_info("search", kind="mcp_call", ok=False) == ("badgeDanger", "monitoring.status_failed")
|
||||
|
||||
|
||||
def test_severity_info_dangerous_command_is_critical() -> None:
|
||||
assert badges.severity_info("run_command") == ("badgeDanger", "monitoring.severity_critical")
|
||||
|
||||
|
||||
def test_agent_badge_name_identity_mapping() -> None:
|
||||
assert badges.agent_badge_name("Security Agent") == "badgeDanger"
|
||||
assert badges.agent_badge_name("graphrag") == "badgePurple"
|
||||
assert badges.agent_badge_name("unknown") == "badge"
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Task 1 (sub-step 6g) — the new presentation/monitoring/monitoring_tab.py
|
||||
container. Builds a real MonitoringTab against a real AppConfig/AppContext
|
||||
(same convention as tests/routing/test_service.py: real config/context, only
|
||||
the true external collaborators — here, none — get a fake), and verifies the
|
||||
public API app.py depends on is intact: constructor signature,
|
||||
``status_message`` signal, ``select_subtab``, ``nav_subtabs``,
|
||||
``hide_tab_bar``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
|
||||
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||
|
||||
from cowork_local.config import AppConfig, DEFAULT_CONFIG
|
||||
from cowork_local.presentation.monitoring.monitoring_tab import MonitoringTab
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ctx(tmp_path):
|
||||
config = AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
|
||||
return AppContext(config)
|
||||
|
||||
|
||||
def test_constructor_accepts_apps_py_call_signature(qapp, ctx) -> None:
|
||||
# app.py:499-502 calls MonitoringTab(self.ctx, cowork=..., structure=...,
|
||||
# task_scheduler=...) — all optional besides ctx.
|
||||
tab = MonitoringTab(ctx)
|
||||
assert tab.tabs.count() >= 1
|
||||
|
||||
|
||||
def test_status_message_signal_exists(qapp, ctx) -> None:
|
||||
tab = MonitoringTab(ctx)
|
||||
received = []
|
||||
tab.status_message.connect(received.append)
|
||||
tab.status_message.emit("hello")
|
||||
assert received == ["hello"]
|
||||
|
||||
|
||||
def test_select_subtab_and_nav_subtabs(qapp, ctx) -> None:
|
||||
tab = MonitoringTab(ctx)
|
||||
subtabs = tab.nav_subtabs()
|
||||
assert len(subtabs) == tab.tabs.count()
|
||||
tab.select_subtab(1)
|
||||
assert tab.tabs.currentIndex() == 1
|
||||
|
||||
|
||||
def test_hide_tab_bar_does_not_raise(qapp, ctx) -> None:
|
||||
tab = MonitoringTab(ctx)
|
||||
tab.hide_tab_bar()
|
||||
|
||||
|
||||
def test_full_refresh_populates_event_tabs_via_query_service(qapp, ctx, monkeypatch) -> None:
|
||||
from cowork_local.core import audit_log
|
||||
|
||||
audit_dir = ctx.config.path.parent / "audit"
|
||||
monkeypatch.setattr(audit_log, "AUDIT_DIR", audit_dir)
|
||||
monkeypatch.setattr(audit_log, "_logger", audit_log.CanonicalAuditLogger(audit_dir))
|
||||
audit_log.record("security_block", "run_command", False, detail="blocked")
|
||||
audit_log.record("mcp_call", "search", True)
|
||||
audit_log.record("tool_call", "read_file", True)
|
||||
|
||||
tab = MonitoringTab(ctx, cowork=None, structure=None, task_scheduler=None)
|
||||
tab.refresh()
|
||||
|
||||
assert tab.security_tab.table.rowCount() == 1
|
||||
assert tab.mcp_tab.table.rowCount() == 1
|
||||
assert tab.action_tab.table.rowCount() == 3
|
||||
|
||||
|
||||
def test_retranslate_does_not_raise(qapp, ctx) -> None:
|
||||
tab = MonitoringTab(ctx)
|
||||
tab._retranslate()
|
||||
|
||||
|
||||
def test_settings_changed_callback_is_the_container_full_refresh(qapp, ctx) -> None:
|
||||
# Editing Settings from either the Sandbox or the nested Permissions
|
||||
# card's "Edit" button used to call the same
|
||||
# MonitoringTab._open_settings_and_refresh -> self.refresh(); the
|
||||
# container now wires both cards' on_settings_changed to its own
|
||||
# bound refresh method, so this equality is the direct replacement
|
||||
# for that identity.
|
||||
tab = MonitoringTab(ctx)
|
||||
assert tab.overview_tab.sandbox_card._on_settings_changed == tab.refresh
|
||||
assert tab.overview_tab.sandbox_card.permissions_card._on_settings_changed == tab.refresh
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Task 5 — Sandbox Capability Matrix: pure OS/risk-tier policy, no execution,
|
||||
no PySide6, no dependency on core/sandbox_manager.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.infrastructure.sandbox import sandbox_capabilities as sc
|
||||
|
||||
|
||||
def test_detect_os_from_injected_platform_name() -> None:
|
||||
assert sc.detect_os("win32") == sc.WINDOWS
|
||||
assert sc.detect_os("linux") == sc.LINUX
|
||||
assert sc.detect_os("darwin") == sc.MACOS
|
||||
assert sc.detect_os("some-other-os") == sc.UNKNOWN
|
||||
|
||||
|
||||
def test_windows_matches_todays_real_backends() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.WINDOWS)
|
||||
names = {b.name for b in matrix.available_backends()}
|
||||
assert names == {"direct", "integrity_job_wfp", "appcontainer", "windows_sandbox"}
|
||||
|
||||
|
||||
def test_windows_routing_matches_core_sandbox_manager_today() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.WINDOWS)
|
||||
assert matrix.select_backend(sc.SAFE) == "integrity_job_wfp"
|
||||
assert matrix.select_backend(sc.MODERATE) == "integrity_job_wfp"
|
||||
assert matrix.select_backend(sc.HIGH) == "appcontainer"
|
||||
assert matrix.select_backend(sc.CRITICAL) == "windows_sandbox"
|
||||
|
||||
|
||||
def test_linux_has_no_real_backend_yet() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.LINUX)
|
||||
available = {b.name for b in matrix.available_backends()}
|
||||
assert available == {"direct"} # namespaces_bubblewrap declared but not implemented
|
||||
assert matrix.select_backend(sc.SAFE) == "direct" # SAFE/MODERATE only ever wanted direct
|
||||
# HIGH's preferred backend (namespaces_bubblewrap) isn't implemented, and
|
||||
# HIGH's routing table names "blocked" as the explicit next preference
|
||||
# (not a silent fallback to unisolated "direct") — a HIGH-risk command
|
||||
# must never quietly downgrade to no isolation just because the real
|
||||
# sandbox backend is missing on this OS.
|
||||
assert matrix.select_backend(sc.HIGH) == "blocked"
|
||||
assert matrix.select_backend(sc.CRITICAL) == "blocked"
|
||||
|
||||
|
||||
def test_macos_has_no_real_backend_yet() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.MACOS)
|
||||
available = {b.name for b in matrix.available_backends()}
|
||||
assert available == {"direct"}
|
||||
|
||||
|
||||
def test_disallowing_direct_fallback_blocks_instead() -> None:
|
||||
# LINUX's own HIGH routing already names "blocked" explicitly, so it
|
||||
# doesn't exercise the allow_direct_fallback branch. Register a profile
|
||||
# whose HIGH tier names only an unavailable backend (no explicit
|
||||
# "direct"/"blocked" entry) to exercise the bottom-of-select_backend
|
||||
# fallback path directly.
|
||||
os_name = "test-os-fallback"
|
||||
profile = sc.OsSandboxProfile(
|
||||
operating_system=os_name,
|
||||
backends=(sc.SandboxBackend("direct", "none", True),),
|
||||
routing={sc.SAFE: ("direct",), sc.MODERATE: ("direct",),
|
||||
sc.HIGH: ("not_yet_implemented",), sc.CRITICAL: ("not_yet_implemented",)},
|
||||
)
|
||||
sc.register_profile(profile)
|
||||
try:
|
||||
allowed = sc.SandboxCapabilityMatrix(operating_system=os_name, allow_direct_fallback=True)
|
||||
disallowed = sc.SandboxCapabilityMatrix(operating_system=os_name, allow_direct_fallback=False)
|
||||
assert allowed.select_backend(sc.HIGH) == "direct"
|
||||
assert disallowed.select_backend(sc.HIGH) == "blocked"
|
||||
# CRITICAL never falls back to direct even when allowed.
|
||||
assert allowed.select_backend(sc.CRITICAL) == "blocked"
|
||||
finally:
|
||||
del sc._PROFILES[os_name]
|
||||
|
||||
|
||||
def test_unknown_os_always_blocks() -> None:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=sc.UNKNOWN)
|
||||
assert matrix.available_backends() == ()
|
||||
for tier in (sc.SAFE, sc.MODERATE, sc.HIGH, sc.CRITICAL):
|
||||
assert matrix.select_backend(tier) == "blocked"
|
||||
|
||||
|
||||
def test_registering_a_brand_new_os_requires_no_class_changes() -> None:
|
||||
freebsd = "freebsd"
|
||||
profile = sc.OsSandboxProfile(
|
||||
operating_system=freebsd,
|
||||
backends=(sc.SandboxBackend("direct", "none", True),),
|
||||
routing={sc.SAFE: ("direct",), sc.MODERATE: ("direct",),
|
||||
sc.HIGH: ("blocked",), sc.CRITICAL: ("blocked",)},
|
||||
)
|
||||
sc.register_profile(profile)
|
||||
try:
|
||||
matrix = sc.SandboxCapabilityMatrix(operating_system=freebsd)
|
||||
assert matrix.select_backend(sc.SAFE) == "direct"
|
||||
assert matrix.select_backend(sc.HIGH) == "blocked"
|
||||
finally:
|
||||
del sc._PROFILES[freebsd] # don't leak state into other tests
|
||||
|
||||
|
||||
def test_no_pyside6_or_subprocess_dependency() -> None:
|
||||
assert "PySide6" not in sc.__dict__
|
||||
assert "subprocess" not in sc.__dict__
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Dựng Qt ở chế độ offscreen cho test giao diện.
|
||||
|
||||
Offscreen là bắt buộc, không phải cho nhanh: máy dev là máy làm việc thật của
|
||||
người dùng. Test bật cửa sổ lên là nó nhảy ra trước mặt, che thứ đang mở.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qapp():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Đặc tả hành vi SettingsDialog TRƯỚC khi tách — R08-T07.
|
||||
|
||||
Không phải test tính năng mới. Đây là lưới an toàn: chốt lại dialog hiện
|
||||
đang làm gì, để khi bóc 727 dòng thành các widget con còn biết mình có làm
|
||||
lệch đi chỗ nào không. Bài nào ở đây đỏ sau khi tách nghĩa là tách sai.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _Config:
|
||||
"""Đủ dùng cho SettingsDialog, không hơn — xem danh sách ctx.* nó chạm."""
|
||||
|
||||
def __init__(self):
|
||||
self.data = {
|
||||
"active_provider": "openai_compat",
|
||||
"language": "vi",
|
||||
"theme": "dark",
|
||||
"providers": {
|
||||
"openai_compat": {"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "khoa-cu", "model": "gpt-4o"},
|
||||
"ollama": {"base_url": "http://localhost:11434",
|
||||
"api_key": "ollama", "model": "qwen2.5-coder"},
|
||||
},
|
||||
"tray": {"minimize_on_close": True, "notify_on_done": False},
|
||||
"agent_security": {
|
||||
"enabled": True, "cowork_confirm_commands": False,
|
||||
"block_network": True, "command_ai_check": False,
|
||||
"resource_limit_cpu_percent": 55,
|
||||
"resource_limit_memory_mb": 1024,
|
||||
"resource_limit_disk_mb": 2048,
|
||||
},
|
||||
"attachments": {"max_tokens": 32000, "max_files": 7},
|
||||
"structure": {"max_nodes": 300, "max_edges": 600},
|
||||
"routing": {"switch_mode": "auto", "policy": "cost",
|
||||
"min_score_gain": 0.05, "confirm_timeout_sec": 90,
|
||||
"reassess_interval_hours": 12,
|
||||
"per_provider_concurrency": 3,
|
||||
"judge_model": "gpt-4o-mini"},
|
||||
}
|
||||
self._data = self.data
|
||||
self._agent_security = self.data["agent_security"]
|
||||
|
||||
language = property(lambda self: self.data["language"])
|
||||
theme = property(lambda self: self.data["theme"])
|
||||
active_provider = property(lambda self: self.data["active_provider"])
|
||||
agent_security = property(lambda self: self.data["agent_security"])
|
||||
routing = property(lambda self: self.data["routing"])
|
||||
|
||||
|
||||
class _Ctx:
|
||||
def __init__(self):
|
||||
self.config = _Config()
|
||||
self.routing = None
|
||||
self.saves = 0
|
||||
|
||||
def save(self):
|
||||
self.saves += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dialog(qapp):
|
||||
from cowork_local.ui.settings_dialog import SettingsDialog
|
||||
ctx = _Ctx()
|
||||
dlg = SettingsDialog(ctx)
|
||||
yield dlg, ctx
|
||||
dlg.deleteLater()
|
||||
|
||||
|
||||
# ---- dialog gồm những mục nào -------------------------------------------
|
||||
|
||||
def test_co_dung_nam_muc(dialog):
|
||||
"""Năm mục thật trên màn hình. Plan R08-T07 ghi bốn widget và có một cái
|
||||
tên `connector`, nhưng UI connector đã dời khỏi Settings từ trước (xem
|
||||
ghi chú ở settings_dialog.py:180) — nên con số thật là năm, không bốn."""
|
||||
dlg, _ = dialog
|
||||
labels = [dlg.section_list.item(i).text()
|
||||
for i in range(dlg.section_list.count())]
|
||||
assert len(labels) == 5, labels
|
||||
assert dlg.section_stack.count() == 5
|
||||
|
||||
|
||||
def test_moi_muc_deu_bam_duoc(dialog):
|
||||
dlg, _ = dialog
|
||||
for i in range(dlg.section_list.count()):
|
||||
dlg.section_list.setCurrentRow(i)
|
||||
assert dlg.section_stack.currentIndex() == i
|
||||
|
||||
|
||||
# ---- nạp giá trị từ config ----------------------------------------------
|
||||
|
||||
def test_nap_dung_gia_tri_dang_co(dialog):
|
||||
dlg, ctx = dialog
|
||||
assert dlg.language_combo.currentData() == "vi"
|
||||
assert dlg.theme_combo.currentData() == "dark"
|
||||
assert dlg.provider_combo.currentData() == "openai_compat"
|
||||
assert dlg.prov_base.text() == "https://api.openai.com/v1"
|
||||
assert dlg.tray_chk.isChecked() is True
|
||||
assert dlg.notify_chk.isChecked() is False
|
||||
assert dlg.routing_mode.currentData() == "auto"
|
||||
assert dlg.routing_policy.currentData() == "cost"
|
||||
assert dlg.routing_timeout.value() == 90
|
||||
assert dlg.attach_files.value() == 7
|
||||
assert dlg.struct_nodes.value() == 300
|
||||
assert dlg.sandbox_cpu.value() == 55
|
||||
|
||||
|
||||
def test_khoa_api_khong_hien_ro(dialog):
|
||||
"""QLineEdit.Password — khoá không được đọc được bằng mắt qua vai."""
|
||||
from PySide6.QtWidgets import QLineEdit
|
||||
dlg, _ = dialog
|
||||
assert dlg.prov_key.echoMode() == QLineEdit.Password
|
||||
|
||||
|
||||
# ---- lưu ghi ra đúng chỗ -------------------------------------------------
|
||||
|
||||
def test_luu_ghi_dung_moi_o(dialog):
|
||||
dlg, ctx = dialog
|
||||
dlg.language_combo.setCurrentIndex(
|
||||
dlg.language_combo.findData("en") if dlg.language_combo.findData("en") >= 0 else 0)
|
||||
dlg.tray_chk.setChecked(False)
|
||||
dlg.routing_timeout.setValue(120)
|
||||
dlg.attach_files.setValue(3)
|
||||
dlg.sandbox_cpu.setValue(80)
|
||||
|
||||
dlg._save()
|
||||
d = ctx.config.data
|
||||
|
||||
assert d["tray"]["minimize_on_close"] is False
|
||||
assert d["routing"]["confirm_timeout_sec"] == 120
|
||||
assert d["attachments"]["max_files"] == 3
|
||||
assert d["agent_security"]["resource_limit_cpu_percent"] == 80
|
||||
assert ctx.saves == 1
|
||||
|
||||
|
||||
def test_luu_doi_min_gain_tu_phan_tram_sang_phan_le(dialog):
|
||||
"""Ô nhập là %, config lưu số thập phân. Đây là chỗ dễ tách sai nhất."""
|
||||
dlg, ctx = dialog
|
||||
dlg.routing_min_gain.setValue(25)
|
||||
dlg._save()
|
||||
assert ctx.config.data["routing"]["min_score_gain"] == 0.25
|
||||
|
||||
|
||||
def test_luu_xoa_cache_de_app_doc_lai_ngay(dialog):
|
||||
dlg, ctx = dialog
|
||||
dlg._save()
|
||||
assert ctx.config._data is None
|
||||
assert ctx.config._agent_security is None
|
||||
@@ -34,332 +34,25 @@ original value, so the deviation is auditable rather than silent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .theme_palettes import ( # noqa: F401 — giữ đường vào cũ
|
||||
DARK, LIGHT, Palette, _chevron_asset, _FONT, _MONO, _PALETTES,
|
||||
)
|
||||
from .theme_qss import _TEMPLATE
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from string import Template
|
||||
|
||||
|
||||
def _chevron_asset(direction: str, color: str) -> str:
|
||||
"""Render (and cache on disk) a small chevron PNG for QComboBox/QSpinBox
|
||||
arrow subcontrols. QSS's ``image:`` property only accepts a resource or
|
||||
file path, never a live QPixmap — and once ``::drop-down``/``::up-button``/
|
||||
``::down-button`` are styled at all, Qt stops drawing its own built-in
|
||||
arrow, so without this the controls show no affordance whatsoever."""
|
||||
import hashlib
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
key = hashlib.md5(f"{direction}-{color}".encode()).hexdigest()[:10]
|
||||
path = Path(tempfile.gettempdir()) / "cowork_local_theme" / f"chevron_{key}.png"
|
||||
if not path.exists():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
from PySide6.QtCore import QPointF, Qt
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
|
||||
size = 12
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
pen = p.pen()
|
||||
pen.setColor(QColor(color))
|
||||
pen.setWidthF(1.6)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
pen.setJoinStyle(Qt.RoundJoin)
|
||||
p.setPen(pen)
|
||||
pts = ([QPointF(2.5, 4.5), QPointF(6, 8), QPointF(9.5, 4.5)] if direction == "down"
|
||||
else [QPointF(2.5, 7.5), QPointF(6, 4), QPointF(9.5, 7.5)])
|
||||
p.drawPolyline(pts)
|
||||
p.end()
|
||||
pm.save(str(path))
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Palette:
|
||||
"""Every colour and shape value the interface is allowed to use."""
|
||||
|
||||
name: str
|
||||
|
||||
# --- surfaces: a 4-step ramp from the window back to the frontmost layer.
|
||||
bg: str # window / canvas backdrop
|
||||
surface: str # panels, cards, group boxes (NOT the nav rail)
|
||||
surface_raised: str # inputs, lists, trees — things you type or pick in
|
||||
overlay: str # menus, tooltips, popups (floats above everything)
|
||||
sunken: str # logs, code, terminals — things you read into
|
||||
hover: str # hover wash on rows, tabs, ghost buttons
|
||||
active: str # pressed / held state
|
||||
|
||||
# The nav rail gets its own step rather than borrowing `surface`. It is a
|
||||
# permanent region of the window, not a card floating on the page.
|
||||
#
|
||||
# Following VS Code, the rail is *darker* than the content area (dark) or a
|
||||
# shade off white (light). The step is small on purpose — VS Code separates
|
||||
# the rail with a border, not a big tonal jump — so `nav_border` is doing
|
||||
# real work here and must stay visible.
|
||||
nav_bg: str
|
||||
nav_border: str
|
||||
nav_hover: str
|
||||
nav_selected: str
|
||||
|
||||
# --- lines
|
||||
border: str # default hairline
|
||||
border_strong: str # hairline that must survive next to a filled surface
|
||||
focus_ring: str # keyboard/typing focus
|
||||
|
||||
# --- text
|
||||
text: str
|
||||
text_muted: str # secondary copy, captions, group-box titles
|
||||
text_faint: str # metadata, timestamps, placeholder
|
||||
text_disabled: str
|
||||
on_accent: str # text drawn on top of a filled accent/status surface
|
||||
|
||||
# --- accent: `accent` tints text & icons, `accent_solid` fills buttons.
|
||||
accent: str
|
||||
accent_solid: str
|
||||
accent_solid_hover: str
|
||||
accent_solid_active: str
|
||||
accent_soft: str # translucent wash for selected rows (QSS only)
|
||||
accent_soft_hover: str
|
||||
accent_wash: str # the same tint pre-blended to a solid, for Qt rich
|
||||
# text (bgcolor=, <table>) where alpha is ignored
|
||||
|
||||
# --- status
|
||||
success: str
|
||||
success_soft: str
|
||||
warning: str
|
||||
warning_soft: str
|
||||
danger: str
|
||||
danger_solid: str
|
||||
danger_solid_hover: str
|
||||
danger_soft: str
|
||||
info: str
|
||||
info_soft: str
|
||||
purple: str
|
||||
purple_soft: str
|
||||
pink: str
|
||||
pink_soft: str
|
||||
|
||||
# --- selection (text selection inside editors and inputs)
|
||||
selection_bg: str
|
||||
selection_fg: str
|
||||
|
||||
# --- scrollbars
|
||||
scroll_handle: str
|
||||
scroll_handle_hover: str
|
||||
|
||||
# --- code & terminal
|
||||
code_bg: str
|
||||
code_fg: str
|
||||
code_gutter_bg: str
|
||||
code_gutter_fg: str
|
||||
code_selection: str
|
||||
code_comment: str
|
||||
code_keyword: str
|
||||
code_type: str
|
||||
code_func: str
|
||||
code_attr: str
|
||||
code_string: str
|
||||
code_number: str
|
||||
code_error: str
|
||||
|
||||
# --- diff / inline change badges
|
||||
diff_add_bg: str
|
||||
diff_add_fg: str
|
||||
diff_del_bg: str
|
||||
diff_del_fg: str
|
||||
|
||||
# --- charts
|
||||
chart_grid: str
|
||||
chart_label: str
|
||||
|
||||
# --- conversation & graph node roles
|
||||
role_user: str
|
||||
role_assistant: str
|
||||
role_tool: str
|
||||
role_result: str
|
||||
role_error: str
|
||||
|
||||
# --- shape & type
|
||||
radius_sm: int
|
||||
radius: int
|
||||
radius_lg: int
|
||||
font_family: str
|
||||
font_size: int
|
||||
font_mono: str
|
||||
|
||||
|
||||
_FONT = '"Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Yu Gothic UI", "Meiryo", sans-serif'
|
||||
_MONO = '"Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", monospace'
|
||||
|
||||
|
||||
DARK = Palette(
|
||||
name="dark",
|
||||
# ---- VS Code "Dark Modern" ----------------------------------------------
|
||||
# Values taken from the shipped theme JSON. Where VS Code's own choice falls
|
||||
# below WCAG AA it is nudged just far enough to pass; each such value carries
|
||||
# a note with VS Code's original and the measured ratio.
|
||||
bg="#1F1F1F", # editor.background
|
||||
surface="#252526", # panel / card
|
||||
surface_raised="#313131", # input.background
|
||||
overlay="#252526", # menus, tooltips
|
||||
sunken="#181818", # logs, terminals — below the ramp
|
||||
hover="#2A2D2E", # list.hoverBackground
|
||||
active="#37373D", # list.inactiveSelectionBackground
|
||||
# The sidebar is DARKER than the editor — that is the VS Code silhouette.
|
||||
nav_bg="#181818", # sideBar.background
|
||||
nav_border="#2B2B2B", # sideBar.border
|
||||
nav_hover="#2A2D2E",
|
||||
nav_selected="#04395E", # list.activeSelectionBackground
|
||||
border="#2B2B2B", # panel.border
|
||||
border_strong="#3C3C3C", # input.border
|
||||
focus_ring="#0078D4", # focusBorder
|
||||
text="#CCCCCC", # editor.foreground
|
||||
text_muted="#9D9D9D", # descriptionForeground
|
||||
text_faint="#9A9A9A", # lifted: #8B8B8B was 3.82:1 on input surfaces
|
||||
text_disabled="#5A5A5A",
|
||||
on_accent="#FFFFFF",
|
||||
accent="#4DAAFC", # textLink.foreground — accent as TEXT
|
||||
accent_solid="#0078D4", # button.background — accent as FILL
|
||||
accent_solid_hover="#026EC1",
|
||||
accent_solid_active="#005FB8",
|
||||
accent_soft="rgba(0,120,212,0.22)",
|
||||
accent_soft_hover="rgba(0,120,212,0.32)",
|
||||
accent_wash="#12283C", # pre-blended: Qt rich text ignores alpha
|
||||
success="#89D185", # gitDecoration added
|
||||
success_soft="rgba(137,209,133,0.16)",
|
||||
warning="#CCA700", # editorWarning
|
||||
warning_soft="rgba(204,167,0,0.16)",
|
||||
danger="#F76464", # editorError #F14C4C lifted (4.29:1 on panels)
|
||||
danger_solid="#C4302B",
|
||||
danger_solid_hover="#D9433C",
|
||||
danger_soft="rgba(241,76,76,0.16)",
|
||||
info="#4DAAFC",
|
||||
info_soft="rgba(77,170,252,0.16)",
|
||||
purple="#C586C0", # Dark+ syntax purple
|
||||
purple_soft="rgba(197,134,192,0.16)",
|
||||
pink="#D16D9E",
|
||||
pink_soft="rgba(209,109,158,0.16)",
|
||||
selection_bg="#264F78", # editor.selectionBackground
|
||||
selection_fg="#FFFFFF",
|
||||
scroll_handle="#4E4E4E", # scrollbarSlider
|
||||
scroll_handle_hover="#5A5A5A",
|
||||
code_bg="#1F1F1F",
|
||||
code_fg="#CCCCCC",
|
||||
code_gutter_bg="#1F1F1F",
|
||||
# VS Code uses #6E7681 for line numbers — only 3.59:1. Lifted to clear AA.
|
||||
code_gutter_fg="#858D97",
|
||||
code_selection="#264F78",
|
||||
code_comment="#6A9955", # ---- Dark+ syntax, unchanged --------------
|
||||
code_keyword="#569CD6",
|
||||
code_type="#4EC9B0",
|
||||
code_func="#DCDCAA",
|
||||
code_attr="#9CDCFE",
|
||||
code_string="#CE9178",
|
||||
code_number="#B5CEA8",
|
||||
code_error="#F44747",
|
||||
diff_add_bg="#1B3A1B", # diffEditor inserted, pre-blended
|
||||
diff_add_fg="#89D185",
|
||||
diff_del_bg="#4B1818", # diffEditor removed, pre-blended
|
||||
diff_del_fg="#F76464",
|
||||
chart_grid="#2B2B2B",
|
||||
chart_label="#9D9D9D",
|
||||
role_user="#4DAAFC",
|
||||
role_assistant="#4EC9B0",
|
||||
role_tool="#C586C0",
|
||||
role_result="#89D185",
|
||||
role_error="#F14C4C",
|
||||
radius_sm=3, # VS Code is squarer than the previous look
|
||||
radius=4,
|
||||
radius_lg=6,
|
||||
font_family=_FONT,
|
||||
font_size=13,
|
||||
font_mono=_MONO,
|
||||
)
|
||||
|
||||
|
||||
LIGHT = Palette(
|
||||
name="light",
|
||||
# ---- VS Code "Light Modern" ---------------------------------------------
|
||||
bg="#FFFFFF", # editor.background
|
||||
surface="#F8F8F8", # sideBar / panel
|
||||
surface_raised="#FFFFFF", # input.background
|
||||
overlay="#FFFFFF",
|
||||
sunken="#F3F3F3",
|
||||
hover="#F2F2F2", # list.hoverBackground
|
||||
active="#E8E8E8", # list.activeSelectionBackground
|
||||
nav_bg="#F8F8F8", # sideBar.background
|
||||
nav_border="#E5E5E5", # sideBar.border
|
||||
nav_hover="#F2F2F2",
|
||||
nav_selected="#E4E6F1", # active row, tinted toward the accent
|
||||
border="#E5E5E5",
|
||||
border_strong="#CECECE", # input.border
|
||||
focus_ring="#005FB8", # focusBorder
|
||||
text="#3B3B3B", # editor.foreground
|
||||
text_muted="#616161",
|
||||
# VS Code uses #767676 — 4.28:1 on the sidebar. Darkened to clear AA there.
|
||||
text_faint="#6E6E6E",
|
||||
text_disabled="#A0A0A0",
|
||||
on_accent="#FFFFFF",
|
||||
accent="#005FB8", # textLink / button
|
||||
accent_solid="#005FB8",
|
||||
accent_solid_hover="#0258A8",
|
||||
accent_solid_active="#004C97",
|
||||
accent_soft="rgba(0,95,184,0.10)",
|
||||
accent_soft_hover="rgba(0,95,184,0.16)",
|
||||
accent_wash="#E6EEF8",
|
||||
# VS Code green #388A34 is 4.33:1 and amber #BF8803 only 3.12:1 — both lifted.
|
||||
success="#317A2D",
|
||||
success_soft="#DFF3DE",
|
||||
warning="#8F6500", # VS Code #BF8803 = 3.12:1
|
||||
warning_soft="#FBF0D0",
|
||||
danger="#CD3131", # editorError
|
||||
danger_solid="#CD3131",
|
||||
danger_solid_hover="#B82A2A",
|
||||
danger_soft="#FDEFEF", # lightened so #CD3131 clears AA on it
|
||||
info="#005FB8",
|
||||
info_soft="#DDEBF9",
|
||||
purple="#6F42C1",
|
||||
purple_soft="#EDE7FA",
|
||||
pink="#B3247E",
|
||||
pink_soft="#FAE3F0",
|
||||
selection_bg="#ADD6FF", # editor.selectionBackground
|
||||
selection_fg="#000000",
|
||||
scroll_handle="#C1C1C1",
|
||||
scroll_handle_hover="#A6A6A6",
|
||||
code_bg="#FFFFFF",
|
||||
code_fg="#3B3B3B",
|
||||
code_gutter_bg="#F8F8F8",
|
||||
code_gutter_fg="#656C76", # VS Code #6E7681 = 4.33:1 on the gutter
|
||||
code_selection="#ADD6FF",
|
||||
code_comment="#008000", # ---- Light+ syntax ------------------------
|
||||
code_keyword="#0000FF",
|
||||
code_type="#267F99",
|
||||
code_func="#795E26",
|
||||
code_attr="#E50000",
|
||||
code_string="#A31515",
|
||||
code_number="#098658",
|
||||
code_error="#CD3131",
|
||||
diff_add_bg="#DBF4DB",
|
||||
diff_add_fg="#1E6F1A",
|
||||
diff_del_bg="#FBE3E3",
|
||||
diff_del_fg="#B82A2A",
|
||||
chart_grid="#E5E5E5",
|
||||
chart_label="#616161",
|
||||
role_user="#005FB8",
|
||||
role_assistant="#267F99",
|
||||
role_tool="#6F42C1",
|
||||
role_result="#317A2D",
|
||||
role_error="#CD3131",
|
||||
radius_sm=3,
|
||||
radius=4,
|
||||
radius_lg=6,
|
||||
font_family=_FONT,
|
||||
font_size=13,
|
||||
font_mono=_MONO,
|
||||
)
|
||||
|
||||
|
||||
_PALETTES = {"dark": DARK, "light": LIGHT}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -368,476 +61,6 @@ _PALETTES = {"dark": DARK, "light": LIGHT}
|
||||
#
|
||||
# Read it as a cascade: reset -> shell -> surfaces -> controls -> chrome.
|
||||
# ---------------------------------------------------------------------------
|
||||
_TEMPLATE = Template("""
|
||||
/* ---- reset ------------------------------------------------------------ */
|
||||
* { font-family: $font_family; font-size: ${font_size}px; }
|
||||
QWidget { background: $bg; color: $text; }
|
||||
QMainWindow::separator { background: $border; width: 1px; height: 1px; }
|
||||
QSplitter::handle { background: $border; }
|
||||
QSplitter::handle:horizontal { width: 1px; }
|
||||
QSplitter::handle:vertical { height: 1px; }
|
||||
QSplitter::handle:hover { background: $border_strong; }
|
||||
QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; }
|
||||
QToolTip {
|
||||
background: $overlay; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 5px 9px;
|
||||
}
|
||||
|
||||
/* Icons are drawn at text scale, not as decoration. */
|
||||
QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; }
|
||||
QTreeWidget#navrail { qproperty-iconSize: 22px 16px; }
|
||||
|
||||
/* ---- shell ------------------------------------------------------------ */
|
||||
QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; }
|
||||
QLabel#brand { color: $text; font-size: 15px; font-weight: 700; background: transparent; }
|
||||
QWidget#navWrap { background: $nav_bg; border-right: 1px solid $nav_border; }
|
||||
QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget { background: transparent; border: none; }
|
||||
QWidget#contentArea { background: $bg; }
|
||||
|
||||
/* Rail rows sit on nav_bg, so they need their own hover/selected steps — the
|
||||
generic ones are tuned against `bg` and wash out here. The active item also
|
||||
carries a 2px accent marker, so which section you are in survives even at a
|
||||
glance or for anyone who cannot separate the two greys. */
|
||||
QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item {
|
||||
padding: 6px 4px; border-radius: ${radius}px;
|
||||
}
|
||||
QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover {
|
||||
background: $nav_hover;
|
||||
}
|
||||
QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:selected {
|
||||
background: $nav_selected; color: $text;
|
||||
border-left: 2px solid $accent; font-weight: 600;
|
||||
}
|
||||
/* Rows the project gate is holding shut: still listed, visibly not open. */
|
||||
QWidget#navWrap QTreeWidget::item:disabled { color: $text_faint; }
|
||||
/* The pinned bottom group (Dashboard, Monitoring) — one hairline separates it
|
||||
from the list above so "occasional" reads apart from "everyday". */
|
||||
QTreeWidget#navrailBottom { border-top: 1px solid $nav_border; }
|
||||
QScrollArea#navScroll { background: transparent; border: none; }
|
||||
QScrollArea#navScroll > QWidget > QWidget { background: transparent; }
|
||||
/* Monitoring ▸ Overview reads as titled sections down one column, the way the
|
||||
audit page draws it — a quiet caps heading with the content flat underneath,
|
||||
not six bordered boxes competing with the cards inside them. */
|
||||
QGroupBox#monSection {
|
||||
background: transparent; border: none; margin-top: 16px;
|
||||
padding: 6px 0 0 0; font-weight: 700;
|
||||
}
|
||||
QGroupBox#monSection::title {
|
||||
subcontrol-origin: margin; subcontrol-position: top left; left: 2px; top: 0;
|
||||
padding: 0; color: $text_faint; font-size: 11px; letter-spacing: 0.5px;
|
||||
}
|
||||
/* Segmented control: two-to-four choices shown side by side (language, theme)
|
||||
instead of a drop-list you must open to see what the options even are. */
|
||||
QPushButton#segItem {
|
||||
background: $surface_raised; color: $text_muted; border: 1px solid $border;
|
||||
padding: 4px 12px; margin: 0; border-radius: 0;
|
||||
}
|
||||
QPushButton#segItem:hover { background: $hover; color: $text; }
|
||||
QPushButton#segItem:checked {
|
||||
background: $accent_solid; color: #FFFFFF; border-color: $accent_solid; font-weight: 600;
|
||||
}
|
||||
/* Table of contents down the left of the long dialogs (Settings, Task editor). */
|
||||
QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; }
|
||||
QListWidget#sectionIndex::item { padding: 7px 10px; border-radius: ${radius}px; }
|
||||
QListWidget#sectionIndex::item:hover { background: $hover; }
|
||||
QListWidget#sectionIndex::item:selected {
|
||||
background: $nav_selected; color: $text; font-weight: 600;
|
||||
}
|
||||
/* The strip under the typing box: agent · routing · usage · folder. Reads as
|
||||
status, not as a second toolbar, so the eye lands on the input first. */
|
||||
QWidget#composerStatus { border-top: 1px solid $border; background: transparent; }
|
||||
QWidget#composerStatus QLabel { color: $text_faint; font-size: 11px; }
|
||||
QWidget#composerStatus QPushButton, QWidget#composerStatus QComboBox {
|
||||
background: transparent; border: none; color: $text_muted; font-size: 11px;
|
||||
padding: 2px 6px; border-radius: ${radius_sm}px;
|
||||
}
|
||||
QWidget#composerStatus QPushButton:hover, QWidget#composerStatus QComboBox:hover {
|
||||
background: $hover; color: $text;
|
||||
}
|
||||
/* Folder: the current path, written as the screen's title. */
|
||||
QLabel#folderTitle { color: $text; font-size: 14px; font-weight: 600; background: transparent; }
|
||||
/* A pair of view tabs inside a page header (Schedule, GraphRAG) — flatter and
|
||||
quieter than the app's main tab bars, since they switch a view, not a page. */
|
||||
QTabBar#viewTabs::tab {
|
||||
background: transparent; color: $text_muted; border: none;
|
||||
padding: 4px 12px; margin: 0 2px; border-radius: ${radius}px;
|
||||
}
|
||||
QTabBar#viewTabs::tab:hover { background: $hover; color: $text; }
|
||||
QTabBar#viewTabs::tab:selected { background: $active; color: $text; font-weight: 600; }
|
||||
/* Co4E sidebar section headings — same quiet caps as the rail's RECENTS, so
|
||||
"which list am I looking at" is answered on screen, not in a tooltip. */
|
||||
/* The action beside a Co4E section heading ("+ Mới", "Quản lý skill…"). The
|
||||
wireframe writes these as small accent text; as full buttons they were the
|
||||
loudest thing in the sidebar and each cost a row of height. */
|
||||
QPushButton#co4eSectionAction {
|
||||
background: transparent; border: none; color: $accent;
|
||||
font-size: 11px; font-weight: 600; padding: 1px 4px;
|
||||
border-radius: ${radius_sm}px; qproperty-iconSize: 11px 11px;
|
||||
}
|
||||
QPushButton#co4eSectionAction:hover { background: $hover; color: $accent; }
|
||||
QPushButton#co4eSectionAction:pressed { background: $active; }
|
||||
QPushButton#co4eSectionHdr {
|
||||
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||
background: transparent; border: none; text-align: left; padding: 2px 0;
|
||||
}
|
||||
QPushButton#co4eSectionHdr:hover { color: $text; }
|
||||
/* Account row at the foot of the rail: who you are + the settings that follow
|
||||
you (provider, language, theme). Separated by a hairline like the group above. */
|
||||
QWidget#navAccount { border-top: 1px solid $nav_border; }
|
||||
QWidget#navAccount QComboBox {
|
||||
background: $surface_raised; border: 1px solid $nav_border; color: $text;
|
||||
padding: 3px 6px; border-radius: ${radius}px;
|
||||
}
|
||||
/* RECENTS section label — quiet, so the thread titles under it read first. */
|
||||
QLabel#navSectionHdr {
|
||||
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||
padding: 8px 8px 2px 8px; background: transparent;
|
||||
}
|
||||
QTreeWidget#navRecents { border-top: 1px solid $nav_border; }
|
||||
|
||||
/* Icon library cells. The audit page's note on this screen is that the cells
|
||||
had no visible edge on hover or selection, so you could not tell what you
|
||||
were about to pick — "cùng ngôn ngữ thẻ với Agent/Công cụ". */
|
||||
QListWidget#iconGrid { background: transparent; border: none; }
|
||||
QListWidget#iconGrid::item {
|
||||
border: 1px solid transparent; border-radius: ${radius}px;
|
||||
color: $text_muted; padding: 4px;
|
||||
}
|
||||
QListWidget#iconGrid::item:hover {
|
||||
border: 1px solid $accent; background: $hover; color: $text;
|
||||
}
|
||||
QListWidget#iconGrid::item:selected {
|
||||
border: 1px solid $accent; background: $accent_wash; color: $text;
|
||||
}
|
||||
/* Screen title beside its actions, same weight the other admin screens use. */
|
||||
QLabel#monTitle { font-size: 15px; font-weight: 700; color: $text; }
|
||||
/* Rail header — the primary action, so it is the one filled button up there. */
|
||||
QPushButton#navNewChatBtn {
|
||||
background: $accent_solid; color: #FFFFFF; border: none; font-weight: 600;
|
||||
padding: 7px 10px; border-radius: ${radius}px; text-align: left;
|
||||
}
|
||||
QPushButton#navNewChatBtn:hover { background: $accent_solid_hover; }
|
||||
QPushButton#navNewChatBtn:disabled { background: $border_strong; color: $text_faint; }
|
||||
QComboBox#navProjectPick {
|
||||
background: $surface_raised; border: 1px solid $nav_border; color: $text;
|
||||
padding: 4px 8px; border-radius: ${radius}px;
|
||||
}
|
||||
/* Stand-in for the picker while the rail is 54px wide: icon only, no arrow —
|
||||
the arrow would eat a third of the width for no information. */
|
||||
QToolButton#navProjectPickMini {
|
||||
background: $surface_raised; border: 1px solid $nav_border; border-radius: ${radius}px;
|
||||
padding: 4px; qproperty-iconSize: 16px 16px;
|
||||
}
|
||||
QToolButton#navProjectPickMini:hover { background: $nav_hover; }
|
||||
QToolButton#navProjectPickMini:disabled { background: transparent; border-color: $border; }
|
||||
QToolButton#navProjectPickMini::menu-indicator { image: none; width: 0; }
|
||||
|
||||
QPushButton#navSettingsBtn {
|
||||
background: transparent; border: none; color: $text_muted;
|
||||
/* Padding stays at 0: the row lays its own icon and label out, so that
|
||||
the spacing does not change with the platform's button style. */
|
||||
padding: 0; text-align: left; border-radius: ${radius}px;
|
||||
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
|
||||
so its icon has to start on their x. A 6px margin put it at 14 — near
|
||||
enough the middle of the collapsed 54px rail to look centred. */
|
||||
margin: 2px 0px 6px 0px;
|
||||
}
|
||||
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
|
||||
QPushButton#navSettingsBtn:pressed { background: $active; }
|
||||
|
||||
|
||||
/* ---- surfaces --------------------------------------------------------- */
|
||||
QGroupBox {
|
||||
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
|
||||
margin-top: 14px; padding: 16px 12px 12px 12px; font-weight: 600;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 1px;
|
||||
padding: 0 6px; color: $text_muted; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
QFrame[frameShape="4"], QFrame[frameShape="5"] { background: $border; border: none; }
|
||||
QScrollArea { background: transparent; border: none; }
|
||||
QAbstractScrollArea::corner { background: transparent; }
|
||||
|
||||
/* ---- tabs: an underline, not a pill. -------------------------------------
|
||||
The old pill tabs read as buttons and fought the real buttons for
|
||||
attention. A 2px rule under the active label is quieter and unambiguous. */
|
||||
QTabWidget::pane { background: $bg; border: none; border-top: 1px solid $border; top: -1px; }
|
||||
QTabBar { background: transparent; qproperty-drawBase: 0; }
|
||||
QTabBar::tab {
|
||||
background: transparent; color: $text_muted; padding: 9px 14px; margin: 0 2px 0 0;
|
||||
border: none; border-bottom: 2px solid transparent; font-weight: 500;
|
||||
}
|
||||
QTabBar::tab:selected { color: $text; border-bottom: 2px solid $accent; font-weight: 600; }
|
||||
QTabBar::tab:hover:!selected { color: $text; background: $hover; }
|
||||
|
||||
/* Co4E flow strip — browser-style tabs, so these stay enclosed. */
|
||||
QTabBar#flowTabs::tab {
|
||||
background: $surface; color: $text_muted; border: 1px solid $border;
|
||||
border-radius: ${radius}px; padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px;
|
||||
}
|
||||
QTabBar#flowTabs::tab:selected { background: $surface_raised; color: $text; border-color: $border_strong; }
|
||||
QTabBar#flowTabs::tab:hover:!selected { background: $hover; color: $text; }
|
||||
QPushButton#flowAddBtn {
|
||||
background: transparent; color: $text_muted; border: 1px solid $border;
|
||||
border-radius: ${radius}px; padding: 6px 0; font-size: 15px; min-height: 22px;
|
||||
}
|
||||
QPushButton#flowAddBtn:hover { background: $hover; color: $text; }
|
||||
|
||||
/* Co4E icon sidebar — no chrome until it is the active one. */
|
||||
QTabBar#co4eSideTabs { qproperty-iconSize: 18px 18px; }
|
||||
QTabBar#co4eSideTabs::tab {
|
||||
background: transparent; color: $text_muted; border: none;
|
||||
border-radius: ${radius}px; padding: 6px; margin: 0 4px 0 0;
|
||||
}
|
||||
QTabBar#co4eSideTabs::tab:selected { background: $accent_soft; color: $text; }
|
||||
QTabBar#co4eSideTabs::tab:hover:!selected { background: $hover; color: $text; }
|
||||
QGraphicsView#co4eCanvas {
|
||||
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
|
||||
}
|
||||
|
||||
/* ---- text entry & item views ------------------------------------------ */
|
||||
QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox,
|
||||
QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget {
|
||||
background: $surface_raised; color: $text; border: 1px solid $border;
|
||||
border-radius: ${radius}px; selection-background-color: $selection_bg;
|
||||
selection-color: $selection_fg; outline: 0;
|
||||
}
|
||||
QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; }
|
||||
QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; }
|
||||
QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus,
|
||||
QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; }
|
||||
QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled {
|
||||
background: $surface; color: $text_disabled;
|
||||
}
|
||||
/* Explicit up/down button geometry: once ANY spin-box subcontrol is styled,
|
||||
Qt uses exactly this rect for both painting AND hit-testing, so the
|
||||
clickable area can no longer drift from what's drawn (the previous
|
||||
unstyled default arrows misaligned their own click region at 125%/150%
|
||||
Windows display scaling — this pins both to the same rect instead). */
|
||||
QSpinBox::up-button, QDoubleSpinBox::up-button {
|
||||
subcontrol-origin: border; subcontrol-position: top right;
|
||||
width: 18px; height: 15px; border: none; background: transparent;
|
||||
}
|
||||
QSpinBox::down-button, QDoubleSpinBox::down-button {
|
||||
subcontrol-origin: border; subcontrol-position: bottom right;
|
||||
width: 18px; height: 15px; border: none; background: transparent;
|
||||
}
|
||||
QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
|
||||
QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background: $hover; }
|
||||
QSpinBox::up-button:pressed, QDoubleSpinBox::up-button:pressed,
|
||||
QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed { background: $active; }
|
||||
QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url($chevron_up); width: 9px; height: 9px; }
|
||||
QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url($chevron_down); width: 9px; height: 9px; }
|
||||
QSpinBox::up-arrow:disabled, QSpinBox::down-arrow:disabled,
|
||||
QDoubleSpinBox::up-arrow:disabled, QDoubleSpinBox::down-arrow:disabled { image: none; }
|
||||
|
||||
QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; }
|
||||
QTreeView::item:hover, QListView::item:hover { background: $hover; }
|
||||
QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {
|
||||
background: $accent_soft; color: $text;
|
||||
}
|
||||
/* The platform style draws its own dotted/solid focus rect on the current
|
||||
cell on top of the selection tint above — visible as a stray light border
|
||||
on a click. The selection tint already marks "current row"; drop the rect. */
|
||||
QTreeView::item:focus, QListView::item:focus, QTableView::item:focus { outline: none; border: none; }
|
||||
/* Kanban lanes (Schedule Task): seven lanes share the board width, so a card's
|
||||
own inset competes with the other six for space the same way the inter-lane
|
||||
gap did — trimmed to match. */
|
||||
QListWidget#kanbanLane::item { padding: 3px 2px; }
|
||||
QHeaderView::section {
|
||||
background: $bg; color: $text_muted; border: none;
|
||||
border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- buttons -----------------------------------------------------------
|
||||
Default is a quiet outline. Weight is reserved for #primary / #danger, so
|
||||
at most one button per view should carry a fill. */
|
||||
QPushButton {
|
||||
background: $surface_raised; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 7px 14px; font-weight: 500;
|
||||
}
|
||||
QPushButton:hover { background: $hover; border-color: $border_strong; }
|
||||
QPushButton:pressed { background: $active; }
|
||||
QPushButton:disabled { color: $text_disabled; background: $surface; border-color: $border; }
|
||||
QPushButton:focus { border: 1px solid $focus_ring; }
|
||||
|
||||
QPushButton#primary {
|
||||
background: $accent_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
|
||||
}
|
||||
QPushButton#primary:hover { background: $accent_solid_hover; }
|
||||
QPushButton#primary:pressed { background: $accent_solid_active; }
|
||||
QPushButton#primary:disabled { background: $surface; color: $text_disabled; border-color: $border; }
|
||||
|
||||
QPushButton#danger {
|
||||
background: $danger_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
|
||||
}
|
||||
QPushButton#danger:hover { background: $danger_solid_hover; }
|
||||
QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; }
|
||||
|
||||
/* Ghost buttons: nav section headers and icon-only chrome. */
|
||||
QPushButton#navMenuBtn {
|
||||
background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px;
|
||||
font-weight: 600; font-size: 11px; letter-spacing: 0.6px; color: $text_faint; text-align: left;
|
||||
}
|
||||
QPushButton#navMenuBtn:hover { background: $hover; color: $text; }
|
||||
QPushButton#navMenuBtn:pressed { background: $active; }
|
||||
|
||||
QToolButton {
|
||||
background: transparent; color: $text_muted; border: none;
|
||||
border-radius: ${radius}px; padding: 5px;
|
||||
}
|
||||
QToolButton:hover { background: $hover; color: $text; }
|
||||
QToolButton:pressed { background: $active; }
|
||||
QToolButton::menu-indicator { image: none; }
|
||||
|
||||
/* ---- pickers ----------------------------------------------------------- */
|
||||
QComboBox {
|
||||
background: $surface_raised; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 6px 10px;
|
||||
}
|
||||
QComboBox:hover { background: $hover; }
|
||||
QComboBox:focus { border-color: $focus_ring; }
|
||||
QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; }
|
||||
QComboBox::drop-down { border: none; width: 20px; }
|
||||
QComboBox::down-arrow { image: url($chevron_down); width: 10px; height: 10px; }
|
||||
QComboBox::down-arrow:disabled { image: none; }
|
||||
QComboBox QAbstractItemView {
|
||||
background: $overlay; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 4px; outline: none;
|
||||
selection-background-color: $accent_soft; selection-color: $text;
|
||||
}
|
||||
|
||||
QMenu { background: $overlay; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 4px; }
|
||||
QMenu::item { padding: 6px 14px; border-radius: ${radius_sm}px; }
|
||||
QMenu::item:selected { background: $accent_soft; color: $text; }
|
||||
QMenu::item:disabled { color: $text_disabled; }
|
||||
QMenu::separator { height: 1px; background: $border; margin: 4px 6px; }
|
||||
|
||||
QMenuBar { background: $bg; color: $text; border-bottom: 1px solid $border; }
|
||||
QMenuBar::item { padding: 5px 10px; border-radius: ${radius_sm}px; background: transparent; }
|
||||
QMenuBar::item:selected { background: $hover; }
|
||||
|
||||
/* ---- toggles ----------------------------------------------------------- */
|
||||
QCheckBox, QRadioButton { spacing: 8px; background: transparent; }
|
||||
QCheckBox::indicator, QRadioButton::indicator {
|
||||
width: 16px; height: 16px; background: $surface_raised;
|
||||
border: 1px solid $border_strong; border-radius: ${radius_sm}px;
|
||||
}
|
||||
QRadioButton::indicator { border-radius: 9px; }
|
||||
QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $accent; }
|
||||
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
|
||||
background: $accent_solid; border-color: $accent_solid;
|
||||
}
|
||||
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
|
||||
background: $surface; border-color: $border;
|
||||
}
|
||||
QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {
|
||||
background: $border_strong; border-color: $border_strong;
|
||||
}
|
||||
|
||||
QSlider::groove:horizontal { height: 4px; background: $border; border-radius: 2px; }
|
||||
QSlider::sub-page:horizontal { background: $accent_solid; border-radius: 2px; }
|
||||
QSlider::handle:horizontal {
|
||||
width: 14px; height: 14px; margin: -6px 0; border-radius: 7px;
|
||||
background: $surface_raised; border: 1px solid $border_strong;
|
||||
}
|
||||
QSlider::handle:horizontal:hover { border-color: $accent; }
|
||||
|
||||
QProgressBar {
|
||||
background: $surface; border: none; border-radius: 3px;
|
||||
height: 6px; text-align: center; color: $text_muted;
|
||||
}
|
||||
QProgressBar::chunk { background: $accent_solid; border-radius: 3px; }
|
||||
|
||||
/* ---- scrollbars: overlay-thin, no arrows. ------------------------------ */
|
||||
QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
|
||||
QScrollBar::handle:vertical { background: $scroll_handle; border-radius: 4px; min-height: 28px; }
|
||||
QScrollBar::handle:vertical:hover { background: $scroll_handle_hover; }
|
||||
QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
|
||||
QScrollBar::handle:horizontal { background: $scroll_handle; border-radius: 4px; min-width: 28px; }
|
||||
QScrollBar::handle:horizontal:hover { background: $scroll_handle_hover; }
|
||||
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none; background: none; }
|
||||
QScrollBar::add-page, QScrollBar::sub-page { background: none; }
|
||||
|
||||
/* ---- badges & inline text tones ---------------------------------------
|
||||
One shape, seven tones. Pick by meaning: badgeSuccess for a finished run,
|
||||
badgeDanger for a failed one — not by which colour looks nice. badgeNeutral
|
||||
is the odd one out: it deliberately uses OPAQUE tokens ($active/$text_muted,
|
||||
not an rgba() *_soft one) since it renders inside table cells that can sit
|
||||
over a selection tint — an rgba() background there would composite
|
||||
differently selected vs not (see Monitoring ▸ Sự kiện bảo mật's Hành động
|
||||
pill, which hit exactly this). */
|
||||
QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger,
|
||||
QLabel#badgePurple, QLabel#badgePink, QLabel#badgeNeutral {
|
||||
border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
QLabel#badge { background: $info_soft; color: $info; }
|
||||
QLabel#badgeSuccess { background: $success_soft; color: $success; }
|
||||
QLabel#badgeWarn { background: $warning_soft; color: $warning; }
|
||||
QLabel#badgeDanger { background: $danger_soft; color: $danger; }
|
||||
QLabel#badgePurple { background: $purple_soft; color: $purple; }
|
||||
QLabel#badgePink { background: $pink_soft; color: $pink; }
|
||||
QLabel#badgeNeutral { background: $active; color: $text_muted; }
|
||||
|
||||
/* ---- event-detail panel (Monitoring ▸ Sự kiện bảo mật) -----------------
|
||||
A neutral, low-emphasis tag — the "Loại" chip: a category label with no
|
||||
colour coding of its own (colour is reserved for the Trạng thái badge
|
||||
beside it). */
|
||||
QLabel#neutralTag {
|
||||
background: $surface_raised; border: 1px solid $border; border-radius: ${radius_sm}px;
|
||||
padding: 2px 8px; font-size: 12px;
|
||||
}
|
||||
/* A short identifier shown as a bordered monospace chip (machine name,
|
||||
event id). */
|
||||
QLabel#monoChip {
|
||||
font-family: "Cascadia Code", Consolas, monospace; background: $surface_raised;
|
||||
border: 1px solid $border; border-radius: ${radius_sm}px; padding: 1px 6px; font-size: 12px;
|
||||
}
|
||||
/* Section caption inside the panel — the same quiet caps heading as
|
||||
Monitoring ▸ Overview's group titles (monSection::title above), with a
|
||||
hairline under it since the panel has no group-box border of its own. */
|
||||
QLabel#detailSectionHdr {
|
||||
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||
padding-bottom: 4px; margin-top: 6px; border-bottom: 1px solid $border;
|
||||
}
|
||||
/* The blocked-detail text renders as a fixed dark "terminal" block — the
|
||||
same look in both themes, like a code snippet, so it reads consistently
|
||||
against whichever tint the row around it happens to carry. */
|
||||
QWidget#detailCodeBlock { background: #1B1A19; border-radius: ${radius}px; }
|
||||
QLabel#detailCodeText {
|
||||
color: #CCFF00; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px;
|
||||
}
|
||||
QPushButton#detailCopyBtn {
|
||||
background: rgba(255,255,255,0.15); color: #FFFFFF; border: none;
|
||||
border-radius: ${radius_sm}px; padding: 3px 10px; font-size: 11px;
|
||||
}
|
||||
QPushButton#detailCopyBtn:hover { background: rgba(255,255,255,0.25); }
|
||||
|
||||
QLabel { background: transparent; }
|
||||
QLabel#hint { color: $text_muted; }
|
||||
QLabel#faint { color: $text_faint; }
|
||||
QLabel#warning { color: $warning; font-weight: 600; }
|
||||
QLabel#error { color: $danger; font-weight: 600; }
|
||||
QLabel#success { color: $success; font-weight: 600; }
|
||||
QLabel#sectionTitle { color: $text; font-size: 15px; font-weight: 600; }
|
||||
|
||||
/* ---- code, terminals & logs -------------------------------------------
|
||||
These read as "sunken" surfaces: the eye goes in, not across. */
|
||||
QPlainTextEdit#codeEditor, QPlainTextEdit#termOutput, QPlainTextEdit#logView {
|
||||
background: $code_bg; color: $code_fg; border: none;
|
||||
font-family: $font_mono; selection-background-color: $code_selection;
|
||||
}
|
||||
QLineEdit#termInput {
|
||||
background: $code_bg; color: $code_fg; border: none; border-top: 1px solid $border;
|
||||
font-family: $font_mono; border-radius: 0; padding: 7px 10px;
|
||||
}
|
||||
QLineEdit#termInput:focus { border-top-color: $accent; }
|
||||
|
||||
/* The help-agent dock styles itself from these same tokens — it is a floating
|
||||
overlay that re-applies on every theme switch. See ui/help_agent_widget.py. */
|
||||
""")
|
||||
|
||||
|
||||
def resolve_theme(theme: str) -> str:
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Hai bảng màu Tối và Sáng, cùng lớp ``Palette`` — dữ liệu, không logic.
|
||||
|
||||
Tách khỏi ``theme.py``: đây là chỗ duy nhất cần mở khi đổi màu. Mọi thứ khác
|
||||
trong theme chỉ đọc từ đây.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from string import Template
|
||||
|
||||
def _chevron_asset(direction: str, color: str) -> str:
|
||||
"""Render (and cache on disk) a small chevron PNG for QComboBox/QSpinBox
|
||||
arrow subcontrols. QSS's ``image:`` property only accepts a resource or
|
||||
file path, never a live QPixmap — and once ``::drop-down``/``::up-button``/
|
||||
``::down-button`` are styled at all, Qt stops drawing its own built-in
|
||||
arrow, so without this the controls show no affordance whatsoever."""
|
||||
import hashlib
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
key = hashlib.md5(f"{direction}-{color}".encode()).hexdigest()[:10]
|
||||
path = Path(tempfile.gettempdir()) / "cowork_local_theme" / f"chevron_{key}.png"
|
||||
if not path.exists():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
from PySide6.QtCore import QPointF, Qt
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
|
||||
size = 12
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
pen = p.pen()
|
||||
pen.setColor(QColor(color))
|
||||
pen.setWidthF(1.6)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
pen.setJoinStyle(Qt.RoundJoin)
|
||||
p.setPen(pen)
|
||||
pts = ([QPointF(2.5, 4.5), QPointF(6, 8), QPointF(9.5, 4.5)] if direction == "down"
|
||||
else [QPointF(2.5, 7.5), QPointF(6, 4), QPointF(9.5, 7.5)])
|
||||
p.drawPolyline(pts)
|
||||
p.end()
|
||||
pm.save(str(path))
|
||||
return path.as_posix()
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Palette:
|
||||
"""Every colour and shape value the interface is allowed to use."""
|
||||
|
||||
name: str
|
||||
|
||||
# --- surfaces: a 4-step ramp from the window back to the frontmost layer.
|
||||
bg: str # window / canvas backdrop
|
||||
surface: str # panels, cards, group boxes (NOT the nav rail)
|
||||
surface_raised: str # inputs, lists, trees — things you type or pick in
|
||||
overlay: str # menus, tooltips, popups (floats above everything)
|
||||
sunken: str # logs, code, terminals — things you read into
|
||||
hover: str # hover wash on rows, tabs, ghost buttons
|
||||
active: str # pressed / held state
|
||||
|
||||
# The nav rail gets its own step rather than borrowing `surface`. It is a
|
||||
# permanent region of the window, not a card floating on the page.
|
||||
#
|
||||
# Following VS Code, the rail is *darker* than the content area (dark) or a
|
||||
# shade off white (light). The step is small on purpose — VS Code separates
|
||||
# the rail with a border, not a big tonal jump — so `nav_border` is doing
|
||||
# real work here and must stay visible.
|
||||
nav_bg: str
|
||||
nav_border: str
|
||||
nav_hover: str
|
||||
nav_selected: str
|
||||
|
||||
# --- lines
|
||||
border: str # default hairline
|
||||
border_strong: str # hairline that must survive next to a filled surface
|
||||
focus_ring: str # keyboard/typing focus
|
||||
|
||||
# --- text
|
||||
text: str
|
||||
text_muted: str # secondary copy, captions, group-box titles
|
||||
text_faint: str # metadata, timestamps, placeholder
|
||||
text_disabled: str
|
||||
on_accent: str # text drawn on top of a filled accent/status surface
|
||||
|
||||
# --- accent: `accent` tints text & icons, `accent_solid` fills buttons.
|
||||
accent: str
|
||||
accent_solid: str
|
||||
accent_solid_hover: str
|
||||
accent_solid_active: str
|
||||
accent_soft: str # translucent wash for selected rows (QSS only)
|
||||
accent_soft_hover: str
|
||||
accent_wash: str # the same tint pre-blended to a solid, for Qt rich
|
||||
# text (bgcolor=, <table>) where alpha is ignored
|
||||
|
||||
# --- status
|
||||
success: str
|
||||
success_soft: str
|
||||
warning: str
|
||||
warning_soft: str
|
||||
danger: str
|
||||
danger_solid: str
|
||||
danger_solid_hover: str
|
||||
danger_soft: str
|
||||
info: str
|
||||
info_soft: str
|
||||
purple: str
|
||||
purple_soft: str
|
||||
pink: str
|
||||
pink_soft: str
|
||||
|
||||
# --- selection (text selection inside editors and inputs)
|
||||
selection_bg: str
|
||||
selection_fg: str
|
||||
|
||||
# --- scrollbars
|
||||
scroll_handle: str
|
||||
scroll_handle_hover: str
|
||||
|
||||
# --- code & terminal
|
||||
code_bg: str
|
||||
code_fg: str
|
||||
code_gutter_bg: str
|
||||
code_gutter_fg: str
|
||||
code_selection: str
|
||||
code_comment: str
|
||||
code_keyword: str
|
||||
code_type: str
|
||||
code_func: str
|
||||
code_attr: str
|
||||
code_string: str
|
||||
code_number: str
|
||||
code_error: str
|
||||
|
||||
# --- diff / inline change badges
|
||||
diff_add_bg: str
|
||||
diff_add_fg: str
|
||||
diff_del_bg: str
|
||||
diff_del_fg: str
|
||||
|
||||
# --- charts
|
||||
chart_grid: str
|
||||
chart_label: str
|
||||
|
||||
# --- conversation & graph node roles
|
||||
role_user: str
|
||||
role_assistant: str
|
||||
role_tool: str
|
||||
role_result: str
|
||||
role_error: str
|
||||
|
||||
# --- shape & type
|
||||
radius_sm: int
|
||||
radius: int
|
||||
radius_lg: int
|
||||
font_family: str
|
||||
font_size: int
|
||||
font_mono: str
|
||||
|
||||
_FONT = '"Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Yu Gothic UI", "Meiryo", sans-serif'
|
||||
|
||||
_MONO = '"Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", monospace'
|
||||
|
||||
DARK = Palette(
|
||||
name="dark",
|
||||
# ---- VS Code "Dark Modern" ----------------------------------------------
|
||||
# Values taken from the shipped theme JSON. Where VS Code's own choice falls
|
||||
# below WCAG AA it is nudged just far enough to pass; each such value carries
|
||||
# a note with VS Code's original and the measured ratio.
|
||||
bg="#1F1F1F", # editor.background
|
||||
surface="#252526", # panel / card
|
||||
surface_raised="#313131", # input.background
|
||||
overlay="#252526", # menus, tooltips
|
||||
sunken="#181818", # logs, terminals — below the ramp
|
||||
hover="#2A2D2E", # list.hoverBackground
|
||||
active="#37373D", # list.inactiveSelectionBackground
|
||||
# The sidebar is DARKER than the editor — that is the VS Code silhouette.
|
||||
nav_bg="#181818", # sideBar.background
|
||||
nav_border="#2B2B2B", # sideBar.border
|
||||
nav_hover="#2A2D2E",
|
||||
nav_selected="#04395E", # list.activeSelectionBackground
|
||||
border="#2B2B2B", # panel.border
|
||||
border_strong="#3C3C3C", # input.border
|
||||
focus_ring="#0078D4", # focusBorder
|
||||
text="#CCCCCC", # editor.foreground
|
||||
text_muted="#9D9D9D", # descriptionForeground
|
||||
text_faint="#9A9A9A", # lifted: #8B8B8B was 3.82:1 on input surfaces
|
||||
text_disabled="#5A5A5A",
|
||||
on_accent="#FFFFFF",
|
||||
accent="#4DAAFC", # textLink.foreground — accent as TEXT
|
||||
accent_solid="#0078D4", # button.background — accent as FILL
|
||||
accent_solid_hover="#026EC1",
|
||||
accent_solid_active="#005FB8",
|
||||
accent_soft="rgba(0,120,212,0.22)",
|
||||
accent_soft_hover="rgba(0,120,212,0.32)",
|
||||
accent_wash="#12283C", # pre-blended: Qt rich text ignores alpha
|
||||
success="#89D185", # gitDecoration added
|
||||
success_soft="rgba(137,209,133,0.16)",
|
||||
warning="#CCA700", # editorWarning
|
||||
warning_soft="rgba(204,167,0,0.16)",
|
||||
danger="#F76464", # editorError #F14C4C lifted (4.29:1 on panels)
|
||||
danger_solid="#C4302B",
|
||||
danger_solid_hover="#D9433C",
|
||||
danger_soft="rgba(241,76,76,0.16)",
|
||||
info="#4DAAFC",
|
||||
info_soft="rgba(77,170,252,0.16)",
|
||||
purple="#C586C0", # Dark+ syntax purple
|
||||
purple_soft="rgba(197,134,192,0.16)",
|
||||
pink="#D16D9E",
|
||||
pink_soft="rgba(209,109,158,0.16)",
|
||||
selection_bg="#264F78", # editor.selectionBackground
|
||||
selection_fg="#FFFFFF",
|
||||
scroll_handle="#4E4E4E", # scrollbarSlider
|
||||
scroll_handle_hover="#5A5A5A",
|
||||
code_bg="#1F1F1F",
|
||||
code_fg="#CCCCCC",
|
||||
code_gutter_bg="#1F1F1F",
|
||||
# VS Code uses #6E7681 for line numbers — only 3.59:1. Lifted to clear AA.
|
||||
code_gutter_fg="#858D97",
|
||||
code_selection="#264F78",
|
||||
code_comment="#6A9955", # ---- Dark+ syntax, unchanged --------------
|
||||
code_keyword="#569CD6",
|
||||
code_type="#4EC9B0",
|
||||
code_func="#DCDCAA",
|
||||
code_attr="#9CDCFE",
|
||||
code_string="#CE9178",
|
||||
code_number="#B5CEA8",
|
||||
code_error="#F44747",
|
||||
diff_add_bg="#1B3A1B", # diffEditor inserted, pre-blended
|
||||
diff_add_fg="#89D185",
|
||||
diff_del_bg="#4B1818", # diffEditor removed, pre-blended
|
||||
diff_del_fg="#F76464",
|
||||
chart_grid="#2B2B2B",
|
||||
chart_label="#9D9D9D",
|
||||
role_user="#4DAAFC",
|
||||
role_assistant="#4EC9B0",
|
||||
role_tool="#C586C0",
|
||||
role_result="#89D185",
|
||||
role_error="#F14C4C",
|
||||
radius_sm=3, # VS Code is squarer than the previous look
|
||||
radius=4,
|
||||
radius_lg=6,
|
||||
font_family=_FONT,
|
||||
font_size=13,
|
||||
font_mono=_MONO,
|
||||
)
|
||||
|
||||
LIGHT = Palette(
|
||||
name="light",
|
||||
# ---- VS Code "Light Modern" ---------------------------------------------
|
||||
bg="#FFFFFF", # editor.background
|
||||
surface="#F8F8F8", # sideBar / panel
|
||||
surface_raised="#FFFFFF", # input.background
|
||||
overlay="#FFFFFF",
|
||||
sunken="#F3F3F3",
|
||||
hover="#F2F2F2", # list.hoverBackground
|
||||
active="#E8E8E8", # list.activeSelectionBackground
|
||||
nav_bg="#F8F8F8", # sideBar.background
|
||||
nav_border="#E5E5E5", # sideBar.border
|
||||
nav_hover="#F2F2F2",
|
||||
nav_selected="#E4E6F1", # active row, tinted toward the accent
|
||||
border="#E5E5E5",
|
||||
border_strong="#CECECE", # input.border
|
||||
focus_ring="#005FB8", # focusBorder
|
||||
text="#3B3B3B", # editor.foreground
|
||||
text_muted="#616161",
|
||||
# VS Code uses #767676 — 4.28:1 on the sidebar. Darkened to clear AA there.
|
||||
text_faint="#6E6E6E",
|
||||
text_disabled="#A0A0A0",
|
||||
on_accent="#FFFFFF",
|
||||
accent="#005FB8", # textLink / button
|
||||
accent_solid="#005FB8",
|
||||
accent_solid_hover="#0258A8",
|
||||
accent_solid_active="#004C97",
|
||||
accent_soft="rgba(0,95,184,0.10)",
|
||||
accent_soft_hover="rgba(0,95,184,0.16)",
|
||||
accent_wash="#E6EEF8",
|
||||
# VS Code green #388A34 is 4.33:1 and amber #BF8803 only 3.12:1 — both lifted.
|
||||
success="#317A2D",
|
||||
success_soft="#DFF3DE",
|
||||
warning="#8F6500", # VS Code #BF8803 = 3.12:1
|
||||
warning_soft="#FBF0D0",
|
||||
danger="#CD3131", # editorError
|
||||
danger_solid="#CD3131",
|
||||
danger_solid_hover="#B82A2A",
|
||||
danger_soft="#FDEFEF", # lightened so #CD3131 clears AA on it
|
||||
info="#005FB8",
|
||||
info_soft="#DDEBF9",
|
||||
purple="#6F42C1",
|
||||
purple_soft="#EDE7FA",
|
||||
pink="#B3247E",
|
||||
pink_soft="#FAE3F0",
|
||||
selection_bg="#ADD6FF", # editor.selectionBackground
|
||||
selection_fg="#000000",
|
||||
scroll_handle="#C1C1C1",
|
||||
scroll_handle_hover="#A6A6A6",
|
||||
code_bg="#FFFFFF",
|
||||
code_fg="#3B3B3B",
|
||||
code_gutter_bg="#F8F8F8",
|
||||
code_gutter_fg="#656C76", # VS Code #6E7681 = 4.33:1 on the gutter
|
||||
code_selection="#ADD6FF",
|
||||
code_comment="#008000", # ---- Light+ syntax ------------------------
|
||||
code_keyword="#0000FF",
|
||||
code_type="#267F99",
|
||||
code_func="#795E26",
|
||||
code_attr="#E50000",
|
||||
code_string="#A31515",
|
||||
code_number="#098658",
|
||||
code_error="#CD3131",
|
||||
diff_add_bg="#DBF4DB",
|
||||
diff_add_fg="#1E6F1A",
|
||||
diff_del_bg="#FBE3E3",
|
||||
diff_del_fg="#B82A2A",
|
||||
chart_grid="#E5E5E5",
|
||||
chart_label="#616161",
|
||||
role_user="#005FB8",
|
||||
role_assistant="#267F99",
|
||||
role_tool="#6F42C1",
|
||||
role_result="#317A2D",
|
||||
role_error="#CD3131",
|
||||
radius_sm=3,
|
||||
radius=4,
|
||||
radius_lg=6,
|
||||
font_family=_FONT,
|
||||
font_size=13,
|
||||
font_mono=_MONO,
|
||||
)
|
||||
|
||||
_PALETTES = {"dark": DARK, "light": LIGHT}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
"""Khuôn QSS của toàn ứng dụng — 470 dòng bảng kiểu.
|
||||
|
||||
Tách khỏi ``theme.py`` vì nó là **dữ liệu**, không phải logic: một chuỗi
|
||||
``string.Template`` mà ``stylesheet()`` thay biến vào. Để chung thì mỗi lần
|
||||
muốn sửa một hàm nhỏ trong theme.py lại phải cuộn qua 470 dòng CSS.
|
||||
|
||||
Sửa màu thì sang ``theme_palettes.py``; ở đây chỉ sửa hình dạng và khoảng cách.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from string import Template
|
||||
|
||||
|
||||
from .theme_qss_controls import QSS_CONTROLS
|
||||
|
||||
_QSS_SHELL = """
|
||||
/* ---- reset ------------------------------------------------------------ */
|
||||
* { font-family: $font_family; font-size: ${font_size}px; }
|
||||
QWidget { background: $bg; color: $text; }
|
||||
QMainWindow::separator { background: $border; width: 1px; height: 1px; }
|
||||
QSplitter::handle { background: $border; }
|
||||
QSplitter::handle:horizontal { width: 1px; }
|
||||
QSplitter::handle:vertical { height: 1px; }
|
||||
QSplitter::handle:hover { background: $border_strong; }
|
||||
QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; }
|
||||
QToolTip {
|
||||
background: $overlay; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 5px 9px;
|
||||
}
|
||||
|
||||
/* Icons are drawn at text scale, not as decoration. */
|
||||
QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; }
|
||||
QTreeWidget#navrail { qproperty-iconSize: 22px 16px; }
|
||||
|
||||
/* ---- shell ------------------------------------------------------------ */
|
||||
QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; }
|
||||
QLabel#brand { color: $text; font-size: 15px; font-weight: 700; background: transparent; }
|
||||
QWidget#navWrap { background: $nav_bg; border-right: 1px solid $nav_border; }
|
||||
QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget { background: transparent; border: none; }
|
||||
QWidget#contentArea { background: $bg; }
|
||||
|
||||
/* Rail rows sit on nav_bg, so they need their own hover/selected steps — the
|
||||
generic ones are tuned against `bg` and wash out here. The active item also
|
||||
carries a 2px accent marker, so which section you are in survives even at a
|
||||
glance or for anyone who cannot separate the two greys. */
|
||||
QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item {
|
||||
padding: 6px 4px; border-radius: ${radius}px;
|
||||
}
|
||||
QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover {
|
||||
background: $nav_hover;
|
||||
}
|
||||
QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:selected {
|
||||
background: $nav_selected; color: $text;
|
||||
border-left: 2px solid $accent; font-weight: 600;
|
||||
}
|
||||
/* Rows the project gate is holding shut: still listed, visibly not open. */
|
||||
QWidget#navWrap QTreeWidget::item:disabled { color: $text_faint; }
|
||||
/* The pinned bottom group (Dashboard, Monitoring) — one hairline separates it
|
||||
from the list above so "occasional" reads apart from "everyday". */
|
||||
QTreeWidget#navrailBottom { border-top: 1px solid $nav_border; }
|
||||
QScrollArea#navScroll { background: transparent; border: none; }
|
||||
QScrollArea#navScroll > QWidget > QWidget { background: transparent; }
|
||||
/* Monitoring ▸ Overview reads as titled sections down one column, the way the
|
||||
audit page draws it — a quiet caps heading with the content flat underneath,
|
||||
not six bordered boxes competing with the cards inside them. */
|
||||
QGroupBox#monSection {
|
||||
background: transparent; border: none; margin-top: 16px;
|
||||
padding: 6px 0 0 0; font-weight: 700;
|
||||
}
|
||||
QGroupBox#monSection::title {
|
||||
subcontrol-origin: margin; subcontrol-position: top left; left: 2px; top: 0;
|
||||
padding: 0; color: $text_faint; font-size: 11px; letter-spacing: 0.5px;
|
||||
}
|
||||
/* Segmented control: two-to-four choices shown side by side (language, theme)
|
||||
instead of a drop-list you must open to see what the options even are. */
|
||||
QPushButton#segItem {
|
||||
background: $surface_raised; color: $text_muted; border: 1px solid $border;
|
||||
padding: 4px 12px; margin: 0; border-radius: 0;
|
||||
}
|
||||
QPushButton#segItem:hover { background: $hover; color: $text; }
|
||||
QPushButton#segItem:checked {
|
||||
background: $accent_solid; color: #FFFFFF; border-color: $accent_solid; font-weight: 600;
|
||||
}
|
||||
/* Table of contents down the left of the long dialogs (Settings, Task editor). */
|
||||
QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; }
|
||||
QListWidget#sectionIndex::item { padding: 7px 10px; border-radius: ${radius}px; }
|
||||
QListWidget#sectionIndex::item:hover { background: $hover; }
|
||||
QListWidget#sectionIndex::item:selected {
|
||||
background: $nav_selected; color: $text; font-weight: 600;
|
||||
}
|
||||
/* The strip under the typing box: agent · routing · usage · folder. Reads as
|
||||
status, not as a second toolbar, so the eye lands on the input first. */
|
||||
QWidget#composerStatus { border-top: 1px solid $border; background: transparent; }
|
||||
QWidget#composerStatus QLabel { color: $text_faint; font-size: 11px; }
|
||||
QWidget#composerStatus QPushButton, QWidget#composerStatus QComboBox {
|
||||
background: transparent; border: none; color: $text_muted; font-size: 11px;
|
||||
padding: 2px 6px; border-radius: ${radius_sm}px;
|
||||
}
|
||||
QWidget#composerStatus QPushButton:hover, QWidget#composerStatus QComboBox:hover {
|
||||
background: $hover; color: $text;
|
||||
}
|
||||
/* Folder: the current path, written as the screen's title. */
|
||||
QLabel#folderTitle { color: $text; font-size: 14px; font-weight: 600; background: transparent; }
|
||||
/* A pair of view tabs inside a page header (Schedule, GraphRAG) — flatter and
|
||||
quieter than the app's main tab bars, since they switch a view, not a page. */
|
||||
QTabBar#viewTabs::tab {
|
||||
background: transparent; color: $text_muted; border: none;
|
||||
padding: 4px 12px; margin: 0 2px; border-radius: ${radius}px;
|
||||
}
|
||||
QTabBar#viewTabs::tab:hover { background: $hover; color: $text; }
|
||||
QTabBar#viewTabs::tab:selected { background: $active; color: $text; font-weight: 600; }
|
||||
/* Co4E sidebar section headings — same quiet caps as the rail's RECENTS, so
|
||||
"which list am I looking at" is answered on screen, not in a tooltip. */
|
||||
/* The action beside a Co4E section heading ("+ Mới", "Quản lý skill…"). The
|
||||
wireframe writes these as small accent text; as full buttons they were the
|
||||
loudest thing in the sidebar and each cost a row of height. */
|
||||
QPushButton#co4eSectionAction {
|
||||
background: transparent; border: none; color: $accent;
|
||||
font-size: 11px; font-weight: 600; padding: 1px 4px;
|
||||
border-radius: ${radius_sm}px; qproperty-iconSize: 11px 11px;
|
||||
}
|
||||
QPushButton#co4eSectionAction:hover { background: $hover; color: $accent; }
|
||||
QPushButton#co4eSectionAction:pressed { background: $active; }
|
||||
QPushButton#co4eSectionHdr {
|
||||
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||
background: transparent; border: none; text-align: left; padding: 2px 0;
|
||||
}
|
||||
QPushButton#co4eSectionHdr:hover { color: $text; }
|
||||
/* Account row at the foot of the rail: who you are + the settings that follow
|
||||
you (provider, language, theme). Separated by a hairline like the group above. */
|
||||
QWidget#navAccount { border-top: 1px solid $nav_border; }
|
||||
QWidget#navAccount QComboBox {
|
||||
background: $surface_raised; border: 1px solid $nav_border; color: $text;
|
||||
padding: 3px 6px; border-radius: ${radius}px;
|
||||
}
|
||||
/* RECENTS section label — quiet, so the thread titles under it read first. */
|
||||
QLabel#navSectionHdr {
|
||||
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||
padding: 8px 8px 2px 8px; background: transparent;
|
||||
}
|
||||
QTreeWidget#navRecents { border-top: 1px solid $nav_border; }
|
||||
|
||||
/* Icon library cells. The audit page's note on this screen is that the cells
|
||||
had no visible edge on hover or selection, so you could not tell what you
|
||||
were about to pick — "cùng ngôn ngữ thẻ với Agent/Công cụ". */
|
||||
QListWidget#iconGrid { background: transparent; border: none; }
|
||||
QListWidget#iconGrid::item {
|
||||
border: 1px solid transparent; border-radius: ${radius}px;
|
||||
color: $text_muted; padding: 4px;
|
||||
}
|
||||
QListWidget#iconGrid::item:hover {
|
||||
border: 1px solid $accent; background: $hover; color: $text;
|
||||
}
|
||||
QListWidget#iconGrid::item:selected {
|
||||
border: 1px solid $accent; background: $accent_wash; color: $text;
|
||||
}
|
||||
/* Screen title beside its actions, same weight the other admin screens use. */
|
||||
QLabel#monTitle { font-size: 15px; font-weight: 700; color: $text; }
|
||||
/* Rail header — the primary action, so it is the one filled button up there. */
|
||||
QPushButton#navNewChatBtn {
|
||||
background: $accent_solid; color: #FFFFFF; border: none; font-weight: 600;
|
||||
padding: 7px 10px; border-radius: ${radius}px; text-align: left;
|
||||
}
|
||||
QPushButton#navNewChatBtn:hover { background: $accent_solid_hover; }
|
||||
QPushButton#navNewChatBtn:disabled { background: $border_strong; color: $text_faint; }
|
||||
QComboBox#navProjectPick {
|
||||
background: $surface_raised; border: 1px solid $nav_border; color: $text;
|
||||
padding: 4px 8px; border-radius: ${radius}px;
|
||||
}
|
||||
/* Stand-in for the picker while the rail is 54px wide: icon only, no arrow —
|
||||
the arrow would eat a third of the width for no information. */
|
||||
QToolButton#navProjectPickMini {
|
||||
background: $surface_raised; border: 1px solid $nav_border; border-radius: ${radius}px;
|
||||
padding: 4px; qproperty-iconSize: 16px 16px;
|
||||
}
|
||||
QToolButton#navProjectPickMini:hover { background: $nav_hover; }
|
||||
QToolButton#navProjectPickMini:disabled { background: transparent; border-color: $border; }
|
||||
QToolButton#navProjectPickMini::menu-indicator { image: none; width: 0; }
|
||||
|
||||
QPushButton#navSettingsBtn {
|
||||
background: transparent; border: none; color: $text_muted;
|
||||
/* Padding stays at 0: the row lays its own icon and label out, so that
|
||||
the spacing does not change with the platform's button style. */
|
||||
padding: 0; text-align: left; border-radius: ${radius}px;
|
||||
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
|
||||
so its icon has to start on their x. A 6px margin put it at 14 — near
|
||||
enough the middle of the collapsed 54px rail to look centred. */
|
||||
margin: 2px 0px 6px 0px;
|
||||
}
|
||||
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
|
||||
QPushButton#navSettingsBtn:pressed { background: $active; }
|
||||
|
||||
|
||||
"""
|
||||
|
||||
#: Hai nửa nối lại. Cắt đôi vì một chuỗi 470 dòng vượt ngưỡng 400 dòng/file.
|
||||
_TEMPLATE = Template(_QSS_SHELL + QSS_CONTROLS)
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Nửa sau của khuôn QSS: bề mặt, tab, ô nhập, nút, badge, log.
|
||||
|
||||
Cắt đôi khuôn QSS đúng mạch của chính nó: ``theme_qss.py`` giữ phần vỏ
|
||||
(reset + shell: thanh rail, khung chính), file này giữ phần điều khiển.
|
||||
Hai nửa được nối lại trong ``theme_qss.py``.
|
||||
|
||||
Sửa màu thì sang ``theme_palettes.py``; ở đây chỉ sửa hình dạng và khoảng cách.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from string import Template
|
||||
|
||||
|
||||
QSS_CONTROLS = """/* ---- surfaces --------------------------------------------------------- */
|
||||
QGroupBox {
|
||||
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
|
||||
margin-top: 14px; padding: 16px 12px 12px 12px; font-weight: 600;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 1px;
|
||||
padding: 0 6px; color: $text_muted; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
QFrame[frameShape="4"], QFrame[frameShape="5"] { background: $border; border: none; }
|
||||
QScrollArea { background: transparent; border: none; }
|
||||
QAbstractScrollArea::corner { background: transparent; }
|
||||
|
||||
/* ---- tabs: an underline, not a pill. -------------------------------------
|
||||
The old pill tabs read as buttons and fought the real buttons for
|
||||
attention. A 2px rule under the active label is quieter and unambiguous. */
|
||||
QTabWidget::pane { background: $bg; border: none; border-top: 1px solid $border; top: -1px; }
|
||||
QTabBar { background: transparent; qproperty-drawBase: 0; }
|
||||
QTabBar::tab {
|
||||
background: transparent; color: $text_muted; padding: 9px 14px; margin: 0 2px 0 0;
|
||||
border: none; border-bottom: 2px solid transparent; font-weight: 500;
|
||||
}
|
||||
QTabBar::tab:selected { color: $text; border-bottom: 2px solid $accent; font-weight: 600; }
|
||||
QTabBar::tab:hover:!selected { color: $text; background: $hover; }
|
||||
|
||||
/* Co4E flow strip — browser-style tabs, so these stay enclosed. */
|
||||
QTabBar#flowTabs::tab {
|
||||
background: $surface; color: $text_muted; border: 1px solid $border;
|
||||
border-radius: ${radius}px; padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px;
|
||||
}
|
||||
QTabBar#flowTabs::tab:selected { background: $surface_raised; color: $text; border-color: $border_strong; }
|
||||
QTabBar#flowTabs::tab:hover:!selected { background: $hover; color: $text; }
|
||||
QPushButton#flowAddBtn {
|
||||
background: transparent; color: $text_muted; border: 1px solid $border;
|
||||
border-radius: ${radius}px; padding: 6px 0; font-size: 15px; min-height: 22px;
|
||||
}
|
||||
QPushButton#flowAddBtn:hover { background: $hover; color: $text; }
|
||||
|
||||
/* Co4E icon sidebar — no chrome until it is the active one. */
|
||||
QTabBar#co4eSideTabs { qproperty-iconSize: 18px 18px; }
|
||||
QTabBar#co4eSideTabs::tab {
|
||||
background: transparent; color: $text_muted; border: none;
|
||||
border-radius: ${radius}px; padding: 6px; margin: 0 4px 0 0;
|
||||
}
|
||||
QTabBar#co4eSideTabs::tab:selected { background: $accent_soft; color: $text; }
|
||||
QTabBar#co4eSideTabs::tab:hover:!selected { background: $hover; color: $text; }
|
||||
QGraphicsView#co4eCanvas {
|
||||
background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px;
|
||||
}
|
||||
|
||||
/* ---- text entry & item views ------------------------------------------ */
|
||||
QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox,
|
||||
QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget {
|
||||
background: $surface_raised; color: $text; border: 1px solid $border;
|
||||
border-radius: ${radius}px; selection-background-color: $selection_bg;
|
||||
selection-color: $selection_fg; outline: 0;
|
||||
}
|
||||
QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; }
|
||||
QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; }
|
||||
QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus,
|
||||
QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; }
|
||||
QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled {
|
||||
background: $surface; color: $text_disabled;
|
||||
}
|
||||
/* Explicit up/down button geometry: once ANY spin-box subcontrol is styled,
|
||||
Qt uses exactly this rect for both painting AND hit-testing, so the
|
||||
clickable area can no longer drift from what's drawn (the previous
|
||||
unstyled default arrows misaligned their own click region at 125%/150%
|
||||
Windows display scaling — this pins both to the same rect instead). */
|
||||
QSpinBox::up-button, QDoubleSpinBox::up-button {
|
||||
subcontrol-origin: border; subcontrol-position: top right;
|
||||
width: 18px; height: 15px; border: none; background: transparent;
|
||||
}
|
||||
QSpinBox::down-button, QDoubleSpinBox::down-button {
|
||||
subcontrol-origin: border; subcontrol-position: bottom right;
|
||||
width: 18px; height: 15px; border: none; background: transparent;
|
||||
}
|
||||
QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
|
||||
QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background: $hover; }
|
||||
QSpinBox::up-button:pressed, QDoubleSpinBox::up-button:pressed,
|
||||
QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed { background: $active; }
|
||||
QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url($chevron_up); width: 9px; height: 9px; }
|
||||
QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url($chevron_down); width: 9px; height: 9px; }
|
||||
QSpinBox::up-arrow:disabled, QSpinBox::down-arrow:disabled,
|
||||
QDoubleSpinBox::up-arrow:disabled, QDoubleSpinBox::down-arrow:disabled { image: none; }
|
||||
|
||||
QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; }
|
||||
QTreeView::item:hover, QListView::item:hover { background: $hover; }
|
||||
QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {
|
||||
background: $accent_soft; color: $text;
|
||||
}
|
||||
/* The platform style draws its own dotted/solid focus rect on the current
|
||||
cell on top of the selection tint above — visible as a stray light border
|
||||
on a click. The selection tint already marks "current row"; drop the rect. */
|
||||
QTreeView::item:focus, QListView::item:focus, QTableView::item:focus { outline: none; border: none; }
|
||||
/* Kanban lanes (Schedule Task): seven lanes share the board width, so a card's
|
||||
own inset competes with the other six for space the same way the inter-lane
|
||||
gap did — trimmed to match. */
|
||||
QListWidget#kanbanLane::item { padding: 3px 2px; }
|
||||
QHeaderView::section {
|
||||
background: $bg; color: $text_muted; border: none;
|
||||
border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- buttons -----------------------------------------------------------
|
||||
Default is a quiet outline. Weight is reserved for #primary / #danger, so
|
||||
at most one button per view should carry a fill. */
|
||||
QPushButton {
|
||||
background: $surface_raised; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 7px 14px; font-weight: 500;
|
||||
}
|
||||
QPushButton:hover { background: $hover; border-color: $border_strong; }
|
||||
QPushButton:pressed { background: $active; }
|
||||
QPushButton:disabled { color: $text_disabled; background: $surface; border-color: $border; }
|
||||
QPushButton:focus { border: 1px solid $focus_ring; }
|
||||
|
||||
QPushButton#primary {
|
||||
background: $accent_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
|
||||
}
|
||||
QPushButton#primary:hover { background: $accent_solid_hover; }
|
||||
QPushButton#primary:pressed { background: $accent_solid_active; }
|
||||
QPushButton#primary:disabled { background: $surface; color: $text_disabled; border-color: $border; }
|
||||
|
||||
QPushButton#danger {
|
||||
background: $danger_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600;
|
||||
}
|
||||
QPushButton#danger:hover { background: $danger_solid_hover; }
|
||||
QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; }
|
||||
|
||||
/* Ghost buttons: nav section headers and icon-only chrome. */
|
||||
QPushButton#navMenuBtn {
|
||||
background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px;
|
||||
font-weight: 600; font-size: 11px; letter-spacing: 0.6px; color: $text_faint; text-align: left;
|
||||
}
|
||||
QPushButton#navMenuBtn:hover { background: $hover; color: $text; }
|
||||
QPushButton#navMenuBtn:pressed { background: $active; }
|
||||
|
||||
QToolButton {
|
||||
background: transparent; color: $text_muted; border: none;
|
||||
border-radius: ${radius}px; padding: 5px;
|
||||
}
|
||||
QToolButton:hover { background: $hover; color: $text; }
|
||||
QToolButton:pressed { background: $active; }
|
||||
QToolButton::menu-indicator { image: none; }
|
||||
|
||||
/* ---- pickers ----------------------------------------------------------- */
|
||||
QComboBox {
|
||||
background: $surface_raised; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 6px 10px;
|
||||
}
|
||||
QComboBox:hover { background: $hover; }
|
||||
QComboBox:focus { border-color: $focus_ring; }
|
||||
QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; }
|
||||
QComboBox::drop-down { border: none; width: 20px; }
|
||||
QComboBox::down-arrow { image: url($chevron_down); width: 10px; height: 10px; }
|
||||
QComboBox::down-arrow:disabled { image: none; }
|
||||
QComboBox QAbstractItemView {
|
||||
background: $overlay; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 4px; outline: none;
|
||||
selection-background-color: $accent_soft; selection-color: $text;
|
||||
}
|
||||
|
||||
QMenu { background: $overlay; color: $text; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px; padding: 4px; }
|
||||
QMenu::item { padding: 6px 14px; border-radius: ${radius_sm}px; }
|
||||
QMenu::item:selected { background: $accent_soft; color: $text; }
|
||||
QMenu::item:disabled { color: $text_disabled; }
|
||||
QMenu::separator { height: 1px; background: $border; margin: 4px 6px; }
|
||||
|
||||
QMenuBar { background: $bg; color: $text; border-bottom: 1px solid $border; }
|
||||
QMenuBar::item { padding: 5px 10px; border-radius: ${radius_sm}px; background: transparent; }
|
||||
QMenuBar::item:selected { background: $hover; }
|
||||
|
||||
/* ---- toggles ----------------------------------------------------------- */
|
||||
QCheckBox, QRadioButton { spacing: 8px; background: transparent; }
|
||||
QCheckBox::indicator, QRadioButton::indicator {
|
||||
width: 16px; height: 16px; background: $surface_raised;
|
||||
border: 1px solid $border_strong; border-radius: ${radius_sm}px;
|
||||
}
|
||||
QRadioButton::indicator { border-radius: 9px; }
|
||||
QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $accent; }
|
||||
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
|
||||
background: $accent_solid; border-color: $accent_solid;
|
||||
}
|
||||
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
|
||||
background: $surface; border-color: $border;
|
||||
}
|
||||
QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {
|
||||
background: $border_strong; border-color: $border_strong;
|
||||
}
|
||||
|
||||
QSlider::groove:horizontal { height: 4px; background: $border; border-radius: 2px; }
|
||||
QSlider::sub-page:horizontal { background: $accent_solid; border-radius: 2px; }
|
||||
QSlider::handle:horizontal {
|
||||
width: 14px; height: 14px; margin: -6px 0; border-radius: 7px;
|
||||
background: $surface_raised; border: 1px solid $border_strong;
|
||||
}
|
||||
QSlider::handle:horizontal:hover { border-color: $accent; }
|
||||
|
||||
QProgressBar {
|
||||
background: $surface; border: none; border-radius: 3px;
|
||||
height: 6px; text-align: center; color: $text_muted;
|
||||
}
|
||||
QProgressBar::chunk { background: $accent_solid; border-radius: 3px; }
|
||||
|
||||
/* ---- scrollbars: overlay-thin, no arrows. ------------------------------ */
|
||||
QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
|
||||
QScrollBar::handle:vertical { background: $scroll_handle; border-radius: 4px; min-height: 28px; }
|
||||
QScrollBar::handle:vertical:hover { background: $scroll_handle_hover; }
|
||||
QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
|
||||
QScrollBar::handle:horizontal { background: $scroll_handle; border-radius: 4px; min-width: 28px; }
|
||||
QScrollBar::handle:horizontal:hover { background: $scroll_handle_hover; }
|
||||
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none; background: none; }
|
||||
QScrollBar::add-page, QScrollBar::sub-page { background: none; }
|
||||
|
||||
/* ---- badges & inline text tones ---------------------------------------
|
||||
One shape, seven tones. Pick by meaning: badgeSuccess for a finished run,
|
||||
badgeDanger for a failed one — not by which colour looks nice. badgeNeutral
|
||||
is the odd one out: it deliberately uses OPAQUE tokens ($active/$text_muted,
|
||||
not an rgba() *_soft one) since it renders inside table cells that can sit
|
||||
over a selection tint — an rgba() background there would composite
|
||||
differently selected vs not (see Monitoring ▸ Sự kiện bảo mật's Hành động
|
||||
pill, which hit exactly this). */
|
||||
QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger,
|
||||
QLabel#badgePurple, QLabel#badgePink, QLabel#badgeNeutral {
|
||||
border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
QLabel#badge { background: $info_soft; color: $info; }
|
||||
QLabel#badgeSuccess { background: $success_soft; color: $success; }
|
||||
QLabel#badgeWarn { background: $warning_soft; color: $warning; }
|
||||
QLabel#badgeDanger { background: $danger_soft; color: $danger; }
|
||||
QLabel#badgePurple { background: $purple_soft; color: $purple; }
|
||||
QLabel#badgePink { background: $pink_soft; color: $pink; }
|
||||
QLabel#badgeNeutral { background: $active; color: $text_muted; }
|
||||
|
||||
/* ---- event-detail panel (Monitoring ▸ Sự kiện bảo mật) -----------------
|
||||
A neutral, low-emphasis tag — the "Loại" chip: a category label with no
|
||||
colour coding of its own (colour is reserved for the Trạng thái badge
|
||||
beside it). */
|
||||
QLabel#neutralTag {
|
||||
background: $surface_raised; border: 1px solid $border; border-radius: ${radius_sm}px;
|
||||
padding: 2px 8px; font-size: 12px;
|
||||
}
|
||||
/* A short identifier shown as a bordered monospace chip (machine name,
|
||||
event id). */
|
||||
QLabel#monoChip {
|
||||
font-family: "Cascadia Code", Consolas, monospace; background: $surface_raised;
|
||||
border: 1px solid $border; border-radius: ${radius_sm}px; padding: 1px 6px; font-size: 12px;
|
||||
}
|
||||
/* Section caption inside the panel — the same quiet caps heading as
|
||||
Monitoring ▸ Overview's group titles (monSection::title above), with a
|
||||
hairline under it since the panel has no group-box border of its own. */
|
||||
QLabel#detailSectionHdr {
|
||||
color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||
padding-bottom: 4px; margin-top: 6px; border-bottom: 1px solid $border;
|
||||
}
|
||||
/* The blocked-detail text renders as a fixed dark "terminal" block — the
|
||||
same look in both themes, like a code snippet, so it reads consistently
|
||||
against whichever tint the row around it happens to carry. */
|
||||
QWidget#detailCodeBlock { background: #1B1A19; border-radius: ${radius}px; }
|
||||
QLabel#detailCodeText {
|
||||
color: #CCFF00; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px;
|
||||
}
|
||||
QPushButton#detailCopyBtn {
|
||||
background: rgba(255,255,255,0.15); color: #FFFFFF; border: none;
|
||||
border-radius: ${radius_sm}px; padding: 3px 10px; font-size: 11px;
|
||||
}
|
||||
QPushButton#detailCopyBtn:hover { background: rgba(255,255,255,0.25); }
|
||||
|
||||
QLabel { background: transparent; }
|
||||
QLabel#hint { color: $text_muted; }
|
||||
QLabel#faint { color: $text_faint; }
|
||||
QLabel#warning { color: $warning; font-weight: 600; }
|
||||
QLabel#error { color: $danger; font-weight: 600; }
|
||||
QLabel#success { color: $success; font-weight: 600; }
|
||||
QLabel#sectionTitle { color: $text; font-size: 15px; font-weight: 600; }
|
||||
|
||||
/* ---- code, terminals & logs -------------------------------------------
|
||||
These read as "sunken" surfaces: the eye goes in, not across. */
|
||||
QPlainTextEdit#codeEditor, QPlainTextEdit#termOutput, QPlainTextEdit#logView {
|
||||
background: $code_bg; color: $code_fg; border: none;
|
||||
font-family: $font_mono; selection-background-color: $code_selection;
|
||||
}
|
||||
QLineEdit#termInput {
|
||||
background: $code_bg; color: $code_fg; border: none; border-top: 1px solid $border;
|
||||
font-family: $font_mono; border-radius: 0; padding: 7px 10px;
|
||||
}
|
||||
QLineEdit#termInput:focus { border-top-color: $accent; }
|
||||
|
||||
/* The help-agent dock styles itself from these same tokens — it is a floating
|
||||
overlay that re-applies on every theme switch. See ui/help_agent_widget.py. */
|
||||
"""
|
||||
+136
-132
@@ -1,132 +1,136 @@
|
||||
"""Round 5: do the checks actually bite?
|
||||
|
||||
Rounds 1–4 all report green. That is only worth something if the checks would
|
||||
have turned red had the work not been done. So this round breaks the app on
|
||||
purpose, one feature at a time, and fails if the corresponding check still
|
||||
passes — a check that cannot fail is not evidence.
|
||||
|
||||
Each mutation is applied by monkey-patching the module BEFORE the checker
|
||||
builds its own window, then undone.
|
||||
|
||||
Run: python tools/check_probes_bite.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO.parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
# (name, file, find, replace, checker that must FAIL because of it)
|
||||
MUTATIONS = [
|
||||
("phong to cham tro ly gap doi khai bao",
|
||||
"ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104",
|
||||
"check_layout_geometry.py"),
|
||||
("tra lane Running ve khong vien",
|
||||
"ui/schedule_task_tab.py",
|
||||
'if status == "running" and counts[status]:',
|
||||
'if False:',
|
||||
"check_design_parity.py"),
|
||||
("bo cot muc luc cua Cai dat",
|
||||
"ui/settings_dialog.py",
|
||||
"self.section_list, self.section_stack = section_panels(pages)",
|
||||
"self.section_list, self.section_stack = section_panels(pages[:1])",
|
||||
"check_dialogs.py"),
|
||||
("noi lai dai tab flow Co4E",
|
||||
"ui/co4e_tab.py",
|
||||
"self.flow_scroll.setVisible(False)",
|
||||
"self.flow_scroll.setVisible(True)",
|
||||
"check_co4e.py"),
|
||||
("bo dong 'Tat ca project...' khoi GAN DAY",
|
||||
"app.py",
|
||||
'more.setData(0, Qt.UserRole, {"all": True})',
|
||||
'more.setData(0, Qt.UserRole, {})',
|
||||
"check_design_parity.py"),
|
||||
("tra thanh menu ve accordion (bo nhom day)",
|
||||
"app.py",
|
||||
'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,',
|
||||
'rows.append((self.nav, self._ROW_DASHBOARD, None,',
|
||||
"check_layout_geometry.py"),
|
||||
]
|
||||
|
||||
|
||||
def run_checker(script: str) -> int:
|
||||
"""Run a checker in a fresh process; return its exit code."""
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO / "tools" / script)],
|
||||
cwd=REPO, capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen",
|
||||
"PYTHONIOENCODING": "utf-8"})
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def tree_state() -> str:
|
||||
return subprocess.run(["git", "status", "--short"], cwd=REPO,
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
fails: list[str] = []
|
||||
# Compare the tree BEFORE and AFTER, not against a clean tree: work in
|
||||
# progress is legitimately uncommitted, and demanding a clean tree made this
|
||||
# round fail for a reason that has nothing to do with the mutations.
|
||||
before = tree_state()
|
||||
print(f"{'hong gi':44} {'phep do':26} ket qua")
|
||||
print("-" * 88)
|
||||
for name, rel, find, repl, checker in MUTATIONS:
|
||||
path = REPO / rel
|
||||
# newline="" both ways: the default translates on read AND write, so a
|
||||
# LF file came back as CRLF and every mutated file was left "modified"
|
||||
# even after being restored.
|
||||
with io.open(path, "r", encoding="utf-8", newline="") as fh:
|
||||
original = fh.read()
|
||||
if find not in original:
|
||||
fails.append(f"{name}: khong tim thay doan can sua trong {rel}")
|
||||
print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***")
|
||||
continue
|
||||
|
||||
def write(text: str) -> None:
|
||||
with io.open(path, "w", encoding="utf-8", newline="") as fh:
|
||||
fh.write(text)
|
||||
|
||||
write(original.replace(find, repl, 1))
|
||||
try:
|
||||
code = run_checker(checker)
|
||||
finally:
|
||||
write(original) # always restore
|
||||
bit = code != 0
|
||||
print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}")
|
||||
if not bit:
|
||||
fails.append(f"{name}: {checker} van bao xanh du da lam hong")
|
||||
|
||||
# Everything must be back exactly as it was before this run.
|
||||
after = tree_state()
|
||||
same = after == before
|
||||
print()
|
||||
print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***")
|
||||
if not same:
|
||||
print(" truoc:", before.replace("\n", " | ") or "(sach)")
|
||||
print(" sau :", after.replace("\n", " | ") or "(sach)")
|
||||
fails.append("file chua duoc khoi phuc sau khi thu")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print("*** VONG 5 THAT BAI ***")
|
||||
for f in fails:
|
||||
print(" " + f)
|
||||
return 1
|
||||
print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
"""Round 5: do the checks actually bite?
|
||||
|
||||
Rounds 1–4 all report green. That is only worth something if the checks would
|
||||
have turned red had the work not been done. So this round breaks the app on
|
||||
purpose, one feature at a time, and fails if the corresponding check still
|
||||
passes — a check that cannot fail is not evidence.
|
||||
|
||||
Each mutation is applied by monkey-patching the module BEFORE the checker
|
||||
builds its own window, then undone.
|
||||
|
||||
Run: python tools/check_probes_bite.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO.parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
# (name, file, find, replace, checker that must FAIL because of it)
|
||||
MUTATIONS = [
|
||||
("phong to cham tro ly gap doi khai bao",
|
||||
"ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104",
|
||||
"check_layout_geometry.py"),
|
||||
("tra lane Running ve khong vien",
|
||||
"ui/schedule_task_tab.py",
|
||||
'if status == "running" and counts[status]:',
|
||||
'if False:',
|
||||
"check_design_parity.py"),
|
||||
("bo cot muc luc cua Cai dat",
|
||||
"ui/settings_dialog.py",
|
||||
"self.section_list, self.section_stack = section_panels(pages)",
|
||||
"self.section_list, self.section_stack = section_panels(pages[:1])",
|
||||
"check_dialogs.py"),
|
||||
("noi lai dai tab flow Co4E",
|
||||
# R08-T09 doi cho: Co4ETab tach thanh 7 mixin duoi presentation/co4e/.
|
||||
"presentation/co4e/co4e_layout.py",
|
||||
"self.flow_scroll.setVisible(False)",
|
||||
"self.flow_scroll.setVisible(True)",
|
||||
"check_co4e.py"),
|
||||
("bo dong 'Tat ca project...' khoi GAN DAY",
|
||||
# R08-T10 doi cho: MainWindow bi boc khoi app.py sang presentation/shell/,
|
||||
# RECENTS nam o rail_project.py, cay dieu huong o nav_rail.py.
|
||||
"presentation/shell/rail_project.py",
|
||||
'more.setData(0, Qt.UserRole, {"all": True})',
|
||||
'more.setData(0, Qt.UserRole, {})',
|
||||
"check_design_parity.py"),
|
||||
("tra thanh menu ve accordion (bo nhom day)",
|
||||
"presentation/shell/nav_rail.py",
|
||||
'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,',
|
||||
'rows.append((self.nav, self._ROW_DASHBOARD, None,',
|
||||
"check_layout_geometry.py"),
|
||||
]
|
||||
|
||||
|
||||
def run_checker(script: str) -> int:
|
||||
"""Run a checker in a fresh process; return its exit code."""
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO / "tools" / script)],
|
||||
cwd=REPO, capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen",
|
||||
"PYTHONIOENCODING": "utf-8"})
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def tree_state() -> str:
|
||||
return subprocess.run(["git", "status", "--short"], cwd=REPO,
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
fails: list[str] = []
|
||||
# Compare the tree BEFORE and AFTER, not against a clean tree: work in
|
||||
# progress is legitimately uncommitted, and demanding a clean tree made this
|
||||
# round fail for a reason that has nothing to do with the mutations.
|
||||
before = tree_state()
|
||||
print(f"{'hong gi':44} {'phep do':26} ket qua")
|
||||
print("-" * 88)
|
||||
for name, rel, find, repl, checker in MUTATIONS:
|
||||
path = REPO / rel
|
||||
# newline="" both ways: the default translates on read AND write, so a
|
||||
# LF file came back as CRLF and every mutated file was left "modified"
|
||||
# even after being restored.
|
||||
with io.open(path, "r", encoding="utf-8", newline="") as fh:
|
||||
original = fh.read()
|
||||
if find not in original:
|
||||
fails.append(f"{name}: khong tim thay doan can sua trong {rel}")
|
||||
print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***")
|
||||
continue
|
||||
|
||||
def write(text: str) -> None:
|
||||
with io.open(path, "w", encoding="utf-8", newline="") as fh:
|
||||
fh.write(text)
|
||||
|
||||
write(original.replace(find, repl, 1))
|
||||
try:
|
||||
code = run_checker(checker)
|
||||
finally:
|
||||
write(original) # always restore
|
||||
bit = code != 0
|
||||
print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}")
|
||||
if not bit:
|
||||
fails.append(f"{name}: {checker} van bao xanh du da lam hong")
|
||||
|
||||
# Everything must be back exactly as it was before this run.
|
||||
after = tree_state()
|
||||
same = after == before
|
||||
print()
|
||||
print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***")
|
||||
if not same:
|
||||
print(" truoc:", before.replace("\n", " | ") or "(sach)")
|
||||
print(" sau :", after.replace("\n", " | ") or "(sach)")
|
||||
fails.append("file chua duoc khoi phuc sau khi thu")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print("*** VONG 5 THAT BAI ***")
|
||||
for f in fails:
|
||||
print(" " + f)
|
||||
return 1
|
||||
print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
+18
-1514
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user