diff --git a/config/__init__.py b/config/__init__.py index 561e1e3..ee0e7fd 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1,35 +1,106 @@ """Config package initializer,保持对旧 `from config import ...` 的兼容。""" +import json import os from pathlib import Path def _load_dotenv(): - """在未显式导入 python-dotenv 的情况下,尽量从仓库根目录加载 .env。""" - env_path = Path(__file__).resolve().parents[1] / ".env" - if not env_path.exists(): - return - # 仅保护启动前已有的环境变量;.env 内重复键以后者为准 + """加载配置:.env(仓库根)+ settings.json(数据根)。 + + 1. 先读仓库根目录 .env(若存在),不覆盖已有的环境变量; + 2. 再读 {data_root}/settings.json,将其中的 env_vars 注入 os.environ, + 并将非 env_vars 的顶级字段也注入为 AGENT_CFG_ 环境变量(供 config/auth.py 等使用); + 3. settings.json 的加载依赖 data_root 路径,若 settings.json 不存在则静默跳过。 + """ + import sys pre_existing_keys = set(os.environ.keys()) - try: - for raw_line in env_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - if not key: - continue - # 若在进程启动前已存在,则尊重外部环境;否则允许 .env 内后续行覆盖前面行 - if key in pre_existing_keys: - continue - os.environ[key] = value - except Exception: - # 加载失败时静默继续,保持兼容 - pass + + # 1) 仓库根 .env(开发便利,不覆盖已有环境变量) + if getattr(sys, 'frozen', False): + env_path = Path.home() / '.agents' / 'agents' / '.env' + else: + env_path = Path(__file__).resolve().parents[1] / '.env' + if env_path.exists(): + try: + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if not key: + continue + if key in pre_existing_keys: + continue + os.environ[key] = value + except Exception: + pass + + # 2) settings.json(数据根下的统一配置) + data_root = os.environ.get("AGENTS_DATA_ROOT", str(Path.home() / ".agents" / "agents")) + settings_path = Path(data_root) / "settings.json" + if settings_path.exists(): + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + # 注入 env_vars(API key 等) + env_vars = settings.get("env_vars", {}) + if isinstance(env_vars, dict): + for k, v in env_vars.items(): + if k not in pre_existing_keys and v: + os.environ[k] = str(v) + + # 映射 settings.json 字段 → 旧版环境变量名(兼容现有 config/*.py 模块) + _LEGACY_MAP = { + "terminal.sandbox_mode": "TERMINAL_SANDBOX_MODE", + "terminal.execution_mode_default": "HOST_EXECUTION_MODE_DEFAULT", + "terminal.direct_ttl_seconds": "HOST_EXECUTION_DIRECT_TTL_SECONDS", + "terminal.macos_writable_paths": "HOST_SANDBOX_MACOS_WRITABLE_PATHS", + "terminal.max_active_containers": "MAX_ACTIVE_USER_CONTAINERS", + "terminal.project_max_storage_mb": "PROJECT_MAX_STORAGE_MB", + "server.port": "WEB_SERVER_PORT", + "server.host": "WEB_SERVER_HOST", + "admin.username": "AGENT_ADMIN_USERNAME", + "admin.password_hash": "AGENT_ADMIN_PASSWORD_HASH", + "admin.secondary_password_hash": "ADMIN_SECONDARY_PASSWORD_HASH", + "secrets.web_secret_key": "WEB_SECRET_KEY", + "secrets.api_token_secret": "API_TOKEN_SECRET", + "search.tavily_api_key": "AGENT_TAVILY_API_KEY", + "search.tavily_api_key_2": "AGENT_TAVILY_API_KEY_2", + "balance.aliyun_access_key_id": "ALIYUN_ACCESS_KEY_ID", + "balance.aliyun_access_key_secret": "ALIYUN_ACCESS_KEY_SECRET", + "balance.usd_cny_rate": "USD_CNY_RATE", + "models.default_response_max_tokens": "AGENT_DEFAULT_RESPONSE_MAX_TOKENS", + } + for dotted_key, env_name in _LEGACY_MAP.items(): + if env_name in pre_existing_keys: + continue + parts = dotted_key.split(".") + val = settings + for p in parts: + val = val.get(p) if isinstance(val, dict) else None + if val is None: + break + if val is not None: + os.environ[env_name] = str(val) + + # 注入非 env_vars 的顶级配置为 AGENT_CFG_* 环境变量(供 config/auth.py 等使用) + for k, v in settings.items(): + if k == "env_vars": + continue + env_key = f"AGENT_CFG_{k.upper()}" + if isinstance(v, dict): + for sub_k, sub_v in v.items(): + flat_key = f"AGENT_CFG_{k.upper()}_{sub_k.upper()}" + if flat_key not in pre_existing_keys: + os.environ[flat_key] = str(sub_v) + elif env_key not in pre_existing_keys: + os.environ[env_key] = str(v) + except Exception: + pass _load_dotenv() diff --git a/config/auth.py b/config/auth.py index 292cc1d..54dfe1d 100644 --- a/config/auth.py +++ b/config/auth.py @@ -1,46 +1,30 @@ -"""认证与后台账户配置。""" +"""认证与后台账户配置。 + +配置来源(优先级从高到低): +1. 环境变量(os.environ,由 config/__init__.py 从 settings.json / .env 注入) +2. 默认值 +""" import os -from pathlib import Path - - -def _dotenv_cache(): - cache = getattr(_dotenv_cache, "_cache", None) - if cache is not None: - return cache - env_path = Path(__file__).resolve().parents[1] / ".env" - data = {} - if env_path.exists(): - for raw in env_path.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - data[key.strip()] = value.strip().strip('"').strip("'") - _dotenv_cache._cache = data - return data - def _get(name: str, default: str = "") -> str: - # 优先读取 .env 文件,未找到再回退环境变量 - return _dotenv_cache().get(name, os.environ.get(name, default)) + """从环境变量获取配置。""" + return str(os.environ.get(name, "") or "").strip() or default -ADMIN_USERNAME = _get("AGENT_ADMIN_USERNAME", "") -ADMIN_PASSWORD_HASH = _get("AGENT_ADMIN_PASSWORD_HASH", "") +ADMIN_USERNAME = _get("AGENT_CFG_ADMIN_USERNAME") or _get("AGENT_ADMIN_USERNAME", "") +ADMIN_PASSWORD_HASH = _get("AGENT_CFG_ADMIN_PASSWORD_HASH") or _get("AGENT_ADMIN_PASSWORD_HASH", "") # 管理员二级密码(可选)。 -# 优先使用哈希值;若未提供哈希,将回退到明文配置。 -ADMIN_SECONDARY_PASSWORD_HASH = _get("ADMIN_SECONDARY_PASSWORD_HASH", "") +ADMIN_SECONDARY_PASSWORD_HASH = _get("AGENT_CFG_ADMIN_SECONDARY_PASSWORD_HASH") or _get("ADMIN_SECONDARY_PASSWORD_HASH", "") ADMIN_SECONDARY_PASSWORD = _get("ADMIN_SECONDARY_PASSWORD", "") -# 二级密码会话有效期(秒)。默认为 30 分钟,可根据需要在环境变量中覆盖。 +# 二级密码会话有效期(秒)。默认 30 分钟。 ADMIN_SECONDARY_TTL_SECONDS = int(_get("AGENT_ADMIN_SECONDARY_TTL_SECONDS", "1800") or 1800) -# API Token 加密密钥来源;用于后台安全存储可回显的 Token。 -# 使用任意字符串即可,内部会通过 SHA256 导出 Fernet key。 -API_TOKEN_SECRET = _get("API_TOKEN_SECRET", "") +# API Token 加密密钥来源。 +API_TOKEN_SECRET = _get("AGENT_CFG_SECRETS_API_TOKEN_SECRET") or _get("API_TOKEN_SECRET", "") __all__ = [ "ADMIN_USERNAME", diff --git a/config/model_profiles.py b/config/model_profiles.py index 0232da5..f0332dc 100644 --- a/config/model_profiles.py +++ b/config/model_profiles.py @@ -7,22 +7,8 @@ from .paths import resolve_deploy_config def _env_optional(name: str) -> Optional[str]: + """从环境变量获取可选配置(已由 config/__init__.py 统一注入)。""" value = os.environ.get(name) - if value is None: - # 回退读取 .env(支持运行中更新) - env_path = Path(__file__).resolve().parents[1] / ".env" - if env_path.exists(): - try: - for raw_line in env_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, val = line.split("=", 1) - if key.strip() == name: - value = val.strip().strip('"').strip("'") - break - except Exception: - value = None if value is None: return None value = value.strip() diff --git a/config/paths.py b/config/paths.py index a31c294..7d938fe 100644 --- a/config/paths.py +++ b/config/paths.py @@ -1,20 +1,26 @@ """项目路径与目录配置。 -运行态数据(对话、用户工作区、日志等)默认存放在用户主目录下的 -``~/.agents`` 中,并按运行模式分流: +运行态数据存放在 ``~/.agents/agents/`` 下: -- 宿主机模式(``TERMINAL_SANDBOX_MODE=host``)-> ``~/.agents/host`` -- 其它模式(默认 docker / web) -> ``~/.agents/web`` +:: -对齐 ``~/.claude``、``~/.codex`` 的惯例,把运行态产物移出源码树。 + ~/.agents/agents/ ← data_root(默认,可通过 AGENTS_DATA_ROOT 覆盖) + ├── settings.json ← 唯一配置文件 + ├── config/ ← 部署级 JSON,host/web 模式共享 + ├── host/ ← host 模式(单用户) + │ ├── data/ ← 对话/记忆/数据库 + │ └── logs/ + └── web/ ← web 模式(多用户) + ├── users/ ← 每用户独立工作区 + │ └── /data/ + └── logs/ 路径解析优先级(从高到低): 1. 具体目录环境变量(``DATA_DIR`` / ``LOGS_DIR`` / ``USER_SPACE_DIR`` / ``API_USER_SPACE_DIR``)——单独覆盖某个目录,最高优先级; -2. 当前模式的根目录环境变量(``AGENTS_HOST_HOME`` 或 ``AGENTS_WEB_HOME``)—— - 整体搬迁该模式下的全部运行态数据; -3. 兜底默认值 ``~/.agents/``。 +2. ``AGENTS_DATA_ROOT`` ——整体搬迁数据根目录; +3. 兜底默认值 ``~/.agents/agents``。 注意:``config/*.json`` 分两类,不再一概锚定源码树: @@ -23,11 +29,12 @@ - **部署者自定义配置**(``custom_models`` / ``host_workspaces`` / ``auto_approval`` / ``goal_review`` / ``forbidden_commands`` / ``host_sandbox_policy``):因部署/机器而异、或含密钥,外置到 - ``~/.agents//config/``(即 ``DEPLOY_CONFIG_DIR``)。读取走 + ``/config/``(即 ``DEPLOY_CONFIG_DIR``)。读取走 ``resolve_deploy_config``,回退链为:部署目录 -> 源码树 ``.json`` -> - 源码树 ``.json.example``。这样开发环境不必先跑 setup 也能用源码树种子。 + 源码树 ``.json.example``。 """ +import json import os from pathlib import Path @@ -59,49 +66,54 @@ def _resolve_repo_path(raw_value: str, default: str) -> str: def _runtime_mode() -> str: """运行模式:``host`` 或 ``web``。 - 仅当 ``TERMINAL_SANDBOX_MODE=host`` 时为 host 模式,其余(默认 docker)归为 web。 - 直接读环境变量,避免对 ``config.terminal`` 形成导入依赖。 + 仅当 ``TERMINAL_SANDBOX_MODE=host`` 时为 host 模式。 + 优先从 settings.json 读取,其次环境变量,最后默认 host。 """ - return "host" if _env("TERMINAL_SANDBOX_MODE").lower() == "host" else "web" + # 尝试从已加载的 settings.json 中读取 + mode = _env("TERMINAL_SANDBOX_MODE").lower() + if mode == "host": + return "host" + if mode == "web" or mode == "docker": + return "web" + return "host" +# ── 数据根目录(data_root)── +# 优先级:AGENTS_DATA_ROOT 环境变量 > 默认 ~/.agents/agents +_DEFAULT_DATA_ROOT = Path("~/.agents/agents") +RUNTIME_ROOT = str(_norm(Path(_env("AGENTS_DATA_ROOT") or _DEFAULT_DATA_ROOT))) + _MODE = _runtime_mode() -# 当前模式的运行态根目录。 -# 优先级:模式根变量(AGENTS_HOST_HOME / AGENTS_WEB_HOME)> 默认 ~/.agents/。 -_MODE_HOME_ENV = "AGENTS_HOST_HOME" if _MODE == "host" else "AGENTS_WEB_HOME" -_DEFAULT_RUNTIME_ROOT = Path("~/.agents") / _MODE -RUNTIME_ROOT = str(_norm(Path(_env(_MODE_HOME_ENV) or _DEFAULT_RUNTIME_ROOT))) - def _runtime_dir(env_name: str, subdir: str) -> str: """解析运行态子目录。 - 优先级:具体目录环境变量 > 当前模式根目录下的对应子目录。 + 优先级:具体目录环境变量 > data_root/{mode}/subdir。 具体目录变量支持相对路径(相对仓库根目录)、绝对路径与 ``~``。 """ raw = _env(env_name) if raw: return str(_norm(Path(raw))) - return str(Path(RUNTIME_ROOT) / subdir) + return str(Path(RUNTIME_ROOT) / _MODE / subdir) -# ── 运行态目录(默认落在 ~/.agents/ 下,可被各自的环境变量覆盖)── +# ── 运行态目录(默认落在 // 下)── DATA_DIR = _runtime_dir("DATA_DIR", "data") LOGS_DIR = _runtime_dir("LOGS_DIR", "logs") USER_SPACE_DIR = _runtime_dir("USER_SPACE_DIR", "users") # API 专用用户与工作区(与网页用户隔离) API_USER_SPACE_DIR = _runtime_dir("API_USER_SPACE_DIR", "api/users") -# ── 部署级配置目录(部署者自定义的 config/*.json 外置到这里)── -# 默认 ~/.agents//config,可用 DEPLOY_CONFIG_DIR 覆盖。 -DEPLOY_CONFIG_DIR = _runtime_dir("DEPLOY_CONFIG_DIR", "config") +# ── 部署级配置目录 —— 放在 data_root 根下,两个模式共享 ── +_DEFAULT_DEPLOY_CONFIG = Path(RUNTIME_ROOT) / "config" +DEPLOY_CONFIG_DIR = str(_norm(Path(_env("DEPLOY_CONFIG_DIR") or _DEFAULT_DEPLOY_CONFIG))) # 源码树内的配置种子目录(首次运行/开发环境的默认内容来源)。 _SEED_CONFIG_DIR = _REPO_ROOT / "config" def deploy_config_path(filename: str) -> str: - """部署级配置文件应写入/读取的位置(``~/.agents//config`` 下)。 + """部署级配置文件应写入/读取的位置(``/config`` 下)。 用于会被运行时写回的文件(如 host_workspaces / host_sandbox_policy), 始终指向部署目录,不回退源码树。 @@ -112,7 +124,7 @@ def deploy_config_path(filename: str) -> str: def resolve_deploy_config(filename: str) -> str: """解析部署级配置的只读路径,回退链(高→低): - 1. ``~/.agents//config/``(部署者真实配置) + 1. ``/config/``(部署者真实配置) 2. 源码树 ``config/``(仓库内种子) 3. 源码树 ``config/.example``(示例兜底) @@ -132,17 +144,13 @@ def resolve_deploy_config(filename: str) -> str: # ── 启动工作区 ── -# 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录。 DEFAULT_PROJECT_PATH = _resolve_repo_path(os.environ.get("DEFAULT_PROJECT_PATH", ""), "./project") -# 兼容旧配置:若仍有模块读取 HOST_PROJECT_PATH,保留该键(实际宿主机路径选择改由 JSON 管理)。 HOST_PROJECT_PATH = _resolve_repo_path(os.environ.get("HOST_PROJECT_PATH", ""), DEFAULT_PROJECT_PATH) # ── 源码树内的配置与资源(不随运行态根目录迁移)── PROMPTS_DIR = _resolve_repo_path(os.environ.get("PROMPTS_DIR", ""), "./prompts") AGENT_SKILLS_DIR = _resolve_repo_path(os.environ.get("AGENT_SKILLS_DIR", ""), "./agentskills") WORKSPACE_SKILLS_DIRNAME = ".agents/skills" -# 宿主机模式工作区配置文件(JSON)。属于部署级配置且会被运行时写回, -# 因此指向部署目录 ~/.agents//config(HOST_WORKSPACES_FILE 显式设置时优先)。 HOST_WORKSPACES_FILE = _resolve_repo_path( os.environ.get("HOST_WORKSPACES_FILE", ""), deploy_config_path("host_workspaces.json"), diff --git a/docs/config_refactor_plan.md b/docs/config_refactor_plan.md new file mode 100644 index 0000000..ee05afc --- /dev/null +++ b/docs/config_refactor_plan.md @@ -0,0 +1,522 @@ +# 配置系统改造计划 + +> 版本:v2.0 | 日期:2026-06-13 | 基于 OpenCode 架构分析 + 当前代码现状 + +--- + +## 〇、最终目录结构(已确认) + +### 目录树 + +``` +~/.agents/agents/ ← 数据根目录(data_root) +├── settings.json ← 唯一配置文件 +├── config/ ← 部署级 JSON,两个模式共享 +│ ├── custom_models.json +│ ├── host_workspaces.json +│ ├── host_sandbox_policy.json +│ ├── forbidden_commands.json +│ └── ... +├── host/ ← host 模式(单用户) +│ ├── data/ ← 对话/记忆/用户数据库 +│ └── logs/ +└── web/ ← web 模式(多用户) + ├── users/ ← 每个用户独立工作区 + │ ├── alice/ + │ │ └── data/ ← alice 的对话/记忆 + │ └── bob/ + │ └── data/ + └── logs/ ← web 服务级日志 +``` + +> **注意**:host 模式没有 `users/`(单用户直接走 `data/`);web 模式根下没有 `data/`(数据分散在各 `users//` 下)。 + +### 元配置 vs 普通配置 + +**元配置**(启动前必须知道,不在 `settings.json` 里): + +| 元配置项 | 设定方式 | 默认值 | +|---------|---------|--------| +| `data_root` | CLI `--data-root` / 环境变量 `AGENTS_DATA_ROOT` | `~/.agents/agents` | + +**普通配置**(启动后加载,在 `settings.json` 里): + +| 分类 | 示例 | +|------|------| +| 运行模式 | `terminal.sandbox_mode`(host/web) | +| 服务 | `server.port`、`server.host` | +| 模型 | `models.default_model_key` | +| 终端 | `terminal.execution_mode_default`、沙箱参数 | +| 管理员 | `admin.username`、`admin.password_hash` | +| Flags | `flags.api_dump_enabled` 等 | + +### 启动流程 + +``` +1. 解析 CLI args / 环境变量 → 确定 data_root(默认 ~/.agents/agents) +2. 在 {data_root}/ 下创建目录结构(若不存在) +3. 加载 {data_root}/settings.json +4. 根据 settings.json 中的 sandbox_mode 确定数据子目录(host/ 或 web/) +5. 初始化子目录下的 data/ users/ logs/ +6. 加载 {data_root}/config/ 下的部署级 JSON +7. 启动 Web 服务 +``` + +关键约束: +- `data_root` 是元配置,**不在** `settings.json` 里(否则鸡生蛋) +- `config/` 在根目录,两个模式**共享**(custom_models 不应该切换模式就丢失) +- 首次启动时若 `settings.json` 不存在,使用硬编码默认值 + 自动弹出初始化向导 + +与 OpenCode 对照: +``` +OpenCode: ~/.config/opencode/config.json + ~/.local/share/opencode/ +我们: {data_root}/settings.json + {data_root}/{mode}/ +其中 data_root 默认 ~/.agents/agents +``` + +--- + +## 一、现状诊断 + +### 1.1 架构问题全景图 + +``` +当前启动流程: + ./start.sh + → _bootstrap.sh(创建 venv、pip install、npm ci & build) + → 若 .env 不存在 → python -m scripts.setup(交互式向导,写出 .env) + → exec python -m server.app + → import config(触达 _load_dotenv(),锁定一切 ↓) + + ┌──────────────────────────────────────────────────────────┐ + │ import config 瞬间,以下全部固化为模块级常量: │ + │ │ + │ config/paths.py RUNTIME_ROOT, DATA_DIR, LOGS_DIR, │ + │ USER_SPACE_DIR, DEFAULT_PROJECT_PATH │ + │ config/server.py WEB_SERVER_PORT, WEB_SERVER_HOST │ + │ config/terminal.py TERMINAL_SANDBOX_MODE, ... │ + │ config/auth.py ADMIN_USERNAME, ADMIN_PASSWORD_HASH │ + │ config/model_profiles.py 模型列表 │ + └──────────────────────────────────────────────────────────┘ + +当前目录结构(问题:web 模式数据也落在 host/ 下,分流名存实亡): + ~/.agents/ + ├── host/ ← 实际数据全在这里(因为 .env 写死 host) + │ ├── .env ← ❌ 配置居然放在数据目录里 + │ ├── config/ + │ ├── data/ + │ ├── users/ + │ └── logs/ + └── web/ ← 几乎空,从未被真正使用 +``` + +### 1.2 .env 被 6 处独立读取 + +| # | 文件 | 读取方式 | 问题 | +|---|------|---------|------| +| 1 | `config/__init__.py:_load_dotenv()` | 主加载器,读入 `os.environ` | 唯一的"正规"入口,但逻辑是 .env 不覆盖已有环境变量 | +| 2 | `config/auth.py:_dotenv_cache()` | 直接读 `.env` 文件,有独立缓存 | **优先级反转**:优先 .env 再回退 os.environ | +| 3 | `config/model_profiles.py:_env_optional()` | 先 os.environ,回退读 `.env` | 与主加载器逻辑重复 | +| 4 | `utils/api_client.py:_resolve_env_value()` | 同上 | 同上 | +| 5 | `server/chat_flow_helpers.py:_env_optional()` | 同上 | 同上 | +| 6 | `modules/balance_client.py:_read_dotenv()` | 直接读 `.env`,有独立缓存 | 第 3 份 .env 解析实现 | + +**核心问题**:没有统一的配置来源,6 个模块各有一套 .env 解析逻辑,优先级不一致。 + +### 1.3 历史遗留代码 + +| 文件 | 问题 | +|------|------| +| `web_server.py` | 已标记 deprecated,但仍存在,且是 `main.py` 的实际调用路径 | +| `main.py` | CLI 模式代码是死代码(`setup_run_mode()` 硬编码 `self.web_mode = True`) | +| `server/app.py` | 完全转发 `app_legacy.py`,"逐步替换"从未实现 | +| `server/app_legacy.py` | 1409 行巨型文件,包含了从路由到工具执行的几乎所有逻辑 | + +### 1.4 初始化脚本 + +``` +setup.sh → _bootstrap.sh(共享引导)→ scripts/setup.py(交互向导) +start.sh → _bootstrap.sh(共享引导)→ python -m server.app +``` + +- `scripts/setup.py` 刻意不 `import config`(怕过早锁定路径),说明架构本身有问题 +- 每次启动都走 `ensure_python_env` / `ensure_node_env` + +--- + +## 二、OpenCode 架构参考 + +### 2.1 核心设计原则 + +| 原则 | OpenCode 做法 | +|------|-------------| +| **配置存储** | JSON(C) 文件 + 环境变量,**无 .env 文件** | +| **加载时机** | 运行时 Effect Service,惰性按需加载 | +| **路径标准** | XDG 标准(`~/.config/opencode`、`~/.local/share/opencode`) | +| **配置层级** | 9 层优先级叠加(全局→项目→远程→托管→MDM) | +| **变量引用** | `{env:VAR}` / `{file:path}` 语法 | +| **运行时修改** | `Config.update()` / `Config.updateGlobal()` 写回 JSON | +| **依赖管理** | Effect-TS Layer 依赖注入,服务间松耦合 | + +### 2.2 可借鉴的关键模式 + +**模式 1:运行时 Service 而非模块常量** + +```python +# ❌ 当前做法 +from config import DATA_DIR # import 时固化 + +# ✅ 目标做法 +from config.manager import config_manager +data_dir = config_manager.get("DATA_DIR") # 运行时获取,支持热更新 +``` + +**模式 2:分层配置优先级** + +``` +优先级(高→低): + 运行时 Set(config_manager.set()) +→ 环境变量覆盖 +→ 项目级配置(/.agents/config.json) +→ 全局配置(~/.agents/agents/settings.json) +→ 默认值 +``` + +**模式 3:统一 ConfigManager 单例** + +``` +ConfigManager + ├── load() 从 JSON 文件加载 + ├── get(key) 获取配置值 + ├── set(key, value) 运行时修改 + 持久化 + ├── reload() 重新加载(配置热更新) + ├── subscribe() 变更监听 + └── _resolve_path() 路径解析 +``` + +**模式 4:Flag 系统** + +```python +# 所有 feature flag 集中定义 +class Flags: + DISABLE_AUTOCOMPACT = _flag("AGENT_DISABLE_AUTOCOMPACT") + ENABLE_API_DUMP = _flag("AGENT_API_DUMP_ENABLED") + # ... +``` + +--- + +## 三、改造目标 + +### 3.1 最终状态 + +``` +用户启动 + → python -m server.app [--data-root ~/.agents/agents] + → 解析 data_root(元配置,CLI > 环境变量 > 默认值) + → ConfigManager.initialize(data_root) + → 加载 {data_root}/settings.json + → 解析 sandbox_mode → 确定数据子目录 {data_root}/{mode}/ + → 加载 {data_root}/config/ 下的部署级 JSON + → Web 服务启动 + → 管理后台 UI 可实时修改 settings.json 中的配置 + → 配置变更自动通知相关组件 +``` + +### 3.2 核心原则 + +1. **启动后再配置**:除最低限度启动参数(端口等)外,所有配置可在运行时修改 +2. **单一配置源**:所有模块统一通过 `ConfigManager` 获取配置 +3. **JSON 为主、.env 退役**:配置文件统一为 `{data_root}/settings.json`。.env 仅作为开发环境快捷方式(可选),不再参与正式配置流程 +4. **元配置与普通配置分离**:`data_root` 等必须在加载配置前确定的参数走 CLI/环境变量,其余走 `settings.json` +4. **向后兼容**:分阶段改造,每阶段可独立上线 +5. **最小改动**:先统一读取入口,再逐步改造消费端 + +--- + +## 四、分阶段改造计划 + +### 阶段 1:统一配置源(预计改动文件数:~8) + +**目标**:消除 6 处独立 .env 读取,所有配置从单一入口获取。 + +#### 1.1 新建 `config/manager.py` + +```python +""" +ConfigManager — 统一配置管理器(单例) + +职责: + - 从 JSON 配置文件加载配置 + - .env 仅作为"种子",ConfigManager 加载后统一接管 + - 所有模块通过 ConfigManager 获取配置,不再直接读 .env 或 os.environ +""" + +class ConfigManager: + _instance = None + + def __init__(self): + self._data: dict = {} + self._seeds: dict = {} # 从 .env 一次性读入的种子值 + self._callbacks: list = [] + + # ── 初始化 ── + def initialize(self, mode: str | None = None): + """启动时调用一次。加载 .env 种子 → 合并 JSON 配置 → 应用默认值。""" + self._load_env_seeds() + self._load_json_config(mode) + self._apply_defaults() + + # ── 读取 ── + def get(self, key: str, default=None): + """获取配置值。优先级:运行时 Set > 环境变量 > JSON 配置 > 默认值。""" + + def get_path(self, key: str) -> str: + """获取路径类配置(自动展开 ~、转绝对路径)。""" + + # ── 写入 ── + def set(self, key: str, value): + """运行时修改配置(持久化到 JSON + 通知订阅者)。""" + + # ── 热更新 ── + def reload(self): + """重新加载 JSON 配置(保留运行时 Set 的覆盖)。""" + + def on_change(self, callback): + """注册配置变更回调。""" +``` + +#### 1.2 统一改造点 + +| 当前位置 | 改造方式 | +|---------|---------| +| `config/__init__.py:_load_dotenv()` | 替换为 `config_manager.initialize()` | +| `config/auth.py:_dotenv_cache()` | 改为 `config_manager.get("AGENT_ADMIN_USERNAME")` | +| `config/model_profiles.py:_env_optional()` | 改为 `config_manager.get(...)`,移除 .env 回退 | +| `utils/api_client.py:_resolve_env_value()` | 同上 | +| `server/chat_flow_helpers.py:_env_optional()` | 同上 | +| `modules/balance_client.py:_read_dotenv()` | 同上 | +| `config/terminal.py` | 常量改为从 `config_manager` 获取 | +| `config/server.py` | 同上(端口等可保留环境变量覆盖,但统一入口) | + +#### 1.3 兼容性策略 + +```python +# config/__init__.py 保持向后兼容 +from config.manager import config_manager + +# 旧的 from config import DATA_DIR 仍然可用(通过模块级 __getattr__ 代理) +def __getattr__(name): + return config_manager.get(name) +``` + +#### 1.4 配置文件格式 + +```jsonc +// ~/.agents/agents/settings.json(唯一配置,不分 host/web) +{ + "terminal": { + "sandbox_mode": "host", // "host" | "web" + "execution_mode_default": "sandbox", + "direct_ttl_seconds": 600 + }, + "server": { + "port": 8091, + "host": "127.0.0.1" + }, + "paths": { + "project_path": null // null = ./project(相对于仓库根目录) + }, + "models": { + "default_model_key": null + }, + "flags": { + "api_dump_enabled": false + } +} +``` + +路径解析规则: +- `data_root` 未设置时,默认 `~/.agents/agents` +- `data_root` 设置后,host/web 子目录在其下创建 +- 模式子目录:`{data_root}/{sandbox_mode}/`,如 `~/.agents/agents/host/data/` + +#### 1.5 验证标准 + +- [ ] 删除项目根目录 `.env` 后,程序仍能正常启动(使用默认值) +- [ ] 所有现有功能不受影响 +- [ ] `grep -rn 'parents\[1\].*\.env'` 结果中只剩 `config/manager.py` 和 `scripts/setup.py` + +--- + +### 阶段 2:运行时配置修改(预计改动文件数:~10) + +**目标**:管理后台 UI 可修改配置,无需重启。 + +#### 2.1 ConfigManager 增强 + +```python +class ConfigManager: + def set(self, key: str, value): + """运行时修改配置""" + self._data[key] = value + self._persist() # 写回 JSON + self._notify(key, value) # 通知订阅者 + + def subscribe(self, key: str, callback): + """订阅特定配置项的变更""" + + def get_all(self) -> dict: + """获取完整配置(供管理后台展示)""" +``` + +#### 2.2 前端管理后台新增"系统设置"页面 + +``` +/ 管理后台 + ├── 用户管理 + ├── 模型配置 (已有) + ├── 系统设置 (新增) + │ ├── 运行模式(host/web) + │ ├── 数据目录 + │ ├── Web 服务(端口/地址) + │ ├── 终端沙箱配置 + │ └── 管理员账户 + └── ... +``` + +#### 2.3 不可运行时修改的配置 + +以下配置修改后需要手动重启才能生效,管理后台应有明确提示: + +| 配置项 | 原因 | +|--------|------| +| `server.port` | Flask/SocketIO 已在监听端口 | +| `server.host` | 同上 | +| `terminal.sandbox_mode` | 影响终端管理器初始化 | +| `data_root` | 元配置,启动后数据目录已锁定 | + +#### 2.4 验证标准 + +- [ ] 在管理后台修改模型配置后,新对话立即生效(无需重启) +- [ ] 修改数据目录路径后,新数据写入新位置 +- [ ] 修改 Web 端口时,前端提示"需要重启" + +--- + +### 阶段 3:分层配置 + 项目级覆盖(预计改动文件数:~6) + +**目标**:支持全局配置 + 项目级配置 + 环境变量覆盖。 + +#### 3.1 配置优先级 + +``` +优先级(高→低): + 1. 运行时 Set(config_manager.set(),内存中) + 2. 环境变量覆盖(os.environ) + 3. 项目级配置(/.agents/config.json) + 4. 全局配置(~/.agents//config/settings.json) + 5. 默认值(硬编码) +``` + +#### 3.2 变量引用语法 + +```jsonc +// 配置文件中支持变量引用 +{ + "model": { + "base_url": "{env:AGENT_API_BASE_URL}", + "api_key": "{env:AGENT_API_KEY}" + }, + "prompt": { + "custom": "{file:~/my_prompt.txt}" + } +} +``` + +- `{env:VAR}` — 引用环境变量 +- `{file:path}` — 引用文件内容(`~/` 展开为用户主目录) + +#### 3.3 项目级配置 + +```python +# 当用户选择了工作区后,自动加载项目级配置 +config_manager.load_project_config(workspace_path) +# → 读取 /.agents/config.json +# → 浅层覆盖全局配置 +``` + +#### 3.4 验证标准 + +- [ ] 不同工作区可以有不同配置(如不同默认模型) +- [ ] `{env:VAR}` 语法正常工作 +- [ ] 配置文件缺失时回退到上一级,不报错 + +--- + +### 阶段 4:清理历史遗留(预计改动文件数:~5) + +**目标**:移除屎山代码,精简入口。 + +#### 4.1 清理清单 + +| 文件 | 操作 | +|------|------| +| `web_server.py` | 删除(已是兼容入口,无实际逻辑) | +| `main.py` | 删除 CLI 模式死代码(`setup_project_path`、`setup_run_mode` 等交互式部分),精简为纯 Web 启动 | +| `server/app.py` | 合并 `app_legacy.py` 的核心逻辑,"转发"改为"所有权" | +| `server/app_legacy.py` | 拆分为多个模块(chat/、tasks/、conversation/ 已有蓝图,继续拆分) | + +#### 4.2 标准化启动 + +```bash +# 唯一的启动方式 +python -m server.app [--port 8091] [--host 127.0.0.1] + +# 或保留 start.sh 作为便利脚本 +./start.sh +``` + +移除 `setup.sh` → `start.sh` 的双脚本依赖,合并为一个入口。 + +#### 4.3 验证标准 + +- [ ] `python -m server.app` 正常工作 +- [ ] 旧的 `python web_server.py` 报错提示使用新入口 +- [ ] `python main.py` 仍可工作但输出 deprecation warning + +--- + +## 五、文件变更清单总览 + +| 阶段 | 新增文件 | 修改文件 | 删除文件 | +|------|---------|---------|---------| +| 1 | `config/manager.py` | `config/__init__.py`, `config/auth.py`, `config/model_profiles.py`, `config/paths.py`, `config/terminal.py`, `config/server.py`, `utils/api_client.py`, `server/chat_flow_helpers.py`, `modules/balance_client.py` | — | +| 2 | `static/src/components/admin/SystemSettings.vue`(前端) | `config/manager.py`(增强), `server/` 新增 `/api/admin/settings` 路由 | — | +| 3 | — | `config/manager.py`(增强分层加载、变量解析) | — | +| 4 | — | `main.py`(精简), `server/app.py`(接管) | `web_server.py` | + +--- + +## 六、风险控制 + +1. **每阶段独立可上线**:阶段 1 完成后即可合并,不影响阶段 2-4 的进度 +2. **向后兼容**:阶段 1 保留 `from config import DATA_DIR` 语法,通过 `__getattr__` 代理 +3. **回滚安全**:配置文件备份机制(`settings.json.bak_`) +4. **渐进式清理**:先统一入口,再逐个模块迁移,不搞大爆炸重构 + +--- + +## 七、OpenCode vs 我们的对照速查表 + +| 概念 | OpenCode | 我们的目标 | +|------|---------|-----------| +| 全局配置路径 | `~/.config/opencode/config.json` | `{data_root}/settings.json`(默认 `~/.agents/agents/settings.json`) | +| 项目配置 | `/opencode.json(c)` 向上查找 | `/.agents/config.json` | +| 配置 Service | `Config.Service` (Effect-TS) | `ConfigManager` (Python 单例) | +| 变量引用 | `{env:VAR}` / `{file:path}` | 同 | +| 运行时修改 | `Config.update()` / `Config.updateGlobal()` | `config_manager.set()` | +| Flag 系统 | `Flag.OPENCODE_*` 集中定义 | `config/flags.py` 集中定义 | +| 分层加载 | 9 层 mergeDeep | 4 层优先级 | +| 路径标准 | XDG (`xdg-basedir`) | `{data_root}/` + `{mode}/`,data_root 由 CLI/环境变量指定 | +| 初始化入口 | CLI handler → Runtime → Bootstrap → Config.get() | `server.app` → `config_manager.initialize()` → 服务启动 | diff --git a/modules/balance_client.py b/modules/balance_client.py index 9eed330..5161cc6 100644 --- a/modules/balance_client.py +++ b/modules/balance_client.py @@ -22,37 +22,14 @@ from pathlib import Path from typing import Any, Dict, Tuple from urllib import parse, request -# -------- .env 读取助手 -------- -_DOTENV_CACHE: Dict[str, str] | None = None - - -def _read_dotenv() -> Dict[str, str]: - global _DOTENV_CACHE - if _DOTENV_CACHE is not None: - return _DOTENV_CACHE - path = Path(__file__).resolve().parents[1] / ".env" - result: Dict[str, str] = {} - if path.exists(): - try: - for raw_line in path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip('"').strip("'") - if key: - result[key] = value - except Exception: - pass - _DOTENV_CACHE = result - return result +# -------- .env 读取助手(简化版,已由 config/__init__.py 统一注入) -------- def _env(key: str, default: str | None = None) -> str | None: + """从环境变量获取配置。""" if key in os.environ: return os.environ[key] - return _read_dotenv().get(key, default) + return default USD_CNY_RATE = float(_env("USD_CNY_RATE", "7.1") or "7.1") diff --git a/server/chat_flow_helpers.py b/server/chat_flow_helpers.py index d5b2d69..c5309bf 100644 --- a/server/chat_flow_helpers.py +++ b/server/chat_flow_helpers.py @@ -33,21 +33,8 @@ def _title_debug_log(message: str, **extra: Any) -> None: def _env_optional(name: str) -> Optional[str]: + """从环境变量获取可选配置(已由 config/__init__.py 统一注入)。""" value = os.environ.get(name) - if value is None: - env_path = Path(__file__).resolve().parents[1] / ".env" - if env_path.exists(): - try: - for raw_line in env_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, val = line.split("=", 1) - if key.strip() == name: - value = val.strip().strip('"').strip("'") - break - except Exception: - value = None if value is None: return None value = value.strip() diff --git a/utils/api_client.py b/utils/api_client.py index 840c82a..2193b91 100644 --- a/utils/api_client.py +++ b/utils/api_client.py @@ -95,21 +95,8 @@ class DeepSeekClient: # legacy name; generic OpenAI-compatible client pass def _resolve_env_value(self, name: str) -> Optional[str]: + """从环境变量获取配置(已由 config/__init__.py 统一注入)。""" value = os.environ.get(name) - if value is None: - env_path = Path(__file__).resolve().parents[1] / ".env" - if env_path.exists(): - try: - for raw_line in env_path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, val = line.split("=", 1) - if key.strip() == name: - value = val.strip().strip('"').strip("'") - break - except Exception: - value = None if value is None: return None value = value.strip()