feat: 完善本地功能 (视频UI修复, 工作台重构, PWA支持, 提示词增强)
This commit is contained in:
1 parent
82dda21248
commit
bac0ed508c
55 files changed
+3817
-344
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "grok2api"
|
||||
|
||||
[setup]
|
||||
script = "chcp 65001"
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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]}...")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,3 +249,11 @@ body {
|
||||
border-radius: 999px;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.voice-actions,
|
||||
#clearLogBtn,
|
||||
#copyLogBtn {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"814.ffmpeg.js","mappings":"CAAA,SAA2CA,EAAMC,GAC1B,iBAAZC,SAA0C,iBAAXC,OACxCA,OAAOD,QAAUD,IACQ,mBAAXG,QAAyBA,OAAOC,IAC9CD,OAAO,GAAIH,GACe,iBAAZC,QACdA,QAAoB,WAAID,IAExBD,EAAiB,WAAIC,GACtB,CATD,CASGK,MAAM,I,mBCTF,MAGMC,EAAW,gEACjB,IAAIC,GACX,SAAWA,GACPA,EAAoB,KAAI,OACxBA,EAAoB,KAAI,OACxBA,EAA0B,WAAI,aAC9BA,EAAyB,UAAI,YAC7BA,EAA2B,YAAI,cAC/BA,EAAsB,OAAI,SAC1BA,EAA0B,WAAI,aAC9BA,EAAwB,SAAI,WAC5BA,EAA0B,WAAI,aAC9BA,EAAqB,MAAI,QACzBA,EAAwB,SAAI,WAC5BA,EAAwB,SAAI,WAC5BA,EAAmB,IAAI,MACvBA,EAAqB,MAAI,QACzBA,EAAuB,QAAI,SAC9B,CAhBD,CAgBGA,IAAkBA,EAAgB,CAAC,ICrB/B,MAAMC,EAA6B,IAAIC,MAAM,wBACvCC,EAAmB,IAAID,MAAM,0DAE7BE,GADmB,IAAIF,MAAM,6BACN,IAAIA,MAAM,oCCE9C,IAAIG,E,OAyFJP,KAAKQ,UAAYC,OAASC,MAAQC,KAAIC,OAAMF,KAAMG,OAC9C,MAAMC,EAAQ,GACd,IAAIJ,EACJ,IACI,GAAIE,IAASV,EAAca,OAASR,EAChC,MAAMF,EACV,OAAQO,GACJ,KAAKV,EAAca,KACfL,OAhGHD,QAASO,QAASC,EAAUC,QAASC,EAAUC,UAAWC,MACnE,MAAMC,GAASf,EACf,IACSU,IACDA,EAAWhB,GAEfsB,cAAcN,EAClB,CACA,MAMI,GALKA,IACDA,EAAWhB,EAASuB,QAAQ,QAAS,UAEzCxB,KAAKyB,wBAA0BC,OACcT,IAAWU,SACnD3B,KAAKyB,iBACN,MAAMnB,CAEd,CACA,MAAMU,EAAUC,EACVC,EAAUC,GAAsBF,EAASO,QAAQ,QAAS,SAC1DJ,EAAYC,GAEZJ,EAASO,QAAQ,QAAS,cAWhC,OAVAjB,QAAeP,KAAKyB,iBAAiB,CAGjCG,oBAAqB,GAAGZ,KAAWa,KAAKC,KAAKC,UAAU,CAAEb,UAASE,mBAEtEb,EAAOyB,WAAWtB,GAASV,KAAKiC,YAAY,CAAErB,KAAMV,EAAcgC,IAAKxB,WACvEH,EAAO4B,aAAazB,GAASV,KAAKiC,YAAY,CAC1CrB,KAAMV,EAAckC,SACpB1B,WAEGY,CAAK,EA+Dae,CAAKxB,GAClB,MACJ,KAAKX,EAAcoC,KACf5B,EAhEH,GAAG6B,OAAMC,WAAU,MAC5BjC,EAAOkC,WAAWD,GAClBjC,EAAOmC,QAAQH,GACf,MAAMI,EAAMpC,EAAOoC,IAEnB,OADApC,EAAOqC,QACAD,CAAG,EA2DSD,CAAK7B,GACZ,MACJ,KAAKX,EAAc2C,WACfnC,EA5DE,GAAGoC,OAAMpC,WACvBH,EAAOwC,GAAGC,UAAUF,EAAMpC,IACnB,GA0DYsC,CAAUnC,GACjB,MACJ,KAAKX,EAAc+C,UACfvC,EA3DC,GAAGoC,OAAMI,cAAe3C,EAAOwC,GAAGI,SAASL,EAAM,CAAEI,aA2D7CC,CAAStC,GAChB,MACJ,KAAKX,EAAckD,YACf1C,EA5DG,GAAGoC,WAClBvC,EAAOwC,GAAGM,OAAOP,IACV,GA0DYQ,CAAWzC,GAClB,MACJ,KAAKX,EAAcqD,OACf7C,EA3DD,GAAG8C,UAASC,cACvBlD,EAAOwC,GAAGW,OAAOF,EAASC,IACnB,GAyDYC,CAAO7C,GACd,MACJ,KAAKX,EAAcyD,WACfjD,EAzDE,GAAGoC,WACjBvC,EAAOwC,GAAGa,MAAMd,IACT,GAuDYe,CAAUhD,GACjB,MACJ,KAAKX,EAAc4D,SACfpD,EAxDA,GAAGoC,WACf,MAAMiB,EAAQxD,EAAOwC,GAAGiB,QAAQlB,GAC1BmB,EAAQ,GACd,IAAK,MAAMC,KAAQH,EAAO,CACtB,MAAMI,EAAO5D,EAAOwC,GAAGoB,KAAK,GAAGrB,KAAQoB,KACjCE,EAAQ7D,EAAOwC,GAAGqB,MAAMD,EAAKE,MACnCJ,EAAMK,KAAK,CAAEJ,OAAME,SACvB,CACA,OAAOH,CAAK,EAgDOM,CAAQ1D,GACf,MACJ,KAAKX,EAAcsE,WACf9D,EAhDE,GAAGoC,WACjBvC,EAAOwC,GAAG0B,MAAM3B,IACT,GA8CY4B,CAAU7D,GACjB,MACJ,KAAKX,EAAcyE,MACfjE,EA/CF,GAAGkE,SAAQC,UAASC,iBAC9B,MAAMC,EAAMH,EACNI,EAAKzE,EAAOwC,GAAGkC,YAAYF,GACjC,QAAKC,IAELzE,EAAOwC,GAAGmC,MAAMF,EAAIH,EAASC,IACtB,EAAI,EAyCQI,CAAMrE,GACb,MACJ,KAAKX,EAAciF,QACfzE,EA1CA,GAAGoE,iBACfvE,EAAOwC,GAAGqC,QAAQN,IACX,GAwCYM,CAAQvE,GACf,MACJ,QACI,MAAMV,EAElB,CACA,MAAOkF,GAMH,YALArF,KAAKiC,YAAY,CACbtB,KACAC,KAAMV,EAAcoF,MACpB5E,KAAM2E,EAAEE,YAGhB,CACI7E,aAAgB8E,YAChB1E,EAAMwD,KAAK5D,EAAK+E,QAEpBzF,KAAKiC,YAAY,CAAEtB,KAAIC,OAAMF,QAAQI,EAAM,E","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
@@ -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;
|
||||
BIN
Binary file not shown.
+1
@@ -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)})()));
|
||||
Vendored
+2
@@ -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
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ffmpeg.js","mappings":"CAAA,SAA2CA,EAAMC,GAC1B,iBAAZC,SAA0C,iBAAXC,OACxCA,OAAOD,QAAUD,IACQ,mBAAXG,QAAyBA,OAAOC,IAC9CD,OAAO,GAAIH,GACe,iBAAZC,QACdA,QAAoB,WAAID,IAExBD,EAAiB,WAAIC,GACtB,CATD,CASGK,MAAM,I,uBCRLC,EAAsB,CAG1BA,E,GCHAA,EAAwB,CAACL,EAASM,KACjC,IAAI,IAAIC,KAAOD,EACXD,EAAoBG,EAAEF,EAAYC,KAASF,EAAoBG,EAAER,EAASO,IAC5EE,OAAOC,eAAeV,EAASO,EAAK,CAAEI,YAAY,EAAMC,IAAKN,EAAWC,IAE1E,ECLDF,EAAyBQ,GAEZA,EAAU,cCHvBR,EAAoBS,EAAI,WACvB,GAA0B,iBAAfC,WAAyB,OAAOA,WAC3C,IACC,OAAOC,MAAQ,IAAIC,SAAS,cAAb,EAChB,CAAE,MAAOC,GACR,GAAsB,iBAAXC,OAAqB,OAAOA,MACxC,CACA,CAPuB,GCAxBd,EAAoBG,EAAI,CAACY,EAAKC,IAAUZ,OAAOa,UAAUC,eAAeC,KAAKJ,EAAKC,GCClFhB,EAAoBoB,EAAKzB,IACH,oBAAX0B,QAA0BA,OAAOC,aAC1ClB,OAAOC,eAAeV,EAAS0B,OAAOC,YAAa,CAAEC,MAAO,WAE7DnB,OAAOC,eAAeV,EAAS,aAAc,CAAE4B,OAAO,GAAO,E,MCL9D,IAAIC,EACAxB,EAAoBS,EAAEgB,gBAAeD,EAAYxB,EAAoBS,EAAEiB,SAAW,IACtF,IAAIC,EAAW3B,EAAoBS,EAAEkB,SACrC,IAAKH,GAAaG,IACbA,EAASC,gBACZJ,EAAYG,EAASC,cAAcC,MAC/BL,GAAW,CACf,IAAIM,EAAUH,EAASI,qBAAqB,UAC5C,GAAGD,EAAQE,OAEV,IADA,IAAIC,EAAIH,EAAQE,OAAS,EAClBC,GAAK,IAAMT,GAAWA,EAAYM,EAAQG,KAAKJ,GAExD,CAID,IAAKL,EAAW,MAAM,IAAIU,MAAM,yDAChCV,EAAYA,EAAUW,QAAQ,OAAQ,IAAIA,QAAQ,QAAS,IAAIA,QAAQ,YAAa,KACpFnC,EAAoBoC,EAAIZ,C,KClBxBxB,EAAoBqC,EAAIV,SAASW,SAAWvC,KAAK2B,SAASa,K,ICI/CC,E,kCACX,SAAWA,GACPA,EAAoB,KAAI,OACxBA,EAAoB,KAAI,OACxBA,EAA0B,WAAI,aAC9BA,EAAyB,UAAI,YAC7BA,EAA2B,YAAI,cAC/BA,EAAsB,OAAI,SAC1BA,EAA0B,WAAI,aAC9BA,EAAwB,SAAI,WAC5BA,EAA0B,WAAI,aAC9BA,EAAqB,MAAI,QACzBA,EAAwB,SAAI,WAC5BA,EAAwB,SAAI,WAC5BA,EAAmB,IAAI,MACvBA,EAAqB,MAAI,QACzBA,EAAuB,QAAI,SAC9B,CAhBD,CAgBGA,IAAkBA,EAAgB,CAAC,IClB/B,MAAMC,EAAe,MACxB,IAAIC,EAAY,EAChB,MAAO,IAAMA,GAChB,EAH2B,GCFfC,GAD6B,IAAIT,MAAM,wBACpB,IAAIA,MAAM,2DAC7BU,EAAmB,IAAIV,MAAM,6BACN,IAAIA,MAAM,mCCQvC,MAAMW,EACT,GAAU,KAKV,GAAY,CAAC,EACb,GAAW,CAAC,EACZ,GAAqB,GACrB,GAA0B,GAC1BC,QAAS,EAIT,GAAoB,KACZnC,MAAK,IACLA,MAAK,EAAQoC,UAAY,EAAGC,MAAQC,KAAIC,OAAMF,YAC1C,OAAQE,GACJ,KAAKV,EAAcW,KACfxC,KAAKmC,QAAS,EACdnC,MAAK,EAAUsC,GAAID,GACnB,MACJ,KAAKR,EAAcY,MACnB,KAAKZ,EAAca,QACnB,KAAKb,EAAcc,KACnB,KAAKd,EAAce,WACnB,KAAKf,EAAcgB,UACnB,KAAKhB,EAAciB,YACnB,KAAKjB,EAAckB,OACnB,KAAKlB,EAAcmB,WACnB,KAAKnB,EAAcoB,SACnB,KAAKpB,EAAcqB,WACflD,MAAK,EAAUsC,GAAID,GACnB,MACJ,KAAKR,EAAcsB,IACfnD,MAAK,EAAmBoD,SAASC,GAAMA,EAAEhB,KACzC,MACJ,KAAKR,EAAcyB,SACftD,MAAK,EAAwBoD,SAASC,GAAMA,EAAEhB,KAC9C,MACJ,KAAKR,EAAc0B,MACfvD,MAAK,EAASsC,GAAID,UAGnBrC,MAAK,EAAUsC,UACftC,MAAK,EAASsC,EAAG,EAEhC,EAKJ,GAAQ,EAAGC,OAAMF,QAAQmB,EAAQ,GAAIC,IAC5BzD,MAAK,EAGH,IAAI0D,SAAQ,CAACC,EAASC,KACzB,MAAMtB,EAAKR,IACX9B,MAAK,GAAWA,MAAK,EAAQ6D,YAAY,CAAEvB,KAAIC,OAAMF,QAAQmB,GAC7DxD,MAAK,EAAUsC,GAAMqB,EACrB3D,MAAK,EAASsC,GAAMsB,EACpBH,GAAQK,iBAAiB,SAAS,KAC9BF,EAAO,IAAIG,aAAa,aAAazB,gBAAkB,cAAc,GACtE,CAAE0B,MAAM,GAAO,IATXN,QAAQE,OAAO5B,GAY9B,EAAAiC,CAAGC,EAAOC,GACQ,QAAVD,EACAlE,MAAK,EAAmBoE,KAAKD,GAEd,aAAVD,GACLlE,MAAK,EAAwBoE,KAAKD,EAE1C,CACA,GAAAE,CAAIH,EAAOC,GACO,QAAVD,EACAlE,MAAK,EAAqBA,MAAK,EAAmBsE,QAAQjB,GAAMA,IAAMc,IAEvD,aAAVD,IACLlE,MAAK,EAA0BA,MAAK,EAAwBsE,QAAQjB,GAAMA,IAAMc,IAExF,CAQAI,KAAO,EAAGC,oBAAmBC,GAAW,CAAC,GAAKhB,UAAW,CAAC,KACjDzD,MAAK,IACNA,MAAK,EAAUwE,EACX,IAAIE,OAAO,IAAIC,IAAIH,EAAgB,yEAAkB,CACjDjC,KAAM,WAIV,IAAImC,OAAO,IAAIC,IAAI,kBAAiC,CAChDpC,UAAM,IAEdvC,MAAK,KAEFA,MAAK,EAAM,CACduC,KAAMV,EAAcW,KACpBH,KAAMoC,QACPG,EAAWnB,IAsBlBoB,KAAO,CAEPC,EAMAC,GAAU,GAAMtB,UAAW,CAAC,IAAMzD,MAAK,EAAM,CACzCuC,KAAMV,EAAcc,KACpBN,KAAM,CAAEyC,OAAMC,iBACfH,EAAWnB,GAOduB,UAAY,KACR,MAAMC,EAAMxF,OAAOyF,KAAKlF,MAAK,GAE7B,IAAK,MAAMsC,KAAM2C,EACbjF,MAAK,EAASsC,GAAIL,UACXjC,MAAK,EAASsC,UACdtC,MAAK,EAAUsC,GAEtBtC,MAAK,IACLA,MAAK,EAAQgF,YACbhF,MAAK,EAAU,KACfA,KAAKmC,QAAS,EAClB,EAeJgD,UAAY,CAACC,EAAM/C,GAAQoB,UAAW,CAAC,KACnC,MAAMD,EAAQ,GAId,OAHInB,aAAgBgD,YAChB7B,EAAMY,KAAK/B,EAAKiD,QAEbtF,MAAK,EAAM,CACduC,KAAMV,EAAce,WACpBP,KAAM,CAAE+C,OAAM/C,SACfmB,EAAOC,EAAO,EAErB8B,MAAQ,CAACC,EAAQC,EAASC,IAEf1F,MAAK,EAAM,CACduC,KAAMV,EAAcY,MACpBJ,KAAM,CAAEmD,SAAQC,UAASC,eAHf,IAMlBC,QAAWD,GAEA1F,MAAK,EAAM,CACduC,KAAMV,EAAca,QACpBL,KAAM,CAAEqD,eAHE,IAkBlBE,SAAW,CAACR,EAQZS,EAAW,UAAYpC,UAAW,CAAC,IAAMzD,MAAK,EAAM,CAChDuC,KAAMV,EAAcgB,UACpBR,KAAM,CAAE+C,OAAMS,kBACfjB,EAAWnB,GAMdqC,WAAa,CAACV,GAAQ3B,UAAW,CAAC,IAAMzD,MAAK,EAAM,CAC/CuC,KAAMV,EAAciB,YACpBT,KAAM,CAAE+C,cACTR,EAAWnB,GAMdsC,OAAS,CAACC,EAASC,GAAWxC,UAAW,CAAC,IAAMzD,MAAK,EAAM,CACvDuC,KAAMV,EAAckB,OACpBV,KAAM,CAAE2D,UAASC,iBAClBrB,EAAWnB,GAMdyC,UAAY,CAACd,GAAQ3B,UAAW,CAAC,IAAMzD,MAAK,EAAM,CAC9CuC,KAAMV,EAAcmB,WACpBX,KAAM,CAAE+C,cACTR,EAAWnB,GAMd0C,QAAU,CAACf,GAAQ3B,UAAW,CAAC,IAAMzD,MAAK,EAAM,CAC5CuC,KAAMV,EAAcoB,SACpBZ,KAAM,CAAE+C,cACTR,EAAWnB,GAMd2C,UAAY,CAAChB,GAAQ3B,UAAW,CAAC,IAAMzD,MAAK,EAAM,CAC9CuC,KAAMV,EAAcqB,WACpBb,KAAM,CAAE+C,cACTR,EAAWnB,G","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
@@ -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
File mode changed.
Executable → Regular
File mode changed.
Reference in new issue
Block a user