agent-Specialization/scripts/setup.py
JOJO b8a51c1c63 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>
2026-06-01 16:59:34 +08:00

330 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())