refactor(config): 移除硬编码模型残留,部署级配置外置到 ~/.agents

## 模型逻辑清理
早期把模型 API 端点/密钥/模型 ID 硬编码的残留(AGENT_API_* / THINKING_* /
TITLE_* 三件套)已彻底移除。模型配置统一由 config/custom_models.json
(经 model_profiles 解析为档案)描述,运行时通过 apply_profile 注入;
没有任何可用模型时按既定行为报错。

- config/api.py: 删 9 个三件套符号,仅保留 DEFAULT_RESPONSE_MAX_TOKENS
- utils/api_client.py: client 改为空配置起步,移除三件套 import
- server/chat_flow_helpers.py: 删死 import(TITLE_* 符号)

## 部署级配置外置(按"决策权归谁"分类)
config/*.json 不再一概锚定源码树:
- 程序能力(docker_risk_markers / skill_hints)留源码树,随版本演进
- 部署者自定义(custom_models / host_workspaces / auto_approval /
  goal_review / forbidden_commands / host_sandbox_policy)外置到
  ~/.agents/<mode>/config/

新增统一解析机制(config/paths.py):
- DEPLOY_CONFIG_DIR(默认 ~/.agents/<mode>/config,可用环境变量覆盖)
- resolve_deploy_config():只读,回退链 部署目录→源码树.json→源码树.example
  (开发环境不必先跑 setup 也能用源码树种子)
- deploy_config_path():写回用,稳定指向部署目录

6 个加载点改用上述解析;顺带修复 approval_agent / goal_review_agent 的
DEBUG_TRANSCRIPT_DIR 仍指旧源码树 logs/ 的漏迁问题。

## 安全与运维
- 含密钥/机器特定的 5 个文件停止 git 追踪(git rm --cached,本地保留)
  并加入 .gitignore,仓库仅留 .example 种子;forbidden_commands 保留
  追踪作默认黑名单
- scripts/setup.py: 模型配置写到部署目录(用与 paths 一致的独立推导,
  不 import config 以免过早锁定路径)
- scripts/migrate_runtime_data.py: 新增 config/*.json → 部署目录迁移
  (备份 + 不覆盖已存在)

## 关联:P1 配置收敛 + P2 首启向导
- config/server.py: Web 端口/监听地址/debug/NODE_BIN/PYTHON_BIN 单一事实源
- 消灭 8091 多处重复定义(state.py 死代码、app_legacy、main.py 各读各的)
- 修复 sub_agent_manager 命令数组硬编码 "node" → NODE_BIN(便携包内置
  Node 的前提)
- scripts/setup.py: 终端首启向导(模式/端口/管理员/密钥/模型)

## 测试
test_config_paths_resolution 更新以反映新行为(host_workspaces 锚定部署
目录、新增 DEPLOY_CONFIG_DIR 用例);全部离线用例通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-06-01 16:59:34 +08:00
parent eaa5e3bee9
commit b8a51c1c63
27 changed files with 599 additions and 95 deletions

View File

@ -31,6 +31,20 @@ AGENT_ADMIN_PASSWORD_HASH=
# === Web Session =============================================================
WEB_SECRET_KEY=replace-with-random-hex
# === Web 服务 ================================================================
# 监听端口统一来源main.py / server.app / state 均读取此项)
WEB_SERVER_PORT=8091
# 监听地址:多用户/服务器用 0.0.0.0;单机便携包建议 127.0.0.1 只听本机
WEB_SERVER_HOST=0.0.0.0
# 调试模式(同时控制 Flask reloader生产保持 0
WEB_SERVER_DEBUG=0
# === 运行环境二进制(便携 release 包用,开发环境留默认即可)==================
# 子智能体使用的 node 可执行文件;留 "node" 表示走系统 PATH
AGENT_NODE_BIN=node
# Python 解释器路径;留空表示使用当前进程的 sys.executable
AGENT_PYTHON_BIN=
# === 终端容器沙箱 =============================================================
# 模式docker / host
TERMINAL_SANDBOX_MODE=docker

5
.gitignore vendored
View File

@ -40,9 +40,12 @@ _experiments/
# Ignore docs
doc/
# Host workspace config (local, use .example)
# 部署级配置(含密钥引用 / 机器特定,本地保留,仓库仅留 .example 种子)
config/host_workspaces.json
config/custom_models.json
config/auto_approval.json
config/goal_review.json
config/host_sandbox_policy.json
# Claude Code local artifacts (settings, worktrees)
.claude/

View File

@ -55,7 +55,9 @@
具体目录变量支持相对路径(相对仓库根目录展开)、绝对路径与 `~`
> 注意:`config/*.json``custom_models.json`、`host_workspaces.json`、`auto_approval.json` 等)属于**配置而非运行态数据**,仍锚定源码树,不随运行态根目录迁移。`prompts/`、`agentskills/` 同理。
> 注意:`config/*.json` 分两类:
> - **程序能力**`docker_risk_markers.json`、`skill_hints.json`):是程序行为的一部分,随版本演进,仍锚定源码树。`prompts/`、`agentskills/` 同理。
> - **部署级配置**`custom_models`、`host_workspaces`、`auto_approval`、`goal_review`、`forbidden_commands`、`host_sandbox_policy`):因部署/机器而异或含密钥,外置到 `~/.agents/<mode>/config/`(即 `DEPLOY_CONFIG_DIR`,可单独用该环境变量覆盖)。读取走 `config.resolve_deploy_config(name)`,回退链:部署目录 → 源码树 `.json` → 源码树 `.json.example`,因此开发环境不必先跑 setup 也能用源码树种子。含密钥/机器特定的 5 个(除 `forbidden_commands`)不纳入 git仓库仅留 `.example`
### 1.5.3 日志策略2026-06

View File

@ -159,7 +159,7 @@ tail -f ~/.agents/web/logs/container_stats.log
2. 模式根变量 `AGENTS_HOST_HOME` / `AGENTS_WEB_HOME`(整体搬迁该模式数据)
3. 兜底 `~/.agents/<mode>`
> `config/*.json`、`prompts/`、`agentskills/` 属于配置/资源,仍锚定源码树,不随运行态迁移
> `config/*.json` 分两类:**程序能力**`docker_risk_markers.json`、`skill_hints.json`)与 `prompts/`、`agentskills/` 一样锚定源码树;**部署级配置**`custom_models`、`host_workspaces`、`auto_approval`、`goal_review`、`forbidden_commands`、`host_sandbox_policy`)外置到 `~/.agents/<mode>/config/``DEPLOY_CONFIG_DIR`),读取走 `config.resolve_deploy_config(name)`,回退链:部署目录 → 源码树 `.json` → 源码树 `.json.example`。含密钥/机器特定的 5 个不进 git仓库仅留 `.example`
**日志策略**:
- API 请求体 dump 默认**关闭**`AGENT_API_DUMP_ENABLED=1` 开启。

View File

@ -50,6 +50,7 @@ from . import uploads as _uploads
from . import sub_agent as _sub_agent
from . import custom_tools as _custom_tools
from . import mcp as _mcp
from . import server as _server
from .api import *
from .search import *
@ -67,9 +68,10 @@ from .uploads import *
from .sub_agent import *
from .custom_tools import *
from .mcp import *
from .server import *
__all__ = []
for module in (_api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp):
for module in (_api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp, _server):
__all__ += getattr(module, "__all__", [])
del _api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp
del _api, _search, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp, _server

View File

@ -1,43 +1,19 @@
"""API 和外部服务配置。"""
"""API 和外部服务配置。
模型的 API 端点 / 密钥 / 模型 ID 现已统一由 ``config/custom_models.json``
``config/model_profiles.py`` 解析为模型档案描述并在运行时通过
``DeepSeekClient.apply_profile`` 注入旧的 ``AGENT_API_BASE_URL`` /
``AGENT_API_KEY`` / ``AGENT_MODEL_ID``以及 THINKING / TITLE 三件套属于早期
把模型逻辑硬编码在程序里的残留已移除没有任何可用模型时
``model_profiles.get_default_model_key`` 会直接报错提示去配置模型
"""
import os
def _env(name: str, default: str = "", required: bool = False) -> str:
value = os.environ.get(name, default)
if required and not value:
raise RuntimeError(f"缺少必要环境变量: {name}")
return value
API_BASE_URL = _env("AGENT_API_BASE_URL", "https://api.example.com")
API_KEY = _env("AGENT_API_KEY", "")
MODEL_ID = _env("AGENT_MODEL_ID", "")
# 推理模型配置(智能思考模式使用)
THINKING_API_BASE_URL = _env("AGENT_THINKING_API_BASE_URL", API_BASE_URL)
THINKING_API_KEY = _env("AGENT_THINKING_API_KEY", API_KEY)
THINKING_MODEL_ID = _env("AGENT_THINKING_MODEL_ID", MODEL_ID)
from .search import TAVILY_API_KEY
# 对话标题生成模型(可选,留空则回退到基础快速模型)
TITLE_API_BASE_URL = _env("AGENT_TITLE_API_BASE_URL", API_BASE_URL)
TITLE_API_KEY = _env("AGENT_TITLE_API_KEY", API_KEY)
TITLE_MODEL_ID = _env("AGENT_TITLE_MODEL_ID", MODEL_ID)
# 默认响应 token 限制
# 默认响应 token 限制(与具体模型无关,保留为全局上限)
DEFAULT_RESPONSE_MAX_TOKENS = int(os.environ.get("AGENT_DEFAULT_RESPONSE_MAX_TOKENS", "32768"))
__all__ = [
"API_BASE_URL",
"API_KEY",
"MODEL_ID",
"DEFAULT_RESPONSE_MAX_TOKENS",
"THINKING_API_BASE_URL",
"THINKING_API_KEY",
"THINKING_MODEL_ID",
"TITLE_API_BASE_URL",
"TITLE_API_KEY",
"TITLE_MODEL_ID",
]

View File

@ -1,8 +1,8 @@
{
"name": "auto-approval-agent",
"url": "https://opencode.ai/zen/go/v1",
"url": "https://api.example.com/v1",
"key": "${API_KEY_DEEPSEEK}",
"model": "deepseek-v4-flash",
"model": "your-model-id",
"extra_params": {
"thinking": {
"type": "enabled"

View File

@ -1,8 +1,8 @@
{
"name": "goal-review-agent",
"url": "https://opencode.ai/zen/go/v1",
"url": "https://api.example.com/v1",
"key": "${API_KEY_DEEPSEEK}",
"model": "deepseek-v4-flash",
"model": "your-model-id",
"extra_params": {
"thinking": {
"type": "enabled"

View File

@ -1,8 +0,0 @@
{
"macos_writable_paths": [
"/Users/jojo/Desktop"
],
"macos_readable_extra_paths": [
"/Users/jojo/Desktop"
]
}

View File

@ -0,0 +1,4 @@
{
"macos_writable_paths": [],
"macos_readable_extra_paths": []
}

View File

@ -3,6 +3,8 @@ import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from .paths import resolve_deploy_config
def _env_optional(name: str) -> Optional[str]:
value = os.environ.get(name)
@ -160,7 +162,7 @@ def _build_custom_profile(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def _load_custom_models() -> Dict[str, Dict[str, Any]]:
custom_path = Path(__file__).resolve().parent / "custom_models.json"
custom_path = Path(resolve_deploy_config("custom_models.json"))
if not custom_path.exists():
return {}
try:

View File

@ -16,8 +16,16 @@
整体搬迁该模式下的全部运行态数据
3. 兜底默认值 ``~/.agents/<mode>``
注意``config/*.json``custom_modelshost_workspacesauto_approval 属于
配置而非运行态数据仍锚定在源码树内不随运行态根目录迁移
注意``config/*.json`` 分两类不再一概锚定源码树
- **开发者配置 / 程序能力**``docker_risk_markers.json`` / ``skill_hints.json``
是程序行为的一部分随版本演进仍锚定源码树
- **部署者自定义配置**``custom_models`` / ``host_workspaces`` /
``auto_approval`` / ``goal_review`` / ``forbidden_commands`` /
``host_sandbox_policy``因部署/机器而异或含密钥外置到
``~/.agents/<mode>/config/`` ``DEPLOY_CONFIG_DIR``读取走
``resolve_deploy_config``回退链为部署目录 -> 源码树 ``.json`` ->
源码树 ``.json.example``这样开发环境不必先跑 setup 也能用源码树种子
"""
import os
@ -85,6 +93,44 @@ 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")
# 源码树内的配置种子目录(首次运行/开发环境的默认内容来源)。
_SEED_CONFIG_DIR = _REPO_ROOT / "config"
def deploy_config_path(filename: str) -> str:
"""部署级配置文件应写入/读取的位置(``~/.agents/<mode>/config`` 下)。
用于会被运行时写回的文件 host_workspaces / host_sandbox_policy
始终指向部署目录不回退源码树
"""
return str(Path(DEPLOY_CONFIG_DIR) / filename)
def resolve_deploy_config(filename: str) -> str:
"""解析部署级配置的只读路径,回退链(高→低):
1. ``~/.agents/<mode>/config/<filename>``部署者真实配置
2. 源码树 ``config/<filename>``仓库内种子
3. 源码树 ``config/<filename>.example``示例兜底
都不存在时返回部署目录下的目标路径由调用方处理文件不存在
仅用于定位不在此做拷贝拷贝交给 setup / 迁移脚本
"""
deployed = Path(DEPLOY_CONFIG_DIR) / filename
if deployed.exists():
return str(deployed)
seed = _SEED_CONFIG_DIR / filename
if seed.exists():
return str(seed)
example = _SEED_CONFIG_DIR / f"{filename}.example"
if example.exists():
return str(example)
return str(deployed)
# ── 启动工作区 ──
# 默认项目路径,可通过环境变量覆盖以指向宿主机任意目录。
DEFAULT_PROJECT_PATH = _resolve_repo_path(os.environ.get("DEFAULT_PROJECT_PATH", ""), "./project")
@ -95,8 +141,12 @@ HOST_PROJECT_PATH = _resolve_repo_path(os.environ.get("HOST_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属于配置锚定源码树
HOST_WORKSPACES_FILE = _resolve_repo_path(os.environ.get("HOST_WORKSPACES_FILE", ""), "./config/host_workspaces.json")
# 宿主机模式工作区配置文件JSON。属于部署级配置且会被运行时写回
# 因此指向部署目录 ~/.agents/<mode>/configHOST_WORKSPACES_FILE 显式设置时优先)。
HOST_WORKSPACES_FILE = _resolve_repo_path(
os.environ.get("HOST_WORKSPACES_FILE", ""),
deploy_config_path("host_workspaces.json"),
)
# ── 基于 DATA_DIR 派生的数据文件 ──
USERS_DB_FILE = f"{DATA_DIR}/users.json"
@ -124,4 +174,7 @@ __all__ = [
"API_USERS_DB_FILE",
"API_TOKENS_FILE",
"API_USAGE_FILE",
"DEPLOY_CONFIG_DIR",
"deploy_config_path",
"resolve_deploy_config",
]

View File

@ -3,9 +3,15 @@
from pathlib import Path
import json
from .paths import resolve_deploy_config
def _load_forbidden_commands() -> list[str]:
"""从独立 JSON 文件加载命令拦截关键词。"""
"""从独立 JSON 文件加载命令拦截关键词。
属于部署级配置部署者可调宽松/严苛优先读 ~/.agents/<mode>/config
回退源码树种子
"""
fallback = [
"rm -rf /",
"rm -rf ~",
@ -15,7 +21,7 @@ def _load_forbidden_commands() -> list[str]:
"kill -9",
"dd if=",
]
cfg_path = Path(__file__).resolve().parent / "forbidden_commands.json"
cfg_path = Path(resolve_deploy_config("forbidden_commands.json"))
if not cfg_path.exists():
return fallback
try:

42
config/server.py Normal file
View File

@ -0,0 +1,42 @@
"""Web 服务与运行环境配置(单一事实源)。
集中所有"部署/运行"相关配置避免端口监听地址运行二进制路径等散落多处
各读各的全项目应统一从 ``config`` 导入这里导出的常量禁止再硬编码
解析优先级环境变量 > 此处默认值命令行参数 ``--port``可在入口层再覆盖
"""
import os
def _env(name: str, default: str = "") -> str:
return str(os.environ.get(name, "") or "").strip() or default
def _flag(name: str, default: str = "0") -> bool:
return _env(name, default).lower() in ("1", "true", "yes", "on")
# === Web 服务 ===============================================================
# 监听端口(历史上散落在 main.py / server.app_legacy / server.state / cli
WEB_SERVER_PORT = int(_env("WEB_SERVER_PORT", "8091"))
# 监听地址:多用户/服务器用 0.0.0.0;单机便携包建议设 127.0.0.1 只听本机。
WEB_SERVER_HOST = _env("WEB_SERVER_HOST", "0.0.0.0")
# 调试模式(同时控制 Flask reloader
WEB_SERVER_DEBUG = _flag("WEB_SERVER_DEBUG", "0")
# === 运行环境二进制 =========================================================
# 便携 release 包启动脚本会注入这两个绝对路径,使主程序与子智能体使用内置运行时。
# 留空/默认时回退到系统 PATH 上的 node开发环境或系统已安装时
NODE_BIN = _env("AGENT_NODE_BIN", "node")
# Python 解释器路径;留空表示使用当前进程的 sys.executable。
PYTHON_BIN = _env("AGENT_PYTHON_BIN", "")
__all__ = [
"WEB_SERVER_PORT",
"WEB_SERVER_HOST",
"WEB_SERVER_DEBUG",
"NODE_BIN",
"PYTHON_BIN",
]

View File

@ -195,7 +195,7 @@ class AgentSystem:
print(f"\n{OUTPUT_FORMATS['success']} 正在启动Web服务器...")
print(f"{OUTPUT_FORMATS['info']} 项目路径: {self.project_path}")
port = int(os.environ.get("WEB_SERVER_PORT", "8091"))
port = WEB_SERVER_PORT
# 运行服务器(这会阻塞)
run_server(

View File

@ -9,13 +9,16 @@ from typing import Any, Callable, Dict, List, Optional
import httpx
from config import resolve_deploy_config, LOGS_DIR
CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "auto_approval.json"
# 部署级配置(审批智能体 url/key/model含密钥引用→ 优先 ~/.agents/<mode>/config
CONFIG_PATH = Path(resolve_deploy_config("auto_approval.json"))
DEFAULT_MAX_ROUNDS = 3
DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_MAX_COMMAND_TIMEOUT = 20
DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT = True
DEBUG_TRANSCRIPT_DIR = Path(__file__).resolve().parents[1] / "logs" / "approval_agent"
DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "approval_agent"
def _resolve_env_token(value: str) -> str:

View File

@ -23,16 +23,17 @@ from typing import Any, Callable, Dict, List, Optional
import httpx
from config import PROMPTS_DIR
from config import PROMPTS_DIR, resolve_deploy_config, LOGS_DIR
from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "goal_review.json"
# 部署级配置(目标审核智能体 url/key/model含密钥引用→ 优先 ~/.agents/<mode>/config
CONFIG_PATH = Path(resolve_deploy_config("goal_review.json"))
PROMPT_NAME = "goal_review_agent.txt"
DEFAULT_MAX_ROUNDS = 3
DEFAULT_TIMEOUT_SECONDS = 60
DEFAULT_MAX_COMMAND_TIMEOUT = 60
DEBUG_SAVE_GOAL_REVIEW_TRANSCRIPT = True
DEBUG_TRANSCRIPT_DIR = Path(__file__).resolve().parents[1] / "logs" / "goal_review_agent"
DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "goal_review_agent"
# 兜底续命消息(审核智能体未能产出明确结论时使用)
FALLBACK_CONTINUE_MESSAGE = "审核未能给出明确结论。请重新对照目标核查当前进度,找出尚未完成的部分并继续推进。"

View File

@ -5,10 +5,11 @@ import threading
from pathlib import Path
from typing import Dict, List
from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS
from config import HOST_SANDBOX_MACOS_WRITABLE_PATHS, deploy_config_path
_LOCK = threading.Lock()
_POLICY_PATH = Path(__file__).resolve().parents[1] / "config" / "host_sandbox_policy.json"
# 部署级配置(机器特定可读写路径,会被运行时写回)→ ~/.agents/<mode>/config
_POLICY_PATH = Path(deploy_config_path("host_sandbox_policy.json"))
def _ensure_file() -> None:

View File

@ -19,6 +19,7 @@ from config import (
SUB_AGENT_STATE_FILE,
SUB_AGENT_STATUS_POLL_INTERVAL,
SUB_AGENT_TASKS_BASE_DIR,
NODE_BIN,
)
from utils.logger import setup_logger
import logging
@ -382,7 +383,7 @@ class SubAgentManager:
thinking_mode: Optional[str],
) -> List[str]:
cmd = [
"node",
NODE_BIN,
batch_path,
"--workspace", workspace_path,
"--task-file", task_file,

1
scripts/__init__.py Normal file
View File

@ -0,0 +1 @@
"""运维与初始化脚本包。"""

View File

@ -14,6 +14,11 @@
先打包备份到 ``<运行态根上级>/backup_<时间戳>.tar.gz``
校验备份成功后再复制到目标位置复制采用不覆盖已存在目标
保守策略全程复制而非移动旧目录保留便于回滚
- 部署级配置文件custom_models / host_workspaces / auto_approval / goal_review /
forbidden_commands / host_sandbox_policy
从源码树 ``config/`` 复制到部署配置目录 ``~/.agents/<mode>/config/``
同样先备份不覆盖已存在目标docker_risk_markersskill_hints 属于
程序能力保留源码树不迁
本脚本只做复制 + 备份不删除任何源数据确保完全可回滚
确认新目录工作正常后可自行手动删除源码树内的旧目录
@ -52,6 +57,30 @@ def _migrate_targets() -> dict[str, Path]:
}
# 部署级配置文件:源码树 config/<name> -> 部署配置目录 ~/.agents/<mode>/config/<name>。
# 仅迁移部署者自定义类(含密钥/机器特定/可调策略docker_risk_markers、skill_hints
# 属于程序能力,保留源码树,不迁。
_DEPLOY_CONFIG_FILES = (
"custom_models.json",
"host_workspaces.json",
"auto_approval.json",
"goal_review.json",
"forbidden_commands.json",
"host_sandbox_policy.json",
)
def _migrate_config_files() -> list[tuple[Path, Path]]:
"""返回 (源码树配置文件, 部署目录目标) 列表,仅含实际存在的源文件。"""
deploy_dir = Path(config.DEPLOY_CONFIG_DIR)
pairs: list[tuple[Path, Path]] = []
for name in _DEPLOY_CONFIG_FILES:
src = REPO_ROOT / "config" / name
if src.is_file():
pairs.append((src, deploy_dir / name))
return pairs
def _human_size(num: int) -> str:
value = float(num)
for unit in ("B", "KB", "MB", "GB", "TB"):
@ -111,6 +140,17 @@ def _copy_tree(src: Path, dst: Path, *, dry_run: bool) -> None:
shutil.copytree(src, dst)
def _copy_file(src: Path, dst: Path, *, dry_run: bool) -> None:
if dst.exists():
print(f"[skip] 目标已存在,跳过以避免覆盖:{dst}")
return
print(f"[copy] {src} -> {dst} ({_human_size(src.stat().st_size)})")
if dry_run:
return
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def main() -> int:
parser = argparse.ArgumentParser(description="迁移运行态数据到运行态根目录(~/.agents/<mode>")
parser.add_argument("--dry-run", action="store_true", help="仅打印将要执行的操作,不实际写入")
@ -132,19 +172,27 @@ def main() -> int:
else:
print(f"[absent] 源目录不存在或为空,跳过:{src}")
if not present:
# 收集需迁移的部署级配置文件(源码树 config/*.json -> 部署目录)
config_files = _migrate_config_files()
for src, dst in config_files:
print(f"[config] 待迁移配置:{src.name} -> {dst}")
if not config_files:
print("[config] 无部署级配置文件需迁移。")
if not present and not config_files:
print("没有需要迁移的运行态数据。")
return 0
# 1) 备份(备份到运行态根的上级目录,与运行态根同级)
backup_root = runtime_root.parent
backup_sources = [s for s, _ in present] + [s for s, _ in config_files]
try:
_backup([s for s, _ in present], backup_root, dry_run=args.dry_run)
_backup(backup_sources, backup_root, dry_run=args.dry_run)
except Exception as exc:
print(f"[error] 备份失败,已中止迁移(未改动任何数据):{exc}", file=sys.stderr)
return 1
# 2) 复制(不覆盖已有目标,源目录保留)
# 2) 复制(不覆盖已有目标,源保留)
for src, dst in present:
try:
_copy_tree(src, dst, dry_run=args.dry_run)
@ -152,6 +200,13 @@ def main() -> int:
print(f"[error] 复制失败:{src} -> {dst}{exc}", file=sys.stderr)
print(" 已迁移的部分保留,源数据未删除,可重跑或手动处理。", file=sys.stderr)
return 1
for src, dst in config_files:
try:
_copy_file(src, dst, dry_run=args.dry_run)
except Exception as exc:
print(f"[error] 复制配置失败:{src} -> {dst}{exc}", file=sys.stderr)
print(" 已迁移的部分保留,源文件未删除,可重跑或手动处理。", file=sys.stderr)
return 1
print("-" * 60)
if args.dry_run:

329
scripts/setup.py Normal file
View File

@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""首次启动初始化向导(终端交互)。
在启动 Web 服务之前于终端一次性设置好"必须在程序启动前确定"的内容
并写出项目根目录的 ``.env``
- 运行模式 host / web决定数据目录与子容器策略 import config 时即锁定
- Web 监听端口 / 地址服务 listen 前必须确定
- 管理员账号用户名 + 密码哈希 user_manager._ensure_admin_user 兼容
- 会话与 Token 密钥自动随机生成不打扰用户
- 基础 API 三件套base_url / key / model_id
- 可选的 thinking / title 模型
设计要点
* **单一线性流程**逐项询问每项带默认值回车即取默认
* **不联网校验**API 配置只写入不当场测试连通性
* ** .env.example 为模板**逐行读取模板仅替换被本向导接管的键的值
其余行包括全部注释与未涉及的配置项原样保留保证生成的 .env
仍带完整文档也不会丢任何配置开关
* ** import config**向导在 .env 尚不存在时运行且模式选择会影响路径解析
因此刻意不依赖 config避免过早锁定路径或触发 .env 加载
用法
python -m scripts.setup # 交互式向导
python scripts/setup.py # 同上
python -m scripts.setup --force # 跳过"已存在 .env"的确认(仍会备份)
"""
from __future__ import annotations
import argparse
import json
import secrets
import shutil
import sys
import time
from getpass import getpass
from pathlib import Path
try:
from werkzeug.security import generate_password_hash
except ImportError: # pragma: no cover - 缺依赖时给出可读提示
print("缺少依赖 werkzeug请先安装pip install -r requirements.txt")
sys.exit(1)
REPO_ROOT = Path(__file__).resolve().parents[1]
ENV_PATH = REPO_ROOT / ".env"
ENV_EXAMPLE_PATH = REPO_ROOT / ".env.example"
def _deploy_config_dir(mode: str, custom_data: str) -> Path:
"""推导部署级配置目录(~/.agents/<mode>/config
必须与 config/paths.py 的解析保持一致
- 自定义数据根向导里填的优先作为运行态根目录
- 否则默认 ~/.agents/<mode>
- 配置目录 = 运行态根 / config
setup 刻意不 import config避免在 .env 写好前过早锁定路径
因此这里用向导现场收集到的 mode / custom_data 自行推导
"""
root = Path(custom_data).expanduser() if custom_data else (Path("~/.agents").expanduser() / mode)
return (root / "config").resolve()
# === 终端交互辅助 ===========================================================
def _ask(prompt: str, default: str = "") -> str:
"""询问一项回车取默认。default 非空时在提示里展示。"""
suffix = f" [{default}]" if default else ""
try:
raw = input(f"{prompt}{suffix}: ").strip()
except EOFError:
raw = ""
return raw or default
def _ask_choice(prompt: str, choices: list[str], default: str) -> str:
"""从有限选项中选择,输入不在集合内则重问。"""
choices_lower = {c.lower(): c for c in choices}
hint = "/".join(choices)
while True:
raw = _ask(f"{prompt}{hint}", default).lower()
if raw in choices_lower:
return choices_lower[raw]
print(f" 请输入 {hint} 之一。")
def _ask_required(prompt: str, default: str = "") -> str:
"""必填项,留空则重问。"""
while True:
value = _ask(prompt, default)
if value:
return value
print(" 此项必填,不能为空。")
def _ask_password(prompt: str) -> str:
"""隐藏输入密码,二次确认一致。"""
while True:
first = getpass(f"{prompt}: ")
if not first:
print(" 密码不能为空。")
continue
second = getpass("再次输入确认: ")
if first != second:
print(" 两次输入不一致,请重试。")
continue
return first
# === .env 生成 =============================================================
def _render_env(values: dict[str, str]) -> str:
"""以 .env.example 为模板渲染 .env仅替换被接管键的值其余原样保留。
被接管的键写入用户配置值模板里未被接管的键与全部注释空行原样保留
若某接管键在模板中不存在理论上不会追加到末尾
"""
if not ENV_EXAMPLE_PATH.exists():
# 没有模板时退化为按 values 直接生成(无注释)
return "".join(f"{k}={v}\n" for k, v in values.items())
remaining = dict(values)
out_lines: list[str] = []
for raw in ENV_EXAMPLE_PATH.read_text(encoding="utf-8").splitlines():
stripped = raw.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
key = stripped.split("=", 1)[0].strip()
if key in remaining:
out_lines.append(f"{key}={remaining.pop(key)}")
continue
out_lines.append(raw)
# 模板里没有、但本向导要写的键(兜底,正常不会触发)
if remaining:
out_lines.append("")
out_lines.append("# === 由 setup 向导补充 ===")
for key, value in remaining.items():
out_lines.append(f"{key}={value}")
return "\n".join(out_lines) + "\n"
def _backup_existing_env() -> Path | None:
if not ENV_PATH.exists():
return None
backup = ENV_PATH.with_name(f".env.bak_{time.strftime('%Y%m%d_%H%M%S')}")
shutil.copy2(ENV_PATH, backup)
return backup
# === custom_models.json 生成 ==============================================
def _build_custom_model_entry(
*, model_name: str, url: str, api_key: str, model_id: str, supports_thinking: bool
) -> dict:
"""构造一个最小可用的 custom_models 条目。
字段与 config/model_profiles.py 的解析规则对齐
- url / apikey 直接写字面量值解析器对非全大写字符串按字面量处理
- param_toggle 类型用 thinkmode_status.model_id 承载真实模型 ID
- reasoning_capability 决定是否暴露思考能力
"""
capability = "fast,thinking" if supports_thinking else "fast"
entry: dict = {
"model_name": model_name,
"description": model_name,
"visible": True,
"url": url,
"apikey": api_key,
"multimodal": "none",
"reasoning_capability": capability,
"context_window": 128000,
"max_output_tokens": 32768,
"thinkmode_status": {
"type": "param_toggle",
"model_id": model_id,
"fast_extra_parameter": {},
"thinking_extra_parameter": {},
},
"extra_parameter": {},
"model_description": f"你的基础模型是 {model_name}",
}
return entry
def _write_custom_models(target_path: Path, entry: dict) -> Path | None:
"""写出 custom_models.json 到部署配置目录。已存在则先备份再覆盖。"""
backup = None
if target_path.exists():
backup = target_path.with_name(
f"custom_models.json.bak_{time.strftime('%Y%m%d_%H%M%S')}"
)
shutil.copy2(target_path, backup)
payload = {"models": [entry]}
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
return backup
# === 主流程 ================================================================
def run_wizard(force: bool = False) -> int:
print("=" * 60)
print(" AI Agent 首次启动初始化向导")
print("=" * 60)
print("逐项设置启动前必须确定的内容,每项可回车使用 [默认值]。")
print()
if ENV_PATH.exists() and not force:
print(f"检测到已存在配置文件:{ENV_PATH}")
ans = _ask_choice("是否备份后重新配置", ["y", "n"], "n")
if ans == "n":
print("已取消,未做任何修改。")
return 0
values: dict[str, str] = {}
# --- 1. 运行模式 ---
print("\n[1/5] 运行模式")
print(" host = 单机本地模式,命令直接在宿主机沙箱执行")
print(" web = 多用户模式,每个用户分配独立 Docker 子容器")
mode = _ask_choice("选择运行模式", ["host", "web"], "host")
values["TERMINAL_SANDBOX_MODE"] = mode
default_host = "127.0.0.1" if mode == "host" else "0.0.0.0"
# --- 2. Web 服务 ---
print("\n[2/5] Web 服务监听")
values["WEB_SERVER_PORT"] = _ask("监听端口", "8091")
print(" 监听地址:单机本地建议 127.0.0.1(只听本机);多用户/服务器用 0.0.0.0")
values["WEB_SERVER_HOST"] = _ask("监听地址", default_host)
# --- 3. 数据目录(可选覆盖)---
print("\n[3/5] 数据目录")
default_data = f"~/.agents/{mode}"
print(f" 运行态数据(对话/用户/记忆/日志)默认存放在 {default_data}")
custom_data = _ask("自定义数据根目录(留空用默认)", "")
if custom_data:
# 模式根变量host -> AGENTS_HOST_HOMEweb -> AGENTS_WEB_HOME
values["AGENTS_HOST_HOME" if mode == "host" else "AGENTS_WEB_HOME"] = custom_data
# --- 4. 管理员账户 ---
print("\n[4/5] 管理员账户")
admin_user = _ask("管理员用户名", "admin")
admin_pw = _ask_password("管理员密码")
values["AGENT_ADMIN_USERNAME"] = admin_user
values["AGENT_ADMIN_PASSWORD_HASH"] = generate_password_hash(admin_pw)
# --- 5. 模型配置(写入部署级配置目录 ~/.agents/<mode>/config/custom_models.json---
print("\n[5/5] 模型配置")
print(" 配置一个可用模型(写入 ~/.agents/<mode>/config/custom_models.json")
print(" 启动后还可在后台页面继续增删模型。")
model_name = _ask_required("模型显示名(界面上看到的名字)", "默认模型")
model_url = _ask_required("API 地址Base URL", "")
model_apikey = _ask_required("API Key", "")
model_id = _ask_required("模型 ID请求里实际发送的 model 字段)", "")
supports_thinking = _ask_choice(
"该模型是否支持思考模式", ["y", "n"], "n"
) == "y"
model_entry = _build_custom_model_entry(
model_name=model_name,
url=model_url,
api_key=model_apikey,
model_id=model_id,
supports_thinking=supports_thinking,
)
# --- 自动生成密钥 ---
values["WEB_SECRET_KEY"] = secrets.token_hex(32)
values["API_TOKEN_SECRET"] = secrets.token_hex(32)
# --- 写出 ---
rendered = _render_env(values)
backup = _backup_existing_env()
ENV_PATH.write_text(rendered, encoding="utf-8")
try:
ENV_PATH.chmod(0o600) # .env 含密钥收紧权限Windows 上静默忽略)
except (OSError, NotImplementedError):
pass
# 模型配置写到部署级配置目录(~/.agents/<mode>/config而非源码树
custom_models_path = _deploy_config_dir(mode, custom_data) / "custom_models.json"
models_backup = _write_custom_models(custom_models_path, model_entry)
# --- 摘要 ---
print("\n" + "=" * 60)
print(" 配置完成")
print("=" * 60)
print(f" 配置文件:{ENV_PATH}")
if backup:
print(f" 原配置已备份:{backup}")
print(f" 模型配置:{custom_models_path}")
if models_backup:
print(f" 原模型配置已备份:{models_backup}")
print(f" 运行模式:{mode}")
print(f" 监听地址:{values['WEB_SERVER_HOST']}:{values['WEB_SERVER_PORT']}")
print(f" 数据目录:{custom_data or default_data}")
print(f" 管理员: {admin_user}")
print(f" 模型: {model_name}{model_id}@ {model_url}")
print(f" 密钥: 已自动生成 WEB_SECRET_KEY / API_TOKEN_SECRET")
print()
print(" 下一步启动服务:")
print(" python -m server.app")
print()
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="AI Agent 首次启动初始化向导")
parser.add_argument(
"--force",
action="store_true",
help="跳过『已存在 .env』的确认提示仍会先备份再覆盖",
)
args = parser.parse_args()
try:
return run_wizard(force=args.force)
except KeyboardInterrupt:
print("\n已中断,未写入配置。")
return 130
if __name__ == "__main__":
sys.exit(main())

View File

@ -228,6 +228,8 @@ from config import (
PROJECT_MAX_STORAGE_MB,
PROJECT_MAX_STORAGE_BYTES,
UPLOAD_SCAN_LOG_SUBDIR,
WEB_SERVER_PORT,
WEB_SERVER_HOST,
)
from modules.user_manager import UserManager, UserWorkspace
from modules.gui_file_manager import GuiFileManager
@ -325,7 +327,7 @@ ADMIN_CUSTOM_TOOLS_DIR = (Path(app.static_folder) / 'custom_tools').resolve()
RECENT_UPLOAD_EVENT_LIMIT = 150
RECENT_UPLOAD_FEED_LIMIT = 60
DEFAULT_PORT = 8091
DEFAULT_PORT = WEB_SERVER_PORT
THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"]
CSRF_HEADER_NAME = state.CSRF_HEADER_NAME
CSRF_SESSION_KEY = state.CSRF_SESSION_KEY
@ -1343,7 +1345,7 @@ def run_server(path: str, thinking_mode: bool = False, port: int = DEFAULT_PORT,
start_background_jobs()
socketio.run(
app,
host='0.0.0.0',
host=WEB_SERVER_HOST,
port=port,
debug=debug,
use_reloader=debug,

View File

@ -8,7 +8,6 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from config import TITLE_API_BASE_URL, TITLE_API_KEY, TITLE_MODEL_ID
from core.web_terminal import WebTerminal
from config import LOGS_DIR
from utils.api_client import DeepSeekClient

View File

@ -6,7 +6,7 @@ from collections import defaultdict, deque
from pathlib import Path
from typing import Dict, Any, Optional
from config import LOGS_DIR, PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_MB
from config import LOGS_DIR, PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_MB, WEB_SERVER_PORT
from core.web_terminal import WebTerminal
from modules.custom_tool_registry import CustomToolRegistry
from modules.mcp_server_registry import MCPServerRegistry
@ -60,7 +60,7 @@ PROJECT_STORAGE_CACHE: Dict[str, Dict[str, Any]] = {}
PROJECT_STORAGE_CACHE_TTL_SECONDS = float(os.environ.get("PROJECT_STORAGE_CACHE_TTL", "30"))
# 其他配置
DEFAULT_PORT = 8091
DEFAULT_PORT = WEB_SERVER_PORT
THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"]
CSRF_HEADER_NAME = "X-CSRF-Token"
CSRF_SESSION_KEY = "_csrf_token"

View File

@ -26,6 +26,7 @@ print(json.dumps({
"LOGS_DIR": config.LOGS_DIR,
"USER_SPACE_DIR": config.USER_SPACE_DIR,
"API_USER_SPACE_DIR": config.API_USER_SPACE_DIR,
"DEPLOY_CONFIG_DIR": config.DEPLOY_CONFIG_DIR,
"SUB_AGENT_STATE_FILE": config.SUB_AGENT_STATE_FILE,
"SUB_AGENT_TASKS_BASE_DIR": config.SUB_AGENT_TASKS_BASE_DIR,
}, ensure_ascii=False))
@ -37,6 +38,7 @@ print(json.dumps({
"TERMINAL_SANDBOX_MODE", "AGENTS_HOST_HOME", "AGENTS_WEB_HOME",
"DATA_DIR", "LOGS_DIR", "USER_SPACE_DIR", "API_USER_SPACE_DIR",
"DEFAULT_PROJECT_PATH", "HOST_WORKSPACES_FILE", "PROMPTS_DIR",
"DEPLOY_CONFIG_DIR",
"SUB_AGENT_STATE_FILE", "SUB_AGENT_TASKS_BASE_DIR",
):
env.pop(key, None)
@ -80,11 +82,11 @@ print(json.dumps({
self.assertEqual(data["LOGS_DIR"], str(host_home / "logs"))
def test_source_tree_paths_stay_anchored_to_repo_root(self):
"""配置类路径(提示词、host_workspaces.json、默认工作区)仍锚定源码树。"""
"""配置类路径(提示词、默认工作区)仍锚定源码树。"""
with tempfile.TemporaryDirectory() as td:
data = self._load_paths(cwd=Path(td), extra_env={"TERMINAL_SANDBOX_MODE": "docker"})
for key in ("DEFAULT_PROJECT_PATH", "HOST_WORKSPACES_FILE", "PROMPTS_DIR"):
for key in ("DEFAULT_PROJECT_PATH", "PROMPTS_DIR"):
value = Path(data[key])
self.assertTrue(value.is_absolute(), f"{key} not absolute: {data[key]}")
self.assertTrue(
@ -92,6 +94,30 @@ print(json.dumps({
f"{key} should anchor to repo root: {data[key]}",
)
def test_deploy_config_anchors_to_runtime_root(self):
"""部署级配置目录与 host_workspaces.json 锚定到运行态根 ~/.agents/<mode>/config。"""
with tempfile.TemporaryDirectory() as td:
data = self._load_paths(cwd=Path(td), extra_env={"TERMINAL_SANDBOX_MODE": "web"})
web_config = Path("~/.agents/web/config").expanduser().resolve()
self.assertEqual(data["DEPLOY_CONFIG_DIR"], str(web_config))
self.assertEqual(
data["HOST_WORKSPACES_FILE"], str(web_config / "host_workspaces.json")
)
def test_deploy_config_follows_mode_home(self):
"""模式根变量覆盖时,部署配置目录跟随该根目录。"""
with tempfile.TemporaryDirectory() as td:
custom = Path(td) / "web_root"
data = self._load_paths(
cwd=Path(td),
extra_env={
"TERMINAL_SANDBOX_MODE": "web",
"AGENTS_WEB_HOME": str(custom),
},
)
self.assertEqual(data["DEPLOY_CONFIG_DIR"], str((custom / "config").resolve()))
def test_mode_home_env_overrides_root(self):
"""模式根变量AGENTS_HOST_HOME / AGENTS_WEB_HOME整体搬迁运行态目录。"""
with tempfile.TemporaryDirectory() as td:

View File

@ -14,14 +14,8 @@ from pathlib import Path
from typing import Tuple
try:
from config import (
API_BASE_URL,
API_KEY,
MODEL_ID,
OUTPUT_FORMATS,
DEFAULT_RESPONSE_MAX_TOKENS,
THINKING_API_BASE_URL,
THINKING_API_KEY,
THINKING_MODEL_ID,
LOGS_DIR,
)
except ImportError:
@ -31,14 +25,8 @@ except ImportError:
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import (
API_BASE_URL,
API_KEY,
MODEL_ID,
OUTPUT_FORMATS,
DEFAULT_RESPONSE_MAX_TOKENS,
THINKING_API_BASE_URL,
THINKING_API_KEY,
THINKING_MODEL_ID,
LOGS_DIR,
)
@ -51,15 +39,17 @@ def _api_dump_enabled() -> bool:
class DeepSeekClient: # legacy name; generic OpenAI-compatible client
def __init__(self, thinking_mode: bool = True, web_mode: bool = False):
# 模型 API 配置(端点 / 密钥 / 模型 ID由 apply_profile 从 custom_models 注入;
# 这里仅初始化为空,未应用任何模型档案前不可直接发请求。
self.fast_api_config = {
"base_url": API_BASE_URL,
"api_key": API_KEY,
"model_id": MODEL_ID
"base_url": "",
"api_key": "",
"model_id": ""
}
self.thinking_api_config = {
"base_url": THINKING_API_BASE_URL or API_BASE_URL,
"api_key": THINKING_API_KEY or API_KEY,
"model_id": THINKING_MODEL_ID or MODEL_ID
"base_url": "",
"api_key": "",
"model_id": ""
}
self.fast_max_tokens = None
self.thinking_max_tokens = None