diff --git a/docs/svc.md b/docs/svc.md index 5d7f9851..a40f28a5 100644 --- a/docs/svc.md +++ b/docs/svc.md @@ -793,6 +793,8 @@ scripts/svc.sh prod deploy --env .env.prod # 等价 | `SVC_LIGHT_BASE/KEY/NAME` | 轻量模型 | | `SVC_VISION_MODEL` | 视觉兜底模型(播种 `settings.models.vision.model`) | | `SVC_VISION_CAPABLE` | 可看图模型清单(逗号分隔,播种 `settings.models.vision.capable`) | +| `SVC_MINIMAX_BASE/KEY` | 原 MiniMax 端点(播种 `settings.models.reserve.minimax`,不替换当前对话模型) | +| `SVC_MINIMAX_CHAT/FAST` | 原 MiniMax 对话模型与快速模型名 | | `SVC_TAVILY_KEYS` | Tavily 密钥池(逗号分隔) | ### 9.4 持久化语义 diff --git a/scripts/svc.sh b/scripts/svc.sh index 9c8250e8..a2625e9b 100755 --- a/scripts/svc.sh +++ b/scripts/svc.sh @@ -528,6 +528,8 @@ dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只 SVC_LIGHT_BASE/KEY/NAME 轻量模型 SVC_VISION_MODEL 视觉兜底模型(播种 models.vision.model) SVC_VISION_CAPABLE 可看图模型清单(逗号分隔,播种 models.vision.capable) + SVC_MINIMAX_BASE/KEY 原 MiniMax 端点(播种 models.reserve.minimax,不作为当前对话模型) + SVC_MINIMAX_CHAT/FAST 原 MiniMax 对话模型与快速模型名 SVC_TAVILY_KEYS Tavily 密钥池 SVC_SEED_OWNER 播种目标账号(缺省取库内首个管理员) 播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。 @@ -681,6 +683,39 @@ load_env_file() { done < "$f" } +# load_env_file_if_unset FILE:只填当前为空的变量,不覆盖调用方已经传入的环境。 +load_env_file_if_unset() { + local f="$1" line k v + [ -f "$f" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in ''|'#'*) continue ;; esac + [[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*= ]] || continue + k="${line%%=*}" + k="${k#"${k%%[![:space:]]*}"}" + k="${k#export}" + k="${k#"${k%%[![:space:]]*}"}" + [ -n "${!k:-}" ] && continue + v="${line#*=}" + v="${v#"${v%%[![:space:]]*}"}" + v="${v%\"}"; v="${v%\'}"; v="${v#\"}"; v="${v#\'}" + printf -v "$k" '%s' "$v" + done < "$f" +} + +# load_saved_envfile:svc.env 记住的 envfile= 在播种前补进空变量。 +load_saved_envfile() { + local f + [ -n "${SVC_ENVFILE:-}" ] && return 0 + f="$(svc_get envfile)" + [ -n "$f" ] || return 0 + case "$f" in + /*) ;; + *) f="$ROOT/$f" ;; + esac + load_env_file_if_unset "$f" +} + # ---- 命令行参数(覆盖层)---- CLI_ADDR=""; CLI_HOST=""; CLI_PORT=""; CLI_DATA=""; CLI_DSN="" CLI_AUTH=""; CLI_MOCK=0; CLI_PLUGINS=""; CLI_EXTRA=""; CLI_LOGS_N="" @@ -854,7 +889,7 @@ svc_merge_tags() { # agent 服务经统一解析启动后按生效态写 db=(pg/mock;显式 sqlite)与 dbref=/dsn= # (托管绑定不重复存 DSN,外部直连存受保护 dsn=);未走解析的调用保持旧值。 record_state() { - local pid prev_src prev_dbref prev_conffile prev_db prev_dsn merged_tags + local pid prev_src prev_dbref prev_conffile prev_db prev_dsn prev_envfile merged_tags pid="$(read_pid)" prev_src="" [ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)" @@ -862,6 +897,7 @@ record_state() { prev_conffile="$(svc_get conffile)" prev_db="$(svc_get db)" prev_dsn="$(svc_get dsn)" + prev_envfile="$(svc_get envfile)" SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}" merged_tags="$(svc_merge_tags)" local db_out="" dbref_out="" dsn_out="" @@ -899,6 +935,11 @@ record_state() { [ -n "$dbref_out" ] && printf 'dbref=%s\n' "$dbref_out" [ -n "$dsn_out" ] && printf 'dsn=%s\n' "$dsn_out" [ -n "$prev_conffile" ] && printf 'conffile=%s\n' "$prev_conffile" + if [ -n "${SVC_ENVFILE:-}" ]; then + printf 'envfile=%s\n' "$SVC_ENVFILE" + elif [ -n "$prev_envfile" ]; then + printf 'envfile=%s\n' "$prev_envfile" + fi [ $# -gt 0 ] && printf '%s\n' "$@" } > "$tmp" chmod 600 "$tmp" @@ -2823,26 +2864,18 @@ print_agent_plan() { return 0 } -# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入系统设置(幂等)。 -# P2 受管(提案 2026-10-13 rdm-settings-members-duties-perms-increment):models.* -# 与 tools.tavily.* 是系统受管项,user 层写入整笔拒绝,播种改走系统管理面 -# PUT /api/settings/system。认证开启(PG 档默认)时该面要求管理员凭证:自动铸一把 -# 临时 API key(name=svc-seed)完成写入后立即回收;owner 缺省取库内首个管理员 -#(系统面必须 isAdmin,不再回落普通账号),--seed-owner / SVC_SEED_OWNER 可指定。 -# 铸 key 失败维持无头 PUT(仅无认证单机档可成)。 -seed_settings() { - local host="$1" port="$2" +# model_seed_payload:把当前环境里的模型配置打成系统设置 JSON。没有可写项时输出空。 +model_seed_payload() { local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}" local lb="${CLI_LBASE:-${SVC_LIGHT_BASE:-}}" lk="${CLI_LKEY:-${SVC_LIGHT_KEY:-}}" ln="${CLI_LNAME:-${SVC_LIGHT_NAME:-}}" local tk="${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}" local fb="${SVC_MODEL_FALLBACKS:-}" vm="${SVC_VISION_MODEL:-}" vc="${SVC_VISION_CAPABLE:-}" - [ -z "$cb$ck$cn$lb$lk$ln$tk$fb$vm$vc" ] && return 0 - command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; } - local payload - payload="$(python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" "$fb" "$vm" "$vc" <<'PY' + local mb="${SVC_MINIMAX_BASE:-}" mk="${SVC_MINIMAX_KEY:-}" mc="${SVC_MINIMAX_CHAT:-}" mf="${SVC_MINIMAX_FAST:-}" + [ -z "$cb$ck$cn$lb$lk$ln$tk$fb$vm$vc$mb$mk$mc$mf" ] && return 0 + command -v python3 >/dev/null 2>&1 || return 1 + python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" "$fb" "$vm" "$vc" "$mb" "$mk" "$mc" "$mf" <<'PY' import json, sys -cb, ck, cn, lb, lk, ln, tk, fb, vm, vc = sys.argv[1:11] -out = {} +cb, ck, cn, lb, lk, ln, tk, fb, vm, vc, mb, mk, mc, mf = sys.argv[1:15] def sub(base, key, name): d = {} if base: d["baseUrl"] = base @@ -2859,11 +2892,33 @@ vision = {} if vm: vision["model"] = vm if vc: vision["capable"] = vc if vision: models["vision"] = vision +reserve = {} +if mb: reserve["baseUrl"] = mb +if mk: reserve["apiKey"] = mk +if mc: reserve["chat"] = mc +if mf: reserve["fast"] = mf +if reserve: + models["reserve"] = {"minimax": reserve} +out = {} if models: out["models"] = models if tk: out["tools"] = {"tavily": {"apiKeys": tk}} print(json.dumps(out)) PY -)" +} + +# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入系统设置(幂等)。 +# P2 受管(提案 2026-10-13 rdm-settings-members-duties-perms-increment):models.* +# 与 tools.tavily.* 是系统受管项,user 层写入整笔拒绝,播种改走系统管理面 +# PUT /api/settings/system。认证开启(PG 档默认)时该面要求管理员凭证:自动铸一把 +# 临时 API key(name=svc-seed)完成写入后立即回收;owner 缺省取库内首个管理员 +#(系统面必须 isAdmin,不再回落普通账号),--seed-owner / SVC_SEED_OWNER 可指定。 +# 铸 key 失败维持无头 PUT(仅无认证单机档可成)。 +seed_settings() { + local host="$1" port="$2" + local payload + payload="$(model_seed_payload)" || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; } + [ -n "$payload" ] && [ "$payload" != "{}" ] || return 0 + command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; } # 认证开启时的播种凭证:临时 key,明文只存在于本函数局部变量,不回显、不落盘。 # apikey 子命令不读环境 DSN,只能经 CLI 传递(进程列表短暂可见;serve 走 # AGENT_SYSTEM_DSN 环境注入,此路对 CLI 子命令不可用)。 @@ -2901,17 +2956,59 @@ for u in users: fi if echo "$resp" | grep -q '"ok": *true'; then local did="" - [ -n "$cb$ck$cn" ] && did="主模型" - [ -n "$lb$lk$ln" ] && did="${did:+$did、}轻量模型" - [ -n "$fb" ] && did="${did:+$did、}回退链" - [ -n "$vm$vc" ] && did="${did:+$did、}视觉模型" - [ -n "$tk" ] && did="${did:+$did、}Tavily" + [ -n "${CLI_MBASE:-${SVC_MODEL_BASE:-}}${CLI_MKEY:-${SVC_MODEL_KEY:-}}${CLI_MNAME:-${SVC_MODEL_NAME:-}}" ] && did="主模型" + [ -n "${CLI_LBASE:-${SVC_LIGHT_BASE:-}}${CLI_LKEY:-${SVC_LIGHT_KEY:-}}${CLI_LNAME:-${SVC_LIGHT_NAME:-}}" ] && did="${did:+$did、}轻量模型" + [ -n "${SVC_MODEL_FALLBACKS:-}" ] && did="${did:+$did、}回退链" + [ -n "${SVC_VISION_MODEL:-}${SVC_VISION_CAPABLE:-}" ] && did="${did:+$did、}视觉模型" + [ -n "${SVC_MINIMAX_BASE:-}${SVC_MINIMAX_KEY:-}${SVC_MINIMAX_CHAT:-}${SVC_MINIMAX_FAST:-}" ] && did="${did:+$did、}MiniMax 备用" + [ -n "${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}" ] && did="${did:+$did、}Tavily" c_ok "[$ENV_NAME] 已播种设置:$did(持久化于 $ENV_DATA,后续启动无需再传)" else c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(认证开启时需库内已有账号以铸临时密钥,或用 --seed-owner 指定;也可在设置页手动配置)" fi } +# seed_settings_db:rdm 产物不能铸 API key 时,把同一份配置合并进嵌入式系统设置。 +# 库还不存在时不创建,避免在空数据目录里写出半套表。 +seed_settings_db() { + local payload db + payload="$(model_seed_payload)" || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; } + [ -n "$payload" ] && [ "$payload" != "{}" ] || return 0 + db="${ENV_DATA:-}/agent-system.db" + [ -f "$db" ] || return 0 + if python3 - "$db" "$payload" <<'PY' +import json, sqlite3, sys +db, payload = sys.argv[1], sys.argv[2] +incoming = json.loads(payload) +con = sqlite3.connect(db, timeout=10) +tables = {row[0] for row in con.execute("SELECT name FROM sqlite_master WHERE type='table'")} +if "settings" not in tables: + raise SystemExit(2) +row = con.execute('SELECT "values" FROM settings WHERE owner=? AND scope=?', ("", "system")).fetchone() +current = json.loads(row[0]) if row else {} + +def merge(dst, src): + for key, value in src.items(): + if isinstance(value, dict) and isinstance(dst.get(key), dict): + merge(dst[key], value) + else: + dst[key] = value + return dst + +blob = json.dumps(merge(current, incoming), ensure_ascii=False, separators=(",", ":")) +if row: + con.execute('UPDATE settings SET "values"=? WHERE owner=? AND scope=?', (blob, "", "system")) +else: + con.execute('INSERT INTO settings(owner, scope, "values") VALUES(?,?,?)', ("", "system", blob)) +con.commit() +PY + then + c_ok "[$ENV_NAME] 已把模型配置写入数据目录(rdm 产物无播种命令,直接更新系统设置)" + else + c_warn "[$ENV_NAME] 数据目录中的模型配置未写上" + fi +} + # dsn_from_dbref DBREF:由 dbref(pg实例/库名)从该实例登记重建连接串。 # 实例未登记 / 非 postgres / 缺 addr·user·password 时返回非零且不输出。 dsn_from_dbref() { @@ -3330,10 +3427,11 @@ agent_do_start() { svc_mark_autostart c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)" hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}" - # rdm 组合产物无 user/apikey CLI 子命令:设置播种(--model-*/--tavily-keys)仅 - # 基座产物支持;记录态 svc.env 的 source=rdm@* 跨裸 start/restart 稳定标识形态。 + load_saved_envfile + # rdm 组合产物没有 user/apikey 子命令,不能铸临时管理员密钥走 HTTP。 + # 嵌入式库改为直接合并系统设置,避免重启后回到旧模型。 case "${SVC_SOURCE:-}" in - rdm@*) ;; + rdm@*) seed_settings_db ;; *) seed_settings "$phost" "${probe##*:}" ;; esac return 0