feat: 完善本地功能 (视频UI修复, 工作台重构, PWA支持, 提示词增强)

This commit is contained in:
XianYuDaXian committed 2026-02-18 23:18:58 +08:00
1 parent 82dda21248
commit bac0ed508c
55 files changed
+3817 -344

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "grok2api"
[setup]
script = "chcp 65001"
+34
View File
@@ -42,3 +42,37 @@ async def public_video():
if not is_public_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return FileResponse(STATIC_DIR / "public/pages/video.html")
@router.get("/nsfw", include_in_schema=False)
async def public_nsfw():
if not is_public_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return FileResponse(STATIC_DIR / "public/pages/nsfw.html")
@router.get("/imagine-workbench", include_in_schema=False)
async def public_imagine_workbench():
if not is_public_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return FileResponse(STATIC_DIR / "public/pages/imagine_workbench.html")
@router.get("/manifest.webmanifest", include_in_schema=False)
async def public_manifest():
if not is_public_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return FileResponse(
STATIC_DIR / "public/manifest.webmanifest",
media_type="application/manifest+json",
)
@router.get("/sw.js", include_in_schema=False)
async def public_service_worker():
if not is_public_enabled():
raise HTTPException(status_code=404, detail="Not Found")
return FileResponse(
STATIC_DIR / "public/sw.js",
media_type="application/javascript",
)
+8 -3
View File
@@ -4,7 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException
from app.core.auth import verify_app_key
from app.core.config import config
from app.core.storage import get_storage, LocalStorage, RedisStorage, SQLStorage
from app.core.storage import (
get_storage as get_storage_backend,
LocalStorage,
RedisStorage,
SQLStorage,
)
router = APIRouter()
@@ -33,11 +38,11 @@ async def update_config(data: dict):
@router.get("/storage", dependencies=[Depends(verify_app_key)])
async def get_storage():
async def admin_get_storage():
"""获取当前存储模式"""
storage_type = os.getenv("SERVER_STORAGE_TYPE", "").lower()
if not storage_type:
storage = get_storage()
storage = get_storage_backend()
if isinstance(storage, LocalStorage):
storage_type = "local"
elif isinstance(storage, RedisStorage):
+19 -4
View File
@@ -3,6 +3,7 @@
"""
import aiofiles.os
from urllib.parse import unquote
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
@@ -18,13 +19,28 @@ IMAGE_DIR = BASE_DIR / "image"
VIDEO_DIR = BASE_DIR / "video"
def _normalize_cached_filename(filename: str) -> str:
"""
规范化客户端传入文件名,兼容尾部误带反斜杠等情况。
"""
value = (filename or "").strip()
# 尝试解码一次,兼容 %5C 这类编码
try:
value = unquote(value)
except Exception:
pass
value = value.strip().strip('"').strip("'").rstrip("\\/")
# 将路径分隔符统一扁平化到缓存命名规则
value = value.replace("\\", "-").replace("/", "-")
return value
@router.get("/image/{filename:path}")
async def get_image(filename: str):
"""
获取图片文件
"""
if "/" in filename:
filename = filename.replace("/", "-")
filename = _normalize_cached_filename(filename)
file_path = IMAGE_DIR / filename
@@ -52,8 +68,7 @@ async def get_video(filename: str):
"""
获取视频文件
"""
if "/" in filename:
filename = filename.replace("/", "-")
filename = _normalize_cached_filename(filename)
file_path = VIDEO_DIR / filename
+19 -5
View File
@@ -193,15 +193,21 @@ def validate_edit_request(request: ImageEditRequest, images: List[UploadFile]):
code="model_not_supported",
)
_validate_common_request(request, allow_ws_stream=False)
if request.n != 1:
raise ValidationException(
message="For image edits, n must be 1.",
param="n",
code="invalid_n",
)
if not images:
raise ValidationException(
message="Image is required",
param="image",
code="missing_image",
)
if len(images) > 16:
if len(images) > 3:
raise ValidationException(
message="Too many images. Maximum is 16.",
message="Too many images. Maximum is 3.",
param="image",
code="invalid_image_count",
)
@@ -302,7 +308,8 @@ async def create_image(request: ImageGenerationRequest):
@router.post("/images/edits")
async def edit_image(
prompt: str = Form(...),
image: List[UploadFile] = File(...),
image: Optional[List[UploadFile]] = File(None),
image_bracket: Optional[List[UploadFile]] = File(None, alias="image[]"),
model: Optional[str] = Form("grok-imagine-1.0-edit"),
n: int = Form(1),
size: str = Form("1024x1024"),
@@ -347,6 +354,13 @@ async def edit_image(
if edit_request.stream is None:
edit_request.stream = False
# 兼容两种多文件字段:image / image[]
upload_images: List[UploadFile] = []
if image:
upload_images.extend(image)
if image_bracket:
upload_images.extend(image_bracket)
response_format = resolve_response_format(edit_request.response_format)
if response_format == "base64":
response_format = "b64_json"
@@ -354,13 +368,13 @@ async def edit_image(
response_field = response_field_name(response_format)
# 参数验证
validate_edit_request(edit_request, image)
validate_edit_request(edit_request, upload_images)
max_image_bytes = 50 * 1024 * 1024
allowed_types = {"image/png", "image/jpeg", "image/webp", "image/jpg"}
images: List[str] = []
for item in image:
for item in upload_images:
content = await item.read()
await item.close()
if not content:
+606
View File
@@ -0,0 +1,606 @@
"""
NSFW 全流程 API 路由
"""
import re
import asyncio
import math
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.core.config import get_config
from app.core.exceptions import AppException, ErrorType, ValidationException
from app.core.logger import logger
from app.services.grok.services.image import ImageGenerationService
from app.services.grok.services.model import ModelService
from app.services.grok.services.video import VideoService, VideoCollectProcessor
from app.services.token import get_token_manager
router = APIRouter(tags=["NSFW"])
RATIO_TO_SIZE = {
"16:9": "1280x720",
"9:16": "720x1280",
"3:2": "1792x1024",
"2:3": "1024x1792",
"1:1": "1024x1024",
}
class NSFWRequest(BaseModel):
"""NSFW 全流程请求"""
image_prompt: str = Field(..., description="图片提示词")
video_prompt: Optional[str] = Field(None, description="视频提示词(可选)")
aspect_ratio: Optional[str] = Field(
None, description="比例: 16:9/9:16/3:2/2:3/1:1"
)
ratio: Optional[str] = Field(
None, description="比例别名: 16:9/9:16/3:2/2:3/1:1"
)
size: Optional[str] = Field(
None,
description="尺寸别名: 1280x720/720x1280/1792x1024/1024x1792/1024x1024",
)
image_parallel: int = Field(4, ge=1, le=8, description="并发生图数")
video_parallel: int = Field(4, ge=1, le=8, description="并发视频数")
max_image_attempts: int = Field(20, ge=1, le=50, description="生图最大尝试次数")
video_length: int = Field(6, ge=5, le=15, description="视频秒数")
resolution: str = Field("480p", description="视频分辨率: 480p/720p")
preset: str = Field("spicy", description="视频风格: fun/normal/spicy")
upscale: bool = Field(True, description="视频完成后是否尝试超分")
image_only: bool = Field(False, description="仅生图不生视频")
parent_post_only: bool = Field(
True, description="仅使用 parentPostId 路径,不回退上传图片路径"
)
text_video_fallback: bool = Field(
True, description="parentPostId 空结果时,回退普通文字生视频"
)
parent_post_empty_retry: int = Field(
3, ge=1, le=10, description="parentPostId 空结果重试次数"
)
parent_post_empty_retry_delay: float = Field(
2.0, ge=0.0, le=30.0, description="parentPostId 空结果重试间隔(秒)"
)
def _normalize_ratio(raw: str) -> str:
ratio = (raw or "").strip()
ratio = ratio.replace(":", ":").replace("/", ":").replace(" ", "")
# 兼容把 16x9 误传成比例写法
m = re.match(r"^(\d+)[xX](\d+)$", ratio)
if m:
ratio = f"{m.group(1)}:{m.group(2)}"
if ratio not in RATIO_TO_SIZE:
raise ValidationException(
message="aspect_ratio must be one of 16:9/9:16/3:2/2:3/1:1",
param="aspect_ratio",
code="invalid_aspect_ratio",
)
return ratio
def _resolve_ratio(data: NSFWRequest) -> str:
"""兼容 aspect_ratio / ratio / size 三种输入方式。"""
raw_ratio = (data.aspect_ratio or "").strip() or (data.ratio or "").strip()
raw_ratio = raw_ratio.replace(":", ":").replace("/", ":")
if not raw_ratio:
# 兼容把 size 直接传成比例字符串(如 16:9)
maybe_ratio_in_size = (data.size or "").strip()
maybe_ratio_in_size = maybe_ratio_in_size.replace(":", ":").replace("/", ":")
if ":" in maybe_ratio_in_size:
raw_ratio = maybe_ratio_in_size
if raw_ratio:
return _normalize_ratio(raw_ratio)
raw_size = (data.size or "").strip()
raw_size = raw_size.replace(":", ":").replace("*", "x")
if raw_size:
# 先精确映射常见尺寸
for ratio, size in RATIO_TO_SIZE.items():
if size == raw_size:
return ratio
# 再兼容任意 WxH(如 1536x864)
m = re.match(r"^\s*(\d+)\s*[xX]\s*(\d+)\s*$", raw_size)
if m:
w = int(m.group(1))
h = int(m.group(2))
if w > 0 and h > 0:
v = w / h
candidates = {
"16:9": 16 / 9,
"9:16": 9 / 16,
"3:2": 3 / 2,
"2:3": 2 / 3,
"1:1": 1.0,
}
# 选最近的合法比例
return min(candidates.keys(), key=lambda k: math.fabs(candidates[k] - v))
return "2:3"
def _normalize_image_url(url: str) -> str:
app_url = (get_config("app.app_url") or "").strip().rstrip("/")
if url.startswith("http://") or url.startswith("https://"):
return url
if not url.startswith("/"):
url = f"/{url}"
if app_url:
return f"{app_url}{url}"
return url
def _extract_video_urls(content: str) -> tuple[str, str]:
if not content:
return "", ""
src_match = re.search(r'<source[^>]+src="([^"]+)"', content, re.IGNORECASE)
poster_match = re.search(r'poster="([^"]+)"', content, re.IGNORECASE)
video_url = src_match.group(1) if src_match else ""
poster_url = poster_match.group(1) if poster_match else ""
if (not video_url) and content.startswith(("http://", "https://")):
video_url = content.strip()
return video_url, poster_url
def _clean_url(url: str) -> str:
"""清洗 URL 末尾脏字符,避免客户端误请求。"""
value = (url or "").strip().strip('"').strip("'")
value = value.rstrip("\\")
return value
def _extract_parent_post_id(image_url: str) -> str:
"""从本地图片 URL 中提取 parentPostId 候选值。"""
if not image_url:
return ""
# 形如: /v1/files/image/<uuid>-final.jpg
m = re.search(
r"/v1/files/image/([0-9a-fA-F-]{32,36})(?:-[a-z]+)?\.(?:jpg|jpeg|png|webp)$",
image_url,
)
if m:
return m.group(1)
# 兜底: 任意位置的 uuid
m = re.search(r"([0-9a-fA-F-]{32,36})", image_url)
if m:
return m.group(1)
return ""
def _build_imagine_public_url(parent_post_id: str) -> str:
"""根据 image_id / parentPostId 生成 imagine-public 图片 URL。"""
if not parent_post_id:
return ""
return f"https://imagine-public.x.ai/imagine-public/images/{parent_post_id}.jpg"
def _token_tag(token: str) -> str:
raw = token[4:] if token.startswith("sso=") else token
if not raw:
return "empty"
if len(raw) <= 14:
return raw
return f"{raw[:6]}...{raw[-6:]}"
async def _pick_image_token() -> tuple[Any, str]:
token_mgr = await get_token_manager()
await token_mgr.reload_if_stale()
token = None
for pool_name in ModelService.pool_candidates_for_model("grok-imagine-1.0"):
token = token_mgr.get_token(pool_name)
if token:
break
if not token:
raise AppException(
message="No available tokens. Please try again later.",
error_type=ErrorType.RATE_LIMIT.value,
code="rate_limit_exceeded",
status_code=429,
)
return token_mgr, token
async def _generate_nsfw_inner(data: NSFWRequest) -> Dict[str, Any]:
"""
NSFW 全流程:
1) 并发生图(最多 max_image_attempts 轮)
2) 选第一张图生视频(可关闭)
3) 返回本地 URL 与汇总文本
"""
if not data.image_prompt or not data.image_prompt.strip():
raise ValidationException(
message="image_prompt cannot be empty",
param="image_prompt",
code="empty_image_prompt",
)
ratio = _resolve_ratio(data)
image_prompt = data.image_prompt.strip()
raw_video_prompt = (data.video_prompt or "").strip()
video_prompt_is_custom = VideoService.is_meaningful_video_prompt(raw_video_prompt)
video_prompt = raw_video_prompt if video_prompt_is_custom else ""
fields_set = set(getattr(data, "model_fields_set", set()))
effective_resolution = data.resolution
effective_video_length = data.video_length
logger.info(
f"NSFW start: ratio={ratio}, image_parallel={data.image_parallel}, "
f"max_image_attempts={data.max_image_attempts}, image_only={data.image_only}, "
f"parent_post_only={data.parent_post_only}, "
f"text_video_fallback={data.text_video_fallback}, "
f"parent_post_empty_retry={data.parent_post_empty_retry}, "
f"parent_post_empty_retry_delay={data.parent_post_empty_retry_delay}"
)
logger.info(
f"NSFW ratio input: aspect_ratio={data.aspect_ratio}, ratio={data.ratio}, size={data.size}"
)
logger.info(f"NSFW image prompt: {image_prompt[:120]}")
logger.info(f"NSFW video prompt: {video_prompt[:120]}")
logger.info(
"NSFW video config: "
f"resolution={effective_resolution}, video_length={effective_video_length}, "
f"upscale={data.upscale}, fields_set={sorted(fields_set)}"
)
if raw_video_prompt and not video_prompt_is_custom:
logger.info(
"NSFW video prompt treated as generic, force spicy mode with image-url-only message"
)
if not video_prompt_is_custom:
logger.info(
"NSFW video prompt absent or generic, force spicy mode with image-url-only message"
)
token_mgr, token = await _pick_image_token()
model_info = ModelService.get("grok-imagine-1.0")
if not model_info:
raise AppException(
message="Model grok-imagine-1.0 not found",
error_type=ErrorType.SERVER.value,
code="model_not_found",
status_code=500,
)
image_service = ImageGenerationService()
images: List[str] = []
seen = set()
async def _generate_one() -> Optional[str]:
result = await image_service.generate(
token_mgr=token_mgr,
token=token,
model_info=model_info,
prompt=image_prompt,
n=1,
response_format="url",
size=RATIO_TO_SIZE[ratio],
aspect_ratio=ratio,
stream=False,
enable_nsfw=True,
)
if isinstance(result.data, list) and result.data:
first = result.data[0]
if first and first != "error":
return _normalize_image_url(first)
return None
for attempt in range(1, data.max_image_attempts + 1):
remain = max(1, data.image_parallel - len(images))
tasks = [_generate_one() for _ in range(remain)]
results = await asyncio.gather(*tasks, return_exceptions=True)
new_count = 0
for item in results:
if isinstance(item, Exception):
logger.warning(f"NSFW image attempt failed: {item}")
continue
if item and item not in seen:
seen.add(item)
images.append(item)
new_count += 1
logger.info(
f"NSFW image attempt {attempt}/{data.max_image_attempts}: +{new_count}, total={len(images)}"
)
if len(images) >= data.image_parallel:
break
if not images:
raise AppException(
message="NSFW image generation failed after retries",
error_type=ErrorType.SERVER.value,
code="nsfw_image_failed",
status_code=502,
)
response_payload: Dict[str, Any] = {
"image_prompt": image_prompt,
"video_prompt": video_prompt,
"aspect_ratio": ratio,
"images": images,
"videos": [],
}
if data.image_only:
lines = [
f"图片提示词: {image_prompt}",
f"比例: {ratio}",
"返回链接:",
]
for idx, u in enumerate(images, 1):
lines.append(f"{idx}. {u}")
text = "\n".join(lines)
response_payload["result"] = text
return response_payload
sem = asyncio.Semaphore(max(1, data.video_parallel))
# 为并发视频任务预分配不同 token,避免并行任务争抢同一 token。
video_pool_candidates = ModelService.pool_candidates_for_model("grok-imagine-1.0-video")
used_video_tokens: set[str] = set()
video_task_tokens: list[str] = []
for idx in range(len(images)):
token_info = token_mgr.get_token_for_video(
resolution=effective_resolution,
video_length=effective_video_length,
pool_candidates=video_pool_candidates,
exclude=used_video_tokens,
)
if not token_info:
raise AppException(
message=(
"No enough distinct tokens for parallel video generation. "
"Please reduce video_parallel or add more tokens."
),
error_type=ErrorType.RATE_LIMIT.value,
code="insufficient_distinct_video_tokens",
status_code=429,
)
task_token = token_info.token[4:] if token_info.token.startswith("sso=") else token_info.token
used_video_tokens.add(task_token)
video_task_tokens.append(task_token)
pool_name = token_mgr.get_pool_name_for_token(task_token) or "unknown"
logger.info(
f"NSFW video token assigned: task={idx + 1}, pool={pool_name}, token={_token_tag(task_token)}"
)
async def _gen_video(image_url: str, index: int, task_token: str) -> Dict[str, Any]:
async with sem:
task_tag = f"task={index}, token={_token_tag(task_token)}"
logger.info(f"NSFW video start: {task_tag}, image={image_url[:120]}")
parent_post_id = _extract_parent_post_id(image_url)
# 对齐你的规则:
# - 有视频提示词: mode=custom
# - 无视频提示词: mode=extremely-spicy-or-crazy
# 这里保留 task_preset 仅用于兼容旧调用链,不再作为最终 mode 决策依据。
task_mode = "custom" if video_prompt_is_custom else "extremely-spicy-or-crazy"
task_preset = "custom" if video_prompt_is_custom else "spicy"
task_prompt = video_prompt if video_prompt_is_custom else ""
logger.info(
"NSFW video mode resolved: "
f"{task_tag}, mode={task_mode}, "
f"video_prompt_custom={video_prompt_is_custom}, "
f"video_prompt_raw_provided={bool(raw_video_prompt)}"
)
try:
raw_video_response: Dict[str, Any] = {}
video_content = ""
video_url = ""
poster_url = ""
# 优先直接用 parentPostId 生视频,避免额外上传图片。
used_parent_post = False
if parent_post_id:
source_image_url = _build_imagine_public_url(parent_post_id)
logger.info(
"NSFW video parentPostId path: "
f"{task_tag}, parent_post_id={parent_post_id}, source_image_url={source_image_url}"
)
used_parent_post = True
for p_attempt in range(1, data.parent_post_empty_retry + 1):
response_stream = await VideoService().generate_from_parent_post(
token=task_token,
prompt=task_prompt,
parent_post_id=parent_post_id,
source_image_url=source_image_url,
aspect_ratio=ratio,
video_length=effective_video_length,
resolution=effective_resolution,
preset=task_preset,
)
raw_video_response = await VideoCollectProcessor(
"grok-imagine-1.0-video",
task_token,
upscale_on_finish=data.upscale,
).process(response_stream)
video_content = (
raw_video_response.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
video_url, poster_url = _extract_video_urls(video_content)
if video_url:
break
logger.warning(
"NSFW video parentPostId empty result, retrying parentPost mode: "
f"{task_tag}, attempt={p_attempt}/{data.parent_post_empty_retry}"
)
if p_attempt < data.parent_post_empty_retry:
await asyncio.sleep(data.parent_post_empty_retry_delay)
else:
logger.warning(
f"NSFW video parentPostId unavailable, fallback upload path: {task_tag}"
)
response_stream = await VideoService().generate_from_image(
token=task_token,
prompt=task_prompt,
image_url=image_url,
aspect_ratio=ratio,
video_length=effective_video_length,
resolution=effective_resolution,
preset=task_preset,
)
raw_video_response = await VideoCollectProcessor(
"grok-imagine-1.0-video",
task_token,
upscale_on_finish=data.upscale,
).process(response_stream)
video_content = (
raw_video_response.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
video_url, poster_url = _extract_video_urls(video_content)
# parentPostId 路径无产出时,自动降级到上传图片路径重试一次
if used_parent_post and not video_url:
if data.text_video_fallback:
logger.warning(
"NSFW video parentPostId empty result, fallback text2video path: "
f"{task_tag}"
)
text_video_prompt = (
f"{_build_imagine_public_url(parent_post_id)} {task_prompt}".strip()
)
response_stream = await VideoService().generate(
token=task_token,
prompt=text_video_prompt,
aspect_ratio=ratio,
video_length=effective_video_length,
resolution_name=effective_resolution,
preset=task_preset,
)
raw_video_response = await VideoCollectProcessor(
"grok-imagine-1.0-video",
task_token,
upscale_on_finish=data.upscale,
).process(response_stream)
video_content = (
raw_video_response.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
video_url, poster_url = _extract_video_urls(video_content)
if data.parent_post_only:
if not video_url:
logger.warning(
"NSFW video parentPostId empty result, keep parentPost-only mode: "
f"{task_tag}"
)
else:
if not video_url:
logger.warning(
f"NSFW video parentPostId empty result, fallback upload path: {task_tag}"
)
response_stream = await VideoService().generate_from_image(
token=task_token,
prompt=task_prompt,
image_url=image_url,
aspect_ratio=ratio,
video_length=effective_video_length,
resolution=effective_resolution,
preset=task_preset,
)
raw_video_response = await VideoCollectProcessor(
"grok-imagine-1.0-video",
task_token,
upscale_on_finish=data.upscale,
).process(response_stream)
video_content = (
raw_video_response.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
video_url, poster_url = _extract_video_urls(video_content)
if used_parent_post and data.parent_post_only and not video_url:
return {
"index": index,
"image_url": image_url,
"parent_post_id": parent_post_id,
"error": "parent_post_video_not_ready",
"url": "",
"poster_url": "",
"content": video_content,
"raw_response": raw_video_response,
}
return {
"index": index,
"image_url": image_url,
"content": video_content,
"url": _clean_url(video_url),
"poster_url": _clean_url(poster_url),
"raw_response": raw_video_response,
"parent_post_id": parent_post_id,
}
except Exception as e:
logger.warning(f"NSFW video failed: {task_tag}, error={e}")
return {
"index": index,
"image_url": image_url,
"parent_post_id": parent_post_id,
"error": str(e),
"url": "",
"poster_url": "",
"content": "",
}
video_tasks = [
_gen_video(image_url, idx + 1, video_task_tokens[idx])
for idx, image_url in enumerate(images)
]
video_results = await asyncio.gather(*video_tasks, return_exceptions=False)
response_payload["videos"] = video_results
# 只输出干净链接,避免原始 JSON 被客户端二次提取造成重复脏链接。
clean_links: List[str] = []
seen_links: set[str] = set()
for u in images:
cu = _clean_url(u)
if cu and cu not in seen_links:
seen_links.add(cu)
clean_links.append(cu)
for item in video_results:
for k in ("url", "poster_url"):
cu = _clean_url(item.get(k, ""))
if cu and cu not in seen_links:
seen_links.add(cu)
clean_links.append(cu)
lines = ["返回链接:"]
for idx, u in enumerate(clean_links, 1):
lines.append(f"{idx}. {u}")
response_payload["result"] = "\n".join(lines)
return response_payload
@router.post("/nsfw")
async def generate_nsfw(data: NSFWRequest, request: Request) -> Dict[str, Any]:
task = asyncio.create_task(_generate_nsfw_inner(data))
try:
while True:
done, _ = await asyncio.wait({task}, timeout=0.2)
if done:
break
if await request.is_disconnected():
logger.info(
"NSFW interrupted by client disconnect: "
f"aspect_ratio={data.aspect_ratio}, ratio={data.ratio}, size={data.size}, "
f"image_parallel={data.image_parallel}, "
f"video_parallel={data.video_parallel}"
)
task.cancel()
raise HTTPException(status_code=499, detail="client_closed")
return await task
except asyncio.CancelledError:
logger.info("NSFW task cancelled after disconnect")
raise HTTPException(status_code=499, detail="client_closed")
finally:
if not task.done():
logger.info("NSFW force-cancel unfinished task")
task.cancel()
+636 -7
View File
@@ -1,7 +1,10 @@
import asyncio
import contextlib
import re
import time
import uuid
from typing import Optional, List, Dict, Any
from urllib.parse import urlparse
import orjson
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
@@ -13,6 +16,7 @@ from app.core.config import get_config
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.token.manager import get_token_manager
@@ -21,6 +25,136 @@ 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"}
IMAGINE_IMAGE_TOKEN_TTL = 7200
_IMAGINE_IMAGE_TOKENS: dict[str, dict] = {}
_IMAGINE_IMAGE_TOKENS_LOCK = asyncio.Lock()
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"
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 _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:
if raw_b64.startswith("data:"):
return raw_b64
compact = re.sub(r"\s+", "", raw_b64)
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")
return f"data:image/png;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",
)
async def _clean_sessions(now: float) -> None:
@@ -33,6 +167,55 @@ async def _clean_sessions(now: float) -> None:
_IMAGINE_SESSIONS.pop(key, None)
async def _clean_image_tokens(now: float) -> None:
expired = [
key
for key, info in _IMAGINE_IMAGE_TOKENS.items()
if now - float(info.get("created_at") or 0) > IMAGINE_IMAGE_TOKEN_TTL
]
for key in expired:
_IMAGINE_IMAGE_TOKENS.pop(key, None)
async def _bind_image_token(parent_post_id: str, token: str) -> None:
image_id = _extract_parent_post_id_from_url(parent_post_id)
token_text = str(token or "").strip()
if not image_id or not token_text:
return
now = time.time()
async with _IMAGINE_IMAGE_TOKENS_LOCK:
await _clean_image_tokens(now)
_IMAGINE_IMAGE_TOKENS[image_id] = {
"token": token_text,
"created_at": now,
}
async def _get_bound_image_token(parent_post_id: str) -> Optional[str]:
image_id = _extract_parent_post_id_from_url(parent_post_id)
if not image_id:
return None
now = time.time()
async with _IMAGINE_IMAGE_TOKENS_LOCK:
await _clean_image_tokens(now)
info = _IMAGINE_IMAGE_TOKENS.get(image_id)
if not info:
return None
token = str(info.get("token") or "").strip()
return token or 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
@@ -223,6 +406,11 @@ async def public_imagine_ws(websocket: WebSocket):
continue
if isinstance(payload, dict):
payload.setdefault("run_id", run_id)
parent_post_id = _extract_parent_post_id_from_payload(
payload
)
if parent_post_id:
await _bind_image_token(parent_post_id, token)
await _send(payload)
else:
images = [img for img in result.data if img and img != "error"]
@@ -292,9 +480,7 @@ async def public_imagine_ws(websocket: WebSocket):
}
)
continue
aspect_ratio = resolve_aspect_ratio(
str(payload.get("aspect_ratio") or "2:3").strip() or "2:3"
)
aspect_ratio = _normalize_imagine_ratio(payload.get("aspect_ratio"))
nsfw = payload.get("nsfw")
if nsfw is not None:
nsfw = bool(nsfw)
@@ -354,14 +540,13 @@ async def public_imagine_sse(
if session:
prompt = str(session.get("prompt") or "").strip()
ratio = str(session.get("aspect_ratio") or "2:3").strip() or "2:3"
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 = str(aspect_ratio or "2:3").strip() or "2:3"
ratio = resolve_aspect_ratio(ratio)
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")
@@ -386,6 +571,10 @@ async def public_imagine_sse(
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)
@@ -428,6 +617,11 @@ async def public_imagine_sse(
continue
if isinstance(payload, dict):
payload.setdefault("run_id", run_id)
parent_post_id = _extract_parent_post_id_from_payload(
payload
)
if parent_post_id:
await _bind_image_token(parent_post_id, token)
yield f"data: {orjson.dumps(payload).decode()}\n\n"
else:
images = [img for img in result.data if img and img != "error"]
@@ -490,11 +684,213 @@ 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 = resolve_aspect_ratio(str(data.aspect_ratio or "2:3").strip() or "2:3")
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.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 _get_bound_image_token(parent_post_id)
if token:
pool_name = token_mgr.get_pool_name_for_token(token) or "-"
logger.info(
"Imagine edit token bound hit: "
f"parent_post_id={parent_post_id}, pool={pool_name}, token={_mask_token(token)}"
)
else:
for pool_name in ModelService.pool_candidates_for_model(model_id):
token = token_mgr.get_token(pool_name)
if token:
break
if token:
logger.info(
"Imagine edit token bound miss, fallback pool token: "
f"parent_post_id={parent_post_id}, token={_mask_token(token)}"
)
if not token:
raise HTTPException(
status_code=429,
detail="No available tokens. Please try again later.",
)
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])
generated_parent_post_id = _extract_parent_post_id_from_url(image_url)
current_parent_post_id = generated_parent_post_id or parent_post_id
if current_parent_post_id:
await _bind_image_token(current_parent_post_id, token)
current_source_image_url = _resolve_source_image_url(
image_url=image_url,
parent_post_id=current_parent_post_id,
fallback_source_image_url=source_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:
await queue.put(
{
"type": "error",
"payload": {
"message": str(e),
},
}
)
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"},
)
return await _run_once(progress_cb=None)
class ImagineStopRequest(BaseModel):
task_ids: List[str]
@@ -503,3 +899,236 @@ class ImagineStopRequest(BaseModel):
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
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 _get_bound_image_token(parent_post_id)
if token:
pool_name = token_mgr.get_pool_name_for_token(token) or "-"
logger.info(
"Imagine workbench token bound hit: "
f"parent_post_id={parent_post_id}, pool={pool_name}, token={_mask_token(token)}"
)
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.",
)
async def _run_once(progress_cb=None):
started_at = time.time()
edit_service = ImageEditService()
source_image_url = (data.source_image_url or "").strip()
if use_parent_mode:
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)
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=source_image_url,
response_format="url",
stream=False,
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,
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")
image_url = str(images[0])
generated_parent_post_id = _extract_parent_post_id_from_url(image_url)
current_parent_post_id = generated_parent_post_id or parent_post_id
if current_parent_post_id:
await _bind_image_token(current_parent_post_id, token)
current_source_image_url = _resolve_source_image_url(
image_url=image_url,
parent_post_id=current_parent_post_id,
fallback_source_image_url=source_image_url,
)
elapsed_ms = int((time.time() - started_at) * 1000)
return {
"created": int(time.time()),
"data": [{"url": image_url}],
"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:
await queue.put(
{
"type": "error",
"payload": {
"message": str(e),
},
}
)
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"},
)
return await _run_once(progress_cb=None)
+94 -9
View File
@@ -1,12 +1,18 @@
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import asyncio
import contextlib
import uuid
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.core.auth import verify_public_key
from app.core.logger import logger
from app.services.grok.services.chat import ChatService
router = APIRouter()
_PROMPT_ENHANCE_TASKS: dict[str, asyncio.Task] = {}
_PROMPT_ENHANCE_TASKS_LOCK = asyncio.Lock()
SYSTEM_PROMPT = """你是一个智能视觉提示词增强器 + 敏感概念翻译器,专为Grok Imagine(FLUX)优化。
@@ -58,6 +64,21 @@ SYSTEM_PROMPT = """你是一个智能视觉提示词增强器 + 敏感概念翻
class PromptEnhanceRequest(BaseModel):
prompt: str = Field(..., description="原始提示词")
temperature: float = Field(0.7, ge=0, le=2)
request_id: Optional[str] = Field(None, description="enhance request id")
class PromptEnhanceStopRequest(BaseModel):
request_id: str = Field(..., description="enhance request id")
async def _register_prompt_enhance_task(request_id: str, task: asyncio.Task) -> None:
async with _PROMPT_ENHANCE_TASKS_LOCK:
_PROMPT_ENHANCE_TASKS[request_id] = task
async def _pop_prompt_enhance_task(request_id: str) -> Optional[asyncio.Task]:
async with _PROMPT_ENHANCE_TASKS_LOCK:
return _PROMPT_ENHANCE_TASKS.pop(request_id, None)
def _extract_text(result: Dict[str, Any]) -> str:
@@ -84,10 +105,15 @@ def _extract_text(result: Dict[str, Any]) -> str:
@router.post("/prompt/enhance", dependencies=[Depends(verify_public_key)])
async def public_prompt_enhance(data: PromptEnhanceRequest):
async def public_prompt_enhance(data: PromptEnhanceRequest, request: Request):
raw_prompt = (data.prompt or "").strip()
if not raw_prompt:
raise HTTPException(status_code=400, detail="prompt is required")
request_id = (
(data.request_id or "").strip()
or (request.headers.get("x-enhance-request-id") or "").strip()
or uuid.uuid4().hex
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
@@ -96,17 +122,76 @@ async def public_prompt_enhance(data: PromptEnhanceRequest):
"content": f"请基于下面的原始提示词进行增强,严格遵循你的工作流程与输出格式。\n\n原始提示词:\n{raw_prompt}",
},
]
result = await ChatService.completions(
model="grok-4.1-fast",
messages=messages,
stream=False,
temperature=float(data.temperature or 0.7),
top_p=0.95,
task = asyncio.create_task(
ChatService.completions(
model="grok-4.1-fast",
messages=messages,
stream=False,
temperature=float(data.temperature or 0.7),
top_p=0.95,
)
)
await _register_prompt_enhance_task(request_id, task)
logger.info(
"Prompt enhance task registered: "
f"request_id={request_id}, prompt_len={len(raw_prompt)}"
)
try:
while True:
done, _ = await asyncio.wait({task}, timeout=0.2)
if done:
break
if await request.is_disconnected():
logger.info(
"Prompt enhance interrupted by client disconnect: "
f"request_id={request_id}, prompt_len={len(raw_prompt)}"
)
task.cancel()
raise HTTPException(status_code=499, detail="client_closed")
result = await task
except asyncio.CancelledError:
logger.info(
"Prompt enhance upstream task cancelled: "
f"request_id={request_id}, prompt_len={len(raw_prompt)}"
)
raise HTTPException(status_code=499, detail="client_closed")
finally:
task_ref = await _pop_prompt_enhance_task(request_id)
if task_ref and not task_ref.done():
logger.info(
"Prompt enhance force-cancel unfinished upstream task: "
f"request_id={request_id}"
)
task_ref.cancel()
with contextlib.suppress(Exception):
await task_ref
enhanced = _extract_text(result if isinstance(result, dict) else {})
if not enhanced:
raise HTTPException(status_code=502, detail="upstream returned empty content")
return {
"enhanced_prompt": enhanced,
"model": "grok-4.1-fast",
"request_id": request_id,
}
@router.post("/prompt/enhance/stop", dependencies=[Depends(verify_public_key)])
async def public_prompt_enhance_stop(data: PromptEnhanceStopRequest):
request_id = (data.request_id or "").strip()
if not request_id:
raise HTTPException(status_code=400, detail="request_id is required")
task = await _pop_prompt_enhance_task(request_id)
if not task:
logger.info(f"Prompt enhance stop ignored: request_id={request_id}, reason=not_found")
return {"status": "not_found", "request_id": request_id}
if task.done():
logger.info(
f"Prompt enhance stop ignored: request_id={request_id}, reason=already_done"
)
return {"status": "already_done", "request_id": request_id}
logger.info(f"Prompt enhance stop requested: request_id={request_id}, action=cancel")
task.cancel()
return {"status": "cancelling", "request_id": request_id}
+7 -2
View File
@@ -91,12 +91,17 @@ class AuthenticationException(AppException):
class UpstreamException(AppException):
"""上游服务错误"""
def __init__(self, message: str, details: Any = None):
def __init__(
self,
message: str,
details: Any = None,
status_code: int = 502,
):
super().__init__(
message=message,
error_type=ErrorType.SERVER.value,
code="upstream_error",
status_code=502,
status_code=status_code,
)
self.details = details
+2
View File
@@ -503,6 +503,7 @@ class StreamProcessor(proc_base.BaseProcessor):
yield "data: [DONE]\n\n"
except asyncio.CancelledError:
logger.debug("Stream cancelled by client", extra={"model": self.model})
raise
except StreamIdleTimeoutError as e:
raise UpstreamException(
message=f"Stream idle timeout after {e.idle_seconds}s",
@@ -644,6 +645,7 @@ class CollectProcessor(proc_base.BaseProcessor):
except asyncio.CancelledError:
logger.debug("Collect cancelled by client", extra={"model": self.model})
raise
except StreamIdleTimeoutError as e:
logger.warning(f"Collect idle timeout: {e}", extra={"model": self.model})
except RequestsError as e:
+322 -37
View File
@@ -6,7 +6,7 @@ import asyncio
import random
import re
from dataclasses import dataclass
from typing import AsyncGenerator, AsyncIterable, List, Union, Any
from typing import AsyncGenerator, AsyncIterable, List, Union, Any, Callable
import orjson
from curl_cffi.requests.errors import RequestsError
@@ -43,6 +43,26 @@ class ImageEditResult:
class ImageEditService:
"""Image edit orchestration service."""
async def _emit_progress(
self,
progress_cb: Callable[[str, dict], Any] | None,
event: str,
progress: int,
message: str,
**extra: Any,
) -> None:
if not progress_cb:
return
payload = {"progress": int(progress), "message": message}
if extra:
payload.update(extra)
try:
result = progress_cb(event, payload)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.debug(f"Image edit progress callback ignored: {e}")
async def edit(
self,
*,
@@ -54,6 +74,7 @@ class ImageEditService:
n: int,
response_format: str,
stream: bool,
progress_cb: Callable[[str, dict], Any] | None = None,
) -> ImageEditResult:
max_token_retries = int(get_config("retry.max_retry"))
tried_tokens: set[str] = set()
@@ -75,11 +96,43 @@ class ImageEditService:
)
tried_tokens.add(current_token)
await self._emit_progress(
progress_cb,
"token_selected",
8,
"已匹配编辑令牌",
)
try:
await self._emit_progress(
progress_cb,
"upload_start",
16,
"正在上传输入图片",
)
image_urls = await self._upload_images(images, current_token)
await self._emit_progress(
progress_cb,
"upload_done",
30,
f"图片上传完成,共 {len(image_urls)} 张",
count=len(image_urls),
)
await self._emit_progress(
progress_cb,
"pre_create_start",
36,
"正在创建媒体帖子",
)
parent_post_id = await self._get_parent_post_id(
current_token, image_urls
)
await self._emit_progress(
progress_cb,
"pre_create_done",
42,
"媒体帖子创建完成",
parent_post_id=parent_post_id or "",
)
model_config_override = {
"modelMap": {
@@ -105,6 +158,7 @@ class ImageEditService:
stream=True,
tool_overrides=tool_overrides,
model_config_override=model_config_override,
image_generation_count=1,
)
processor = ImageStreamProcessor(
model_info.model_id,
@@ -122,14 +176,27 @@ class ImageEditService:
),
)
await self._emit_progress(
progress_cb,
"chat_request_start",
48,
"已提交编辑请求",
parent_post_id=parent_post_id or "",
)
images_out = await self._collect_images(
token=current_token,
prompt=prompt,
model_info=model_info,
n=n,
response_format=response_format,
tool_overrides=tool_overrides,
model_config_override=model_config_override,
progress_cb=progress_cb,
)
await self._emit_progress(
progress_cb,
"collect_done",
92,
f"已收到 {len(images_out)} 张结果",
)
try:
effort = (
@@ -149,6 +216,199 @@ class ImageEditService:
last_error = e
if rate_limited(e):
await token_mgr.mark_rate_limited(current_token)
await self._emit_progress(
progress_cb,
"rate_limited",
16,
"令牌限流,正在切换重试",
)
logger.warning(
f"Token {current_token[:10]}... rate limited (429), "
f"trying next token (attempt {attempt + 1}/{max_token_retries})"
)
continue
raise
if last_error:
raise last_error
raise AppException(
message="No available tokens. Please try again later.",
error_type=ErrorType.RATE_LIMIT.value,
code="rate_limit_exceeded",
status_code=429,
)
async def edit_with_parent_post(
self,
*,
token_mgr: Any,
token: str,
model_info: Any,
prompt: str,
parent_post_id: str,
source_image_url: str,
response_format: str,
stream: bool,
progress_cb: Callable[[str, dict], Any] | None = None,
) -> ImageEditResult:
"""基于 parentPostId 进行编辑,不上传图片。"""
max_token_retries = int(get_config("retry.max_retry"))
tried_tokens: set[str] = set()
last_error: Exception | None = None
for attempt in range(max_token_retries):
preferred = token if attempt == 0 else None
current_token = await pick_token(
token_mgr, model_info.model_id, tried_tokens, preferred=preferred
)
if not current_token:
if last_error:
raise last_error
raise AppException(
message="No available tokens. Please try again later.",
error_type=ErrorType.RATE_LIMIT.value,
code="rate_limit_exceeded",
status_code=429,
)
tried_tokens.add(current_token)
await self._emit_progress(
progress_cb,
"token_selected",
8,
"已匹配编辑令牌",
)
try:
image_ref = (source_image_url or "").strip()
if not image_ref:
image_ref = (
f"https://imagine-public.x.ai/imagine-public/images/{parent_post_id}.jpg"
)
effective_parent_post_id = parent_post_id
await self._emit_progress(
progress_cb,
"pre_create_start",
18,
"正在创建媒体帖子",
parent_post_id=parent_post_id,
)
try:
# 与 nsfw 的 parentPostId 链路保持一致:先预创建 media post
# 这样上游在 imagine-image-edit 校验 parentPostId 时更稳定。
image_post_id = await VideoService().create_image_post(
current_token, image_ref
)
if image_post_id:
effective_parent_post_id = image_post_id
logger.info(
"Image edit(parentPostId) pre-create media post done: "
f"parent_post_id={parent_post_id}, "
f"image_post_id={effective_parent_post_id}, media_url={image_ref}"
)
await self._emit_progress(
progress_cb,
"pre_create_done",
34,
"媒体帖子创建完成",
image_post_id=effective_parent_post_id,
)
except Exception as e:
logger.warning(
"Image edit(parentPostId) pre-create media post failed, continue anyway: "
f"parent_post_id={parent_post_id}, media_url={image_ref}, error={e}"
)
await self._emit_progress(
progress_cb,
"pre_create_failed",
28,
"媒体帖子创建失败,继续请求",
)
model_config_override = {
"modelMap": {
"imageEditModel": "imagine",
"imageEditModelConfig": {
"imageReferences": [image_ref],
"parentPostId": effective_parent_post_id,
},
}
}
tool_overrides = {"imageGen": True}
if stream:
response = await GrokChatService().chat(
token=current_token,
message=prompt,
model=model_info.grok_model,
mode=None,
stream=True,
tool_overrides=tool_overrides,
model_config_override=model_config_override,
image_generation_count=1,
)
processor = ImageStreamProcessor(
model_info.model_id,
current_token,
n=1,
response_format=response_format,
)
return ImageEditResult(
stream=True,
data=wrap_stream_with_usage(
processor.process(response),
token_mgr,
current_token,
model_info.model_id,
),
)
await self._emit_progress(
progress_cb,
"chat_request_start",
48,
"已提交编辑请求",
parent_post_id=effective_parent_post_id,
)
images_out = await self._collect_images(
token=current_token,
prompt=prompt,
model_info=model_info,
response_format=response_format,
tool_overrides=tool_overrides,
model_config_override=model_config_override,
progress_cb=progress_cb,
)
await self._emit_progress(
progress_cb,
"collect_done",
92,
f"已收到 {len(images_out)} 张结果",
)
try:
effort = (
EffortType.HIGH
if (model_info and model_info.cost.value == "high")
else EffortType.LOW
)
await token_mgr.consume(current_token, effort)
logger.debug(
"Image edit(parentPostId) completed, "
f"recorded usage (effort={effort.value})"
)
except Exception as e:
logger.warning(f"Failed to record image edit(parentPostId) usage: {e}")
return ImageEditResult(stream=False, data=images_out)
except UpstreamException as e:
last_error = e
if rate_limited(e):
await token_mgr.mark_rate_limited(current_token)
await self._emit_progress(
progress_cb,
"rate_limited",
16,
"令牌限流,正在切换重试",
)
logger.warning(
f"Token {current_token[:10]}... rate limited (429), "
f"trying next token (attempt {attempt + 1}/{max_token_retries})"
@@ -222,13 +482,11 @@ class ImageEditService:
token: str,
prompt: str,
model_info: Any,
n: int,
response_format: str,
tool_overrides: dict,
model_config_override: dict,
progress_cb: Callable[[str, dict], Any] | None = None,
) -> List[str]:
calls_needed = (n + 1) // 2
async def _call_edit():
response = await GrokChatService().chat(
token=token,
@@ -238,47 +496,23 @@ class ImageEditService:
stream=True,
tool_overrides=tool_overrides,
model_config_override=model_config_override,
image_generation_count=1,
)
processor = ImageCollectProcessor(
model_info.model_id, token, response_format=response_format
model_info.model_id,
token,
response_format=response_format,
progress_cb=progress_cb,
)
return await processor.process(response)
last_error: Exception | None = None
rate_limit_error: Exception | None = None
if calls_needed == 1:
all_images = await _call_edit()
else:
tasks = [_call_edit() for _ in range(calls_needed)]
results = await asyncio.gather(*tasks, return_exceptions=True)
all_images: List[str] = []
for result in results:
if isinstance(result, Exception):
logger.error(f"Concurrent call failed: {result}")
last_error = result
if rate_limited(result):
rate_limit_error = result
elif isinstance(result, list):
all_images.extend(result)
all_images = await _call_edit()
if not all_images:
if rate_limit_error:
raise rate_limit_error
if last_error:
raise last_error
raise UpstreamException(
"Image edit returned no results", details={"error": "empty_result"}
)
if len(all_images) >= n:
return all_images[:n]
selected_images = all_images.copy()
while len(selected_images) < n:
selected_images.append("error")
return selected_images
return [all_images[0]]
class ImageStreamProcessor(BaseProcessor):
@@ -436,16 +670,39 @@ class ImageStreamProcessor(BaseProcessor):
class ImageCollectProcessor(BaseProcessor):
"""HTTP image non-stream processor."""
def __init__(self, model: str, token: str = "", response_format: str = "b64_json"):
def __init__(
self,
model: str,
token: str = "",
response_format: str = "b64_json",
progress_cb: Callable[[str, dict], Any] | None = None,
):
if response_format == "base64":
response_format = "b64_json"
super().__init__(model, token)
self.response_format = response_format
self.progress_cb = progress_cb
async def _emit_progress(
self, event: str, progress: int, message: str, **extra: Any
) -> None:
if not self.progress_cb:
return
payload = {"progress": int(progress), "message": message}
if extra:
payload.update(extra)
try:
result = self.progress_cb(event, payload)
if asyncio.iscoroutine(result):
await result
except Exception:
pass
async def process(self, response: AsyncIterable[bytes]) -> List[str]:
"""Process and collect images."""
images = []
idle_timeout = get_config("image.stream_timeout")
chat_connected_emitted = False
try:
async for line in _with_idle_timeout(response, idle_timeout, self.model):
@@ -458,6 +715,13 @@ class ImageCollectProcessor(BaseProcessor):
continue
resp = data.get("result", {}).get("response", {})
if not chat_connected_emitted and resp:
chat_connected_emitted = True
await self._emit_progress(
"chat_connected",
60,
"模型连接成功,正在生成图片",
)
if mr := resp.get("modelResponse"):
if urls := _collect_images(mr):
@@ -466,6 +730,13 @@ class ImageCollectProcessor(BaseProcessor):
processed = await self.process_url(url, "image")
if processed:
images.append(processed)
progress = min(90, 64 + len(images) * 12)
await self._emit_progress(
"image_downloaded",
progress,
f"已下载第 {len(images)} 张图片",
count=len(images),
)
continue
try:
dl_service = self._get_dl()
@@ -478,6 +749,13 @@ class ImageCollectProcessor(BaseProcessor):
else:
b64 = base64_data
images.append(b64)
progress = min(90, 64 + len(images) * 12)
await self._emit_progress(
"image_downloaded",
progress,
f"已下载第 {len(images)} 张图片",
count=len(images),
)
except Exception as e:
logger.warning(
f"Failed to convert image to base64, falling back to URL: {e}"
@@ -485,6 +763,13 @@ class ImageCollectProcessor(BaseProcessor):
processed = await self.process_url(url, "image")
if processed:
images.append(processed)
progress = min(90, 64 + len(images) * 12)
await self._emit_progress(
"image_downloaded",
progress,
f"已下载第 {len(images)} 张图片",
count=len(images),
)
except asyncio.CancelledError:
logger.debug("Image collect cancelled by client")
+541 -85
View File
@@ -33,7 +33,7 @@ from app.services.grok.utils.retry import rate_limited
from app.services.reverse.app_chat import AppChatReverse
from app.services.reverse.media_post import MediaPostReverse
from app.services.reverse.video_upscale import VideoUpscaleReverse
from app.services.token.manager import BASIC_POOL_NAME
from app.services.reverse.assets_list import AssetsListReverse
_VIDEO_SEMAPHORE = None
_VIDEO_SEM_VALUE = 0
@@ -48,12 +48,132 @@ def _get_video_semaphore() -> asyncio.Semaphore:
return _VIDEO_SEMAPHORE
def _token_tag(token: str) -> str:
raw = token[4:] if token.startswith("sso=") else token
if not raw:
return "empty"
if len(raw) <= 14:
return raw
return f"{raw[:6]}...{raw[-6:]}"
class VideoService:
"""Video generation service."""
def __init__(self):
self.timeout = None
@staticmethod
def is_meaningful_video_prompt(prompt: str) -> bool:
"""判断提示词是否属于“有效自定义视频提示词”。
以下场景视为非自定义(返回 False):
- 空提示词
- 仅“让它动起来/生成视频/animate this”等泛化短提示
"""
text = (prompt or "").strip().lower()
if not text:
return False
# 统一空白与常见收尾标点
text = re.sub(r"\s+", " ", text).strip(
" \t\r\n.,!?;:,。!?;:'\"`~()[]{}<>《》「」【】"
)
key = re.sub(r"\s+", "", text)
if not text:
return False
generic_en = {
"animate",
"animate this",
"animate this image",
"make it move",
"make this move",
"generate video",
"make video",
"make a video",
"create video",
"turn this into a video",
"turn it into a video",
"video",
}
generic_zh = {
"动起来",
"让它动起来",
"让图片动起来",
"让这张图动起来",
"生成视频",
"生成一个视频",
"生成一段视频",
"做成视频",
"做个视频",
"制作视频",
"变成视频",
"变成一个视频",
"视频",
}
if text in generic_en or key in generic_zh:
return False
# 英文泛化短句:please animate this / please generate a video
if re.fullmatch(r"(please\s+)?animate(\s+this(\s+image)?)?", text):
return False
if re.fullmatch(
r"(please\s+)?(make|create|generate)\s+(a\s+)?video", text
):
return False
# 中文泛化短句:请让它动起来 / 帮我生成视频 / 把这张图做成视频
if re.fullmatch(
r"(请|请你|帮我|麻烦你)?(把)?(它|图片|这张图)?"
r"(动起来|生成视频|做成视频|制作视频)(吧|一下|下)?",
key,
):
return False
return True
@staticmethod
def _build_video_message(
prompt: str,
preset: str = "normal",
source_image_url: str = "",
) -> str:
"""构造视频请求 message:
- 有提示词:统一走 custom,并发送 image_url + prompt + mode
- 无提示词:统一走 spicy(忽略 preset)
"""
prompt_text = (prompt or "").strip()
if not VideoService.is_meaningful_video_prompt(prompt_text):
prompt_text = ""
image_core = (source_image_url or "").strip()
if prompt_text:
if image_core:
return f"{image_core} {prompt_text} --mode=custom"
return f"{prompt_text} --mode=custom"
mode_flag = "--mode=extremely-spicy-or-crazy"
if image_core:
return f"{image_core} {mode_flag}"
return mode_flag
@staticmethod
def _build_imagine_public_url(parent_post_id: str) -> str:
return f"https://imagine-public.x.ai/imagine-public/images/{parent_post_id}.jpg"
@staticmethod
def _is_moderated_line(line: bytes) -> bool:
text = _normalize_line(line)
if not text:
return False
try:
data = orjson.loads(text)
except Exception:
return False
resp = data.get("result", {}).get("response", {})
video_resp = resp.get("streamingVideoGenerationResponse", {})
return bool(video_resp.get("moderated") is True)
async def create_post(
self,
token: str,
@@ -108,17 +228,17 @@ class VideoService:
preset: str = "normal",
) -> AsyncGenerator[bytes, None]:
"""Generate video."""
token_tag = _token_tag(token)
mode = (
"custom"
if VideoService.is_meaningful_video_prompt(prompt)
else "extremely-spicy-or-crazy"
)
logger.info(
f"Video generation: prompt='{prompt[:50]}...', ratio={aspect_ratio}, length={video_length}s, preset={preset}"
f"Video generation: token={token_tag}, prompt='{prompt[:50]}...', ratio={aspect_ratio}, length={video_length}s, mode={mode}"
)
post_id = await self.create_post(token, prompt)
mode_map = {
"fun": "--mode=extremely-crazy",
"normal": "--mode=normal",
"spicy": "--mode=extremely-spicy-or-crazy",
}
mode_flag = mode_map.get(preset, "--mode=custom")
message = f"{prompt} {mode_flag}"
message = self._build_video_message(prompt=prompt, preset=preset)
model_config_override = {
"modelMap": {
"videoGenModelConfig": {
@@ -126,34 +246,58 @@ class VideoService:
"parentPostId": post_id,
"resolutionName": resolution_name,
"videoLength": video_length,
"isVideoEdit": False,
}
}
}
moderated_max_retry = max(1, int(get_config("video.moderated_max_retry", 5)))
async def _stream():
session = AsyncSession()
try:
async with _get_video_semaphore():
stream_response = await AppChatReverse.request(
session,
token,
message=message,
model="grok-3",
tool_overrides={"videoGen": True},
model_config_override=model_config_override,
)
logger.info(f"Video generation started: post_id={post_id}")
async for line in stream_response:
yield line
except Exception as e:
for attempt in range(1, moderated_max_retry + 1):
session = AsyncSession()
moderated_hit = False
try:
await session.close()
except Exception:
pass
logger.error(f"Video generation error: {e}")
if isinstance(e, AppException):
raise
raise UpstreamException(f"Video generation error: {str(e)}")
async with _get_video_semaphore():
stream_response = await AppChatReverse.request(
session,
token,
message=message,
model="grok-3",
tool_overrides={"videoGen": True},
model_config_override=model_config_override,
)
logger.info(
f"Video generation started: token={token_tag}, post_id={post_id}, attempt={attempt}/{moderated_max_retry}"
)
async for line in stream_response:
if self._is_moderated_line(line):
moderated_hit = True
logger.warning(
f"Video generation moderated: token={token_tag}, retry {attempt}/{moderated_max_retry}"
)
break
yield line
if not moderated_hit:
return
if attempt < moderated_max_retry:
await asyncio.sleep(1.2)
continue
raise UpstreamException(
"Video blocked by moderation",
status_code=400,
details={"moderated": True, "attempts": moderated_max_retry},
)
except Exception as e:
logger.error(f"Video generation error: {e}")
if isinstance(e, AppException):
raise
raise UpstreamException(f"Video generation error: {str(e)}")
finally:
try:
await session.close()
except Exception:
pass
return _stream()
@@ -168,17 +312,21 @@ class VideoService:
preset: str = "normal",
) -> AsyncGenerator[bytes, None]:
"""Generate video from image."""
token_tag = _token_tag(token)
mode = (
"custom"
if VideoService.is_meaningful_video_prompt(prompt)
else "extremely-spicy-or-crazy"
)
logger.info(
f"Image to video: prompt='{prompt[:50]}...', image={image_url[:80]}"
f"Image to video: token={token_tag}, prompt='{prompt[:50]}...', image={image_url[:80]}, mode={mode}"
)
post_id = await self.create_image_post(token, image_url)
mode_map = {
"fun": "--mode=extremely-crazy",
"normal": "--mode=normal",
"spicy": "--mode=extremely-spicy-or-crazy",
}
mode_flag = mode_map.get(preset, "--mode=custom")
message = f"{prompt} {mode_flag}"
message = self._build_video_message(
prompt=prompt,
preset=preset,
source_image_url=image_url,
)
model_config_override = {
"modelMap": {
"videoGenModelConfig": {
@@ -186,34 +334,178 @@ class VideoService:
"parentPostId": post_id,
"resolutionName": resolution,
"videoLength": video_length,
"isVideoEdit": False,
}
}
}
moderated_max_retry = max(1, int(get_config("video.moderated_max_retry", 5)))
async def _stream():
session = AsyncSession()
try:
async with _get_video_semaphore():
stream_response = await AppChatReverse.request(
session,
token,
message=message,
model="grok-3",
tool_overrides={"videoGen": True},
model_config_override=model_config_override,
)
logger.info(f"Video generation started: post_id={post_id}")
async for line in stream_response:
yield line
except Exception as e:
for attempt in range(1, moderated_max_retry + 1):
session = AsyncSession()
moderated_hit = False
try:
await session.close()
except Exception:
pass
logger.error(f"Video generation error: {e}")
if isinstance(e, AppException):
raise
raise UpstreamException(f"Video generation error: {str(e)}")
async with _get_video_semaphore():
stream_response = await AppChatReverse.request(
session,
token,
message=message,
model="grok-3",
tool_overrides={"videoGen": True},
model_config_override=model_config_override,
)
logger.info(
f"Video generation started: token={token_tag}, post_id={post_id}, attempt={attempt}/{moderated_max_retry}"
)
async for line in stream_response:
if self._is_moderated_line(line):
moderated_hit = True
logger.warning(
f"Video generation moderated: token={token_tag}, retry {attempt}/{moderated_max_retry}"
)
break
yield line
if not moderated_hit:
return
if attempt < moderated_max_retry:
await asyncio.sleep(1.2)
continue
raise UpstreamException(
"Video blocked by moderation",
status_code=400,
details={"moderated": True, "attempts": moderated_max_retry},
)
except Exception as e:
logger.error(f"Video generation error: {e}")
if isinstance(e, AppException):
raise
raise UpstreamException(f"Video generation error: {str(e)}")
finally:
try:
await session.close()
except Exception:
pass
return _stream()
async def generate_from_parent_post(
self,
token: str,
prompt: str,
parent_post_id: str,
source_image_url: str = "",
aspect_ratio: str = "3:2",
video_length: int = 6,
resolution: str = "480p",
preset: str = "normal",
) -> AsyncGenerator[bytes, None]:
"""Generate video by existing parent post ID (preferred path)."""
token_tag = _token_tag(token)
mode = (
"custom"
if VideoService.is_meaningful_video_prompt(prompt)
else "extremely-spicy-or-crazy"
)
logger.info(
f"ParentPost to video: token={token_tag}, prompt='{prompt[:50]}...', parent_post_id={parent_post_id}"
)
raw_source_image_url = (source_image_url or "").strip()
source_image_url = self._build_imagine_public_url(parent_post_id)
if raw_source_image_url and raw_source_image_url != source_image_url:
logger.info(
"ParentPost source image normalized to imagine-public: "
f"token={token_tag}, parent_post_id={parent_post_id}, "
f"raw_source_image_url={raw_source_image_url}, normalized_source_image_url={source_image_url}"
)
# 对齐官网全链路:先创建 IMAGE 类型 media post,再触发 conversations/new。
# 注意:videoGenModelConfig.parentPostId 仍使用 imagine 的 image_id。
try:
created_image_post_id = await self.create_image_post(token, source_image_url)
logger.info(
"ParentPost pre-create media post done: "
f"parent_post_id={parent_post_id}, image_post_id={created_image_post_id}, "
f"media_url={source_image_url}"
)
except Exception as e:
logger.warning(
"ParentPost pre-create media post failed, continue anyway: "
f"parent_post_id={parent_post_id}, media_url={source_image_url}, error={e}"
)
message = self._build_video_message(
prompt=prompt,
preset=preset,
source_image_url=source_image_url,
)
model_config_override = {
"modelMap": {
"videoGenModelConfig": {
"aspectRatio": aspect_ratio,
"parentPostId": parent_post_id,
"resolutionName": resolution,
"videoLength": video_length,
"isVideoEdit": False,
}
}
}
moderated_max_retry = max(1, int(get_config("video.moderated_max_retry", 5)))
logger.info(
"ParentPost video request prepared: "
f"token={token_tag}, parent_post_id={parent_post_id}, "
f"message_len={len(message)}, has_prompt={bool((prompt or '').strip())}, "
f"resolution={resolution}, video_length={video_length}, ratio={aspect_ratio}, mode={mode}"
)
async def _stream():
for attempt in range(1, moderated_max_retry + 1):
session = AsyncSession()
moderated_hit = False
try:
async with _get_video_semaphore():
stream_response = await AppChatReverse.request(
session,
token,
message=message,
model="grok-3",
tool_overrides={"videoGen": True},
model_config_override=model_config_override,
)
logger.info(
"Video generation started by parentPostId: "
f"token={token_tag}, parent_post_id={parent_post_id}, attempt={attempt}/{moderated_max_retry}"
)
async for line in stream_response:
if self._is_moderated_line(line):
moderated_hit = True
logger.warning(
f"Video generation moderated: token={token_tag}, retry {attempt}/{moderated_max_retry}"
)
break
yield line
if not moderated_hit:
return
if attempt < moderated_max_retry:
await asyncio.sleep(1.2)
continue
raise UpstreamException(
"Video blocked by moderation",
status_code=400,
details={"moderated": True, "attempts": moderated_max_retry},
)
except Exception as e:
logger.error(f"Video generation error: {e}")
if isinstance(e, AppException):
raise
raise UpstreamException(f"Video generation error: {str(e)}")
finally:
try:
await session.close()
except Exception:
pass
return _stream()
@@ -227,6 +519,9 @@ class VideoService:
video_length: int = 6,
resolution: str = "480p",
preset: str = "normal",
parent_post_id: str | None = None,
source_image_url: str | None = None,
preferred_token: str | None = None,
):
"""Video generation entrypoint."""
# Get token via intelligent routing.
@@ -247,37 +542,60 @@ class VideoService:
from app.services.grok.utils.upload import UploadService
prompt, file_attachments, image_attachments = MessageExtractor.extract(messages)
parent_post_id = (parent_post_id or "").strip() or None
source_image_url = (source_image_url or "").strip()
preferred_token = (preferred_token or "").strip()
if preferred_token.startswith("sso="):
preferred_token = preferred_token[4:]
used_tokens: set[str] = set()
for attempt in range(max_token_retries):
# Select token based on video requirements and pool candidates.
pool_candidates = ModelService.pool_candidates_for_model(model)
token_info = token_mgr.get_token_for_video(
resolution=resolution,
video_length=video_length,
pool_candidates=pool_candidates,
)
token = ""
if preferred_token and preferred_token not in used_tokens:
if token_mgr.get_pool_name_for_token(preferred_token):
token = preferred_token
logger.info(
f"Video token routing: preferred bound token -> "
f"token={_token_tag(token)}"
)
else:
used_tokens.add(preferred_token)
logger.warning(
f"Video token routing: preferred token not in pool, fallback to normal routing "
f"(token={_token_tag(preferred_token)})"
)
if not token_info:
if last_error:
raise last_error
raise AppException(
message="No available tokens. Please try again later.",
error_type=ErrorType.RATE_LIMIT.value,
code="rate_limit_exceeded",
status_code=429,
if not token:
# Select token based on video requirements and pool candidates.
pool_candidates = ModelService.pool_candidates_for_model(model)
token_info = token_mgr.get_token_for_video(
resolution=resolution,
video_length=video_length,
pool_candidates=pool_candidates,
exclude=used_tokens,
)
# Extract token string from TokenInfo.
token = token_info.token
if token.startswith("sso="):
token = token[4:]
pool_name = token_mgr.get_pool_name_for_token(token)
should_upscale = resolution == "720p" and pool_name == BASIC_POOL_NAME
if not token_info:
if last_error:
raise last_error
raise AppException(
message="No available tokens. Please try again later.",
error_type=ErrorType.RATE_LIMIT.value,
code="rate_limit_exceeded",
status_code=429,
)
token = token_info.token
if token.startswith("sso="):
token = token[4:]
used_tokens.add(token)
should_upscale = bool(get_config("video.auto_upscale", True))
try:
# Handle image attachments.
image_url = None
if image_attachments:
if (not parent_post_id) and image_attachments:
upload_service = UploadService()
try:
for attach_data in image_attachments:
@@ -292,7 +610,18 @@ class VideoService:
# Generate video.
service = VideoService()
if image_url:
if parent_post_id:
response = await service.generate_from_parent_post(
token=token,
prompt=prompt,
parent_post_id=parent_post_id,
source_image_url=source_image_url,
aspect_ratio=aspect_ratio,
video_length=video_length,
resolution=resolution,
preset=preset,
)
elif image_url:
response = await service.generate_from_image(
token,
prompt,
@@ -347,7 +676,7 @@ class VideoService:
if rate_limited(e):
await token_mgr.mark_rate_limited(token)
logger.warning(
f"Token {token[:10]}... rate limited (429), "
f"Token {_token_tag(token)} rate limited (429), "
f"trying next token (attempt {attempt + 1}/{max_token_retries})"
)
continue
@@ -596,10 +925,83 @@ class VideoCollectProcessor(BaseProcessor):
logger.warning(f"Video upscale failed: {e}")
return video_url
async def _resolve_video_asset_path(self, asset_id: str) -> tuple[str, str]:
"""当流里未返回 videoUrl 时,尝试从 assets 接口反查 key。"""
if not asset_id or not self.token:
return "", ""
retries = 3
delay = 1.5
page_size = 50
max_pages = 20
marker = f"/{asset_id}/"
async with AsyncSession() as session:
for attempt in range(1, retries + 1):
params = {
"pageSize": page_size,
"orderBy": "ORDER_BY_LAST_USE_TIME",
"source": "SOURCE_ANY",
"isLatest": "true",
}
page_token = ""
page_count = 0
try:
while True:
if page_token:
params["pageToken"] = page_token
else:
params.pop("pageToken", None)
response = await AssetsListReverse.request(
session, self.token, params
)
data = response.json() if response is not None else {}
assets = data.get("assets", []) if isinstance(data, dict) else []
for asset in assets:
if not isinstance(asset, dict):
continue
current_asset_id = str(asset.get("assetId", "")).strip()
key = str(asset.get("key", "")).strip()
mime_type = str(asset.get("mimeType", "")).lower()
if (
current_asset_id == asset_id
or marker in key
or key.endswith(f"{asset_id}/content")
):
if mime_type.startswith("video/") or "generated_video" in key:
preview_key = str(asset.get("previewImageKey", "")).strip()
if not preview_key:
aux = asset.get("auxKeys") or {}
if isinstance(aux, dict):
preview_key = str(aux.get("preview-image", "")).strip()
logger.info(
"Video asset resolved by assets list: "
f"asset_id={asset_id}, key={key}, preview={preview_key}"
)
return key, preview_key
page_token = str(data.get("nextPageToken", "")).strip()
page_count += 1
if not page_token or page_count >= max_pages:
break
except Exception as e:
logger.warning(
f"Video asset resolve failed (attempt={attempt}/{retries}): {e}"
)
if attempt < retries:
await asyncio.sleep(delay)
return "", ""
async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]:
"""Process and collect video response."""
response_id = ""
content = ""
fallback_video_id = ""
fallback_thumb = ""
idle_timeout = get_config("video.stream_timeout")
try:
@@ -615,6 +1017,18 @@ class VideoCollectProcessor(BaseProcessor):
resp = data.get("result", {}).get("response", {})
if video_resp := resp.get("streamingVideoGenerationResponse"):
fallback_video_id = (
str(video_resp.get("videoPostId", "")).strip()
or str(video_resp.get("assetId", "")).strip()
or str(video_resp.get("videoId", "")).strip()
or fallback_video_id
)
thumb_from_stream = str(
video_resp.get("thumbnailImageUrl", "")
).strip()
if thumb_from_stream:
fallback_thumb = thumb_from_stream
if video_resp.get("progress") == 100:
response_id = resp.get("responseId", "")
video_url = video_resp.get("videoUrl", "")
@@ -628,6 +1042,14 @@ class VideoCollectProcessor(BaseProcessor):
video_url, self.token, thumbnail_url
)
logger.info(f"Video generated: {video_url}")
elif model_resp := resp.get("modelResponse"):
file_attachments = model_resp.get("fileAttachments", [])
if isinstance(file_attachments, list):
for fid in file_attachments:
fid = str(fid).strip()
if fid:
fallback_video_id = fid
break
except asyncio.CancelledError:
logger.debug(
@@ -647,6 +1069,23 @@ class VideoCollectProcessor(BaseProcessor):
logger.error(
f"Video collect request error: {e}", extra={"model": self.model}
)
except UpstreamException as e:
# 对于上游明确返回的业务终止错误(如 moderation 封禁),
# 不应吞掉并伪装成“空结果”,否则上层会误判为 parentPost 空结果继续下一轮。
details = getattr(e, "details", {}) or {}
is_moderated_block = bool(details.get("moderated")) or (
"blocked by moderation" in str(e).lower()
)
if is_moderated_block:
logger.error(
f"Video collect got terminal moderation error: {e}",
extra={"model": self.model},
)
raise
logger.error(
f"Video collect upstream error: {e}",
extra={"model": self.model, "error_type": type(e).__name__},
)
except Exception as e:
logger.error(
f"Video collect processing error: {e}",
@@ -655,6 +1094,23 @@ class VideoCollectProcessor(BaseProcessor):
finally:
await self.close()
if not content and fallback_video_id:
asset_video_path, asset_thumb_path = await self._resolve_video_asset_path(
fallback_video_id
)
if asset_video_path:
if self.upscale_on_finish:
asset_video_path = await self._upscale_video_url(asset_video_path)
dl_service = self._get_dl()
content = await dl_service.render_video(
asset_video_path, self.token, asset_thumb_path or fallback_thumb
)
response_id = response_id or f"chatcmpl-{uuid.uuid4().hex[:24]}"
logger.info(
"Video generated via assets fallback: "
f"video_id={fallback_video_id}, key={asset_video_path}"
)
return {
"id": response_id,
"object": "chat.completion",
+4
View File
@@ -55,6 +55,10 @@ class AssetsUploadReverse:
"fileMimeType": fileMimeType,
"content": content,
}
logger.info(
"AssetsUpload request prepared: "
f"fileName={fileName}, fileMimeType={fileMimeType}, content_len={len(content or '')}"
)
# Curl Config
timeout = get_config("asset.upload_timeout")
+5 -4
View File
@@ -2,7 +2,6 @@
Reverse interface: media post create.
"""
import orjson
from typing import Any
from curl_cffi.requests import AsyncSession
@@ -14,8 +13,6 @@ from app.services.reverse.utils.headers import build_headers
from app.services.reverse.utils.retry import retry_on_status
MEDIA_POST_API = "https://grok.com/rest/media/post/create"
class MediaPostReverse:
"""/rest/media/post/create reverse interface."""
@@ -57,6 +54,10 @@ class MediaPostReverse:
payload["mediaUrl"] = mediaUrl
if prompt:
payload["prompt"] = prompt
logger.info(
"MediaPost request prepared: "
f"mediaType={mediaType}, has_media_url={bool(mediaUrl)}, prompt_len={len(prompt or '')}"
)
# Curl Config
timeout = get_config("video.timeout")
@@ -66,7 +67,7 @@ class MediaPostReverse:
response = await session.post(
MEDIA_POST_API,
headers=headers,
data=orjson.dumps(payload),
json=payload,
timeout=timeout,
proxies=proxies,
impersonate=browser,
+2 -4
View File
@@ -2,7 +2,6 @@
Reverse interface: video upscale.
"""
import orjson
from typing import Any
from curl_cffi.requests import AsyncSession
@@ -14,8 +13,6 @@ from app.services.reverse.utils.headers import build_headers
from app.services.reverse.utils.retry import retry_on_status
VIDEO_UPSCALE_API = "https://grok.com/rest/media/video/upscale"
class VideoUpscaleReverse:
"""/rest/media/video/upscale reverse interface."""
@@ -46,6 +43,7 @@ class VideoUpscaleReverse:
# Build payload
payload = {"videoId": video_id}
logger.info(f"VideoUpscale request prepared: video_id={video_id}")
# Curl Config
timeout = get_config("video.timeout")
@@ -55,7 +53,7 @@ class VideoUpscaleReverse:
response = await session.post(
VIDEO_UPSCALE_API,
headers=headers,
data=orjson.dumps(payload),
json=payload,
timeout=timeout,
proxies=proxies,
impersonate=browser,
+5
View File
@@ -86,6 +86,7 @@ class ImagineWebSocketReverse:
"skip_upsampler": False,
"is_initial": False,
"aspect_ratio": aspect_ratio,
"aspectRatio": aspect_ratio,
},
}
],
@@ -180,6 +181,10 @@ class ImagineWebSocketReverse:
message = self._build_request_message(
request_id, prompt, aspect_ratio, enable_nsfw
)
logger.info(
"Imagine WS request prepared: "
f"request_id={request_id}, ratio={aspect_ratio}, nsfw={enable_nsfw}"
)
await ws.send_json(message)
logger.info(f"WebSocket request sent: {prompt[:80]}...")
+17 -5
View File
@@ -38,6 +38,15 @@ def _default_quota_for_pool(pool_name: str) -> int:
return BASIC__DEFAULT_QUOTA
def _token_tag(token: str) -> str:
raw = token[4:] if token.startswith("sso=") else token
if not raw:
return "empty"
if len(raw) <= 14:
return raw
return f"{raw[:6]}...{raw[-6:]}"
class TokenManager:
"""管理 Token 的增删改查和配额同步"""
@@ -211,7 +220,9 @@ class TokenManager:
return token[4:]
return token
def get_token_info(self, pool_name: str = "ssoBasic") -> Optional["TokenInfo"]:
def get_token_info(
self, pool_name: str = "ssoBasic", exclude: set | None = None
) -> Optional["TokenInfo"]:
"""
获取可用 Token 的完整信息
@@ -226,7 +237,7 @@ class TokenManager:
logger.warning(f"Pool '{pool_name}' not found")
return None
token_info = pool.select()
token_info = pool.select(exclude=exclude)
if not token_info:
logger.warning(f"No available token in pool '{pool_name}'")
return None
@@ -238,6 +249,7 @@ class TokenManager:
resolution: str = "480p",
video_length: int = 6,
pool_candidates: Optional[List[str]] = None,
exclude: Optional[set[str]] = None,
) -> Optional["TokenInfo"]:
"""
根据视频需求智能选择 Token 池
@@ -269,17 +281,17 @@ class TokenManager:
ordered_pools = [primary_pool, fallback_pool]
for idx, pool_name in enumerate(ordered_pools):
token_info = self.get_token_info(pool_name)
token_info = self.get_token_info(pool_name, exclude=exclude)
if token_info:
if idx == 0:
logger.info(
f"Video token routing: resolution={resolution}, length={video_length}s -> "
f"pool={pool_name} (token={token_info.token[:10]}...)"
f"pool={pool_name} (token={_token_tag(token_info.token)})"
)
else:
logger.info(
f"Video token routing: fallback from {ordered_pools[0]} -> {pool_name} "
f"(token={token_info.token[:10]}...)"
f"(token={_token_tag(token_info.token)})"
)
return token_info
+40
View File
@@ -302,3 +302,43 @@
box-shadow: 0 0 0 2px #000;
border-color: #000;
}
@media (max-width: 900px) {
#batch-actions {
left: 12px;
right: 12px;
bottom: 12px;
transform: none;
border-radius: 14px;
padding: 10px;
white-space: normal;
gap: 8px;
}
#batch-actions .toolbar-sep {
display: none;
}
#batch-actions .batch-action-buttons {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
#batch-actions .batch-action-buttons .geist-button-outline,
#batch-actions .batch-action-buttons .geist-button-danger {
flex: 1;
min-width: 120px;
justify-content: center;
}
#batch-actions #batch-progress {
width: 100%;
flex-wrap: wrap;
}
.failure-list {
max-height: min(46dvh, 320px);
}
}
+19
View File
@@ -57,3 +57,22 @@
top: 50%;
transform: translateY(-50%);
}
@media (max-width: 900px) {
.config-section {
padding: 14px;
border-radius: 10px;
}
.config-field.has-action {
padding-right: 0;
padding-bottom: 40px;
}
.config-field-action {
top: auto;
right: 0;
bottom: 0;
transform: none;
}
}
+23
View File
@@ -10,6 +10,12 @@
overflow: hidden;
}
@supports (min-height: 100dvh) {
.login-body {
min-height: 100dvh;
}
}
.login-bg {
position: fixed;
inset: 0;
@@ -97,3 +103,20 @@
font-size: 12px;
justify-content: center;
}
@media (max-width: 900px) {
.login-shell {
width: min(460px, 100vw);
padding: 16px;
}
.login-card {
padding: 18px;
}
.login-input,
.login-button {
height: 42px;
font-size: 16px;
}
}
+34 -9
View File
@@ -1,9 +1,9 @@
<nav class="bg-[var(--bg)]/80 backdrop-blur-md fixed top-0 left-0 right-0 z-10">
<div class="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="flex items-center gap-6 min-w-0">
<div class="flex items-center gap-2">
<a href="https://github.com/chenyme/grok2api" target="_blank" rel="noopener"
class="brand-link font-semibold tracking-tight hover:text-black">
class="brand-link font-semibold tracking-tight hover:opacity-80">
<svg class="github-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 2C6.48 2 2 6.58 2 12.26c0 4.5 2.87 8.32 6.84 9.67.5.1.68-.22.68-.48 0-.24-.01-.86-.01-1.69-2.78.62-3.37-1.37-3.37-1.37-.45-1.17-1.11-1.48-1.11-1.48-.91-.64.07-.63.07-.63 1.01.07 1.54 1.06 1.54 1.06.9 1.58 2.36 1.12 2.94.86.09-.67.35-1.12.63-1.38-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.04 1.03-2.76-.1-.26-.45-1.3.1-2.71 0 0 .84-.27 2.75 1.05A9.3 9.3 0 0 1 12 6.79c.85 0 1.7.12 2.5.36 1.9-1.32 2.74-1.05 2.74-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.64 1.03 2.76 0 3.94-2.34 4.81-4.58 5.06.36.32.68.95.68 1.92 0 1.38-.01 2.49-.01 2.83 0 .26.18.58.69.48A10.05 10.05 0 0 0 22 12.26C22 6.58 17.52 2 12 2Z" />
@@ -11,18 +11,43 @@
<span>Grok2API</span>
</a>
<a href="https://github.com/chenyme" target="_blank" rel="noopener"
class="text-xs text-[var(--accents-4)] hover:text-black">@chenyme</a>
class="text-xs text-[var(--accents-4)] hover:opacity-80 admin-brand-author">@chenyme</a>
</div>
<div class="h-4 w-px bg-[var(--border)] admin-nav-divider"></div>
<div class="admin-nav-links">
<a href="/admin/token" class="nav-link text-sm" data-nav="/admin/token">Token管理</a>
<a href="/admin/config" class="nav-link text-sm" data-nav="/admin/config">配置管理</a>
<a href="/admin/cache" class="nav-link text-sm" data-nav="/admin/cache">缓存管理</a>
</div>
<div class="h-4 w-px bg-[var(--border)]"></div>
<a href="/admin/token" class="nav-link text-sm" data-nav="/admin/token">Token管理</a>
<a href="/admin/config" class="nav-link text-sm" data-nav="/admin/config">配置管理</a>
<a href="/admin/cache" class="nav-link text-sm" data-nav="/admin/cache">缓存管理</a>
</div>
<div class="nav-actions">
<button id="storage-mode-btn" type="button" class="nav-action-btn" title="存储模式">-</button>
<div class="nav-actions admin-desktop-actions">
<button id="storage-mode-btn" data-storage-mode-btn type="button" class="nav-action-btn" title="存储模式">-</button>
<button type="button" class="nav-action-btn" data-theme-toggle-btn title="切换深夜模式">深夜模式</button>
<a href="/imagine" class="nav-action-btn">媒体页</a>
<a href="https://github.com/chenyme/grok2api/issues" target="_blank" rel="noopener"
class="nav-action-btn">反馈</a>
<button onclick="logout()" type="button" class="nav-action-btn">退出</button>
</div>
<button id="admin-menu-toggle" class="admin-menu-toggle" type="button" aria-label="打开管理菜单"
aria-expanded="false" aria-controls="admin-mobile-menu">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
</div>
<div id="admin-mobile-menu" class="admin-mobile-menu" hidden>
<div class="admin-mobile-menu-inner">
<a href="/admin/token" class="nav-link text-sm" data-nav="/admin/token">Token管理</a>
<a href="/admin/config" class="nav-link text-sm" data-nav="/admin/config">配置管理</a>
<a href="/admin/cache" class="nav-link text-sm" data-nav="/admin/cache">缓存管理</a>
<div class="admin-mobile-menu-actions">
<button id="storage-mode-btn-mobile" data-storage-mode-btn type="button" class="nav-action-btn" title="存储模式">-</button>
<button type="button" class="nav-action-btn" data-theme-toggle-btn title="切换深夜模式">深夜模式</button>
<a href="/imagine" class="nav-action-btn">媒体页</a>
<a href="https://github.com/chenyme/grok2api/issues" target="_blank" rel="noopener"
class="nav-action-btn">反馈</a>
<button onclick="logout()" type="button" class="nav-action-btn">退出</button>
</div>
</div>
</div>
</nav>
+14 -10
View File
@@ -255,18 +255,22 @@ function formatStorageLabel(type) {
}
async function updateStorageModeButton() {
const btn = document.getElementById('storage-mode-btn');
if (!btn) return;
btn.textContent = '...';
btn.title = '存储模式';
btn.classList.remove('storage-ready');
const buttons = Array.from(document.querySelectorAll('[data-storage-mode-btn]'));
if (!buttons.length) return;
buttons.forEach((btn) => {
btn.textContent = '...';
btn.title = '存储模式';
btn.classList.remove('storage-ready');
});
const storageType = await fetchStorageType();
const label = formatStorageLabel(storageType);
btn.textContent = label === '-' ? label : label.toUpperCase();
btn.title = '存储模式';
if (label !== '-') {
btn.classList.add('storage-ready');
}
buttons.forEach((btn) => {
btn.textContent = label === '-' ? label : label.toUpperCase();
btn.title = '存储模式';
if (label !== '-') {
btn.classList.add('storage-ready');
}
});
}
if (document.readyState === 'loading') {
+56 -1
View File
@@ -2,7 +2,7 @@ async function loadAdminHeader() {
const container = document.getElementById('app-header');
if (!container) return;
try {
const res = await fetch('/static/common/html/header.html?v=0.3.0');
const res = await fetch('/static/common/html/header.html?v=0.3.1');
if (!res.ok) return;
container.innerHTML = await res.text();
const path = window.location.pathname;
@@ -23,6 +23,61 @@ async function loadAdminHeader() {
if (typeof updateStorageModeButton === 'function') {
updateStorageModeButton();
}
const nav = container.querySelector('nav');
const menuToggle = container.querySelector('#admin-menu-toggle');
const mobileMenu = container.querySelector('#admin-mobile-menu');
if (menuToggle && mobileMenu) {
const setMenuOpen = (open) => {
const isOpen = Boolean(open);
menuToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
mobileMenu.hidden = !isOpen;
mobileMenu.classList.toggle('open', isOpen);
if (nav) {
nav.classList.toggle('mobile-menu-open', isOpen);
}
document.body.classList.toggle('mobile-menu-open', isOpen);
};
setMenuOpen(false);
menuToggle.addEventListener('click', () => {
const expanded = menuToggle.getAttribute('aria-expanded') === 'true';
setMenuOpen(!expanded);
});
mobileMenu.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('a') || target.closest('button')) {
setMenuOpen(false);
}
});
document.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (!container.contains(target)) {
setMenuOpen(false);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
setMenuOpen(false);
}
});
window.addEventListener('resize', () => {
if (window.innerWidth > 900) {
setMenuOpen(false);
}
});
}
if (window.themeController && typeof window.themeController.refreshButtons === 'function') {
window.themeController.refreshButtons();
}
} catch (e) {
// Fail silently to avoid breaking page load
}
+95
View File
@@ -0,0 +1,95 @@
(() => {
function bindFab(root) {
if (!root || root.dataset.fabBound === '1') return;
root.dataset.fabBound = '1';
const toggleBtn = root.querySelector('[data-fab-toggle]');
const proxyButtons = Array.from(root.querySelectorAll('[data-proxy-click]'));
const setOpen = (open) => {
const isOpen = Boolean(open);
root.classList.toggle('open', isOpen);
if (toggleBtn) {
toggleBtn.setAttribute('aria-expanded', String(isOpen));
}
};
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
setOpen(!root.classList.contains('open'));
});
}
document.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (!root.contains(target)) {
setOpen(false);
}
});
proxyButtons.forEach((btn) => {
btn.addEventListener('click', () => {
const selector = btn.getAttribute('data-proxy-click');
if (!selector) return;
const source = document.querySelector(selector);
if (!(source instanceof HTMLElement)) return;
source.click();
});
});
const syncProxyState = () => {
proxyButtons.forEach((btn) => {
const selector = btn.getAttribute('data-proxy-click');
if (!selector) return;
const source = document.querySelector(selector);
if (!(source instanceof HTMLElement)) {
btn.classList.add('hidden');
btn.disabled = true;
return;
}
const logicallyHidden = Boolean(source.hidden) || source.classList.contains('hidden');
btn.disabled = Boolean(source.disabled);
btn.classList.toggle('hidden', logicallyHidden);
const nextLabel = String(source.textContent || '').trim();
if (nextLabel && btn.textContent !== nextLabel) {
btn.textContent = nextLabel;
}
});
};
const observer = new MutationObserver(syncProxyState);
const observedSources = new Set();
proxyButtons.forEach((btn) => {
const selector = btn.getAttribute('data-proxy-click');
if (!selector) return;
const source = document.querySelector(selector);
if (!(source instanceof HTMLElement) || observedSources.has(source)) return;
observedSources.add(source);
observer.observe(source, {
attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'disabled'],
childList: true,
characterData: true,
subtree: true,
});
});
syncProxyState();
setOpen(false);
}
function setupAll() {
document.querySelectorAll('[data-mobile-fab]').forEach((root) => {
if (root instanceof HTMLElement) {
bindFab(root);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupAll);
} else {
setupAll();
}
})();
+138
View File
@@ -0,0 +1,138 @@
(() => {
const STORAGE_KEY = 'grok2api_parent_post_memory_v1';
const MAX_ITEMS = 300;
function nowMs() {
return Date.now();
}
function isParentPostId(value) {
return /^[0-9a-fA-F-]{32,36}$/.test(String(value || '').trim());
}
function buildImaginePublicUrl(parentPostId) {
const id = String(parentPostId || '').trim();
if (!id) return '';
return `https://imagine-public.x.ai/imagine-public/images/${id}.jpg`;
}
function extractParentPostId(text) {
const raw = String(text || '').trim();
if (!raw) return '';
if (isParentPostId(raw)) return raw;
const patterns = [
/\/generated\/([0-9a-fA-F-]{32,36})(?:\/|$)/,
/\/imagine-public\/images\/([0-9a-fA-F-]{32,36})(?:\.jpg|\/|$)/,
/\/images\/([0-9a-fA-F-]{32,36})(?:\.jpg|\/|$)/,
];
for (const pattern of patterns) {
const match = raw.match(pattern);
if (match) {
return match[1];
}
}
const all = raw.match(/([0-9a-fA-F-]{32,36})/g);
return all && all.length ? all[all.length - 1] : '';
}
function loadMemory() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const data = JSON.parse(raw);
if (!Array.isArray(data)) return [];
return data.filter((item) => item && isParentPostId(item.parentPostId));
} catch (e) {
return [];
}
}
function saveMemory(items) {
try {
const normalized = Array.isArray(items) ? items.slice(0, MAX_ITEMS) : [];
localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized));
} catch (e) {
// ignore
}
}
function normalizeUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('data:image/')) {
return raw;
}
if (raw.startsWith('/')) {
return raw;
}
return raw;
}
function remember(entry) {
if (!entry || typeof entry !== 'object') return null;
const parentPostId = extractParentPostId(entry.parentPostId || entry.parent_post_id || entry.id || '');
if (!isParentPostId(parentPostId)) return null;
const sourceImageUrl = normalizeUrl(entry.sourceImageUrl || entry.source_image_url || '');
const imageUrl = normalizeUrl(entry.imageUrl || entry.image_url || entry.url || '');
const item = {
parentPostId,
sourceImageUrl: sourceImageUrl || imageUrl || buildImaginePublicUrl(parentPostId),
imageUrl: imageUrl || sourceImageUrl || buildImaginePublicUrl(parentPostId),
origin: String(entry.origin || 'unknown'),
updatedAt: Number(entry.updatedAt || nowMs()),
};
const all = loadMemory();
const idx = all.findIndex((it) => it.parentPostId === parentPostId);
if (idx >= 0) {
all.splice(idx, 1);
}
all.unshift(item);
saveMemory(all);
return item;
}
function getByParentPostId(parentPostId) {
const id = extractParentPostId(parentPostId);
if (!isParentPostId(id)) return null;
const all = loadMemory();
return all.find((item) => item.parentPostId === id) || null;
}
function list(limit = 50) {
const n = Math.max(1, Math.min(500, Number(limit) || 50));
return loadMemory().slice(0, n);
}
function resolveByText(text) {
const raw = String(text || '').trim();
const parentPostId = extractParentPostId(raw);
if (!parentPostId) return null;
const hit = getByParentPostId(parentPostId);
if (hit) return hit;
const looksLikeUrl = raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('/');
const fallbackUrl = looksLikeUrl ? raw : buildImaginePublicUrl(parentPostId);
return {
parentPostId,
sourceImageUrl: fallbackUrl,
imageUrl: fallbackUrl,
origin: 'fallback',
updatedAt: nowMs(),
};
}
window.ParentPostMemory = {
key: STORAGE_KEY,
extractParentPostId,
isParentPostId,
buildImaginePublicUrl,
remember,
getByParentPostId,
list,
resolveByText,
};
})();
+317 -82
View File
@@ -1,5 +1,6 @@
(() => {
const enhanceStateMap = new WeakMap();
const enhanceRequestMap = new WeakMap();
function toast(message, type) {
if (typeof window.showToast === 'function') {
@@ -15,16 +16,47 @@
.prompt-enhance-wrap {
position: relative;
width: 100%;
--pe-action-width: 104px;
--pe-action-height: 30px;
--pe-action-radius: 8px;
--pe-slash-size: 8px;
--pe-lang-share: 40%;
--pe-border-color: #5f7087;
--pe-scrollbar-thumb: rgba(110, 132, 160, 0.82);
--pe-scrollbar-thumb-hover: rgba(86, 112, 145, 0.95);
--pe-scrollbar-track: rgba(148, 163, 184, 0.18);
}
.prompt-enhance-wrap > textarea {
padding-bottom: 40px;
scrollbar-width: thin;
scrollbar-color: var(--pe-scrollbar-thumb) var(--pe-scrollbar-track);
}
.prompt-enhance-wrap > textarea::-webkit-scrollbar {
width: 7px;
height: 7px;
}
.prompt-enhance-wrap > textarea::-webkit-scrollbar-track {
background: var(--pe-scrollbar-track);
border-radius: 999px;
}
.prompt-enhance-wrap > textarea::-webkit-scrollbar-thumb {
background: var(--pe-scrollbar-thumb);
border-radius: 999px;
border: 1px solid transparent;
background-clip: padding-box;
}
.prompt-enhance-wrap > textarea:hover::-webkit-scrollbar-thumb {
background: var(--pe-scrollbar-thumb-hover);
}
.prompt-enhance-wrap > textarea:focus::-webkit-scrollbar-thumb {
background: var(--pe-scrollbar-thumb-hover);
}
.prompt-enhance-wrap.lightbox-mode > textarea {
width: 100%;
display: block;
box-sizing: border-box;
padding-left: 12px;
padding-right: 124px;
padding-right: calc(var(--pe-action-width) + 20px);
min-height: 84px;
}
.prompt-enhance-wrap.lightbox-mode {
@@ -33,6 +65,17 @@
width: 100%;
position: relative;
}
.prompt-enhance-wrap > .prompt-enhance-actions {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 3;
width: var(--pe-action-width);
height: var(--pe-action-height);
display: flex;
align-items: stretch;
justify-content: stretch;
}
.prompt-enhance-wrap.lightbox-mode > .inline-send-inside,
.prompt-enhance-wrap.lightbox-mode > #lightboxEditSend,
.prompt-enhance-wrap.lightbox-mode > .lightbox-edit-send {
@@ -48,21 +91,21 @@
border-radius: 8px;
flex: none !important;
}
.prompt-enhance-wrap.lightbox-mode > .prompt-enhance-btn {
position: absolute !important;
.prompt-enhance-wrap.lightbox-mode > .prompt-enhance-actions {
right: 10px !important;
bottom: 46px !important;
z-index: 4;
height: 30px !important;
min-width: 104px;
width: auto !important;
margin: 0 !important;
padding: 0 12px !important;
}
@media (max-width: 768px) {
.prompt-enhance-wrap {
--pe-action-width: 92px;
--pe-action-height: 28px;
--pe-slash-size: 7px;
--pe-lang-share: 40%;
}
.prompt-enhance-wrap.lightbox-mode > textarea {
padding-left: 12px;
padding-right: 104px;
padding-right: calc(var(--pe-action-width) + 12px);
padding-bottom: 50px;
min-height: 96px;
}
@@ -75,74 +118,120 @@
height: 28px !important;
padding: 0 10px !important;
}
.prompt-enhance-wrap.lightbox-mode > .prompt-enhance-btn {
.prompt-enhance-wrap.lightbox-mode > .prompt-enhance-actions {
right: 8px !important;
bottom: 42px !important;
min-width: 92px;
height: 28px !important;
padding: 0 10px !important;
}
}
.prompt-enhance-btn {
position: absolute;
right: 10px;
bottom: 10px;
z-index: 3;
height: 30px;
min-width: 92px;
.prompt-enhance-actions > .prompt-enhance-btn,
.prompt-enhance-actions > .prompt-lang-toggle-btn {
height: 100%;
min-width: 0;
width: 100%;
padding: 0 10px;
border-radius: 8px;
background: var(--bg);
border-color: var(--border);
border-color: var(--pe-border-color);
color: var(--fg);
cursor: pointer;
user-select: none;
transition: border-color .15s ease, background .15s ease;
}
.prompt-enhance-actions > .prompt-enhance-btn {
border-radius: var(--pe-action-radius);
}
.prompt-enhance-actions > .prompt-lang-toggle-btn {
display: none;
}
.prompt-enhance-btn:hover {
border-color: #000;
border-color: #34455d;
background: #e7eef8;
box-shadow: inset 0 0 0 1px rgba(52, 69, 93, 0.22);
}
.prompt-enhance-btn:active {
border-color: #2a3d57;
background: #dce7f6;
}
html[data-theme='dark'] .prompt-enhance-btn {
background: #111821;
border-color: #3b4654;
border-color: #607286;
color: var(--fg);
}
html[data-theme='dark'] .prompt-enhance-btn:hover {
border-color: #6b7788;
border-color: #7a8ea6;
background: #1a2330;
}
.prompt-lang-toggle-btn {
position: absolute;
right: 110px;
bottom: 10px;
z-index: 3;
height: 30px;
min-width: 48px;
padding: 0 10px;
border-radius: 8px;
background: var(--bg);
border-color: var(--border);
color: var(--fg);
cursor: pointer;
user-select: none;
display: none;
}
.prompt-lang-toggle-btn.is-visible {
display: inline-flex;
align-items: center;
justify-content: center;
}
.prompt-lang-toggle-btn:hover {
border-color: #000;
border-color: #34455d;
background: #e7eef8;
box-shadow: inset 0 0 0 1px rgba(52, 69, 93, 0.22);
}
.prompt-lang-toggle-btn:active {
border-color: #2a3d57;
background: #dce7f6;
}
html[data-theme='dark'] .prompt-lang-toggle-btn {
background: #111821;
border-color: #3b4654;
border-color: #607286;
color: var(--fg);
}
html[data-theme='dark'] .prompt-lang-toggle-btn:hover {
border-color: #6b7788;
border-color: #7a8ea6;
background: #1a2330;
}
.prompt-enhance-actions.is-group-active {
padding: 1px;
background: var(--pe-border-color);
border: 1px solid var(--pe-border-color);
border-radius: var(--pe-action-radius);
overflow: hidden;
}
.prompt-enhance-actions.is-group-active > .prompt-lang-toggle-btn.is-visible {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 var(--pe-lang-share);
max-width: none;
margin-right: 0;
border-radius: var(--pe-action-radius) 0 0 var(--pe-action-radius);
border: 0;
clip-path: polygon(0 0, 100% 0, calc(100% - var(--pe-slash-size)) 100%, 0 100%);
padding: 0 4px 0 8px;
z-index: 2;
}
.prompt-enhance-actions.is-group-active > .prompt-enhance-btn {
flex: 1 1 auto;
min-width: 0;
max-width: none;
margin-left: calc(var(--pe-slash-size) * -1);
border-radius: 0 var(--pe-action-radius) var(--pe-action-radius) 0;
border: 0;
clip-path: polygon(var(--pe-slash-size) 0, 100% 0, 100% 100%, 0 100%);
padding: 0 10px 0 calc(10px + var(--pe-slash-size));
z-index: 1;
}
.prompt-enhance-actions.is-group-active > .prompt-lang-toggle-btn.is-visible:hover,
.prompt-enhance-actions.is-group-active > .prompt-enhance-btn:hover {
border: 0;
background: #dfe9f7;
box-shadow: none;
}
.prompt-enhance-actions.is-group-active > .prompt-lang-toggle-btn.is-visible:active,
.prompt-enhance-actions.is-group-active > .prompt-enhance-btn:active {
border: 0;
background: #d3e0f4;
box-shadow: none;
}
html[data-theme='dark'] .prompt-enhance-actions.is-group-active > .prompt-lang-toggle-btn.is-visible:hover,
html[data-theme='dark'] .prompt-enhance-actions.is-group-active > .prompt-enhance-btn:hover {
border: 0;
background: #1d2a3a;
}
html[data-theme='dark'] .prompt-enhance-wrap {
--pe-border-color: #607286;
--pe-scrollbar-thumb: rgba(130, 154, 184, 0.72);
--pe-scrollbar-thumb-hover: rgba(156, 182, 214, 0.9);
--pe-scrollbar-track: rgba(86, 102, 123, 0.28);
}
.prompt-enhance-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
@@ -166,18 +255,11 @@
return false;
}
async function callEnhanceApi(rawPrompt) {
if (typeof window.ensurePublicKey !== 'function' || typeof window.buildAuthHeaders !== 'function') {
throw new Error('public_auth_api_missing');
}
const authHeader = await window.ensurePublicKey();
if (authHeader === null) {
throw new Error('public_key_missing');
}
async function callEnhanceApi(rawPrompt, signal, authHeader, requestId) {
const body = {
prompt: rawPrompt,
temperature: 0.7,
request_id: requestId,
};
const res = await fetch('/v1/public/prompt/enhance', {
@@ -185,8 +267,10 @@
headers: {
'Content-Type': 'application/json',
...window.buildAuthHeaders(authHeader),
'X-Enhance-Request-Id': requestId,
},
body: JSON.stringify(body),
signal,
});
if (!res.ok) {
let detail = '';
@@ -206,16 +290,35 @@
return text;
}
function newRequestId() {
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
return window.crypto.randomUUID();
}
return `enh_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`;
}
function requestEnhanceStop(meta) {
if (!meta || !meta.requestId || !meta.authHeader) return;
fetch('/v1/public/prompt/enhance/stop', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...window.buildAuthHeaders(meta.authHeader),
},
body: JSON.stringify({ request_id: meta.requestId }),
}).catch(() => {});
}
function isMobileViewport() {
return window.matchMedia('(max-width: 768px)').matches;
}
function parseEnhancedPrompt(text) {
const raw = String(text || '').trim();
const headMatch = raw.match(/^([\s\S]*?)\s*最终提示词:/);
const enMatch = raw.match(/最终提示词:\s*([\s\S]*?)\s*中文参考版:/);
const zhMatch = raw.match(/中文参考版:\s*([\s\S]*?)\s*可调参数:/);
const tailMatch = raw.match(/(可调参数:[\s\S]*)$/);
const headMatch = raw.match(/^([\s\S]*?)\s*最终提示词[::]/);
const enMatch = raw.match(/最终提示词[::]\s*([\s\S]*?)\s*中文参考版[::]/);
const zhMatch = raw.match(/中文参考版[::]\s*([\s\S]*?)\s*可调参数[::]/);
const tailMatch = raw.match(/(可调参数[::][\s\S]*)$/);
return {
head: headMatch && headMatch[1] ? String(headMatch[1]).trim() : '',
en: enMatch && enMatch[1] ? String(enMatch[1]).trim() : '',
@@ -232,7 +335,7 @@
}
function updateToggleButtonText(toggleBtn, mode) {
toggleBtn.textContent = mode === 'zh' ? '中文' : 'EN';
toggleBtn.textContent = mode === 'en' ? 'CN' : 'EN';
}
function setToggleButtonVisible(toggleBtn, visible) {
@@ -244,11 +347,118 @@
enhanceBtn.textContent = mode === 'clear' ? '清空' : '增强提示词';
}
function resetEnhancerState(textarea, enhanceBtn, toggleBtn) {
function pickReferenceActionButton(textarea, wrapper) {
if (!wrapper) return null;
const inlineBtn = wrapper.querySelector('.inline-submit-btn, .inline-send-inside, #lightboxEditSend, .lightbox-edit-send');
if (inlineBtn instanceof HTMLButtonElement) return inlineBtn;
const id = String(textarea && textarea.id ? textarea.id : '').toLowerCase();
if (id === 'editpromptinput') {
const btn = document.getElementById('submitEditBtn');
if (
btn instanceof HTMLButtonElement &&
btn.classList.contains('inline-submit-btn') &&
wrapper.contains(btn)
) {
return btn;
}
}
if (id === 'lightboxeditinput') {
const btn = document.getElementById('lightboxEditSend');
if (
btn instanceof HTMLButtonElement &&
btn.classList.contains('inline-send-inside') &&
wrapper.contains(btn)
) {
return btn;
}
}
return null;
}
function syncActionDimensions(textarea, wrapper, actionWrap) {
if (!textarea || !wrapper || !actionWrap) return null;
const refBtn = pickReferenceActionButton(textarea, wrapper);
const mobile = isMobileViewport();
const targetWidth = mobile ? 92 : 104;
const targetHeight = mobile ? 28 : 30;
wrapper.style.setProperty('--pe-action-width', `${targetWidth}px`);
wrapper.style.setProperty('--pe-action-height', `${targetHeight}px`);
if (!(refBtn instanceof HTMLButtonElement)) return null;
const style = window.getComputedStyle(refBtn);
const radius = parseFloat(style.borderTopLeftRadius || '8') || 8;
wrapper.style.setProperty('--pe-action-radius', `${Math.max(4, Math.round(radius))}px`);
return refBtn;
}
function bindActionDimensionSync(textarea, wrapper, actionWrap) {
const run = () => syncActionDimensions(textarea, wrapper, actionWrap);
let observedBtn = null;
let resizeObserver = null;
let mutationObserver = null;
const rebind = () => {
const nextBtn = run();
if (!window.ResizeObserver) return;
if (!resizeObserver) {
resizeObserver = new ResizeObserver(() => run());
}
if (observedBtn === nextBtn) return;
if (observedBtn) resizeObserver.unobserve(observedBtn);
observedBtn = nextBtn;
if (observedBtn) resizeObserver.observe(observedBtn);
};
rebind();
requestAnimationFrame(rebind);
setTimeout(rebind, 80);
setTimeout(rebind, 240);
window.addEventListener('resize', rebind, { passive: true });
mutationObserver = new MutationObserver(() => rebind());
mutationObserver.observe(wrapper, { childList: true });
wrapper.__peResizeObserver = resizeObserver;
wrapper.__peMutationObserver = mutationObserver;
}
function syncActionGroupState(enhanceBtn, toggleBtn, actionWrap) {
if (!actionWrap || !enhanceBtn || !toggleBtn) return;
const isClearMode = String(enhanceBtn.dataset.mode || 'enhance') === 'clear';
const isRunning = String(enhanceBtn.dataset.running || '0') === '1';
const langVisible = toggleBtn.classList.contains('is-visible');
actionWrap.classList.toggle('is-group-active', isClearMode && langVisible && !isRunning);
}
function setEnhanceRunning(textarea, enhanceBtn, toggleBtn, running, actionWrap) {
enhanceBtn.dataset.running = running ? '1' : '0';
if (running) {
enhanceBtn.textContent = '中止';
enhanceBtn.disabled = false;
if (toggleBtn) toggleBtn.disabled = true;
syncActionGroupState(enhanceBtn, toggleBtn, actionWrap);
return;
}
if (toggleBtn) toggleBtn.disabled = false;
setEnhanceButtonMode(enhanceBtn, String(enhanceBtn.dataset.mode || 'enhance'));
enhanceRequestMap.delete(textarea);
syncActionGroupState(enhanceBtn, toggleBtn, actionWrap);
}
function cancelEnhance(textarea) {
const requestMeta = enhanceRequestMap.get(textarea);
if (!requestMeta) return;
if (requestMeta.controller) {
requestMeta.controller.abort();
}
requestEnhanceStop(requestMeta);
}
function resetEnhancerState(textarea, enhanceBtn, toggleBtn, actionWrap) {
enhanceStateMap.delete(textarea);
setToggleButtonVisible(toggleBtn, false);
updateToggleButtonText(toggleBtn, 'zh');
setEnhanceButtonMode(enhanceBtn, 'enhance');
syncActionGroupState(enhanceBtn, toggleBtn, actionWrap);
}
function buildDesktopText(state, mode) {
@@ -282,25 +492,44 @@
updateToggleButtonText(toggleBtn, state.mode);
}
async function onEnhanceClick(textarea, enhanceBtn, toggleBtn) {
async function onEnhanceClick(textarea, enhanceBtn, toggleBtn, actionWrap) {
if (String(enhanceBtn.dataset.running || '0') === '1') {
cancelEnhance(textarea);
toast('已取消提示词增强', 'warning');
return;
}
const currentMode = String(enhanceBtn.dataset.mode || 'enhance');
if (currentMode === 'clear') {
applyPromptToTextarea(textarea, '');
resetEnhancerState(textarea, enhanceBtn, toggleBtn);
resetEnhancerState(textarea, enhanceBtn, toggleBtn, actionWrap);
toast('已清空提示词', 'success');
return;
}
const raw = String(textarea.value || '').trim();
if (!raw) {
toast('请先输入提示词', 'warning');
toast('请输入提示词', 'warning');
return;
}
enhanceBtn.disabled = true;
toggleBtn.disabled = true;
enhanceBtn.textContent = '增强中...';
if (typeof window.ensurePublicKey !== 'function' || typeof window.buildAuthHeaders !== 'function') {
toast('公共鉴权脚本未加载', 'error');
return;
}
const authHeader = await window.ensurePublicKey();
if (authHeader === null) {
toast('请先配置 Public Key', 'error');
return;
}
const requestId = newRequestId();
const controller = new AbortController();
enhanceRequestMap.set(textarea, {
controller,
requestId,
authHeader,
});
setEnhanceRunning(textarea, enhanceBtn, toggleBtn, true, actionWrap);
try {
const enhanced = await callEnhanceApi(raw);
const enhanced = await callEnhanceApi(raw, controller.signal, authHeader, requestId);
const parsed = parseEnhancedPrompt(enhanced);
const hasDualLanguage = Boolean(parsed.en && parsed.zh && parsed.head && parsed.tail);
const mode = ((enhanceStateMap.get(textarea) || {}).mode || 'zh');
@@ -322,18 +551,17 @@
applyPromptToTextarea(textarea, parsed.raw);
}
setEnhanceButtonMode(enhanceBtn, 'clear');
syncActionGroupState(enhanceBtn, toggleBtn, actionWrap);
toast('提示词增强完成', 'success');
} catch (e) {
const msg = String(e && e.message ? e.message : e);
if (msg === 'public_key_missing') {
toast('请先配置 Public Key', 'error');
} else {
toast(`提示词增强失败: ${msg}`, 'error');
if (e && e.name === 'AbortError') {
toast('已取消提示词增强', 'warning');
return;
}
const msg = String(e && e.message ? e.message : e);
toast(`提示词增强失败: ${msg}`, 'error');
} finally {
enhanceBtn.disabled = false;
toggleBtn.disabled = false;
setEnhanceButtonMode(enhanceBtn, String(enhanceBtn.dataset.mode || 'enhance'));
setEnhanceRunning(textarea, enhanceBtn, toggleBtn, false, actionWrap);
}
}
@@ -376,6 +604,10 @@
}
}
const actionWrap = document.createElement('div');
actionWrap.className = 'prompt-enhance-actions';
wrapper.appendChild(actionWrap);
const langBtn = document.createElement('button');
langBtn.type = 'button';
langBtn.className = 'geist-button-outline prompt-lang-toggle-btn';
@@ -389,14 +621,16 @@
const nextMode = (state.mode || 'zh') === 'zh' ? 'en' : 'zh';
applyEnhancedByMode(textarea, langBtn, nextMode);
});
wrapper.appendChild(langBtn);
actionWrap.appendChild(langBtn);
const button = document.createElement('button');
button.type = 'button';
button.className = 'geist-button-outline prompt-enhance-btn';
setEnhanceButtonMode(button, 'enhance');
button.addEventListener('click', () => onEnhanceClick(textarea, button, langBtn));
wrapper.appendChild(button);
button.addEventListener('click', () => onEnhanceClick(textarea, button, langBtn, actionWrap));
actionWrap.appendChild(button);
syncActionGroupState(button, langBtn, actionWrap);
bindActionDimensionSync(textarea, wrapper, actionWrap);
textarea.dataset.promptEnhancerMounted = '1';
}
@@ -413,3 +647,4 @@
init();
}
})();
+75 -5
View File
@@ -2,16 +2,27 @@ async function loadPublicHeader() {
const container = document.getElementById('app-header');
if (!container) return;
try {
const res = await fetch('/static/common/html/public-header.html?v=0.3.0');
const res = await fetch('/static/common/html/public-header.html?v=0.3.3');
if (!res.ok) return;
container.innerHTML = await res.text();
const logoutBtn = container.querySelector('#public-logout-btn');
if (logoutBtn) {
logoutBtn.classList.add('hidden');
const desktopLogoutBtn = container.querySelector('#public-logout-btn');
const mobileLogoutBtn = container.querySelector('#public-mobile-logout-btn');
if (desktopLogoutBtn) {
desktopLogoutBtn.classList.add('hidden');
}
if (mobileLogoutBtn) {
mobileLogoutBtn.classList.add('hidden');
}
if (desktopLogoutBtn || mobileLogoutBtn) {
try {
const verify = await fetch('/v1/public/verify', { method: 'GET' });
if (verify.status === 401) {
logoutBtn.classList.remove('hidden');
if (desktopLogoutBtn) {
desktopLogoutBtn.classList.remove('hidden');
}
if (mobileLogoutBtn) {
mobileLogoutBtn.classList.remove('hidden');
}
}
} catch (e) {
// Ignore verification errors and keep it hidden
@@ -25,6 +36,65 @@ async function loadPublicHeader() {
link.classList.add('active');
}
});
const nav = container.querySelector('nav');
const menuToggle = container.querySelector('#public-menu-toggle');
const mobileMenu = container.querySelector('#public-mobile-menu');
if (menuToggle && mobileMenu) {
const setMenuOpen = (open) => {
const isOpen = Boolean(open);
menuToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
mobileMenu.hidden = !isOpen;
mobileMenu.classList.toggle('open', isOpen);
if (nav) {
nav.classList.toggle('mobile-menu-open', isOpen);
}
document.body.classList.toggle('mobile-menu-open', isOpen);
};
setMenuOpen(false);
menuToggle.addEventListener('click', () => {
const expanded = menuToggle.getAttribute('aria-expanded') === 'true';
setMenuOpen(!expanded);
});
mobileMenu.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('a') || target.closest('button')) {
setMenuOpen(false);
}
});
document.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (!container.contains(target)) {
setMenuOpen(false);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
setMenuOpen(false);
}
});
window.addEventListener('resize', () => {
if (window.innerWidth > 900) {
setMenuOpen(false);
}
});
}
if (window.themeController && typeof window.themeController.refreshButtons === 'function') {
window.themeController.refreshButtons();
}
if (typeof window.setupPwaInstallButtons === 'function') {
window.setupPwaInstallButtons(container);
}
} catch (e) {
// Fail silently to avoid breaking page load
}
+109
View File
@@ -0,0 +1,109 @@
(() => {
if (!("serviceWorker" in navigator)) return;
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
if (!window.isSecureContext && !isLocalhost) return;
let deferredPrompt = null;
let installMode = "none";
function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true;
}
function isIosDevice() {
const ua = window.navigator.userAgent || "";
return /iphone|ipad|ipod/i.test(ua);
}
function toast(message, type) {
if (typeof window.showToast === "function") {
window.showToast(message, type);
}
}
function getInstallButtons(scope) {
const root = scope instanceof Element || scope instanceof Document ? scope : document;
return Array.from(root.querySelectorAll("[data-pwa-install-btn]"));
}
function refreshInstallButtons(scope) {
const buttons = getInstallButtons(scope);
const canShow = !isStandalone() && (installMode === "prompt" || installMode === "ios");
buttons.forEach((btn) => {
btn.classList.toggle("hidden", !canShow);
btn.disabled = !canShow;
});
}
async function runInstallPrompt() {
if (isStandalone()) return;
if (installMode === "ios") {
toast("iOS 请点击浏览器分享按钮,再选择“添加到主屏幕”", "success");
return;
}
if (!deferredPrompt) {
toast("当前环境暂不支持安装", "warning");
return;
}
try {
deferredPrompt.prompt();
await deferredPrompt.userChoice;
} finally {
deferredPrompt = null;
installMode = isIosDevice() ? "ios" : "none";
refreshInstallButtons();
}
}
function bindInstallButtonClick(scope) {
const buttons = getInstallButtons(scope);
buttons.forEach((btn) => {
if (btn.dataset.pwaBound === "1") return;
btn.dataset.pwaBound = "1";
btn.addEventListener("click", () => {
runInstallPrompt();
});
});
}
window.setupPwaInstallButtons = (scope) => {
bindInstallButtonClick(scope);
refreshInstallButtons(scope);
};
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredPrompt = event;
installMode = "prompt";
refreshInstallButtons();
});
window.addEventListener("appinstalled", () => {
deferredPrompt = null;
installMode = "none";
refreshInstallButtons();
});
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js?v=0.1.2").catch(() => {});
if (isIosDevice() && !isStandalone()) {
installMode = "ios";
}
window.setupPwaInstallButtons(document);
});
const observer = new MutationObserver(() => {
window.setupPwaInstallButtons(document);
});
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
} else {
window.addEventListener("DOMContentLoaded", () => {
observer.observe(document.body, { childList: true, subtree: true });
window.setupPwaInstallButtons(document);
});
}
})();
+119
View File
@@ -0,0 +1,119 @@
(() => {
const THEME_KEY = 'grok2api_theme_preference';
const THEME_LIGHT = 'light';
const THEME_DARK = 'dark';
const THEME_AUTO = 'auto';
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
function safeGetPreference() {
try {
const stored = localStorage.getItem(THEME_KEY);
if (stored === THEME_LIGHT || stored === THEME_DARK || stored === THEME_AUTO) {
return stored;
}
} catch (e) {
// 忽略 localStorage 访问异常
}
return THEME_AUTO;
}
function safeSetPreference(preference) {
try {
localStorage.setItem(THEME_KEY, preference);
} catch (e) {
// 忽略 localStorage 写入异常
}
}
function resolvePreference(preference) {
if (preference === THEME_LIGHT || preference === THEME_DARK) {
return preference;
}
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return THEME_DARK;
}
return THEME_LIGHT;
}
function syncThemeMeta(resolvedTheme) {
if (!metaThemeColor) return;
metaThemeColor.setAttribute('content', resolvedTheme === THEME_DARK ? '#0b0f14' : '#fafafa');
}
function syncThemeButtons(preference, resolvedTheme) {
const buttons = document.querySelectorAll('[data-theme-toggle-btn]');
buttons.forEach((btn) => {
const nextLabel = resolvedTheme === THEME_DARK ? '日间模式' : '深夜模式';
const nextTitle = resolvedTheme === THEME_DARK ? '切换到日间模式' : '切换到深夜模式';
btn.textContent = nextLabel;
btn.setAttribute('title', nextTitle);
btn.classList.toggle('theme-active', resolvedTheme === THEME_DARK);
btn.setAttribute('aria-pressed', resolvedTheme === THEME_DARK ? 'true' : 'false');
btn.setAttribute('data-theme-preference', preference);
btn.setAttribute('data-theme-resolved', resolvedTheme);
});
}
function applyTheme(preference, persist = false) {
const normalized = (preference === THEME_LIGHT || preference === THEME_DARK || preference === THEME_AUTO)
? preference
: THEME_AUTO;
const resolved = resolvePreference(normalized);
const root = document.documentElement;
root.setAttribute('data-theme-preference', normalized);
root.setAttribute('data-theme', resolved);
syncThemeMeta(resolved);
syncThemeButtons(normalized, resolved);
if (persist) {
safeSetPreference(normalized);
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') === THEME_DARK ? THEME_DARK : THEME_LIGHT;
const next = current === THEME_DARK ? THEME_LIGHT : THEME_DARK;
applyTheme(next, true);
}
function handleThemeButtonClick(event) {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest('[data-theme-toggle-btn]');
if (!button) return;
event.preventDefault();
toggleTheme();
}
function init() {
applyTheme(safeGetPreference(), false);
document.addEventListener('click', handleThemeButtonClick);
if (window.matchMedia) {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const onSystemThemeChange = () => {
if (safeGetPreference() === THEME_AUTO) {
applyTheme(THEME_AUTO, false);
}
};
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', onSystemThemeChange);
} else if (typeof media.addListener === 'function') {
media.addListener(onSystemThemeChange);
}
}
}
window.themeController = {
applyTheme,
refreshButtons() {
const preference = document.documentElement.getAttribute('data-theme-preference') || safeGetPreference();
const resolved = document.documentElement.getAttribute('data-theme') || resolvePreference(preference);
syncThemeButtons(preference, resolved);
syncThemeMeta(resolved);
},
getPreference: safeGetPreference,
};
init();
})();
+71 -15
View File
@@ -225,28 +225,38 @@ body {
display: block;
box-sizing: border-box;
min-height: 150px;
padding-left: 112px;
padding-right: 116px;
padding-bottom: 44px;
padding-left: 12px;
padding-right: calc(var(--pe-action-width, 104px) + 12px);
padding-bottom: 82px;
}
.field .prompt-enhance-wrap > .inline-submit-btn,
.field .prompt-enhance-wrap > .prompt-enhance-actions {
position: absolute;
right: 8px;
z-index: 3;
height: 30px;
}
.field .prompt-enhance-wrap > .inline-submit-btn {
position: absolute;
left: 8px;
bottom: 8px;
z-index: 3;
height: 30px;
min-width: 88px;
width: var(--pe-action-width, 104px);
min-width: var(--pe-action-width, 104px);
padding: 0 10px;
}
.field .prompt-enhance-wrap > .prompt-enhance-btn {
right: 8px;
bottom: 8px;
.field .prompt-enhance-wrap > .prompt-enhance-actions {
width: var(--pe-action-width, 104px);
min-width: var(--pe-action-width, 104px);
padding: 0;
}
.field .prompt-enhance-wrap > .prompt-lang-toggle-btn {
right: 66px;
.field .prompt-enhance-wrap > .prompt-enhance-actions {
right: 8px;
bottom: 44px;
}
.field .prompt-enhance-wrap > .inline-submit-btn {
right: 8px;
bottom: 8px;
}
@@ -316,6 +326,35 @@ body {
}
}
@media (max-width: 768px) {
.field .prompt-enhance-wrap > textarea.form-textarea {
padding-right: calc(var(--pe-action-width, 92px) + 12px);
padding-bottom: 76px;
}
.field .prompt-enhance-wrap > .inline-submit-btn,
.field .prompt-enhance-wrap > .prompt-enhance-actions {
height: var(--pe-action-height, 28px);
}
.field .prompt-enhance-wrap > .inline-submit-btn {
width: var(--pe-action-width, 92px);
min-width: var(--pe-action-width, 92px);
padding: 0 10px;
}
.field .prompt-enhance-wrap > .prompt-enhance-actions {
width: var(--pe-action-width, 92px);
min-width: var(--pe-action-width, 92px);
bottom: 40px;
}
.field .prompt-enhance-wrap > .inline-submit-btn {
bottom: 8px;
}
}
.empty-state {
border: 1px dashed var(--workbench-border);
border-radius: 10px;
@@ -379,11 +418,28 @@ body {
.history-prompt {
margin-top: 4px;
font-size: 12px;
color: #111827;
color: var(--workbench-title);
line-height: 1.5;
word-break: break-word;
}
html[data-theme='dark'] .history-item {
background: #111821;
border-color: #324154;
}
html[data-theme='dark'] .history-line {
color: #b7c4d4;
}
html[data-theme='dark'] .history-line strong {
color: #eef4ff;
}
html[data-theme='dark'] .history-prompt {
color: #e8effa;
}
.history-actions {
margin-top: 8px;
display: flex;
+83 -8
View File
@@ -12,6 +12,40 @@ body {
background-color: var(--video-bg);
background-image: none;
overflow-x: hidden;
max-width: 100vw;
width: 100%;
}
/* 移动端强制约束 */
@media (max-width: 768px) {
.video-main-grid {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
}
.video-main-left,
.video-main-right {
width: 100%;
min-width: 0;
max-width: 100%;
}
.video-card {
width: 100%;
max-width: 100%;
box-sizing: border-box;
min-width: 0;
}
/* 修复编辑面板溢出 */
#editPanel {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
overflow-x: hidden;
}
}
.video-header-row {
@@ -69,6 +103,15 @@ body {
display: flex;
gap: 8px;
flex-wrap: wrap;
/* Allow wrapping on small screens */
width: 100%;
}
.video-actions button {
flex: 1 1 auto;
/* Allow buttons to shrink/grow */
min-width: 0;
white-space: nowrap;
}
.video-top-grid {
@@ -93,7 +136,7 @@ body {
}
/* 生成设置卡片始终随文档流滚动,禁止任何置顶行为 */
.video-main-left > .video-card:first-child {
.video-main-left>.video-card:first-child {
position: static !important;
top: auto !important;
z-index: auto !important;
@@ -468,6 +511,7 @@ body {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(220%);
}
@@ -1001,9 +1045,33 @@ html[data-theme='dark'] .cache-video-item.is-active .cache-video-thumb-wrap {
}
.settings-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: auto;
display: grid;
grid-template-columns: 1fr !important;
/* Force single column */
width: 100% !important;
max-width: 100% !important;
gap: 8px;
min-width: 0 !important;
}
.settings-block {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
box-sizing: border-box;
}
.settings-actions {
width: 100% !important;
max-width: 100% !important;
flex-wrap: wrap;
min-width: 0 !important;
}
.settings-actions button {
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
}
.prompt-block,
@@ -1099,8 +1167,9 @@ html[data-theme='dark'] .cache-video-item.is-active .cache-video-thumb-wrap {
width: min(100%, 420px);
margin-left: auto;
margin-right: auto;
height: 320px;
max-height: 320px;
height: auto;
aspect-ratio: 3/2;
/* Removed fixed height 320px to prevent deformation on mobile */
display: flex;
align-items: center;
justify-content: center;
@@ -1114,7 +1183,7 @@ html[data-theme='dark'] .cache-video-item.is-active .cache-video-thumb-wrap {
inset: 0;
width: 100%;
height: 100%;
max-height: 320px;
/* max-height: 320px; Removed to follow container */
object-fit: contain;
}
@@ -1159,7 +1228,13 @@ html[data-theme='dark'] .cache-video-item.is-active .cache-video-thumb-wrap {
.settings-actions .geist-button,
.settings-actions .geist-button-outline {
flex: 1;
flex: 1 1 0%;
/* Force equal width, side-by-side */
min-width: 0;
padding-left: 4px;
padding-right: 4px;
justify-content: center;
width: auto !important;
/* Allow buttons to shrink */
}
}
}
+8
View File
@@ -249,3 +249,11 @@ body {
border-radius: 999px;
background: #111;
}
@media (max-width: 900px) {
.voice-actions,
#clearLogBtn,
#copyLogBtn {
display: none !important;
}
}
+52 -9
View File
@@ -57,6 +57,7 @@
let editDurationEstimateMs = 14000;
let wsPausedByEdit = false;
let lightboxImageFullscreen = false;
let lightboxEditAbortController = null;
let finalMinBytesDefault = 100000;
const lightboxHistoryByItem = new WeakMap();
if (lightboxEditSend) {
@@ -402,6 +403,26 @@
}, 900);
}
function setLightboxEditButtonState(running) {
if (!lightboxEditSend) return;
lightboxEditSend.dataset.running = running ? '1' : '0';
if (running) {
lightboxEditSend.textContent = '中止';
lightboxEditSend.disabled = false;
return;
}
lightboxEditSend.textContent = '发送编辑';
const currentItem = getItemByImageIndex(currentImageIndex);
const currentParent = currentItem ? String(currentItem.dataset.parentPostId || '').trim() : '';
lightboxEditSend.disabled = !currentParent;
}
function cancelLightboxEdit() {
if (lightboxEditAbortController) {
lightboxEditAbortController.abort();
}
}
function estimateBase64Bytes(raw) {
if (!raw) return null;
if (raw.startsWith('http://') || raw.startsWith('https://')) {
@@ -496,7 +517,7 @@
return await res.json();
}
async function requestImagineEditStream(authHeader, prompt, parentPostId, sourceImageUrl, onProgress) {
async function requestImagineEditStream(authHeader, prompt, parentPostId, sourceImageUrl, onProgress, signal) {
const res = await fetch('/v1/public/imagine/edit', {
method: 'POST',
headers: {
@@ -509,6 +530,7 @@
source_image_url: sourceImageUrl,
stream: true,
}),
signal,
});
if (!res.ok) {
@@ -1841,7 +1863,10 @@
renderLightboxHistory(item);
if (lightboxEditSend) {
const parentPostId = item ? String(item.dataset.parentPostId || '').trim() : '';
lightboxEditSend.disabled = !parentPostId;
if (String(lightboxEditSend.dataset.running || '0') !== '1') {
lightboxEditSend.disabled = !parentPostId;
lightboxEditSend.textContent = '发送编辑';
}
lightboxEditSend.title = parentPostId ? '使用 parentPostId 发起编辑' : '当前图片缺少 parentPostId,无法编辑';
if (lightboxEditInput && !lightboxEditInput.value.trim()) {
const seedPrompt = item ? String(item.dataset.prompt || '').trim() : '';
@@ -1870,6 +1895,10 @@
}
async function startEditFromLightbox() {
if (lightboxEditSend && String(lightboxEditSend.dataset.running || '0') === '1') {
cancelLightboxEdit();
return;
}
const item = getItemByImageIndex(currentImageIndex);
if (!item) {
toast('未找到当前图片', 'error');
@@ -1901,9 +1930,11 @@
parentPostId,
String(item.dataset.sourceImageUrl || '').trim()
);
lightboxEditAbortController = new AbortController();
if (lightboxEditSend) {
lightboxEditSend.disabled = true;
lightboxEditSend.textContent = '编辑中...';
lightboxEditSend.dataset.running = '1';
lightboxEditSend.textContent = '中止';
lightboxEditSend.disabled = false;
}
if (lightboxEditInput) {
lightboxEditInput.disabled = true;
@@ -1925,7 +1956,8 @@
} else if (text) {
setEditProgress(editProgressValue, text);
}
}
},
lightboxEditAbortController ? lightboxEditAbortController.signal : undefined
);
const list = (data && Array.isArray(data.data)) ? data.data : [];
const first = list.length ? list[0] : null;
@@ -1996,15 +2028,19 @@
lightboxEditInput.value = '';
}
} catch (e) {
if (e && e.name === 'AbortError') {
finishEditProgress(false, '已中止');
toast('已中止编辑', 'warning');
return;
}
const msg = String(e && e.message ? e.message : e);
finishEditProgress(false, '编辑失败');
toast(`编辑失败:${msg}`, 'error');
} finally {
lightboxEditAbortController = null;
if (lightboxEditSend) {
lightboxEditSend.textContent = '发送编辑';
const currentItem = getItemByImageIndex(currentImageIndex);
const currentParent = currentItem ? String(currentItem.dataset.parentPostId || '').trim() : '';
lightboxEditSend.disabled = !currentParent;
lightboxEditSend.dataset.running = '0';
setLightboxEditButtonState(false);
}
if (lightboxEditInput) {
lightboxEditInput.disabled = false;
@@ -2015,6 +2051,7 @@
if (lightbox && closeLightbox) {
closeLightbox.addEventListener('click', (e) => {
e.stopPropagation();
cancelLightboxEdit();
setLightboxImageFullscreen(false);
lightbox.classList.remove('active');
setLightboxKeyboardShift(0);
@@ -2033,6 +2070,7 @@
});
lightbox.addEventListener('click', () => {
cancelLightboxEdit();
setLightboxImageFullscreen(false);
lightbox.classList.remove('active');
setLightboxKeyboardShift(0);
@@ -2061,6 +2099,10 @@
if (lightboxEditSend) {
lightboxEditSend.addEventListener('click', async (e) => {
e.stopPropagation();
if (String(lightboxEditSend.dataset.running || '0') === '1') {
cancelLightboxEdit();
return;
}
await startEditFromLightbox();
});
}
@@ -2113,6 +2155,7 @@
setLightboxImageFullscreen(false);
return;
}
cancelLightboxEdit();
lightbox.classList.remove('active');
setLightboxKeyboardShift(0);
currentImageIndex = -1;
+47 -14
View File
@@ -1,4 +1,4 @@
(() => {
(() => {
const seedImageInput = document.getElementById('seedImageInput');
const selectSeedBtn = document.getElementById('selectSeedBtn');
const seedFileName = document.getElementById('seedFileName');
@@ -32,6 +32,7 @@
history: [],
editRound: 0,
};
let workbenchEditAbortController = null;
let editProgressTimer = null;
let editProgressHideTimer = null;
let editProgressValue = 0;
@@ -406,7 +407,13 @@
function setEditing(loading) {
state.editing = Boolean(loading);
if (submitEditBtn) submitEditBtn.disabled = state.editing;
if (submitEditBtn) {
submitEditBtn.disabled = false;
submitEditBtn.dataset.running = state.editing ? '1' : '0';
submitEditBtn.textContent = state.editing ? '中止' : '执行编辑';
submitEditBtn.classList.toggle('is-editing', state.editing);
submitEditBtn.removeAttribute('aria-disabled');
}
if (selectSeedBtn) selectSeedBtn.disabled = state.editing;
if (resetCycleBtn) resetCycleBtn.disabled = state.editing;
if (clearHistoryBtn) clearHistoryBtn.disabled = state.editing;
@@ -414,12 +421,19 @@
if (state.editing) {
setStatus('running', '编辑中...');
if (submitEditBtn) submitEditBtn.textContent = '编辑中...';
} else {
if (submitEditBtn) submitEditBtn.textContent = '执行编辑';
}
}
function forceSubmitButtonAbortClickable() {
if (!submitEditBtn) return;
if (!state.editing) return;
submitEditBtn.disabled = false;
submitEditBtn.dataset.running = '1';
submitEditBtn.textContent = '中止';
submitEditBtn.classList.add('is-editing');
submitEditBtn.removeAttribute('aria-disabled');
}
function setCurrentFromEntry(entry) {
if (!entry) return;
state.currentParentPostId = String(entry.parentPostId || '').trim();
@@ -466,11 +480,11 @@
const line1 = document.createElement('div');
line1.className = 'history-line';
line1.innerHTML = `<strong>#${entry.round}</strong> · ${formatTime(entry.createdAt)} · ${entry.elapsedMs}ms`;
line1.innerHTML = `<strong>#${entry.round}</strong> 路 ${formatTime(entry.createdAt)} 路 ${entry.elapsedMs}ms`;
const line2 = document.createElement('div');
line2.className = 'history-line';
line2.innerHTML = `mode=<strong>${entry.mode}</strong> · parentPostId=<strong>${shortId(entry.parentPostId)}</strong>`;
line2.innerHTML = `mode=<strong>${entry.mode}</strong> 路 parentPostId=<strong>${shortId(entry.parentPostId)}</strong>`;
const prompt = document.createElement('div');
prompt.className = 'history-prompt';
@@ -558,7 +572,7 @@
return text;
}
async function requestWorkbenchEditStream(authHeader, body, onProgress) {
async function requestWorkbenchEditStream(authHeader, body, onProgress, signal) {
const payload = {
...body,
stream: true,
@@ -570,6 +584,7 @@
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal,
});
if (!res.ok) {
@@ -663,7 +678,12 @@
}
async function runEdit() {
if (state.editing) return;
if (state.editing) {
if (workbenchEditAbortController) {
workbenchEditAbortController.abort();
}
return;
}
const prompt = String(editPromptInput ? editPromptInput.value : '').trim();
if (!prompt) {
@@ -701,10 +721,13 @@
}
setEditing(true);
forceSubmitButtonAbortClickable();
startEditProgress();
workbenchEditAbortController = new AbortController();
try {
const payload = await requestWorkbenchEditStream(authHeader, body, (evt) => {
forceSubmitButtonAbortClickable();
const next = Number(evt && evt.progress ? evt.progress : 0);
const message = String((evt && evt.message) || '').trim();
if (Number.isFinite(next) && next > 0) {
@@ -715,7 +738,7 @@
setEditProgress(editProgressValue, message);
setStatus('running', message);
}
});
}, workbenchEditAbortController ? workbenchEditAbortController.signal : undefined);
const imageUrl = String(payload?.data?.[0]?.url || '').trim();
if (!imageUrl) {
throw new Error('返回结果缺少图片 URL');
@@ -777,10 +800,17 @@
setStatus('done', `编辑完成 · round #${entry.round}`);
toast('编辑成功,已更新当前画面', 'success');
} catch (e) {
finishEditProgress(false, '编辑失败');
setStatus('error', '编辑失败');
toast(String(e.message || e), 'error');
if (e && e.name === 'AbortError') {
finishEditProgress(false, '已中止');
setStatus('error', '已中止');
toast('已中止编辑', 'warning');
} else {
finishEditProgress(false, '编辑失败');
setStatus('error', '编辑失败');
toast(String(e.message || e), 'error');
}
} finally {
workbenchEditAbortController = null;
setEditing(false);
}
}
@@ -796,7 +826,7 @@
if (!file) return;
try {
await applySeedImageFile(file, '本地图片');
await applySeedImageFile(file, '本地图像');
} catch (e) {
toast(String(e.message || e), 'error');
} finally {
@@ -976,3 +1006,6 @@
init();
})();
+67 -16
View File
@@ -1,4 +1,4 @@
(() => {
(() => {
const imagePromptInput = document.getElementById('imagePromptInput');
const videoPromptInput = document.getElementById('videoPromptInput');
const ratioSelect = document.getElementById('ratioSelect');
@@ -64,6 +64,7 @@
editDurationEstimateMs: 14000,
imageFullscreen: false,
};
let lightboxEditAbortController = null;
if (lightboxEditSend) {
lightboxEditSend.disabled = true;
}
@@ -143,15 +144,28 @@
}
}
function setToggleButtonState(button, running, runningText) {
if (!button) return;
if (!button.dataset.defaultText) {
button.dataset.defaultText = String(button.textContent || '').trim() || '';
}
button.dataset.running = running ? '1' : '0';
button.textContent = running ? runningText : (button.dataset.defaultText || button.textContent);
}
function updateImageButtons() {
if (generateBatchBtn) generateBatchBtn.disabled = state.imageRunning;
const running = state.imageRunning;
setToggleButtonState(generateBatchBtn, running, '中止');
if (generateBatchBtn) generateBatchBtn.disabled = false;
if (nextBatchBtn) nextBatchBtn.disabled = state.imageRunning;
if (stopBatchBtn) stopBatchBtn.disabled = !state.imageRunning;
}
function updateVideoButtons() {
const hasSelected = Boolean(getSelectedCandidate());
if (startVideoBtn) startVideoBtn.disabled = state.videoRunning || !hasSelected;
const running = state.videoRunning;
setToggleButtonState(startVideoBtn, running, '中止');
if (startVideoBtn) startVideoBtn.disabled = running ? false : !hasSelected;
if (stopVideoBtn) stopVideoBtn.disabled = !state.videoRunning;
}
@@ -317,7 +331,7 @@
}
}
async function requestImagineEditStream(authHeader, prompt, parentPostId, sourceImageUrl, onProgress) {
async function requestImagineEditStream(authHeader, prompt, parentPostId, sourceImageUrl, onProgress, signal) {
const res = await fetch('/v1/public/imagine/edit', {
method: 'POST',
headers: {
@@ -330,6 +344,7 @@
source_image_url: sourceImageUrl,
stream: true,
}),
signal,
});
if (!res.ok) {
@@ -577,6 +592,26 @@
}, 900);
}
function setLightboxEditButtonState(running) {
if (!lightboxEditSend) return;
lightboxEditSend.dataset.running = running ? '1' : '0';
if (running) {
lightboxEditSend.textContent = '中止';
lightboxEditSend.disabled = false;
return;
}
lightboxEditSend.textContent = '发送编辑';
const current = getCurrentLightboxCandidate();
const currentParent = current ? String(current.parentPostId || '').trim() : '';
lightboxEditSend.disabled = !currentParent;
}
function cancelLightboxEdit() {
if (lightboxEditAbortController) {
lightboxEditAbortController.abort();
}
}
function renderLightboxHistory(candidate) {
if (!lightboxHistoryCount || !lightboxHistoryEmpty || !lightboxHistoryList) return;
lightboxHistoryList.innerHTML = '';
@@ -707,7 +742,9 @@
if (lightboxNext) lightboxNext.disabled = (index === state.candidates.length - 1);
if (lightboxEditSend) {
const parentPostId = String(candidate.parentPostId || '').trim();
lightboxEditSend.disabled = !parentPostId;
if (String(lightboxEditSend.dataset.running || '0') !== '1') {
setLightboxEditButtonState(false);
}
lightboxEditSend.title = parentPostId ? '使用 parentPostId 发起编辑' : '当前图片缺少 parentPostId,无法编辑';
if (lightboxEditInput && !lightboxEditInput.value.trim()) {
const seedPrompt = String(candidate.prompt || '').trim();
@@ -732,11 +769,13 @@
function closeLightboxView() {
if (!lightbox) return;
cancelLightboxEdit();
setLightboxImageFullscreen(false);
lightbox.classList.remove('active');
setLightboxKeyboardShift(0);
state.lightboxIndex = -1;
if (lightboxEditSend) {
lightboxEditSend.dataset.running = '0';
lightboxEditSend.textContent = '发送编辑';
lightboxEditSend.disabled = true;
}
@@ -789,10 +828,8 @@
[candidate.sourceImageUrl, candidate.imageUrl],
parentPostId
);
if (lightboxEditSend) {
lightboxEditSend.disabled = true;
lightboxEditSend.textContent = '编辑中...';
}
lightboxEditAbortController = new AbortController();
setLightboxEditButtonState(true);
if (lightboxEditInput) {
lightboxEditInput.disabled = true;
}
@@ -814,7 +851,8 @@
} else if (text) {
setEditProgress(state.editProgressValue, text);
}
}
},
lightboxEditAbortController ? lightboxEditAbortController.signal : undefined
);
const list = (data && Array.isArray(data.data)) ? data.data : [];
const first = list.length ? list[0] : null;
@@ -878,16 +916,17 @@
lightboxEditInput.value = '';
}
} catch (e) {
if (e && e.name === 'AbortError') {
finishEditProgress(false, '已中止');
toast('已中止编辑', 'warning');
return;
}
const msg = String(e && e.message ? e.message : e);
finishEditProgress(false, '编辑失败');
toast(`编辑失败:${msg}`, 'error');
} finally {
if (lightboxEditSend) {
lightboxEditSend.textContent = '发送编辑';
const current = getCurrentLightboxCandidate();
const currentParent = current ? String(current.parentPostId || '').trim() : '';
lightboxEditSend.disabled = !currentParent;
}
lightboxEditAbortController = null;
setLightboxEditButtonState(false);
if (lightboxEditInput) {
lightboxEditInput.disabled = false;
}
@@ -1628,6 +1667,10 @@
if (generateBatchBtn) {
generateBatchBtn.addEventListener('click', () => {
if (String(generateBatchBtn.dataset.running || '0') === '1') {
stopImageBatch(false);
return;
}
startImageBatch();
});
}
@@ -1649,6 +1692,10 @@
if (startVideoBtn) {
startVideoBtn.addEventListener('click', () => {
if (String(startVideoBtn.dataset.running || '0') === '1') {
stopVideos(false);
return;
}
startVideos();
});
}
@@ -1701,6 +1748,10 @@
if (lightboxEditSend) {
lightboxEditSend.addEventListener('click', async (e) => {
e.stopPropagation();
if (String(lightboxEditSend.dataset.running || '0') === '1') {
cancelLightboxEdit();
return;
}
await startEditFromLightbox();
});
}
+1
View File
@@ -142,6 +142,7 @@
}
function lockWorkspacePreviewSize(force = false) {
if (window.innerWidth < 640) return; // Disable on mobile to allow fluid width
if (!editPreviewWrap || !editVideo) return;
if (!shouldLockWorkspacePreviewSize()) {
editPreviewWrap.style.removeProperty('width');
+24
View File
@@ -0,0 +1,24 @@
{
"name": "Grok2API",
"short_name": "Grok2API",
"description": "Grok2API 功能工作台",
"start_url": "/login",
"scope": "/",
"display": "standalone",
"background_color": "#fafafa",
"theme_color": "#fafafa",
"icons": [
{
"src": "/static/public/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/public/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+1 -1
View File
@@ -291,7 +291,7 @@
<script src="/static/common/js/footer.js?v=0.3.0"></script>
<script src="/static/common/js/mobile-fab.js?v=0.1.1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/common/js/prompt-enhancer.js?v=0.1.2"></script>
<script src="/static/common/js/prompt-enhancer.js?v=0.1.15"></script>
<script src="/static/common/js/pwa.js?v=0.1.1"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="/static/public/js/imagine.js?v=0.3.8"></script>
@@ -19,7 +19,7 @@
<script src="/static/common/js/theme.js?v=0.1.0"></script>
<link href="/static/common/css/common.css?v=0.3.7" rel="stylesheet">
<link href="/static/common/css/toast.css" rel="stylesheet">
<link href="/static/public/css/imagine_workbench.css?v=0.1.2" rel="stylesheet">
<link href="/static/public/css/imagine_workbench.css?v=0.1.9" rel="stylesheet">
</head>
<body class="public-page min-h-screen flex flex-col" style="background-color: var(--bg);">
@@ -129,7 +129,7 @@
<script src="/static/common/js/footer.js?v=0.3.0"></script>
<script src="/static/common/js/mobile-fab.js?v=0.1.1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/common/js/prompt-enhancer.js?v=0.1.2"></script>
<script src="/static/common/js/prompt-enhancer.js?v=0.1.15"></script>
<script src="/static/common/js/pwa.js?v=0.1.1"></script>
<script src="/static/public/js/imagine_workbench.js?v=0.1.4"></script>
</body>
+1 -1
View File
@@ -201,7 +201,7 @@
<script src="/static/common/js/footer.js?v=0.3.0"></script>
<script src="/static/common/js/mobile-fab.js?v=0.1.1"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/common/js/prompt-enhancer.js?v=0.1.2"></script>
<script src="/static/common/js/prompt-enhancer.js?v=0.1.15"></script>
<script src="/static/common/js/pwa.js?v=0.1.1"></script>
<script src="/static/public/js/nsfw.js?v=0.3.5"></script>
</body>
+1 -1
View File
@@ -296,7 +296,7 @@
<script src="/static/common/js/public-header.js?v=0.3.3"></script>
<script src="/static/common/js/footer.js?v=0.3.0"></script>
<script src="/static/common/js/toast.js"></script>
<script src="/static/common/js/prompt-enhancer.js?v=0.1.2"></script>
<script src="/static/common/js/prompt-enhancer.js?v=0.1.15"></script>
<script src="/static/common/js/pwa.js?v=0.1.1"></script>
<script src="/static/vendor/ffmpeg/ffmpeg.js?v=0.1.0"></script>
<script src="/static/vendor/ffmpeg/ffmpeg-util.js?v=0.1.0"></script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+56
View File
@@ -0,0 +1,56 @@
const CACHE_PREFIX = "grok2api-pwa-";
const CACHE_NAME = `${CACHE_PREFIX}v2`;
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
await self.clients.claim();
})()
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
const cacheablePathPrefixes = [
"/static/",
"/login",
"/imagine",
"/video",
"/voice",
"/nsfw",
"/imagine-workbench",
"/manifest.webmanifest"
];
const isCacheable = cacheablePathPrefixes.some((prefix) => url.pathname.startsWith(prefix));
if (!isCacheable) return;
event.respondWith(
(async () => {
try {
const response = await fetch(request);
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) return cached;
throw error;
}
})()
);
});
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FFmpegWASM=t():e.FFmpegWASM=t()}(self,(()=>(()=>{"use strict";const e="https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js";var t;!function(e){e.LOAD="LOAD",e.EXEC="EXEC",e.WRITE_FILE="WRITE_FILE",e.READ_FILE="READ_FILE",e.DELETE_FILE="DELETE_FILE",e.RENAME="RENAME",e.CREATE_DIR="CREATE_DIR",e.LIST_DIR="LIST_DIR",e.DELETE_DIR="DELETE_DIR",e.ERROR="ERROR",e.DOWNLOAD="DOWNLOAD",e.PROGRESS="PROGRESS",e.LOG="LOG",e.MOUNT="MOUNT",e.UNMOUNT="UNMOUNT"}(t||(t={}));const r=new Error("unknown message type"),a=new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first"),s=(new Error("called FFmpeg.terminate()"),new Error("failed to import ffmpeg-core.js"));let o;return self.onmessage=async({data:{id:n,type:E,data:c}})=>{const i=[];let p;try{if(E!==t.LOAD&&!o)throw a;switch(E){case t.LOAD:p=await(async({coreURL:r,wasmURL:a,workerURL:n})=>{const E=!o;try{r||(r=e),importScripts(r)}catch{if(r||(r=e.replace("/umd/","/esm/")),self.createFFmpegCore=(await import(r)).default,!self.createFFmpegCore)throw s}const c=r,i=a||r.replace(/.js$/g,".wasm"),p=n||r.replace(/.js$/g,".worker.js");return o=await self.createFFmpegCore({mainScriptUrlOrBlob:`${c}#${btoa(JSON.stringify({wasmURL:i,workerURL:p}))}`}),o.setLogger((e=>self.postMessage({type:t.LOG,data:e}))),o.setProgress((e=>self.postMessage({type:t.PROGRESS,data:e}))),E})(c);break;case t.EXEC:p=(({args:e,timeout:t=-1})=>{o.setTimeout(t),o.exec(...e);const r=o.ret;return o.reset(),r})(c);break;case t.WRITE_FILE:p=(({path:e,data:t})=>(o.FS.writeFile(e,t),!0))(c);break;case t.READ_FILE:p=(({path:e,encoding:t})=>o.FS.readFile(e,{encoding:t}))(c);break;case t.DELETE_FILE:p=(({path:e})=>(o.FS.unlink(e),!0))(c);break;case t.RENAME:p=(({oldPath:e,newPath:t})=>(o.FS.rename(e,t),!0))(c);break;case t.CREATE_DIR:p=(({path:e})=>(o.FS.mkdir(e),!0))(c);break;case t.LIST_DIR:p=(({path:e})=>{const t=o.FS.readdir(e),r=[];for(const a of t){const t=o.FS.stat(`${e}/${a}`),s=o.FS.isDir(t.mode);r.push({name:a,isDir:s})}return r})(c);break;case t.DELETE_DIR:p=(({path:e})=>(o.FS.rmdir(e),!0))(c);break;case t.MOUNT:p=(({fsType:e,options:t,mountPoint:r})=>{const a=e,s=o.FS.filesystems[a];return!!s&&(o.FS.mount(s,t,r),!0)})(c);break;case t.UNMOUNT:p=(({mountPoint:e})=>(o.FS.unmount(e),!0))(c);break;default:throw r}}catch(e){return void self.postMessage({id:n,type:t.ERROR,data:e.toString()})}p instanceof Uint8Array&&i.push(p.buffer),self.postMessage({id:n,type:E,data:p},i)},{}})()));
//# sourceMappingURL=814.ffmpeg.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"814.ffmpeg.js","mappings":"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","sources":["webpack://FFmpegWASM/webpack/universalModuleDefinition","webpack://FFmpegWASM/./dist/esm/const.js","webpack://FFmpegWASM/./dist/esm/errors.js","webpack://FFmpegWASM/./dist/esm/worker.js"],"sourcesContent":["(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"FFmpegWASM\"] = factory();\n\telse\n\t\troot[\"FFmpegWASM\"] = factory();\n})(self, () => {\nreturn ","export const MIME_TYPE_JAVASCRIPT = \"text/javascript\";\nexport const MIME_TYPE_WASM = \"application/wasm\";\nexport const CORE_VERSION = \"0.12.6\";\nexport const CORE_URL = `https://unpkg.com/@ffmpeg/core@${CORE_VERSION}/dist/umd/ffmpeg-core.js`;\nexport var FFMessageType;\n(function (FFMessageType) {\n FFMessageType[\"LOAD\"] = \"LOAD\";\n FFMessageType[\"EXEC\"] = \"EXEC\";\n FFMessageType[\"WRITE_FILE\"] = \"WRITE_FILE\";\n FFMessageType[\"READ_FILE\"] = \"READ_FILE\";\n FFMessageType[\"DELETE_FILE\"] = \"DELETE_FILE\";\n FFMessageType[\"RENAME\"] = \"RENAME\";\n FFMessageType[\"CREATE_DIR\"] = \"CREATE_DIR\";\n FFMessageType[\"LIST_DIR\"] = \"LIST_DIR\";\n FFMessageType[\"DELETE_DIR\"] = \"DELETE_DIR\";\n FFMessageType[\"ERROR\"] = \"ERROR\";\n FFMessageType[\"DOWNLOAD\"] = \"DOWNLOAD\";\n FFMessageType[\"PROGRESS\"] = \"PROGRESS\";\n FFMessageType[\"LOG\"] = \"LOG\";\n FFMessageType[\"MOUNT\"] = \"MOUNT\";\n FFMessageType[\"UNMOUNT\"] = \"UNMOUNT\";\n})(FFMessageType || (FFMessageType = {}));\n","export const ERROR_UNKNOWN_MESSAGE_TYPE = new Error(\"unknown message type\");\nexport const ERROR_NOT_LOADED = new Error(\"ffmpeg is not loaded, call `await ffmpeg.load()` first\");\nexport const ERROR_TERMINATED = new Error(\"called FFmpeg.terminate()\");\nexport const ERROR_IMPORT_FAILURE = new Error(\"failed to import ffmpeg-core.js\");\n","/// <reference no-default-lib=\"true\" />\n/// <reference lLine truncated
+21
View File
@@ -0,0 +1,21 @@
var createFFmpegCore = (() => {
var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;
if (typeof __filename !== 'undefined') _scriptDir = _scriptDir || __filename;
return (
function(createFFmpegCore = {}) {
var Module=typeof createFFmpegCore!="undefined"?createFFmpegCore:{};var readyPromiseResolve,readyPromiseReject;Module["ready"]=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});const NULL=0;const SIZE_I32=Uint32Array.BYTES_PER_ELEMENT;const DEFAULT_ARGS=["./ffmpeg","-nostdin","-y"];Module["NULL"]=NULL;Module["SIZE_I32"]=SIZE_I32;Module["DEFAULT_ARGS"]=DEFAULT_ARGS;Module["ret"]=-1;Module["timeout"]=-1;Module["logger"]=()=>{};Module["progress"]=()=>{};function stringToPtr(str){const len=Module["lengthBytesUTF8"](str)+1;const ptr=Module["_malloc"](len);Module["stringToUTF8"](str,ptr,len);return ptr}function stringsToPtr(strs){const len=strs.length;const ptr=Module["_malloc"](len*SIZE_I32);for(let i=0;i<len;i++){Module["setValue"](ptr+SIZE_I32*i,stringToPtr(strs[i]),"i32")}return ptr}function print(message){Module["logger"]({type:"stdout",message:message})}function printErr(message){if(!message.startsWith("Aborted(native code called abort())"))Module["logger"]({type:"stderr",message:message})}function exec(..._args){const args=[...Module["DEFAULT_ARGS"],..._args];try{Module["_ffmpeg"](args.length,stringsToPtr(args))}catch(e){if(!e.message.startsWith("Aborted")){throw e}}return Module["ret"]}function setLogger(logger){Module["logger"]=logger}function setTimeout(timeout){Module["timeout"]=timeout}function setProgress(handler){Module["progress"]=handler}function receiveProgress(progress,time){Module["progress"]({progress:progress,time:time})}function reset(){Module["ret"]=-1;Module["timeout"]=-1}function _locateFile(path,prefix){const mainScriptUrlOrBlob=Module["mainScriptUrlOrBlob"];if(mainScriptUrlOrBlob){const{wasmURL:wasmURL,workerURL:workerURL}=JSON.parse(atob(mainScriptUrlOrBlob.slice(mainScriptUrlOrBlob.lastIndexOf("#")+1)));if(path.endsWith(".wasm"))return wasmURL;if(path.endsWith(".worker.js"))return workerURL}return prefix+path}Module["stringToPtr"]=stringToPtr;Module["stringsToPtr"]=stringsToPtr;Module["print"]=print;Module["printErr"]=printErr;Module["locateFile"]=_locateFile;Module["exec"]=exec;Module["setLogger"]=setLogger;Module["setTimeout"]=setTimeout;Module["setProgress"]=setProgress;Module["reset"]=reset;Module["receiveProgress"]=receiveProgress;var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string";var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_NODE){var fs=require("fs");var nodePath=require("path");if(ENVIRONMENT_IS_WORKER){scriptDirectory=nodePath.dirname(scriptDirectory)+"/"}else{scriptDirectory=__dirname+"/"}read_=(filename,binary)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);return fs.readFileSync(filename,binary?undefined:"utf8")};readBinary=filename=>{var ret=read_(filename,true);if(!ret.buffer){ret=new Uint8Array(ret)}return ret};readAsync=(filename,onload,onerror,binary=true)=>{filename=isFileURI(filename)?new URL(filename):nodePath.normalize(filename);fs.readFile(filename,binary?undefined:"utf8",(err,data)=>{if(err)onerror(err);else onload(binary?data.buffer:data)})};if(!Module["thisProgram"]&&process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow};Module["inspect"]=()=>"[Emscripten Module object]"}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptDir){scriptDirectory=_scriptDir}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}else{scriptDirectory=""}{read_=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=(url,onload,onerror)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=title=>document.title=title}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"]Line truncated
return createFFmpegCore.ready
}
);
})();
if (typeof exports === 'object' && typeof module === 'object')
module.exports = createFFmpegCore;
else if (typeof define === 'function' && define['amd'])
define([], function() { return createFFmpegCore; });
else if (typeof exports === 'object')
exports["createFFmpegCore"] = createFFmpegCore;
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FFmpegUtil=t():e.FFmpegUtil=t()}(self,(()=>(()=>{"use strict";var e={591:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.HeaderContentLength=void 0,t.HeaderContentLength="Content-Length"},431:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ERROR_INCOMPLETED_DOWNLOAD=t.ERROR_RESPONSE_BODY_READER=void 0,t.ERROR_RESPONSE_BODY_READER=new Error("failed to get response body reader"),t.ERROR_INCOMPLETED_DOWNLOAD=new Error("failed to complete download")},915:function(e,t,o){var r=this&&this.__awaiter||function(e,t,o,r){return new(o||(o=Promise))((function(n,i){function d(e){try{l(r.next(e))}catch(e){i(e)}}function a(e){try{l(r.throw(e))}catch(e){i(e)}}function l(e){var t;e.done?n(e.value):(t=e.value,t instanceof o?t:new o((function(e){e(t)}))).then(d,a)}l((r=r.apply(e,t||[])).next())}))};Object.defineProperty(t,"__esModule",{value:!0}),t.toBlobURL=t.downloadWithProgress=t.importScript=t.fetchFile=void 0;const n=o(431),i=o(591);t.fetchFile=e=>r(void 0,void 0,void 0,(function*(){let t;if("string"==typeof e)t=/data:_data\/([a-zA-Z]*);base64,([^"]*)/.test(e)?atob(e.split(",")[1]).split("").map((e=>e.charCodeAt(0))):yield(yield fetch(e)).arrayBuffer();else if(e instanceof URL)t=yield(yield fetch(e)).arrayBuffer();else{if(!(e instanceof File||e instanceof Blob))return new Uint8Array;t=yield(o=e,new Promise(((e,t)=>{const r=new FileReader;r.onload=()=>{const{result:t}=r;t instanceof ArrayBuffer?e(new Uint8Array(t)):e(new Uint8Array)},r.onerror=e=>{var o,r;t(Error(`File could not be read! Code=${(null===(r=null===(o=null==e?void 0:e.target)||void 0===o?void 0:o.error)||void 0===r?void 0:r.code)||-1}`))},r.readAsArrayBuffer(o)})))}var o;return new Uint8Array(t)})),t.importScript=e=>r(void 0,void 0,void 0,(function*(){return new Promise((t=>{const o=document.createElement("script"),r=()=>{o.removeEventListener("load",r),t()};o.src=e,o.type="text/javascript",o.addEventListener("load",r),document.getElementsByTagName("head")[0].appendChild(o)}))})),t.downloadWithProgress=(e,t)=>r(void 0,void 0,void 0,(function*(){var o;const r=yield fetch(e);let d;try{const a=parseInt(r.headers.get(i.HeaderContentLength)||"-1"),l=null===(o=r.body)||void 0===o?void 0:o.getReader();if(!l)throw n.ERROR_RESPONSE_BODY_READER;const c=[];let s=0;for(;;){const{done:o,value:r}=yield l.read(),i=r?r.length:0;if(o){if(-1!=a&&a!==s)throw n.ERROR_INCOMPLETED_DOWNLOAD;t&&t({url:e,total:a,received:s,delta:i,done:o});break}c.push(r),s+=i,t&&t({url:e,total:a,received:s,delta:i,done:o})}const f=new Uint8Array(s);let u=0;for(const e of c)f.set(e,u),u+=e.length;d=f.buffer}catch(o){console.log("failed to send download progress event: ",o),d=yield r.arrayBuffer(),t&&t({url:e,total:d.byteLength,received:d.byteLength,delta:0,done:!0})}return d})),t.toBlobURL=(e,o,n=!1,i)=>r(void 0,void 0,void 0,(function*(){const r=n?yield(0,t.downloadWithProgress)(e,i):yield(yield fetch(e)).arrayBuffer(),d=new Blob([r],{type:o});return URL.createObjectURL(d)}))}},t={};return function o(r){var n=t[r];if(void 0!==n)return n.exports;var i=t[r]={exports:{}};return e[r].call(i.exports,i,i.exports,o),i.exports}(915)})()));
+2
View File
@@ -0,0 +1,2 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FFmpegWASM=t():e.FFmpegWASM=t()}(self,(()=>(()=>{"use strict";var e={m:{},d:(t,s)=>{for(var r in s)e.o(s,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:s[r]})},u:e=>e+".ffmpeg.js"};e.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),e.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),e.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},(()=>{var t;e.g.importScripts&&(t=e.g.location+"");var s=e.g.document;if(!t&&s&&(s.currentScript&&(t=s.currentScript.src),!t)){var r=s.getElementsByTagName("script");if(r.length)for(var a=r.length-1;a>-1&&!t;)t=r[a--].src}if(!t)throw new Error("Automatic publicPath is not supported in this browser");t=t.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),e.p=t})(),e.b=document.baseURI||self.location.href;var t,s={};e.r(s),e.d(s,{FFmpeg:()=>i}),function(e){e.LOAD="LOAD",e.EXEC="EXEC",e.WRITE_FILE="WRITE_FILE",e.READ_FILE="READ_FILE",e.DELETE_FILE="DELETE_FILE",e.RENAME="RENAME",e.CREATE_DIR="CREATE_DIR",e.LIST_DIR="LIST_DIR",e.DELETE_DIR="DELETE_DIR",e.ERROR="ERROR",e.DOWNLOAD="DOWNLOAD",e.PROGRESS="PROGRESS",e.LOG="LOG",e.MOUNT="MOUNT",e.UNMOUNT="UNMOUNT"}(t||(t={}));const r=(()=>{let e=0;return()=>e++})(),a=(new Error("unknown message type"),new Error("ffmpeg is not loaded, call `await ffmpeg.load()` first")),o=new Error("called FFmpeg.terminate()");new Error("failed to import ffmpeg-core.js");class i{#e=null;#t={};#s={};#r=[];#a=[];loaded=!1;#o=()=>{this.#e&&(this.#e.onmessage=({data:{id:e,type:s,data:r}})=>{switch(s){case t.LOAD:this.loaded=!0,this.#t[e](r);break;case t.MOUNT:case t.UNMOUNT:case t.EXEC:case t.WRITE_FILE:case t.READ_FILE:case t.DELETE_FILE:case t.RENAME:case t.CREATE_DIR:case t.LIST_DIR:case t.DELETE_DIR:this.#t[e](r);break;case t.LOG:this.#r.forEach((e=>e(r)));break;case t.PROGRESS:this.#a.forEach((e=>e(r)));break;case t.ERROR:this.#s[e](r)}delete this.#t[e],delete this.#s[e]})};#i=({type:e,data:t},s=[],o)=>this.#e?new Promise(((a,i)=>{const n=r();this.#e&&this.#e.postMessage({id:n,type:e,data:t},s),this.#t[n]=a,this.#s[n]=i,o?.addEventListener("abort",(()=>{i(new DOMException(`Message # ${n} was aborted`,"AbortError"))}),{once:!0})})):Promise.reject(a);on(e,t){"log"===e?this.#r.push(t):"progress"===e&&this.#a.push(t)}off(e,t){"log"===e?this.#r=this.#r.filter((e=>e!==t)):"progress"===e&&(this.#a=this.#a.filter((e=>e!==t)))}load=({classWorkerURL:s,...r}={},{signal:a}={})=>(this.#e||(this.#e=s?new Worker(new URL(s,"file:///home/jeromewu/ffmpeg.wasm/packages/ffmpeg/dist/esm/classes.js"),{type:"module"}):new Worker(new URL(e.p+e.u(814),e.b),{type:void 0}),this.#o()),this.#i({type:t.LOAD,data:r},void 0,a));exec=(e,s=-1,{signal:r}={})=>this.#i({type:t.EXEC,data:{args:e,timeout:s}},void 0,r);terminate=()=>{const e=Object.keys(this.#s);for(const t of e)this.#s[t](o),delete this.#s[t],delete this.#t[t];this.#e&&(this.#e.terminate(),this.#e=null,this.loaded=!1)};writeFile=(e,s,{signal:r}={})=>{const a=[];return s instanceof Uint8Array&&a.push(s.buffer),this.#i({type:t.WRITE_FILE,data:{path:e,data:s}},a,r)};mount=(e,s,r)=>this.#i({type:t.MOUNT,data:{fsType:e,options:s,mountPoint:r}},[]);unmount=e=>this.#i({type:t.UNMOUNT,data:{mountPoint:e}},[]);readFile=(e,s="binary",{signal:r}={})=>this.#i({type:t.READ_FILE,data:{path:e,encoding:s}},void 0,r);deleteFile=(e,{signal:s}={})=>this.#i({type:t.DELETE_FILE,data:{path:e}},void 0,s);rename=(e,s,{signal:r}={})=>this.#i({type:t.RENAME,data:{oldPath:e,newPath:s}},void 0,r);createDir=(e,{signal:s}={})=>this.#i({type:t.CREATE_DIR,data:{path:e}},void 0,s);listDir=(e,{signal:s}={})=>this.#i({type:t.LIST_DIR,data:{path:e}},void 0,s);deleteDir=(e,{signal:s}={})=>this.#i({type:t.DELETE_DIR,data:{path:e}},void 0,s)}return s})()));
//# sourceMappingURL=ffmpeg.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"ffmpeg.js","mappings":"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","sources":["webpack://FFmpegWASM/webpack/universalModuleDefinition","webpack://FFmpegWASM/webpack/bootstrap","webpack://FFmpegWASM/webpack/runtime/define property getters","webpack://FFmpegWASM/webpack/runtime/get javascript chunk filename","webpack://FFmpegWASM/webpack/runtime/global","webpack://FFmpegWASM/webpack/runtime/hasOwnProperty shorthand","webpack://FFmpegWASM/webpacLine truncated
+9 -5
View File
@@ -1,22 +1,22 @@
[app]
app_url = "http://127.0.0.1:8000"
app_url = "http://192.168.1.168:8000/"
app_key = "grok2api"
api_key = ""
api_key = "sk-3b5b3bd501a145789e02462c351a6051"
public_enabled = true
public_key = ""
image_format = "url"
video_format = "html"
temporary = true
disable_memory = true
stream = true
thinking = true
stream = false
thinking = false
dynamic_statsig = true
filter_tags = ["xaiartifact","xai:tool_usage_card","grok:render"]
[proxy]
base_proxy_url = ""
asset_proxy_url = ""
cf_clearance = ""
cf_clearance = "TZDYXNr3xjKU918mEpvp4pZ7GdWfWAeVWYdr25ZBFZk-1771200552-1.2.1.1-nBA2kDmJbeOzB4LtkWmrIRlSg8J8PgCeTS822.l9hFN6rQ7oximaVJ59rJN1eXhjuF_4qvCDQNtkHojrkNXMkP4lkHH.KvhUqB1Q7jwGFvgBPWp.ZDgHV.zYRUznVSQQnoyo9XNnyUN_wGFLOmj8rZDhV2pFXuT14E4rGO1ULTjC297..CBvH2K7fLpBAJWnzZpskPec3mX4fsnbn9ZKs3mW9YU__PkTp.TWcxINYGU"
browser = "chrome136"
user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
@@ -60,6 +60,10 @@ stream_timeout = 60
[voice]
timeout = 120
livekit_url = ""
livekit_urls = []
signal_proxy_enabled = true
signal_proxy_url = ""
[asset]
upload_concurrent = 30
Executable → Regular
View File
File mode changed.
Executable → Regular
View File
File mode changed.