feat: add visual settings and readiness gate

This commit is contained in:
Codex
2026-08-01 22:14:20 +08:00
parent 6a15217d55
commit 3b4106e8cc
25 changed files with 2447 additions and 239 deletions
+16 -22
View File
@@ -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.")
+14 -7
View File
@@ -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()
+21 -14
View File
@@ -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)),
)