feat: add visual settings and readiness gate
This commit is contained in:
@@ -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