Files

1394 lines
52 KiB
Python

import asyncio
import base64
import binascii
import contextlib
import io
import re
import time
import uuid
from typing import Optional, List, Dict, Any
from urllib.parse import urlparse
import orjson
from curl_cffi.requests import AsyncSession
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.core.auth import verify_public_key, get_public_api_key, is_public_enabled
from app.core.config import get_config
from app.core.exceptions import AppException
from app.core.logger import logger
from app.api.v1.image import resolve_aspect_ratio
from app.services.grok.services.image import ImageGenerationService
from app.services.grok.services.image_edit import ImageEditService
from app.services.grok.services.model import ModelService
from app.services.reverse.media_post import MediaPostReverse
from app.services.token.manager import get_token_manager
router = APIRouter()
IMAGINE_SESSION_TTL = 600
_IMAGINE_SESSIONS: dict[str, dict] = {}
_IMAGINE_SESSIONS_LOCK = asyncio.Lock()
_RATIO_ALLOWED = {"16:9", "9:16", "3:2", "2:3", "1:1"}
def _validate_parent_post_id(value: str) -> str:
raw = (value or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="parent_post_id cannot be empty")
if not re.fullmatch(r"[0-9a-fA-F-]{32,36}", raw):
raise HTTPException(status_code=400, detail="parent_post_id format is invalid")
return raw
def _build_imagine_public_url(parent_post_id: str) -> str:
return f"https://imagine-public.x.ai/imagine-public/images/{parent_post_id}.jpg"
async def _pick_imagine_parent_token(token_mgr, model_id: str, parent_post_id: str) -> str | None:
"""图片 parentPostId 编辑统一使用当前请求可用 token。"""
for pool_name in ModelService.pool_candidates_for_model(model_id):
token = token_mgr.get_token(pool_name)
if token:
logger.info(
"Imagine edit selected pool token: "
f"parent_post_id={parent_post_id}, pool={pool_name}, token={_mask_token(token)}"
)
return token
return None
def _is_local_proxy_image_url(url: str) -> bool:
raw = str(url or "").strip().lower()
return (
"/v1/files/image/" in raw
or raw.startswith("http://127.0.0.1:")
or raw.startswith("http://localhost:")
or raw.startswith("https://127.0.0.1:")
or raw.startswith("https://localhost:")
)
async def _fetch_media_post_info(token: str, parent_post_id: str) -> dict[str, Any]:
post_id = _extract_parent_post_id_from_url(parent_post_id)
if not post_id or not token:
return {}
try:
async with AsyncSession() as session:
response = await MediaPostReverse.get(session, token, post_id)
data = response.json() if hasattr(response, "json") else {}
return data.get("post", {}) if isinstance(data, dict) else {}
except Exception as e:
logger.warning(
"Imagine edit media_post/get failed: "
f"parent_post_id={post_id}, error={e}"
)
return {}
async def _canonicalize_parent_source_image_url(
token: str,
parent_post_id: str,
source_image_url: str = "",
) -> str:
raw = str(source_image_url or "").strip()
if raw and "imagine-public.x.ai/imagine-public/share-images/" in raw:
return raw
if raw.startswith("http://") or raw.startswith("https://"):
if not _is_local_proxy_image_url(raw):
return raw
elif raw and not raw.startswith("/users/") and not raw.startswith("users/"):
return raw
post = await _fetch_media_post_info(token, parent_post_id)
media_url = str(post.get("mediaUrl") or "").strip()
if media_url:
return media_url
thumb_url = str(post.get("thumbnailImageUrl") or "").strip()
if thumb_url:
return thumb_url
return raw or _build_imagine_public_url(parent_post_id)
def _extract_parent_post_id_from_url(url: str) -> str:
text = str(url or "").strip()
if not text:
return ""
if re.fullmatch(r"[0-9a-fA-F-]{32,36}", text):
return text
# 优先匹配真正的图片产物 ID,避免误取 /users/<user_id>。
for pattern in (
r"/generated/([0-9a-fA-F-]{32,36})(?:/|$)",
r"/imagine-public/images/([0-9a-fA-F-]{32,36})(?:\.jpg|/|$)",
r"/images/([0-9a-fA-F-]{32,36})(?:\.jpg|/|$)",
):
match = re.search(pattern, text)
if match:
return match.group(1)
matches = re.findall(r"([0-9a-fA-F-]{32,36})", text)
return matches[-1] if matches else ""
def _to_assets_url(path: str) -> str:
raw = str(path or "").strip()
if not raw:
return ""
if not raw.startswith("/"):
raw = f"/{raw}"
return f"https://assets.grok.com{raw}"
def _resolve_source_image_url(
image_url: str,
parent_post_id: str = "",
fallback_source_image_url: str = "",
) -> str:
raw = str(image_url or "").strip() or str(fallback_source_image_url or "").strip()
if raw:
if raw.startswith("http://") or raw.startswith("https://"):
parsed = urlparse(raw)
host = (parsed.netloc or "").lower()
path = parsed.path or ""
if "assets.grok.com" in host and path:
return _to_assets_url(path)
if "imagine-public.x.ai" in host:
return raw
marker = "/v1/files/image/"
if marker in path:
suffix = path.split(marker, 1)[1]
return _to_assets_url(suffix)
if path.startswith("/users/"):
return _to_assets_url(path)
return raw
if raw.startswith("/v1/files/image/"):
suffix = raw.split("/v1/files/image/", 1)[1]
return _to_assets_url(suffix)
if raw.startswith("/users/") or raw.startswith("users/"):
return _to_assets_url(raw)
if raw.startswith("/imagine-public/images/"):
return f"https://imagine-public.x.ai{raw}"
parsed_parent = _extract_parent_post_id_from_url(parent_post_id)
if parsed_parent:
return _build_imagine_public_url(parsed_parent)
return ""
def _mask_token(token: str) -> str:
raw = str(token or "").replace("sso=", "")
if len(raw) <= 12:
return raw or "-"
return f"{raw[:6]}...{raw[-6:]}"
def _extract_parent_post_id_from_payload(payload: Dict[str, Any]) -> str:
if not isinstance(payload, dict):
return ""
candidates = [
payload.get("parent_post_id"),
payload.get("parentPostId"),
payload.get("image_id"),
payload.get("imageId"),
payload.get("url"),
payload.get("image"),
]
for value in candidates:
parent_post_id = _extract_parent_post_id_from_url(str(value or ""))
if parent_post_id:
return parent_post_id
return ""
def _decode_image_b64(compact_b64: str) -> bytes:
"""解码 base64 图片数据。"""
try:
return base64.b64decode(compact_b64, validate=True)
except (binascii.Error, ValueError):
raise HTTPException(status_code=400, detail="image_base64 format is invalid")
def _detect_image_mime(raw: bytes) -> str:
"""根据字节头判断真实图片 MIME。"""
if raw.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if raw.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if raw.startswith(b"RIFF") and len(raw) >= 12 and raw[8:12] == b"WEBP":
return "image/webp"
if raw.startswith((b"GIF87a", b"GIF89a")):
return "image/gif"
# 无法识别时维持 png 兼容行为
return "image/png"
def _encode_jpeg_base64(raw: bytes) -> str:
"""将任意图片字节转为 JPEG base64。"""
try:
from PIL import Image, ImageOps
with Image.open(io.BytesIO(raw)) as img:
# 统一旋转方向(EXIF)并转 RGB,确保 JPEG 可保存
img = ImageOps.exif_transpose(img)
if img.mode in ("RGBA", "LA"):
bg = Image.new("RGB", img.size, (255, 255, 255))
alpha = img.split()[-1]
bg.paste(img.convert("RGBA"), mask=alpha)
out_img = bg
elif img.mode == "P":
out_img = img.convert("RGB")
else:
out_img = img.convert("RGB")
out = io.BytesIO()
out_img.save(out, format="JPEG", quality=92, optimize=True)
return base64.b64encode(out.getvalue()).decode()
except Exception as e:
raise HTTPException(status_code=400, detail=f"image decode failed: {e}")
def _normalize_image_input(image_base64: str, image_url: str) -> str:
raw_b64 = str(image_base64 or "").strip()
raw_url = str(image_url or "").strip()
if raw_b64:
declared_mime = ""
compact = raw_b64
if raw_b64.startswith("data:"):
try:
header, payload = raw_b64.split(",", 1)
except ValueError:
raise HTTPException(status_code=400, detail="image_base64 format is invalid")
compact = payload
mime_part = header[5:].split(";", 1)[0].strip().lower()
declared_mime = mime_part
compact = re.sub(r"\s+", "", compact)
if not compact:
raise HTTPException(status_code=400, detail="image_base64 is empty")
if not re.fullmatch(r"[A-Za-z0-9+/=_-]+", compact):
raise HTTPException(status_code=400, detail="image_base64 format is invalid")
raw = _decode_image_b64(compact)
real_mime = _detect_image_mime(raw)
if declared_mime and declared_mime != real_mime:
logger.warning(
"Imagine workbench image MIME mismatch corrected: "
f"declared={declared_mime}, detected={real_mime}"
)
# 按需求:非 jpeg 一律转为 jpeg 再上传,规避上游对特定格式/元数据的 400。
if real_mime != "image/jpeg":
compact = _encode_jpeg_base64(raw)
logger.info(
"Imagine workbench image normalized to JPEG before upload: "
f"source_mime={real_mime}, jpeg_b64_len={len(compact)}"
)
return f"data:image/jpeg;base64,{compact}"
return f"data:image/jpeg;base64,{compact}"
if raw_url:
if raw_url.startswith("http://") or raw_url.startswith("https://"):
return raw_url
raise HTTPException(status_code=400, detail="image_url must be http(s) URL")
raise HTTPException(
status_code=400,
detail="image_base64 or image_url is required for first edit",
)
def _normalize_image_references(image_references: Optional[List[str]]) -> List[str]:
refs = image_references or []
if not isinstance(refs, list):
raise HTTPException(status_code=400, detail="image_references must be a list")
cleaned: List[str] = []
for raw in refs:
item = str(raw or "").strip()
if not item:
continue
cleaned.append(item)
if len(cleaned) > 5:
raise HTTPException(status_code=400, detail="image_references supports at most 5 images")
normalized: List[str] = []
for item in cleaned:
if item.startswith("data:"):
normalized.append(_normalize_image_input(image_base64=item, image_url=""))
else:
normalized.append(_normalize_image_input(image_base64="", image_url=item))
return normalized
def _normalize_reference_items(reference_items: Optional[List[Dict[str, Any]]]) -> List[Dict[str, str]]:
items = reference_items or []
if not isinstance(items, list):
raise HTTPException(status_code=400, detail="reference_items must be a list")
cleaned: List[Dict[str, str]] = []
for item in items:
if not isinstance(item, dict):
continue
image_url = str(item.get("image_url") or item.get("data") or "").strip()
source_image_url = str(item.get("source_image_url") or "").strip()
parent_post_id = str(item.get("parent_post_id") or "").strip()
mention_alias = str(item.get("mention_alias") or "").strip()
if not image_url and not source_image_url and not parent_post_id:
continue
cleaned.append(
{
"image_url": image_url,
"source_image_url": source_image_url,
"parent_post_id": parent_post_id,
"mention_alias": mention_alias,
}
)
if len(cleaned) > 5:
raise HTTPException(status_code=400, detail="reference_items supports at most 5 images")
return cleaned
async def _clean_sessions(now: float) -> None:
expired = [
key
for key, info in _IMAGINE_SESSIONS.items()
if now - float(info.get("created_at") or 0) > IMAGINE_SESSION_TTL
]
for key in expired:
_IMAGINE_SESSIONS.pop(key, None)
def _normalize_imagine_ratio(value: Optional[str]) -> str:
"""统一解析 imagine 比例参数,兼容 ratio 与 size 两种写法。"""
raw = str(value or "").strip()
if not raw:
return "2:3"
if raw in _RATIO_ALLOWED:
return raw
mapped = resolve_aspect_ratio(raw)
return mapped if mapped in _RATIO_ALLOWED else "2:3"
def _parse_sse_chunk(chunk: str) -> Optional[Dict[str, Any]]:
if not chunk:
return None
event = None
data_lines: List[str] = []
for raw in str(chunk).splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("event:"):
event = line[6:].strip()
continue
if line.startswith("data:"):
data_lines.append(line[5:].strip())
if not data_lines:
return None
data_str = "\n".join(data_lines)
if data_str == "[DONE]":
return None
try:
payload = orjson.loads(data_str)
except orjson.JSONDecodeError:
return None
if event and isinstance(payload, dict) and "type" not in payload:
payload["type"] = event
return payload
async def _new_session(prompt: str, aspect_ratio: str, nsfw: Optional[bool]) -> str:
task_id = uuid.uuid4().hex
now = time.time()
async with _IMAGINE_SESSIONS_LOCK:
await _clean_sessions(now)
_IMAGINE_SESSIONS[task_id] = {
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"nsfw": nsfw,
"created_at": now,
}
return task_id
async def _get_session(task_id: str) -> Optional[dict]:
if not task_id:
return None
now = time.time()
async with _IMAGINE_SESSIONS_LOCK:
await _clean_sessions(now)
info = _IMAGINE_SESSIONS.get(task_id)
if not info:
return None
created_at = float(info.get("created_at") or 0)
if now - created_at > IMAGINE_SESSION_TTL:
_IMAGINE_SESSIONS.pop(task_id, None)
return None
return dict(info)
async def _drop_session(task_id: str) -> None:
if not task_id:
return
async with _IMAGINE_SESSIONS_LOCK:
_IMAGINE_SESSIONS.pop(task_id, None)
async def _drop_sessions(task_ids: List[str]) -> int:
if not task_ids:
return 0
removed = 0
async with _IMAGINE_SESSIONS_LOCK:
for task_id in task_ids:
if task_id and task_id in _IMAGINE_SESSIONS:
_IMAGINE_SESSIONS.pop(task_id, None)
removed += 1
return removed
@router.websocket("/imagine/ws")
async def public_imagine_ws(websocket: WebSocket):
session_id = None
task_id = websocket.query_params.get("task_id")
if task_id:
info = await _get_session(task_id)
if info:
session_id = task_id
ok = True
if session_id is None:
public_key = get_public_api_key()
public_enabled = is_public_enabled()
if not public_key:
ok = public_enabled
else:
key = websocket.query_params.get("public_key")
ok = key == public_key
if not ok:
await websocket.close(code=1008)
return
await websocket.accept()
stop_event = asyncio.Event()
run_task: Optional[asyncio.Task] = None
async def _send(payload: dict) -> bool:
try:
await websocket.send_text(orjson.dumps(payload).decode())
return True
except Exception:
return False
async def _stop_run():
nonlocal run_task
stop_event.set()
if run_task and not run_task.done():
run_task.cancel()
try:
await run_task
except Exception:
pass
run_task = None
stop_event.clear()
async def _run(prompt: str, aspect_ratio: str, nsfw: Optional[bool]):
model_id = "grok-imagine-1.0"
model_info = ModelService.get(model_id)
if not model_info or not model_info.is_image:
await _send(
{
"type": "error",
"message": "Image model is not available.",
"code": "model_not_supported",
}
)
return
token_mgr = await get_token_manager()
run_id = uuid.uuid4().hex
await _send(
{
"type": "status",
"status": "running",
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"run_id": run_id,
}
)
while not stop_event.is_set():
try:
await token_mgr.reload_if_stale()
token = None
for pool_name in ModelService.pool_candidates_for_model(
model_info.model_id
):
token = token_mgr.get_token(pool_name)
if token:
break
if not token:
await _send(
{
"type": "error",
"message": "No available tokens. Please try again later.",
"code": "rate_limit_exceeded",
}
)
await asyncio.sleep(2)
continue
result = await ImageGenerationService().generate(
token_mgr=token_mgr,
token=token,
model_info=model_info,
prompt=prompt,
n=6,
response_format="b64_json",
size="1024x1024",
aspect_ratio=aspect_ratio,
stream=True,
enable_nsfw=nsfw,
)
if result.stream:
async for chunk in result.data:
payload = _parse_sse_chunk(chunk)
if not payload:
continue
if isinstance(payload, dict):
payload.setdefault("run_id", run_id)
await _send(payload)
else:
images = [img for img in result.data if img and img != "error"]
if images:
for img_b64 in images:
await _send(
{
"type": "image",
"b64_json": img_b64,
"created_at": int(time.time() * 1000),
"aspect_ratio": aspect_ratio,
"run_id": run_id,
}
)
else:
await _send(
{
"type": "error",
"message": "Image generation returned empty data.",
"code": "empty_image",
}
)
except asyncio.CancelledError:
break
except Exception as e:
logger.warning(f"Imagine stream error: {e}")
await _send(
{
"type": "error",
"message": str(e),
"code": "internal_error",
}
)
await asyncio.sleep(1.5)
await _send({"type": "status", "status": "stopped", "run_id": run_id})
try:
while True:
try:
raw = await websocket.receive_text()
except (RuntimeError, WebSocketDisconnect):
break
try:
payload = orjson.loads(raw)
except Exception:
await _send(
{
"type": "error",
"message": "Invalid message format.",
"code": "invalid_payload",
}
)
continue
action = payload.get("type")
if action == "start":
prompt = str(payload.get("prompt") or "").strip()
if not prompt:
await _send(
{
"type": "error",
"message": "Prompt cannot be empty.",
"code": "invalid_prompt",
}
)
continue
aspect_ratio = _normalize_imagine_ratio(payload.get("aspect_ratio"))
nsfw = payload.get("nsfw")
if nsfw is not None:
nsfw = bool(nsfw)
await _stop_run()
run_task = asyncio.create_task(_run(prompt, aspect_ratio, nsfw))
elif action == "stop":
await _stop_run()
else:
await _send(
{
"type": "error",
"message": "Unknown action.",
"code": "invalid_action",
}
)
except WebSocketDisconnect:
logger.debug("WebSocket disconnected by client")
except Exception as e:
logger.warning(f"WebSocket error: {e}")
finally:
await _stop_run()
try:
from starlette.websockets import WebSocketState
if websocket.client_state == WebSocketState.CONNECTED:
await websocket.close(code=1000, reason="Server closing connection")
except Exception as e:
logger.debug(f"WebSocket close ignored: {e}")
if session_id:
await _drop_session(session_id)
@router.get("/imagine/sse")
async def public_imagine_sse(
request: Request,
task_id: str = Query(""),
prompt: str = Query(""),
aspect_ratio: str = Query("2:3"),
):
"""Imagine 图片瀑布流(SSE 兜底)"""
session = None
if task_id:
session = await _get_session(task_id)
if not session:
raise HTTPException(status_code=404, detail="Task not found")
else:
public_key = get_public_api_key()
public_enabled = is_public_enabled()
if not public_key:
if not public_enabled:
raise HTTPException(status_code=401, detail="Public access is disabled")
else:
key = request.query_params.get("public_key")
if key != public_key:
raise HTTPException(status_code=401, detail="Invalid authentication token")
if session:
prompt = str(session.get("prompt") or "").strip()
ratio = _normalize_imagine_ratio(session.get("aspect_ratio"))
nsfw = session.get("nsfw")
else:
prompt = (prompt or "").strip()
if not prompt:
raise HTTPException(status_code=400, detail="Prompt cannot be empty")
ratio = _normalize_imagine_ratio(aspect_ratio)
nsfw = request.query_params.get("nsfw")
if nsfw is not None:
nsfw = str(nsfw).lower() in ("1", "true", "yes", "on")
async def event_stream():
try:
model_id = "grok-imagine-1.0"
model_info = ModelService.get(model_id)
if not model_info or not model_info.is_image:
yield (
f"data: {orjson.dumps({'type': 'error', 'message': 'Image model is not available.', 'code': 'model_not_supported'}).decode()}\n\n"
)
return
token_mgr = await get_token_manager()
sequence = 0
run_id = uuid.uuid4().hex
yield (
f"data: {orjson.dumps({'type': 'status', 'status': 'running', 'prompt': prompt, 'aspect_ratio': ratio, 'run_id': run_id}).decode()}\n\n"
)
while True:
if await request.is_disconnected():
logger.info(
"Imagine SSE interrupted by client disconnect: "
f"task_id={task_id or '-'}, run_id={run_id}"
)
break
if task_id:
session_alive = await _get_session(task_id)
if not session_alive:
break
try:
await token_mgr.reload_if_stale()
token = None
for pool_name in ModelService.pool_candidates_for_model(
model_info.model_id
):
token = token_mgr.get_token(pool_name)
if token:
break
if not token:
yield (
f"data: {orjson.dumps({'type': 'error', 'message': 'No available tokens. Please try again later.', 'code': 'rate_limit_exceeded'}).decode()}\n\n"
)
await asyncio.sleep(2)
continue
result = await ImageGenerationService().generate(
token_mgr=token_mgr,
token=token,
model_info=model_info,
prompt=prompt,
n=6,
response_format="b64_json",
size="1024x1024",
aspect_ratio=ratio,
stream=True,
enable_nsfw=nsfw,
)
if result.stream:
async for chunk in result.data:
payload = _parse_sse_chunk(chunk)
if not payload:
continue
if isinstance(payload, dict):
payload.setdefault("run_id", run_id)
yield f"data: {orjson.dumps(payload).decode()}\n\n"
else:
images = [img for img in result.data if img and img != "error"]
if images:
for img_b64 in images:
sequence += 1
payload = {
"type": "image",
"b64_json": img_b64,
"sequence": sequence,
"created_at": int(time.time() * 1000),
"aspect_ratio": ratio,
"run_id": run_id,
}
yield f"data: {orjson.dumps(payload).decode()}\n\n"
else:
yield (
f"data: {orjson.dumps({'type': 'error', 'message': 'Image generation returned empty data.', 'code': 'empty_image'}).decode()}\n\n"
)
except asyncio.CancelledError:
break
except Exception as e:
logger.warning(f"Imagine SSE error: {e}")
yield (
f"data: {orjson.dumps({'type': 'error', 'message': str(e), 'code': 'internal_error'}).decode()}\n\n"
)
await asyncio.sleep(1.5)
yield (
f"data: {orjson.dumps({'type': 'status', 'status': 'stopped', 'run_id': run_id}).decode()}\n\n"
)
finally:
if task_id:
await _drop_session(task_id)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
@router.get("/imagine/config")
async def public_imagine_config():
return {
"final_min_bytes": int(get_config("image.final_min_bytes") or 0),
"medium_min_bytes": int(get_config("image.medium_min_bytes") or 0),
"nsfw": bool(get_config("image.nsfw")),
}
class ImagineStartRequest(BaseModel):
prompt: str
aspect_ratio: Optional[str] = "2:3"
nsfw: Optional[bool] = None
@router.post("/imagine/start", dependencies=[Depends(verify_public_key)])
async def public_imagine_start(data: ImagineStartRequest):
prompt = (data.prompt or "").strip()
if not prompt:
raise HTTPException(status_code=400, detail="Prompt cannot be empty")
ratio = _normalize_imagine_ratio(data.aspect_ratio)
task_id = await _new_session(prompt, ratio, data.nsfw)
return {"task_id": task_id, "aspect_ratio": ratio}
class ImagineEditRequest(BaseModel):
prompt: str
parent_post_id: str
source_image_url: Optional[str] = None
stream: Optional[bool] = False
@router.get("/imagine/parent-post", dependencies=[Depends(verify_public_key)])
async def public_imagine_parent_post(parent_post_id: str = Query(..., description="图片 parentPostId")):
resolved_parent_post_id = _validate_parent_post_id(parent_post_id)
model_id = "grok-imagine-1.0-edit"
token_mgr = await get_token_manager()
await token_mgr.reload_if_stale()
token = await _pick_imagine_parent_token(token_mgr, model_id, resolved_parent_post_id)
if not token:
raise HTTPException(
status_code=429,
detail="No available tokens. Please try again later.",
)
post = await _fetch_media_post_info(token, resolved_parent_post_id)
media_url = str(post.get("mediaUrl") or "").strip()
thumbnail_url = str(post.get("thumbnailImageUrl") or "").strip()
source_image_url = await _canonicalize_parent_source_image_url(
token,
resolved_parent_post_id,
media_url or thumbnail_url,
)
return {
"parent_post_id": resolved_parent_post_id,
"media_url": media_url,
"thumbnail_image_url": thumbnail_url,
"source_image_url": source_image_url,
"mime_type": str(post.get("mimeType") or "").strip(),
"original_post_id": str(post.get("originalPostId") or "").strip(),
"original_ref_type": str(post.get("originalRefType") or "").strip(),
}
@router.post("/imagine/edit", dependencies=[Depends(verify_public_key)])
async def public_imagine_edit(data: ImagineEditRequest, request: Request):
prompt = (data.prompt or "").strip()
if not prompt:
raise HTTPException(status_code=400, detail="Prompt cannot be empty")
parent_post_id = _validate_parent_post_id(data.parent_post_id)
source_image_url = (data.source_image_url or "").strip()
if source_image_url and not (
source_image_url.startswith("http://") or source_image_url.startswith("https://")
):
raise HTTPException(status_code=400, detail="source_image_url must be http(s) URL")
if not source_image_url:
source_image_url = _build_imagine_public_url(parent_post_id)
model_id = "grok-imagine-1.0-edit"
model_info = ModelService.get(model_id)
if not model_info or not model_info.is_image_edit:
raise HTTPException(status_code=503, detail="Image edit model is not available")
token_mgr = await get_token_manager()
await token_mgr.reload_if_stale()
token = await _pick_imagine_parent_token(token_mgr, model_id, parent_post_id)
if not token:
raise HTTPException(
status_code=429,
detail="No available tokens. Please try again later.",
)
source_image_url = await _canonicalize_parent_source_image_url(
token, parent_post_id, source_image_url
)
async def _run_once(
progress_cb=None,
):
started_at = time.time()
result = await ImageEditService().edit_with_parent_post(
token_mgr=token_mgr,
token=token,
model_info=model_info,
prompt=prompt,
parent_post_id=parent_post_id,
source_image_url=source_image_url,
response_format="url",
stream=False,
progress_cb=progress_cb,
)
images = result.data if isinstance(result.data, list) else []
if not images:
raise HTTPException(status_code=502, detail="Image edit returned no results")
image_url = str(images[0])
token_used = str(getattr(result, "token_used", "") or token)
generated_parent_post_id = _extract_parent_post_id_from_url(image_url)
current_parent_post_id = generated_parent_post_id or parent_post_id
current_source_image_url = await _canonicalize_parent_source_image_url(
token_used,
current_parent_post_id,
image_url,
)
elapsed_ms = int((time.time() - started_at) * 1000)
return {
"created": int(time.time()),
"data": [{"url": image_url}],
"parent_post_id": parent_post_id,
"generated_parent_post_id": generated_parent_post_id,
"current_parent_post_id": current_parent_post_id,
"current_source_image_url": current_source_image_url,
"elapsed_ms": elapsed_ms,
}
if data.stream:
async def event_stream():
queue: asyncio.Queue[dict] = asyncio.Queue()
disconnect_event = asyncio.Event()
client_disconnected = False
async def progress_cb(event: str, payload: dict):
item = {"event": event}
if isinstance(payload, dict):
item.update(payload)
await queue.put({"type": "progress", "payload": item})
async def watch_disconnect():
nonlocal client_disconnected
while True:
if await request.is_disconnected():
client_disconnected = True
logger.info(
"Imagine edit stream interrupted by client disconnect: "
f"parent_post_id={parent_post_id}"
)
disconnect_event.set()
break
await asyncio.sleep(0.2)
async def runner():
try:
await queue.put(
{
"type": "progress",
"payload": {
"event": "request_accepted",
"progress": 4,
"message": "已接收编辑请求",
},
}
)
body = await _run_once(progress_cb=progress_cb)
await queue.put(
{
"type": "progress",
"payload": {
"event": "completed",
"progress": 100,
"message": "编辑完成 100%",
},
}
)
await queue.put({"type": "result", "payload": body})
except asyncio.CancelledError:
logger.info(
"Imagine edit stream runner cancelled: "
f"parent_post_id={parent_post_id}"
)
raise
except Exception as e:
message = str(e)
if not message:
message = "编辑失败,请稍后重试"
await queue.put(
{
"type": "error",
"payload": {
"message": message,
},
}
)
finally:
await queue.put({"type": "done", "payload": {}})
task = asyncio.create_task(runner())
watch_task = asyncio.create_task(watch_disconnect())
try:
while True:
if disconnect_event.is_set():
break
try:
item = await asyncio.wait_for(queue.get(), timeout=0.2)
except asyncio.TimeoutError:
continue
item_type = item.get("type")
if item_type == "done":
break
payload = item.get("payload", {})
yield (
f"event: {item_type}\n"
f"data: {orjson.dumps(payload).decode()}\n\n"
)
finally:
disconnect_event.set()
if not task.done():
if client_disconnected:
logger.info(
"Imagine edit stream cancel unfinished runner task: "
f"parent_post_id={parent_post_id}"
)
task.cancel()
with contextlib.suppress(Exception):
await task
if not watch_task.done():
watch_task.cancel()
with contextlib.suppress(Exception):
await watch_task
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
try:
return await _run_once(progress_cb=None)
except AppException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
except Exception:
raise HTTPException(status_code=500, detail="编辑失败,请稍后重试")
class ImagineStopRequest(BaseModel):
task_ids: List[str]
@router.post("/imagine/stop", dependencies=[Depends(verify_public_key)])
async def public_imagine_stop(data: ImagineStopRequest):
removed = await _drop_sessions(data.task_ids or [])
return {"status": "success", "removed": removed}
class ImagineWorkbenchEditRequest(BaseModel):
prompt: str
parent_post_id: Optional[str] = ""
source_image_url: Optional[str] = None
image_base64: Optional[str] = None
image_url: Optional[str] = None
image_references: Optional[List[str]] = None
reference_items: Optional[List[Dict[str, Any]]] = None
stream: Optional[bool] = False
@router.post("/imagine/workbench/edit", dependencies=[Depends(verify_public_key)])
async def public_imagine_workbench_edit(data: ImagineWorkbenchEditRequest, request: Request):
prompt = (data.prompt or "").strip()
if not prompt:
raise HTTPException(status_code=400, detail="Prompt cannot be empty")
model_id = "grok-imagine-1.0-edit"
model_info = ModelService.get(model_id)
if not model_info or not model_info.is_image_edit:
raise HTTPException(status_code=503, detail="Image edit model is not available")
token_mgr = await get_token_manager()
await token_mgr.reload_if_stale()
parent_post_id_raw = str(data.parent_post_id or "").strip()
use_parent_mode = bool(parent_post_id_raw)
parent_post_id = _validate_parent_post_id(parent_post_id_raw) if use_parent_mode else ""
token = None
if use_parent_mode:
token = await _pick_imagine_parent_token(token_mgr, model_id, parent_post_id)
if not token:
for pool_name in ModelService.pool_candidates_for_model(model_id):
token = token_mgr.get_token(pool_name)
if token:
break
if not token:
raise HTTPException(
status_code=429,
detail="No available tokens. Please try again later.",
)
reference_items = _normalize_reference_items(data.reference_items)
if use_parent_mode:
source_image_url = await _canonicalize_parent_source_image_url(
token, parent_post_id, str(data.source_image_url or "").strip()
)
else:
source_image_url = str(data.source_image_url or "").strip()
async def _run_once(progress_cb=None):
started_at = time.time()
edit_service = ImageEditService()
current_source_image_url_input = source_image_url
effective_reference_items = list(reference_items)
if use_parent_mode and not effective_reference_items:
effective_reference_items.append(
{
"parent_post_id": parent_post_id,
"source_image_url": current_source_image_url_input,
"image_url": current_source_image_url_input,
"mention_alias": "Image 1",
}
)
image_inputs = _normalize_image_references(data.image_references)
if image_inputs and not effective_reference_items:
effective_reference_items = [
{
"image_url": image_ref,
"source_image_url": image_ref,
"mention_alias": f"Image {index + 1}",
}
for index, image_ref in enumerate(image_inputs)
]
if effective_reference_items:
first_ref = effective_reference_items[0] if effective_reference_items else {}
only_single_parent_ref = (
len(effective_reference_items) == 1
and bool(str(first_ref.get("parent_post_id") or "").strip())
)
if only_single_parent_ref:
single_parent_post_id = str(first_ref.get("parent_post_id") or "").strip()
single_source_image_url = str(
first_ref.get("source_image_url")
or first_ref.get("image_url")
or current_source_image_url_input
or ""
).strip()
result = await edit_service.edit_with_parent_post(
token_mgr=token_mgr,
token=token,
model_info=model_info,
prompt=prompt,
parent_post_id=single_parent_post_id,
source_image_url=single_source_image_url,
response_format="url",
stream=False,
return_all_images=True,
progress_cb=progress_cb,
)
mode = "parent_post"
else:
for item in effective_reference_items:
ref_parent = _extract_parent_post_id_from_url(str(item.get("parent_post_id") or ""))
if ref_parent and not str(item.get("source_image_url") or "").strip():
item["parent_post_id"] = ref_parent
item["source_image_url"] = await _canonicalize_parent_source_image_url(
token,
ref_parent,
str(item.get("image_url") or "").strip(),
)
if not str(item.get("image_url") or "").strip():
item["image_url"] = item["source_image_url"]
result = await edit_service.edit_with_reference_items(
token_mgr=token_mgr,
token=token,
model_info=model_info,
prompt=prompt,
reference_items=effective_reference_items,
root_parent_post_id=parent_post_id if use_parent_mode else "",
response_format="url",
stream=False,
return_all_images=True,
progress_cb=progress_cb,
)
mode = "parent_post" if use_parent_mode else "upload"
elif use_parent_mode:
if current_source_image_url_input and not (
current_source_image_url_input.startswith("http://")
or current_source_image_url_input.startswith("https://")
):
raise HTTPException(
status_code=400, detail="source_image_url must be http(s) URL"
)
result = await edit_service.edit_with_parent_post(
token_mgr=token_mgr,
token=token,
model_info=model_info,
prompt=prompt,
parent_post_id=parent_post_id,
source_image_url=current_source_image_url_input,
response_format="url",
stream=False,
return_all_images=True,
progress_cb=progress_cb,
)
mode = "parent_post"
else:
image_input = _normalize_image_input(
image_base64=str(data.image_base64 or ""),
image_url=str(data.image_url or ""),
)
result = await edit_service.edit(
token_mgr=token_mgr,
token=token,
model_info=model_info,
prompt=prompt,
images=[image_input],
n=1,
response_format="url",
stream=False,
return_all_images=True,
progress_cb=progress_cb,
)
mode = "upload"
images = result.data if isinstance(result.data, list) else []
if not images:
raise HTTPException(status_code=502, detail="Image edit returned no results")
normalized_images = [str(item or "").strip() for item in images if str(item or "").strip()]
if not normalized_images:
raise HTTPException(status_code=502, detail="Image edit returned no results")
image_url = normalized_images[0]
token_used = str(getattr(result, "token_used", "") or token)
generated_parent_post_id = _extract_parent_post_id_from_url(image_url)
current_parent_post_id = generated_parent_post_id or parent_post_id
current_source_image_url = await _canonicalize_parent_source_image_url(
token_used,
current_parent_post_id,
image_url,
)
elapsed_ms = int((time.time() - started_at) * 1000)
return {
"created": int(time.time()),
"data": [{"url": url} for url in normalized_images],
"mode": mode,
"input_parent_post_id": parent_post_id if use_parent_mode else "",
"generated_parent_post_id": generated_parent_post_id,
"current_parent_post_id": current_parent_post_id,
"current_source_image_url": current_source_image_url,
"elapsed_ms": elapsed_ms,
}
if data.stream:
async def event_stream():
queue: asyncio.Queue[dict] = asyncio.Queue()
disconnect_event = asyncio.Event()
client_disconnected = False
async def progress_cb(event: str, payload: dict):
item = {"event": event}
if isinstance(payload, dict):
item.update(payload)
await queue.put({"type": "progress", "payload": item})
async def watch_disconnect():
nonlocal client_disconnected
while True:
if await request.is_disconnected():
client_disconnected = True
logger.info(
"Imagine workbench stream interrupted by client disconnect: "
f"mode={'parent_post' if use_parent_mode else 'upload'}, "
f"parent_post_id={parent_post_id or '-'}"
)
disconnect_event.set()
break
await asyncio.sleep(0.2)
async def runner():
try:
await queue.put(
{
"type": "progress",
"payload": {
"event": "request_accepted",
"progress": 4,
"message": "已接收编辑请求",
},
}
)
body = await _run_once(progress_cb=progress_cb)
await queue.put(
{
"type": "progress",
"payload": {
"event": "completed",
"progress": 100,
"message": "编辑完成 100%",
},
}
)
await queue.put({"type": "result", "payload": body})
except asyncio.CancelledError:
logger.info(
"Imagine workbench stream runner cancelled: "
f"mode={'parent_post' if use_parent_mode else 'upload'}, "
f"parent_post_id={parent_post_id or '-'}"
)
raise
except Exception as e:
message = str(e)
if not message:
message = "编辑失败,请稍后重试"
await queue.put(
{
"type": "error",
"payload": {
"message": message,
},
}
)
finally:
await queue.put({"type": "done", "payload": {}})
task = asyncio.create_task(runner())
watch_task = asyncio.create_task(watch_disconnect())
try:
while True:
if disconnect_event.is_set():
break
try:
item = await asyncio.wait_for(queue.get(), timeout=0.2)
except asyncio.TimeoutError:
continue
item_type = item.get("type")
if item_type == "done":
break
payload = item.get("payload", {})
yield (
f"event: {item_type}\n"
f"data: {orjson.dumps(payload).decode()}\n\n"
)
finally:
disconnect_event.set()
if not task.done():
if client_disconnected:
logger.info(
"Imagine workbench stream cancel unfinished runner task: "
f"mode={'parent_post' if use_parent_mode else 'upload'}, "
f"parent_post_id={parent_post_id or '-'}"
)
task.cancel()
with contextlib.suppress(Exception):
await task
if not watch_task.done():
watch_task.cancel()
with contextlib.suppress(Exception):
await watch_task
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
try:
return await _run_once(progress_cb=None)
except AppException as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
except Exception:
raise HTTPException(status_code=500, detail="编辑失败,请稍后重试")