Files
le-mystere-professeur-zacus/tools/dev/local_studio_ai_gateway.py
T
L'électron rare f87820e105 fix: P0 fixes — VAD hint routing, session leak, YAML regex, encoding
- Add hint routing regex to VAD auto-transcribe path (was bypassed)
- Clean up audio sessions on WebSocket disconnect (memory leak)
- Fix YAML extraction regex (double-escaped backslashes)
- Fix corrupted markdown output (literal \\n)
- Add UTF-8 encoding to export_md write_text()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 05:59:24 +01:00

683 lines
28 KiB
Python

#!/usr/bin/env python3
"""Local HTTP gateway for Zacus Story Studio AI generation.
Exposes lightweight endpoints that mirror the frontend contract:
- POST /story_generate
-> returns {yaml, rationale}
- POST /printables_plan
-> returns {manifest_yaml, markdown}
- POST /visual_generate or /image_generate
-> returns {images, count, provider, source}
This lets the frontend call a local AI model (ex: Ollama / vLLM / OpenAI-compatible)
without touching firmware.
"""
from __future__ import annotations
import argparse
import base64
import json
import re
import time
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from yaml import safe_dump, safe_load
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8787
DEFAULT_LLM_URL = "http://127.0.0.1:11434/v1/chat/completions"
DEFAULT_LLM_MODEL = "qwen2.5-coder:14b"
DEFAULT_IMAGE_URL = "http://127.0.0.1:7860/sdapi/v1/txt2img"
DEFAULT_IMAGE_MODEL = "stabilityai/stable-diffusion-xl-base-1.0"
DEFAULT_IMAGE_TIMEOUT = 180
DEFAULT_PRINTABLES_MANIFEST = Path("printables/manifests/zacus_v2_printables.yaml")
IMAGE_PROVIDER_OPENAI = "openai"
IMAGE_PROVIDER_SD_WEBUI = "sd_webui"
BASE_APP_BINDINGS = [
{"id": "APP_AUDIO", "app": "AUDIO_PACK"},
{"id": "APP_SCREEN", "app": "SCREEN_SCENE"},
{"id": "APP_GATE", "app": "MP3_GATE"},
{"id": "APP_WIFI", "app": "WIFI_STACK"},
{"id": "APP_ESPNOW", "app": "ESPNOW_STACK"},
{"id": "APP_QR_UNLOCK", "app": "QR_UNLOCK_APP"},
]
BASE_NODES = [
{"step_id": "STEP_U_SON_PROTO", "screen": "SCENE_U_SON_PROTO", "audio_pack_id": ""},
{"step_id": "STEP_LA_DETECTOR", "screen": "SCENE_LA_DETECTOR", "audio_pack_id": ""},
{"step_id": "STEP_RTC_ESP_ETAPE1", "screen": "SCENE_WIN_ETAPE1", "audio_pack_id": "PACK_WIN1"},
{"step_id": "STEP_WIN_ETAPE1", "screen": "SCENE_WIN_ETAPE1", "audio_pack_id": "PACK_WIN1"},
{"step_id": "STEP_WARNING", "screen": "SCENE_WARNING", "audio_pack_id": ""},
{"step_id": "STEP_LEFOU_DETECTOR", "screen": "SCENE_LEFOU_DETECTOR", "audio_pack_id": ""},
{"step_id": "STEP_RTC_ESP_ETAPE2", "screen": "SCENE_WIN_ETAPE2", "audio_pack_id": "PACK_WIN2"},
{"step_id": "STEP_QR_DETECTOR", "screen": "SCENE_QR_DETECTOR", "audio_pack_id": "PACK_QR"},
{"step_id": "STEP_FINAL_WIN", "screen": "SCENE_FINAL_WIN", "audio_pack_id": "PACK_WIN3"},
{"step_id": "SCENE_MEDIA_MANAGER", "screen": "SCENE_MEDIA_MANAGER", "audio_pack_id": ""},
]
STEP_TRANSITIONS = {
"STEP_U_SON_PROTO": [
{"trigger": "on_event", "event_type": "audio_done", "event_name": "AUDIO_DONE", "target_step_id": "STEP_U_SON_PROTO", "after_ms": 0, "priority": 90},
{"trigger": "on_event", "event_type": "button", "event_name": "ANY", "target_step_id": "STEP_LA_DETECTOR", "after_ms": 0, "priority": 120},
{"trigger": "on_event", "event_type": "serial", "event_name": "FORCE_ETAPE2", "target_step_id": "STEP_LA_DETECTOR", "after_ms": 0, "priority": 130},
],
"STEP_LA_DETECTOR": [
{"trigger": "on_event", "event_type": "timer", "event_name": "ETAPE2_DUE", "target_step_id": "STEP_U_SON_PROTO", "after_ms": 0, "priority": 80},
{"trigger": "on_event", "event_type": "serial", "event_name": "BTN_NEXT", "target_step_id": "STEP_RTC_ESP_ETAPE1", "after_ms": 0, "priority": 110},
{"trigger": "on_event", "event_type": "unlock", "event_name": "UNLOCK", "target_step_id": "STEP_RTC_ESP_ETAPE1", "after_ms": 0, "priority": 120},
{"trigger": "on_event", "event_type": "action", "event_name": "ACTION_FORCE_ETAPE2", "target_step_id": "STEP_RTC_ESP_ETAPE1", "after_ms": 0, "priority": 130},
{"trigger": "on_event", "event_type": "serial", "event_name": "FORCE_WIN_ETAPE1", "target_step_id": "STEP_RTC_ESP_ETAPE1", "after_ms": 0, "priority": 140},
],
"STEP_RTC_ESP_ETAPE1": [
{"trigger": "on_event", "event_type": "esp_now", "event_name": "ACK_WIN1", "target_step_id": "STEP_WIN_ETAPE1", "after_ms": 0, "priority": 130},
{"trigger": "on_event", "event_type": "serial", "event_name": "FORCE_DONE", "target_step_id": "STEP_WIN_ETAPE1", "after_ms": 0, "priority": 125},
],
"STEP_WIN_ETAPE1": [
{"trigger": "on_event", "event_type": "serial", "event_name": "BTN_NEXT", "target_step_id": "STEP_WARNING", "after_ms": 0, "priority": 120},
{"trigger": "on_event", "event_type": "serial", "event_name": "FORCE_DONE", "target_step_id": "STEP_WARNING", "after_ms": 0, "priority": 110},
{"trigger": "on_event", "event_type": "esp_now", "event_name": "ACK_WARNING", "target_step_id": "STEP_WARNING", "after_ms": 0, "priority": 125},
],
"STEP_WARNING": [
{"trigger": "on_event", "event_type": "audio_done", "event_name": "AUDIO_DONE", "target_step_id": "STEP_WARNING", "after_ms": 0, "priority": 80},
{"trigger": "on_event", "event_type": "button", "event_name": "ANY", "target_step_id": "STEP_LEFOU_DETECTOR", "after_ms": 0, "priority": 120},
{"trigger": "on_event", "event_type": "serial", "event_name": "FORCE_ETAPE2", "target_step_id": "STEP_LEFOU_DETECTOR", "after_ms": 0, "priority": 130},
],
"STEP_LEFOU_DETECTOR": [
{"trigger": "on_event", "event_type": "timer", "event_name": "ETAPE2_DUE", "target_step_id": "STEP_WARNING", "after_ms": 0, "priority": 100},
{"trigger": "on_event", "event_type": "serial", "event_name": "BTN_NEXT", "target_step_id": "STEP_RTC_ESP_ETAPE2", "after_ms": 0, "priority": 110},
{"trigger": "on_event", "event_type": "unlock", "event_name": "UNLOCK", "target_step_id": "STEP_RTC_ESP_ETAPE2", "after_ms": 0, "priority": 115},
{"trigger": "on_event", "event_type": "action", "event_name": "ACTION_FORCE_ETAPE2", "target_step_id": "STEP_RTC_ESP_ETAPE2", "after_ms": 0, "priority": 125},
{"trigger": "on_event", "event_type": "serial", "event_name": "FORCE_WIN_ETAPE2", "target_step_id": "STEP_RTC_ESP_ETAPE2", "after_ms": 0, "priority": 130},
],
"STEP_RTC_ESP_ETAPE2": [
{"trigger": "on_event", "event_type": "esp_now", "event_name": "ACK_WIN2", "target_step_id": "STEP_QR_DETECTOR", "after_ms": 0, "priority": 130},
{"trigger": "on_event", "event_type": "serial", "event_name": "FORCE_DONE", "target_step_id": "STEP_QR_DETECTOR", "after_ms": 0, "priority": 120},
],
"STEP_QR_DETECTOR": [
{"trigger": "on_event", "event_type": "timer", "event_name": "ETAPE2_DUE", "target_step_id": "STEP_WARNING", "after_ms": 0, "priority": 100},
{"trigger": "on_event", "event_type": "serial", "event_name": "BTN_NEXT", "target_step_id": "STEP_FINAL_WIN", "after_ms": 0, "priority": 110},
{"trigger": "on_event", "event_type": "unlock", "event_name": "UNLOCK_QR", "target_step_id": "STEP_FINAL_WIN", "after_ms": 0, "priority": 140},
{"trigger": "on_event", "event_type": "action", "event_name": "ACTION_FORCE_ETAPE2", "target_step_id": "STEP_FINAL_WIN", "after_ms": 0, "priority": 125},
{"trigger": "on_event", "event_type": "serial", "event_name": "FORCE_WIN_ETAPE2", "target_step_id": "STEP_FINAL_WIN", "after_ms": 0, "priority": 130},
],
"STEP_FINAL_WIN": [
{"trigger": "on_event", "event_type": "timer", "event_name": "WIN_DUE", "target_step_id": "SCENE_MEDIA_MANAGER", "after_ms": 0, "priority": 140},
{"trigger": "on_event", "event_type": "serial", "event_name": "BTN_NEXT", "target_step_id": "SCENE_MEDIA_MANAGER", "after_ms": 0, "priority": 120},
{"trigger": "on_event", "event_type": "unlock", "event_name": "UNLOCK", "target_step_id": "SCENE_MEDIA_MANAGER", "after_ms": 0, "priority": 115},
{"trigger": "on_event", "event_type": "action", "event_name": "ACTION_FORCE_ETAPE2", "target_step_id": "SCENE_MEDIA_MANAGER", "after_ms": 0, "priority": 130},
{"trigger": "on_event", "event_type": "serial", "event_name": "FORCE_WIN_ETAPE2", "target_step_id": "SCENE_MEDIA_MANAGER", "after_ms": 0, "priority": 135},
],
}
def sanitize_scenario_id(value: str) -> str:
raw = (value or "CUSTOM").strip().upper()
cleaned = re.sub(r"[^A-Z0-9_]", "_", raw)
cleaned = re.sub(r"_+", "_", cleaned).strip("_")
return cleaned or "CUSTOM"
def to_int(value: Any, fallback: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return fallback
def call_json_api(url: str, payload: dict[str, Any], timeout: int = 120) -> Any:
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
url=url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read().decode("utf-8")
except urllib.error.URLError as exc:
raise RuntimeError(f"Endpoint unreachable: {exc}") from exc
return json.loads(body)
def detect_image_provider(url: str, forced_provider: str) -> str:
forced = (forced_provider or "").strip().lower()
if forced in {"auto", IMAGE_PROVIDER_OPENAI, IMAGE_PROVIDER_SD_WEBUI}:
return forced
url_lower = (url or "").lower()
if url_lower.endswith("/v1/images/generations") or "/v1/images" in url_lower:
return IMAGE_PROVIDER_OPENAI
if "/sdapi/" in url_lower:
return IMAGE_PROVIDER_SD_WEBUI
return IMAGE_PROVIDER_OPENAI
def _ensure_base64(value: Any) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
try:
base64.b64decode(text, validate=True)
return text
except Exception:
return None
def call_llm(url: str, model: str, prompt: str, timeout: int = 120) -> str:
payload = {
"model": model,
"messages": [
{
"role": "system",
"content": (
"Tu es un générateur de scénarios Zacus.\n"
"Tu dois répondre en YAML strict, sans explication ni markdown.\n"
"Le YAML doit rester compatible Story V2 du frontend (id/version/steps/app_bindings).\n"
"Conserve les noms d'événements EXACTS quand tu les réécris."
),
},
{"role": "user", "content": prompt},
],
"temperature": 0.2,
"max_tokens": 2048,
}
parsed = call_json_api(url, payload, timeout)
if "choices" in parsed and parsed["choices"]:
first = parsed["choices"][0]
message = first.get("message", {})
content = message.get("content")
if content:
return str(content).strip()
# Fallback for non-chat providers (legacy Ollama generate format)
if "response" in parsed:
response_text = parsed["response"]
if isinstance(response_text, str):
return response_text.strip()
raise RuntimeError("Réponse LLM invalide (format inattendu)")
def call_image_generation(
url: str,
model: str,
prompt: str,
negative_prompt: str,
width: int,
height: int,
steps: int,
cfg_scale: float,
seed: int,
count: int,
timeout: int = DEFAULT_IMAGE_TIMEOUT,
forced_provider: str = "auto",
) -> list[dict[str, str]]:
provider = detect_image_provider(url, forced_provider)
if provider == IMAGE_PROVIDER_OPENAI:
payload = {
"model": model,
"prompt": prompt,
"n": max(1, count),
"size": f"{width}x{height}",
"response_format": "b64_json",
}
if seed >= 0:
payload["seed"] = seed
parsed = call_json_api(url, payload, timeout)
else:
payload = {
"prompt": prompt,
"negative_prompt": negative_prompt,
"steps": steps,
"cfg_scale": cfg_scale,
"width": width,
"height": height,
"seed": seed,
"batch_size": max(1, count),
"n_iter": 1,
}
parsed = call_json_api(url, payload, timeout)
images: list[dict[str, str]] = []
if isinstance(parsed, dict) and isinstance(parsed.get("data"), list):
for index, item in enumerate(parsed["data"]):
if not isinstance(item, dict):
continue
data = item.get("b64_json") or item.get("b64") or item.get("image") or item.get("base64")
if isinstance(data, str) and data.strip():
b64 = _ensure_base64(data)
if b64:
images.append(
{
"filename": f"sdxl_{int(time.time())}_{index}.png",
"mime": "image/png",
"base64": b64,
}
)
continue
image_url = item.get("url")
if isinstance(image_url, str) and image_url.strip():
images.append(
{
"filename": f"sdxl_{int(time.time())}_{index}.txt",
"mime": "text/uri-list",
"url": image_url.strip(),
}
)
if isinstance(parsed, dict) and isinstance(parsed.get("images"), list):
for index, data in enumerate(parsed["images"]):
b64 = _ensure_base64(data)
if b64:
images.append(
{
"filename": f"sdxl_{int(time.time())}_{index}.png",
"mime": "image/png",
"base64": b64,
}
)
if not images:
raise RuntimeError("Réponse image invalide (aucune image retournée)")
return images
def to_float(value: Any, fallback: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return fallback
def extract_yaml(payload: str) -> str:
block_match = re.search(r"```ya?ml\s*\n([\s\S]*?)```", payload, re.IGNORECASE)
if block_match:
return block_match.group(1).strip()
first_brace = payload.find("---")
if first_brace == 0:
return payload.strip()
if first_brace > 0:
return payload[first_brace:].strip()
return payload.strip()
def build_fallback_scenario(blueprint: dict[str, Any]) -> str:
scenario_id = sanitize_scenario_id(str(blueprint.get("scenarioId", "CUSTOM")))
title = str(blueprint.get("title", "Scenario généré localement")).strip() or "Scenario généré localement"
duration = to_int(blueprint.get("durationMinutes"), 90)
min_players = to_int(blueprint.get("minPlayers"), 4)
max_players = to_int(blueprint.get("maxPlayers"), 12)
if max_players < min_players:
max_players = min_players
include_media = bool(blueprint.get("includeMediaManager", False))
custom_note = str(blueprint.get("customPrompt", "") or blueprint.get("aiHint", "") or "").strip()
steps = []
for index, node in enumerate(BASE_NODES):
step: dict[str, Any] = {
"step_id": node["step_id"],
"screen_scene_id": node["screen"],
"audio_pack_id": node["audio_pack_id"],
"actions": ["ACTION_TRACE_STEP"],
"apps": [binding["id"] for binding in BASE_APP_BINDINGS],
"mp3_gate_open": node["step_id"] == "STEP_QR_DETECTOR",
"transitions": [dict(transition) for transition in STEP_TRANSITIONS.get(node["step_id"], [])],
}
if index == 0:
step["is_initial"] = True
if node["step_id"] == "SCENE_MEDIA_MANAGER" and include_media:
step["apps"] = [binding["id"] for binding in BASE_APP_BINDINGS] + ["APP_MEDIA"]
steps.append(step)
content = {
"id": scenario_id,
"version": 2,
"title": title,
"duration_minutes": duration,
"players": {
"min": min_players,
"max": max_players,
},
"theme": "Scénario généré localement.",
"difficulty": str(blueprint.get("difficulty", "standard")).strip() or "standard",
"initial_step": "STEP_U_SON_PROTO",
"debug_transition_bypass_enabled": False,
"app_bindings": BASE_APP_BINDINGS,
"steps": steps,
"note": custom_note or "Génération locale de secours (fallback).",
}
return safe_dump(content, sort_keys=False, allow_unicode=True).strip()
def build_story_prompt(blueprint: dict[str, Any]) -> str:
normalized = {
"scenarioId": sanitize_scenario_id(str(blueprint.get("scenarioId", ""))),
"title": str(blueprint.get("title", "")).strip(),
"missionSummary": str(blueprint.get("missionSummary", "")).strip(),
"durationMinutes": to_int(blueprint.get("durationMinutes"), 90),
"minPlayers": to_int(blueprint.get("minPlayers"), 4),
"maxPlayers": to_int(blueprint.get("maxPlayers"), 12),
"difficulty": str(blueprint.get("difficulty", "standard")).strip(),
"includeMediaManager": bool(blueprint.get("includeMediaManager", False)),
"customPrompt": str(blueprint.get("customPrompt", "")).strip(),
"aiHint": str(blueprint.get("aiHint", "")).strip(),
}
normalized_json = json.dumps(normalized, ensure_ascii=False, indent=2)
return (
"Génère un YAML de scénario Zacus V2 canonique, compatible studio + Runtime 3.\n"
"Le YAML doit contenir au minimum id, version, title, players, duration, canon, steps_narrative, firmware.initial_step et firmware.steps.\n"
"Conserver le flow par défaut décrit ci-dessous (noms d'événements inchangés).\n"
"Si certains champs manquent, utilise les valeurs ci-dessous.\n\n"
f"{normalized_json}\n\n"
"Règles techniques:\n"
"- firmware.steps[*] contient: step_id, screen_scene_id, audio_pack_id, actions, apps, transitions.\n"
"- transitions: event_type, event_name, target_step_id, after_ms, priority.\n"
"- event_name respecte exactement: BTN_NEXT, AUDIO_DONE, ACK_*, UNLOCK_*, QR_TIMEOUT.\n"
"- garder un ordre cohérent entre steps_narrative et firmware.steps.\n"
)
def build_printables_plan(scenario_id: str, title: str, selected: list[str] | None) -> tuple[str, str]:
manifest_path = DEFAULT_PRINTABLES_MANIFEST
manifest = safe_load(manifest_path.read_text())
items = manifest.get("items", [])
if not isinstance(items, list):
raise RuntimeError(f"Manifest invalid: {manifest_path}")
selected_set = set(selected or [])
filtered = [item for item in items if not selected_set or item.get("id") in selected_set]
manifest_out = dict(manifest)
manifest_out["manifest_id"] = f"{sanitize_scenario_id(scenario_id).lower()}_printables"
manifest_out["scenario_id"] = sanitize_scenario_id(scenario_id)
manifest_out["title"] = title.strip() or manifest.get("title", "Plan imprimables")
manifest_out["version"] = manifest_out.get("version", 1)
manifest_out["items"] = filtered
yaml_text = safe_dump(manifest_out, sort_keys=False, allow_unicode=True).strip()
markdown = (
f"# Pack imprimables — {manifest_out['title']}\n\n"
f"- Scenario: {manifest_out['scenario_id']}\n"
f"- Items: {len(filtered)}\n\n"
+ "\n".join(f"- {entry.get('id')} ({entry.get('category')})" for entry in filtered)
+ "\n\nGénéré via gateway IA locale.\n"
)
return yaml_text, markdown
def write_json(self: BaseHTTPRequestHandler, status: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.end_headers()
self.wfile.write(body)
class Handler(BaseHTTPRequestHandler):
llm_url: str
llm_model: str
image_url: str
image_model: str
image_timeout: int
image_provider: str
printable_manifest_default: Path
fallback: bool
def do_OPTIONS(self) -> None:
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.end_headers()
def _load_body(self) -> dict[str, Any] | None:
length_header = self.headers.get("Content-Length")
if not length_header:
return {}
length = int(length_header)
raw = self.rfile.read(length)
try:
return json.loads(raw.decode("utf-8"))
except Exception:
return None
def do_POST(self) -> None:
if self.path in {"/", "/health", "/healthz"}:
return write_json(self, 200, {"status": "ok", "version": "local-studio-ai-gateway"})
body = self._load_body()
if body is None:
return write_json(self, 400, {"error": "invalid_json"})
mode = str(body.get("mode", "story_generate")).strip()
if mode == "story_generate":
return self.handle_story_generate(body)
if mode == "printables_plan":
return self.handle_printables_plan(body)
if mode in {"visual_generate", "image_generate"}:
return self.handle_visual_generate(body)
return write_json(self, 400, {"error": f"mode not supported: {mode}"})
def do_GET(self) -> None:
if self.path in {"/", "/health", "/healthz"}:
return write_json(self, 200, {"status": "ok", "version": "local-studio-ai-gateway"})
if self.path == "/ping":
return write_json(self, 200, {"status": "pong"})
if self.path.startswith("/story_generate") or self.path.startswith("/printables_plan"):
return write_json(self, 405, {"error": "use POST for this endpoint"})
return write_json(self, 404, {"error": "not_found"})
def handle_story_generate(self, body: dict[str, Any]) -> None:
blueprint = body.get("scenario", {})
if not isinstance(blueprint, dict):
write_json(self, 400, {"error": "scenario payload must be an object"})
return
prompt = build_story_prompt(blueprint)
yaml_text = ""
rationale = "Scénario généré par le gateway local."
try:
llm_output = call_llm(self.llm_url, self.llm_model, prompt)
yaml_text = extract_yaml(llm_output)
parsed = safe_load(yaml_text)
if not isinstance(parsed, dict):
raise ValueError("LLM output is not a YAML object")
rationale = "Scénario généré via LLM local (vérification YAML OK)."
write_json(self, 200, {"yaml": yaml_text, "rationale": rationale, "source": "ai"})
return
except Exception as exc: # noqa: BLE001
if self.fallback:
yaml_text = build_fallback_scenario(blueprint)
rationale = f"Fallback local activé: {exc}"
write_json(self, 200, {"yaml": yaml_text, "rationale": rationale, "source": "local"})
return
write_json(self, 502, {"error": str(exc), "source": "ai"})
def handle_printables_plan(self, body: dict[str, Any]) -> None:
scenario_id = sanitize_scenario_id(str(body.get("scenarioId", "CUSTOM")))
title = str(body.get("title", scenario_id))
selected = body.get("selected")
selected_ids: list[str] | None
if selected is None:
selected_ids = None
elif isinstance(selected, list):
selected_ids = [str(item) for item in selected if str(item).strip()]
else:
write_json(self, 400, {"error": "selected must be an array"})
return
try:
manifest_yaml, markdown = build_printables_plan(scenario_id, title, selected_ids)
write_json(
self,
200,
{
"manifest_yaml": manifest_yaml,
"markdown": markdown,
"items": len(markdown.splitlines()),
"source": "local",
},
)
except Exception as exc: # noqa: BLE001
write_json(self, 500, {"error": str(exc)})
def handle_visual_generate(self, body: dict[str, Any]) -> None:
prompt = str(body.get("prompt", "")).strip()
if not prompt:
write_json(self, 400, {"error": "prompt is required"})
return
negative_prompt = str(body.get("negativePrompt", "")).strip() or str(body.get("negative_prompt", "")).strip()
width = to_int(body.get("width"), 1024)
height = to_int(body.get("height"), 1024)
steps = to_int(body.get("steps"), 25)
cfg_scale = to_float(body.get("cfgScale", body.get("cfg_scale", 7.5)), 7.5)
seed = to_int(body.get("seed"), -1)
count = to_int(body.get("count"), 1)
model = str(body.get("model", self.image_model)).strip() or self.image_model
provider = str(body.get("provider", self.image_provider)).strip() or self.image_provider
width = max(256, min(width, 2048))
height = max(256, min(height, 2048))
steps = max(1, min(steps, 150))
count = max(1, min(count, 4))
try:
images = call_image_generation(
self.image_url,
model=model,
prompt=prompt,
negative_prompt=negative_prompt,
width=width,
height=height,
steps=steps,
cfg_scale=cfg_scale,
seed=seed,
count=count,
timeout=self.image_timeout,
forced_provider=provider,
)
write_json(
self,
200,
{
"images": images,
"count": len(images),
"provider": provider,
"source": "sd",
},
)
except Exception as exc: # noqa: BLE001
write_json(self, 500, {"error": str(exc), "source": "image"})
def run_server(
host: str,
port: int,
llm_url: str,
llm_model: str,
image_url: str,
image_model: str,
image_timeout: int,
image_provider: str,
fallback: bool,
) -> None:
handler = Handler
handler.llm_url = llm_url
handler.llm_model = llm_model
handler.image_url = image_url
handler.image_model = image_model
handler.image_timeout = image_timeout
handler.image_provider = image_provider
handler.fallback = fallback
handler.printable_manifest_default = DEFAULT_PRINTABLES_MANIFEST
server = HTTPServer((host, port), handler)
print(f"Local Story AI gateway listening on http://{host}:{port}")
print(f"LLM URL: {llm_url}")
print(f"IMAGE URL: {image_url} (provider={image_provider})")
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
thread.join()
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Local Zacus Studio AI gateway")
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--llm-url", default=DEFAULT_LLM_URL)
parser.add_argument("--llm-model", default=DEFAULT_LLM_MODEL)
parser.add_argument(
"--no-fallback",
action="store_true",
help="Do not use local fallback when AI is unavailable.",
)
parser.add_argument("--image-url", default=DEFAULT_IMAGE_URL)
parser.add_argument("--image-model", default=DEFAULT_IMAGE_MODEL)
parser.add_argument(
"--image-provider",
choices=["auto", IMAGE_PROVIDER_OPENAI, IMAGE_PROVIDER_SD_WEBUI],
default="auto",
)
parser.add_argument("--image-timeout", type=int, default=DEFAULT_IMAGE_TIMEOUT)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
run_server(
args.host,
args.port,
llm_url=args.llm_url,
llm_model=args.llm_model,
fallback=not args.no_fallback,
image_url=args.image_url,
image_model=args.image_model,
image_timeout=args.image_timeout,
image_provider=args.image_provider,
)
except KeyboardInterrupt:
print("Stop.")
return 0
except Exception as exc:
print(f"Failed to start gateway: {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())