diff --git a/.env.example b/.env.example index a05dd69..538d736 100644 --- a/.env.example +++ b/.env.example @@ -1,149 +1,46 @@ -# 作用:标记当前运行环境并控制日志、调试和安全默认值。必填。可选值为 development、test、production。 +# 这份文件只保留“应用启动前必须存在”的基础配置。请运行 scripts/bootstrap.ps1 自动生成 .env,不需要手工填写。 + +# 作用:运行环境。开发使用 development;正式部署选择 production。默认 development。 APP_ENV=development -# 作用:供日志和可观测性识别当前服务。必填。通常无需修改。 +# 作用:服务名称,只用于日志和监控展示。通常无需修改。 APP_NAME=zhuangxiu-api -# 作用:控制后端日志输出级别。必填。开发使用 INFO,排障可临时改为 DEBUG,生产不建议 DEBUG。 +# 作用:日志详细程度。INFO 适合日常使用;DEBUG 仅用于短期排障。默认 INFO。 LOG_LEVEL=INFO -# 作用:后端监听地址。必填。容器内使用 0.0.0.0,本机仅监听本地时可用 127.0.0.1。 +# 作用:API 在容器内监听的地址。Docker 部署保持 0.0.0.0。 API_HOST=0.0.0.0 -# 作用:后端 HTTP 端口。必填。修改后需要同步更新 NEXT_PUBLIC_API_BASE_URL。 +# 作用:API 对外端口。默认 8000;端口冲突时再修改。 API_PORT=8000 -# 作用:浏览器允许访问后端的前端来源,多个来源使用英文逗号分隔。必填。生产环境不要使用星号。 +# 作用:允许访问 API 的前端地址。多个地址使用英文逗号分隔。 CORS_ORIGINS=http://localhost:3000 -# 作用:前端访问后端 API 的公开地址。必填。该值会进入浏览器代码,不得包含任何密钥。 +# 作用:浏览器访问 API 的公开地址。该值会进入前端代码,不得包含密钥。 NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -# 作用:PostgreSQL 数据库名。必填。Docker Compose 会用它初始化数据库。 +# 作用:PostgreSQL 数据库名。安装器会直接使用默认值创建数据库。 POSTGRES_DB=zhuangxiu -# 作用:PostgreSQL 应用账号。必填。生产环境不要使用 postgres 超级用户。 +# 作用:PostgreSQL 应用账号。默认使用非超级用户 zhuangxiu。 POSTGRES_USER=zhuangxiu -# 作用:PostgreSQL 应用密码。必填。使用 `openssl rand -hex 24` 单独生成,不要与其他密码复用。 -POSTGRES_PASSWORD=replace_with_random_hex +# 作用:PostgreSQL 密码。不要手填;scripts/bootstrap.ps1 会用系统加密随机数自动替换。 +POSTGRES_PASSWORD=__AUTO_POSTGRES_PASSWORD__ -# 作用:后端连接 PostgreSQL 的完整地址。必填。若密码包含特殊字符需要做 URL 编码。 -DATABASE_URL=postgresql+psycopg://zhuangxiu:replace_with_random_hex@postgres:5432/zhuangxiu +# 作用:后端连接 PostgreSQL 的地址。密码占位符会与上一项同时自动替换。 +DATABASE_URL=postgresql://zhuangxiu:__AUTO_POSTGRES_PASSWORD__@postgres:5432/zhuangxiu -# 作用:Redis 访问密码。必填。使用 `openssl rand -hex 24` 生成,生产环境不可留空。 -REDIS_PASSWORD=replace_with_random_hex +# 作用:Redis 密码。不要手填;scripts/bootstrap.ps1 会自动生成独立随机密码。 +REDIS_PASSWORD=__AUTO_REDIS_PASSWORD__ -# 作用:后端连接 Redis 的完整地址,用于队列、缓存和分布式锁。必填。 -REDIS_URL=redis://:replace_with_random_hex@redis:6379/0 +# 作用:后端连接 Redis 的地址。密码占位符会与上一项同时自动替换。 +REDIS_URL=redis://:__AUTO_REDIS_PASSWORD__@redis:6379/0 -# 作用:MinIO 的内部 S3 API 地址。必填。应用与 NAS 同网时填写 NAS 内网地址和 API 端口,不是控制台端口。 -S3_ENDPOINT=http://192.168.200.36:9000 +# 作用:加密保存可视化设置的目录。Docker 会挂载独立数据卷,通常无需修改。 +RUNTIME_SETTINGS_DIR=.runtime -# 作用:浏览器通过预签名 URL 访问 MinIO 的公开 HTTPS 地址。外部用户上传时必填,纯内网开发可与 S3_ENDPOINT 相同。 -S3_PUBLIC_ENDPOINT=http://192.168.200.36:9000 - -# 作用:MinIO 专用服务账号的 Access Key。必填。不要填写 MINIO_ROOT_USER,也不要暴露给前端。 -S3_ACCESS_KEY_ID=replace_with_minio_service_account - -# 作用:MinIO 专用服务账号的 Secret Key。必填。由 MinIO 创建服务账号时生成,只保存在后端。 -S3_SECRET_ACCESS_KEY=replace_with_minio_service_secret - -# 作用:S3 签名使用的区域。MinIO 通常使用 us-east-1,除非服务端配置了其他区域。 -S3_REGION=us-east-1 - -# 作用:保存用户原始 PDF、DXF 和户型图片的私有 bucket。必填。 -S3_BUCKET_INPUTS=renovation-inputs - -# 作用:保存 SVG、蒙版、控制图和 3D 白模等中间结果的私有 bucket。必填。 -S3_BUCKET_DERIVED=renovation-derived - -# 作用:保存最终效果图、局部修改图和版本预览的私有 bucket。必填。 -S3_BUCKET_RENDERS=renovation-renders - -# 作用:强制使用路径式 S3 地址。MinIO 通常需要 true,AWS S3 通常可以为 false。 -S3_FORCE_PATH_STYLE=true - -# 作用:预签名上传和下载 URL 的有效秒数。必填。建议保持较短,默认 15 分钟。 -S3_PRESIGNED_URL_TTL_SECONDS=900 - -# 作用:启用百度 OCR。矢量 PDF 主流程不依赖 OCR,扫描图或文字层损坏时设为 true。 -BAIDU_OCR_ENABLED=false - -# 作用:百度智能云 OCR 应用的 API Key。启用百度 OCR 时必填,只能放在后端。 -BAIDU_OCR_API_KEY= - -# 作用:百度智能云 OCR 应用的 Secret Key。启用百度 OCR 时必填,不得写入日志或返回前端。 -BAIDU_OCR_SECRET_KEY= - -# 作用:百度 OCR OAuth Token 接口地址。通常无需修改,保留配置便于企业代理或兼容服务。 -BAIDU_OCR_TOKEN_URL=https://aip.baidubce.com/oauth/2.0/token - -# 作用:默认大语言模型供应商名称。必填。当前适配层预留 openai、gemini 和 compatible。 -LLM_DEFAULT_PROVIDER=openai - -# 作用:OpenAI 或 OpenAI 兼容服务的 API Key。使用该供应商时必填,只在服务端读取。 -OPENAI_API_KEY= - -# 作用:OpenAI 兼容 API 的基础地址。官方服务保留默认值,使用代理或兼容服务时修改。 -OPENAI_BASE_URL=https://api.openai.com/v1 - -# 作用:空间理解与总调度默认模型名称。必填。实际模型需与所选供应商账号权限一致。 -OPENAI_TEXT_MODEL=gpt-5 - -# 作用:OpenAI 图像生成或编辑默认模型。启用 OpenAI 图像适配器时必填。 -OPENAI_IMAGE_MODEL=gpt-image-2 - -# 作用:Google Gemini API Key。启用 Gemini 或 Nano Banana 图像适配器时必填。 -GEMINI_API_KEY= - -# 作用:Gemini 多模态推理默认模型名称。启用 Gemini 时必填。 -GEMINI_MODEL=gemini-3-pro - -# 作用:火山方舟模型调用 API Key。启用 Seedream 时必填,建议使用方舟专用 Key 而非云账号长期 AK/SK。 -ARK_API_KEY= - -# 作用:Seedream 图像模型的 Endpoint ID 或模型标识。启用 Seedream 时必填,以火山方舟控制台显示值为准。 -SEEDREAM_MODEL_ENDPOINT= - -# 作用:优先使用的生图供应商顺序,多个值使用英文逗号分隔。必填。路由器会按任务能力和可用性选择。 -IMAGE_PROVIDER_PRIORITY=seedream,openai,gemini - -# 作用:本地 GPU 推理服务地址。启用本地户型分割、深度估计或审美评分时必填,仅使用内网地址。 -GPU_SERVICE_URL=http://gpu-worker:8100 - -# 作用:业务后端调用 GPU 服务时使用的内部 Bearer Token。启用 GPU 服务时必填,使用 `openssl rand -hex 32` 生成。 -GPU_SERVICE_TOKEN=replace_with_random_hex - -# 作用:用户登录 JWT 的签名密钥。启用用户系统时必填,使用 `openssl rand -hex 32` 独立生成。 -JWT_SECRET=replace_with_random_hex - -# 作用:签名或加密会话 Cookie 和 CSRF 状态。启用用户系统时必填,使用 `openssl rand -hex 32` 生成,不得与 JWT_SECRET 共用。 -SESSION_SECRET=replace_with_random_hex - -# 作用:加密数据库中的用户模型 Key 和第三方令牌。必填。使用 `openssl rand -base64 32` 生成,丢失后旧密文无法恢复。 -APP_ENCRYPTION_KEY=replace_with_32_byte_base64_key - -# 作用:验证内部异步回调的 HMAC 签名。启用 Worker 回调时必填,使用 `openssl rand -hex 32` 独立生成。 -WEBHOOK_SIGNING_SECRET=replace_with_random_hex - -# 作用:启用 Langfuse 调用链追踪。开发初期可以关闭,准备评测不同模型后建议开启。 -LANGFUSE_ENABLED=false - -# 作用:Langfuse 服务地址。自部署时填写内网或 HTTPS 地址,关闭追踪时可保留默认值。 -LANGFUSE_HOST=http://langfuse-web:3000 - -# 作用:Langfuse 项目的 Public Key。启用追踪时必填,该值用于标识项目但仍不应放入公开仓库。 -LANGFUSE_PUBLIC_KEY= - -# 作用:Langfuse 项目的 Secret Key。启用追踪时必填,只能由后端和 Worker 使用。 -LANGFUSE_SECRET_KEY= - -# 作用:Sentry DSN,用于收集后端异常和性能事件。未部署或暂不使用 Sentry 时留空。 -SENTRY_DSN= - -# 作用:标记 Sentry 事件所属环境。填写 development、staging 或 production,便于区分告警。 -SENTRY_ENVIRONMENT=development - -# 作用:Sentry 性能追踪采样率,范围 0 到 1。生产初期建议 0.05 到 0.2,开发可设为 0。 -SENTRY_TRACES_SAMPLE_RATE=0 +# 作用:运行期设置的主加密密钥。留空时首次启动自动生成并保存在私有数据卷中;迁移服务器时需同时迁移该卷。 +SETTINGS_MASTER_KEY= diff --git a/.gitignore b/.gitignore index fb7820c..300bae1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ venv/ .pytest_cache/ .mypy_cache/ .ruff_cache/ +.runtime/ # Node.js node_modules/ diff --git a/README.md b/README.md index 2e186a3..4415671 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,16 @@ docs 架构与工作流设计 ## 本地启动 -1. 将 `.env.example` 复制为 `.env`,按注释填写配置。 -2. 安装前端依赖:`pnpm install`。 -3. 启动前端:`pnpm dev`。 -4. 创建 Python 虚拟环境并安装 API:`pip install -e "services/api[dev]"`。 -5. 启动 API:`uvicorn app.main:app --app-dir services/api --reload --port 8000`。 +推荐使用 Docker: + +1. 在 PowerShell 运行 `./scripts/bootstrap.ps1 -Force`,自动生成数据库和 Redis 密码。 +2. 运行 `docker compose up -d --build`。 +3. 打开 `http://localhost:3000`,首次进入会自动显示“系统设置”。 +4. 在网页中填写 MinIO、百度 OCR 和模型 API,并逐项点击测试。 + +日常使用不需要编辑 `.env`。它只保留应用启动前必须存在的基础参数,模型密钥等运行期配置会在网页中填写并加密保存。 + +需要单独开发前后端时:安装前端依赖 `pnpm install`,启动前端 `pnpm dev`;创建 Python 虚拟环境并运行 `pip install -e "services/api[dev]"`,随后使用 `uvicorn app.main:app --app-dir services/api --reload --port 8000` 启动 API。 前端默认访问 `http://localhost:3000`,API 文档默认位于 `http://localhost:8000/docs`。 @@ -35,5 +40,6 @@ docs 架构与工作流设计 - 提供结构化 Style DNA 面板。 - 提供可验证的工作流状态机和命令接口。 - 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。 +- 提供分类设置中心、连接测试、必填检查和工作流就绪门禁。 -详见 [架构设计](docs/architecture.md) 与 [工作流定义](docs/workflow.md)。 +详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。 diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 6a233bb..514fb1b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -59,6 +59,11 @@ textarea { font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; } +select { + font-family: inherit; + color: inherit; +} + button, input, textarea { @@ -69,6 +74,11 @@ button { cursor: pointer; } +button:disabled { + cursor: not-allowed; + opacity: 0.48; +} + button:focus-visible, input:focus-visible, textarea:focus-visible, @@ -150,6 +160,14 @@ textarea:focus-visible, gap: 9px; } +.settings-alert-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--warning); + box-shadow: 0 0 0 3px var(--warning-soft); +} + .save-state { gap: 5px; margin-right: 6px; @@ -853,6 +871,448 @@ textarea:focus-visible, font-size: 10px; } +.settings-backdrop { + position: fixed; + inset: 0; + z-index: 100; + padding: 24px; + display: grid; + place-items: center; + background: rgb(18 24 20 / 0.56); + backdrop-filter: blur(8px); +} + +.settings-dialog { + width: min(1120px, 100%); + height: min(820px, calc(100dvh - 48px)); + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: var(--radius-surface); + background: var(--surface); + box-shadow: 0 34px 100px rgb(7 13 9 / 0.34); +} + +.settings-header { + padding: 20px 22px 16px; + display: flex; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--line); + background: var(--surface-raised); +} + +.settings-header h1, +.settings-header p, +.settings-section-heading h2, +.settings-section-heading p, +.settings-readiness p, +.setting-status-row p, +.setting-toggle-row p, +.deployment-note p { + margin: 0; +} + +.settings-header h1 { + margin-top: 3px; + font-size: 21px; + font-weight: 690; + letter-spacing: -0.02em; +} + +.settings-header > div > p:last-child { + margin-top: 5px; + color: var(--text-soft); + font-size: 11px; +} + +.settings-kicker { + color: var(--accent-strong); + font-size: 9px; + font-weight: 720; + letter-spacing: 0.12em; +} + +.settings-readiness { + min-height: 70px; + padding: 11px 22px; + display: grid; + grid-template-columns: auto minmax(220px, auto) minmax(180px, 1fr); + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--accent-soft) 68%, var(--surface)); +} + +.readiness-orb { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 50%; + color: var(--warning); + background: var(--warning-soft); +} + +.readiness-orb.ready { + color: var(--accent-strong); + background: var(--accent-soft); +} + +.settings-readiness strong { + display: block; + font-size: 12px; +} + +.settings-readiness p { + margin-top: 4px; + color: var(--text-soft); + font-size: 10px; +} + +.readiness-meter { + height: 5px; + overflow: hidden; + border-radius: 5px; + background: var(--line); +} + +.readiness-meter span { + height: 100%; + display: block; + border-radius: inherit; + background: var(--accent); + transition: width 240ms ease; +} + +.settings-layout { + min-height: 0; + display: grid; + grid-template-columns: 190px minmax(0, 1fr); +} + +.settings-nav { + padding: 14px 10px; + display: grid; + align-content: start; + gap: 3px; + border-right: 1px solid var(--line); + background: var(--surface-muted); +} + +.settings-nav button { + min-height: 43px; + padding: 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid transparent; + border-radius: 8px; + color: var(--text-soft); + background: transparent; + text-align: left; + font-size: 11px; + font-weight: 620; +} + +.settings-nav button:hover { + background: color-mix(in srgb, var(--surface-raised) 72%, transparent); +} + +.settings-nav button.active { + color: var(--text); + border-color: var(--line); + background: var(--surface-raised); +} + +.settings-nav small { + color: var(--text-faint); + font-size: 8px; + font-weight: 650; +} + +.settings-nav small.required { + color: var(--warning); +} + +.settings-nav small.done { + color: var(--accent-strong); +} + +.settings-content { + min-width: 0; + overflow-y: auto; + padding: 22px 26px 30px; +} + +.settings-loading { + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-soft); + font-size: 11px; +} + +.settings-section-heading { + padding-bottom: 16px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + border-bottom: 1px solid var(--line); +} + +.settings-section-heading h2 { + font-size: 16px; + font-weight: 690; +} + +.settings-section-heading p { + max-width: 620px; + margin-top: 5px; + color: var(--text-soft); + font-size: 10px; + line-height: 1.6; +} + +.advanced-toggle { + min-width: 86px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + color: var(--text-soft); + font-size: 9px; +} + +.advanced-toggle input { + accent-color: var(--accent); +} + +.settings-fields { + display: grid; +} + +.setting-field, +.setting-toggle-row, +.setting-status-row { + padding: 15px 0; + border-bottom: 1px solid var(--line); +} + +.setting-field { + display: grid; + grid-template-columns: minmax(160px, 0.36fr) minmax(260px, 0.64fr); + gap: 4px 22px; +} + +.setting-field-label { + display: flex; + align-items: baseline; + gap: 6px; +} + +.setting-field-label strong, +.setting-toggle-row strong, +.setting-status-row strong { + font-size: 11px; + font-weight: 650; +} + +.setting-field-label small { + color: var(--warning); + font-size: 8px; +} + +.setting-description { + grid-column: 1; + color: var(--text-faint); + font-size: 9px; + line-height: 1.55; +} + +.setting-control-wrap { + grid-column: 2; + grid-row: 1 / span 2; + display: flex; + align-items: center; + gap: 7px; +} + +.setting-control-wrap input, +.setting-control-wrap select { + width: 100%; + min-width: 0; + height: 38px; + padding: 0 10px; + border: 1px solid var(--line-strong); + border-radius: 8px; + color: var(--text); + background: var(--surface-raised); + font-size: 10px; +} + +.setting-control-wrap input::placeholder { + color: var(--text-faint); + opacity: 1; +} + +.generate-button { + min-width: 88px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--accent-strong); + background: var(--accent-soft); + font-size: 9px; + font-weight: 650; +} + +.option-help { + grid-column: 2; + color: var(--accent-strong); + font-size: 9px; +} + +.setting-toggle-row, +.setting-status-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 14px; +} + +.setting-toggle-row p, +.setting-status-row p { + margin-top: 4px; + color: var(--text-faint); + font-size: 9px; + line-height: 1.5; +} + +.setting-toggle-row input { + width: 35px; + height: 19px; + accent-color: var(--accent); +} + +.setting-status-row { + grid-template-columns: auto 1fr auto; +} + +.setting-status-row > span { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 50%; +} + +.setting-status-row > span.configured { + color: var(--accent-strong); + background: var(--accent-soft); +} + +.setting-status-row > span.missing { + color: var(--warning); + background: var(--warning-soft); +} + +.setting-status-row > small { + color: var(--text-soft); + font-size: 9px; +} + +.deployment-note { + margin-top: 16px; + padding: 12px; + display: grid; + grid-template-columns: auto 1fr; + gap: 9px; + color: var(--text-soft); + border-left: 3px solid var(--accent); + background: var(--accent-soft); +} + +.deployment-note p { + font-size: 9px; + line-height: 1.65; +} + +.deployment-note code { + color: var(--text); + font-family: "Cascadia Code", monospace; +} + +.settings-test-actions { + margin-top: 18px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.settings-test-actions .button svg:last-child { + color: var(--accent-strong); +} + +.spin { + animation: settings-spin 900ms linear infinite; +} + +@keyframes settings-spin { + to { transform: rotate(360deg); } +} + +.settings-footer { + min-height: 68px; + padding: 12px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + border-top: 1px solid var(--line); + background: var(--surface-raised); +} + +.settings-notice, +.settings-footer-actions { + display: flex; + align-items: center; +} + +.settings-notice { + min-width: 0; + gap: 6px; + color: var(--text-soft); + font-size: 9px; +} + +.settings-notice span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-notice.error { + color: var(--warning); +} + +.settings-notice.success { + color: var(--accent-strong); +} + +.settings-footer-actions { + gap: 8px; +} + @media (max-width: 1180px) { .studio-grid { grid-template-columns: 190px minmax(520px, 1fr); @@ -871,7 +1331,7 @@ textarea:focus-visible, } .save-state, - .topbar-actions .button-secondary, + .topbar-actions .button-secondary:not(.settings-button), .brand-subtitle { display: none; } @@ -933,6 +1393,69 @@ textarea:focus-visible, .conversation { min-height: 230px; } + + .settings-backdrop { + padding: 0; + } + + .settings-dialog { + height: 100dvh; + border: 0; + border-radius: 0; + } + + .settings-header { + padding: 14px; + } + + .settings-readiness { + padding: 10px 14px; + grid-template-columns: auto 1fr; + } + + .readiness-meter { + grid-column: 1 / -1; + } + + .settings-layout { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + } + + .settings-nav { + padding: 8px; + display: flex; + overflow-x: auto; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .settings-nav button { + min-width: 108px; + } + + .settings-content { + padding: 18px 15px 24px; + } + + .setting-field { + grid-template-columns: 1fr; + } + + .setting-control-wrap, + .option-help { + grid-column: 1; + grid-row: auto; + margin-top: 6px; + } + + .settings-footer { + padding: 10px 12px; + } + + .settings-notice { + display: none; + } } @media (prefers-reduced-motion: reduce) { diff --git a/apps/web/components/settings-center.tsx b/apps/web/components/settings-center.tsx new file mode 100644 index 0000000..ce69596 --- /dev/null +++ b/apps/web/components/settings-center.tsx @@ -0,0 +1,412 @@ +"use client"; + +import { + ArrowClockwiseIcon, + CheckCircleIcon, + DatabaseIcon, + FloppyDiskIcon, + KeyIcon, + PlugIcon, + WarningCircleIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useEffect, useMemo, useState } from "react"; + +import type { + SettingField, + SettingsReadiness, + SettingsResponse, + TestResult, +} from "@/types/settings"; + +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; + +const testLabels: Record = { + infrastructure: "测试数据库与队列", + storage: "测试 MinIO", + baidu_ocr: "测试百度 OCR", + ai_models: "验证 API 与模型", + gpu: "测试 GPU Worker", + langfuse: "测试 Langfuse", + sentry: "测试 Sentry", +}; + +type SettingsCenterProps = { + open: boolean; + onClose: () => void; + onReadinessChange: (readiness: SettingsReadiness) => void; +}; + +function isVisible(field: SettingField, draft: Record) { + if (!field.visible_when) return true; + return Object.entries(field.visible_when).every(([key, expected]) => { + if (key.endsWith("__not")) return draft[key.slice(0, -5)] !== expected; + return draft[key] === expected; + }); +} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : "请求失败,请检查 API 服务是否启动。"; +} + +export function SettingsCenter({ open, onClose, onReadinessChange }: SettingsCenterProps) { + const [data, setData] = useState(null); + const [draft, setDraft] = useState>({}); + const [activeCategoryId, setActiveCategoryId] = useState("deployment"); + const [showAdvanced, setShowAdvanced] = useState(false); + const [busy, setBusy] = useState(null); + const [notice, setNotice] = useState<{ tone: "success" | "error"; text: string } | null>(null); + const [testResults, setTestResults] = useState>({}); + + const activeCategory = useMemo( + () => data?.categories.find((category) => category.id === activeCategoryId) ?? data?.categories[0], + [activeCategoryId, data], + ); + + function applyResponse(response: SettingsResponse) { + setData(response); + setDraft(response.values); + onReadinessChange(response.readiness); + } + + async function loadSettings() { + setBusy("load"); + setNotice(null); + try { + const response = await fetch(`${API_BASE}/v1/settings`, { cache: "no-store" }); + if (!response.ok) throw new Error("系统设置读取失败。请确认后端 API 已启动。"); + applyResponse(await response.json() as SettingsResponse); + } catch (error) { + setNotice({ tone: "error", text: getErrorMessage(error) }); + } finally { + setBusy(null); + } + } + + useEffect(() => { + if (!open) return; + let cancelled = false; + fetch(`${API_BASE}/v1/settings`, { cache: "no-store" }) + .then((response) => { + if (!response.ok) throw new Error("系统设置读取失败。请确认后端 API 已启动。"); + return response.json() as Promise; + }) + .then((response) => { + if (cancelled) return; + setData(response); + setDraft(response.values); + onReadinessChange(response.readiness); + }) + .catch((error: unknown) => { + if (!cancelled) setNotice({ tone: "error", text: getErrorMessage(error) }); + }); + return () => { cancelled = true; }; + }, [open, onReadinessChange]); + + function updateField(key: string, value: unknown) { + setDraft((current) => ({ ...current, [key]: value })); + setNotice(null); + } + + async function saveSettings(showSuccess = true) { + setBusy("save"); + setNotice(null); + try { + const response = await fetch(`${API_BASE}/v1/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values: draft }), + }); + if (!response.ok) throw new Error("保存失败,请检查填写内容。"); + const payload = await response.json() as SettingsResponse; + applyResponse(payload); + if (showSuccess) setNotice({ tone: "success", text: "设置已加密保存。" }); + return payload; + } catch (error) { + setNotice({ tone: "error", text: getErrorMessage(error) }); + throw error; + } finally { + setBusy(null); + } + } + + async function testTarget(target: string) { + setBusy(target); + setNotice(null); + try { + await saveSettings(false); + setBusy(target); + const response = await fetch(`${API_BASE}/v1/settings/test`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target, values: {} }), + }); + const result = await response.json() as TestResult; + setTestResults((current) => ({ ...current, [target]: result })); + await loadSettings(); + setNotice({ tone: result.ok ? "success" : "error", text: result.message }); + } catch (error) { + setNotice({ tone: "error", text: getErrorMessage(error) }); + } finally { + setBusy(null); + } + } + + async function generateFor(field: SettingField) { + if (!field.generator) return; + setBusy(field.key); + try { + const response = await fetch(`${API_BASE}/v1/settings/generate-secret`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: field.generator }), + }); + if (!response.ok) throw new Error("密钥生成失败。"); + const payload = await response.json() as { value: string }; + updateField(field.key, payload.value); + setNotice({ tone: "success", text: `${field.label}已生成,点击保存后会加密存储。` }); + } catch (error) { + setNotice({ tone: "error", text: getErrorMessage(error) }); + } finally { + setBusy(null); + } + } + + function closeSafely() { + if (data) { + const secretKeys = new Set(data.categories.flatMap((category) => category.fields) + .filter((field) => field.secret) + .map((field) => field.key)); + setDraft((current) => Object.fromEntries( + Object.entries(current).filter(([key]) => !secretKeys.has(key)), + )); + } + onClose(); + } + + if (!open) return null; + + return ( +
+
+
+
+

