feat: add visual settings and readiness gate
This commit is contained in:
+23
-126
@@ -1,149 +1,46 @@
|
|||||||
# 作用:标记当前运行环境并控制日志、调试和安全默认值。必填。可选值为 development、test、production。
|
# 这份文件只保留“应用启动前必须存在”的基础配置。请运行 scripts/bootstrap.ps1 自动生成 .env,不需要手工填写。
|
||||||
|
|
||||||
|
# 作用:运行环境。开发使用 development;正式部署选择 production。默认 development。
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
|
|
||||||
# 作用:供日志和可观测性识别当前服务。必填。通常无需修改。
|
# 作用:服务名称,只用于日志和监控展示。通常无需修改。
|
||||||
APP_NAME=zhuangxiu-api
|
APP_NAME=zhuangxiu-api
|
||||||
|
|
||||||
# 作用:控制后端日志输出级别。必填。开发使用 INFO,排障可临时改为 DEBUG,生产不建议 DEBUG。
|
# 作用:日志详细程度。INFO 适合日常使用;DEBUG 仅用于短期排障。默认 INFO。
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# 作用:后端监听地址。必填。容器内使用 0.0.0.0,本机仅监听本地时可用 127.0.0.1。
|
# 作用:API 在容器内监听的地址。Docker 部署保持 0.0.0.0。
|
||||||
API_HOST=0.0.0.0
|
API_HOST=0.0.0.0
|
||||||
|
|
||||||
# 作用:后端 HTTP 端口。必填。修改后需要同步更新 NEXT_PUBLIC_API_BASE_URL。
|
# 作用:API 对外端口。默认 8000;端口冲突时再修改。
|
||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
|
|
||||||
# 作用:浏览器允许访问后端的前端来源,多个来源使用英文逗号分隔。必填。生产环境不要使用星号。
|
# 作用:允许访问 API 的前端地址。多个地址使用英文逗号分隔。
|
||||||
CORS_ORIGINS=http://localhost:3000
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
# 作用:前端访问后端 API 的公开地址。必填。该值会进入浏览器代码,不得包含任何密钥。
|
# 作用:浏览器访问 API 的公开地址。该值会进入前端代码,不得包含密钥。
|
||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
# 作用:PostgreSQL 数据库名。必填。Docker Compose 会用它初始化数据库。
|
# 作用:PostgreSQL 数据库名。安装器会直接使用默认值创建数据库。
|
||||||
POSTGRES_DB=zhuangxiu
|
POSTGRES_DB=zhuangxiu
|
||||||
|
|
||||||
# 作用:PostgreSQL 应用账号。必填。生产环境不要使用 postgres 超级用户。
|
# 作用:PostgreSQL 应用账号。默认使用非超级用户 zhuangxiu。
|
||||||
POSTGRES_USER=zhuangxiu
|
POSTGRES_USER=zhuangxiu
|
||||||
|
|
||||||
# 作用:PostgreSQL 应用密码。必填。使用 `openssl rand -hex 24` 单独生成,不要与其他密码复用。
|
# 作用:PostgreSQL 密码。不要手填;scripts/bootstrap.ps1 会用系统加密随机数自动替换。
|
||||||
POSTGRES_PASSWORD=replace_with_random_hex
|
POSTGRES_PASSWORD=__AUTO_POSTGRES_PASSWORD__
|
||||||
|
|
||||||
# 作用:后端连接 PostgreSQL 的完整地址。必填。若密码包含特殊字符需要做 URL 编码。
|
# 作用:后端连接 PostgreSQL 的地址。密码占位符会与上一项同时自动替换。
|
||||||
DATABASE_URL=postgresql+psycopg://zhuangxiu:replace_with_random_hex@postgres:5432/zhuangxiu
|
DATABASE_URL=postgresql://zhuangxiu:__AUTO_POSTGRES_PASSWORD__@postgres:5432/zhuangxiu
|
||||||
|
|
||||||
# 作用:Redis 访问密码。必填。使用 `openssl rand -hex 24` 生成,生产环境不可留空。
|
# 作用:Redis 密码。不要手填;scripts/bootstrap.ps1 会自动生成独立随机密码。
|
||||||
REDIS_PASSWORD=replace_with_random_hex
|
REDIS_PASSWORD=__AUTO_REDIS_PASSWORD__
|
||||||
|
|
||||||
# 作用:后端连接 Redis 的完整地址,用于队列、缓存和分布式锁。必填。
|
# 作用:后端连接 Redis 的地址。密码占位符会与上一项同时自动替换。
|
||||||
REDIS_URL=redis://:replace_with_random_hex@redis:6379/0
|
REDIS_URL=redis://:__AUTO_REDIS_PASSWORD__@redis:6379/0
|
||||||
|
|
||||||
# 作用:MinIO 的内部 S3 API 地址。必填。应用与 NAS 同网时填写 NAS 内网地址和 API 端口,不是控制台端口。
|
# 作用:加密保存可视化设置的目录。Docker 会挂载独立数据卷,通常无需修改。
|
||||||
S3_ENDPOINT=http://192.168.200.36:9000
|
RUNTIME_SETTINGS_DIR=.runtime
|
||||||
|
|
||||||
# 作用:浏览器通过预签名 URL 访问 MinIO 的公开 HTTPS 地址。外部用户上传时必填,纯内网开发可与 S3_ENDPOINT 相同。
|
# 作用:运行期设置的主加密密钥。留空时首次启动自动生成并保存在私有数据卷中;迁移服务器时需同时迁移该卷。
|
||||||
S3_PUBLIC_ENDPOINT=http://192.168.200.36:9000
|
SETTINGS_MASTER_KEY=
|
||||||
|
|
||||||
# 作用: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
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ venv/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
.runtime/
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ docs 架构与工作流设计
|
|||||||
|
|
||||||
## 本地启动
|
## 本地启动
|
||||||
|
|
||||||
1. 将 `.env.example` 复制为 `.env`,按注释填写配置。
|
推荐使用 Docker:
|
||||||
2. 安装前端依赖:`pnpm install`。
|
|
||||||
3. 启动前端:`pnpm dev`。
|
1. 在 PowerShell 运行 `./scripts/bootstrap.ps1 -Force`,自动生成数据库和 Redis 密码。
|
||||||
4. 创建 Python 虚拟环境并安装 API:`pip install -e "services/api[dev]"`。
|
2. 运行 `docker compose up -d --build`。
|
||||||
5. 启动 API:`uvicorn app.main:app --app-dir services/api --reload --port 8000`。
|
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`。
|
前端默认访问 `http://localhost:3000`,API 文档默认位于 `http://localhost:8000/docs`。
|
||||||
|
|
||||||
@@ -35,5 +40,6 @@ docs 架构与工作流设计
|
|||||||
- 提供结构化 Style DNA 面板。
|
- 提供结构化 Style DNA 面板。
|
||||||
- 提供可验证的工作流状态机和命令接口。
|
- 提供可验证的工作流状态机和命令接口。
|
||||||
- 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。
|
- 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。
|
||||||
|
- 提供分类设置中心、连接测试、必填检查和工作流就绪门禁。
|
||||||
|
|
||||||
详见 [架构设计](docs/architecture.md) 与 [工作流定义](docs/workflow.md)。
|
详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。
|
||||||
|
|||||||
+524
-1
@@ -59,6 +59,11 @@ textarea {
|
|||||||
font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea {
|
||||||
@@ -69,6 +74,11 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
textarea:focus-visible,
|
textarea:focus-visible,
|
||||||
@@ -150,6 +160,14 @@ textarea:focus-visible,
|
|||||||
gap: 9px;
|
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 {
|
.save-state {
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
@@ -853,6 +871,448 @@ textarea:focus-visible,
|
|||||||
font-size: 10px;
|
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) {
|
@media (max-width: 1180px) {
|
||||||
.studio-grid {
|
.studio-grid {
|
||||||
grid-template-columns: 190px minmax(520px, 1fr);
|
grid-template-columns: 190px minmax(520px, 1fr);
|
||||||
@@ -871,7 +1331,7 @@ textarea:focus-visible,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.save-state,
|
.save-state,
|
||||||
.topbar-actions .button-secondary,
|
.topbar-actions .button-secondary:not(.settings-button),
|
||||||
.brand-subtitle {
|
.brand-subtitle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -933,6 +1393,69 @@ textarea:focus-visible,
|
|||||||
.conversation {
|
.conversation {
|
||||||
min-height: 230px;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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<SettingsResponse | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
||||||
|
const [activeCategoryId, setActiveCategoryId] = useState("deployment");
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<{ tone: "success" | "error"; text: string } | null>(null);
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
|
||||||
|
|
||||||
|
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<SettingsResponse>;
|
||||||
|
})
|
||||||
|
.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 (
|
||||||
|
<div className="settings-backdrop" role="presentation">
|
||||||
|
<section className="settings-dialog" role="dialog" aria-modal="true" aria-label="系统设置">
|
||||||
|
<header className="settings-header">
|
||||||
|
<div>
|
||||||
|
<p className="settings-kicker">首次使用向导</p>
|
||||||
|
<h1>系统设置</h1>
|
||||||
|
<p>只填写你真正使用的服务;密钥保存后不会再次显示。</p>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button" type="button" onClick={closeSafely} aria-label="关闭系统设置">
|
||||||
|
<XIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{data ? (
|
||||||
|
<div className="settings-readiness">
|
||||||
|
<div className={`readiness-orb ${data.readiness.ready ? "ready" : ""}`}>
|
||||||
|
{data.readiness.ready ? <CheckCircleIcon size={22} weight="fill" /> : <DatabaseIcon size={22} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{data.readiness.ready ? "工作流已就绪" : "完成必备配置后才能开始设计"}</strong>
|
||||||
|
<p>{data.readiness.completed_required} / {data.readiness.total_required} 个必备分类已配置并测试</p>
|
||||||
|
</div>
|
||||||
|
<div className="readiness-meter" aria-label="配置完成度">
|
||||||
|
<span style={{ width: `${(data.readiness.completed_required / data.readiness.total_required) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="settings-layout">
|
||||||
|
<nav className="settings-nav" aria-label="设置分类">
|
||||||
|
{data?.categories.map((category) => {
|
||||||
|
const categoryTested = category.test_targets.length === 0 || category.test_targets.every(
|
||||||
|
(target) => data.tests[target]?.ok,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
className={category.id === activeCategory?.id ? "active" : ""}
|
||||||
|
onClick={() => setActiveCategoryId(category.id)}
|
||||||
|
>
|
||||||
|
<span>{category.label}</span>
|
||||||
|
{category.required_for_workflow ? (
|
||||||
|
<small className={categoryTested ? "done" : "required"}>
|
||||||
|
{categoryTested ? "已测试" : "必备"}
|
||||||
|
</small>
|
||||||
|
) : <small>可选</small>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="settings-content">
|
||||||
|
{busy === "load" && !data ? (
|
||||||
|
<div className="settings-loading"><ArrowClockwiseIcon size={24} /> 正在读取设置</div>
|
||||||
|
) : activeCategory && data ? (
|
||||||
|
<>
|
||||||
|
<div className="settings-section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>{activeCategory.label}</h2>
|
||||||
|
<p>{activeCategory.description}</p>
|
||||||
|
</div>
|
||||||
|
{activeCategory.fields.some((field) => field.advanced) ? (
|
||||||
|
<label className="advanced-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showAdvanced}
|
||||||
|
onChange={(event) => setShowAdvanced(event.target.checked)}
|
||||||
|
/>
|
||||||
|
高级设置
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-fields">
|
||||||
|
{activeCategory.fields.map((field) => {
|
||||||
|
if (!isVisible(field, draft) || (field.advanced && !showAdvanced)) return null;
|
||||||
|
return (
|
||||||
|
<SettingControl
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
value={draft[field.key]}
|
||||||
|
configured={Boolean(data.configured[field.key])}
|
||||||
|
onChange={(value) => updateField(field.key, value)}
|
||||||
|
onGenerate={() => void generateFor(field)}
|
||||||
|
busy={busy === field.key}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeCategory.id === "deployment" ? (
|
||||||
|
<div className="deployment-note">
|
||||||
|
<KeyIcon size={18} />
|
||||||
|
<p>数据库和 Redis 密码存在“先有服务还是先打开设置页”的启动顺序问题,因此不在这里修改。运行 <code>scripts/bootstrap.ps1</code> 时会一次性安全生成,你不需要安装 OpenSSL。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="settings-test-actions">
|
||||||
|
{activeCategory.test_targets.map((target) => {
|
||||||
|
const recorded = testResults[target] ?? (data.tests[target] as TestResult | undefined);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="button button-secondary"
|
||||||
|
type="button"
|
||||||
|
key={target}
|
||||||
|
onClick={() => void testTarget(target)}
|
||||||
|
disabled={Boolean(busy)}
|
||||||
|
>
|
||||||
|
{busy === target ? <ArrowClockwiseIcon className="spin" size={16} /> : <PlugIcon size={16} />}
|
||||||
|
{testLabels[target] ?? "测试连接"}
|
||||||
|
{recorded?.ok ? <CheckCircleIcon size={15} weight="fill" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="settings-footer">
|
||||||
|
<div className={`settings-notice ${notice?.tone ?? ""}`}>
|
||||||
|
{notice?.tone === "error" ? <WarningCircleIcon size={17} /> : null}
|
||||||
|
{notice?.tone === "success" ? <CheckCircleIcon size={17} weight="fill" /> : null}
|
||||||
|
<span>{notice?.text ?? "必备项完成并测试通过后,设计工作流会自动解锁。"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-footer-actions">
|
||||||
|
<button className="button button-secondary" type="button" onClick={closeSafely}>稍后设置</button>
|
||||||
|
<button
|
||||||
|
className="button button-primary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void saveSettings()}
|
||||||
|
disabled={Boolean(busy) || !data}
|
||||||
|
>
|
||||||
|
<FloppyDiskIcon size={16} /> 保存设置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="setting-status-row">
|
||||||
|
<span className={configured ? "configured" : "missing"}>
|
||||||
|
{configured ? <CheckCircleIcon size={18} weight="fill" /> : <WarningCircleIcon size={18} />}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{field.label}</strong>
|
||||||
|
<p>{field.description}</p>
|
||||||
|
</div>
|
||||||
|
<small>{configured ? "已配置" : "缺失"}</small>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.kind === "toggle") {
|
||||||
|
return (
|
||||||
|
<label className="setting-toggle-row">
|
||||||
|
<div>
|
||||||
|
<strong>{field.label}</strong>
|
||||||
|
<p>{field.description}</p>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" checked={Boolean(value)} onChange={(event) => onChange(event.target.checked)} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="setting-field">
|
||||||
|
<span className="setting-field-label">
|
||||||
|
<strong>{field.label}</strong>
|
||||||
|
{field.required ? <small>必填</small> : null}
|
||||||
|
</span>
|
||||||
|
<span className="setting-description">{field.description}</span>
|
||||||
|
<span className="setting-control-wrap">
|
||||||
|
{field.kind === "select" ? (
|
||||||
|
<select value={String(value ?? "")} onChange={(event) => onChange(event.target.value)}>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type={field.kind === "password" ? "password" : field.kind === "number" ? "number" : "text"}
|
||||||
|
list={field.kind === "combobox" ? `options-${field.key}` : undefined}
|
||||||
|
value={String(value ?? "")}
|
||||||
|
placeholder={field.secret && configured ? "已安全保存,留空不修改" : field.placeholder}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{field.kind === "combobox" ? (
|
||||||
|
<datalist id={`options-${field.key}`}>
|
||||||
|
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||||
|
</datalist>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{field.generator ? (
|
||||||
|
<button className="generate-button" type="button" onClick={onGenerate} disabled={busy}>
|
||||||
|
<KeyIcon size={14} /> 一键生成
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{selectedOption ? <span className="option-help">{selectedOption.help}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CubeIcon,
|
CubeIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
EyeSlashIcon,
|
EyeSlashIcon,
|
||||||
|
GearSixIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
LockSimpleIcon,
|
LockSimpleIcon,
|
||||||
PaperPlaneTiltIcon,
|
PaperPlaneTiltIcon,
|
||||||
@@ -21,8 +22,9 @@ import {
|
|||||||
WarningCircleIcon,
|
WarningCircleIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import Image from "next/image";
|
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 {
|
import {
|
||||||
initialLayers,
|
initialLayers,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
@@ -30,8 +32,10 @@ import {
|
|||||||
workflowStages,
|
workflowStages,
|
||||||
} from "@/lib/demo";
|
} from "@/lib/demo";
|
||||||
import type { ChatMessage } from "@/types/workflow";
|
import type { ChatMessage } from "@/types/workflow";
|
||||||
|
import type { SettingsReadiness } from "@/types/settings";
|
||||||
|
|
||||||
const iconWeight = "regular" as const;
|
const iconWeight = "regular" as const;
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
export function WorkflowStudio() {
|
export function WorkflowStudio() {
|
||||||
const [layers, setLayers] = useState(initialLayers);
|
const [layers, setLayers] = useState(initialLayers);
|
||||||
@@ -39,12 +43,32 @@ export function WorkflowStudio() {
|
|||||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
|
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [readiness, setReadiness] = useState<SettingsReadiness | null>(null);
|
||||||
|
|
||||||
const currentDirection = useMemo(
|
const currentDirection = useMemo(
|
||||||
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
|
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
|
||||||
[selectedDirection],
|
[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) {
|
function toggleLayer(id: string) {
|
||||||
setLayers((items) =>
|
setLayers((items) =>
|
||||||
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
|
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
|
||||||
@@ -84,9 +108,19 @@ export function WorkflowStudio() {
|
|||||||
|
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
<span className="save-state"><CheckIcon size={14} weight="bold" /> 已保存</span>
|
<span className="save-state"><CheckIcon size={14} weight="bold" /> 已保存</span>
|
||||||
|
<button className="button button-secondary settings-button" type="button" onClick={() => setSettingsOpen(true)}>
|
||||||
|
<GearSixIcon size={16} /> 系统设置
|
||||||
|
{readiness && !readiness.ready ? <span className="settings-alert-dot" aria-label="有必备配置未完成" /> : null}
|
||||||
|
</button>
|
||||||
<button className="button button-secondary" type="button">版本记录</button>
|
<button className="button button-secondary" type="button">版本记录</button>
|
||||||
<button className="button button-primary" type="button">
|
<button
|
||||||
确认结构 <ArrowRightIcon size={16} weight="bold" />
|
className="button button-primary"
|
||||||
|
type="button"
|
||||||
|
disabled={!readiness?.ready}
|
||||||
|
onClick={() => { if (!readiness?.ready) setSettingsOpen(true); }}
|
||||||
|
title={!readiness?.ready ? "请先完成系统设置" : undefined}
|
||||||
|
>
|
||||||
|
{readiness?.ready ? "确认结构" : "配置后开始"} <ArrowRightIcon size={16} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -327,6 +361,11 @@ export function WorkflowStudio() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
<SettingsCenter
|
||||||
|
open={settingsOpen}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onReadinessChange={setReadiness}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||||
|
configured: Record<string, boolean>;
|
||||||
|
tests: Record<string, { ok: boolean; message: string }>;
|
||||||
|
readiness: SettingsReadiness;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestResult = {
|
||||||
|
target: string;
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
};
|
||||||
@@ -29,3 +29,13 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
`expected_revision` 用于乐观锁。若客户端基于旧版本提交,服务端返回 `409 Conflict`,避免覆盖其他修改。
|
`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`。
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
- "${API_PORT:-8000}:8000"
|
- "${API_PORT:-8000}:8000"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- settings-data:/app/.runtime
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -58,3 +62,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
settings-data:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ FastAPI Orchestrator
|
|||||||
|-- Workflow State Machine
|
|-- Workflow State Machine
|
||||||
|-- Project Command Handler
|
|-- Project Command Handler
|
||||||
|-- Model Router
|
|-- Model Router
|
||||||
|
|-- Encrypted Settings Store
|
||||||
|
|-- Readiness Gate
|
||||||
|-- Signed Upload Service
|
|-- Signed Upload Service
|
||||||
|
|
|
|
||||||
Workers
|
Workers
|
||||||
@@ -61,5 +63,6 @@ Storage
|
|||||||
|
|
||||||
- 浏览器不接触 MinIO、OCR 或模型服务的永久密钥。
|
- 浏览器不接触 MinIO、OCR 或模型服务的永久密钥。
|
||||||
- 上传和下载通过短期预签名 URL。
|
- 上传和下载通过短期预签名 URL。
|
||||||
- 用户提供的模型 Key 使用 `APP_ENCRYPTION_KEY` 加密后存储。
|
- 可视化设置中的密钥整体使用 Fernet 加密,接口只返回“是否已配置”,不返回原文。
|
||||||
|
- 主加密密钥在首次启动时自动生成到私有数据卷,迁移服务器必须连同该卷一起备份。
|
||||||
- GPU 和内部 Worker 只接受内网请求,并校验内部服务令牌。
|
- GPU 和内部 Worker 只接受内网请求,并校验内部服务令牌。
|
||||||
|
|||||||
@@ -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` 私有卷。
|
||||||
|
- 密钥接口只返回是否已配置;保存成功后浏览器会清空输入值。
|
||||||
|
- 测试请求由后端发出,密钥不会直接交给第三方网页脚本。
|
||||||
|
- 当前版本应部署在可信内网;正式开放公网前还需给设置接口增加所有者登录和权限控制。
|
||||||
@@ -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."
|
||||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
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.models import CommandRequest, ProjectSnapshot, WorkflowDefinition
|
||||||
from app.domain.workflow import (
|
from app.domain.workflow import (
|
||||||
InvalidTransitionError,
|
InvalidTransitionError,
|
||||||
@@ -14,6 +15,7 @@ from app.domain.workflow import (
|
|||||||
from app.integrations.model_router import ModelRouter
|
from app.integrations.model_router import ModelRouter
|
||||||
from app.integrations.storage import S3Storage
|
from app.integrations.storage import S3Storage
|
||||||
from app.repositories.memory import repository
|
from app.repositories.memory import repository
|
||||||
|
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1")
|
router = APIRouter(prefix="/v1")
|
||||||
|
|
||||||
@@ -24,23 +26,28 @@ class UploadRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
def health(settings: Settings = Depends(get_settings)) -> dict:
|
def health(
|
||||||
router_state = ModelRouter(settings)
|
settings: Settings = Depends(get_settings),
|
||||||
|
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
|
||||||
|
) -> dict:
|
||||||
|
values = runtime_store.merged_values()
|
||||||
|
router_state = ModelRouter(values)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"environment": settings.app_env,
|
"environment": settings.app_env,
|
||||||
"integrations": {
|
"integrations": {
|
||||||
"storage": S3Storage(settings).configured,
|
"storage": S3Storage(values).configured,
|
||||||
"baidu_ocr": bool(
|
"baidu_ocr": bool(
|
||||||
settings.baidu_ocr_enabled
|
values.get("baidu_ocr_enabled")
|
||||||
and settings.baidu_ocr_api_key
|
and values.get("baidu_ocr_api_key")
|
||||||
and settings.baidu_ocr_secret_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(
|
"langfuse": bool(
|
||||||
settings.langfuse_enabled
|
values.get("langfuse_enabled")
|
||||||
and settings.langfuse_public_key
|
and values.get("langfuse_public_key")
|
||||||
and settings.langfuse_secret_key
|
and values.get("langfuse_secret_key")
|
||||||
),
|
),
|
||||||
"image_providers": [item.__dict__ for item in router_state.capabilities()],
|
"image_providers": [item.__dict__ for item in router_state.capabilities()],
|
||||||
},
|
},
|
||||||
@@ -60,7 +67,11 @@ def get_project(project_id: str) -> ProjectSnapshot:
|
|||||||
return project
|
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:
|
def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot:
|
||||||
project = repository.get(project_id)
|
project = repository.get(project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
@@ -74,15 +85,18 @@ def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot
|
|||||||
return repository.save(updated)
|
return repository.save(updated)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/uploads/presign")
|
@router.post("/uploads/presign", dependencies=[Depends(require_workflow_ready)])
|
||||||
def create_upload(
|
def create_upload(
|
||||||
request: UploadRequest,
|
request: UploadRequest,
|
||||||
settings: Settings = Depends(get_settings),
|
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
safe_name = request.filename.replace("/", "_").replace("\\", "_")
|
safe_name = request.filename.replace("/", "_").replace("\\", "_")
|
||||||
object_key = f"projects/pending/{uuid4()}/{safe_name}"
|
object_key = f"projects/pending/{uuid4()}/{safe_name}"
|
||||||
try:
|
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:
|
except RuntimeError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||||
return upload.__dict__
|
return upload.__dict__
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -18,52 +18,12 @@ class Settings(BaseSettings):
|
|||||||
api_port: int = 8000
|
api_port: int = 8000
|
||||||
cors_origins: str = "http://localhost:3000"
|
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"
|
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
|
@property
|
||||||
def cors_origin_list(self) -> list[str]:
|
def cors_origin_list(self) -> list[str]:
|
||||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
|
|
||||||
@@ -15,35 +17,27 @@ class ProviderCapability:
|
|||||||
|
|
||||||
|
|
||||||
class ModelRouter:
|
class ModelRouter:
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
|
||||||
self.settings = settings
|
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]:
|
def capabilities(self) -> list[ProviderCapability]:
|
||||||
return [
|
return [
|
||||||
ProviderCapability(
|
ProviderCapability(
|
||||||
provider="seedream",
|
provider=self._value("ai_provider", "custom"),
|
||||||
configured=_configured(self.settings.ark_api_key)
|
configured=_configured(self._value("ai_api_key"))
|
||||||
and bool(self.settings.seedream_model_endpoint),
|
and bool(self._value("ai_base_url"))
|
||||||
strengths=("中文风格指令", "快速方向探索"),
|
and bool(self._value("image_model")),
|
||||||
),
|
strengths=("统一模型网关", "空间理解", "图像生成与编辑"),
|
||||||
ProviderCapability(
|
|
||||||
provider="openai",
|
|
||||||
configured=_configured(self.settings.openai_api_key),
|
|
||||||
strengths=("局部编辑", "多轮一致性", "遮罩修改"),
|
|
||||||
),
|
|
||||||
ProviderCapability(
|
|
||||||
provider="gemini",
|
|
||||||
configured=_configured(self.settings.gemini_api_key),
|
|
||||||
strengths=("多参考图理解", "复杂视觉指令"),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def choose_image_provider(self, task: str) -> str:
|
def choose_image_provider(self, task: str) -> str:
|
||||||
configured = {item.provider for item in self.capabilities() if item.configured}
|
configured = [item.provider for item in self.capabilities() if item.configured]
|
||||||
priorities = [item.strip() for item in self.settings.image_provider_priority.split(",")]
|
if configured:
|
||||||
if task == "masked_edit" and "openai" in configured:
|
return configured[0]
|
||||||
return "openai"
|
|
||||||
for provider in priorities:
|
|
||||||
if provider in configured:
|
|
||||||
return provider
|
|
||||||
raise RuntimeError("No image provider is configured.")
|
raise RuntimeError("No image provider is configured.")
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import base64
|
import base64
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -8,25 +10,30 @@ from app.config import Settings
|
|||||||
class BaiduOcrAdapter:
|
class BaiduOcrAdapter:
|
||||||
OCR_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic"
|
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
|
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
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(
|
return bool(
|
||||||
self.settings.baidu_ocr_enabled
|
self._value("baidu_ocr_enabled")
|
||||||
and self.settings.baidu_ocr_api_key
|
and self._value("baidu_ocr_api_key")
|
||||||
and self.settings.baidu_ocr_secret_key
|
and self._value("baidu_ocr_secret_key")
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _access_token(self) -> str:
|
async def _access_token(self) -> str:
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
response = await client.post(
|
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={
|
params={
|
||||||
"grant_type": "client_credentials",
|
"grant_type": "client_credentials",
|
||||||
"client_id": self.settings.baidu_ocr_api_key,
|
"client_id": self._value("baidu_ocr_api_key"),
|
||||||
"client_secret": self.settings.baidu_ocr_secret_key,
|
"client_secret": self._value("baidu_ocr_secret_key"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from botocore.client import Config
|
from botocore.client import Config
|
||||||
@@ -15,33 +17,38 @@ class PresignedUpload:
|
|||||||
|
|
||||||
|
|
||||||
class S3Storage:
|
class S3Storage:
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
|
||||||
self.settings = settings
|
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
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
values = (
|
values = (
|
||||||
self.settings.s3_endpoint,
|
self._value("s3_endpoint"),
|
||||||
self.settings.s3_access_key_id,
|
self._value("s3_access_key_id"),
|
||||||
self.settings.s3_secret_access_key,
|
self._value("s3_secret_access_key"),
|
||||||
)
|
)
|
||||||
return all(values) and not any(value.startswith("replace_with_") for value in values)
|
return all(values) and not any(value.startswith("replace_with_") for value in values)
|
||||||
|
|
||||||
def _client(self, public: bool = False):
|
def _client(self, public: bool = False):
|
||||||
endpoint = (
|
endpoint = (
|
||||||
self.settings.s3_public_endpoint
|
self._value("s3_public_endpoint")
|
||||||
if public and self.settings.s3_public_endpoint
|
if public and self._value("s3_public_endpoint")
|
||||||
else self.settings.s3_endpoint
|
else self._value("s3_endpoint")
|
||||||
)
|
)
|
||||||
return boto3.client(
|
return boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
endpoint_url=endpoint,
|
endpoint_url=endpoint,
|
||||||
aws_access_key_id=self.settings.s3_access_key_id,
|
aws_access_key_id=self._value("s3_access_key_id"),
|
||||||
aws_secret_access_key=self.settings.s3_secret_access_key,
|
aws_secret_access_key=self._value("s3_secret_access_key"),
|
||||||
region_name=self.settings.s3_region,
|
region_name=self._value("s3_region", "us-east-1"),
|
||||||
config=Config(
|
config=Config(
|
||||||
signature_version="s3v4",
|
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(
|
url = self._client(public=True).generate_presigned_url(
|
||||||
"put_object",
|
"put_object",
|
||||||
Params={
|
Params={
|
||||||
"Bucket": self.settings.s3_bucket_inputs,
|
"Bucket": self._value("s3_bucket_inputs"),
|
||||||
"Key": object_key,
|
"Key": object_key,
|
||||||
"ContentType": content_type,
|
"ContentType": content_type,
|
||||||
},
|
},
|
||||||
ExpiresIn=self.settings.s3_presigned_url_ttl_seconds,
|
ExpiresIn=int(self._value("s3_presigned_url_ttl_seconds", 900)),
|
||||||
)
|
)
|
||||||
return PresignedUpload(
|
return PresignedUpload(
|
||||||
method="PUT",
|
method="PUT",
|
||||||
url=url,
|
url=url,
|
||||||
object_key=object_key,
|
object_key=object_key,
|
||||||
expires_in=self.settings.s3_presigned_url_ttl_seconds,
|
expires_in=int(self._value("s3_presigned_url_ttl_seconds", 900)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import router
|
from app.api.routes import router
|
||||||
|
from app.api.settings import router as settings_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -20,6 +21,7 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
app.include_router(settings_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -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 服务返回异常。",
|
||||||
|
)
|
||||||
@@ -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"}
|
||||||
@@ -9,11 +9,14 @@ description = "Workflow orchestrator for the AI interior style studio"
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3>=1.35,<2",
|
"boto3>=1.35,<2",
|
||||||
|
"cryptography>=44,<47",
|
||||||
"fastapi>=0.115,<1",
|
"fastapi>=0.115,<1",
|
||||||
"httpx>=0.28,<1",
|
"httpx>=0.28,<1",
|
||||||
|
"psycopg[binary]>=3.2,<4",
|
||||||
"pydantic>=2.10,<3",
|
"pydantic>=2.10,<3",
|
||||||
"pydantic-settings>=2.7,<3",
|
"pydantic-settings>=2.7,<3",
|
||||||
"python-multipart>=0.0.20,<1",
|
"python-multipart>=0.0.20,<1",
|
||||||
|
"redis>=5.2,<7",
|
||||||
"uvicorn[standard]>=0.34,<1"
|
"uvicorn[standard]>=0.34,<1"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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)
|
return httpx.ASGITransport(app=app)
|
||||||
|
|
||||||
|
|
||||||
@@ -25,3 +37,27 @@ async def test_demo_project_contract(transport: httpx.ASGITransport) -> None:
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["stage"] == "plan_review"
|
assert body["stage"] == "plan_review"
|
||||||
assert body["plan"]["cad_layer_count"] == 43
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user