feat: add visual settings and readiness gate

This commit is contained in:
Codex
2026-08-01 22:14:20 +08:00
parent 6a15217d55
commit 3b4106e8cc
25 changed files with 2447 additions and 239 deletions
+23 -126
View File
@@ -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 通常需要 trueAWS 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
+1
View File
@@ -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/
+12 -6
View File
@@ -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
View File
@@ -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) {
+412
View File
@@ -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>
);
}
+42 -3
View File
@@ -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>
); );
+52
View File
@@ -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>;
};
+10
View File
@@ -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`
+5
View File
@@ -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:
+4 -1
View File
@@ -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 只接受内网请求,并校验内部服务令牌。
+60
View File
@@ -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` 私有卷。
- 密钥接口只返回是否已配置;保存成功后浏览器会清空输入值。
- 测试请求由后端发出,密钥不会直接交给第三方网页脚本。
- 当前版本应部署在可信内网;正式开放公网前还需给设置接口增加所有者登录和权限控制。
+37
View File
@@ -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."
+28 -14
View File
@@ -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__
+89
View File
@@ -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,
},
)
+4 -44
View File
@@ -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()]
+16 -22
View File
@@ -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.")
+14 -7
View File
@@ -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()
+21 -14
View File
@@ -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
View File
@@ -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("/")
+500
View File
@@ -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 服务返回异常。",
)
+404
View File
@@ -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"}
+3
View File
@@ -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"
] ]
+37 -1
View File
@@ -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"
+69
View File
@@ -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