refactor(config): 统一配置源,迁移数据到 ~/.agents/agents/

- config/paths.py: data_root 改为 ~/.agents/agents,config/ 提至根目录共享
- config/__init__.py: 统一加载 .env + settings.json,注入 legacy 环境变量
- 移除 4 处独立 .env 读取 (auth/model_profiles/api_client/chat_flow/balance)
- 新增 docs/config_refactor_plan.md 完整改造计划
- 数据已从 ~/.agents/host/ 迁移至 ~/.agents/agents/
This commit is contained in:
JOJO 2026-06-13 21:37:02 +08:00
parent 50e49d60ec
commit f43025047e
8 changed files with 676 additions and 154 deletions

View File

@ -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_<KEY> 环境变量 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_varsAPI 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()

View File

@ -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",

View File

@ -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()

View File

@ -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/ 部署级 JSONhost/web 模式共享
host/ host 模式单用户
data/ 对话/记忆/数据库
logs/
web/ web 模式多用户
users/ 每用户独立工作区
<name>/data/
logs/
路径解析优先级从高到低
1. 具体目录环境变量``DATA_DIR`` / ``LOGS_DIR`` / ``USER_SPACE_DIR`` /
``API_USER_SPACE_DIR``单独覆盖某个目录最高优先级
2. 当前模式的根目录环境变量``AGENTS_HOST_HOME`` ``AGENTS_WEB_HOME``
整体搬迁该模式下的全部运行态数据
3. 兜底默认值 ``~/.agents/<mode>``
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/<mode>/config/`` ``DEPLOY_CONFIG_DIR``读取走
``<data_root>/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>。
_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/<mode> 下,可被各自的环境变量覆盖)──
# ── 运行态目录(默认落在 <data_root>/<mode>/ 下)──
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/<mode>/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/<mode>/config`` 下)。
"""部署级配置文件应写入/读取的位置(``<data_root>/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/<mode>/config/<filename>``部署者真实配置
1. ``<data_root>/config/<filename>``部署者真实配置
2. 源码树 ``config/<filename>``仓库内种子
3. 源码树 ``config/<filename>.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/<mode>/configHOST_WORKSPACES_FILE 显式设置时优先)。
HOST_WORKSPACES_FILE = _resolve_repo_path(
os.environ.get("HOST_WORKSPACES_FILE", ""),
deploy_config_path("host_workspaces.json"),

View File

@ -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/<name>/` 下)。
### 元配置 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分层配置优先级**
```
优先级(高→低):
运行时 Setconfig_manager.set()
→ 环境变量覆盖
→ 项目级配置(<workspace>/.agents/config.json
→ 全局配置(~/.agents/agents/settings.json
→ 默认值
```
**模式 3统一 ConfigManager 单例**
```
ConfigManager
├── load() 从 JSON 文件加载
├── get(key) 获取配置值
├── set(key, value) 运行时修改 + 持久化
├── reload() 重新加载(配置热更新)
├── subscribe() 变更监听
└── _resolve_path() 路径解析
```
**模式 4Flag 系统**
```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. 运行时 Setconfig_manager.set(),内存中)
2. 环境变量覆盖os.environ
3. 项目级配置(<workspace>/.agents/config.json
4. 全局配置(~/.agents/<mode>/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)
# → 读取 <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_<timestamp>`
4. **渐进式清理**:先统一入口,再逐个模块迁移,不搞大爆炸重构
---
## 七、OpenCode vs 我们的对照速查表
| 概念 | OpenCode | 我们的目标 |
|------|---------|-----------|
| 全局配置路径 | `~/.config/opencode/config.json` | `{data_root}/settings.json`(默认 `~/.agents/agents/settings.json` |
| 项目配置 | `<cwd>/opencode.json(c)` 向上查找 | `<workspace>/.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()` → 服务启动 |

View File

@ -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")

View File

@ -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()

View File

@ -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()