首次使用向导

+

系统设置

+

只填写你真正使用的服务;密钥保存后不会再次显示。

+
+ +
+ + {data ? ( +
+
+ {data.readiness.ready ? : } +
+
+ {data.readiness.ready ? "工作流已就绪" : "完成必备配置后才能开始设计"} +

{data.readiness.completed_required} / {data.readiness.total_required} 个必备分类已配置并测试

+
+
+ +
+
+ ) : null} + +
+ + +
+ {busy === "load" && !data ? ( +
正在读取设置
+ ) : activeCategory && data ? ( + <> +
+
+

{activeCategory.label}

+

{activeCategory.description}

+
+ {activeCategory.fields.some((field) => field.advanced) ? ( + + ) : null} +
+ +
+ {activeCategory.fields.map((field) => { + if (!isVisible(field, draft) || (field.advanced && !showAdvanced)) return null; + return ( + updateField(field.key, value)} + onGenerate={() => void generateFor(field)} + busy={busy === field.key} + /> + ); + })} +
+ + {activeCategory.id === "deployment" ? ( +
+ +

数据库和 Redis 密码存在“先有服务还是先打开设置页”的启动顺序问题,因此不在这里修改。运行 scripts/bootstrap.ps1 时会一次性安全生成,你不需要安装 OpenSSL。

+
+ ) : null} + +
+ {activeCategory.test_targets.map((target) => { + const recorded = testResults[target] ?? (data.tests[target] as TestResult | undefined); + return ( + + ); + })} +
+ + ) : null} +
+
+ +
+
+ {notice?.tone === "error" ? : null} + {notice?.tone === "success" ? : null} + {notice?.text ?? "必备项完成并测试通过后,设计工作流会自动解锁。"} +
+
+ + +
+
+
+
+ ); +} + +type SettingControlProps = { + field: SettingField; + value: unknown; + configured: boolean; + onChange: (value: unknown) => void; + onGenerate: () => void; + busy: boolean; +}; + +function SettingControl({ field, value, configured, onChange, onGenerate, busy }: SettingControlProps) { + const selectedOption = field.options.find((option) => option.value === String(value ?? "")); + + if (field.kind === "status") { + return ( +
+ + {configured ? : } + +
+ {field.label} +

{field.description}

+
+ {configured ? "已配置" : "缺失"} +
+ ); + } + + if (field.kind === "toggle") { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/components/workflow-studio.tsx b/apps/web/components/workflow-studio.tsx index 94fe7c5..f9a47ad 100644 --- a/apps/web/components/workflow-studio.tsx +++ b/apps/web/components/workflow-studio.tsx @@ -11,6 +11,7 @@ import { CubeIcon, EyeIcon, EyeSlashIcon, + GearSixIcon, ImageIcon, LockSimpleIcon, PaperPlaneTiltIcon, @@ -21,8 +22,9 @@ import { WarningCircleIcon, } from "@phosphor-icons/react"; import Image from "next/image"; -import { FormEvent, useMemo, useState } from "react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { SettingsCenter } from "@/components/settings-center"; import { initialLayers, initialMessages, @@ -30,8 +32,10 @@ import { workflowStages, } from "@/lib/demo"; import type { ChatMessage } from "@/types/workflow"; +import type { SettingsReadiness } from "@/types/settings"; const iconWeight = "regular" as const; +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; export function WorkflowStudio() { const [layers, setLayers] = useState(initialLayers); @@ -39,12 +43,32 @@ export function WorkflowStudio() { const [messages, setMessages] = useState(initialMessages); const [draft, setDraft] = useState(""); const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference"); + const [settingsOpen, setSettingsOpen] = useState(false); + const [readiness, setReadiness] = useState(null); const currentDirection = useMemo( () => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0], [selectedDirection], ); + useEffect(() => { + async function loadReadiness() { + try { + const response = await fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" }); + if (!response.ok) return; + const current = await response.json() as SettingsReadiness; + setReadiness(current); + if (!current.ready && !window.sessionStorage.getItem("settings-intro-seen")) { + window.sessionStorage.setItem("settings-intro-seen", "true"); + setSettingsOpen(true); + } + } catch { + setReadiness(null); + } + } + void loadReadiness(); + }, []); + function toggleLayer(id: string) { setLayers((items) => items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)), @@ -84,9 +108,19 @@ export function WorkflowStudio() {
已保存 + -
@@ -327,6 +361,11 @@ export function WorkflowStudio() { + setSettingsOpen(false)} + onReadinessChange={setReadiness} + /> ); diff --git a/apps/web/types/settings.ts b/apps/web/types/settings.ts new file mode 100644 index 0000000..26d6bd5 --- /dev/null +++ b/apps/web/types/settings.ts @@ -0,0 +1,52 @@ +export type SettingOption = { + value: string; + label: string; + help: string; +}; + +export type SettingField = { + key: string; + label: string; + description: string; + kind: "text" | "password" | "url" | "select" | "combobox" | "toggle" | "number" | "status"; + required: boolean; + secret: boolean; + default: unknown; + placeholder: string; + options: SettingOption[]; + generator: "hex24" | "hex32" | "base64_32" | null; + advanced: boolean; + visible_when: Record | null; +}; + +export type SettingCategory = { + id: string; + label: string; + description: string; + test_targets: string[]; + required_for_workflow: boolean; + fields: SettingField[]; +}; + +export type SettingsReadiness = { + ready: boolean; + completed_required: number; + total_required: number; + missing: string[]; + untested: string[]; +}; + +export type SettingsResponse = { + categories: SettingCategory[]; + values: Record; + configured: Record; + tests: Record; + readiness: SettingsReadiness; +}; + +export type TestResult = { + target: string; + ok: boolean; + message: string; + details: Record; +}; diff --git a/contracts/workflow-contract.md b/contracts/workflow-contract.md index c8dd9f8..d0c5b3d 100644 --- a/contracts/workflow-contract.md +++ b/contracts/workflow-contract.md @@ -29,3 +29,13 @@ ``` `expected_revision` 用于乐观锁。若客户端基于旧版本提交,服务端返回 `409 Conflict`,避免覆盖其他修改。 + +## Settings 与 Readiness + +- `GET /v1/settings`:返回分类、非敏感值、每项是否已配置、测试结果和工作流就绪状态。 +- `PUT /v1/settings`:增量更新运行期设置;空密钥表示保留原值。 +- `POST /v1/settings/test`:保存后测试指定集成,不执行收费的模型生成任务。 +- `POST /v1/settings/generate-secret`:生成内部服务令牌,不生成第三方 API Key。 +- `GET /v1/readiness`:供前端决定是否解锁设计工作流。 + +任何密钥字段都不会出现在 `values` 中,只会在 `configured` 中返回布尔值。必备配置未完成或测试未通过时,上传与命令接口返回 `503` 和 `SETTINGS_INCOMPLETE`。 diff --git a/docker-compose.yml b/docker-compose.yml index 1629e4c..9bc7b65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,10 @@ services: - .env ports: - "${API_PORT:-8000}:8000" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - settings-data:/app/.runtime depends_on: postgres: condition: service_healthy @@ -58,3 +62,4 @@ services: volumes: postgres-data: redis-data: + settings-data: diff --git a/docs/architecture.md b/docs/architecture.md index fc26951..29beabe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,6 +21,8 @@ FastAPI Orchestrator |-- Workflow State Machine |-- Project Command Handler |-- Model Router + |-- Encrypted Settings Store + |-- Readiness Gate |-- Signed Upload Service | Workers @@ -61,5 +63,6 @@ Storage - 浏览器不接触 MinIO、OCR 或模型服务的永久密钥。 - 上传和下载通过短期预签名 URL。 -- 用户提供的模型 Key 使用 `APP_ENCRYPTION_KEY` 加密后存储。 +- 可视化设置中的密钥整体使用 Fernet 加密,接口只返回“是否已配置”,不返回原文。 +- 主加密密钥在首次启动时自动生成到私有数据卷,迁移服务器必须连同该卷一起备份。 - GPU 和内部 Worker 只接受内网请求,并校验内部服务令牌。 diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..940a56a --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,60 @@ +# 可视化系统设置 + +## 为什么不再把所有内容放进 `.env` + +`.env` 只负责应用启动前必须存在的 PostgreSQL、Redis、监听地址和运行期设置目录。数据库和 Redis 必须先启动,网页设置才有机会打开,因此它们不能完全由已运行后的网页管理。 + +`scripts/bootstrap.ps1` 使用操作系统的加密随机数一次性生成数据库和 Redis 密码,不依赖 OpenSSL,也不会在终端打印密码。普通用户不需要打开 `.env`。 + +以下内容改为网页配置并加密保存: + +- NAS MinIO 与三个 bucket。 +- 百度 OCR。 +- 总调度、空间理解和生图模型。 +- 本地或局域网 GPU Worker。 +- Langfuse 与 Sentry。 + +## 分类与门禁 + +工作流必备分类为: + +1. 基础运行:PostgreSQL、Redis、配置加密。 +2. 文件存储:MinIO 地址、服务账号和三个 bucket。 +3. AI 模型:统一 API、总调度模型、空间理解模型和生图模型。 + +必备分类既要填写完整,也要通过对应连接测试。未完成时,上传和工作流命令接口返回 `SETTINGS_INCOMPLETE`,前端“开始设计”按钮保持锁定。 + +百度 OCR、GPU、Langfuse 和 Sentry 是条件必填:关闭时不影响工作流;开启后应填写完整并测试。 + +## 聚合引擎 AIGC + +聚合引擎按 OpenAI 兼容网关接入,设置项包括: + +- API Base URL。 +- API Key。 +- 总调度模型 ID。 +- 空间理解模型 ID。 +- 默认生图模型 ID,预设为 `gpt-image-2`。 +- 高级路径:`/models`、`/chat/completions`、`/images/generations`、`/images/edits`。 + +平台的公开文档入口在未登录状态下会跳转首页,因此 Base URL 不在代码中猜测或写死。用户从登录后的开发者文档复制一次即可,随后“验证 API 与模型”会调用模型列表接口,检查 Key 和三个模型名。 + +## 为什么本地 GPU 仍然需要 Worker + +网页和 Docker 容器不能安全、稳定地直接执行宿主机 CUDA。把 GPU 推理拆成 Worker 可以隔离模型依赖、显存崩溃和长任务,并允许未来迁移到独立 GPU 服务器。 + +设置页提供三种自然语意选项: + +- 暂不使用:全部调用云 API。 +- 本机 GPU Worker:无需输入地址。原生运行 API 时自动访问 `127.0.0.1:8100`;Docker 中自动访问 `host.docker.internal:8100`。 +- 局域网 GPU 服务器:仅此模式需要填写内网地址。 + +GPU 主要用于户型分割、深度估计、几何校验和可选本地视觉模型。云端生图 API 本身不需要本地 GPU。 + +## 密钥安全 + +- 运行期配置加密写入 `.runtime/settings.enc`。 +- 主密钥保存在 `.runtime/.master-key` 或 Docker 的 `settings-data` 私有卷。 +- 密钥接口只返回是否已配置;保存成功后浏览器会清空输入值。 +- 测试请求由后端发出,密钥不会直接交给第三方网页脚本。 +- 当前版本应部署在可信内网;正式开放公网前还需给设置接口增加所有者登录和权限控制。 diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 new file mode 100644 index 0000000..fd26341 --- /dev/null +++ b/scripts/bootstrap.ps1 @@ -0,0 +1,37 @@ +param( + [switch]$Force +) + +$ErrorActionPreference = "Stop" +$projectRoot = Split-Path -Parent $PSScriptRoot +$templatePath = Join-Path $projectRoot ".env.example" +$targetPath = Join-Path $projectRoot ".env" + +if ((Test-Path -LiteralPath $targetPath) -and -not $Force) { + Write-Host ".env already exists. Use -Force to regenerate it." + exit 0 +} + +function New-SecureHex([int]$byteCount) { + $bytes = New-Object byte[] $byteCount + $generator = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { + $generator.GetBytes($bytes) + } + finally { + $generator.Dispose() + } + return -join ($bytes | ForEach-Object { $_.ToString("x2") }) +} + +$postgresPassword = New-SecureHex 24 +$redisPassword = New-SecureHex 24 +$content = Get-Content -Raw -Encoding UTF8 -LiteralPath $templatePath +$content = $content.Replace("__AUTO_POSTGRES_PASSWORD__", $postgresPassword) +$content = $content.Replace("__AUTO_REDIS_PASSWORD__", $redisPassword) + +$utf8WithoutBom = New-Object System.Text.UTF8Encoding($false) +[System.IO.File]::WriteAllText($targetPath, $content, $utf8WithoutBom) + +Write-Host "Bootstrap complete: PostgreSQL and Redis passwords were generated securely." +Write-Host "Configure MinIO, OCR, AI models, GPU, Langfuse and Sentry in System Settings." diff --git a/services/api/app/api/routes.py b/services/api/app/api/routes.py index 02ae54b..a0e3d48 100644 --- a/services/api/app/api/routes.py +++ b/services/api/app/api/routes.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from app.config import Settings, get_settings +from app.api.settings import require_workflow_ready from app.domain.models import CommandRequest, ProjectSnapshot, WorkflowDefinition from app.domain.workflow import ( InvalidTransitionError, @@ -14,6 +15,7 @@ from app.domain.workflow import ( from app.integrations.model_router import ModelRouter from app.integrations.storage import S3Storage from app.repositories.memory import repository +from app.runtime_settings import EncryptedSettingsStore, get_runtime_store router = APIRouter(prefix="/v1") @@ -24,23 +26,28 @@ class UploadRequest(BaseModel): @router.get("/health") -def health(settings: Settings = Depends(get_settings)) -> dict: - router_state = ModelRouter(settings) +def health( + settings: Settings = Depends(get_settings), + runtime_store: EncryptedSettingsStore = Depends(get_runtime_store), +) -> dict: + values = runtime_store.merged_values() + router_state = ModelRouter(values) return { "status": "ok", "environment": settings.app_env, "integrations": { - "storage": S3Storage(settings).configured, + "storage": S3Storage(values).configured, "baidu_ocr": bool( - settings.baidu_ocr_enabled - and settings.baidu_ocr_api_key - and settings.baidu_ocr_secret_key + values.get("baidu_ocr_enabled") + and values.get("baidu_ocr_api_key") + and values.get("baidu_ocr_secret_key") ), - "gpu": bool(settings.gpu_service_url and settings.gpu_service_token), + "gpu": values.get("gpu_mode") in {"local", "remote"} + and bool(values.get("gpu_service_token")), "langfuse": bool( - settings.langfuse_enabled - and settings.langfuse_public_key - and settings.langfuse_secret_key + values.get("langfuse_enabled") + and values.get("langfuse_public_key") + and values.get("langfuse_secret_key") ), "image_providers": [item.__dict__ for item in router_state.capabilities()], }, @@ -60,7 +67,11 @@ def get_project(project_id: str) -> ProjectSnapshot: return project -@router.post("/projects/{project_id}/commands", response_model=ProjectSnapshot) +@router.post( + "/projects/{project_id}/commands", + response_model=ProjectSnapshot, + dependencies=[Depends(require_workflow_ready)], +) def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot: project = repository.get(project_id) if project is None: @@ -74,15 +85,18 @@ def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot return repository.save(updated) -@router.post("/uploads/presign") +@router.post("/uploads/presign", dependencies=[Depends(require_workflow_ready)]) def create_upload( request: UploadRequest, - settings: Settings = Depends(get_settings), + runtime_store: EncryptedSettingsStore = Depends(get_runtime_store), ) -> dict: safe_name = request.filename.replace("/", "_").replace("\\", "_") object_key = f"projects/pending/{uuid4()}/{safe_name}" try: - upload = S3Storage(settings).presign_input_upload(object_key, request.content_type) + upload = S3Storage(runtime_store.merged_values()).presign_input_upload( + object_key, + request.content_type, + ) except RuntimeError as exc: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc return upload.__dict__ diff --git a/services/api/app/api/settings.py b/services/api/app/api/settings.py new file mode 100644 index 0000000..b93c28d --- /dev/null +++ b/services/api/app/api/settings.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from app.config import Settings, get_settings +from app.runtime_settings import ( + EncryptedSettingsStore, + RuntimeSettingsResponse, + RuntimeSettingsTestRequest, + RuntimeSettingsTestResult, + RuntimeSettingsUpdate, + SecretGenerateRequest, + SecretGenerateResponse, + SettingsReadiness, + generate_secret, + get_runtime_store, + test_runtime_settings, +) + +router = APIRouter(prefix="/v1") + + +@router.get("/settings", response_model=RuntimeSettingsResponse) +def read_settings( + store: EncryptedSettingsStore = Depends(get_runtime_store), +) -> RuntimeSettingsResponse: + try: + return store.public_response() + except RuntimeError as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc + + +@router.put("/settings", response_model=RuntimeSettingsResponse) +def update_settings( + request: RuntimeSettingsUpdate, + store: EncryptedSettingsStore = Depends(get_runtime_store), +) -> RuntimeSettingsResponse: + try: + store.update(request.values) + return store.public_response() + except RuntimeError as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc + + +@router.post("/settings/test", response_model=RuntimeSettingsTestResult) +async def test_settings( + request: RuntimeSettingsTestRequest, + store: EncryptedSettingsStore = Depends(get_runtime_store), + bootstrap: Settings = Depends(get_settings), +) -> RuntimeSettingsTestResult: + try: + values = store.merged_values(request.values) + result = await test_runtime_settings(request.target, values, bootstrap) + if not request.values: + store.record_test(result, values) + return result + except (KeyError, RuntimeError) as exc: + return RuntimeSettingsTestResult( + target=request.target, + ok=False, + message=f"配置不完整:{exc}", + ) + + +@router.post("/settings/generate-secret", response_model=SecretGenerateResponse) +def create_secret(request: SecretGenerateRequest) -> SecretGenerateResponse: + return SecretGenerateResponse(value=generate_secret(request.kind)) + + +@router.get("/readiness", response_model=SettingsReadiness) +def readiness( + store: EncryptedSettingsStore = Depends(get_runtime_store), +) -> SettingsReadiness: + return store.public_response().readiness + + +def require_workflow_ready( + store: EncryptedSettingsStore = Depends(get_runtime_store), +) -> None: + current = store.public_response().readiness + if current.ready: + return + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "SETTINGS_INCOMPLETE", + "message": "请先完成系统设置并通过必备连接测试。", + "missing": current.missing, + "untested": current.untested, + }, + ) diff --git a/services/api/app/config.py b/services/api/app/config.py index 34ea7ab..67da367 100644 --- a/services/api/app/config.py +++ b/services/api/app/config.py @@ -18,52 +18,12 @@ class Settings(BaseSettings): api_port: int = 8000 cors_origins: str = "http://localhost:3000" - database_url: str = "postgresql+psycopg://zhuangxiu:zhuangxiu@localhost:5432/zhuangxiu" + runtime_settings_dir: str = ".runtime" + settings_master_key: str = "" + + database_url: str = "postgresql://zhuangxiu:zhuangxiu@localhost:5432/zhuangxiu" redis_url: str = "redis://localhost:6379/0" - s3_endpoint: str = "" - s3_public_endpoint: str = "" - s3_access_key_id: str = "" - s3_secret_access_key: str = "" - s3_region: str = "us-east-1" - s3_bucket_inputs: str = "renovation-inputs" - s3_bucket_derived: str = "renovation-derived" - s3_bucket_renders: str = "renovation-renders" - s3_force_path_style: bool = True - s3_presigned_url_ttl_seconds: int = 900 - - baidu_ocr_enabled: bool = False - baidu_ocr_api_key: str = "" - baidu_ocr_secret_key: str = "" - baidu_ocr_token_url: str = "https://aip.baidubce.com/oauth/2.0/token" - - llm_default_provider: str = "openai" - openai_api_key: str = "" - openai_base_url: str = "https://api.openai.com/v1" - openai_text_model: str = "gpt-5" - openai_image_model: str = "gpt-image-2" - gemini_api_key: str = "" - gemini_model: str = "gemini-3-pro" - ark_api_key: str = "" - seedream_model_endpoint: str = "" - image_provider_priority: str = "seedream,openai,gemini" - - gpu_service_url: str = "http://localhost:8100" - gpu_service_token: str = "" - - jwt_secret: str = "" - session_secret: str = "" - app_encryption_key: str = "" - webhook_signing_secret: str = "" - - langfuse_enabled: bool = False - langfuse_host: str = "http://localhost:3001" - langfuse_public_key: str = "" - langfuse_secret_key: str = "" - sentry_dsn: str = "" - sentry_environment: str = "development" - sentry_traces_sample_rate: float = 0 - @property def cors_origin_list(self) -> list[str]: return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()] diff --git a/services/api/app/integrations/model_router.py b/services/api/app/integrations/model_router.py index 741893f..a4a5fb4 100644 --- a/services/api/app/integrations/model_router.py +++ b/services/api/app/integrations/model_router.py @@ -1,4 +1,6 @@ from dataclasses import dataclass +from collections.abc import Mapping +from typing import Any from app.config import Settings @@ -15,35 +17,27 @@ class ProviderCapability: class ModelRouter: - def __init__(self, settings: Settings) -> None: + def __init__(self, settings: Settings | Mapping[str, Any]) -> None: self.settings = settings + def _value(self, key: str, default: str = "") -> str: + if isinstance(self.settings, Mapping): + return str(self.settings.get(key, default) or "") + return str(getattr(self.settings, key, default) or "") + def capabilities(self) -> list[ProviderCapability]: return [ ProviderCapability( - provider="seedream", - configured=_configured(self.settings.ark_api_key) - and bool(self.settings.seedream_model_endpoint), - strengths=("中文风格指令", "快速方向探索"), - ), - ProviderCapability( - provider="openai", - configured=_configured(self.settings.openai_api_key), - strengths=("局部编辑", "多轮一致性", "遮罩修改"), - ), - ProviderCapability( - provider="gemini", - configured=_configured(self.settings.gemini_api_key), - strengths=("多参考图理解", "复杂视觉指令"), + provider=self._value("ai_provider", "custom"), + configured=_configured(self._value("ai_api_key")) + and bool(self._value("ai_base_url")) + and bool(self._value("image_model")), + strengths=("统一模型网关", "空间理解", "图像生成与编辑"), ), ] def choose_image_provider(self, task: str) -> str: - configured = {item.provider for item in self.capabilities() if item.configured} - priorities = [item.strip() for item in self.settings.image_provider_priority.split(",")] - if task == "masked_edit" and "openai" in configured: - return "openai" - for provider in priorities: - if provider in configured: - return provider + configured = [item.provider for item in self.capabilities() if item.configured] + if configured: + return configured[0] raise RuntimeError("No image provider is configured.") diff --git a/services/api/app/integrations/ocr.py b/services/api/app/integrations/ocr.py index f096e7d..3ef8aa6 100644 --- a/services/api/app/integrations/ocr.py +++ b/services/api/app/integrations/ocr.py @@ -1,4 +1,6 @@ import base64 +from collections.abc import Mapping +from typing import Any import httpx @@ -8,25 +10,30 @@ from app.config import Settings class BaiduOcrAdapter: OCR_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic" - def __init__(self, settings: Settings) -> None: + def __init__(self, settings: Settings | Mapping[str, Any]) -> None: self.settings = settings + def _value(self, key: str, default: str | bool = "") -> str | bool: + if isinstance(self.settings, Mapping): + return self.settings.get(key, default) + return getattr(self.settings, key, default) + @property def configured(self) -> bool: return bool( - self.settings.baidu_ocr_enabled - and self.settings.baidu_ocr_api_key - and self.settings.baidu_ocr_secret_key + self._value("baidu_ocr_enabled") + and self._value("baidu_ocr_api_key") + and self._value("baidu_ocr_secret_key") ) async def _access_token(self) -> str: async with httpx.AsyncClient(timeout=20) as client: response = await client.post( - self.settings.baidu_ocr_token_url, + str(self._value("baidu_ocr_token_url", "https://aip.baidubce.com/oauth/2.0/token")), params={ "grant_type": "client_credentials", - "client_id": self.settings.baidu_ocr_api_key, - "client_secret": self.settings.baidu_ocr_secret_key, + "client_id": self._value("baidu_ocr_api_key"), + "client_secret": self._value("baidu_ocr_secret_key"), }, ) response.raise_for_status() diff --git a/services/api/app/integrations/openai_compatible.py b/services/api/app/integrations/openai_compatible.py new file mode 100644 index 0000000..cdfa524 --- /dev/null +++ b/services/api/app/integrations/openai_compatible.py @@ -0,0 +1,78 @@ +from collections.abc import Mapping +from typing import Any +from urllib.parse import urljoin + +import httpx + + +class OpenAICompatibleGateway: + """One adapter for OpenAI, lk666.ai and other OpenAI-compatible gateways.""" + + def __init__(self, values: Mapping[str, Any]) -> None: + self.values = values + + def _url(self, path_key: str, fallback: str) -> str: + base_url = str(self.values.get("ai_base_url", "")).rstrip("/") + "/" + path = str(self.values.get(path_key, fallback)).lstrip("/") + return urljoin(base_url, path) + + @property + def headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.values.get('ai_api_key', '')}", + "Content-Type": "application/json", + } + + async def list_models(self) -> list[str]: + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get(self._url("ai_models_path", "/models"), headers=self.headers) + response.raise_for_status() + payload = response.json() + return [item["id"] for item in payload.get("data", []) if isinstance(item, dict) and item.get("id")] + + async def chat(self, messages: list[dict[str, Any]], *, vision: bool = False) -> dict[str, Any]: + model_key = "vision_model" if vision else "orchestrator_model" + async with httpx.AsyncClient(timeout=120) as client: + response = await client.post( + self._url("ai_chat_path", "/chat/completions"), + headers=self.headers, + json={"model": self.values[model_key], "messages": messages}, + ) + response.raise_for_status() + return response.json() + async def generate_image(self, prompt: str, **options: Any) -> dict[str, Any]: + payload = {"model": self.values["image_model"], "prompt": prompt, **options} + async with httpx.AsyncClient(timeout=180) as client: + response = await client.post( + self._url("ai_image_generation_path", "/images/generations"), + headers=self.headers, + json=payload, + ) + response.raise_for_status() + return response.json() + + async def edit_image( + self, + prompt: str, + image: bytes, + *, + filename: str = "image.png", + mask: bytes | None = None, + **options: Any, + ) -> dict[str, Any]: + files: dict[str, tuple[str, bytes, str]] = { + "image": (filename, image, "image/png"), + } + if mask is not None: + files["mask"] = ("mask.png", mask, "image/png") + data = {"model": self.values["image_model"], "prompt": prompt, **options} + headers = {"Authorization": self.headers["Authorization"]} + async with httpx.AsyncClient(timeout=180) as client: + response = await client.post( + self._url("ai_image_edit_path", "/images/edits"), + headers=headers, + data=data, + files=files, + ) + response.raise_for_status() + return response.json() diff --git a/services/api/app/integrations/storage.py b/services/api/app/integrations/storage.py index 9726972..d3a24f1 100644 --- a/services/api/app/integrations/storage.py +++ b/services/api/app/integrations/storage.py @@ -1,4 +1,6 @@ from dataclasses import dataclass +from collections.abc import Mapping +from typing import Any import boto3 from botocore.client import Config @@ -15,33 +17,38 @@ class PresignedUpload: class S3Storage: - def __init__(self, settings: Settings) -> None: + def __init__(self, settings: Settings | Mapping[str, Any]) -> None: self.settings = settings + def _value(self, key: str, default: Any = "") -> Any: + if isinstance(self.settings, Mapping): + return self.settings.get(key, default) + return getattr(self.settings, key, default) + @property def configured(self) -> bool: values = ( - self.settings.s3_endpoint, - self.settings.s3_access_key_id, - self.settings.s3_secret_access_key, + self._value("s3_endpoint"), + self._value("s3_access_key_id"), + self._value("s3_secret_access_key"), ) return all(values) and not any(value.startswith("replace_with_") for value in values) def _client(self, public: bool = False): endpoint = ( - self.settings.s3_public_endpoint - if public and self.settings.s3_public_endpoint - else self.settings.s3_endpoint + self._value("s3_public_endpoint") + if public and self._value("s3_public_endpoint") + else self._value("s3_endpoint") ) return boto3.client( "s3", endpoint_url=endpoint, - aws_access_key_id=self.settings.s3_access_key_id, - aws_secret_access_key=self.settings.s3_secret_access_key, - region_name=self.settings.s3_region, + aws_access_key_id=self._value("s3_access_key_id"), + aws_secret_access_key=self._value("s3_secret_access_key"), + region_name=self._value("s3_region", "us-east-1"), config=Config( signature_version="s3v4", - s3={"addressing_style": "path" if self.settings.s3_force_path_style else "auto"}, + s3={"addressing_style": "path" if self._value("s3_force_path_style", True) else "auto"}, ), ) @@ -51,15 +58,15 @@ class S3Storage: url = self._client(public=True).generate_presigned_url( "put_object", Params={ - "Bucket": self.settings.s3_bucket_inputs, + "Bucket": self._value("s3_bucket_inputs"), "Key": object_key, "ContentType": content_type, }, - ExpiresIn=self.settings.s3_presigned_url_ttl_seconds, + ExpiresIn=int(self._value("s3_presigned_url_ttl_seconds", 900)), ) return PresignedUpload( method="PUT", url=url, object_key=object_key, - expires_in=self.settings.s3_presigned_url_ttl_seconds, + expires_in=int(self._value("s3_presigned_url_ttl_seconds", 900)), ) diff --git a/services/api/app/main.py b/services/api/app/main.py index 420c937..74cb670 100644 --- a/services/api/app/main.py +++ b/services/api/app/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.routes import router +from app.api.settings import router as settings_router from app.config import get_settings settings = get_settings() @@ -20,6 +21,7 @@ app.add_middleware( allow_headers=["*"], ) app.include_router(router) +app.include_router(settings_router) @app.get("/") diff --git a/services/api/app/runtime_settings.py b/services/api/app/runtime_settings.py new file mode 100644 index 0000000..8b6c012 --- /dev/null +++ b/services/api/app/runtime_settings.py @@ -0,0 +1,500 @@ +import base64 +import hashlib +import json +import os +import secrets +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urljoin, urlparse + +import boto3 +import httpx +import anyio +import psycopg +import redis +from botocore.config import Config as BotoConfig +from cryptography.fernet import Fernet, InvalidToken +from pydantic import BaseModel, Field + +from app.config import Settings, get_settings +from app.settings_schema import CATEGORIES, EDITABLE_FIELDS, FIELDS, SECRET_FIELDS, SettingCategory + + +class SettingsReadiness(BaseModel): + ready: bool + completed_required: int + total_required: int + missing: list[str] = Field(default_factory=list) + untested: list[str] = Field(default_factory=list) + + +class RuntimeSettingsResponse(BaseModel): + categories: list[SettingCategory] + values: dict[str, Any] + configured: dict[str, bool] + tests: dict[str, dict[str, Any]] + readiness: SettingsReadiness + + +class RuntimeSettingsUpdate(BaseModel): + values: dict[str, Any] + + +class RuntimeSettingsTestRequest(BaseModel): + target: Literal[ + "infrastructure", + "storage", + "baidu_ocr", + "ai_models", + "gpu", + "langfuse", + "sentry", + ] + values: dict[str, Any] = Field(default_factory=dict) + + +class RuntimeSettingsTestResult(BaseModel): + target: str + ok: bool + message: str + details: dict[str, Any] = Field(default_factory=dict) + + +class SecretGenerateRequest(BaseModel): + kind: Literal["hex24", "hex32", "base64_32"] + + +class SecretGenerateResponse(BaseModel): + value: str + + +REQUIRED_GROUPS: dict[str, list[str]] = { + "基础运行": ["database_status", "redis_status", "encryption_status"], + "文件存储": [ + "s3_endpoint", + "s3_public_endpoint", + "s3_access_key_id", + "s3_secret_access_key", + "s3_bucket_inputs", + "s3_bucket_derived", + "s3_bucket_renders", + ], + "AI 模型": [ + "ai_provider", + "ai_base_url", + "ai_api_key", + "orchestrator_model", + "vision_model", + "image_model", + ], +} + +REQUIRED_TESTS = { + "基础运行": "infrastructure", + "文件存储": "storage", + "AI 模型": "ai_models", +} + +TEST_FIELDS: dict[str, set[str]] = { + "infrastructure": set(), + "storage": {key for key in FIELDS if key.startswith("s3_")}, + "baidu_ocr": {key for key in FIELDS if key.startswith("baidu_ocr_")}, + "ai_models": {key for key in FIELDS if key.startswith("ai_")} | { + "orchestrator_model", + "vision_model", + "image_model", + }, + "gpu": {key for key in FIELDS if key.startswith("gpu_")}, + "langfuse": {key for key in FIELDS if key.startswith("langfuse_")}, + "sentry": {key for key in FIELDS if key.startswith("sentry_")}, +} + + +def _is_configured(value: Any) -> bool: + if value is None or value is False: + return False + if isinstance(value, str): + normalized = value.strip().lower() + return bool(normalized) and not normalized.startswith(("replace_", "__auto_")) + return True + + +def _join_api_url(base_url: str, path: str) -> str: + return urljoin(base_url.rstrip("/") + "/", path.lstrip("/")) + + +class EncryptedSettingsStore: + def __init__(self, bootstrap: Settings | None = None) -> None: + self.bootstrap = bootstrap or get_settings() + self.root = Path(self.bootstrap.runtime_settings_dir) + if not self.root.is_absolute(): + self.root = Path.cwd() / self.root + self.data_path = self.root / "settings.enc" + self.key_path = self.root / ".master-key" + + def _fernet(self) -> Fernet: + self.root.mkdir(parents=True, exist_ok=True) + configured_key = self.bootstrap.settings_master_key.strip() + if configured_key: + try: + return Fernet(configured_key.encode("ascii")) + except (ValueError, TypeError): + derived = base64.urlsafe_b64encode(hashlib.sha256(configured_key.encode()).digest()) + return Fernet(derived) + + if self.key_path.exists(): + return Fernet(self.key_path.read_bytes().strip()) + + key = Fernet.generate_key() + self.key_path.write_bytes(key) + try: + os.chmod(self.key_path, 0o600) + except OSError: + pass + return Fernet(key) + + def defaults(self) -> dict[str, Any]: + return { + key: field.default + for key, field in FIELDS.items() + if key in EDITABLE_FIELDS and field.default is not None + } + + def load_document(self) -> dict[str, Any]: + if not self.data_path.exists(): + return {"values": self.defaults(), "tests": {}} + try: + decrypted = self._fernet().decrypt(self.data_path.read_bytes()) + document = json.loads(decrypted.decode("utf-8")) + except (InvalidToken, ValueError, json.JSONDecodeError) as exc: + raise RuntimeError("运行期配置无法解密,请检查主密钥是否发生变化。") from exc + document.setdefault("values", {}) + document.setdefault("tests", {}) + return document + + def save_document(self, document: dict[str, Any]) -> None: + self.root.mkdir(parents=True, exist_ok=True) + payload = json.dumps(document, ensure_ascii=False, sort_keys=True).encode("utf-8") + encrypted = self._fernet().encrypt(payload) + temporary = self.data_path.with_suffix(".tmp") + temporary.write_bytes(encrypted) + temporary.replace(self.data_path) + + def merged_values(self, pending: dict[str, Any] | None = None) -> dict[str, Any]: + document = self.load_document() + values = {**self.defaults(), **document["values"]} + for key, value in (pending or {}).items(): + if key not in EDITABLE_FIELDS: + continue + if key in SECRET_FIELDS and not _is_configured(value): + continue + values[key] = self._coerce(key, value) + return values + + def update(self, patch: dict[str, Any]) -> None: + document = self.load_document() + changed: set[str] = set() + for key, raw_value in patch.items(): + if key not in EDITABLE_FIELDS: + continue + if key in SECRET_FIELDS and not _is_configured(raw_value): + continue + value = self._coerce(key, raw_value) + if document["values"].get(key) != value: + document["values"][key] = value + changed.add(key) + for target, fields in TEST_FIELDS.items(): + if changed & fields: + document["tests"].pop(target, None) + self.save_document(document) + + def record_test(self, result: RuntimeSettingsTestResult, values: dict[str, Any] | None = None) -> None: + document = self.load_document() + document["tests"][result.target] = { + "ok": result.ok, + "message": result.message, + "fingerprint": self.test_fingerprint(result.target, values or self.merged_values()), + } + self.save_document(document) + + def public_response(self) -> RuntimeSettingsResponse: + document = self.load_document() + values = {**self.defaults(), **document["values"]} + public_values = {key: value for key, value in values.items() if key not in SECRET_FIELDS} + configured = {key: _is_configured(values.get(key)) for key in FIELDS} + configured.update(self.bootstrap_statuses()) + current_tests = self.current_tests(values, document["tests"]) + return RuntimeSettingsResponse( + categories=CATEGORIES, + values=public_values, + configured=configured, + tests=current_tests, + readiness=self.readiness(values, current_tests), + ) + + def bootstrap_statuses(self) -> dict[str, bool]: + return { + "database_status": _is_configured(self.bootstrap.database_url), + "redis_status": _is_configured(self.bootstrap.redis_url), + "encryption_status": self._master_key_available(), + } + + def _master_key_available(self) -> bool: + if self.bootstrap.settings_master_key.strip() or self.key_path.exists(): + return True + try: + self._fernet() + return True + except OSError: + return False + + def readiness( + self, + values: dict[str, Any] | None = None, + tests: dict[str, dict[str, Any]] | None = None, + ) -> SettingsReadiness: + values = values or self.merged_values() + if tests is None: + tests = self.current_tests(values, self.load_document()["tests"]) + statuses = self.bootstrap_statuses() + all_values = {**values, **statuses} + missing: list[str] = [] + untested: list[str] = [] + completed = 0 + for group, keys in REQUIRED_GROUPS.items(): + group_missing = [key for key in keys if not _is_configured(all_values.get(key))] + if group_missing: + labels = [FIELDS[key].label for key in group_missing] + missing.append(f"{group}:{'、'.join(labels)}") + continue + target = REQUIRED_TESTS[group] + if not tests.get(target, {}).get("ok"): + untested.append(f"{group}:尚未通过连接测试") + continue + completed += 1 + return SettingsReadiness( + ready=not missing and not untested, + completed_required=completed, + total_required=len(REQUIRED_GROUPS), + missing=missing, + untested=untested, + ) + + def current_tests( + self, + values: dict[str, Any], + tests: dict[str, dict[str, Any]], + ) -> dict[str, dict[str, Any]]: + current: dict[str, dict[str, Any]] = {} + for target, result in tests.items(): + item = dict(result) + if item.get("fingerprint") != self.test_fingerprint(target, values): + item["ok"] = False + item["message"] = "配置已变更,请重新测试。" + current[target] = item + return current + + def test_fingerprint(self, target: str, values: dict[str, Any]) -> str: + if target == "infrastructure": + relevant: dict[str, Any] = { + "database_url": self.bootstrap.database_url, + "redis_url": self.bootstrap.redis_url, + } + else: + relevant = {key: values.get(key) for key in sorted(TEST_FIELDS.get(target, set()))} + payload = json.dumps(relevant, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + @staticmethod + def _coerce(key: str, value: Any) -> Any: + field = FIELDS[key] + if field.kind == "toggle": + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + if field.kind == "number": + return int(value) + return value.strip() if isinstance(value, str) else value + + +def get_runtime_store() -> EncryptedSettingsStore: + return EncryptedSettingsStore(get_settings()) + + +def generate_secret(kind: str) -> str: + if kind == "hex24": + return secrets.token_hex(24) + if kind == "hex32": + return secrets.token_hex(32) + if kind == "base64_32": + return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii") + raise ValueError("不支持的密钥类型。") + + +async def test_runtime_settings( + target: str, + values: dict[str, Any], + bootstrap: Settings, +) -> RuntimeSettingsTestResult: + try: + if target == "infrastructure": + return await _test_infrastructure(bootstrap) + if target == "storage": + return await _test_storage(values) + if target == "baidu_ocr": + return await _test_baidu_ocr(values) + if target == "ai_models": + return await _test_ai_models(values) + if target == "gpu": + return await _test_gpu(values) + if target == "langfuse": + return await _test_langfuse(values) + if target == "sentry": + return await _test_sentry(values) + except Exception as exc: # Integration boundaries return a safe, user-readable failure. + return RuntimeSettingsTestResult(target=target, ok=False, message=f"连接失败:{exc}") + return RuntimeSettingsTestResult(target=target, ok=False, message="未知测试类型。") + + +async def _test_infrastructure(bootstrap: Settings) -> RuntimeSettingsTestResult: + return await anyio.to_thread.run_sync(_test_infrastructure_sync, bootstrap) + + +def _test_infrastructure_sync(bootstrap: Settings) -> RuntimeSettingsTestResult: + with psycopg.connect(bootstrap.database_url, connect_timeout=5) as connection: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + redis_client = redis.Redis.from_url( + bootstrap.redis_url, + socket_connect_timeout=5, + socket_timeout=5, + ) + redis_client.ping() + return RuntimeSettingsTestResult( + target="infrastructure", + ok=True, + message="PostgreSQL 与 Redis 均连接正常。", + ) + + +async def _test_storage(values: dict[str, Any]) -> RuntimeSettingsTestResult: + return await anyio.to_thread.run_sync(_test_storage_sync, values) + + +def _test_storage_sync(values: dict[str, Any]) -> RuntimeSettingsTestResult: + client = boto3.client( + "s3", + endpoint_url=values["s3_endpoint"], + aws_access_key_id=values["s3_access_key_id"], + aws_secret_access_key=values["s3_secret_access_key"], + region_name="us-east-1", + config=BotoConfig( + connect_timeout=5, + read_timeout=5, + retries={"max_attempts": 1}, + s3={"addressing_style": "path" if values.get("s3_force_path_style", True) else "virtual"}, + ), + ) + existing = {bucket["Name"] for bucket in client.list_buckets().get("Buckets", [])} + required = [ + values["s3_bucket_inputs"], + values["s3_bucket_derived"], + values["s3_bucket_renders"], + ] + missing = [bucket for bucket in required if bucket not in existing] + return RuntimeSettingsTestResult( + target="storage", + ok=not missing, + message="MinIO 连接正常,三个存储空间均可用。" if not missing else "MinIO 可连接,但需要先创建部分存储空间。", + details={"missing_buckets": missing}, + ) + + +async def _test_baidu_ocr(values: dict[str, Any]) -> RuntimeSettingsTestResult: + if not values.get("baidu_ocr_enabled"): + return RuntimeSettingsTestResult(target="baidu_ocr", ok=True, message="百度 OCR 当前未启用。") + async with httpx.AsyncClient(timeout=8) as client: + response = await client.post( + "https://aip.baidubce.com/oauth/2.0/token", + params={ + "grant_type": "client_credentials", + "client_id": values.get("baidu_ocr_api_key", ""), + "client_secret": values.get("baidu_ocr_secret_key", ""), + }, + ) + response.raise_for_status() + payload = response.json() + ok = bool(payload.get("access_token")) + return RuntimeSettingsTestResult( + target="baidu_ocr", + ok=ok, + message="百度 OCR 凭证有效。" if ok else "百度 OCR 未返回访问令牌。", + ) + + +async def _test_ai_models(values: dict[str, Any]) -> RuntimeSettingsTestResult: + url = _join_api_url(values["ai_base_url"], values.get("ai_models_path", "/models")) + async with httpx.AsyncClient(timeout=12) as client: + response = await client.get(url, headers={"Authorization": f"Bearer {values['ai_api_key']}"}) + response.raise_for_status() + payload = response.json() + model_ids = { + item.get("id") + for item in payload.get("data", []) + if isinstance(item, dict) and item.get("id") + } + selected = [values.get("orchestrator_model"), values.get("vision_model"), values.get("image_model")] + missing = [model for model in selected if model and model_ids and model not in model_ids] + return RuntimeSettingsTestResult( + target="ai_models", + ok=not missing, + message="API Key 有效,三个模型均可用。" if not missing else "API 可以连接,但部分模型名不在账号模型列表中。", + details={"missing_models": missing, "model_count": len(model_ids)}, + ) + + +async def _test_gpu(values: dict[str, Any]) -> RuntimeSettingsTestResult: + mode = values.get("gpu_mode", "disabled") + if mode == "disabled": + return RuntimeSettingsTestResult(target="gpu", ok=True, message="本地 GPU 当前未启用。") + local_host = "host.docker.internal" if Path("/.dockerenv").exists() else "127.0.0.1" + url = f"http://{local_host}:8100" if mode == "local" else values.get("gpu_service_url", "") + async with httpx.AsyncClient(timeout=8) as client: + response = await client.get( + _join_api_url(url, "/health"), + headers={"Authorization": f"Bearer {values.get('gpu_service_token', '')}"}, + ) + response.raise_for_status() + return RuntimeSettingsTestResult(target="gpu", ok=True, message="GPU Worker 连接正常。") + + +async def _test_langfuse(values: dict[str, Any]) -> RuntimeSettingsTestResult: + if not values.get("langfuse_enabled"): + return RuntimeSettingsTestResult(target="langfuse", ok=True, message="Langfuse 当前未启用。") + async with httpx.AsyncClient(timeout=8) as client: + response = await client.get( + _join_api_url(values.get("langfuse_host", ""), "/api/public/projects"), + auth=( + str(values.get("langfuse_public_key", "")), + str(values.get("langfuse_secret_key", "")), + ), + ) + response.raise_for_status() + return RuntimeSettingsTestResult(target="langfuse", ok=True, message="Langfuse 服务与项目密钥均有效。") + + +async def _test_sentry(values: dict[str, Any]) -> RuntimeSettingsTestResult: + if not values.get("sentry_enabled"): + return RuntimeSettingsTestResult(target="sentry", ok=True, message="Sentry 当前未启用。") + dsn = values.get("sentry_dsn", "") + parsed = urlparse(dsn) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or not parsed.username: + return RuntimeSettingsTestResult(target="sentry", ok=False, message="Sentry DSN 格式不正确。") + async with httpx.AsyncClient(timeout=8) as client: + response = await client.get(f"{parsed.scheme}://{parsed.netloc.split('@')[-1]}") + return RuntimeSettingsTestResult( + target="sentry", + ok=response.status_code < 500, + message="Sentry 地址可访问,DSN 格式正确。" if response.status_code < 500 else "Sentry 服务返回异常。", + ) diff --git a/services/api/app/settings_schema.py b/services/api/app/settings_schema.py new file mode 100644 index 0000000..563cac0 --- /dev/null +++ b/services/api/app/settings_schema.py @@ -0,0 +1,404 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +FieldKind = Literal["text", "password", "url", "select", "combobox", "toggle", "number", "status"] + + +class SettingOption(BaseModel): + value: str + label: str + help: str + + +class SettingField(BaseModel): + key: str + label: str + description: str + kind: FieldKind + required: bool = False + secret: bool = False + default: Any = None + placeholder: str = "" + options: list[SettingOption] = Field(default_factory=list) + generator: Literal["hex24", "hex32", "base64_32"] | None = None + advanced: bool = False + visible_when: dict[str, Any] | None = None + + +class SettingCategory(BaseModel): + id: str + label: str + description: str + test_targets: list[str] = Field(default_factory=list) + required_for_workflow: bool = False + fields: list[SettingField] + + +def option(value: str, label: str, help_text: str) -> SettingOption: + return SettingOption(value=value, label=label, help=help_text) + + +CATEGORIES = [ + SettingCategory( + id="deployment", + label="基础运行", + description="由安装器自动管理,通常不需要手动填写。修改数据库密码需要重启服务。", + test_targets=["infrastructure"], + required_for_workflow=True, + fields=[ + SettingField( + key="database_status", + label="PostgreSQL", + description="保存项目、版本、任务和结构化设计状态。密码由启动脚本自动生成。", + kind="status", + required=True, + ), + SettingField( + key="redis_status", + label="Redis", + description="用于任务队列、缓存和并发锁。密码由启动脚本自动生成。", + kind="status", + required=True, + ), + SettingField( + key="encryption_status", + label="配置加密", + description="模型 API Key 会加密保存;主密钥首次启动时自动生成,不显示在页面中。", + kind="status", + required=True, + ), + ], + ), + SettingCategory( + id="storage", + label="文件存储", + description="保存原始户型、清洗图、3D 白模和效果图。你的 NAS MinIO 可以直接使用。", + test_targets=["storage"], + required_for_workflow=True, + fields=[ + SettingField( + key="s3_endpoint", + label="MinIO 内部地址", + description="后端访问 NAS 的 S3 API 地址,不是 MinIO 控制台地址。", + kind="url", + required=True, + placeholder="http://192.168.200.36:9000", + ), + SettingField( + key="s3_public_endpoint", + label="浏览器可访问地址", + description="上传和预览文件时使用;纯内网部署通常与内部地址相同。", + kind="url", + required=True, + placeholder="http://192.168.200.36:9000", + ), + SettingField( + key="s3_access_key_id", + label="服务账号 Access Key", + description="建议在 MinIO 中单独创建服务账号,不要使用 Root 账号。", + kind="password", + required=True, + secret=True, + ), + SettingField( + key="s3_secret_access_key", + label="服务账号 Secret Key", + description="仅加密保存于后端,不会返回到浏览器。", + kind="password", + required=True, + secret=True, + ), + SettingField( + key="s3_bucket_inputs", + label="原始文件空间", + description="保存用户上传的 PDF、DXF 和图片;测试会提示是否缺失。", + kind="text", + required=True, + default="renovation-inputs", + ), + SettingField( + key="s3_bucket_derived", + label="中间结果空间", + description="保存 SVG、控制图、蒙版和 GLB 白模。", + kind="text", + required=True, + default="renovation-derived", + ), + SettingField( + key="s3_bucket_renders", + label="效果图空间", + description="保存方向图、效果图和局部修改版本。", + kind="text", + required=True, + default="renovation-renders", + ), + SettingField( + key="s3_force_path_style", + label="MinIO 兼容模式", + description="MinIO 通常保持开启;AWS S3 可以关闭。", + kind="toggle", + default=True, + advanced=True, + ), + ], + ), + SettingCategory( + id="document", + label="图纸识别", + description="矢量 PDF 优先直接解析;只有扫描图或文字层损坏时才调用 OCR。", + test_targets=["baidu_ocr"], + fields=[ + SettingField( + key="baidu_ocr_enabled", + label="启用百度 OCR", + description="开启后,扫描户型图会使用百度 OCR 识别尺寸和文字。", + kind="toggle", + default=False, + ), + SettingField( + key="baidu_ocr_api_key", + label="百度 OCR API Key", + description="来自百度智能云 OCR 应用。", + kind="password", + required=True, + secret=True, + visible_when={"baidu_ocr_enabled": True}, + ), + SettingField( + key="baidu_ocr_secret_key", + label="百度 OCR Secret Key", + description="只用于换取访问令牌,不会返回前端。", + kind="password", + required=True, + secret=True, + visible_when={"baidu_ocr_enabled": True}, + ), + ], + ), + SettingCategory( + id="models", + label="AI 模型", + description="统一配置总调度、空间理解和生图模型;同一个聚合 API 可以同时承担三类能力。", + test_targets=["ai_models"], + required_for_workflow=True, + fields=[ + SettingField( + key="ai_provider", + label="API 来源", + description="选择自然语意预设;自建或其他聚合服务选择兼容接口。", + kind="select", + required=True, + default="lingke", + options=[ + option("lingke", "聚合引擎 AIGC", "你提供的 lk666.ai 聚合服务,模型名以其控制台为准。"), + option("openai", "OpenAI 官方", "直接使用 OpenAI 官方 API。"), + option("custom", "其他兼容接口", "支持 OpenAI 请求格式的代理、自建或聚合服务。"), + ], + ), + SettingField( + key="ai_base_url", + label="API Base URL", + description="填写到 /v1 层级;聚合引擎的准确地址请从登录后的开发者文档复制。", + kind="url", + required=True, + placeholder="https://example.com/v1", + ), + SettingField( + key="ai_api_key", + label="API Key", + description="同一聚合账号可供总调度、多模态和生图使用。", + kind="password", + required=True, + secret=True, + ), + SettingField( + key="orchestrator_model", + label="总调度模型", + description="负责追问、拆解任务、维护 Plan / Scene / Style 状态。", + kind="combobox", + required=True, + placeholder="选择预设或输入平台模型 ID", + options=[ + option("gpt-5.6-terra", "GPT-5.6 Terra", "速度与规划能力均衡,适合日常工作流。"), + option("gpt-5.6-sol", "GPT-5.6 Sol", "更强推理,适合复杂户型和高价值方案。"), + option("gpt-5", "GPT-5", "通用调度预设,具体可用性取决于账号。"), + ], + ), + SettingField( + key="vision_model", + label="空间理解模型", + description="读取户型、参考图和渲染结果,判断空间关系与审美一致性。", + kind="combobox", + required=True, + placeholder="选择预设或输入多模态模型 ID", + options=[ + option("gpt-5.6-sol", "GPT-5.6 Sol", "优先空间推理和复杂视觉评审。"), + option("gemini-3-pro", "Gemini 3 Pro", "长上下文与多模态理解预设。"), + option("gpt-5", "GPT-5", "通用多模态预设。"), + ], + ), + SettingField( + key="image_model", + label="默认生图模型", + description="先使用你指定的 gpt-image-2;后续可以增加按任务自动路由。", + kind="combobox", + required=True, + default="gpt-image-2", + options=[ + option("gpt-image-2", "GPT Image 2", "默认室内方向图与局部编辑模型。"), + option("seedream-5.0", "Seedream 5.0", "适合高质量中文场景生成,模型 ID 以平台为准。"), + option("nano-banana-pro", "Nano Banana Pro", "适合参考图编辑,模型 ID 以平台为准。"), + ], + ), + SettingField( + key="ai_models_path", + label="模型列表路径", + description="测试按钮使用。OpenAI 兼容接口通常为 /models。", + kind="text", + default="/models", + advanced=True, + ), + SettingField( + key="ai_chat_path", + label="对话路径", + description="OpenAI 兼容接口通常为 /chat/completions。", + kind="text", + default="/chat/completions", + advanced=True, + ), + SettingField( + key="ai_image_generation_path", + label="生图路径", + description="OpenAI 兼容接口通常为 /images/generations。", + kind="text", + default="/images/generations", + advanced=True, + ), + SettingField( + key="ai_image_edit_path", + label="图片编辑路径", + description="OpenAI 兼容接口通常为 /images/edits。", + kind="text", + default="/images/edits", + advanced=True, + ), + ], + ), + SettingCategory( + id="gpu", + label="本地算力", + description="GPU 主要用于户型分割、深度估计和本地视觉模型,不是调用云端生图 API 的必需项。", + test_targets=["gpu"], + fields=[ + SettingField( + key="gpu_mode", + label="运行方式", + description="同机 Worker 会自动使用本机地址;只有独立 GPU 服务器才需要手填地址。", + kind="select", + default="disabled", + options=[ + option("disabled", "暂不使用本地 GPU", "全部使用云端 API,最容易部署。"), + option("local", "本机 GPU Worker", "API 通过本机 Worker 调用显卡,页面无需填写地址。"), + option("remote", "局域网 GPU 服务器", "GPU 在另一台机器时填写内网地址。"), + ], + ), + SettingField( + key="gpu_service_url", + label="GPU 服务地址", + description="仅远程模式需要,例如 http://192.168.200.50:8100。", + kind="url", + required=True, + placeholder="http://192.168.200.50:8100", + visible_when={"gpu_mode": "remote"}, + ), + SettingField( + key="gpu_service_token", + label="内部访问令牌", + description="用于阻止其他内网设备随意调用 GPU;可以一键生成。", + kind="password", + required=True, + secret=True, + generator="hex32", + visible_when={"gpu_mode__not": "disabled"}, + ), + ], + ), + SettingCategory( + id="observability", + label="监控与追踪", + description="均为可选项。Langfuse 用于评估模型链路,Sentry 用于发现程序异常。", + test_targets=["langfuse", "sentry"], + fields=[ + SettingField( + key="langfuse_enabled", + label="启用 Langfuse", + description="记录每次 Agent 调用、模型耗时、费用与评测结果。", + kind="toggle", + default=False, + ), + SettingField( + key="langfuse_host", + label="Langfuse 地址", + description="支持自部署地址,例如 http://192.168.200.20:3001。", + kind="url", + required=True, + visible_when={"langfuse_enabled": True}, + ), + SettingField( + key="langfuse_public_key", + label="Langfuse Public Key", + description="从 Langfuse 项目设置中复制。", + kind="password", + required=True, + secret=True, + visible_when={"langfuse_enabled": True}, + ), + SettingField( + key="langfuse_secret_key", + label="Langfuse Secret Key", + description="只保存在后端。", + kind="password", + required=True, + secret=True, + visible_when={"langfuse_enabled": True}, + ), + SettingField( + key="sentry_enabled", + label="启用 Sentry", + description="收集异常和性能问题,支持自部署。", + kind="toggle", + default=False, + ), + SettingField( + key="sentry_dsn", + label="Sentry DSN", + description="从 Sentry 项目的 Client Keys 页面复制。", + kind="password", + required=True, + secret=True, + visible_when={"sentry_enabled": True}, + ), + SettingField( + key="sentry_traces_sample_rate", + label="性能采样率", + description="0 表示关闭,0.1 表示记录约 10% 的请求。", + kind="select", + default="0", + visible_when={"sentry_enabled": True}, + options=[ + option("0", "仅错误,不采集性能", "最省资源,适合初期。"), + option("0.05", "采样 5%", "适合请求量较大的生产环境。"), + option("0.1", "采样 10%", "排障信息和资源消耗较均衡。"), + option("1", "全部采样", "仅建议短期排障使用。"), + ], + ), + ], + ), +] + + +FIELDS = {field.key: field for category in CATEGORIES for field in category.fields} +SECRET_FIELDS = {key for key, field in FIELDS.items() if field.secret} +EDITABLE_FIELDS = {key for key, field in FIELDS.items() if field.kind != "status"} diff --git a/services/api/pyproject.toml b/services/api/pyproject.toml index 3908e9a..75b07b1 100644 --- a/services/api/pyproject.toml +++ b/services/api/pyproject.toml @@ -9,11 +9,14 @@ description = "Workflow orchestrator for the AI interior style studio" requires-python = ">=3.11" dependencies = [ "boto3>=1.35,<2", + "cryptography>=44,<47", "fastapi>=0.115,<1", "httpx>=0.28,<1", + "psycopg[binary]>=3.2,<4", "pydantic>=2.10,<3", "pydantic-settings>=2.7,<3", "python-multipart>=0.0.20,<1", + "redis>=5.2,<7", "uvicorn[standard]>=0.34,<1" ] diff --git a/services/api/tests/test_api.py b/services/api/tests/test_api.py index f7938b3..0157e83 100644 --- a/services/api/tests/test_api.py +++ b/services/api/tests/test_api.py @@ -1,11 +1,23 @@ import httpx import pytest +from pathlib import Path +from app.config import Settings from app.main import app +from app.runtime_settings import EncryptedSettingsStore, get_runtime_store @pytest.fixture -def transport() -> httpx.ASGITransport: +def transport(tmp_path: Path) -> httpx.ASGITransport: + store = EncryptedSettingsStore( + Settings( + _env_file=None, + runtime_settings_dir=str(tmp_path), + database_url="postgresql://user:secret@postgres:5432/app", + redis_url="redis://:secret@redis:6379/0", + ) + ) + app.dependency_overrides[get_runtime_store] = lambda: store return httpx.ASGITransport(app=app) @@ -25,3 +37,27 @@ async def test_demo_project_contract(transport: httpx.ASGITransport) -> None: body = response.json() assert body["stage"] == "plan_review" assert body["plan"]["cad_layer_count"] == 43 + + +@pytest.mark.asyncio +async def test_settings_contract_never_returns_secret_values(transport: httpx.ASGITransport) -> None: + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/v1/settings") + assert response.status_code == 200 + body = response.json() + assert len(body["categories"]) == 6 + assert "ai_api_key" not in body["values"] + assert body["readiness"]["ready"] is False + + +@pytest.mark.asyncio +async def test_workflow_command_is_blocked_until_required_settings_are_ready( + transport: httpx.ASGITransport, +) -> None: + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/v1/projects/demo-apartment/commands", + json={"command": "confirm_plan", "expected_revision": 3, "payload": {}}, + ) + assert response.status_code == 503 + assert response.json()["detail"]["code"] == "SETTINGS_INCOMPLETE" diff --git a/services/api/tests/test_settings.py b/services/api/tests/test_settings.py new file mode 100644 index 0000000..77acf8b --- /dev/null +++ b/services/api/tests/test_settings.py @@ -0,0 +1,69 @@ +from pathlib import Path + +from app.config import Settings +from app.runtime_settings import ( + EncryptedSettingsStore, + RuntimeSettingsTestResult, + generate_secret, +) + + +def create_store(path: Path) -> EncryptedSettingsStore: + settings = Settings( + _env_file=None, + runtime_settings_dir=str(path), + database_url="postgresql://user:secret@postgres:5432/app", + redis_url="redis://:secret@redis:6379/0", + ) + return EncryptedSettingsStore(settings) + + +def test_runtime_secrets_are_encrypted_and_never_returned(tmp_path: Path) -> None: + store = create_store(tmp_path) + store.update( + { + "s3_endpoint": "http://minio:9000", + "s3_public_endpoint": "http://localhost:9000", + "s3_access_key_id": "service-account", + "s3_secret_access_key": "very-private-secret", + } + ) + + response = store.public_response() + assert "s3_secret_access_key" not in response.values + assert response.configured["s3_secret_access_key"] is True + assert b"very-private-secret" not in store.data_path.read_bytes() + + +def test_readiness_requires_configuration_and_successful_tests(tmp_path: Path) -> None: + store = create_store(tmp_path) + store.update( + { + "s3_endpoint": "http://minio:9000", + "s3_public_endpoint": "http://localhost:9000", + "s3_access_key_id": "access", + "s3_secret_access_key": "secret", + "ai_provider": "lingke", + "ai_base_url": "https://example.com/v1", + "ai_api_key": "api-secret", + "orchestrator_model": "planner", + "vision_model": "vision", + "image_model": "gpt-image-2", + } + ) + assert store.public_response().readiness.ready is False + + for target in ("infrastructure", "storage", "ai_models"): + store.record_test(RuntimeSettingsTestResult(target=target, ok=True, message="ok")) + + assert store.public_response().readiness.ready is True + + store.update({"image_model": "another-image-model"}) + assert store.public_response().readiness.ready is False + assert store.public_response().tests.get("ai_models") is None + + +def test_secret_generators_use_expected_lengths() -> None: + assert len(generate_secret("hex24")) == 48 + assert len(generate_secret("hex32")) == 64 + assert len(generate_secret("base64_32")) == 44