Skip to content

Commit 09c95ab

Browse files
committed
2 parents e3dc43b + 8f24d4e commit 09c95ab

9 files changed

Lines changed: 1112 additions & 105 deletions

File tree

app/api/v1/admin.py

Lines changed: 232 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from fastapi import APIRouter, Depends, HTTPException, Request, Query, Body
1+
from fastapi import APIRouter, Depends, HTTPException, Request, Query, Body, WebSocket
22
from fastapi.responses import HTMLResponse, RedirectResponse
33
from pydantic import BaseModel
4-
from typing import Any
4+
from typing import Any, Optional
55

66
from app.core.auth import verify_api_key
77
from app.core.config import config, get_config
@@ -11,9 +11,21 @@
1111
import aiofiles
1212
import asyncio
1313
import json
14+
import time
15+
import uuid
16+
import orjson
17+
from starlette.websockets import WebSocketDisconnect, WebSocketState
1418
from app.core.logger import logger
1519
from app.services.register import get_auto_register_manager
1620
from app.services.api_keys import api_key_manager
21+
from app.services.grok.model import ModelService
22+
from app.services.grok.imagine_generation import (
23+
collect_experimental_generation_images,
24+
is_valid_image_value as is_valid_imagine_image_value,
25+
resolve_aspect_ratio as resolve_imagine_aspect_ratio,
26+
)
27+
from app.services.token import get_token_manager
28+
from app.core.auth import _load_legacy_api_keys
1729

1830

1931
router = APIRouter()
@@ -82,6 +94,224 @@ async def admin_chat_page():
8294
"""在线聊天页(后台入口)"""
8395
return await render_template("chat/chat_admin.html")
8496

