Add optional AI generation, MQTT, OAuth refresh and calendar recurrence

This commit is contained in:
Pigway
2026-10-08 23:44:07 +08:00
parent 69a0841868
commit 8209aadbb8
24 changed files with 1113 additions and 37 deletions
+176
View File
@@ -0,0 +1,176 @@
"""Optional one-image generation, using the durable operation queue."""
import base64
import json
from io import BytesIO
from typing import Literal
from urllib.parse import urlsplit
from fastapi import Depends, Header, Request
from PIL import Image, ImageOps
from pydantic import Field
from pigway_frame_flow.core.auth import require
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.extensions.api import AISecret
from pigway_frame_flow.extensions.secrets import save_secret
from pigway_frame_flow.models import Accepted, StrictModel
from pigway_frame_flow.renderers.images import MAX_IMAGE_PIXELS
from pigway_frame_flow.storage.database import encode
class ImageGenerationConfig(StrictModel):
endpoint: str = Field(max_length=2048)
model: str = Field(min_length=1, max_length=120)
size: Literal["1024x1024", "1024x1536", "1536x1024"] = "1024x1536"
class ImageGenerationInput(StrictModel):
prompt: str = Field(min_length=1, max_length=4000)
def install_ai_images(app, service):
def config():
row = service.db.one("SELECT payload FROM app_config WHERE key='ai_images'")
if not row:
raise FlowError(E.CONFIG_REQUIRED, "Configure image generation first", 409)
return ImageGenerationConfig.model_validate_json(row["payload"])
@app.get(
"/api/v1/settings/ai-images",
dependencies=[Depends(require("admin"))],
tags=["AI"],
)
def get_config():
row = service.db.one("SELECT payload FROM app_config WHERE key='ai_images'")
return {
"config": json.loads(row["payload"]) if row else None,
"key_configured": (
service.settings.config_dir / "ai-images.secret"
).exists(),
}
@app.put(
"/api/v1/settings/ai-images",
dependencies=[Depends(require("admin"))],
tags=["AI"],
)
def set_config(payload: ImageGenerationConfig):
url = urlsplit(payload.endpoint)
if (
url.scheme != "https"
or not url.hostname
or url.username
or url.password
or url.query
or url.fragment
or url.port not in (None, 443)
):
raise FlowError(
E.INVALID_REQUEST,
"Image generation requires a plain public HTTPS endpoint",
)
if url.hostname not in service.settings.image_url_hosts:
service.settings.image_url_hosts.append(url.hostname)
service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES('image_url_hosts',?)",
(encode(service.settings.image_url_hosts),),
)
service.db.execute(
"INSERT OR REPLACE INTO app_config VALUES('ai_images',?)",
(payload.model_dump_json(),),
)
return payload
@app.put(
"/api/v1/settings/ai-images/key",
status_code=204,
dependencies=[Depends(require("admin"))],
tags=["AI"],
)
def set_key(payload: AISecret):
key = payload.key.get_secret_value()
save_secret(service.settings.config_dir / "ai-images.secret", key)
service.settings.additional_secrets.append(key)
@app.post(
"/api/v1/ai/images",
status_code=202,
response_model=Accepted,
dependencies=[Depends(require("render:write"))],
tags=["AI"],
)
def generate(
payload: ImageGenerationInput,
request: Request,
idempotency_key: str | None = Header(default=None),
):
config()
if not (service.settings.config_dir / "ai-images.secret").exists():
raise FlowError(
E.CONFIG_REQUIRED, "Configure image generation key first", 409
)
id = service.submit(
"render-ai",
payload.model_dump(),
service.settings.device_id,
request.state.client_id,
request.state.request_id,
idempotency_key,
)
return Accepted(operation_id=id, job_id=id, status="accepted")
def execute(op, payload):
service.update(op["id"], "RENDERING")
conf = config()
key = (service.settings.config_dir / "ai-images.secret").read_text().strip()
service.settings.additional_secrets.append(key)
service.db.execute(
"UPDATE operations SET write_started=1 WHERE id=?", (op["id"],)
)
try:
raw = service.assets.fetch_url(
conf.endpoint,
method="POST",
headers={
"Authorization": "Bearer " + key,
"Content-Type": "application/json",
},
body=json.dumps(
{
"model": conf.model,
"prompt": payload["prompt"],
"size": conf.size,
"n": 1,
}
).encode(),
limit=service.settings.max_bytes,
)
result = json.loads(raw)["data"]
if len(result) != 1:
raise ValueError()
if result[0].get("b64_json"):
image_data = base64.b64decode(result[0]["b64_json"], validate=True)
else:
image_data = service.assets.fetch_url(result[0]["url"])
with Image.open(BytesIO(image_data)) as image:
if image.width * image.height > MAX_IMAGE_PIXELS:
raise ValueError()
rgba = ImageOps.exif_transpose(image).convert("RGBA")
rgb = Image.new("RGB", rgba.size, "white")
rgb.paste(rgba, mask=rgba.getchannel("A"))
output = BytesIO()
rgb.save(output, "JPEG", quality=93)
asset = service.assets.put(output.getvalue(), "ai-art.jpg", "generated")
except Exception:
raise FlowError(
E.RENDER_FAILED,
"Image generation failed; request may have been billed. No automatic retry was sent.",
502,
) from None
service.update(op["id"], "COMPLETED", asset)
service.db.history(op, "COMPLETED")
service.db.event("operation.completed", {"operation_id": op["id"]})
service.operation_handlers["render-ai"] = execute