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_NAME=zhuangxiu-api
|
||||
|
||||
# 作用:控制后端日志输出级别。必填。开发使用 INFO,排障可临时改为 DEBUG,生产不建议 DEBUG。
|
||||
# 作用:日志详细程度。INFO 适合日常使用;DEBUG 仅用于短期排障。默认 INFO。
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 作用:后端监听地址。必填。容器内使用 0.0.0.0,本机仅监听本地时可用 127.0.0.1。
|
||||
# 作用:API 在容器内监听的地址。Docker 部署保持 0.0.0.0。
|
||||
API_HOST=0.0.0.0
|
||||
|
||||
# 作用:后端 HTTP 端口。必填。修改后需要同步更新 NEXT_PUBLIC_API_BASE_URL。
|
||||
# 作用:API 对外端口。默认 8000;端口冲突时再修改。
|
||||
API_PORT=8000
|
||||
|
||||
# 作用:浏览器允许访问后端的前端来源,多个来源使用英文逗号分隔。必填。生产环境不要使用星号。
|
||||
# 作用:允许访问 API 的前端地址。多个地址使用英文逗号分隔。
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# 作用:前端访问后端 API 的公开地址。必填。该值会进入浏览器代码,不得包含任何密钥。
|
||||
# 作用:浏览器访问 API 的公开地址。该值会进入前端代码,不得包含密钥。
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# 作用:PostgreSQL 数据库名。必填。Docker Compose 会用它初始化数据库。
|
||||
# 作用:PostgreSQL 数据库名。安装器会直接使用默认值创建数据库。
|
||||
POSTGRES_DB=zhuangxiu
|
||||
|
||||
# 作用:PostgreSQL 应用账号。必填。生产环境不要使用 postgres 超级用户。
|
||||
# 作用:PostgreSQL 应用账号。默认使用非超级用户 zhuangxiu。
|
||||
POSTGRES_USER=zhuangxiu
|
||||
|
||||
# 作用:PostgreSQL 应用密码。必填。使用 `openssl rand -hex 24` 单独生成,不要与其他密码复用。
|
||||
POSTGRES_PASSWORD=replace_with_random_hex
|
||||
# 作用:PostgreSQL 密码。不要手填;scripts/bootstrap.ps1 会用系统加密随机数自动替换。
|
||||
POSTGRES_PASSWORD=__AUTO_POSTGRES_PASSWORD__
|
||||
|
||||
# 作用:后端连接 PostgreSQL 的完整地址。必填。若密码包含特殊字符需要做 URL 编码。
|
||||
DATABASE_URL=postgresql+psycopg://zhuangxiu:replace_with_random_hex@postgres:5432/zhuangxiu
|
||||
# 作用:后端连接 PostgreSQL 的地址。密码占位符会与上一项同时自动替换。
|
||||
DATABASE_URL=postgresql://zhuangxiu:__AUTO_POSTGRES_PASSWORD__@postgres:5432/zhuangxiu
|
||||
|
||||
# 作用:Redis 访问密码。必填。使用 `openssl rand -hex 24` 生成,生产环境不可留空。
|
||||
REDIS_PASSWORD=replace_with_random_hex
|
||||
# 作用:Redis 密码。不要手填;scripts/bootstrap.ps1 会自动生成独立随机密码。
|
||||
REDIS_PASSWORD=__AUTO_REDIS_PASSWORD__
|
||||
|
||||
# 作用:后端连接 Redis 的完整地址,用于队列、缓存和分布式锁。必填。
|
||||
REDIS_URL=redis://:replace_with_random_hex@redis:6379/0
|
||||
# 作用:后端连接 Redis 的地址。密码占位符会与上一项同时自动替换。
|
||||
REDIS_URL=redis://:__AUTO_REDIS_PASSWORD__@redis:6379/0
|
||||
|
||||
# 作用:MinIO 的内部 S3 API 地址。必填。应用与 NAS 同网时填写 NAS 内网地址和 API 端口,不是控制台端口。
|
||||
S3_ENDPOINT=http://192.168.200.36:9000
|
||||
# 作用:加密保存可视化设置的目录。Docker 会挂载独立数据卷,通常无需修改。
|
||||
RUNTIME_SETTINGS_DIR=.runtime
|
||||
|
||||
# 作用:浏览器通过预签名 URL 访问 MinIO 的公开 HTTPS 地址。外部用户上传时必填,纯内网开发可与 S3_ENDPOINT 相同。
|
||||
S3_PUBLIC_ENDPOINT=http://192.168.200.36:9000
|
||||
|
||||
# 作用:MinIO 专用服务账号的 Access Key。必填。不要填写 MINIO_ROOT_USER,也不要暴露给前端。
|
||||
S3_ACCESS_KEY_ID=replace_with_minio_service_account
|
||||
|
||||
# 作用:MinIO 专用服务账号的 Secret Key。必填。由 MinIO 创建服务账号时生成,只保存在后端。
|
||||
S3_SECRET_ACCESS_KEY=replace_with_minio_service_secret
|
||||
|
||||
# 作用:S3 签名使用的区域。MinIO 通常使用 us-east-1,除非服务端配置了其他区域。
|
||||
S3_REGION=us-east-1
|
||||
|
||||
# 作用:保存用户原始 PDF、DXF 和户型图片的私有 bucket。必填。
|
||||
S3_BUCKET_INPUTS=renovation-inputs
|
||||
|
||||
# 作用:保存 SVG、蒙版、控制图和 3D 白模等中间结果的私有 bucket。必填。
|
||||
S3_BUCKET_DERIVED=renovation-derived
|
||||
|
||||
# 作用:保存最终效果图、局部修改图和版本预览的私有 bucket。必填。
|
||||
S3_BUCKET_RENDERS=renovation-renders
|
||||
|
||||
# 作用:强制使用路径式 S3 地址。MinIO 通常需要 true,AWS S3 通常可以为 false。
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# 作用:预签名上传和下载 URL 的有效秒数。必填。建议保持较短,默认 15 分钟。
|
||||
S3_PRESIGNED_URL_TTL_SECONDS=900
|
||||
|
||||
# 作用:启用百度 OCR。矢量 PDF 主流程不依赖 OCR,扫描图或文字层损坏时设为 true。
|
||||
BAIDU_OCR_ENABLED=false
|
||||
|
||||
# 作用:百度智能云 OCR 应用的 API Key。启用百度 OCR 时必填,只能放在后端。
|
||||
BAIDU_OCR_API_KEY=
|
||||
|
||||
# 作用:百度智能云 OCR 应用的 Secret Key。启用百度 OCR 时必填,不得写入日志或返回前端。
|
||||
BAIDU_OCR_SECRET_KEY=
|
||||
|
||||
# 作用:百度 OCR OAuth Token 接口地址。通常无需修改,保留配置便于企业代理或兼容服务。
|
||||
BAIDU_OCR_TOKEN_URL=https://aip.baidubce.com/oauth/2.0/token
|
||||
|
||||
# 作用:默认大语言模型供应商名称。必填。当前适配层预留 openai、gemini 和 compatible。
|
||||
LLM_DEFAULT_PROVIDER=openai
|
||||
|
||||
# 作用:OpenAI 或 OpenAI 兼容服务的 API Key。使用该供应商时必填,只在服务端读取。
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# 作用:OpenAI 兼容 API 的基础地址。官方服务保留默认值,使用代理或兼容服务时修改。
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# 作用:空间理解与总调度默认模型名称。必填。实际模型需与所选供应商账号权限一致。
|
||||
OPENAI_TEXT_MODEL=gpt-5
|
||||
|
||||
# 作用:OpenAI 图像生成或编辑默认模型。启用 OpenAI 图像适配器时必填。
|
||||
OPENAI_IMAGE_MODEL=gpt-image-2
|
||||
|
||||
# 作用:Google Gemini API Key。启用 Gemini 或 Nano Banana 图像适配器时必填。
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# 作用:Gemini 多模态推理默认模型名称。启用 Gemini 时必填。
|
||||
GEMINI_MODEL=gemini-3-pro
|
||||
|
||||
# 作用:火山方舟模型调用 API Key。启用 Seedream 时必填,建议使用方舟专用 Key 而非云账号长期 AK/SK。
|
||||
ARK_API_KEY=
|
||||
|
||||
# 作用:Seedream 图像模型的 Endpoint ID 或模型标识。启用 Seedream 时必填,以火山方舟控制台显示值为准。
|
||||
SEEDREAM_MODEL_ENDPOINT=
|
||||
|
||||
# 作用:优先使用的生图供应商顺序,多个值使用英文逗号分隔。必填。路由器会按任务能力和可用性选择。
|
||||
IMAGE_PROVIDER_PRIORITY=seedream,openai,gemini
|
||||
|
||||
# 作用:本地 GPU 推理服务地址。启用本地户型分割、深度估计或审美评分时必填,仅使用内网地址。
|
||||
GPU_SERVICE_URL=http://gpu-worker:8100
|
||||
|
||||
# 作用:业务后端调用 GPU 服务时使用的内部 Bearer Token。启用 GPU 服务时必填,使用 `openssl rand -hex 32` 生成。
|
||||
GPU_SERVICE_TOKEN=replace_with_random_hex
|
||||
|
||||
# 作用:用户登录 JWT 的签名密钥。启用用户系统时必填,使用 `openssl rand -hex 32` 独立生成。
|
||||
JWT_SECRET=replace_with_random_hex
|
||||
|
||||
# 作用:签名或加密会话 Cookie 和 CSRF 状态。启用用户系统时必填,使用 `openssl rand -hex 32` 生成,不得与 JWT_SECRET 共用。
|
||||
SESSION_SECRET=replace_with_random_hex
|
||||
|
||||
# 作用:加密数据库中的用户模型 Key 和第三方令牌。必填。使用 `openssl rand -base64 32` 生成,丢失后旧密文无法恢复。
|
||||
APP_ENCRYPTION_KEY=replace_with_32_byte_base64_key
|
||||
|
||||
# 作用:验证内部异步回调的 HMAC 签名。启用 Worker 回调时必填,使用 `openssl rand -hex 32` 独立生成。
|
||||
WEBHOOK_SIGNING_SECRET=replace_with_random_hex
|
||||
|
||||
# 作用:启用 Langfuse 调用链追踪。开发初期可以关闭,准备评测不同模型后建议开启。
|
||||
LANGFUSE_ENABLED=false
|
||||
|
||||
# 作用:Langfuse 服务地址。自部署时填写内网或 HTTPS 地址,关闭追踪时可保留默认值。
|
||||
LANGFUSE_HOST=http://langfuse-web:3000
|
||||
|
||||
# 作用:Langfuse 项目的 Public Key。启用追踪时必填,该值用于标识项目但仍不应放入公开仓库。
|
||||
LANGFUSE_PUBLIC_KEY=
|
||||
|
||||
# 作用:Langfuse 项目的 Secret Key。启用追踪时必填,只能由后端和 Worker 使用。
|
||||
LANGFUSE_SECRET_KEY=
|
||||
|
||||
# 作用:Sentry DSN,用于收集后端异常和性能事件。未部署或暂不使用 Sentry 时留空。
|
||||
SENTRY_DSN=
|
||||
|
||||
# 作用:标记 Sentry 事件所属环境。填写 development、staging 或 production,便于区分告警。
|
||||
SENTRY_ENVIRONMENT=development
|
||||
|
||||
# 作用:Sentry 性能追踪采样率,范围 0 到 1。生产初期建议 0.05 到 0.2,开发可设为 0。
|
||||
SENTRY_TRACES_SAMPLE_RATE=0
|
||||
# 作用:运行期设置的主加密密钥。留空时首次启动自动生成并保存在私有数据卷中;迁移服务器时需同时迁移该卷。
|
||||
SETTINGS_MASTER_KEY=
|
||||
|
||||
@@ -18,6 +18,7 @@ venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.runtime/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
|
||||
@@ -20,11 +20,16 @@ docs 架构与工作流设计
|
||||
|
||||
## 本地启动
|
||||
|
||||
1. 将 `.env.example` 复制为 `.env`,按注释填写配置。
|
||||
2. 安装前端依赖:`pnpm install`。
|
||||
3. 启动前端:`pnpm dev`。
|
||||
4. 创建 Python 虚拟环境并安装 API:`pip install -e "services/api[dev]"`。
|
||||
5. 启动 API:`uvicorn app.main:app --app-dir services/api --reload --port 8000`。
|
||||
推荐使用 Docker:
|
||||
|
||||
1. 在 PowerShell 运行 `./scripts/bootstrap.ps1 -Force`,自动生成数据库和 Redis 密码。
|
||||
2. 运行 `docker compose up -d --build`。
|
||||
3. 打开 `http://localhost:3000`,首次进入会自动显示“系统设置”。
|
||||
4. 在网页中填写 MinIO、百度 OCR 和模型 API,并逐项点击测试。
|
||||
|
||||
日常使用不需要编辑 `.env`。它只保留应用启动前必须存在的基础参数,模型密钥等运行期配置会在网页中填写并加密保存。
|
||||
|
||||
需要单独开发前后端时:安装前端依赖 `pnpm install`,启动前端 `pnpm dev`;创建 Python 虚拟环境并运行 `pip install -e "services/api[dev]"`,随后使用 `uvicorn app.main:app --app-dir services/api --reload --port 8000` 启动 API。
|
||||
|
||||
前端默认访问 `http://localhost:3000`,API 文档默认位于 `http://localhost:8000/docs`。
|
||||
|
||||
@@ -35,5 +40,6 @@ docs 架构与工作流设计
|
||||
- 提供结构化 Style DNA 面板。
|
||||
- 提供可验证的工作流状态机和命令接口。
|
||||
- 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。
|
||||
- 提供分类设置中心、连接测试、必填检查和工作流就绪门禁。
|
||||
|
||||
详见 [架构设计](docs/architecture.md) 与 [工作流定义](docs/workflow.md)。
|
||||
详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。
|
||||
|
||||
+524
-1
@@ -59,6 +59,11 @@ textarea {
|
||||
font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
select {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
@@ -69,6 +74,11 @@ button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
@@ -150,6 +160,14 @@ textarea:focus-visible,
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.settings-alert-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 3px var(--warning-soft);
|
||||
}
|
||||
|
||||
.save-state {
|
||||
gap: 5px;
|
||||
margin-right: 6px;
|
||||
@@ -853,6 +871,448 @@ textarea:focus-visible,
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.settings-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(18 24 20 / 0.56);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
width: min(1120px, 100%);
|
||||
height: min(820px, calc(100dvh - 48px));
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-surface);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 34px 100px rgb(7 13 9 / 0.34);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 20px 22px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.settings-header h1,
|
||||
.settings-header p,
|
||||
.settings-section-heading h2,
|
||||
.settings-section-heading p,
|
||||
.settings-readiness p,
|
||||
.setting-status-row p,
|
||||
.setting-toggle-row p,
|
||||
.deployment-note p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
margin-top: 3px;
|
||||
font-size: 21px;
|
||||
font-weight: 690;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.settings-header > div > p:last-child {
|
||||
margin-top: 5px;
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.settings-kicker {
|
||||
color: var(--accent-strong);
|
||||
font-size: 9px;
|
||||
font-weight: 720;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.settings-readiness {
|
||||
min-height: 70px;
|
||||
padding: 11px 22px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(220px, auto) minmax(180px, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--accent-soft) 68%, var(--surface));
|
||||
}
|
||||
|
||||
.readiness-orb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
.readiness-orb.ready {
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.settings-readiness strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-readiness p {
|
||||
margin-top: 4px;
|
||||
color: var(--text-soft);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.readiness-meter {
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.readiness-meter span {
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
transition: width 240ms ease;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 190px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
padding: 14px 10px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 3px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.settings-nav button {
|
||||
min-height: 43px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
color: var(--text-soft);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.settings-nav button:hover {
|
||||
background: color-mix(in srgb, var(--surface-raised) 72%, transparent);
|
||||
}
|
||||
|
||||
.settings-nav button.active {
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.settings-nav small {
|
||||
color: var(--text-faint);
|
||||
font-size: 8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.settings-nav small.required {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.settings-nav small.done {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 22px 26px 30px;
|
||||
}
|
||||
|
||||
.settings-loading {
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-soft);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.settings-section-heading {
|
||||
padding-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.settings-section-heading h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 690;
|
||||
}
|
||||
|
||||
.settings-section-heading p {
|
||||
max-width: 620px;
|
||||
margin-top: 5px;
|
||||
color: var(--text-soft);
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
min-width: 86px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
color: var(--text-soft);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.advanced-toggle input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.settings-fields {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.setting-field,
|
||||
.setting-toggle-row,
|
||||
.setting-status-row {
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.setting-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 0.36fr) minmax(260px, 0.64fr);
|
||||
gap: 4px 22px;
|
||||
}
|
||||
|
||||
.setting-field-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setting-field-label strong,
|
||||
.setting-toggle-row strong,
|
||||
.setting-status-row strong {
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.setting-field-label small {
|
||||
color: var(--warning);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.setting-description {
|
||||
grid-column: 1;
|
||||
color: var(--text-faint);
|
||||
font-size: 9px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.setting-control-wrap {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.setting-control-wrap input,
|
||||
.setting-control-wrap select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
background: var(--surface-raised);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.setting-control-wrap input::placeholder {
|
||||
color: var(--text-faint);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.generate-button {
|
||||
min-width: 88px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.option-help {
|
||||
grid-column: 2;
|
||||
color: var(--accent-strong);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.setting-toggle-row,
|
||||
.setting-status-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.setting-toggle-row p,
|
||||
.setting-status-row p {
|
||||
margin-top: 4px;
|
||||
color: var(--text-faint);
|
||||
font-size: 9px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setting-toggle-row input {
|
||||
width: 35px;
|
||||
height: 19px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.setting-status-row {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
|
||||
.setting-status-row > span {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.setting-status-row > span.configured {
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.setting-status-row > span.missing {
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
.setting-status-row > small {
|
||||
color: var(--text-soft);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.deployment-note {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 9px;
|
||||
color: var(--text-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.deployment-note p {
|
||||
font-size: 9px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.deployment-note code {
|
||||
color: var(--text);
|
||||
font-family: "Cascadia Code", monospace;
|
||||
}
|
||||
|
||||
.settings-test-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-test-actions .button svg:last-child {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: settings-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes settings-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
min-height: 68px;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.settings-notice,
|
||||
.settings-footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-notice {
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
color: var(--text-soft);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.settings-notice span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-notice.error {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.settings-notice.success {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.settings-footer-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.studio-grid {
|
||||
grid-template-columns: 190px minmax(520px, 1fr);
|
||||
@@ -871,7 +1331,7 @@ textarea:focus-visible,
|
||||
}
|
||||
|
||||
.save-state,
|
||||
.topbar-actions .button-secondary,
|
||||
.topbar-actions .button-secondary:not(.settings-button),
|
||||
.brand-subtitle {
|
||||
display: none;
|
||||
}
|
||||
@@ -933,6 +1393,69 @@ textarea:focus-visible,
|
||||
.conversation {
|
||||
min-height: 230px;
|
||||
}
|
||||
|
||||
.settings-backdrop {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
height: 100dvh;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.settings-readiness {
|
||||
padding: 10px 14px;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.readiness-meter {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.settings-nav button {
|
||||
min-width: 108px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 18px 15px 24px;
|
||||
}
|
||||
|
||||
.setting-field {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.setting-control-wrap,
|
||||
.option-help {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.settings-notice {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -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,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
GearSixIcon,
|
||||
ImageIcon,
|
||||
LockSimpleIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
@@ -21,8 +22,9 @@ import {
|
||||
WarningCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import Image from "next/image";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { SettingsCenter } from "@/components/settings-center";
|
||||
import {
|
||||
initialLayers,
|
||||
initialMessages,
|
||||
@@ -30,8 +32,10 @@ import {
|
||||
workflowStages,
|
||||
} from "@/lib/demo";
|
||||
import type { ChatMessage } from "@/types/workflow";
|
||||
import type { SettingsReadiness } from "@/types/settings";
|
||||
|
||||
const iconWeight = "regular" as const;
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
export function WorkflowStudio() {
|
||||
const [layers, setLayers] = useState(initialLayers);
|
||||
@@ -39,12 +43,32 @@ export function WorkflowStudio() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [readiness, setReadiness] = useState<SettingsReadiness | null>(null);
|
||||
|
||||
const currentDirection = useMemo(
|
||||
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
|
||||
[selectedDirection],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadReadiness() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/v1/readiness`, { cache: "no-store" });
|
||||
if (!response.ok) return;
|
||||
const current = await response.json() as SettingsReadiness;
|
||||
setReadiness(current);
|
||||
if (!current.ready && !window.sessionStorage.getItem("settings-intro-seen")) {
|
||||
window.sessionStorage.setItem("settings-intro-seen", "true");
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
} catch {
|
||||
setReadiness(null);
|
||||
}
|
||||
}
|
||||
void loadReadiness();
|
||||
}, []);
|
||||
|
||||
function toggleLayer(id: string) {
|
||||
setLayers((items) =>
|
||||
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
|
||||
@@ -84,9 +108,19 @@ export function WorkflowStudio() {
|
||||
|
||||
<div className="topbar-actions">
|
||||
<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-primary" type="button">
|
||||
确认结构 <ArrowRightIcon size={16} weight="bold" />
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</header>
|
||||
@@ -327,6 +361,11 @@ export function WorkflowStudio() {
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<SettingsCenter
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
onReadinessChange={setReadiness}
|
||||
/>
|
||||
</main>
|
||||
</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`,避免覆盖其他修改。
|
||||
|
||||
## 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
|
||||
ports:
|
||||
- "${API_PORT:-8000}:8000"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- settings-data:/app/.runtime
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -58,3 +62,4 @@ services:
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
settings-data:
|
||||
|
||||
@@ -21,6 +21,8 @@ FastAPI Orchestrator
|
||||
|-- Workflow State Machine
|
||||
|-- Project Command Handler
|
||||
|-- Model Router
|
||||
|-- Encrypted Settings Store
|
||||
|-- Readiness Gate
|
||||
|-- Signed Upload Service
|
||||
|
|
||||
Workers
|
||||
@@ -61,5 +63,6 @@ Storage
|
||||
|
||||
- 浏览器不接触 MinIO、OCR 或模型服务的永久密钥。
|
||||
- 上传和下载通过短期预签名 URL。
|
||||
- 用户提供的模型 Key 使用 `APP_ENCRYPTION_KEY` 加密后存储。
|
||||
- 可视化设置中的密钥整体使用 Fernet 加密,接口只返回“是否已配置”,不返回原文。
|
||||
- 主加密密钥在首次启动时自动生成到私有数据卷,迁移服务器必须连同该卷一起备份。
|
||||
- GPU 和内部 Worker 只接受内网请求,并校验内部服务令牌。
|
||||
|
||||
@@ -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 app.config import Settings, get_settings
|
||||
from app.api.settings import require_workflow_ready
|
||||
from app.domain.models import CommandRequest, ProjectSnapshot, WorkflowDefinition
|
||||
from app.domain.workflow import (
|
||||
InvalidTransitionError,
|
||||
@@ -14,6 +15,7 @@ from app.domain.workflow import (
|
||||
from app.integrations.model_router import ModelRouter
|
||||
from app.integrations.storage import S3Storage
|
||||
from app.repositories.memory import repository
|
||||
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
|
||||
@@ -24,23 +26,28 @@ class UploadRequest(BaseModel):
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(settings: Settings = Depends(get_settings)) -> dict:
|
||||
router_state = ModelRouter(settings)
|
||||
def health(
|
||||
settings: Settings = Depends(get_settings),
|
||||
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
|
||||
) -> dict:
|
||||
values = runtime_store.merged_values()
|
||||
router_state = ModelRouter(values)
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
"integrations": {
|
||||
"storage": S3Storage(settings).configured,
|
||||
"storage": S3Storage(values).configured,
|
||||
"baidu_ocr": bool(
|
||||
settings.baidu_ocr_enabled
|
||||
and settings.baidu_ocr_api_key
|
||||
and settings.baidu_ocr_secret_key
|
||||
values.get("baidu_ocr_enabled")
|
||||
and values.get("baidu_ocr_api_key")
|
||||
and values.get("baidu_ocr_secret_key")
|
||||
),
|
||||
"gpu": bool(settings.gpu_service_url and settings.gpu_service_token),
|
||||
"gpu": values.get("gpu_mode") in {"local", "remote"}
|
||||
and bool(values.get("gpu_service_token")),
|
||||
"langfuse": bool(
|
||||
settings.langfuse_enabled
|
||||
and settings.langfuse_public_key
|
||||
and settings.langfuse_secret_key
|
||||
values.get("langfuse_enabled")
|
||||
and values.get("langfuse_public_key")
|
||||
and values.get("langfuse_secret_key")
|
||||
),
|
||||
"image_providers": [item.__dict__ for item in router_state.capabilities()],
|
||||
},
|
||||
@@ -60,7 +67,11 @@ def get_project(project_id: str) -> ProjectSnapshot:
|
||||
return project
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/commands", response_model=ProjectSnapshot)
|
||||
@router.post(
|
||||
"/projects/{project_id}/commands",
|
||||
response_model=ProjectSnapshot,
|
||||
dependencies=[Depends(require_workflow_ready)],
|
||||
)
|
||||
def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot:
|
||||
project = repository.get(project_id)
|
||||
if project is None:
|
||||
@@ -74,15 +85,18 @@ def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot
|
||||
return repository.save(updated)
|
||||
|
||||
|
||||
@router.post("/uploads/presign")
|
||||
@router.post("/uploads/presign", dependencies=[Depends(require_workflow_ready)])
|
||||
def create_upload(
|
||||
request: UploadRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
|
||||
) -> dict:
|
||||
safe_name = request.filename.replace("/", "_").replace("\\", "_")
|
||||
object_key = f"projects/pending/{uuid4()}/{safe_name}"
|
||||
try:
|
||||
upload = S3Storage(settings).presign_input_upload(object_key, request.content_type)
|
||||
upload = S3Storage(runtime_store.merged_values()).presign_input_upload(
|
||||
object_key,
|
||||
request.content_type,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||
return upload.__dict__
|
||||
|
||||
@@ -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
|
||||
cors_origins: str = "http://localhost:3000"
|
||||
|
||||
database_url: str = "postgresql+psycopg://zhuangxiu:zhuangxiu@localhost:5432/zhuangxiu"
|
||||
runtime_settings_dir: str = ".runtime"
|
||||
settings_master_key: str = ""
|
||||
|
||||
database_url: str = "postgresql://zhuangxiu:zhuangxiu@localhost:5432/zhuangxiu"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
s3_endpoint: str = ""
|
||||
s3_public_endpoint: str = ""
|
||||
s3_access_key_id: str = ""
|
||||
s3_secret_access_key: str = ""
|
||||
s3_region: str = "us-east-1"
|
||||
s3_bucket_inputs: str = "renovation-inputs"
|
||||
s3_bucket_derived: str = "renovation-derived"
|
||||
s3_bucket_renders: str = "renovation-renders"
|
||||
s3_force_path_style: bool = True
|
||||
s3_presigned_url_ttl_seconds: int = 900
|
||||
|
||||
baidu_ocr_enabled: bool = False
|
||||
baidu_ocr_api_key: str = ""
|
||||
baidu_ocr_secret_key: str = ""
|
||||
baidu_ocr_token_url: str = "https://aip.baidubce.com/oauth/2.0/token"
|
||||
|
||||
llm_default_provider: str = "openai"
|
||||
openai_api_key: str = ""
|
||||
openai_base_url: str = "https://api.openai.com/v1"
|
||||
openai_text_model: str = "gpt-5"
|
||||
openai_image_model: str = "gpt-image-2"
|
||||
gemini_api_key: str = ""
|
||||
gemini_model: str = "gemini-3-pro"
|
||||
ark_api_key: str = ""
|
||||
seedream_model_endpoint: str = ""
|
||||
image_provider_priority: str = "seedream,openai,gemini"
|
||||
|
||||
gpu_service_url: str = "http://localhost:8100"
|
||||
gpu_service_token: str = ""
|
||||
|
||||
jwt_secret: str = ""
|
||||
session_secret: str = ""
|
||||
app_encryption_key: str = ""
|
||||
webhook_signing_secret: str = ""
|
||||
|
||||
langfuse_enabled: bool = False
|
||||
langfuse_host: str = "http://localhost:3001"
|
||||
langfuse_public_key: str = ""
|
||||
langfuse_secret_key: str = ""
|
||||
sentry_dsn: str = ""
|
||||
sentry_environment: str = "development"
|
||||
sentry_traces_sample_rate: float = 0
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
@@ -15,35 +17,27 @@ class ProviderCapability:
|
||||
|
||||
|
||||
class ModelRouter:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
|
||||
self.settings = settings
|
||||
|
||||
def _value(self, key: str, default: str = "") -> str:
|
||||
if isinstance(self.settings, Mapping):
|
||||
return str(self.settings.get(key, default) or "")
|
||||
return str(getattr(self.settings, key, default) or "")
|
||||
|
||||
def capabilities(self) -> list[ProviderCapability]:
|
||||
return [
|
||||
ProviderCapability(
|
||||
provider="seedream",
|
||||
configured=_configured(self.settings.ark_api_key)
|
||||
and bool(self.settings.seedream_model_endpoint),
|
||||
strengths=("中文风格指令", "快速方向探索"),
|
||||
),
|
||||
ProviderCapability(
|
||||
provider="openai",
|
||||
configured=_configured(self.settings.openai_api_key),
|
||||
strengths=("局部编辑", "多轮一致性", "遮罩修改"),
|
||||
),
|
||||
ProviderCapability(
|
||||
provider="gemini",
|
||||
configured=_configured(self.settings.gemini_api_key),
|
||||
strengths=("多参考图理解", "复杂视觉指令"),
|
||||
provider=self._value("ai_provider", "custom"),
|
||||
configured=_configured(self._value("ai_api_key"))
|
||||
and bool(self._value("ai_base_url"))
|
||||
and bool(self._value("image_model")),
|
||||
strengths=("统一模型网关", "空间理解", "图像生成与编辑"),
|
||||
),
|
||||
]
|
||||
|
||||
def choose_image_provider(self, task: str) -> str:
|
||||
configured = {item.provider for item in self.capabilities() if item.configured}
|
||||
priorities = [item.strip() for item in self.settings.image_provider_priority.split(",")]
|
||||
if task == "masked_edit" and "openai" in configured:
|
||||
return "openai"
|
||||
for provider in priorities:
|
||||
if provider in configured:
|
||||
return provider
|
||||
configured = [item.provider for item in self.capabilities() if item.configured]
|
||||
if configured:
|
||||
return configured[0]
|
||||
raise RuntimeError("No image provider is configured.")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import base64
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -8,25 +10,30 @@ from app.config import Settings
|
||||
class BaiduOcrAdapter:
|
||||
OCR_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
|
||||
self.settings = settings
|
||||
|
||||
def _value(self, key: str, default: str | bool = "") -> str | bool:
|
||||
if isinstance(self.settings, Mapping):
|
||||
return self.settings.get(key, default)
|
||||
return getattr(self.settings, key, default)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(
|
||||
self.settings.baidu_ocr_enabled
|
||||
and self.settings.baidu_ocr_api_key
|
||||
and self.settings.baidu_ocr_secret_key
|
||||
self._value("baidu_ocr_enabled")
|
||||
and self._value("baidu_ocr_api_key")
|
||||
and self._value("baidu_ocr_secret_key")
|
||||
)
|
||||
|
||||
async def _access_token(self) -> str:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
self.settings.baidu_ocr_token_url,
|
||||
str(self._value("baidu_ocr_token_url", "https://aip.baidubce.com/oauth/2.0/token")),
|
||||
params={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.settings.baidu_ocr_api_key,
|
||||
"client_secret": self.settings.baidu_ocr_secret_key,
|
||||
"client_id": self._value("baidu_ocr_api_key"),
|
||||
"client_secret": self._value("baidu_ocr_secret_key"),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -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 collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
@@ -15,33 +17,38 @@ class PresignedUpload:
|
||||
|
||||
|
||||
class S3Storage:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
|
||||
self.settings = settings
|
||||
|
||||
def _value(self, key: str, default: Any = "") -> Any:
|
||||
if isinstance(self.settings, Mapping):
|
||||
return self.settings.get(key, default)
|
||||
return getattr(self.settings, key, default)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
values = (
|
||||
self.settings.s3_endpoint,
|
||||
self.settings.s3_access_key_id,
|
||||
self.settings.s3_secret_access_key,
|
||||
self._value("s3_endpoint"),
|
||||
self._value("s3_access_key_id"),
|
||||
self._value("s3_secret_access_key"),
|
||||
)
|
||||
return all(values) and not any(value.startswith("replace_with_") for value in values)
|
||||
|
||||
def _client(self, public: bool = False):
|
||||
endpoint = (
|
||||
self.settings.s3_public_endpoint
|
||||
if public and self.settings.s3_public_endpoint
|
||||
else self.settings.s3_endpoint
|
||||
self._value("s3_public_endpoint")
|
||||
if public and self._value("s3_public_endpoint")
|
||||
else self._value("s3_endpoint")
|
||||
)
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id=self.settings.s3_access_key_id,
|
||||
aws_secret_access_key=self.settings.s3_secret_access_key,
|
||||
region_name=self.settings.s3_region,
|
||||
aws_access_key_id=self._value("s3_access_key_id"),
|
||||
aws_secret_access_key=self._value("s3_secret_access_key"),
|
||||
region_name=self._value("s3_region", "us-east-1"),
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
s3={"addressing_style": "path" if self.settings.s3_force_path_style else "auto"},
|
||||
s3={"addressing_style": "path" if self._value("s3_force_path_style", True) else "auto"},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -51,15 +58,15 @@ class S3Storage:
|
||||
url = self._client(public=True).generate_presigned_url(
|
||||
"put_object",
|
||||
Params={
|
||||
"Bucket": self.settings.s3_bucket_inputs,
|
||||
"Bucket": self._value("s3_bucket_inputs"),
|
||||
"Key": object_key,
|
||||
"ContentType": content_type,
|
||||
},
|
||||
ExpiresIn=self.settings.s3_presigned_url_ttl_seconds,
|
||||
ExpiresIn=int(self._value("s3_presigned_url_ttl_seconds", 900)),
|
||||
)
|
||||
return PresignedUpload(
|
||||
method="PUT",
|
||||
url=url,
|
||||
object_key=object_key,
|
||||
expires_in=self.settings.s3_presigned_url_ttl_seconds,
|
||||
expires_in=int(self._value("s3_presigned_url_ttl_seconds", 900)),
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
@@ -20,6 +21,7 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(router)
|
||||
app.include_router(settings_router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -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"
|
||||
dependencies = [
|
||||
"boto3>=1.35,<2",
|
||||
"cryptography>=44,<47",
|
||||
"fastapi>=0.115,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"psycopg[binary]>=3.2,<4",
|
||||
"pydantic>=2.10,<3",
|
||||
"pydantic-settings>=2.7,<3",
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"redis>=5.2,<7",
|
||||
"uvicorn[standard]>=0.34,<1"
|
||||
]
|
||||
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import httpx
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import app
|
||||
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transport() -> httpx.ASGITransport:
|
||||
def transport(tmp_path: Path) -> httpx.ASGITransport:
|
||||
store = EncryptedSettingsStore(
|
||||
Settings(
|
||||
_env_file=None,
|
||||
runtime_settings_dir=str(tmp_path),
|
||||
database_url="postgresql://user:secret@postgres:5432/app",
|
||||
redis_url="redis://:secret@redis:6379/0",
|
||||
)
|
||||
)
|
||||
app.dependency_overrides[get_runtime_store] = lambda: store
|
||||
return httpx.ASGITransport(app=app)
|
||||
|
||||
|
||||
@@ -25,3 +37,27 @@ async def test_demo_project_contract(transport: httpx.ASGITransport) -> None:
|
||||
body = response.json()
|
||||
assert body["stage"] == "plan_review"
|
||||
assert body["plan"]["cad_layer_count"] == 43
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_contract_never_returns_secret_values(transport: httpx.ASGITransport) -> None:
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/v1/settings")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert len(body["categories"]) == 6
|
||||
assert "ai_api_key" not in body["values"]
|
||||
assert body["readiness"]["ready"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_command_is_blocked_until_required_settings_are_ready(
|
||||
transport: httpx.ASGITransport,
|
||||
) -> None:
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/v1/projects/demo-apartment/commands",
|
||||
json={"command": "confirm_plan", "expected_revision": 3, "payload": {}},
|
||||
)
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"]["code"] == "SETTINGS_INCOMPLETE"
|
||||
|
||||
@@ -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