97+
98+
async def _verify_ws_api_key(websocket: WebSocket) -> bool:
99+
api_key = str(get_config("app.api_key", "") or "").strip()
100+
legacy_keys = await _load_legacy_api_keys()
101+
if not api_key and not legacy_keys:
102+
return True
103+
token = str(websocket.query_params.get("api_key") or "").strip()
104+
if not token:
105+
return False
106+
if (api_key and token == api_key) or token in legacy_keys:
107+
return True
108+
try:
109+
await api_key_manager.init()
110+
if api_key_manager.validate_key(token):
111+
return True
112+
except Exception as e:
113+
logger.warning(f"Imagine ws api_key validation fallback failed: {e}")
114+
return False
115+
116+
117+
async def _collect_imagine_batch(token: str, prompt: str, aspect_ratio: str) -> list[str]:
118+
return await collect_experimental_generation_images(
119+
token=token,
120+
prompt=prompt,
121+
n=6,
122+
response_format="b64_json",
123+
aspect_ratio=aspect_ratio,
124+
concurrency=1,
125+
)
126+
127+
128+
@router.websocket("/api/v1/admin/imagine/ws")
129+
async def admin_imagine_ws(websocket: WebSocket):
130+
if not await _verify_ws_api_key(websocket):
131+
await websocket.close(code=1008)
132+
return
133+
134+
await websocket.accept()
135+
stop_event = asyncio.Event()
136+
run_task: Optional[asyncio.Task] = None
137+
138+
async def _send(payload: dict) -> bool:
139+
try:
140+
await websocket.send_text(orjson.dumps(payload).decode())
141+
return True
142+
except Exception:
143+
return False
144+
145+
async def _stop_run():
146+
nonlocal run_task
147+
stop_event.set()
148+
if run_task and not run_task.done():
149+
run_task.cancel()
150+
try:
151+
await run_task
152+
except Exception:
153+
pass
154+
run_task = None
155+
stop_event.clear()
156+
157+
async def _run(prompt: str, aspect_ratio: str):
158+
model_id = "grok-imagine-1.0"
159+
model_info = ModelService.get(model_id)
160+
if not model_info or not model_info.is_image:
161+
await _send(
162+
{
163+
"type": "error",
164+
"message": "Image model is not available.",
165+
"code": "model_not_supported",
166+
}
167+
)
168+
return
169+
170+
token_mgr = await get_token_manager()
171+
sequence = 0
172+
run_id = uuid.uuid4().hex
173+
await _send(
174+
{
175+
"type": "status",
176+
"status": "running",
177+
"prompt": prompt,
178+
"aspect_ratio": aspect_ratio,
179+
"run_id": run_id,
180+
}
181+
)
182+
183+
while not stop_event.is_set():
184+
try:
185+
await token_mgr.reload_if_stale()
186+
token = token_mgr.get_token_for_model(model_info.model_id)
187+
if not token:
188+
await _send(
189+
{
190+
"type": "error",
191+
"message": "No available tokens. Please try again later.",
192+
"code": "rate_limit_exceeded",
193+
}
194+
)
195+
await asyncio.sleep(2)
196+
continue
197+
198+
start_at = time.time()
199+
images = await _collect_imagine_batch(token, prompt, aspect_ratio)
200+
elapsed_ms = int((time.time() - start_at) * 1000)
201+
202+
sent_any = False
203+
for image_b64 in images:
204+
if not is_valid_imagine_image_value(image_b64):
205+
continue
206+
sent_any = True
207+
sequence += 1
208+
ok = await _send(
209+
{
210+
"type": "image",
211+
"b64_json": image_b64,
212+
"sequence": sequence,
213+
"created_at": int(time.time() * 1000),
214+
"elapsed_ms": elapsed_ms,
215+
"aspect_ratio": aspect_ratio,
216+
"run_id": run_id,
217+
}
218+
)
219+
if not ok:
220+
stop_event.set()
221+
break
222+
223+
if sent_any:
224+
try:
225+
await token_mgr.sync_usage(
226+
token,
227+
model_info.model_id,
228+
consume_on_fail=True,
229+
is_usage=True,
230+
)
231+
except Exception as e:
232+
logger.warning(f"Imagine ws token sync failed: {e}")
233+
else:
234+
await _send(
235+
{
236+
"type": "error",
237+
"message": "Image generation returned empty data.",
238+
"code": "empty_image",
239+
}
240+
)
241+
except asyncio.CancelledError:
242+
break
243+
except Exception as e:
244+
logger.warning(f"Imagine stream error: {e}")
245+
await _send(
246+
{
247+
"type": "error",
248+
"message": str(e),
249+
"code": "internal_error",
250+
}
251+
)
252+
await asyncio.sleep(1.5)
253+
254+
await _send({"type": "status", "status": "stopped", "run_id": run_id})
255+
256+
try:
257+
while True:
258+
try:
259+
raw = await websocket.receive_text()
260+
except (RuntimeError, WebSocketDisconnect):
261+
break
262+
263+
try:
264+
payload = orjson.loads(raw)
265+
except Exception:
266+
await _send(
267+
{
268+
"type": "error",
269+
"message": "Invalid message format.",
270+
"code": "invalid_payload",
271+
}
272+
)
273+
continue
274+
275+
msg_type = payload.get("type")
276+
if msg_type == "start":
277+
prompt = str(payload.get("prompt") or "").strip()
278+
if not prompt:
279+
await _send(
280+
{
281+
"type": "error",
282+
"message": "Prompt cannot be empty.",
283+
"code": "empty_prompt",
284+
}
285+
)
286+
continue
287+
ratio = resolve_imagine_aspect_ratio(str(payload.get("aspect_ratio") or "2:3").strip())
288+
await _stop_run()
289+
run_task = asyncio.create_task(_run(prompt, ratio))
290+
elif msg_type == "stop":
291+
await _stop_run()
292+
elif msg_type == "ping":
293+
await _send({"type": "pong"})
294+
else:
295+
await _send(
296+
{
297+
"type": "error",
298+
"message": "Unknown command.",
299+
"code": "unknown_command",
300+
}
301+
)
302+
except WebSocketDisconnect:
303+
logger.debug("WebSocket disconnected by client")
304+
except Exception as e:
305+
logger.warning(f"WebSocket error: {e}")
306+
finally:
307+
await _stop_run()
308+
try:
309+
if websocket.client_state == WebSocketState.CONNECTED:
310+
await websocket.close(code=1000, reason="Server closing connection")
311+
except Exception as e:
312+
logger.debug(f"WebSocket close ignored: {e}")
313+
314+
85315
@router.post("/api/v1/admin/login")
86316
async def admin_login_api(request: Request, body: AdminLoginBody | None = Body(default=None)):
87317
"""管理后台登录验证(用户名+密码)

app/api/v1/image.py

Lines changed: 19 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
ImagineExperimentalService,
2626
resolve_image_generation_method,
2727
)
28+
from app.services.grok.imagine_generation import (
29+
call_experimental_generation_once,
30+
collect_experimental_generation_images,
31+
dedupe_images as dedupe_imagine_images,
32+
is_valid_image_value as is_valid_imagine_image_value,
33+
resolve_aspect_ratio as resolve_imagine_aspect_ratio,
34+
)
2835
from app.services.grok.model import ModelService
2936
from app.services.grok.processor import ImageCollectProcessor, ImageStreamProcessor
3037
from app.services.quota import enforce_daily_quota
@@ -236,44 +243,15 @@ def _image_generation_method() -> str:
236243

237244

238245
def resolve_aspect_ratio(size: Optional[str]) -> str:
239-
value = str(size or "").strip().lower()
240-
if value in {"16:9", "9:16", "1:1", "2:3", "3:2"}:
241-
return value
242-
243-
mapping = {
244-
"1024x1024": "1:1",
245-
"512x512": "1:1",
246-
"1024x576": "16:9",
247-
"1280x720": "16:9",
248-
"1536x864": "16:9",
249-
"576x1024": "9:16",
250-
"720x1280": "9:16",
251-
"864x1536": "9:16",
252-
"1024x1536": "2:3",
253-
"512x768": "2:3",
254-
"768x1024": "2:3",
255-
"1536x1024": "3:2",
256-
"768x512": "3:2",
257-
"1024x768": "3:2",
258-
}
259-
return mapping.get(value, "2:3")
246+
return resolve_imagine_aspect_ratio(size)
260247

261248

262249
def _is_valid_image_value(value: Any) -> bool:
263-
return isinstance(value, str) and bool(value) and value != "error"
250+
return is_valid_imagine_image_value(value)
264251

265252

266253
def _dedupe_images(images: List[str]) -> List[str]:
267-
out: List[str] = []
268-
seen: set[str] = set()
269-
for image in images:
270-
if not isinstance(image, str):
271-
continue
272-
if image in seen:
273-
continue
274-
seen.add(image)
275-
out.append(image)
276-
return out
254+
return dedupe_imagine_images(images)
277255

278256

279257
async def _gather_limited(
@@ -330,14 +308,13 @@ async def call_grok_experimental_ws(
330308
n: int = 4,
331309
aspect_ratio: str = "2:3",
332310
) -> List[str]:
333-
service = ImagineExperimentalService()
334-
raw_urls = await service.generate_ws(
311+
return await call_experimental_generation_once(
335312
token=token,
336313
prompt=prompt,
314+
response_format=response_format,
337315
n=n,
338316
aspect_ratio=aspect_ratio,
339317
)
340-
return await service.convert_urls(token=token, urls=raw_urls, response_format=response_format)
341318

342319

343320
async def call_grok_experimental_edit(
@@ -365,36 +342,14 @@ async def _collect_experimental_generation_images(
365342
aspect_ratio: str,
366343
concurrency: int,
367344
) -> List[str]:
368-
calls_needed = max(1, (n + 3) // 4)
369-
task_factories: List[Callable[[], Awaitable[List[str]]]] = []
370-
remain = n
371-
for _ in range(calls_needed):
372-
target_n = max(1, min(4, remain))
373-
remain -= target_n
374-
task_factories.append(
375-
lambda target_n=target_n: call_grok_experimental_ws(
376-
token,
377-
prompt,
378-
response_format=response_format,
379-
n=target_n,
380-
aspect_ratio=aspect_ratio,
381-
)
382-
)
383-
results = await _gather_limited(
384-
task_factories,
385-
max_concurrency=min(calls_needed, max(1, int(concurrency or 1))),
345+
return await collect_experimental_generation_images(
346+
token=token,
347+
prompt=prompt,
348+
n=n,
349+
response_format=response_format,
350+
aspect_ratio=aspect_ratio,
351+
concurrency=concurrency,
386352
)
387-
all_images: List[str] = []
388-
for result in results:
389-
if isinstance(result, Exception):
390-
logger.warning(f"Experimental imagine websocket call failed: {result}")
391-
continue
392-
if isinstance(result, list):
393-
all_images.extend(result)
394-
all_images = _dedupe_images(all_images)
395-
if not any(_is_valid_image_value(item) for item in all_images):
396-
raise UpstreamException("Experimental imagine websocket returned no images")
397-
return all_images
398353

399354

400355
async def _experimental_stream_generation(

0 commit comments

Comments
 (0)