Download app/admin/config_manager.py from bg5/zai: direct link, hf CLI and curl.
- Browser
- Download file 42.6 kB
-
https://huggingface.co/spaces/bg5/zai/resolve/main/app/admin/config_manager.py
- Command line
-
hf download hf://spaces/bg5/zai/app/admin/config_manager.py
-
curl -L -o config_manager.py https://huggingface.co/spaces/bg5/zai/resolve/main/app/admin/config_manager.py
42.6 kB
| """Admin config metadata and helpers for the configuration console.""" | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any, Awaitable, Callable, Mapping | |
| from dotenv import dotenv_values | |
| from app.core.config import settings | |
| from app.utils.env_file import update_env_file | |
| from app.utils.logger import logger | |
| ENV_PATH = Path(".env") | |
| ENV_EXAMPLE_PATH = Path(".env.example") | |
| _ENV_SOURCE_LINE_PATTERN = re.compile( | |
| r"^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=.*$" | |
| ) | |
| class ConfigFieldSpec: | |
| key: str | |
| label: str | |
| description: str | |
| value_type: str | |
| default_value: object | |
| input_type: str = "text" | |
| placeholder: str = "" | |
| required: bool = False | |
| wide: bool = False | |
| sensitive: bool = False | |
| restart_required: bool = False | |
| min_value: int | None = None | |
| max_value: int | None = None | |
| db_persist: bool = True | |
| options: tuple[str, ...] = () # 仅 input_type="select" 时使用 | |
| class ConfigSectionSpec: | |
| id: str | |
| title: str | |
| description: str | |
| fields: tuple[ConfigFieldSpec, ...] | |
| icon: str = "" | |
| CONFIG_SECTIONS: tuple[ConfigSectionSpec, ...] = ( | |
| ConfigSectionSpec( | |
| id="access", | |
| title="接入与认证", | |
| description="控制上游接口地址、客户端鉴权和 Function Call 行为。", | |
| icon="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="API_ENDPOINT", | |
| label="上游 API 地址", | |
| description="代理请求实际转发到的上游聊天完成接口。", | |
| value_type="str", | |
| default_value="https://chat.z.ai/api/v2/chat/completions", | |
| input_type="url", | |
| placeholder="https://chat.z.ai/api/v2/chat/completions", | |
| required=True, | |
| wide=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="RESIN_COMPAT_ENABLED", | |
| label="启用 Resin 兼容账号头", | |
| description="为出站请求附加 X-Resin-Account,并使用上游 token 的稳定哈希值作为账号,便于 Resin 按同一 token 复用出口 IP。", | |
| value_type="bool", | |
| default_value=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="AUTH_TOKEN", | |
| label="客户端认证密钥", | |
| description="客户端访问本服务时使用的 Bearer Token。", | |
| value_type="str", | |
| default_value="sk-your-api-key", | |
| input_type="password", | |
| placeholder="sk-your-api-key", | |
| wide=True, | |
| sensitive=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="SKIP_AUTH_TOKEN", | |
| label="跳过客户端认证", | |
| description="仅建议开发环境使用,开启后不校验 AUTH_TOKEN。", | |
| value_type="bool", | |
| default_value=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="UPSTREAM_BACKGROUND_TASKS", | |
| label="上游背景任务", | |
| description="是否允许上游执行标题生成、搜索总结等背景任务(开启可能消耗更多额度/时间)。", | |
| value_type="bool", | |
| default_value=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="SESSION_ENABLED", | |
| label="会话管理", | |
| description="启用官方链式会话复用,关闭则每次请求使用独立会话。", | |
| value_type="bool", | |
| default_value=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="SESSION_SYSTEM_INJECT", | |
| label="System 注入模式", | |
| description="控制 system 提示词是否并入用户消息。", | |
| value_type="bool", | |
| default_value=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="PRECREATE_CHAT", | |
| label="预创建 Chat ID", | |
| description="直接模式下通过上游接口预创建 chat_id,而非使用随机 UUID。仅在关闭会话管理时生效。", | |
| value_type="bool", | |
| default_value=False, | |
| ), | |
| ), | |
| ), | |
| ConfigSectionSpec( | |
| id="server", | |
| title="服务运行", | |
| description="服务监听、日志、数据库路径和反向代理前缀。", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="SERVICE_NAME", | |
| label="服务名称", | |
| description="显示在进程列表中的服务名称。", | |
| value_type="str", | |
| default_value="api-proxy-server", | |
| placeholder="api-proxy-server", | |
| required=True, | |
| restart_required=True, | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="LISTEN_PORT", | |
| label="监听端口", | |
| description="HTTP 服务监听端口。", | |
| value_type="int", | |
| default_value=8080, | |
| input_type="number", | |
| min_value=1, | |
| max_value=65535, | |
| required=True, | |
| restart_required=True, | |
| placeholder="8080", | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="ROOT_PATH", | |
| label="反向代理路径前缀", | |
| description="例如 /api,部署在子路径时使用。", | |
| value_type="str", | |
| default_value="", | |
| placeholder="/api", | |
| restart_required=True, | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="DEBUG_LOGGING", | |
| label="启用调试日志", | |
| description="开启后控制台和日志文件统一输出 DEBUG,并使用详细格式。", | |
| value_type="bool", | |
| default_value=False, | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="LOG_FILE_PATH", | |
| label="日志文件路径", | |
| description="相对路径默认相对于项目根目录解析。", | |
| value_type="str", | |
| default_value="logs/app.log", | |
| placeholder="logs/app.log", | |
| wide=True, | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="LOG_FILE_MAX_BYTES", | |
| label="单个日志文件上限 (Bytes)", | |
| description="超过该大小后自动轮转到备份文件。", | |
| value_type="int", | |
| default_value=5242880, | |
| input_type="number", | |
| min_value=1024, | |
| placeholder="5242880", | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="LOG_FILE_BACKUP_COUNT", | |
| label="日志备份数量", | |
| description="保留的轮转备份文件数量。", | |
| value_type="int", | |
| default_value=3, | |
| input_type="number", | |
| min_value=1, | |
| placeholder="3", | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="DB_PATH", | |
| label="数据库路径", | |
| description="SQLite 数据库文件位置。", | |
| value_type="str", | |
| default_value="tokens.db", | |
| placeholder="tokens.db", | |
| required=True, | |
| wide=True, | |
| restart_required=True, | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="DATABASE_URL", | |
| label="PostgreSQL 连接串", | |
| description="(可选)如果使用 PostgreSQL,请填写形如 postgresql://user:pass@localhost:5432/dbname 的地址。留空则使用 SQLite。", | |
| value_type="str", | |
| default_value="", | |
| placeholder="postgresql://user:pass@localhost:5432/dbname", | |
| wide=True, | |
| restart_required=True, | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="MAX_UPLOAD_FILE_SIZE", | |
| label="文件上传限制 (Bytes)", | |
| description="允许上传到上游的最大文件大小(默认 10MB)。", | |
| value_type="int", | |
| default_value=10485760, | |
| input_type="number", | |
| min_value=0, | |
| placeholder="10485760", | |
| ), | |
| ), | |
| ), | |
| ConfigSectionSpec( | |
| id="tokens", | |
| title="Token 池策略", | |
| description="失败判定、恢复时间和自动导入、自动维护计划任务。", | |
| icon="M4 7v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2zm0 4h16m-16 4h16", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="TOKEN_FAILURE_THRESHOLD", | |
| label="失败阈值", | |
| description="连续失败多少次后将 Token 标记为不可用。", | |
| value_type="int", | |
| default_value=3, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| restart_required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_RECOVERY_TIMEOUT", | |
| label="恢复超时(秒)", | |
| description="失败 Token 重新参与调度前的等待时间。", | |
| value_type="int", | |
| default_value=1800, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| restart_required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_LOAD_BALANCE_STRATEGY", | |
| label="负载策略", | |
| description="控制认证 Token 的分配方式:轮询、最少活跃请求优先,或平滑加权轮询。", | |
| value_type="str", | |
| default_value="smooth_weighted_round_robin", | |
| input_type="select", | |
| options=( | |
| "round_robin", | |
| "least_loaded", | |
| "smooth_weighted_round_robin", | |
| ), | |
| required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_AUTO_IMPORT_ENABLED", | |
| label="启用自动导入", | |
| description="按固定周期扫描服务端目录并导入 Token。", | |
| value_type="bool", | |
| default_value=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_AUTO_IMPORT_SOURCE_DIR", | |
| label="自动导入目录", | |
| description="服务端本地目录,开启自动导入时需要可访问。", | |
| value_type="str", | |
| default_value="", | |
| placeholder="E:\\tokens\\input", | |
| wide=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_AUTO_IMPORT_INTERVAL", | |
| label="自动导入间隔(秒)", | |
| description="自动导入的扫描周期。", | |
| value_type="int", | |
| default_value=300, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_AUTO_MAINTENANCE_ENABLED", | |
| label="启用自动维护", | |
| description="定时执行去重、健康检查和删除失效 Token。", | |
| value_type="bool", | |
| default_value=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_AUTO_MAINTENANCE_INTERVAL", | |
| label="自动维护间隔(秒)", | |
| description="自动维护的执行周期。", | |
| value_type="int", | |
| default_value=1800, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_AUTO_REMOVE_DUPLICATES", | |
| label="自动去重", | |
| description="自动维护时清理重复 Token。", | |
| value_type="bool", | |
| default_value=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_AUTO_HEALTH_CHECK", | |
| label="自动健康检查", | |
| description="自动维护时验证 Token 可用性。", | |
| value_type="bool", | |
| default_value=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="TOKEN_AUTO_DELETE_INVALID", | |
| label="自动删除失效 Token", | |
| description="自动维护时移除已验证为无效的 Token。", | |
| value_type="bool", | |
| default_value=False, | |
| ), | |
| ), | |
| ), | |
| ConfigSectionSpec( | |
| id="guest", | |
| title="匿名 Guest 会话池", | |
| description="没有用户 Token 时,控制匿名会话池的容量和维护策略。", | |
| icon="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="ANONYMOUS_MODE", | |
| label="启用匿名模式", | |
| description="无可用用户 Token 时允许使用匿名会话。", | |
| value_type="bool", | |
| default_value=True, | |
| restart_required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="GUEST_POOL_SIZE", | |
| label="Guest 池容量", | |
| description="启动和维持的 guest 会话数量。", | |
| value_type="int", | |
| default_value=3, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| restart_required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="GUEST_SESSION_MAX_AGE", | |
| label="Guest 最大存活时间(秒)", | |
| description="单个 guest 会话的最长存活时长。", | |
| value_type="int", | |
| default_value=480, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| restart_required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="GUEST_POOL_MAINTENANCE_INTERVAL", | |
| label="Guest 维护间隔(秒)", | |
| description="后台维护匿名会话池的频率。", | |
| value_type="int", | |
| default_value=30, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| restart_required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="GUEST_CLEANUP_PARALLELISM", | |
| label="清理并行度", | |
| description="并行回收空闲 guest 会话的数量。", | |
| value_type="int", | |
| default_value=4, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| restart_required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="GUEST_HTTP_MAX_KEEPALIVE_CONNECTIONS", | |
| label="Keep-Alive 连接数", | |
| description="guest 会话 HTTP 连接池 keep-alive 上限。", | |
| value_type="int", | |
| default_value=20, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| restart_required=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="GUEST_HTTP_MAX_CONNECTIONS", | |
| label="HTTP 总连接数", | |
| description="guest 会话 HTTP 连接池的总连接数上限。", | |
| value_type="int", | |
| default_value=50, | |
| input_type="number", | |
| min_value=1, | |
| required=True, | |
| restart_required=True, | |
| ), | |
| ), | |
| ), | |
| ConfigSectionSpec( | |
| id="models", | |
| title="模型设置", | |
| description="在线模型自动解析与变体生成。模型列表从上游拉取后自动生成 Thinking / Search / Agent / advanced-search 变体。", | |
| icon="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="MODEL_BLACKLIST", | |
| label="模型黑名单", | |
| description="逗号分隔的上游模型 ID 列表,解析在线模型时跳过这些模型。例如:glm-4-flash,test-model", | |
| value_type="str", | |
| default_value="glm-4-flash", | |
| placeholder="glm-4-flash", | |
| wide=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="MODEL_ALIASES", | |
| label="模型别名映射", | |
| description="自定义别名,让客户端可以用自定义名称访问模型。格式:别名=目标模型名,逗号分隔。例如:gpt-4o=GLM-5,gpt-4o-mini=GLM-4.7", | |
| value_type="str", | |
| default_value="", | |
| placeholder="gpt-4o=GLM-5,gpt-4o-mini=GLM-4.7", | |
| wide=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="MODEL_AUTO_REFRESH_HOURS", | |
| label="自动刷新周期(小时)", | |
| description="定时从上游拉取在线模型列表并重新生成变体。设为 0 表示禁用自动刷新,仅手动刷新。", | |
| value_type="int", | |
| input_type="number", | |
| default_value=0, | |
| placeholder="0", | |
| min_value=0, | |
| max_value=168, | |
| restart_required=True, | |
| ), | |
| ), | |
| ), | |
| ConfigSectionSpec( | |
| id="proxy", | |
| title="网络代理", | |
| description="用于所有上游访问的全局网络代理配置。", | |
| icon="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="HTTP_PROXY", | |
| label="代理服务器地址", | |
| description="统一代理,支持 http://, https://, socks5:// 等格式。例如:http://127.0.0.1:10808 或 socks5://127.0.0.1:1080。", | |
| value_type="str", | |
| default_value="", | |
| wide=True, | |
| ), | |
| ), | |
| ), | |
| ConfigSectionSpec( | |
| id="timeouts", | |
| title="超时控制", | |
| description="系统各环节的超时时间(单位:秒)。支持小数,修改即时生效。", | |
| icon="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="HTTP_CONNECT_TIMEOUT", | |
| label="连接超时", | |
| description="建立 TCP/TLS 连接的最大时间。", | |
| value_type="float", | |
| default_value=5.0, | |
| input_type="number", | |
| min_value=0.1, | |
| placeholder="5.0", | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="HTTP_WRITE_TIMEOUT", | |
| label="写入超时", | |
| description="向网络套接字写入请求数据的最大时间。", | |
| value_type="float", | |
| default_value=10.0, | |
| input_type="number", | |
| min_value=0.1, | |
| placeholder="10.0", | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="HTTP_POOL_TIMEOUT", | |
| label="连接池等待超时", | |
| description="从 HTTP 连接池获取可用连接的最大等待时间。", | |
| value_type="float", | |
| default_value=5.0, | |
| input_type="number", | |
| min_value=0.1, | |
| placeholder="5.0", | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="HTTP_DEFAULT_READ_TIMEOUT", | |
| label="非流式读取超时", | |
| description="普通请求(如鉴权、获取模型列表)的单次读取超时时间。", | |
| value_type="float", | |
| default_value=60.0, | |
| input_type="number", | |
| min_value=1.0, | |
| placeholder="60.0", | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="HTTP_STREAM_READ_TIMEOUT", | |
| label="流式单步读取超时", | |
| description="流式聊天中,相邻两个数据块(chunk)之间允许的最大静默时间。", | |
| value_type="float", | |
| default_value=120.0, | |
| input_type="number", | |
| min_value=1.0, | |
| placeholder="120.0", | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="HTTP_MODEL_FETCH_TIMEOUT", | |
| label="模型拉取超时", | |
| description="请求上游 /v1/models 接口的超时时间。", | |
| value_type="float", | |
| default_value=10.0, | |
| input_type="number", | |
| min_value=1.0, | |
| placeholder="10.0", | |
| db_persist=False, | |
| ), | |
| ConfigFieldSpec( | |
| key="CHAT_TOTAL_TIMEOUT", | |
| label="非流式端到端总超时", | |
| description="单次非流式聊天请求的总处理时间上限(包含所有内部重试)。", | |
| value_type="float", | |
| default_value=300.0, | |
| input_type="number", | |
| min_value=1.0, | |
| placeholder="300.0", | |
| db_persist=False, | |
| wide=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="HTTP_STREAM_TOTAL_TIMEOUT", | |
| label="流式端到端总超时", | |
| description="单次流式聊天请求的总处理时间上限(包含首包等待及所有抛出数据的总耗时)。", | |
| value_type="float", | |
| default_value=600.0, | |
| input_type="number", | |
| min_value=1.0, | |
| placeholder="600.0", | |
| db_persist=False, | |
| wide=True, | |
| ), | |
| ), | |
| ), | |
| ConfigSectionSpec( | |
| id="captcha", | |
| title="Captcha 验证码", | |
| description="控制验证码服务的启用、地址、超时和重试策略。Captcha token 由独立的 Captcha Token Service 通过浏览器生成。", | |
| icon="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3zm-1 13l-3-3 1.41-1.41L11 14.17l5.59-5.59L18 10l-7 5z", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="CAPTCHA_ENABLED", | |
| label="启用 Captcha", | |
| description="关闭后主服务不再调用 captcha 服务获取 verify_param。", | |
| value_type="bool", | |
| default_value=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="CAPTCHA_SERVICE_URL", | |
| label="Captcha 服务地址", | |
| description="独立部署的 Captcha Token Service 地址。", | |
| value_type="str", | |
| default_value="http://127.0.0.1:9000", | |
| placeholder="http://127.0.0.1:9000", | |
| wide=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="CAPTCHA_SERVICE_TIMEOUT", | |
| label="Captcha 服务超时(秒)", | |
| description="调用 captcha 服务的超时时间,包含浏览器生成 token 的时间。", | |
| value_type="float", | |
| default_value=18.0, | |
| input_type="number", | |
| min_value=1.0, | |
| placeholder="18.0", | |
| ), | |
| ConfigFieldSpec( | |
| key="CAPTCHA_MAX_RETRIES", | |
| label="Captcha 最大重试次数", | |
| description="captcha 验证失败时最多重试次数,每次重试会重新获取 token。", | |
| value_type="int", | |
| default_value=3, | |
| input_type="number", | |
| min_value=1, | |
| placeholder="3", | |
| ), | |
| ), | |
| ), | |
| ConfigSectionSpec( | |
| id="admin", | |
| title="后台安全", | |
| description="管理后台密码和会话密钥。修改后建议重新登录。", | |
| icon="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="ADMIN_PASSWORD", | |
| label="后台密码", | |
| description="管理后台登录密码。", | |
| value_type="str", | |
| default_value="admin123", | |
| input_type="password", | |
| placeholder="admin123", | |
| required=True, | |
| sensitive=True, | |
| ), | |
| ConfigFieldSpec( | |
| key="SESSION_SECRET_KEY", | |
| label="会话密钥", | |
| description="用于后台会话签名的密钥。", | |
| value_type="str", | |
| default_value="your-secret-key-change-in-production", | |
| input_type="password", | |
| placeholder="your-secret-key-change-in-production", | |
| required=True, | |
| sensitive=True, | |
| wide=True, | |
| ), | |
| ), | |
| ), | |
| ConfigSectionSpec( | |
| id="personalization", | |
| title="上游个性化", | |
| description="配置发送给上游的用户画像变量,用于自定义模型回复风格。", | |
| icon="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z", | |
| fields=( | |
| ConfigFieldSpec( | |
| key="UPSTREAM_USER_NAME", | |
| label="用户名称", | |
| description="上游上下文中的 {{USER_NAME}} 变量。", | |
| value_type="str", | |
| default_value="Guest", | |
| ), | |
| ConfigFieldSpec( | |
| key="UPSTREAM_USER_LOCATION", | |
| label="用户位置", | |
| description="上游上下文中的 {{USER_LOCATION}} 变量。", | |
| value_type="str", | |
| default_value="Unknown", | |
| ), | |
| ConfigFieldSpec( | |
| key="UPSTREAM_USER_TIMEZONE", | |
| label="用户时区", | |
| description="上游上下文中的 {{CURRENT_TIMEZONE}} 变量。", | |
| value_type="str", | |
| default_value="Asia/Shanghai", | |
| ), | |
| ConfigFieldSpec( | |
| key="UPSTREAM_USER_LANGUAGE", | |
| label="用户语言", | |
| description="上游上下文中的 {{USER_LANGUAGE}} 变量。", | |
| value_type="str", | |
| default_value="zh-CN", | |
| ), | |
| ), | |
| ), | |
| ) | |
| CONFIG_FIELD_SPECS = { | |
| field.key: field | |
| for section in CONFIG_SECTIONS | |
| for field in section.fields | |
| } | |
| MANAGED_ENV_KEYS = tuple(CONFIG_FIELD_SPECS.keys()) | |
| DB_PERSIST_KEYS = frozenset( | |
| key for key, field in CONFIG_FIELD_SPECS.items() if field.db_persist | |
| ) | |
| ReloadCallback = Callable[[], Awaitable[None]] | |
| def read_env_content(env_path: str | Path = ENV_PATH) -> str: | |
| path = Path(env_path) | |
| if not path.exists(): | |
| return "" | |
| return path.read_text(encoding="utf-8") | |
| def get_config_source_snapshot( | |
| *, | |
| env_values: Mapping[str, str] | None = None, | |
| db_values: Mapping[str, Any] | None = None, | |
| keys: tuple[str, ...] = (), | |
| ) -> dict[str, dict[str, Any]]: | |
| snapshot: dict[str, dict[str, Any]] = {} | |
| db_items = db_values or {} | |
| source_env = env_values or {} | |
| for key in keys: | |
| field = CONFIG_FIELD_SPECS.get(key) | |
| if field is None: | |
| continue | |
| snapshot[key] = { | |
| "env": source_env.get(key, "<unset>"), | |
| "db": db_items.get(key, "<unset>"), | |
| "db_persist": field.db_persist, | |
| } | |
| return snapshot | |
| def validate_env_source(content: str) -> str: | |
| normalized = content.replace("\r\n", "\n").replace("\r", "\n") | |
| for line_number, line in enumerate(normalized.splitlines(), start=1): | |
| stripped = line.strip() | |
| if not stripped or stripped.startswith("#"): | |
| continue | |
| if not _ENV_SOURCE_LINE_PATTERN.match(line): | |
| raise ValueError( | |
| f"第 {line_number} 行不是合法的 KEY=VALUE 格式。" | |
| ) | |
| return normalized | |
| def _serialize_config_value(value: object) -> str: | |
| """将 Python 值序列化为数据库存储的字符串。""" | |
| if isinstance(value, bool): | |
| return "true" if value else "false" | |
| if value is None: | |
| return "" | |
| return str(value) | |
| def _parse_db_value(raw: str, value_type: str) -> Any: | |
| """按字段类型解析数据库中的字符串值。""" | |
| if value_type == "bool": | |
| return raw.lower() in ("true", "1", "yes") | |
| if value_type == "int": | |
| try: | |
| return int(raw) | |
| except (ValueError, TypeError): | |
| return None | |
| if value_type == "float": | |
| try: | |
| return float(raw) | |
| except (ValueError, TypeError): | |
| return None | |
| return raw | |
| def _is_valid_option_value(field: ConfigFieldSpec, value: Any) -> bool: | |
| if field.input_type != "select" or not field.options: | |
| return True | |
| return str(value or "") in field.options | |
| def _is_valid_db_override_value(field: ConfigFieldSpec, raw_value: str) -> bool: | |
| parsed = _parse_db_value(raw_value, field.value_type) | |
| if parsed is None and field.value_type != "str": | |
| return False | |
| return _is_valid_option_value(field, parsed) | |
| async def load_db_overrides() -> dict[str, Any]: | |
| """从数据库加载可持久化的配置值,按类型解析后返回。 | |
| 仅返回解析成功且属于 DB_PERSIST_KEYS 的条目。 | |
| 如果数据库不可用则静默返回空字典。 | |
| """ | |
| try: | |
| from app.services.config_dao import get_config_dao | |
| dao = get_config_dao() | |
| raw_items = await dao.get_all() | |
| except Exception as exc: | |
| logger.debug( | |
| "读取数据库配置失败,回退到环境变量", | |
| exc_info=settings.DEBUG_LOGGING, | |
| ) | |
| return {} | |
| overrides: dict[str, Any] = {} | |
| for key, raw_value in raw_items.items(): | |
| if key not in DB_PERSIST_KEYS: | |
| continue | |
| field = CONFIG_FIELD_SPECS.get(key) | |
| if field is None: | |
| continue | |
| parsed = _parse_db_value(raw_value, field.value_type) | |
| if not _is_valid_db_override_value(field, raw_value): | |
| logger.warning("忽略无效的数据库配置项: %s=%s", key, raw_value) | |
| continue | |
| if parsed is not None or field.value_type == "str": | |
| overrides[key] = parsed | |
| return overrides | |
| async def apply_db_overrides(settings_obj: Any = settings) -> None: | |
| """从数据库加载配置并覆盖到 settings 对象上。""" | |
| overrides = await load_db_overrides() | |
| for key, value in overrides.items(): | |
| setattr(settings_obj, key, value) | |
| if overrides: | |
| logger.debug("已从数据库加载 %s 项配置覆盖", len(overrides)) | |
| def build_config_page_data( | |
| *, | |
| settings_obj: Any = settings, | |
| env_path: str | Path = ENV_PATH, | |
| env_example_path: str | Path = ENV_EXAMPLE_PATH, | |
| db_values: dict[str, str] | None = None, | |
| ) -> dict[str, Any]: | |
| env_file = Path(env_path) | |
| env_content = read_env_content(env_file) | |
| env_values = dotenv_values(env_file) if env_file.exists() else {} | |
| valid_db_keys = { | |
| key | |
| for key, raw_value in (db_values or {}).items() | |
| if ( | |
| (field := CONFIG_FIELD_SPECS.get(key)) is not None | |
| and _is_valid_db_override_value(field, raw_value) | |
| ) | |
| } | |
| sections: list[dict[str, Any]] = [] | |
| total_fields = 0 | |
| overridden_fields = 0 | |
| sensitive_fields = 0 | |
| restart_required_fields = 0 | |
| for section in CONFIG_SECTIONS: | |
| rendered_fields: list[dict[str, Any]] = [] | |
| for field in section.fields: | |
| total_fields += 1 | |
| if field.sensitive: | |
| sensitive_fields += 1 | |
| if field.restart_required: | |
| restart_required_fields += 1 | |
| # 判断来源优先级:数据库 > .env > 默认值 | |
| in_db = field.key in valid_db_keys | |
| in_env = field.key in env_values | |
| if in_db: | |
| source_label = "数据库" | |
| source_badge_class = ( | |
| "bg-blue-50 text-blue-700 ring-blue-200" | |
| ) | |
| overridden_fields += 1 | |
| elif in_env: | |
| source_label = ".env" | |
| source_badge_class = ( | |
| "bg-emerald-50 text-emerald-700 ring-emerald-200" | |
| ) | |
| overridden_fields += 1 | |
| else: | |
| source_label = "默认值" | |
| source_badge_class = ( | |
| "bg-slate-100 text-slate-600 ring-slate-200" | |
| ) | |
| value = getattr(settings_obj, field.key, field.default_value) | |
| if value is None: | |
| value = "" | |
| rendered_fields.append( | |
| { | |
| "key": field.key, | |
| "label": field.label, | |
| "description": field.description, | |
| "value_type": field.value_type, | |
| "value": value, | |
| "input_type": field.input_type, | |
| "placeholder": field.placeholder, | |
| "required": field.required, | |
| "wide": field.wide, | |
| "sensitive": field.sensitive, | |
| "restart_required": field.restart_required, | |
| "min_value": field.min_value, | |
| "max_value": field.max_value, | |
| "source_label": source_label, | |
| "source_badge_class": source_badge_class, | |
| "options": field.options, | |
| } | |
| ) | |
| sections.append( | |
| { | |
| "id": section.id, | |
| "title": section.title, | |
| "description": section.description, | |
| "icon": section.icon, | |
| "fields": rendered_fields, | |
| "field_count": len(rendered_fields), | |
| } | |
| ) | |
| return { | |
| "sections": sections, | |
| "env_content": env_content, | |
| "overview": { | |
| "total_sections": len(CONFIG_SECTIONS), | |
| "total_fields": total_fields, | |
| "overridden_fields": overridden_fields, | |
| "default_fields": total_fields - overridden_fields, | |
| "sensitive_fields": sensitive_fields, | |
| "restart_required_fields": restart_required_fields, | |
| "env_exists": env_file.exists(), | |
| "env_path": str(env_file.resolve()), | |
| "env_line_count": len(env_content.splitlines()) if env_content else 0, | |
| "example_exists": Path(env_example_path).exists(), | |
| }, | |
| } | |
| def build_form_updates(form_data: Mapping[str, Any]) -> dict[str, object]: | |
| updates: dict[str, object] = {} | |
| for key in MANAGED_ENV_KEYS: | |
| field = CONFIG_FIELD_SPECS[key] | |
| if field.value_type == "bool": | |
| updates[key] = key in form_data | |
| continue | |
| raw_value = str(form_data.get(key, "") or "").strip() | |
| if field.required and raw_value == "": | |
| raise ValueError(f"{field.label} 不能为空。") | |
| if field.value_type == "int": | |
| try: | |
| parsed = int(raw_value) | |
| except ValueError as exc: | |
| raise ValueError(f"{field.label} 必须是整数。") from exc | |
| if field.min_value is not None and parsed < field.min_value: | |
| raise ValueError( | |
| f"{field.label} 不能小于 {field.min_value}。" | |
| ) | |
| if field.max_value is not None and parsed > field.max_value: | |
| raise ValueError( | |
| f"{field.label} 不能大于 {field.max_value}。" | |
| ) | |
| updates[key] = parsed | |
| continue | |
| if field.value_type == "float": | |
| try: | |
| parsed_float = float(raw_value) | |
| except ValueError as exc: | |
| raise ValueError(f"{field.label} 必须是数字。") from exc | |
| if field.min_value is not None and parsed_float < field.min_value: | |
| raise ValueError( | |
| f"{field.label} 不能小于 {field.min_value}。" | |
| ) | |
| if field.max_value is not None and parsed_float > field.max_value: | |
| raise ValueError( | |
| f"{field.label} 不能大于 {field.max_value}。" | |
| ) | |
| updates[key] = parsed_float | |
| continue | |
| if field.input_type == "select" and field.options and raw_value not in field.options: | |
| raise ValueError( | |
| f"{field.label} 必须是以下选项之一:{', '.join(field.options)}。" | |
| ) | |
| updates[key] = raw_value | |
| return updates | |
| async def _apply_env_change( | |
| writer: Callable[[Path], None], | |
| *, | |
| reload_callback: ReloadCallback, | |
| env_path: str | Path = ENV_PATH, | |
| ) -> None: | |
| path = Path(env_path) | |
| had_existing_file = path.exists() | |
| previous_content = read_env_content(path) if had_existing_file else "" | |
| try: | |
| writer(path) | |
| await reload_callback() | |
| except Exception: | |
| if had_existing_file: | |
| path.write_text(previous_content, encoding="utf-8") | |
| elif path.exists(): | |
| path.unlink() | |
| try: | |
| await reload_callback() | |
| except Exception as restore_exc: | |
| logger.warning(f"⚠️ 回滚配置后重新加载失败: {restore_exc}") | |
| raise | |
| async def save_form_config( | |
| form_data: Mapping[str, Any], | |
| *, | |
| reload_callback: ReloadCallback, | |
| env_path: str | Path = ENV_PATH, | |
| ) -> dict[str, object]: | |
| updates = build_form_updates(form_data) | |
| # 分流:db_persist=True → 数据库,其余 → .env | |
| db_updates: dict[str, str] = {} | |
| env_updates: dict[str, object] = {} | |
| for key, value in updates.items(): | |
| if key in DB_PERSIST_KEYS: | |
| db_updates[key] = _serialize_config_value(value) | |
| else: | |
| env_updates[key] = value | |
| # 写入数据库 | |
| if db_updates: | |
| try: | |
| from app.services.config_dao import get_config_dao | |
| dao = get_config_dao() | |
| await dao.set_many(db_updates) | |
| logger.debug("已保存 %s 项配置到数据库", len(db_updates)) | |
| except Exception as exc: | |
| logger.warning( | |
| "⚠️ 数据库保存失败,回退到环境变量", | |
| exc_info=settings.DEBUG_LOGGING, | |
| ) | |
| env_updates.update(updates) # fallback: 全部写入 .env | |
| db_updates.clear() | |
| # 写入 .env(仅非数据库字段或回退字段) | |
| if env_updates: | |
| async def _reload() -> None: | |
| await reload_callback() | |
| def _writer(target_path: Path) -> None: | |
| update_env_file(env_updates, env_path=target_path) | |
| await _apply_env_change( | |
| _writer, reload_callback=_reload, env_path=env_path | |
| ) | |
| else: | |
| # 即使没有 env 变更,也需要重新加载以应用 DB 值 | |
| await reload_callback() | |
| return updates | |
| async def save_source_config( | |
| env_content: str, | |
| *, | |
| reload_callback: ReloadCallback, | |
| env_path: str | Path = ENV_PATH, | |
| ) -> None: | |
| normalized = validate_env_source(env_content) | |
| def _writer(target_path: Path) -> None: | |
| content = normalized.rstrip("\n") | |
| target_path.write_text( | |
| f"{content}\n" if content else "", | |
| encoding="utf-8", | |
| ) | |
| await _apply_env_change( | |
| _writer, | |
| reload_callback=reload_callback, | |
| env_path=env_path, | |
| ) | |
| async def reset_env_to_example( | |
| *, | |
| reload_callback: ReloadCallback, | |
| env_path: str | Path = ENV_PATH, | |
| env_example_path: str | Path = ENV_EXAMPLE_PATH, | |
| ) -> None: | |
| example_path = Path(env_example_path) | |
| if not example_path.exists(): | |
| raise FileNotFoundError(".env.example 不存在") | |
| example_content = example_path.read_text(encoding="utf-8") | |
| def _writer(target_path: Path) -> None: | |
| content = example_content.rstrip("\n") | |
| target_path.write_text( | |
| f"{content}\n" if content else "", | |
| encoding="utf-8", | |
| ) | |
| await _apply_env_change( | |
| _writer, | |
| reload_callback=reload_callback, | |
| env_path=env_path, | |
| ) | |