mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-03-13 15:37:32 +08:00
Compare commits
6 Commits
fc95de0f28
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d91b31a7 | ||
|
|
ad554d900a | ||
|
|
404cbd44f0 | ||
|
|
8b406e4e5c | ||
|
|
29fddfa85b | ||
|
|
5a0ccbe2de |
@@ -40,6 +40,8 @@ auto_disable_on_401 = true
|
|||||||
[proxy]
|
[proxy]
|
||||||
proxy_enabled = false
|
proxy_enabled = false
|
||||||
proxy_url = ""
|
proxy_url = ""
|
||||||
|
image_upload_proxy_enabled = false
|
||||||
|
image_upload_proxy_url = ""
|
||||||
|
|
||||||
[watermark_free]
|
[watermark_free]
|
||||||
watermark_free_enabled = false
|
watermark_free_enabled = false
|
||||||
@@ -60,3 +62,19 @@ call_mode = "default"
|
|||||||
# 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -8
|
# 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -8
|
||||||
timezone_offset = 8
|
timezone_offset = 8
|
||||||
|
|
||||||
|
[pow_service]
|
||||||
|
# beta测试,目前仍处于测试阶段
|
||||||
|
# POW 计算模式:local(本地计算)或 external(外部服务)
|
||||||
|
mode = "local"
|
||||||
|
# 是否使用对应 token 进行 POW 计算(默认关闭)
|
||||||
|
# local 模式开启后会使用当前轮询 token 获取 POW
|
||||||
|
# external 模式开启后会向外部服务传递 accesstoken 字段
|
||||||
|
use_token_for_pow = false
|
||||||
|
# 外部 POW 服务地址(仅在 external 模式下使用)
|
||||||
|
server_url = "http://localhost:8002"
|
||||||
|
# 外部 POW 服务访问密钥(仅在 external 模式下使用)
|
||||||
|
api_key = "your-secure-api-key-here"
|
||||||
|
# POW 代理配置
|
||||||
|
proxy_enabled = false
|
||||||
|
proxy_url = ""
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ error_ban_threshold = 3
|
|||||||
[proxy]
|
[proxy]
|
||||||
proxy_enabled = true
|
proxy_enabled = true
|
||||||
proxy_url = "socks5://warp:1080"
|
proxy_url = "socks5://warp:1080"
|
||||||
|
image_upload_proxy_enabled = false
|
||||||
|
image_upload_proxy_url = ""
|
||||||
|
|
||||||
[watermark_free]
|
[watermark_free]
|
||||||
watermark_free_enabled = false
|
watermark_free_enabled = false
|
||||||
|
|||||||
146
src/api/admin.py
146
src/api/admin.py
@@ -129,8 +129,10 @@ class UpdateAdminConfigRequest(BaseModel):
|
|||||||
auto_disable_on_401: Optional[bool] = None
|
auto_disable_on_401: Optional[bool] = None
|
||||||
|
|
||||||
class UpdateProxyConfigRequest(BaseModel):
|
class UpdateProxyConfigRequest(BaseModel):
|
||||||
proxy_enabled: bool
|
proxy_enabled: Optional[bool] = None
|
||||||
proxy_url: Optional[str] = None
|
proxy_url: Optional[str] = None
|
||||||
|
image_upload_proxy_enabled: Optional[bool] = None
|
||||||
|
image_upload_proxy_url: Optional[str] = None
|
||||||
|
|
||||||
class TestProxyRequest(BaseModel):
|
class TestProxyRequest(BaseModel):
|
||||||
test_url: Optional[str] = "https://sora.chatgpt.com"
|
test_url: Optional[str] = "https://sora.chatgpt.com"
|
||||||
@@ -166,11 +168,20 @@ class UpdateWatermarkFreeConfigRequest(BaseModel):
|
|||||||
class UpdateCallLogicConfigRequest(BaseModel):
|
class UpdateCallLogicConfigRequest(BaseModel):
|
||||||
call_mode: Optional[str] = None # "default" or "polling"
|
call_mode: Optional[str] = None # "default" or "polling"
|
||||||
polling_mode_enabled: Optional[bool] = None # Legacy support
|
polling_mode_enabled: Optional[bool] = None # Legacy support
|
||||||
|
poll_interval: Optional[float] = None # Progress polling interval (seconds)
|
||||||
|
|
||||||
class UpdatePowProxyConfigRequest(BaseModel):
|
class UpdatePowProxyConfigRequest(BaseModel):
|
||||||
pow_proxy_enabled: bool
|
pow_proxy_enabled: bool
|
||||||
pow_proxy_url: Optional[str] = None
|
pow_proxy_url: Optional[str] = None
|
||||||
|
|
||||||
|
class UpdatePowServiceConfigRequest(BaseModel):
|
||||||
|
mode: str # "local" or "external"
|
||||||
|
use_token_for_pow: Optional[bool] = False
|
||||||
|
server_url: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
proxy_enabled: Optional[bool] = None
|
||||||
|
proxy_url: Optional[str] = None
|
||||||
|
|
||||||
class BatchDisableRequest(BaseModel):
|
class BatchDisableRequest(BaseModel):
|
||||||
token_ids: List[int]
|
token_ids: List[int]
|
||||||
|
|
||||||
@@ -242,7 +253,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
|
|||||||
"video_enabled": token.video_enabled,
|
"video_enabled": token.video_enabled,
|
||||||
# 并发限制
|
# 并发限制
|
||||||
"image_concurrency": token.image_concurrency,
|
"image_concurrency": token.image_concurrency,
|
||||||
"video_concurrency": token.video_concurrency
|
"video_concurrency": token.video_concurrency,
|
||||||
|
# 过期和禁用信息
|
||||||
|
"is_expired": token.is_expired,
|
||||||
|
"disabled_reason": token.disabled_reason
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -932,7 +946,9 @@ async def get_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
|
|||||||
config = await proxy_manager.get_proxy_config()
|
config = await proxy_manager.get_proxy_config()
|
||||||
return {
|
return {
|
||||||
"proxy_enabled": config.proxy_enabled,
|
"proxy_enabled": config.proxy_enabled,
|
||||||
"proxy_url": config.proxy_url
|
"proxy_url": config.proxy_url,
|
||||||
|
"image_upload_proxy_enabled": config.image_upload_proxy_enabled,
|
||||||
|
"image_upload_proxy_url": config.image_upload_proxy_url
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/api/proxy/config")
|
@router.post("/api/proxy/config")
|
||||||
@@ -942,7 +958,26 @@ async def update_proxy_config(
|
|||||||
):
|
):
|
||||||
"""Update proxy configuration"""
|
"""Update proxy configuration"""
|
||||||
try:
|
try:
|
||||||
await proxy_manager.update_proxy_config(request.proxy_enabled, request.proxy_url)
|
current_config = await proxy_manager.get_proxy_config()
|
||||||
|
proxy_enabled = current_config.proxy_enabled if request.proxy_enabled is None else request.proxy_enabled
|
||||||
|
proxy_url = current_config.proxy_url if request.proxy_url is None else request.proxy_url
|
||||||
|
image_upload_proxy_enabled = (
|
||||||
|
current_config.image_upload_proxy_enabled
|
||||||
|
if request.image_upload_proxy_enabled is None
|
||||||
|
else request.image_upload_proxy_enabled
|
||||||
|
)
|
||||||
|
image_upload_proxy_url = (
|
||||||
|
current_config.image_upload_proxy_url
|
||||||
|
if request.image_upload_proxy_url is None
|
||||||
|
else request.image_upload_proxy_url
|
||||||
|
)
|
||||||
|
|
||||||
|
await proxy_manager.update_proxy_config(
|
||||||
|
proxy_enabled,
|
||||||
|
proxy_url,
|
||||||
|
image_upload_proxy_enabled,
|
||||||
|
image_upload_proxy_url
|
||||||
|
)
|
||||||
return {"success": True, "message": "Proxy configuration updated"}
|
return {"success": True, "message": "Proxy configuration updated"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
@@ -1339,11 +1374,19 @@ async def get_call_logic_config(token: str = Depends(verify_admin_token)) -> dic
|
|||||||
call_mode = getattr(config_obj, "call_mode", None)
|
call_mode = getattr(config_obj, "call_mode", None)
|
||||||
if call_mode not in ("default", "polling"):
|
if call_mode not in ("default", "polling"):
|
||||||
call_mode = "polling" if config_obj.polling_mode_enabled else "default"
|
call_mode = "polling" if config_obj.polling_mode_enabled else "default"
|
||||||
|
poll_interval = getattr(config_obj, "poll_interval", 2.5)
|
||||||
|
try:
|
||||||
|
poll_interval = float(poll_interval)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
poll_interval = 2.5
|
||||||
|
if poll_interval <= 0:
|
||||||
|
poll_interval = 2.5
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"config": {
|
"config": {
|
||||||
"call_mode": call_mode,
|
"call_mode": call_mode,
|
||||||
"polling_mode_enabled": call_mode == "polling"
|
"polling_mode_enabled": call_mode == "polling",
|
||||||
|
"poll_interval": poll_interval
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1360,29 +1403,43 @@ async def update_call_logic_config(
|
|||||||
if call_mode is None:
|
if call_mode is None:
|
||||||
raise HTTPException(status_code=400, detail="Invalid call_mode")
|
raise HTTPException(status_code=400, detail="Invalid call_mode")
|
||||||
|
|
||||||
await db.update_call_logic_config(call_mode)
|
poll_interval = request.poll_interval
|
||||||
|
if poll_interval is not None:
|
||||||
|
try:
|
||||||
|
poll_interval = float(poll_interval)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="poll_interval must be a valid number")
|
||||||
|
if poll_interval <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="poll_interval must be greater than 0")
|
||||||
|
|
||||||
|
await db.update_call_logic_config(call_mode, poll_interval)
|
||||||
config.set_call_logic_mode(call_mode)
|
config.set_call_logic_mode(call_mode)
|
||||||
|
if poll_interval is not None:
|
||||||
|
config.set_poll_interval(poll_interval)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Call logic configuration updated",
|
"message": "Call logic configuration updated",
|
||||||
"call_mode": call_mode,
|
"call_mode": call_mode,
|
||||||
"polling_mode_enabled": call_mode == "polling"
|
"polling_mode_enabled": call_mode == "polling",
|
||||||
|
"poll_interval": config.poll_interval
|
||||||
}
|
}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update call logic configuration: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update call logic configuration: {str(e)}")
|
||||||
|
|
||||||
# POW proxy config endpoints
|
# POW proxy config endpoints (redirected to pow_service config for unified management)
|
||||||
@router.get("/api/pow-proxy/config")
|
@router.get("/api/pow-proxy/config")
|
||||||
async def get_pow_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
|
async def get_pow_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
|
||||||
"""Get POW proxy configuration"""
|
"""Get POW proxy configuration (unified with pow_service config)"""
|
||||||
config_obj = await db.get_pow_proxy_config()
|
# Read from pow_service config for unified management
|
||||||
|
config_obj = await db.get_pow_service_config()
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"config": {
|
"config": {
|
||||||
"pow_proxy_enabled": config_obj.pow_proxy_enabled,
|
"pow_proxy_enabled": config_obj.proxy_enabled,
|
||||||
"pow_proxy_url": config_obj.pow_proxy_url or ""
|
"pow_proxy_url": config_obj.proxy_url or ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,11 +1448,21 @@ async def update_pow_proxy_config(
|
|||||||
request: UpdatePowProxyConfigRequest,
|
request: UpdatePowProxyConfigRequest,
|
||||||
token: str = Depends(verify_admin_token)
|
token: str = Depends(verify_admin_token)
|
||||||
):
|
):
|
||||||
"""Update POW proxy configuration"""
|
"""Update POW proxy configuration (unified with pow_service config)"""
|
||||||
try:
|
try:
|
||||||
await db.update_pow_proxy_config(request.pow_proxy_enabled, request.pow_proxy_url)
|
# Update pow_service config instead for unified management
|
||||||
config.set_pow_proxy_enabled(request.pow_proxy_enabled)
|
config_obj = await db.get_pow_service_config()
|
||||||
config.set_pow_proxy_url(request.pow_proxy_url or "")
|
await db.update_pow_service_config(
|
||||||
|
mode=config_obj.mode,
|
||||||
|
use_token_for_pow=config_obj.use_token_for_pow,
|
||||||
|
server_url=config_obj.server_url,
|
||||||
|
api_key=config_obj.api_key,
|
||||||
|
proxy_enabled=request.pow_proxy_enabled,
|
||||||
|
proxy_url=request.pow_proxy_url
|
||||||
|
)
|
||||||
|
# Update in-memory config
|
||||||
|
config.set_pow_service_proxy_enabled(request.pow_proxy_enabled)
|
||||||
|
config.set_pow_service_proxy_url(request.pow_proxy_url or "")
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "POW proxy configuration updated"
|
"message": "POW proxy configuration updated"
|
||||||
@@ -1403,6 +1470,53 @@ async def update_pow_proxy_config(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update POW proxy configuration: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update POW proxy configuration: {str(e)}")
|
||||||
|
|
||||||
|
# POW service config endpoints
|
||||||
|
@router.get("/api/pow/config")
|
||||||
|
async def get_pow_service_config(token: str = Depends(verify_admin_token)) -> dict:
|
||||||
|
"""Get POW service configuration"""
|
||||||
|
config_obj = await db.get_pow_service_config()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"config": {
|
||||||
|
"mode": config_obj.mode,
|
||||||
|
"use_token_for_pow": config_obj.use_token_for_pow,
|
||||||
|
"server_url": config_obj.server_url or "",
|
||||||
|
"api_key": config_obj.api_key or "",
|
||||||
|
"proxy_enabled": config_obj.proxy_enabled,
|
||||||
|
"proxy_url": config_obj.proxy_url or ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/api/pow/config")
|
||||||
|
async def update_pow_service_config(
|
||||||
|
request: UpdatePowServiceConfigRequest,
|
||||||
|
token: str = Depends(verify_admin_token)
|
||||||
|
):
|
||||||
|
"""Update POW service configuration"""
|
||||||
|
try:
|
||||||
|
await db.update_pow_service_config(
|
||||||
|
mode=request.mode,
|
||||||
|
use_token_for_pow=request.use_token_for_pow or False,
|
||||||
|
server_url=request.server_url,
|
||||||
|
api_key=request.api_key,
|
||||||
|
proxy_enabled=request.proxy_enabled,
|
||||||
|
proxy_url=request.proxy_url
|
||||||
|
)
|
||||||
|
# Update runtime config
|
||||||
|
config.set_pow_service_mode(request.mode)
|
||||||
|
config.set_pow_service_use_token_for_pow(request.use_token_for_pow or False)
|
||||||
|
config.set_pow_service_server_url(request.server_url or "")
|
||||||
|
config.set_pow_service_api_key(request.api_key or "")
|
||||||
|
config.set_pow_service_proxy_enabled(request.proxy_enabled or False)
|
||||||
|
config.set_pow_service_proxy_url(request.proxy_url or "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "POW service configuration updated"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update POW service configuration: {str(e)}")
|
||||||
|
|
||||||
# Task management endpoints
|
# Task management endpoints
|
||||||
@router.post("/api/tasks/{task_id}/cancel")
|
@router.post("/api/tasks/{task_id}/cancel")
|
||||||
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
|
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ async def list_models(api_key: str = Depends(verify_api_key_header)):
|
|||||||
if config['type'] == 'image':
|
if config['type'] == 'image':
|
||||||
description += f" - {config['width']}x{config['height']}"
|
description += f" - {config['width']}x{config['height']}"
|
||||||
elif config['type'] == 'video':
|
elif config['type'] == 'video':
|
||||||
description += f" - {config['orientation']}"
|
if config.get("mode") == "video_extension":
|
||||||
|
description += f" - long video extension ({config.get('extension_duration_s', 10)}s)"
|
||||||
|
else:
|
||||||
|
description += f" - {config.get('orientation', 'unknown')}"
|
||||||
|
elif config['type'] == 'avatar_create':
|
||||||
|
description += " - create avatar from video"
|
||||||
elif config['type'] == 'prompt_enhance':
|
elif config['type'] == 'prompt_enhance':
|
||||||
description += f" - {config['expansion_level']} ({config['duration_s']}s)"
|
description += f" - {config['expansion_level']} ({config['duration_s']}s)"
|
||||||
|
|
||||||
@@ -105,18 +110,22 @@ async def create_chat_completion(
|
|||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
# Simple string format
|
# Simple string format
|
||||||
prompt = content
|
prompt = content
|
||||||
# Extract remix_target_id from prompt if not already provided
|
# Extract sora id from prompt if not already provided
|
||||||
|
extracted_id = _extract_remix_id(prompt)
|
||||||
|
if extracted_id:
|
||||||
if not remix_target_id:
|
if not remix_target_id:
|
||||||
remix_target_id = _extract_remix_id(prompt)
|
remix_target_id = extracted_id
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
# Array format (OpenAI multimodal)
|
# Array format (OpenAI multimodal)
|
||||||
for item in content:
|
for item in content:
|
||||||
if isinstance(item, dict):
|
if isinstance(item, dict):
|
||||||
if item.get("type") == "text":
|
if item.get("type") == "text":
|
||||||
prompt = item.get("text", "")
|
prompt = item.get("text", "")
|
||||||
# Extract remix_target_id from prompt if not already provided
|
# Extract sora id from prompt if not already provided
|
||||||
|
extracted_id = _extract_remix_id(prompt)
|
||||||
|
if extracted_id:
|
||||||
if not remix_target_id:
|
if not remix_target_id:
|
||||||
remix_target_id = _extract_remix_id(prompt)
|
remix_target_id = extracted_id
|
||||||
elif item.get("type") == "image_url":
|
elif item.get("type") == "image_url":
|
||||||
# Extract base64 image from data URI
|
# Extract base64 image from data URI
|
||||||
image_url = item.get("image_url", {})
|
image_url = item.get("image_url", {})
|
||||||
@@ -149,7 +158,7 @@ async def create_chat_completion(
|
|||||||
|
|
||||||
# Check if this is a video model
|
# Check if this is a video model
|
||||||
model_config = MODEL_CONFIG[request.model]
|
model_config = MODEL_CONFIG[request.model]
|
||||||
is_video_model = model_config["type"] == "video"
|
is_video_model = model_config["type"] in ["video", "avatar_create"]
|
||||||
|
|
||||||
# For video models with video parameter, we need streaming
|
# For video models with video parameter, we need streaming
|
||||||
if is_video_model and (video_data or remix_target_id):
|
if is_video_model and (video_data or remix_target_id):
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ class Config:
|
|||||||
def poll_interval(self) -> float:
|
def poll_interval(self) -> float:
|
||||||
return self._config["sora"]["poll_interval"]
|
return self._config["sora"]["poll_interval"]
|
||||||
|
|
||||||
|
def set_poll_interval(self, interval: float):
|
||||||
|
"""Set task progress polling interval in seconds"""
|
||||||
|
if "sora" not in self._config:
|
||||||
|
self._config["sora"] = {}
|
||||||
|
self._config["sora"]["poll_interval"] = float(interval)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_poll_attempts(self) -> int:
|
def max_poll_attempts(self) -> int:
|
||||||
return self._config["sora"]["max_poll_attempts"]
|
return self._config["sora"]["max_poll_attempts"]
|
||||||
@@ -238,25 +244,107 @@ class Config:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def pow_proxy_enabled(self) -> bool:
|
def pow_proxy_enabled(self) -> bool:
|
||||||
"""Get POW proxy enabled status"""
|
"""Get POW proxy enabled status
|
||||||
|
|
||||||
|
DEPRECATED: This configuration is deprecated. Use pow_service_proxy_enabled instead.
|
||||||
|
All POW proxy settings are now unified under [pow_service] section.
|
||||||
|
"""
|
||||||
return self._config.get("pow_proxy", {}).get("pow_proxy_enabled", False)
|
return self._config.get("pow_proxy", {}).get("pow_proxy_enabled", False)
|
||||||
|
|
||||||
def set_pow_proxy_enabled(self, enabled: bool):
|
def set_pow_proxy_enabled(self, enabled: bool):
|
||||||
"""Set POW proxy enabled/disabled"""
|
"""Set POW proxy enabled/disabled
|
||||||
|
|
||||||
|
DEPRECATED: This configuration is deprecated. Use set_pow_service_proxy_enabled instead.
|
||||||
|
All POW proxy settings are now unified under [pow_service] section.
|
||||||
|
"""
|
||||||
if "pow_proxy" not in self._config:
|
if "pow_proxy" not in self._config:
|
||||||
self._config["pow_proxy"] = {}
|
self._config["pow_proxy"] = {}
|
||||||
self._config["pow_proxy"]["pow_proxy_enabled"] = enabled
|
self._config["pow_proxy"]["pow_proxy_enabled"] = enabled
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pow_proxy_url(self) -> str:
|
def pow_proxy_url(self) -> str:
|
||||||
"""Get POW proxy URL"""
|
"""Get POW proxy URL
|
||||||
|
|
||||||
|
DEPRECATED: This configuration is deprecated. Use pow_service_proxy_url instead.
|
||||||
|
All POW proxy settings are now unified under [pow_service] section.
|
||||||
|
"""
|
||||||
return self._config.get("pow_proxy", {}).get("pow_proxy_url", "")
|
return self._config.get("pow_proxy", {}).get("pow_proxy_url", "")
|
||||||
|
|
||||||
def set_pow_proxy_url(self, url: str):
|
def set_pow_proxy_url(self, url: str):
|
||||||
"""Set POW proxy URL"""
|
"""Set POW proxy URL
|
||||||
|
|
||||||
|
DEPRECATED: This configuration is deprecated. Use set_pow_service_proxy_url instead.
|
||||||
|
All POW proxy settings are now unified under [pow_service] section.
|
||||||
|
"""
|
||||||
if "pow_proxy" not in self._config:
|
if "pow_proxy" not in self._config:
|
||||||
self._config["pow_proxy"] = {}
|
self._config["pow_proxy"] = {}
|
||||||
self._config["pow_proxy"]["pow_proxy_url"] = url
|
self._config["pow_proxy"]["pow_proxy_url"] = url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_mode(self) -> str:
|
||||||
|
"""Get POW service mode (local or external)"""
|
||||||
|
return self._config.get("pow_service", {}).get("mode", "local")
|
||||||
|
|
||||||
|
def set_pow_service_mode(self, mode: str):
|
||||||
|
"""Set POW service mode"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["mode"] = mode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_use_token_for_pow(self) -> bool:
|
||||||
|
"""Whether to use current token for POW calculation"""
|
||||||
|
return self._config.get("pow_service", {}).get("use_token_for_pow", False)
|
||||||
|
|
||||||
|
def set_pow_service_use_token_for_pow(self, enabled: bool):
|
||||||
|
"""Set whether to use current token for POW calculation"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["use_token_for_pow"] = enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_server_url(self) -> str:
|
||||||
|
"""Get POW service server URL"""
|
||||||
|
return self._config.get("pow_service", {}).get("server_url", "")
|
||||||
|
|
||||||
|
def set_pow_service_server_url(self, url: str):
|
||||||
|
"""Set POW service server URL"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["server_url"] = url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_api_key(self) -> str:
|
||||||
|
"""Get POW service API key"""
|
||||||
|
return self._config.get("pow_service", {}).get("api_key", "")
|
||||||
|
|
||||||
|
def set_pow_service_api_key(self, api_key: str):
|
||||||
|
"""Set POW service API key"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["api_key"] = api_key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_proxy_enabled(self) -> bool:
|
||||||
|
"""Get POW service proxy enabled status"""
|
||||||
|
return self._config.get("pow_service", {}).get("proxy_enabled", False)
|
||||||
|
|
||||||
|
def set_pow_service_proxy_enabled(self, enabled: bool):
|
||||||
|
"""Set POW service proxy enabled status"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["proxy_enabled"] = enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_proxy_url(self) -> str:
|
||||||
|
"""Get POW service proxy URL"""
|
||||||
|
return self._config.get("pow_service", {}).get("proxy_url", "")
|
||||||
|
|
||||||
|
def set_pow_service_proxy_url(self, url: str):
|
||||||
|
"""Set POW service proxy URL"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["proxy_url"] = url
|
||||||
|
|
||||||
# Global config instance
|
# Global config instance
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|||||||
@@ -83,18 +83,25 @@ class Database:
|
|||||||
# Get proxy config from config_dict if provided, otherwise use defaults
|
# Get proxy config from config_dict if provided, otherwise use defaults
|
||||||
proxy_enabled = False
|
proxy_enabled = False
|
||||||
proxy_url = None
|
proxy_url = None
|
||||||
|
image_upload_proxy_enabled = False
|
||||||
|
image_upload_proxy_url = None
|
||||||
|
|
||||||
if config_dict:
|
if config_dict:
|
||||||
proxy_config = config_dict.get("proxy", {})
|
proxy_config = config_dict.get("proxy", {})
|
||||||
proxy_enabled = proxy_config.get("proxy_enabled", False)
|
proxy_enabled = proxy_config.get("proxy_enabled", False)
|
||||||
proxy_url = proxy_config.get("proxy_url", "")
|
proxy_url = proxy_config.get("proxy_url", "")
|
||||||
|
image_upload_proxy_enabled = proxy_config.get("image_upload_proxy_enabled", False)
|
||||||
|
image_upload_proxy_url = proxy_config.get("image_upload_proxy_url", "")
|
||||||
# Convert empty string to None
|
# Convert empty string to None
|
||||||
proxy_url = proxy_url if proxy_url else None
|
proxy_url = proxy_url if proxy_url else None
|
||||||
|
image_upload_proxy_url = image_upload_proxy_url if image_upload_proxy_url else None
|
||||||
|
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
INSERT INTO proxy_config (id, proxy_enabled, proxy_url)
|
INSERT INTO proxy_config (
|
||||||
VALUES (1, ?, ?)
|
id, proxy_enabled, proxy_url, image_upload_proxy_enabled, image_upload_proxy_url
|
||||||
""", (proxy_enabled, proxy_url))
|
)
|
||||||
|
VALUES (1, ?, ?, ?, ?)
|
||||||
|
""", (proxy_enabled, proxy_url, image_upload_proxy_enabled, image_upload_proxy_url))
|
||||||
|
|
||||||
# Ensure watermark_free_config has a row
|
# Ensure watermark_free_config has a row
|
||||||
cursor = await db.execute("SELECT COUNT(*) FROM watermark_free_config")
|
cursor = await db.execute("SELECT COUNT(*) FROM watermark_free_config")
|
||||||
@@ -187,6 +194,7 @@ class Database:
|
|||||||
# Get call logic config from config_dict if provided, otherwise use defaults
|
# Get call logic config from config_dict if provided, otherwise use defaults
|
||||||
call_mode = "default"
|
call_mode = "default"
|
||||||
polling_mode_enabled = False
|
polling_mode_enabled = False
|
||||||
|
poll_interval = 2.5
|
||||||
|
|
||||||
if config_dict:
|
if config_dict:
|
||||||
call_logic_config = config_dict.get("call_logic", {})
|
call_logic_config = config_dict.get("call_logic", {})
|
||||||
@@ -199,10 +207,22 @@ class Database:
|
|||||||
else:
|
else:
|
||||||
polling_mode_enabled = call_mode == "polling"
|
polling_mode_enabled = call_mode == "polling"
|
||||||
|
|
||||||
|
sora_config = config_dict.get("sora", {})
|
||||||
|
poll_interval = sora_config.get("poll_interval", 2.5)
|
||||||
|
if "poll_interval" in call_logic_config:
|
||||||
|
poll_interval = call_logic_config.get("poll_interval", poll_interval)
|
||||||
|
|
||||||
|
try:
|
||||||
|
poll_interval = float(poll_interval)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
poll_interval = 2.5
|
||||||
|
if poll_interval <= 0:
|
||||||
|
poll_interval = 2.5
|
||||||
|
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
INSERT INTO call_logic_config (id, call_mode, polling_mode_enabled)
|
INSERT INTO call_logic_config (id, call_mode, polling_mode_enabled, poll_interval)
|
||||||
VALUES (1, ?, ?)
|
VALUES (1, ?, ?, ?)
|
||||||
""", (call_mode, polling_mode_enabled))
|
""", (call_mode, polling_mode_enabled, poll_interval))
|
||||||
|
|
||||||
# Ensure pow_proxy_config has a row
|
# Ensure pow_proxy_config has a row
|
||||||
cursor = await db.execute("SELECT COUNT(*) FROM pow_proxy_config")
|
cursor = await db.execute("SELECT COUNT(*) FROM pow_proxy_config")
|
||||||
@@ -224,6 +244,36 @@ class Database:
|
|||||||
VALUES (1, ?, ?)
|
VALUES (1, ?, ?)
|
||||||
""", (pow_proxy_enabled, pow_proxy_url))
|
""", (pow_proxy_enabled, pow_proxy_url))
|
||||||
|
|
||||||
|
# Ensure pow_service_config has a row
|
||||||
|
cursor = await db.execute("SELECT COUNT(*) FROM pow_service_config")
|
||||||
|
count = await cursor.fetchone()
|
||||||
|
if count[0] == 0:
|
||||||
|
# Get POW service config from config_dict if provided, otherwise use defaults
|
||||||
|
mode = "local"
|
||||||
|
use_token_for_pow = False
|
||||||
|
server_url = None
|
||||||
|
api_key = None
|
||||||
|
proxy_enabled = False
|
||||||
|
proxy_url = None
|
||||||
|
|
||||||
|
if config_dict:
|
||||||
|
pow_service_config = config_dict.get("pow_service", {})
|
||||||
|
mode = pow_service_config.get("mode", "local")
|
||||||
|
use_token_for_pow = pow_service_config.get("use_token_for_pow", False)
|
||||||
|
server_url = pow_service_config.get("server_url", "")
|
||||||
|
api_key = pow_service_config.get("api_key", "")
|
||||||
|
proxy_enabled = pow_service_config.get("proxy_enabled", False)
|
||||||
|
proxy_url = pow_service_config.get("proxy_url", "")
|
||||||
|
# Convert empty strings to None
|
||||||
|
server_url = server_url if server_url else None
|
||||||
|
api_key = api_key if api_key else None
|
||||||
|
proxy_url = proxy_url if proxy_url else None
|
||||||
|
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO pow_service_config (id, mode, use_token_for_pow, server_url, api_key, proxy_enabled, proxy_url)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (mode, use_token_for_pow, server_url, api_key, proxy_enabled, proxy_url))
|
||||||
|
|
||||||
|
|
||||||
async def check_and_migrate_db(self, config_dict: dict = None):
|
async def check_and_migrate_db(self, config_dict: dict = None):
|
||||||
"""Check database integrity and perform migrations if needed
|
"""Check database integrity and perform migrations if needed
|
||||||
@@ -291,6 +341,103 @@ class Database:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ✗ Failed to add column '{col_name}': {e}")
|
print(f" ✗ Failed to add column '{col_name}': {e}")
|
||||||
|
|
||||||
|
# Check and add missing columns to proxy_config table
|
||||||
|
if await self._table_exists(db, "proxy_config"):
|
||||||
|
added_image_upload_proxy_enabled_column = False
|
||||||
|
added_image_upload_proxy_url_column = False
|
||||||
|
columns_to_add = [
|
||||||
|
("image_upload_proxy_enabled", "BOOLEAN DEFAULT 0"),
|
||||||
|
("image_upload_proxy_url", "TEXT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_name, col_type in columns_to_add:
|
||||||
|
if not await self._column_exists(db, "proxy_config", col_name):
|
||||||
|
try:
|
||||||
|
await db.execute(f"ALTER TABLE proxy_config ADD COLUMN {col_name} {col_type}")
|
||||||
|
print(f" ✓ Added column '{col_name}' to proxy_config table")
|
||||||
|
if col_name == "image_upload_proxy_enabled":
|
||||||
|
added_image_upload_proxy_enabled_column = True
|
||||||
|
if col_name == "image_upload_proxy_url":
|
||||||
|
added_image_upload_proxy_url_column = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Failed to add column '{col_name}': {e}")
|
||||||
|
|
||||||
|
# On upgrade, initialize value from setting.toml only when columns are newly added
|
||||||
|
if config_dict and (added_image_upload_proxy_enabled_column or added_image_upload_proxy_url_column):
|
||||||
|
try:
|
||||||
|
proxy_config = config_dict.get("proxy", {})
|
||||||
|
image_upload_proxy_enabled = proxy_config.get("image_upload_proxy_enabled", False)
|
||||||
|
image_upload_proxy_url = proxy_config.get("image_upload_proxy_url", "")
|
||||||
|
image_upload_proxy_url = image_upload_proxy_url if image_upload_proxy_url else None
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE proxy_config
|
||||||
|
SET image_upload_proxy_enabled = ?, image_upload_proxy_url = ?
|
||||||
|
WHERE id = 1
|
||||||
|
""", (image_upload_proxy_enabled, image_upload_proxy_url))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Failed to initialize image upload proxy config from config: {e}")
|
||||||
|
|
||||||
|
# Check and add missing columns to pow_service_config table
|
||||||
|
if await self._table_exists(db, "pow_service_config"):
|
||||||
|
added_use_token_for_pow_column = False
|
||||||
|
columns_to_add = [
|
||||||
|
("use_token_for_pow", "BOOLEAN DEFAULT 0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_name, col_type in columns_to_add:
|
||||||
|
if not await self._column_exists(db, "pow_service_config", col_name):
|
||||||
|
try:
|
||||||
|
await db.execute(f"ALTER TABLE pow_service_config ADD COLUMN {col_name} {col_type}")
|
||||||
|
print(f" ✓ Added column '{col_name}' to pow_service_config table")
|
||||||
|
if col_name == "use_token_for_pow":
|
||||||
|
added_use_token_for_pow_column = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Failed to add column '{col_name}': {e}")
|
||||||
|
|
||||||
|
# On upgrade, initialize value from setting.toml only when this column is newly added
|
||||||
|
if config_dict and added_use_token_for_pow_column:
|
||||||
|
try:
|
||||||
|
use_token_for_pow = config_dict.get("pow_service", {}).get("use_token_for_pow", False)
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE pow_service_config
|
||||||
|
SET use_token_for_pow = ?
|
||||||
|
WHERE id = 1
|
||||||
|
""", (use_token_for_pow,))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Failed to initialize use_token_for_pow from config: {e}")
|
||||||
|
|
||||||
|
# Check and add missing columns to call_logic_config table
|
||||||
|
if await self._table_exists(db, "call_logic_config"):
|
||||||
|
added_poll_interval_column = False
|
||||||
|
columns_to_add = [
|
||||||
|
("poll_interval", "REAL DEFAULT 2.5"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col_name, col_type in columns_to_add:
|
||||||
|
if not await self._column_exists(db, "call_logic_config", col_name):
|
||||||
|
try:
|
||||||
|
await db.execute(f"ALTER TABLE call_logic_config ADD COLUMN {col_name} {col_type}")
|
||||||
|
print(f" ✓ Added column '{col_name}' to call_logic_config table")
|
||||||
|
if col_name == "poll_interval":
|
||||||
|
added_poll_interval_column = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Failed to add column '{col_name}': {e}")
|
||||||
|
|
||||||
|
# On upgrade, initialize value from setting.toml only when this column is newly added
|
||||||
|
if config_dict and added_poll_interval_column:
|
||||||
|
try:
|
||||||
|
poll_interval = config_dict.get("sora", {}).get("poll_interval", 2.5)
|
||||||
|
poll_interval = float(poll_interval)
|
||||||
|
if poll_interval <= 0:
|
||||||
|
poll_interval = 2.5
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE call_logic_config
|
||||||
|
SET poll_interval = ?
|
||||||
|
WHERE id = 1
|
||||||
|
""", (poll_interval,))
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Failed to initialize poll_interval from config: {e}")
|
||||||
|
|
||||||
# Check and add missing columns to watermark_free_config table
|
# Check and add missing columns to watermark_free_config table
|
||||||
if await self._table_exists(db, "watermark_free_config"):
|
if await self._table_exists(db, "watermark_free_config"):
|
||||||
columns_to_add = [
|
columns_to_add = [
|
||||||
@@ -330,8 +477,13 @@ class Database:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
print("Database migration check completed.")
|
print("Database migration check completed.")
|
||||||
|
|
||||||
async def init_db(self):
|
async def init_db(self, config_dict: dict = None):
|
||||||
"""Initialize database tables - creates all tables and ensures data integrity"""
|
"""Initialize database tables - creates all tables and ensures data integrity
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_dict: Configuration dictionary from setting.toml (optional).
|
||||||
|
Used to initialize newly-added proxy columns during migration.
|
||||||
|
"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
# Tokens table
|
# Tokens table
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
@@ -365,7 +517,8 @@ class Database:
|
|||||||
video_enabled BOOLEAN DEFAULT 1,
|
video_enabled BOOLEAN DEFAULT 1,
|
||||||
image_concurrency INTEGER DEFAULT -1,
|
image_concurrency INTEGER DEFAULT -1,
|
||||||
video_concurrency INTEGER DEFAULT -1,
|
video_concurrency INTEGER DEFAULT -1,
|
||||||
is_expired BOOLEAN DEFAULT 0
|
is_expired BOOLEAN DEFAULT 0,
|
||||||
|
disabled_reason TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -443,6 +596,8 @@ class Database:
|
|||||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
proxy_enabled BOOLEAN DEFAULT 0,
|
proxy_enabled BOOLEAN DEFAULT 0,
|
||||||
proxy_url TEXT,
|
proxy_url TEXT,
|
||||||
|
image_upload_proxy_enabled BOOLEAN DEFAULT 0,
|
||||||
|
image_upload_proxy_url TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
@@ -501,6 +656,7 @@ class Database:
|
|||||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
call_mode TEXT DEFAULT 'default',
|
call_mode TEXT DEFAULT 'default',
|
||||||
polling_mode_enabled BOOLEAN DEFAULT 0,
|
polling_mode_enabled BOOLEAN DEFAULT 0,
|
||||||
|
poll_interval REAL DEFAULT 2.5,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
@@ -517,6 +673,21 @@ class Database:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Create pow_service_config table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS pow_service_config (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
mode TEXT DEFAULT 'local',
|
||||||
|
use_token_for_pow BOOLEAN DEFAULT 0,
|
||||||
|
server_url TEXT,
|
||||||
|
api_key TEXT,
|
||||||
|
proxy_enabled BOOLEAN DEFAULT 0,
|
||||||
|
proxy_url TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# Create indexes
|
# Create indexes
|
||||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
|
||||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_status ON tasks(status)")
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_status ON tasks(status)")
|
||||||
@@ -544,6 +715,44 @@ class Database:
|
|||||||
if not await self._column_exists(db, "admin_config", "auto_disable_on_401"):
|
if not await self._column_exists(db, "admin_config", "auto_disable_on_401"):
|
||||||
await db.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1")
|
await db.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1")
|
||||||
|
|
||||||
|
# Migration: Add image upload proxy columns to proxy_config table if they don't exist
|
||||||
|
added_image_upload_proxy_enabled_column = False
|
||||||
|
added_image_upload_proxy_url_column = False
|
||||||
|
if not await self._column_exists(db, "proxy_config", "image_upload_proxy_enabled"):
|
||||||
|
await db.execute("ALTER TABLE proxy_config ADD COLUMN image_upload_proxy_enabled BOOLEAN DEFAULT 0")
|
||||||
|
added_image_upload_proxy_enabled_column = True
|
||||||
|
if not await self._column_exists(db, "proxy_config", "image_upload_proxy_url"):
|
||||||
|
await db.execute("ALTER TABLE proxy_config ADD COLUMN image_upload_proxy_url TEXT")
|
||||||
|
added_image_upload_proxy_url_column = True
|
||||||
|
|
||||||
|
# If migration added image upload proxy columns, initialize them from setting.toml defaults
|
||||||
|
if config_dict and (added_image_upload_proxy_enabled_column or added_image_upload_proxy_url_column):
|
||||||
|
proxy_config = config_dict.get("proxy", {})
|
||||||
|
image_upload_proxy_enabled = proxy_config.get("image_upload_proxy_enabled", False)
|
||||||
|
image_upload_proxy_url = proxy_config.get("image_upload_proxy_url", "")
|
||||||
|
image_upload_proxy_url = image_upload_proxy_url if image_upload_proxy_url else None
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE proxy_config
|
||||||
|
SET image_upload_proxy_enabled = ?, image_upload_proxy_url = ?
|
||||||
|
WHERE id = 1
|
||||||
|
""", (image_upload_proxy_enabled, image_upload_proxy_url))
|
||||||
|
|
||||||
|
# Migration: Add disabled_reason column to tokens table if it doesn't exist
|
||||||
|
if not await self._column_exists(db, "tokens", "disabled_reason"):
|
||||||
|
await db.execute("ALTER TABLE tokens ADD COLUMN disabled_reason TEXT")
|
||||||
|
# For existing disabled tokens without a reason, set to 'manual'
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE tokens
|
||||||
|
SET disabled_reason = 'manual'
|
||||||
|
WHERE is_active = 0 AND disabled_reason IS NULL
|
||||||
|
""")
|
||||||
|
# For existing expired tokens, set to 'expired'
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE tokens
|
||||||
|
SET disabled_reason = 'expired'
|
||||||
|
WHERE is_expired = 1 AND disabled_reason IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
|
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
|
||||||
@@ -656,27 +865,35 @@ class Database:
|
|||||||
""", (token_id,))
|
""", (token_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def update_token_status(self, token_id: int, is_active: bool):
|
async def update_token_status(self, token_id: int, is_active: bool, disabled_reason: Optional[str] = None):
|
||||||
"""Update token status"""
|
"""Update token status and disabled reason"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE tokens SET is_active = ? WHERE id = ?
|
UPDATE tokens SET is_active = ?, disabled_reason = ? WHERE id = ?
|
||||||
""", (is_active, token_id))
|
""", (is_active, disabled_reason, token_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def mark_token_expired(self, token_id: int):
|
async def mark_token_expired(self, token_id: int):
|
||||||
"""Mark token as expired and disable it"""
|
"""Mark token as expired and disable it with reason"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE tokens SET is_expired = 1, is_active = 0 WHERE id = ?
|
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
|
||||||
""", (token_id,))
|
""", ("expired", token_id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def mark_token_invalid(self, token_id: int):
|
||||||
|
"""Mark token as invalid (401 error) and disable it"""
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
|
||||||
|
""", ("token_invalid", token_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def clear_token_expired(self, token_id: int):
|
async def clear_token_expired(self, token_id: int):
|
||||||
"""Clear token expired flag"""
|
"""Clear token expired flag and disabled reason"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE tokens SET is_expired = 0 WHERE id = ?
|
UPDATE tokens SET is_expired = 0, disabled_reason = NULL WHERE id = ?
|
||||||
""", (token_id,))
|
""", (token_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -1089,14 +1306,26 @@ class Database:
|
|||||||
# This should not happen in normal operation as _ensure_config_rows should create it
|
# This should not happen in normal operation as _ensure_config_rows should create it
|
||||||
return ProxyConfig(proxy_enabled=False)
|
return ProxyConfig(proxy_enabled=False)
|
||||||
|
|
||||||
async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]):
|
async def update_proxy_config(
|
||||||
|
self,
|
||||||
|
enabled: bool,
|
||||||
|
proxy_url: Optional[str],
|
||||||
|
image_upload_proxy_enabled: bool = False,
|
||||||
|
image_upload_proxy_url: Optional[str] = None
|
||||||
|
):
|
||||||
"""Update proxy configuration"""
|
"""Update proxy configuration"""
|
||||||
|
proxy_url = proxy_url if proxy_url else None
|
||||||
|
image_upload_proxy_url = image_upload_proxy_url if image_upload_proxy_url else None
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE proxy_config
|
UPDATE proxy_config
|
||||||
SET proxy_enabled = ?, proxy_url = ?, updated_at = CURRENT_TIMESTAMP
|
SET proxy_enabled = ?,
|
||||||
|
proxy_url = ?,
|
||||||
|
image_upload_proxy_enabled = ?,
|
||||||
|
image_upload_proxy_url = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = 1
|
WHERE id = 1
|
||||||
""", (enabled, proxy_url))
|
""", (enabled, proxy_url, image_upload_proxy_enabled, image_upload_proxy_url))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Watermark-free config operations
|
# Watermark-free config operations
|
||||||
@@ -1249,19 +1478,46 @@ class Database:
|
|||||||
row_dict = dict(row)
|
row_dict = dict(row)
|
||||||
if not row_dict.get("call_mode"):
|
if not row_dict.get("call_mode"):
|
||||||
row_dict["call_mode"] = "polling" if row_dict.get("polling_mode_enabled") else "default"
|
row_dict["call_mode"] = "polling" if row_dict.get("polling_mode_enabled") else "default"
|
||||||
|
poll_interval = row_dict.get("poll_interval", 2.5)
|
||||||
|
try:
|
||||||
|
poll_interval = float(poll_interval)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
poll_interval = 2.5
|
||||||
|
if poll_interval <= 0:
|
||||||
|
poll_interval = 2.5
|
||||||
|
row_dict["poll_interval"] = poll_interval
|
||||||
return CallLogicConfig(**row_dict)
|
return CallLogicConfig(**row_dict)
|
||||||
return CallLogicConfig(call_mode="default", polling_mode_enabled=False)
|
return CallLogicConfig(call_mode="default", polling_mode_enabled=False, poll_interval=2.5)
|
||||||
|
|
||||||
async def update_call_logic_config(self, call_mode: str):
|
async def update_call_logic_config(self, call_mode: str, poll_interval: Optional[float] = None):
|
||||||
"""Update call logic configuration"""
|
"""Update call logic configuration"""
|
||||||
normalized = "polling" if call_mode == "polling" else "default"
|
normalized = "polling" if call_mode == "polling" else "default"
|
||||||
polling_mode_enabled = normalized == "polling"
|
polling_mode_enabled = normalized == "polling"
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
effective_poll_interval = 2.5
|
||||||
|
cursor = await db.execute("SELECT poll_interval FROM call_logic_config WHERE id = 1")
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row and row[0] is not None:
|
||||||
|
try:
|
||||||
|
effective_poll_interval = float(row[0])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
effective_poll_interval = 2.5
|
||||||
|
if effective_poll_interval <= 0:
|
||||||
|
effective_poll_interval = 2.5
|
||||||
|
|
||||||
|
if poll_interval is not None:
|
||||||
|
try:
|
||||||
|
effective_poll_interval = float(poll_interval)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
effective_poll_interval = 2.5
|
||||||
|
if effective_poll_interval <= 0:
|
||||||
|
effective_poll_interval = 2.5
|
||||||
|
|
||||||
# Use INSERT OR REPLACE to ensure the row exists
|
# Use INSERT OR REPLACE to ensure the row exists
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
INSERT OR REPLACE INTO call_logic_config (id, call_mode, polling_mode_enabled, updated_at)
|
INSERT OR REPLACE INTO call_logic_config (id, call_mode, polling_mode_enabled, poll_interval, updated_at)
|
||||||
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (1, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
""", (normalized, polling_mode_enabled))
|
""", (normalized, polling_mode_enabled, effective_poll_interval))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# POW proxy config operations
|
# POW proxy config operations
|
||||||
@@ -1276,6 +1532,24 @@ class Database:
|
|||||||
return PowProxyConfig(**dict(row))
|
return PowProxyConfig(**dict(row))
|
||||||
return PowProxyConfig(pow_proxy_enabled=False, pow_proxy_url=None)
|
return PowProxyConfig(pow_proxy_enabled=False, pow_proxy_url=None)
|
||||||
|
|
||||||
|
async def get_pow_service_config(self) -> "PowServiceConfig":
|
||||||
|
"""Get POW service configuration"""
|
||||||
|
from .models import PowServiceConfig
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
cursor = await db.execute("SELECT * FROM pow_service_config WHERE id = 1")
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return PowServiceConfig(**dict(row))
|
||||||
|
return PowServiceConfig(
|
||||||
|
mode="local",
|
||||||
|
use_token_for_pow=False,
|
||||||
|
server_url=None,
|
||||||
|
api_key=None,
|
||||||
|
proxy_enabled=False,
|
||||||
|
proxy_url=None
|
||||||
|
)
|
||||||
|
|
||||||
async def update_pow_proxy_config(self, pow_proxy_enabled: bool, pow_proxy_url: Optional[str] = None):
|
async def update_pow_proxy_config(self, pow_proxy_enabled: bool, pow_proxy_url: Optional[str] = None):
|
||||||
"""Update POW proxy configuration"""
|
"""Update POW proxy configuration"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
@@ -1286,3 +1560,22 @@ class Database:
|
|||||||
""", (pow_proxy_enabled, pow_proxy_url))
|
""", (pow_proxy_enabled, pow_proxy_url))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
async def update_pow_service_config(
|
||||||
|
self,
|
||||||
|
mode: str,
|
||||||
|
use_token_for_pow: bool = False,
|
||||||
|
server_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
proxy_enabled: Optional[bool] = None,
|
||||||
|
proxy_url: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""Update POW service configuration"""
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
# Use INSERT OR REPLACE to ensure the row exists
|
||||||
|
await db.execute("""
|
||||||
|
INSERT OR REPLACE INTO pow_service_config (id, mode, use_token_for_pow, server_url, api_key, proxy_enabled, proxy_url, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""", (mode, use_token_for_pow, server_url, api_key, proxy_enabled, proxy_url))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,18 @@ class DebugLogger:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error logging info: {e}")
|
self.logger.error(f"Error logging info: {e}")
|
||||||
|
|
||||||
|
def log_warning(self, message: str):
|
||||||
|
"""Log warning message to log.txt"""
|
||||||
|
|
||||||
|
# Check if debug mode is enabled
|
||||||
|
if not config.debug_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.warning(f"⚠️ [{self._format_timestamp()}] {message}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error logging warning: {e}")
|
||||||
|
|
||||||
# Global debug logger instance
|
# Global debug logger instance
|
||||||
debug_logger = DebugLogger()
|
debug_logger = DebugLogger()
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class Token(BaseModel):
|
|||||||
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
|
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
|
||||||
# 过期标记
|
# 过期标记
|
||||||
is_expired: bool = False # Token是否已过期(401 token_invalidated)
|
is_expired: bool = False # Token是否已过期(401 token_invalidated)
|
||||||
|
# 禁用原因: manual=手动禁用, error_limit=错误次数超限, token_invalid=Token失效, expired=过期失效
|
||||||
|
disabled_reason: Optional[str] = None
|
||||||
|
|
||||||
class TokenStats(BaseModel):
|
class TokenStats(BaseModel):
|
||||||
"""Token statistics"""
|
"""Token statistics"""
|
||||||
@@ -100,6 +102,8 @@ class ProxyConfig(BaseModel):
|
|||||||
id: int = 1
|
id: int = 1
|
||||||
proxy_enabled: bool # Read from database, initialized from setting.toml on first startup
|
proxy_enabled: bool # Read from database, initialized from setting.toml on first startup
|
||||||
proxy_url: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
proxy_url: Optional[str] = None # Read from database, initialized from setting.toml on first startup
|
||||||
|
image_upload_proxy_enabled: bool = False # Image upload proxy enabled
|
||||||
|
image_upload_proxy_url: Optional[str] = None # Image upload proxy URL
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@@ -143,6 +147,7 @@ class CallLogicConfig(BaseModel):
|
|||||||
id: int = 1
|
id: int = 1
|
||||||
call_mode: str = "default" # "default" or "polling"
|
call_mode: str = "default" # "default" or "polling"
|
||||||
polling_mode_enabled: bool = False # Read from database, initialized from setting.toml on first startup
|
polling_mode_enabled: bool = False # Read from database, initialized from setting.toml on first startup
|
||||||
|
poll_interval: float = 2.5 # Progress polling interval in seconds
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
@@ -154,6 +159,18 @@ class PowProxyConfig(BaseModel):
|
|||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class PowServiceConfig(BaseModel):
|
||||||
|
"""POW service configuration"""
|
||||||
|
id: int = 1
|
||||||
|
mode: str = "local" # "local" or "external"
|
||||||
|
use_token_for_pow: bool = False # Whether to use current token for POW calculation
|
||||||
|
server_url: Optional[str] = None # External POW service URL
|
||||||
|
api_key: Optional[str] = None # External POW service API key
|
||||||
|
proxy_enabled: bool = False # Whether to enable proxy for POW service
|
||||||
|
proxy_url: Optional[str] = None # Proxy URL for POW service
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
# API Request/Response models
|
# API Request/Response models
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(BaseModel):
|
||||||
role: str
|
role: str
|
||||||
|
|||||||
15
src/main.py
15
src/main.py
@@ -103,7 +103,7 @@ async def startup_event():
|
|||||||
is_first_startup = not db.db_exists()
|
is_first_startup = not db.db_exists()
|
||||||
|
|
||||||
# Initialize database tables
|
# Initialize database tables
|
||||||
await db.init_db()
|
await db.init_db(config_dict)
|
||||||
|
|
||||||
# Handle database initialization based on startup type
|
# Handle database initialization based on startup type
|
||||||
if is_first_startup:
|
if is_first_startup:
|
||||||
@@ -142,7 +142,18 @@ async def startup_event():
|
|||||||
# Load call logic configuration from database
|
# Load call logic configuration from database
|
||||||
call_logic_config = await db.get_call_logic_config()
|
call_logic_config = await db.get_call_logic_config()
|
||||||
config.set_call_logic_mode(call_logic_config.call_mode)
|
config.set_call_logic_mode(call_logic_config.call_mode)
|
||||||
print(f"✓ Call logic mode: {call_logic_config.call_mode}")
|
config.set_poll_interval(call_logic_config.poll_interval)
|
||||||
|
print(f"✓ Call logic mode: {call_logic_config.call_mode}, poll_interval: {call_logic_config.poll_interval}s")
|
||||||
|
|
||||||
|
# Load POW service configuration from database
|
||||||
|
pow_service_config = await db.get_pow_service_config()
|
||||||
|
config.set_pow_service_mode(pow_service_config.mode)
|
||||||
|
config.set_pow_service_use_token_for_pow(pow_service_config.use_token_for_pow)
|
||||||
|
config.set_pow_service_server_url(pow_service_config.server_url or "")
|
||||||
|
config.set_pow_service_api_key(pow_service_config.api_key or "")
|
||||||
|
config.set_pow_service_proxy_enabled(pow_service_config.proxy_enabled)
|
||||||
|
config.set_pow_service_proxy_url(pow_service_config.proxy_url or "")
|
||||||
|
print(f"✓ POW service mode: {pow_service_config.mode}, use_token_for_pow: {pow_service_config.use_token_for_pow}")
|
||||||
|
|
||||||
# Initialize concurrency manager with all tokens
|
# Initialize concurrency manager with all tokens
|
||||||
all_tokens = await db.get_all_tokens()
|
all_tokens = await db.get_all_tokens()
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ MODEL_CONFIG = {
|
|||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"n_frames": 450
|
"n_frames": 450
|
||||||
},
|
},
|
||||||
|
# Video extension models (long_video_extension)
|
||||||
|
"sora2-extension-10s": {
|
||||||
|
"type": "video",
|
||||||
|
"mode": "video_extension",
|
||||||
|
"extension_duration_s": 10
|
||||||
|
},
|
||||||
|
"sora2-extension-15s": {
|
||||||
|
"type": "video",
|
||||||
|
"mode": "video_extension",
|
||||||
|
"extension_duration_s": 15
|
||||||
|
},
|
||||||
# Video models with 25s duration (750 frames) - require Pro subscription
|
# Video models with 25s duration (750 frames) - require Pro subscription
|
||||||
"sora2-landscape-25s": {
|
"sora2-landscape-25s": {
|
||||||
"type": "video",
|
"type": "video",
|
||||||
@@ -207,6 +218,12 @@ MODEL_CONFIG = {
|
|||||||
"type": "prompt_enhance",
|
"type": "prompt_enhance",
|
||||||
"expansion_level": "long",
|
"expansion_level": "long",
|
||||||
"duration_s": 20
|
"duration_s": 20
|
||||||
|
},
|
||||||
|
# Avatar creation model (character creation only)
|
||||||
|
"avatar-create": {
|
||||||
|
"type": "avatar_create",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"n_frames": 300
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +282,13 @@ class GenerationHandler:
|
|||||||
return False
|
return False
|
||||||
if "429" in error_str or "rate limit" in error_str:
|
if "429" in error_str or "rate limit" in error_str:
|
||||||
return False
|
return False
|
||||||
|
# 参数/模型使用错误无需重试
|
||||||
|
if "invalid model" in error_str:
|
||||||
|
return False
|
||||||
|
if "avatar-create" in error_str:
|
||||||
|
return False
|
||||||
|
if "参数错误" in error_str:
|
||||||
|
return False
|
||||||
|
|
||||||
# 其他所有错误都可以重试
|
# 其他所有错误都可以重试
|
||||||
return True
|
return True
|
||||||
@@ -299,6 +323,29 @@ class GenerationHandler:
|
|||||||
|
|
||||||
return final_username
|
return final_username
|
||||||
|
|
||||||
|
def _extract_generation_id(self, text: str) -> str:
|
||||||
|
"""Extract generation ID from text.
|
||||||
|
|
||||||
|
Supported format: gen_[a-zA-Z0-9]+
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
match = re.search(r'gen_[a-zA-Z0-9]+', text)
|
||||||
|
if match:
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _clean_generation_id_from_prompt(self, prompt: str) -> str:
|
||||||
|
"""Remove generation_id (gen_xxx) from prompt."""
|
||||||
|
if not prompt:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
cleaned = re.sub(r'gen_[a-zA-Z0-9]+', '', prompt)
|
||||||
|
cleaned = ' '.join(cleaned.split())
|
||||||
|
return cleaned
|
||||||
|
|
||||||
def _clean_remix_link_from_prompt(self, prompt: str) -> str:
|
def _clean_remix_link_from_prompt(self, prompt: str) -> str:
|
||||||
"""Remove remix link from prompt
|
"""Remove remix link from prompt
|
||||||
|
|
||||||
@@ -429,9 +476,10 @@ class GenerationHandler:
|
|||||||
raise ValueError(f"Invalid model: {model}")
|
raise ValueError(f"Invalid model: {model}")
|
||||||
|
|
||||||
model_config = MODEL_CONFIG[model]
|
model_config = MODEL_CONFIG[model]
|
||||||
is_video = model_config["type"] == "video"
|
is_video = model_config["type"] in ["video", "avatar_create"]
|
||||||
is_image = model_config["type"] == "image"
|
is_image = model_config["type"] == "image"
|
||||||
is_prompt_enhance = model_config["type"] == "prompt_enhance"
|
is_prompt_enhance = model_config["type"] == "prompt_enhance"
|
||||||
|
is_avatar_create = model_config["type"] == "avatar_create"
|
||||||
|
|
||||||
# Handle prompt enhancement
|
# Handle prompt enhancement
|
||||||
if is_prompt_enhance:
|
if is_prompt_enhance:
|
||||||
@@ -445,38 +493,54 @@ class GenerationHandler:
|
|||||||
if available:
|
if available:
|
||||||
if is_image:
|
if is_image:
|
||||||
message = "All tokens available for image generation. Please enable streaming to use the generation feature."
|
message = "All tokens available for image generation. Please enable streaming to use the generation feature."
|
||||||
|
elif is_avatar_create:
|
||||||
|
message = "All tokens available for avatar creation. Please enable streaming to create avatar."
|
||||||
else:
|
else:
|
||||||
message = "All tokens available for video generation. Please enable streaming to use the generation feature."
|
message = "All tokens available for video generation. Please enable streaming to use the generation feature."
|
||||||
else:
|
else:
|
||||||
if is_image:
|
if is_image:
|
||||||
message = "No available models for image generation"
|
message = "No available models for image generation"
|
||||||
|
elif is_avatar_create:
|
||||||
|
message = "No available tokens for avatar creation"
|
||||||
else:
|
else:
|
||||||
message = "No available models for video generation"
|
message = "No available models for video generation"
|
||||||
|
|
||||||
yield self._format_non_stream_response(message, is_availability_check=True)
|
yield self._format_non_stream_response(message, is_availability_check=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Handle character creation and remix flows for video models
|
# Handle avatar creation model (character creation only)
|
||||||
if is_video:
|
if is_avatar_create:
|
||||||
|
# Priority: video > prompt内generation_id(gen_xxx)
|
||||||
|
if video:
|
||||||
|
video_data = self._decode_base64_video(video) if video.startswith("data:") or not video.startswith("http") else video
|
||||||
|
async for chunk in self._handle_character_creation_only(video_data, model_config):
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
|
||||||
|
# generation_id 仅从提示词解析
|
||||||
|
source_generation_id = self._extract_generation_id(prompt) if prompt else None
|
||||||
|
if source_generation_id:
|
||||||
|
async for chunk in self._handle_character_creation_from_generation_id(source_generation_id, model_config):
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
|
||||||
|
raise Exception("avatar-create 模型需要传入视频文件,或在提示词中包含 generation_id(gen_xxx)。")
|
||||||
|
|
||||||
|
# Handle remix flow for regular video models
|
||||||
|
if model_config["type"] == "video":
|
||||||
# Remix flow: remix_target_id provided
|
# Remix flow: remix_target_id provided
|
||||||
if remix_target_id:
|
if remix_target_id:
|
||||||
async for chunk in self._handle_remix(remix_target_id, prompt, model_config):
|
async for chunk in self._handle_remix(remix_target_id, prompt, model_config):
|
||||||
yield chunk
|
yield chunk
|
||||||
return
|
return
|
||||||
|
|
||||||
# Character creation flow: video provided
|
# Character creation has been isolated into avatar-create model
|
||||||
if video:
|
if video:
|
||||||
# Decode video if it's base64
|
raise Exception("角色创建已独立为 avatar-create 模型,请切换模型后重试。")
|
||||||
video_data = self._decode_base64_video(video) if video.startswith("data:") or not video.startswith("http") else video
|
|
||||||
|
|
||||||
# If no prompt, just create character and return
|
# Handle video extension flow
|
||||||
if not prompt:
|
if model_config.get("mode") == "video_extension":
|
||||||
async for chunk in self._handle_character_creation_only(video_data, model_config):
|
async for chunk in self._handle_video_extension(prompt, model_config, model):
|
||||||
yield chunk
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
# If prompt provided, create character and generate video
|
|
||||||
async for chunk in self._handle_character_and_video_generation(video_data, prompt, model_config):
|
|
||||||
yield chunk
|
yield chunk
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -547,7 +611,11 @@ class GenerationHandler:
|
|||||||
is_first_chunk = False
|
is_first_chunk = False
|
||||||
|
|
||||||
image_data = self._decode_base64_image(image)
|
image_data = self._decode_base64_image(image)
|
||||||
media_id = await self.sora_client.upload_image(image_data, token_obj.token)
|
media_id = await self.sora_client.upload_image(
|
||||||
|
image_data,
|
||||||
|
token_obj.token,
|
||||||
|
token_id=token_obj.id
|
||||||
|
)
|
||||||
|
|
||||||
if stream:
|
if stream:
|
||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
@@ -797,7 +865,15 @@ class GenerationHandler:
|
|||||||
# Try generation
|
# Try generation
|
||||||
# Only show init message on first attempt (not on retries)
|
# Only show init message on first attempt (not on retries)
|
||||||
show_init = (retry_count == 0)
|
show_init = (retry_count == 0)
|
||||||
async for chunk in self.handle_generation(model, prompt, image, video, remix_target_id, stream, show_init_message=show_init):
|
async for chunk in self.handle_generation(
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
image,
|
||||||
|
video,
|
||||||
|
remix_target_id,
|
||||||
|
stream,
|
||||||
|
show_init_message=show_init
|
||||||
|
):
|
||||||
yield chunk
|
yield chunk
|
||||||
# If successful, return
|
# If successful, return
|
||||||
return
|
return
|
||||||
@@ -950,7 +1026,7 @@ class GenerationHandler:
|
|||||||
last_status_output_time = current_time
|
last_status_output_time = current_time
|
||||||
debug_logger.log_info(f"Task {task_id} progress: {progress_pct}% (status: {status})")
|
debug_logger.log_info(f"Task {task_id} progress: {progress_pct}% (status: {status})")
|
||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
reasoning_content=f"**Video Generation Progress**: {progress_pct}% ({status})\n"
|
reasoning_content=f"\n**Video Generation Progress**: {progress_pct}% ({status})\n"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1640,7 +1716,11 @@ class GenerationHandler:
|
|||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
reasoning_content="Uploading character avatar...\n"
|
reasoning_content="Uploading character avatar...\n"
|
||||||
)
|
)
|
||||||
asset_pointer = await self.sora_client.upload_character_image(avatar_data, token_obj.token)
|
asset_pointer = await self.sora_client.upload_character_image(
|
||||||
|
avatar_data,
|
||||||
|
token_obj.token,
|
||||||
|
token_id=token_obj.id
|
||||||
|
)
|
||||||
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
|
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
|
||||||
|
|
||||||
# Step 5: Finalize character
|
# Step 5: Finalize character
|
||||||
@@ -1669,6 +1749,17 @@ class GenerationHandler:
|
|||||||
|
|
||||||
# Log successful character creation
|
# Log successful character creation
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
|
character_card = {
|
||||||
|
"username": username,
|
||||||
|
"display_name": display_name,
|
||||||
|
"character_id": character_id,
|
||||||
|
"cameo_id": cameo_id,
|
||||||
|
"profile_asset_url": profile_asset_url,
|
||||||
|
"instruction_set": instruction_set,
|
||||||
|
"public": True,
|
||||||
|
"source_model": "avatar-create",
|
||||||
|
"created_at": int(datetime.now().timestamp())
|
||||||
|
}
|
||||||
await self._log_request(
|
await self._log_request(
|
||||||
token_id=token_obj.id,
|
token_id=token_obj.id,
|
||||||
operation="character_only",
|
operation="character_only",
|
||||||
@@ -1678,18 +1769,31 @@ class GenerationHandler:
|
|||||||
},
|
},
|
||||||
response_data={
|
response_data={
|
||||||
"success": True,
|
"success": True,
|
||||||
"username": username,
|
"card": character_card
|
||||||
"display_name": display_name,
|
|
||||||
"character_id": character_id,
|
|
||||||
"cameo_id": cameo_id
|
|
||||||
},
|
},
|
||||||
status_code=200,
|
status_code=200,
|
||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 7: Return success message
|
# Step 7: Return structured character card
|
||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
content=f"角色创建成功,角色名@{username}",
|
content=(
|
||||||
|
json.dumps({
|
||||||
|
"event": "character_card",
|
||||||
|
"card": character_card
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 8: Return summary message
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
content=(
|
||||||
|
f"角色创建成功,角色名@{username}\n"
|
||||||
|
f"显示名:{display_name}\n"
|
||||||
|
f"Character ID:{character_id}\n"
|
||||||
|
f"Cameo ID:{cameo_id}"
|
||||||
|
),
|
||||||
finish_reason="STOP"
|
finish_reason="STOP"
|
||||||
)
|
)
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
@@ -1741,6 +1845,189 @@ class GenerationHandler:
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _handle_character_creation_from_generation_id(self, generation_id: str, model_config: Dict) -> AsyncGenerator[str, None]:
|
||||||
|
"""Handle character creation from generation id (gen_xxx)."""
|
||||||
|
token_obj = await self.load_balancer.select_token(for_video_generation=True)
|
||||||
|
if not token_obj:
|
||||||
|
raise Exception("No available tokens for character creation")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
normalized_generation_id = self._extract_generation_id((generation_id or "").strip())
|
||||||
|
try:
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="**Character Creation Begins**\n\nInitializing character creation from generation id...\n",
|
||||||
|
is_first=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not normalized_generation_id:
|
||||||
|
raise Exception("无效 generation_id,请传入 gen_xxx。")
|
||||||
|
|
||||||
|
# Step 1: Create cameo from generation
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content=f"Creating character from generation: {normalized_generation_id} ...\n"
|
||||||
|
)
|
||||||
|
cameo_id = await self.sora_client.create_character_from_generation(
|
||||||
|
generation_id=normalized_generation_id,
|
||||||
|
token=token_obj.token,
|
||||||
|
timestamps=[0, 3]
|
||||||
|
)
|
||||||
|
debug_logger.log_info(f"Character-from-generation submitted, cameo_id: {cameo_id}")
|
||||||
|
|
||||||
|
# Step 2: Poll cameo processing
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Processing generation to extract character...\n"
|
||||||
|
)
|
||||||
|
cameo_status = await self._poll_cameo_status(cameo_id, token_obj.token)
|
||||||
|
debug_logger.log_info(f"Cameo status: {cameo_status}")
|
||||||
|
|
||||||
|
# Extract character info
|
||||||
|
username_hint = cameo_status.get("username_hint", "character")
|
||||||
|
display_name = cameo_status.get("display_name_hint", "Character")
|
||||||
|
username = self._process_character_username(username_hint)
|
||||||
|
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content=f"✨ 角色已识别: {display_name} (@{username})\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Download avatar
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Downloading character avatar...\n"
|
||||||
|
)
|
||||||
|
profile_asset_url = cameo_status.get("profile_asset_url")
|
||||||
|
if not profile_asset_url:
|
||||||
|
raise Exception("Profile asset URL not found in cameo status")
|
||||||
|
|
||||||
|
avatar_data = await self.sora_client.download_character_image(profile_asset_url)
|
||||||
|
debug_logger.log_info(f"Avatar downloaded, size: {len(avatar_data)} bytes")
|
||||||
|
|
||||||
|
# Step 4: Upload avatar
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Uploading character avatar...\n"
|
||||||
|
)
|
||||||
|
asset_pointer = await self.sora_client.upload_character_image(
|
||||||
|
avatar_data,
|
||||||
|
token_obj.token,
|
||||||
|
token_id=token_obj.id
|
||||||
|
)
|
||||||
|
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
|
||||||
|
|
||||||
|
# Step 5: Finalize character
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Finalizing character creation...\n"
|
||||||
|
)
|
||||||
|
instruction_set = cameo_status.get("instruction_set_hint") or cameo_status.get("instruction_set")
|
||||||
|
character_id = await self.sora_client.finalize_character(
|
||||||
|
cameo_id=cameo_id,
|
||||||
|
username=username,
|
||||||
|
display_name=display_name,
|
||||||
|
profile_asset_pointer=asset_pointer,
|
||||||
|
instruction_set=instruction_set,
|
||||||
|
token=token_obj.token
|
||||||
|
)
|
||||||
|
debug_logger.log_info(f"Character finalized, character_id: {character_id}")
|
||||||
|
|
||||||
|
# Step 6: Set public
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="Setting character as public...\n"
|
||||||
|
)
|
||||||
|
await self.sora_client.set_character_public(cameo_id, token_obj.token)
|
||||||
|
debug_logger.log_info("Character set as public")
|
||||||
|
|
||||||
|
# Log success
|
||||||
|
duration = time.time() - start_time
|
||||||
|
character_card = {
|
||||||
|
"username": username,
|
||||||
|
"display_name": display_name,
|
||||||
|
"character_id": character_id,
|
||||||
|
"cameo_id": cameo_id,
|
||||||
|
"profile_asset_url": profile_asset_url,
|
||||||
|
"instruction_set": instruction_set,
|
||||||
|
"public": True,
|
||||||
|
"source_model": "avatar-create",
|
||||||
|
"source_generation_id": normalized_generation_id,
|
||||||
|
"created_at": int(datetime.now().timestamp())
|
||||||
|
}
|
||||||
|
await self._log_request(
|
||||||
|
token_id=token_obj.id,
|
||||||
|
operation="character_only",
|
||||||
|
request_data={
|
||||||
|
"type": "character_creation",
|
||||||
|
"has_video": False,
|
||||||
|
"has_generation_id": True,
|
||||||
|
"generation_id": normalized_generation_id
|
||||||
|
},
|
||||||
|
response_data={
|
||||||
|
"success": True,
|
||||||
|
"card": character_card
|
||||||
|
},
|
||||||
|
status_code=200,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
content=(
|
||||||
|
json.dumps({
|
||||||
|
"event": "character_card",
|
||||||
|
"card": character_card
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
content=(
|
||||||
|
f"角色创建成功,角色名@{username}\n"
|
||||||
|
f"显示名:{display_name}\n"
|
||||||
|
f"Character ID:{character_id}\n"
|
||||||
|
f"Cameo ID:{cameo_id}"
|
||||||
|
),
|
||||||
|
finish_reason="STOP"
|
||||||
|
)
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_response = None
|
||||||
|
try:
|
||||||
|
error_response = json.loads(str(e))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
is_cf_or_429 = False
|
||||||
|
if error_response and isinstance(error_response, dict):
|
||||||
|
error_info = error_response.get("error", {})
|
||||||
|
if error_info.get("code") == "cf_shield_429":
|
||||||
|
is_cf_or_429 = True
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
await self._log_request(
|
||||||
|
token_id=token_obj.id if token_obj else None,
|
||||||
|
operation="character_only",
|
||||||
|
request_data={
|
||||||
|
"type": "character_creation",
|
||||||
|
"has_video": False,
|
||||||
|
"has_generation_id": bool(normalized_generation_id),
|
||||||
|
"generation_id": normalized_generation_id
|
||||||
|
},
|
||||||
|
response_data={
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
},
|
||||||
|
status_code=429 if is_cf_or_429 else 500,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_obj:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
|
||||||
|
if not is_cf_or_429:
|
||||||
|
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
|
||||||
|
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"Character creation from generation id failed: {str(e)}",
|
||||||
|
status_code=429 if is_cf_or_429 else 500,
|
||||||
|
response_text=str(e)
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def _handle_character_and_video_generation(self, video_data, prompt: str, model_config: Dict) -> AsyncGenerator[str, None]:
|
async def _handle_character_and_video_generation(self, video_data, prompt: str, model_config: Dict) -> AsyncGenerator[str, None]:
|
||||||
"""Handle character creation and video generation
|
"""Handle character creation and video generation
|
||||||
|
|
||||||
@@ -1821,7 +2108,11 @@ class GenerationHandler:
|
|||||||
yield self._format_stream_chunk(
|
yield self._format_stream_chunk(
|
||||||
reasoning_content="Uploading character avatar...\n"
|
reasoning_content="Uploading character avatar...\n"
|
||||||
)
|
)
|
||||||
asset_pointer = await self.sora_client.upload_character_image(avatar_data, token_obj.token)
|
asset_pointer = await self.sora_client.upload_character_image(
|
||||||
|
avatar_data,
|
||||||
|
token_obj.token,
|
||||||
|
token_id=token_obj.id
|
||||||
|
)
|
||||||
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
|
debug_logger.log_info(f"Avatar uploaded, asset_pointer: {asset_pointer}")
|
||||||
|
|
||||||
# Step 5: Finalize character
|
# Step 5: Finalize character
|
||||||
@@ -2070,6 +2361,169 @@ class GenerationHandler:
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def _handle_video_extension(self, prompt: str, model_config: Dict, model_name: str) -> AsyncGenerator[str, None]:
|
||||||
|
"""Handle long video extension generation."""
|
||||||
|
token_obj = await self.load_balancer.select_token(for_video_generation=True)
|
||||||
|
if not token_obj:
|
||||||
|
raise Exception("No available tokens for video extension generation")
|
||||||
|
|
||||||
|
task_id = None
|
||||||
|
start_time = time.time()
|
||||||
|
log_id = None
|
||||||
|
log_updated = False
|
||||||
|
try:
|
||||||
|
# Create initial request log entry (in-progress)
|
||||||
|
log_id = await self._log_request(
|
||||||
|
token_obj.id,
|
||||||
|
"video_extension",
|
||||||
|
{"model": model_name, "prompt": prompt},
|
||||||
|
{},
|
||||||
|
-1,
|
||||||
|
-1.0,
|
||||||
|
task_id=None
|
||||||
|
)
|
||||||
|
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content="**Video Extension Process Begins**\n\nInitializing extension request...\n",
|
||||||
|
is_first=True
|
||||||
|
)
|
||||||
|
|
||||||
|
generation_id = self._extract_generation_id(prompt or "")
|
||||||
|
if not generation_id:
|
||||||
|
raise Exception("视频续写模型需要在提示词中包含 generation_id(gen_xxx)。示例:gen_xxx 流星雨")
|
||||||
|
|
||||||
|
clean_prompt = self._clean_generation_id_from_prompt(prompt or "")
|
||||||
|
if not clean_prompt:
|
||||||
|
raise Exception("视频续写模型需要提供续写提示词。示例:gen_xxx 流星雨")
|
||||||
|
|
||||||
|
extension_duration_s = model_config.get("extension_duration_s", 10)
|
||||||
|
if extension_duration_s not in [10, 15]:
|
||||||
|
raise Exception("extension_duration_s 仅支持 10 或 15")
|
||||||
|
|
||||||
|
yield self._format_stream_chunk(
|
||||||
|
reasoning_content=(
|
||||||
|
f"Submitting extension task...\n"
|
||||||
|
f"- generation_id: {generation_id}\n"
|
||||||
|
f"- extension_duration_s: {extension_duration_s}\n\n"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id = await self.sora_client.extend_video(
|
||||||
|
generation_id=generation_id,
|
||||||
|
prompt=clean_prompt,
|
||||||
|
extension_duration_s=extension_duration_s,
|
||||||
|
token=token_obj.token,
|
||||||
|
token_id=token_obj.id
|
||||||
|
)
|
||||||
|
debug_logger.log_info(f"Video extension started, task_id: {task_id}")
|
||||||
|
|
||||||
|
task = Task(
|
||||||
|
task_id=task_id,
|
||||||
|
token_id=token_obj.id,
|
||||||
|
model=model_name,
|
||||||
|
prompt=f"extend:{generation_id} {clean_prompt}",
|
||||||
|
status="processing",
|
||||||
|
progress=0.0
|
||||||
|
)
|
||||||
|
await self.db.create_task(task)
|
||||||
|
if log_id:
|
||||||
|
await self.db.update_request_log_task_id(log_id, task_id)
|
||||||
|
|
||||||
|
await self.token_manager.record_usage(token_obj.id, is_video=True)
|
||||||
|
|
||||||
|
async for chunk in self._poll_task_result(task_id, token_obj.token, True, True, clean_prompt, token_obj.id):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
await self.token_manager.record_success(token_obj.id, is_video=True)
|
||||||
|
|
||||||
|
# Update request log on success
|
||||||
|
if log_id:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
task_info = await self.db.get_task(task_id)
|
||||||
|
response_data = {
|
||||||
|
"task_id": task_id,
|
||||||
|
"status": "success",
|
||||||
|
"model": model_name,
|
||||||
|
"prompt": clean_prompt,
|
||||||
|
"generation_id": generation_id,
|
||||||
|
"extension_duration_s": extension_duration_s
|
||||||
|
}
|
||||||
|
if task_info and task_info.result_urls:
|
||||||
|
try:
|
||||||
|
response_data["result_urls"] = json.loads(task_info.result_urls)
|
||||||
|
except:
|
||||||
|
response_data["result_urls"] = task_info.result_urls
|
||||||
|
|
||||||
|
await self.db.update_request_log(
|
||||||
|
log_id,
|
||||||
|
response_body=json.dumps(response_data),
|
||||||
|
status_code=200,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
log_updated = True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_response = None
|
||||||
|
try:
|
||||||
|
error_response = json.loads(str(e))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
is_cf_or_429 = False
|
||||||
|
if error_response and isinstance(error_response, dict):
|
||||||
|
error_info = error_response.get("error", {})
|
||||||
|
if error_info.get("code") == "cf_shield_429":
|
||||||
|
is_cf_or_429 = True
|
||||||
|
|
||||||
|
if token_obj:
|
||||||
|
error_str = str(e).lower()
|
||||||
|
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
|
||||||
|
if not is_cf_or_429:
|
||||||
|
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
|
||||||
|
|
||||||
|
# Update request log on error
|
||||||
|
if log_id:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
if error_response:
|
||||||
|
await self.db.update_request_log(
|
||||||
|
log_id,
|
||||||
|
response_body=json.dumps(error_response),
|
||||||
|
status_code=429 if is_cf_or_429 else 400,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.db.update_request_log(
|
||||||
|
log_id,
|
||||||
|
response_body=json.dumps({"error": str(e)}),
|
||||||
|
status_code=500,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
log_updated = True
|
||||||
|
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"Video extension failed: {str(e)}",
|
||||||
|
status_code=429 if is_cf_or_429 else 500,
|
||||||
|
response_text=str(e)
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# Ensure log is not stuck at in-progress
|
||||||
|
if log_id and not log_updated:
|
||||||
|
try:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
await self.db.update_request_log(
|
||||||
|
log_id,
|
||||||
|
response_body=json.dumps({"error": "Task failed or interrupted during processing"}),
|
||||||
|
status_code=500,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
except Exception as finally_error:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"Failed to update video extension log in finally block: {str(finally_error)}",
|
||||||
|
status_code=500,
|
||||||
|
response_text=str(finally_error)
|
||||||
|
)
|
||||||
|
|
||||||
async def _poll_cameo_status(self, cameo_id: str, token: str, timeout: int = 600, poll_interval: int = 5) -> Dict[str, Any]:
|
async def _poll_cameo_status(self, cameo_id: str, token: str, timeout: int = 600, poll_interval: int = 5) -> Dict[str, Any]:
|
||||||
"""Poll for cameo (character) processing status
|
"""Poll for cameo (character) processing status
|
||||||
|
|
||||||
|
|||||||
134
src/services/pow_service_client.py
Normal file
134
src/services/pow_service_client.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""POW Service Client - External POW service integration (POST /api/v1/sora/sentinel-token)"""
|
||||||
|
from typing import NamedTuple, Optional
|
||||||
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
||||||
|
from ..core.config import config
|
||||||
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
|
|
||||||
|
class SentinelResult(NamedTuple):
|
||||||
|
"""Result from external sentinel-token API."""
|
||||||
|
|
||||||
|
sentinel_token: str
|
||||||
|
device_id: Optional[str]
|
||||||
|
user_agent: Optional[str]
|
||||||
|
cookie_header: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class POWServiceClient:
|
||||||
|
"""Client for external POW service API."""
|
||||||
|
|
||||||
|
async def get_sentinel_token(
|
||||||
|
self,
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
session_token: Optional[str] = None,
|
||||||
|
proxy_url: Optional[str] = None,
|
||||||
|
device_type: str = "ios",
|
||||||
|
) -> Optional[SentinelResult]:
|
||||||
|
"""Get sentinel token from external POW service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: Sora access token (optional).
|
||||||
|
session_token: Sora session token (optional).
|
||||||
|
proxy_url: Proxy URL for upstream solver (optional).
|
||||||
|
device_type: Device type hint for upstream solver.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SentinelResult or None on failure.
|
||||||
|
"""
|
||||||
|
server_url = config.pow_service_server_url
|
||||||
|
api_key = config.pow_service_api_key
|
||||||
|
request_proxy = config.pow_service_proxy_url if config.pow_service_proxy_enabled else None
|
||||||
|
|
||||||
|
if not server_url or not api_key:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message="POW service not configured: missing server_url or api_key",
|
||||||
|
status_code=0,
|
||||||
|
response_text="Configuration error",
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
api_url = f"{server_url.rstrip('/')}/api/v1/sora/sentinel-token"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {"device_type": device_type}
|
||||||
|
if access_token:
|
||||||
|
payload["access_token"] = access_token
|
||||||
|
if session_token:
|
||||||
|
payload["session_token"] = session_token
|
||||||
|
if proxy_url:
|
||||||
|
payload["proxy_url"] = proxy_url
|
||||||
|
|
||||||
|
def _mask(token_value: Optional[str]) -> str:
|
||||||
|
if not token_value:
|
||||||
|
return "none"
|
||||||
|
if len(token_value) <= 10:
|
||||||
|
return "***"
|
||||||
|
return f"{token_value[:6]}...{token_value[-4:]}"
|
||||||
|
|
||||||
|
debug_logger.log_info(
|
||||||
|
f"[POW Service] POST {api_url} access_token={_mask(access_token)} proxy_url={proxy_url or 'none'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with AsyncSession(impersonate="chrome131") as session:
|
||||||
|
response = await session.post(
|
||||||
|
api_url,
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
proxy=request_proxy,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"POW service request failed: {response.status_code}",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_text=response.text,
|
||||||
|
source="POWServiceClient",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
token = data.get("sentinel_token")
|
||||||
|
device_id = data.get("device_id")
|
||||||
|
user_agent = data.get("user_agent")
|
||||||
|
cookie_header = data.get("cookie_header")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message="POW service returned empty token",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_text=response.text,
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
debug_logger.log_info(
|
||||||
|
f"[POW Service] sentinel_token len={len(token)} device_id={device_id} "
|
||||||
|
f"ua={bool(user_agent)} cookie_header={bool(cookie_header)}"
|
||||||
|
)
|
||||||
|
return SentinelResult(
|
||||||
|
sentinel_token=token,
|
||||||
|
device_id=device_id,
|
||||||
|
user_agent=user_agent,
|
||||||
|
cookie_header=cookie_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"POW service request exception: {str(e)}",
|
||||||
|
status_code=0,
|
||||||
|
response_text=str(e),
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
pow_service_client = POWServiceClient()
|
||||||
@@ -36,9 +36,42 @@ class ProxyManager:
|
|||||||
return config.proxy_url
|
return config.proxy_url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def update_proxy_config(self, enabled: bool, proxy_url: Optional[str]):
|
async def get_image_upload_proxy_url(self, token_id: Optional[int] = None) -> Optional[str]:
|
||||||
|
"""Get proxy URL specifically for image uploads
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. Image upload proxy (if enabled in config)
|
||||||
|
2. Token-specific proxy (if token_id provided)
|
||||||
|
3. Global proxy (fallback)
|
||||||
|
4. None (no proxy)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token_id: Token ID (optional). Used for fallback to token-specific proxy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Proxy URL string or None
|
||||||
|
"""
|
||||||
|
config = await self.db.get_proxy_config()
|
||||||
|
if config.image_upload_proxy_enabled and config.image_upload_proxy_url:
|
||||||
|
return config.image_upload_proxy_url
|
||||||
|
|
||||||
|
# Fallback to standard proxy resolution
|
||||||
|
return await self.get_proxy_url(token_id=token_id)
|
||||||
|
|
||||||
|
async def update_proxy_config(
|
||||||
|
self,
|
||||||
|
enabled: bool,
|
||||||
|
proxy_url: Optional[str],
|
||||||
|
image_upload_proxy_enabled: bool = False,
|
||||||
|
image_upload_proxy_url: Optional[str] = None
|
||||||
|
):
|
||||||
"""Update proxy configuration"""
|
"""Update proxy configuration"""
|
||||||
await self.db.update_proxy_config(enabled, proxy_url)
|
await self.db.update_proxy_config(
|
||||||
|
enabled,
|
||||||
|
proxy_url,
|
||||||
|
image_upload_proxy_enabled,
|
||||||
|
image_upload_proxy_url
|
||||||
|
)
|
||||||
|
|
||||||
async def get_proxy_config(self) -> ProxyConfig:
|
async def get_proxy_config(self) -> ProxyConfig:
|
||||||
"""Get proxy configuration"""
|
"""Get proxy configuration"""
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import random
|
|||||||
import string
|
import string
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional, Dict, Any, Tuple
|
from typing import Optional, Dict, Any, Tuple, List
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from urllib.request import Request, urlopen, build_opener, ProxyHandler
|
from urllib.request import Request, urlopen, build_opener, ProxyHandler
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from curl_cffi.requests import AsyncSession
|
from curl_cffi.requests import AsyncSession
|
||||||
from curl_cffi import CurlMime
|
from curl_cffi import CurlMime
|
||||||
from .proxy_manager import ProxyManager
|
from .proxy_manager import ProxyManager
|
||||||
|
from .pow_service_client import pow_service_client
|
||||||
from ..core.config import config
|
from ..core.config import config
|
||||||
from ..core.logger import debug_logger
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
@@ -31,10 +32,58 @@ _playwright = None
|
|||||||
_current_proxy = None
|
_current_proxy = None
|
||||||
|
|
||||||
# Sentinel token cache
|
# Sentinel token cache
|
||||||
_cached_sentinel_token = None
|
_cached_sentinel_token_map = {}
|
||||||
_cached_device_id = None
|
_cached_device_id = None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_device_id_from_sentinel(sentinel_token: Optional[str]) -> Optional[str]:
|
||||||
|
"""Extract device id from sentinel token JSON."""
|
||||||
|
if not sentinel_token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(sentinel_token)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
value = data.get("id")
|
||||||
|
return str(value) if value else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_session_cookie_header(session_token: str) -> str:
|
||||||
|
"""Build session cookie header used by ChatGPT/Sora requests."""
|
||||||
|
return f"__Secure-next-auth.session-token={session_token}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_session_token(
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
token_id: Optional[int] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Resolve session token (st) from token_id or access token."""
|
||||||
|
if not token_id and not access_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ..core.database import Database
|
||||||
|
|
||||||
|
db = Database()
|
||||||
|
token_obj = None
|
||||||
|
|
||||||
|
if token_id:
|
||||||
|
token_obj = await db.get_token(token_id)
|
||||||
|
|
||||||
|
# Fallback by access token if token_id is unavailable or has no st
|
||||||
|
if (not token_obj or not token_obj.st) and access_token:
|
||||||
|
token_obj = await db.get_token_by_value(access_token)
|
||||||
|
|
||||||
|
if token_obj and token_obj.st:
|
||||||
|
return token_obj.st
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_warning(f"[Sentinel] Failed to resolve session token: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _get_browser(proxy_url: str = None):
|
async def _get_browser(proxy_url: str = None):
|
||||||
"""Get or create browser instance (reuses existing browser)"""
|
"""Get or create browser instance (reuses existing browser)"""
|
||||||
global _browser, _playwright, _current_proxy
|
global _browser, _playwright, _current_proxy
|
||||||
@@ -80,7 +129,12 @@ async def _close_browser():
|
|||||||
_playwright = None
|
_playwright = None
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_oai_did(proxy_url: str = None, max_retries: int = 3) -> str:
|
async def _fetch_oai_did(
|
||||||
|
proxy_url: str = None,
|
||||||
|
max_retries: int = 3,
|
||||||
|
session_token: Optional[str] = None,
|
||||||
|
cookie_header: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
"""Fetch oai-did using curl_cffi (lightweight approach)
|
"""Fetch oai-did using curl_cffi (lightweight approach)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -91,8 +145,15 @@ async def _fetch_oai_did(proxy_url: str = None, max_retries: int = 3) -> str:
|
|||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
async with AsyncSession(impersonate="chrome120") as session:
|
async with AsyncSession(impersonate="chrome120") as session:
|
||||||
|
headers = None
|
||||||
|
if cookie_header:
|
||||||
|
headers = {"Cookie": cookie_header}
|
||||||
|
elif session_token:
|
||||||
|
headers = {"Cookie": _build_session_cookie_header(session_token)}
|
||||||
|
|
||||||
response = await session.get(
|
response = await session.get(
|
||||||
"https://chatgpt.com/",
|
"https://chatgpt.com/",
|
||||||
|
headers=headers,
|
||||||
proxy=proxy_url,
|
proxy=proxy_url,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
allow_redirects=True
|
allow_redirects=True
|
||||||
@@ -129,7 +190,11 @@ async def _fetch_oai_did(proxy_url: str = None, max_retries: int = 3) -> str:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id: str = None) -> str:
|
async def _generate_sentinel_token_lightweight(
|
||||||
|
proxy_url: str = None,
|
||||||
|
device_id: str = None,
|
||||||
|
session_token: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
"""Generate sentinel token using lightweight Playwright approach
|
"""Generate sentinel token using lightweight Playwright approach
|
||||||
|
|
||||||
Uses route interception and SDK injection for minimal resource usage.
|
Uses route interception and SDK injection for minimal resource usage.
|
||||||
@@ -153,7 +218,7 @@ async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id:
|
|||||||
|
|
||||||
# Get oai-did
|
# Get oai-did
|
||||||
if not device_id:
|
if not device_id:
|
||||||
device_id = await _fetch_oai_did(proxy_url)
|
device_id = await _fetch_oai_did(proxy_url, session_token=session_token)
|
||||||
|
|
||||||
if not device_id:
|
if not device_id:
|
||||||
debug_logger.log_info("[Sentinel] Failed to get oai-did")
|
debug_logger.log_info("[Sentinel] Failed to get oai-did")
|
||||||
@@ -170,13 +235,24 @@ async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id:
|
|||||||
bypass_csp=True
|
bypass_csp=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set cookie
|
# Set oai-did cookie (+ session cookie when token-aware POW is enabled)
|
||||||
await context.add_cookies([{
|
cookies_to_set = [{
|
||||||
'name': 'oai-did',
|
'name': 'oai-did',
|
||||||
'value': device_id,
|
'value': device_id,
|
||||||
'domain': 'sora.chatgpt.com',
|
'domain': 'sora.chatgpt.com',
|
||||||
'path': '/'
|
'path': '/'
|
||||||
}])
|
}]
|
||||||
|
if session_token:
|
||||||
|
cookies_to_set.append({
|
||||||
|
'name': '__Secure-next-auth.session-token',
|
||||||
|
'value': session_token,
|
||||||
|
'domain': '.chatgpt.com',
|
||||||
|
'path': '/',
|
||||||
|
'secure': True,
|
||||||
|
'httpOnly': True,
|
||||||
|
'sameSite': 'None',
|
||||||
|
})
|
||||||
|
await context.add_cookies(cookies_to_set)
|
||||||
|
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
|
|
||||||
@@ -230,41 +306,119 @@ async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id:
|
|||||||
await context.close()
|
await context.close()
|
||||||
|
|
||||||
|
|
||||||
async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool = False) -> str:
|
async def _get_cached_sentinel_token(
|
||||||
|
proxy_url: str = None,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
token_id: Optional[int] = None,
|
||||||
|
) -> Optional[Dict[str, Optional[str]]]:
|
||||||
"""Get sentinel token with caching support
|
"""Get sentinel token with caching support
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
proxy_url: Optional proxy URL
|
proxy_url: Optional proxy URL
|
||||||
force_refresh: Force refresh token (e.g., after 400 error)
|
force_refresh: Force refresh token (e.g., after 400 error)
|
||||||
|
access_token: Optional access token to send to external POW service
|
||||||
|
token_id: Optional token id to resolve session token for local POW
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Sentinel token string or None
|
Dict with sentinel_token/device_id/user_agent/cookie_header or None
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Exception: If 403/429 when fetching oai-did
|
Exception: If 403/429 when fetching oai-did
|
||||||
"""
|
"""
|
||||||
global _cached_sentinel_token
|
global _cached_sentinel_token_map
|
||||||
|
|
||||||
# Return cached token if available and not forcing refresh
|
# Whether current request should be token-aware for POW
|
||||||
if _cached_sentinel_token and not force_refresh:
|
use_token_for_pow = bool(config.pow_service_use_token_for_pow and (access_token or token_id))
|
||||||
debug_logger.log_info("[Sentinel] Using cached token")
|
disable_cache_for_local_token_pow = bool(use_token_for_pow and config.pow_service_mode == "local")
|
||||||
return _cached_sentinel_token
|
if use_token_for_pow and access_token:
|
||||||
|
cache_key = access_token
|
||||||
|
elif use_token_for_pow and token_id:
|
||||||
|
cache_key = f"token_id:{token_id}"
|
||||||
|
else:
|
||||||
|
cache_key = "__default__"
|
||||||
|
session_token = await _resolve_session_token(access_token=access_token, token_id=token_id) if use_token_for_pow else None
|
||||||
|
|
||||||
|
# Check if external POW service is configured
|
||||||
|
if config.pow_service_mode == "external":
|
||||||
|
debug_logger.log_info("[POW] Using external POW service (cached sentinel)")
|
||||||
|
result = await pow_service_client.get_sentinel_token(
|
||||||
|
access_token=access_token if use_token_for_pow else None,
|
||||||
|
session_token=session_token if use_token_for_pow else None,
|
||||||
|
proxy_url=proxy_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
sentinel_data = {
|
||||||
|
"sentinel_token": result.sentinel_token,
|
||||||
|
"device_id": result.device_id or _extract_device_id_from_sentinel(result.sentinel_token),
|
||||||
|
"user_agent": result.user_agent,
|
||||||
|
"cookie_header": result.cookie_header,
|
||||||
|
}
|
||||||
|
debug_logger.log_info("[POW] External service returned sentinel token successfully")
|
||||||
|
return sentinel_data
|
||||||
|
else:
|
||||||
|
# Fallback to local mode if external service fails
|
||||||
|
debug_logger.log_info("[POW] External service failed, falling back to local mode")
|
||||||
|
|
||||||
|
# Local mode
|
||||||
|
# local + token-aware POW: do not use cache (compute each time)
|
||||||
|
if disable_cache_for_local_token_pow:
|
||||||
|
debug_logger.log_info("[Sentinel] Local token-aware POW enabled, cache bypassed")
|
||||||
|
# Otherwise keep legacy cache behavior
|
||||||
|
elif not force_refresh and cache_key in _cached_sentinel_token_map:
|
||||||
|
if use_token_for_pow:
|
||||||
|
debug_logger.log_info("[Sentinel] Using token-scoped cached token")
|
||||||
|
else:
|
||||||
|
debug_logger.log_info("[Sentinel] Using shared cached token")
|
||||||
|
cached_value = _cached_sentinel_token_map[cache_key]
|
||||||
|
# Backward compatibility: migrate legacy string cache to structured cache.
|
||||||
|
if isinstance(cached_value, str):
|
||||||
|
cached_value = {
|
||||||
|
"sentinel_token": cached_value,
|
||||||
|
"device_id": _extract_device_id_from_sentinel(cached_value),
|
||||||
|
"user_agent": None,
|
||||||
|
"cookie_header": None,
|
||||||
|
}
|
||||||
|
_cached_sentinel_token_map[cache_key] = cached_value
|
||||||
|
return cached_value
|
||||||
|
|
||||||
# Generate new token
|
# Generate new token
|
||||||
debug_logger.log_info("[Sentinel] Generating new token...")
|
debug_logger.log_info("[Sentinel] Generating new token...")
|
||||||
token = await _generate_sentinel_token_lightweight(proxy_url)
|
token = await _generate_sentinel_token_lightweight(
|
||||||
|
proxy_url=proxy_url,
|
||||||
|
session_token=session_token if use_token_for_pow else None,
|
||||||
|
)
|
||||||
|
|
||||||
if token:
|
if token:
|
||||||
_cached_sentinel_token = token
|
sentinel_data = {
|
||||||
|
"sentinel_token": token,
|
||||||
|
"device_id": _extract_device_id_from_sentinel(token),
|
||||||
|
"user_agent": None,
|
||||||
|
"cookie_header": None,
|
||||||
|
}
|
||||||
|
if not disable_cache_for_local_token_pow:
|
||||||
|
_cached_sentinel_token_map[cache_key] = sentinel_data
|
||||||
debug_logger.log_info("[Sentinel] Token cached successfully")
|
debug_logger.log_info("[Sentinel] Token cached successfully")
|
||||||
|
else:
|
||||||
|
debug_logger.log_info("[Sentinel] Local token-aware POW generated (not cached)")
|
||||||
|
return sentinel_data
|
||||||
|
|
||||||
return token
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_sentinel_cache():
|
def _invalidate_sentinel_cache(access_token: Optional[str] = None):
|
||||||
"""Invalidate cached sentinel token (call after 400 error)"""
|
"""Invalidate cached sentinel token (call after 400 error)
|
||||||
global _cached_sentinel_token
|
|
||||||
_cached_sentinel_token = None
|
Args:
|
||||||
|
access_token: Optional current access token for token-scoped cache invalidation
|
||||||
|
"""
|
||||||
|
global _cached_sentinel_token_map
|
||||||
|
use_token_for_pow = bool(config.pow_service_use_token_for_pow and access_token)
|
||||||
|
cache_key = access_token if use_token_for_pow else "__default__"
|
||||||
|
|
||||||
|
if cache_key in _cached_sentinel_token_map:
|
||||||
|
del _cached_sentinel_token_map[cache_key]
|
||||||
debug_logger.log_info("[Sentinel] Cache invalidated")
|
debug_logger.log_info("[Sentinel] Cache invalidated")
|
||||||
|
|
||||||
|
|
||||||
@@ -598,9 +752,17 @@ class SoraClient:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _nf_create_urllib(self, token: str, payload: dict, sentinel_token: str,
|
async def _nf_create_urllib(
|
||||||
proxy_url: Optional[str], token_id: Optional[int] = None,
|
self,
|
||||||
user_agent: Optional[str] = None) -> Dict[str, Any]:
|
token: str,
|
||||||
|
payload: dict,
|
||||||
|
sentinel_token: str,
|
||||||
|
proxy_url: Optional[str],
|
||||||
|
token_id: Optional[int] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
|
device_id: Optional[str] = None,
|
||||||
|
cookie_header: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Make nf/create request
|
"""Make nf/create request
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -613,23 +775,99 @@ class SoraClient:
|
|||||||
if not user_agent:
|
if not user_agent:
|
||||||
user_agent = random.choice(DESKTOP_USER_AGENTS)
|
user_agent = random.choice(DESKTOP_USER_AGENTS)
|
||||||
|
|
||||||
import json as json_mod
|
sentinel_data = {}
|
||||||
sentinel_data = json_mod.loads(sentinel_token)
|
if not device_id:
|
||||||
device_id = sentinel_data.get("id", str(uuid4()))
|
device_id = _extract_device_id_from_sentinel(sentinel_token)
|
||||||
|
if not device_id:
|
||||||
|
device_id = str(uuid4())
|
||||||
|
try:
|
||||||
|
parsed_data = json.loads(sentinel_token)
|
||||||
|
if isinstance(parsed_data, dict):
|
||||||
|
sentinel_data = parsed_data
|
||||||
|
except Exception:
|
||||||
|
sentinel_data = {}
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"OpenAI-Sentinel-Token": sentinel_token,
|
"openai-sentinel-token": sentinel_token, # 使用小写,与成功的 curl 请求一致
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": user_agent,
|
"User-Agent": user_agent,
|
||||||
"OAI-Language": "en-US",
|
"oai-language": "en-US", # 使用小写
|
||||||
"OAI-Device-Id": device_id,
|
"oai-device-id": device_id, # 使用小写
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Origin": "https://sora.chatgpt.com",
|
||||||
|
"Referer": "https://sora.chatgpt.com/explore",
|
||||||
|
"Sec-Ch-Ua": '"Not(A:Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"',
|
||||||
|
"Sec-Ch-Ua-Mobile": "?0",
|
||||||
|
"Sec-Ch-Ua-Platform": '"Windows"',
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Priority": "u=1, i",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 添加 Cookie 头(关键修复)
|
||||||
|
if cookie_header:
|
||||||
|
headers["Cookie"] = cookie_header
|
||||||
|
debug_logger.log_info(f"[nf/create] Using cookie header from POW service (length: {len(cookie_header)})")
|
||||||
|
elif token_id:
|
||||||
|
try:
|
||||||
|
from src.core.database import Database
|
||||||
|
db = Database()
|
||||||
|
token_obj = await db.get_token(token_id)
|
||||||
|
if token_obj and token_obj.st:
|
||||||
|
# 添加 session token cookie
|
||||||
|
headers["Cookie"] = f"__Secure-next-auth.session-token={token_obj.st}"
|
||||||
|
debug_logger.log_info(f"[nf/create] Added session token cookie (length: {len(token_obj.st)})")
|
||||||
|
else:
|
||||||
|
debug_logger.log_warning("[nf/create] No session token (st) found for this token")
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_warning(f"[nf/create] Failed to get session token: {e}")
|
||||||
|
|
||||||
|
# 记录详细的 Sentinel Token 信息
|
||||||
|
debug_logger.log_info(f"[nf/create] Preparing request to {url}")
|
||||||
|
debug_logger.log_info(f"[nf/create] Device ID: {device_id}")
|
||||||
|
|
||||||
|
# Sentinel Token 前100字符和后50字符
|
||||||
|
if len(sentinel_token) > 150:
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token (first 100 chars): {sentinel_token[:100]}...")
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token (last 50 chars): ...{sentinel_token[-50:]}")
|
||||||
|
else:
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token: {sentinel_token}")
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token length: {len(sentinel_token)}")
|
||||||
|
|
||||||
|
# Sentinel Token 结构信息
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token structure:")
|
||||||
|
if "p" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - p (POW) length: {len(sentinel_data['p'])}")
|
||||||
|
if "t" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - t (Turnstile) length: {len(sentinel_data['t'])}")
|
||||||
|
if "c" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - c (Challenge) length: {len(sentinel_data['c'])}")
|
||||||
|
if "id" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - id: {sentinel_data['id']}")
|
||||||
|
if "flow" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - flow: {sentinel_data['flow']}")
|
||||||
|
|
||||||
|
# 使用 log_request 方法记录完整的请求详情
|
||||||
|
debug_logger.log_request(
|
||||||
|
method="POST",
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
body=payload,
|
||||||
|
proxy=proxy_url,
|
||||||
|
source="Server"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
self._post_json_sync, url, headers, payload, 30, proxy_url
|
self._post_json_sync, url, headers, payload, 30, proxy_url
|
||||||
)
|
)
|
||||||
|
debug_logger.log_info(f"[nf/create] Request succeeded, task_id: {result.get('id', 'N/A')}")
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_str = str(e)
|
error_str = str(e)
|
||||||
@@ -663,8 +901,52 @@ class SoraClient:
|
|||||||
except URLError as exc:
|
except URLError as exc:
|
||||||
raise Exception(f"URL Error: {exc}") from exc
|
raise Exception(f"URL Error: {exc}") from exc
|
||||||
|
|
||||||
async def _generate_sentinel_token(self, token: Optional[str] = None, user_agent: Optional[str] = None) -> Tuple[str, str]:
|
async def _generate_sentinel_token(
|
||||||
"""Generate openai-sentinel-token by calling /backend-api/sentinel/req and solving PoW"""
|
self,
|
||||||
|
token: Optional[str] = None,
|
||||||
|
user_agent: Optional[str] = None,
|
||||||
|
pow_proxy_url: Optional[str] = None,
|
||||||
|
token_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Optional[str]]:
|
||||||
|
"""Generate openai-sentinel-token by calling /backend-api/sentinel/req and solving PoW
|
||||||
|
|
||||||
|
Supports two modes:
|
||||||
|
- external: Get complete sentinel token from external POW service
|
||||||
|
- local: Generate POW locally and call sentinel/req endpoint
|
||||||
|
"""
|
||||||
|
use_token_for_pow = bool(config.pow_service_use_token_for_pow and (token or token_id))
|
||||||
|
session_token = await _resolve_session_token(access_token=token, token_id=token_id) if use_token_for_pow else None
|
||||||
|
|
||||||
|
# Check if external POW service is configured
|
||||||
|
if config.pow_service_mode == "external":
|
||||||
|
debug_logger.log_info("[Sentinel] Using external POW service...")
|
||||||
|
result = await pow_service_client.get_sentinel_token(
|
||||||
|
access_token=token if use_token_for_pow else None,
|
||||||
|
session_token=session_token if use_token_for_pow else None,
|
||||||
|
proxy_url=pow_proxy_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
# Use service user agent if provided, otherwise use default
|
||||||
|
final_user_agent = result.user_agent if result.user_agent else (
|
||||||
|
user_agent if user_agent else
|
||||||
|
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[Sentinel] Got token from external service")
|
||||||
|
debug_logger.log_info(f"[Sentinel] Token cached successfully (external)")
|
||||||
|
return {
|
||||||
|
"sentinel_token": result.sentinel_token,
|
||||||
|
"user_agent": final_user_agent,
|
||||||
|
"device_id": result.device_id or _extract_device_id_from_sentinel(result.sentinel_token),
|
||||||
|
"cookie_header": result.cookie_header,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Fallback to local mode if external service fails
|
||||||
|
debug_logger.log_info("[Sentinel] External service failed, falling back to local mode")
|
||||||
|
|
||||||
|
# Local mode (original logic)
|
||||||
|
debug_logger.log_info("[POW] Using local POW generation")
|
||||||
req_id = str(uuid4())
|
req_id = str(uuid4())
|
||||||
if not user_agent:
|
if not user_agent:
|
||||||
user_agent = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36"
|
user_agent = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36"
|
||||||
@@ -678,7 +960,7 @@ class SoraClient:
|
|||||||
}
|
}
|
||||||
ua_with_pow = f"{user_agent} {json.dumps(init_payload, separators=(',', ':'))}"
|
ua_with_pow = f"{user_agent} {json.dumps(init_payload, separators=(',', ':'))}"
|
||||||
|
|
||||||
proxy_url = await self.proxy_manager.get_proxy_url()
|
proxy_url = pow_proxy_url or await self.proxy_manager.get_proxy_url(token_id)
|
||||||
|
|
||||||
# Request sentinel/req endpoint
|
# Request sentinel/req endpoint
|
||||||
url = f"{self.CHATGPT_BASE_URL}/backend-api/sentinel/req"
|
url = f"{self.CHATGPT_BASE_URL}/backend-api/sentinel/req"
|
||||||
@@ -699,6 +981,9 @@ class SoraClient:
|
|||||||
"sec-ch-ua-mobile": "?1",
|
"sec-ch-ua-mobile": "?1",
|
||||||
"sec-ch-ua-platform": '"Android"',
|
"sec-ch-ua-platform": '"Android"',
|
||||||
}
|
}
|
||||||
|
if use_token_for_pow and session_token:
|
||||||
|
headers["Cookie"] = _build_session_cookie_header(session_token)
|
||||||
|
debug_logger.log_info("[Sentinel] Local mode enabled token-aware cookie for sentinel/req")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with AsyncSession(impersonate="chrome131") as session:
|
async with AsyncSession(impersonate="chrome131") as session:
|
||||||
@@ -732,7 +1017,12 @@ class SoraClient:
|
|||||||
parsed = json.loads(sentinel_token)
|
parsed = json.loads(sentinel_token)
|
||||||
debug_logger.log_info(f"Final sentinel: p_prefix={parsed['p'][:10]}, p_suffix={parsed['p'][-5:]}, t_len={len(parsed['t'])}, c_len={len(parsed['c'])}, flow={parsed['flow']}")
|
debug_logger.log_info(f"Final sentinel: p_prefix={parsed['p'][:10]}, p_suffix={parsed['p'][-5:]}, t_len={len(parsed['t'])}, c_len={len(parsed['c'])}, flow={parsed['flow']}")
|
||||||
|
|
||||||
return sentinel_token, user_agent
|
return {
|
||||||
|
"sentinel_token": sentinel_token,
|
||||||
|
"user_agent": user_agent,
|
||||||
|
"device_id": _extract_device_id_from_sentinel(sentinel_token),
|
||||||
|
"cookie_header": None,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_storyboard_prompt(prompt: str) -> bool:
|
def is_storyboard_prompt(prompt: str) -> bool:
|
||||||
@@ -800,7 +1090,8 @@ class SoraClient:
|
|||||||
json_data: Optional[Dict] = None,
|
json_data: Optional[Dict] = None,
|
||||||
multipart: Optional[Dict] = None,
|
multipart: Optional[Dict] = None,
|
||||||
add_sentinel_token: bool = False,
|
add_sentinel_token: bool = False,
|
||||||
token_id: Optional[int] = None) -> Dict[str, Any]:
|
token_id: Optional[int] = None,
|
||||||
|
use_image_upload_proxy: bool = False) -> Dict[str, Any]:
|
||||||
"""Make HTTP request with proxy support
|
"""Make HTTP request with proxy support
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -811,7 +1102,11 @@ class SoraClient:
|
|||||||
multipart: Multipart form data (for file uploads)
|
multipart: Multipart form data (for file uploads)
|
||||||
add_sentinel_token: Whether to add openai-sentinel-token header (only for generation requests)
|
add_sentinel_token: Whether to add openai-sentinel-token header (only for generation requests)
|
||||||
token_id: Token ID for getting token-specific proxy (optional)
|
token_id: Token ID for getting token-specific proxy (optional)
|
||||||
|
use_image_upload_proxy: Whether to use dedicated image upload proxy selection
|
||||||
"""
|
"""
|
||||||
|
if use_image_upload_proxy:
|
||||||
|
proxy_url = await self.proxy_manager.get_image_upload_proxy_url(token_id)
|
||||||
|
else:
|
||||||
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
|
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
@@ -821,9 +1116,14 @@ class SoraClient:
|
|||||||
|
|
||||||
# 只在生成请求时添加 sentinel token
|
# 只在生成请求时添加 sentinel token
|
||||||
if add_sentinel_token:
|
if add_sentinel_token:
|
||||||
sentinel_token, ua = await self._generate_sentinel_token(token)
|
sentinel_context = await self._generate_sentinel_token(token, token_id=token_id)
|
||||||
headers["openai-sentinel-token"] = sentinel_token
|
headers["openai-sentinel-token"] = sentinel_context["sentinel_token"]
|
||||||
headers["User-Agent"] = ua
|
if sentinel_context.get("user_agent"):
|
||||||
|
headers["User-Agent"] = sentinel_context["user_agent"]
|
||||||
|
if sentinel_context.get("device_id"):
|
||||||
|
headers["oai-device-id"] = sentinel_context["device_id"]
|
||||||
|
if sentinel_context.get("cookie_header"):
|
||||||
|
headers["Cookie"] = sentinel_context["cookie_header"]
|
||||||
|
|
||||||
if not multipart:
|
if not multipart:
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
@@ -927,7 +1227,13 @@ class SoraClient:
|
|||||||
"""Get user information"""
|
"""Get user information"""
|
||||||
return await self._make_request("GET", "/me", token)
|
return await self._make_request("GET", "/me", token)
|
||||||
|
|
||||||
async def upload_image(self, image_data: bytes, token: str, filename: str = "image.png") -> str:
|
async def upload_image(
|
||||||
|
self,
|
||||||
|
image_data: bytes,
|
||||||
|
token: str,
|
||||||
|
filename: str = "image.png",
|
||||||
|
token_id: Optional[int] = None
|
||||||
|
) -> str:
|
||||||
"""Upload image and return media_id
|
"""Upload image and return media_id
|
||||||
|
|
||||||
使用 CurlMime 对象上传文件(curl_cffi 的正确方式)
|
使用 CurlMime 对象上传文件(curl_cffi 的正确方式)
|
||||||
@@ -957,7 +1263,14 @@ class SoraClient:
|
|||||||
data=filename.encode('utf-8')
|
data=filename.encode('utf-8')
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await self._make_request("POST", "/uploads", token, multipart=mp)
|
result = await self._make_request(
|
||||||
|
"POST",
|
||||||
|
"/uploads",
|
||||||
|
token,
|
||||||
|
multipart=mp,
|
||||||
|
token_id=token_id,
|
||||||
|
use_image_upload_proxy=True
|
||||||
|
)
|
||||||
return result["id"]
|
return result["id"]
|
||||||
|
|
||||||
async def generate_image(self, prompt: str, token: str, width: int = 360,
|
async def generate_image(self, prompt: str, token: str, width: int = 360,
|
||||||
@@ -1024,16 +1337,21 @@ class SoraClient:
|
|||||||
|
|
||||||
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
|
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
|
||||||
|
|
||||||
# Get POW proxy from configuration
|
# Get POW proxy from configuration (unified with pow_service config)
|
||||||
pow_proxy_url = None
|
pow_proxy_url = None
|
||||||
if config.pow_proxy_enabled:
|
if config.pow_service_proxy_enabled:
|
||||||
pow_proxy_url = config.pow_proxy_url or None
|
pow_proxy_url = config.pow_service_proxy_url or None
|
||||||
|
|
||||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
# Try to get cached sentinel token first (using lightweight Playwright approach)
|
# Try to get cached sentinel token first (using lightweight Playwright approach)
|
||||||
try:
|
try:
|
||||||
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=False)
|
sentinel_context = await _get_cached_sentinel_token(
|
||||||
|
pow_proxy_url,
|
||||||
|
force_refresh=False,
|
||||||
|
access_token=token,
|
||||||
|
token_id=token_id,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 403/429 errors from oai-did fetch - don't retry, just fail
|
# 403/429 errors from oai-did fetch - don't retry, just fail
|
||||||
error_str = str(e)
|
error_str = str(e)
|
||||||
@@ -1045,16 +1363,30 @@ class SoraClient:
|
|||||||
source="Server"
|
source="Server"
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
sentinel_token = None
|
sentinel_context = None
|
||||||
|
|
||||||
if not sentinel_token:
|
if not sentinel_context:
|
||||||
# Fallback to manual POW if lightweight approach fails
|
# Fallback to manual POW if lightweight approach fails
|
||||||
debug_logger.log_info("[Warning] Lightweight sentinel token failed, falling back to manual POW")
|
debug_logger.log_info("[Warning] Lightweight sentinel token failed, falling back to manual POW")
|
||||||
sentinel_token, user_agent = await self._generate_sentinel_token(token)
|
sentinel_context = await self._generate_sentinel_token(
|
||||||
|
token,
|
||||||
|
user_agent=user_agent,
|
||||||
|
pow_proxy_url=pow_proxy_url,
|
||||||
|
token_id=token_id,
|
||||||
|
)
|
||||||
|
|
||||||
# First attempt with cached/generated token
|
# First attempt with cached/generated token
|
||||||
try:
|
try:
|
||||||
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, token_id, user_agent)
|
result = await self._nf_create_urllib(
|
||||||
|
token,
|
||||||
|
json_data,
|
||||||
|
sentinel_context["sentinel_token"],
|
||||||
|
proxy_url,
|
||||||
|
token_id,
|
||||||
|
sentinel_context.get("user_agent") or user_agent,
|
||||||
|
sentinel_context.get("device_id"),
|
||||||
|
sentinel_context.get("cookie_header"),
|
||||||
|
)
|
||||||
return result["id"]
|
return result["id"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_str = str(e)
|
error_str = str(e)
|
||||||
@@ -1064,24 +1396,43 @@ class SoraClient:
|
|||||||
debug_logger.log_info("[Sentinel] Got 400 error, refreshing token and retrying...")
|
debug_logger.log_info("[Sentinel] Got 400 error, refreshing token and retrying...")
|
||||||
|
|
||||||
# Invalidate cache and get fresh token
|
# Invalidate cache and get fresh token
|
||||||
_invalidate_sentinel_cache()
|
_invalidate_sentinel_cache(token)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=True)
|
sentinel_context = await _get_cached_sentinel_token(
|
||||||
|
pow_proxy_url,
|
||||||
|
force_refresh=True,
|
||||||
|
access_token=token,
|
||||||
|
token_id=token_id,
|
||||||
|
)
|
||||||
except Exception as refresh_e:
|
except Exception as refresh_e:
|
||||||
# 403/429 errors - don't continue
|
# 403/429 errors - don't continue
|
||||||
error_str = str(refresh_e)
|
error_str = str(refresh_e)
|
||||||
if "403" in error_str or "429" in error_str:
|
if "403" in error_str or "429" in error_str:
|
||||||
raise refresh_e
|
raise refresh_e
|
||||||
sentinel_token = None
|
sentinel_context = None
|
||||||
|
|
||||||
if not sentinel_token:
|
if not sentinel_context:
|
||||||
# Fallback to manual POW
|
# Fallback to manual POW
|
||||||
debug_logger.log_info("[Warning] Refresh failed, falling back to manual POW")
|
debug_logger.log_info("[Warning] Refresh failed, falling back to manual POW")
|
||||||
sentinel_token, user_agent = await self._generate_sentinel_token(token)
|
sentinel_context = await self._generate_sentinel_token(
|
||||||
|
token,
|
||||||
|
user_agent=user_agent,
|
||||||
|
pow_proxy_url=pow_proxy_url,
|
||||||
|
token_id=token_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Retry with fresh token
|
# Retry with fresh token
|
||||||
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, token_id, user_agent)
|
result = await self._nf_create_urllib(
|
||||||
|
token,
|
||||||
|
json_data,
|
||||||
|
sentinel_context["sentinel_token"],
|
||||||
|
proxy_url,
|
||||||
|
token_id,
|
||||||
|
sentinel_context.get("user_agent") or user_agent,
|
||||||
|
sentinel_context.get("device_id"),
|
||||||
|
sentinel_context.get("cookie_header"),
|
||||||
|
)
|
||||||
return result["id"]
|
return result["id"]
|
||||||
|
|
||||||
# For other errors, just re-raise
|
# For other errors, just re-raise
|
||||||
@@ -1324,6 +1675,33 @@ class SoraClient:
|
|||||||
result = await self._make_request("POST", "/characters/upload", token, multipart=mp)
|
result = await self._make_request("POST", "/characters/upload", token, multipart=mp)
|
||||||
return result.get("id")
|
return result.get("id")
|
||||||
|
|
||||||
|
async def create_character_from_generation(self, generation_id: str, token: str,
|
||||||
|
timestamps: Optional[List[int]] = None) -> str:
|
||||||
|
"""Create character cameo from generation id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
generation_id: Generation ID (gen_xxx)
|
||||||
|
token: Access token
|
||||||
|
timestamps: Optional frame timestamps, defaults to [0, 3]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
cameo_id
|
||||||
|
"""
|
||||||
|
if timestamps is None:
|
||||||
|
timestamps = [0, 3]
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"generation_id": generation_id,
|
||||||
|
"character_id": None,
|
||||||
|
"timestamps": timestamps
|
||||||
|
}
|
||||||
|
result = await self._make_request("POST", "/characters/from-generation", token, json_data=json_data)
|
||||||
|
return result.get("id")
|
||||||
|
|
||||||
|
async def get_post_detail(self, post_id: str, token: str) -> Dict[str, Any]:
|
||||||
|
"""Get Sora post detail by post id (s_xxx)."""
|
||||||
|
return await self._make_request("GET", f"/project_y/post/{post_id}", token)
|
||||||
|
|
||||||
async def get_cameo_status(self, cameo_id: str, token: str) -> Dict[str, Any]:
|
async def get_cameo_status(self, cameo_id: str, token: str) -> Dict[str, Any]:
|
||||||
"""Get character (cameo) processing status
|
"""Get character (cameo) processing status
|
||||||
|
|
||||||
@@ -1405,7 +1783,12 @@ class SoraClient:
|
|||||||
await self._make_request("POST", f"/project_y/cameos/by_id/{cameo_id}/update_v2", token, json_data=json_data)
|
await self._make_request("POST", f"/project_y/cameos/by_id/{cameo_id}/update_v2", token, json_data=json_data)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def upload_character_image(self, image_data: bytes, token: str) -> str:
|
async def upload_character_image(
|
||||||
|
self,
|
||||||
|
image_data: bytes,
|
||||||
|
token: str,
|
||||||
|
token_id: Optional[int] = None
|
||||||
|
) -> str:
|
||||||
"""Upload character image and return asset_pointer
|
"""Upload character image and return asset_pointer
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -1427,7 +1810,14 @@ class SoraClient:
|
|||||||
data=b"profile"
|
data=b"profile"
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await self._make_request("POST", "/project_y/file/upload", token, multipart=mp)
|
result = await self._make_request(
|
||||||
|
"POST",
|
||||||
|
"/project_y/file/upload",
|
||||||
|
token,
|
||||||
|
multipart=mp,
|
||||||
|
token_id=token_id,
|
||||||
|
use_image_upload_proxy=True
|
||||||
|
)
|
||||||
return result.get("asset_pointer")
|
return result.get("asset_pointer")
|
||||||
|
|
||||||
async def delete_character(self, character_id: str, token: str) -> bool:
|
async def delete_character(self, character_id: str, token: str) -> bool:
|
||||||
@@ -1493,8 +1883,49 @@ class SoraClient:
|
|||||||
|
|
||||||
# Generate sentinel token and call /nf/create using urllib
|
# Generate sentinel token and call /nf/create using urllib
|
||||||
proxy_url = await self.proxy_manager.get_proxy_url()
|
proxy_url = await self.proxy_manager.get_proxy_url()
|
||||||
sentinel_token, user_agent = await self._generate_sentinel_token(token)
|
sentinel_context = await self._generate_sentinel_token(token)
|
||||||
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, user_agent=user_agent)
|
result = await self._nf_create_urllib(
|
||||||
|
token,
|
||||||
|
json_data,
|
||||||
|
sentinel_context["sentinel_token"],
|
||||||
|
proxy_url,
|
||||||
|
user_agent=sentinel_context.get("user_agent"),
|
||||||
|
device_id=sentinel_context.get("device_id"),
|
||||||
|
cookie_header=sentinel_context.get("cookie_header"),
|
||||||
|
)
|
||||||
|
return result.get("id")
|
||||||
|
|
||||||
|
async def extend_video(self, generation_id: str, prompt: str, extension_duration_s: int,
|
||||||
|
token: str, token_id: Optional[int] = None) -> str:
|
||||||
|
"""Extend an existing video draft by generation id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
generation_id: Draft generation ID (gen_xxx)
|
||||||
|
prompt: User prompt for extension
|
||||||
|
extension_duration_s: Extension duration in seconds (10 or 15)
|
||||||
|
token: Access token
|
||||||
|
token_id: Token ID for token-specific proxy (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
task_id
|
||||||
|
"""
|
||||||
|
if extension_duration_s not in [10, 15]:
|
||||||
|
raise ValueError("extension_duration_s must be 10 or 15")
|
||||||
|
|
||||||
|
json_data = {
|
||||||
|
"user_prompt": prompt,
|
||||||
|
"extension_duration_s": extension_duration_s,
|
||||||
|
"enable_rewrite": True
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"/project_y/profile/drafts/{generation_id}/long_video_extension",
|
||||||
|
token,
|
||||||
|
json_data=json_data,
|
||||||
|
add_sentinel_token=True,
|
||||||
|
token_id=token_id
|
||||||
|
)
|
||||||
return result.get("id")
|
return result.get("id")
|
||||||
|
|
||||||
async def generate_storyboard(self, prompt: str, token: str, orientation: str = "landscape",
|
async def generate_storyboard(self, prompt: str, token: str, orientation: str = "landscape",
|
||||||
|
|||||||
@@ -946,19 +946,21 @@ class TokenManager:
|
|||||||
|
|
||||||
async def update_token_status(self, token_id: int, is_active: bool):
|
async def update_token_status(self, token_id: int, is_active: bool):
|
||||||
"""Update token active status"""
|
"""Update token active status"""
|
||||||
await self.db.update_token_status(token_id, is_active)
|
# When manually changing status, set appropriate disabled_reason
|
||||||
|
disabled_reason = None if is_active else "manual"
|
||||||
|
await self.db.update_token_status(token_id, is_active, disabled_reason)
|
||||||
|
|
||||||
async def enable_token(self, token_id: int):
|
async def enable_token(self, token_id: int):
|
||||||
"""Enable a token and reset error count"""
|
"""Enable a token and reset error count"""
|
||||||
await self.db.update_token_status(token_id, True)
|
await self.db.update_token_status(token_id, True, None) # Clear disabled_reason
|
||||||
# Reset error count when enabling (in token_stats table)
|
# Reset error count when enabling (in token_stats table)
|
||||||
await self.db.reset_error_count(token_id)
|
await self.db.reset_error_count(token_id)
|
||||||
# Clear expired flag when enabling
|
# Clear expired flag when enabling
|
||||||
await self.db.clear_token_expired(token_id)
|
await self.db.clear_token_expired(token_id)
|
||||||
|
|
||||||
async def disable_token(self, token_id: int):
|
async def disable_token(self, token_id: int):
|
||||||
"""Disable a token"""
|
"""Disable a token (manual disable)"""
|
||||||
await self.db.update_token_status(token_id, False)
|
await self.db.update_token_status(token_id, False, "manual")
|
||||||
|
|
||||||
async def test_token(self, token_id: int) -> dict:
|
async def test_token(self, token_id: int) -> dict:
|
||||||
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
|
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
|
||||||
@@ -1048,6 +1050,14 @@ class TokenManager:
|
|||||||
"valid": False,
|
"valid": False,
|
||||||
"message": "Token已过期(token_invalidated)"
|
"message": "Token已过期(token_invalidated)"
|
||||||
}
|
}
|
||||||
|
# Check if error is "Failed to get user info:401"
|
||||||
|
if "Failed to get user info:401" in error_msg or "Failed to get user info: 401" in error_msg:
|
||||||
|
# Mark token as invalid and disable it
|
||||||
|
await self.db.mark_token_invalid(token_id)
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"message": "Token无效: Token is invalid: Failed to get user info:401"
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"valid": False,
|
"valid": False,
|
||||||
"message": f"Token is invalid: {error_msg}"
|
"message": f"Token is invalid: {error_msg}"
|
||||||
@@ -1077,7 +1087,8 @@ class TokenManager:
|
|||||||
admin_config = await self.db.get_admin_config()
|
admin_config = await self.db.get_admin_config()
|
||||||
|
|
||||||
if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
|
if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
|
||||||
await self.db.update_token_status(token_id, False)
|
# Disable token with error_limit reason
|
||||||
|
await self.db.update_token_status(token_id, False, "error_limit")
|
||||||
|
|
||||||
async def record_success(self, token_id: int, is_video: bool = False):
|
async def record_success(self, token_id: int, is_video: bool = False):
|
||||||
"""Record successful request (reset error count)"""
|
"""Record successful request (reset error count)"""
|
||||||
|
|||||||
@@ -1559,6 +1559,9 @@
|
|||||||
<option value="sora2pro-hd-portrait-15s">竖屏视频 15 秒 (Pro HD)</option>
|
<option value="sora2pro-hd-portrait-15s">竖屏视频 15 秒 (Pro HD)</option>
|
||||||
<option value="sora2pro-hd-portrait-10s">竖屏视频 10 秒 (Pro HD)</option>
|
<option value="sora2pro-hd-portrait-10s">竖屏视频 10 秒 (Pro HD)</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
<optgroup label="角色创建">
|
||||||
|
<option value="avatar-create">角色创建(视频优先 / 支持提示词generation_id)</option>
|
||||||
|
</optgroup>
|
||||||
<optgroup label="图片">
|
<optgroup label="图片">
|
||||||
<option value="gpt-image">方图 360×360</option>
|
<option value="gpt-image">方图 360×360</option>
|
||||||
<option value="gpt-image-landscape">横图 540×360</option>
|
<option value="gpt-image-landscape">横图 540×360</option>
|
||||||
|
|||||||
@@ -4771,9 +4771,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (batchType === 'character') {
|
} else if (batchType === 'character') {
|
||||||
// 角色卡模式:只需要视频文件,不需要提示词
|
if (model !== 'avatar-create') {
|
||||||
|
showToast('角色卡模式请先切换模型为“角色创建(视频优先 / 支持提示词generation_id)/avatar-create”', 'warn', { title: '模型不匹配', duration: 4200 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 角色卡模式:只使用视频文件(提示词内 generation_id 请走普通模式)
|
||||||
if (!files.length) {
|
if (!files.length) {
|
||||||
showToast('角色卡模式:请上传视频文件', 'warn', { title: '缺少视频', duration: 3600 });
|
showToast('角色卡模式:请上传视频文件(提示词generation_id请用普通模式)', 'warn', { title: '缺少视频', duration: 3600 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const videoFile = files.find((f) => (f.type || '').startsWith('video'));
|
const videoFile = files.find((f) => (f.type || '').startsWith('video'));
|
||||||
|
|||||||
@@ -374,6 +374,28 @@
|
|||||||
<input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
<input id="cfgProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890 或 socks5://127.0.0.1:1080">
|
||||||
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
|
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-4 p-4 rounded-md bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="h-4 w-4 text-blue-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||||
|
<polyline points="21 15 16 10 5 21"/>
|
||||||
|
</svg>
|
||||||
|
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-100">图片上传专用代理</h4>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="cfgImageUploadProxyEnabled" class="h-4 w-4 rounded border-input">
|
||||||
|
<span class="text-sm font-medium">启用图片上传专用代理</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">启用后,图片上传将使用下方设置的专用代理</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-2 block">图片上传代理地址</label>
|
||||||
|
<input id="cfgImageUploadProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:8888 或 socks5://127.0.0.1:1080">
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">仅用于图片上传操作,未启用时将使用全局代理</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium mb-2 block">测试域名</label>
|
<label class="text-sm font-medium mb-2 block">测试域名</label>
|
||||||
<input id="cfgProxyTestUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://sora.chatgpt.com" value="https://sora.chatgpt.com">
|
<input id="cfgProxyTestUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="https://sora.chatgpt.com" value="https://sora.chatgpt.com">
|
||||||
@@ -390,23 +412,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- POW代理配置 -->
|
<!-- POW配置 -->
|
||||||
<div class="rounded-lg border border-border bg-background p-6">
|
<div class="rounded-lg border border-border bg-background p-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">POW代理配置</h3>
|
<h3 class="text-lg font-semibold mb-4">POW配置</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-2 block">计算模式</label>
|
||||||
|
<select id="cfgPowMode" onchange="togglePowFields()" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
|
||||||
|
<option value="local">本地计算</option>
|
||||||
|
<option value="external">外部服务</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">选择 POW 计算方式</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" id="cfgPowProxyEnabled" class="h-4 w-4 rounded border-input">
|
<input type="checkbox" id="cfgPowUseTokenForPow" class="h-4 w-4 rounded border-input">
|
||||||
|
<span class="text-sm font-medium">使用对应 Token 进行计算</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">默认关闭。local 模式下使用当前轮询 Token 计算;external 模式下会传递 accesstoken 字段。</p>
|
||||||
|
</div>
|
||||||
|
<div id="powExternalFields" style="display: none;">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-2 block">服务器地址</label>
|
||||||
|
<input id="cfgPowServerUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:8002">
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">外部 POW 服务的地址</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium mb-2 block">API 密钥</label>
|
||||||
|
<input id="cfgPowApiKey" type="password" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="输入 API 密钥">
|
||||||
|
<p class="text-xs text-muted-foreground mt-1">访问外部 POW 服务的密钥</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" id="cfgPowProxyEnabled" onchange="togglePowProxyFields()" class="h-4 w-4 rounded border-input">
|
||||||
<span class="text-sm font-medium">启用POW代理</span>
|
<span class="text-sm font-medium">启用POW代理</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-muted-foreground mt-1">获取 Sentinel Token 时使用的代理</p>
|
<p class="text-xs text-muted-foreground mt-1">获取 Sentinel Token 时使用的代理</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div id="powProxyUrlField" style="display: none;">
|
||||||
<label class="text-sm font-medium mb-2 block">POW代理地址</label>
|
<label class="text-sm font-medium mb-2 block">POW代理地址</label>
|
||||||
<input id="cfgPowProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890">
|
<input id="cfgPowProxyUrl" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="http://127.0.0.1:7890">
|
||||||
<p class="text-xs text-muted-foreground mt-1">用于获取 POW Token 的代理地址</p>
|
<p class="text-xs text-muted-foreground mt-1">用于获取 POW Token 的代理地址</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="savePowProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
<button onclick="savePowConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -556,6 +607,11 @@
|
|||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-muted-foreground mt-2">随机轮询:随机选择可用账号;逐个轮询:每个活跃账号只使用一次,全部使用过后再开始下一轮</p>
|
<p class="text-xs text-muted-foreground mt-2">随机轮询:随机选择可用账号;逐个轮询:每个活跃账号只使用一次,全部使用过后再开始下一轮</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium block">进度轮询间隔(秒)</label>
|
||||||
|
<input id="cfgCallLogicPollInterval" type="number" step="0.1" min="0.1" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" placeholder="2.5">
|
||||||
|
<p class="text-xs text-muted-foreground mt-2">控制生成任务进度查询的时间间隔,保存后立即热更新生效</p>
|
||||||
|
</div>
|
||||||
<button onclick="saveCallLogicConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
<button onclick="saveCallLogicConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1036,19 +1092,21 @@
|
|||||||
const $=(id)=>document.getElementById(id),
|
const $=(id)=>document.getElementById(id),
|
||||||
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
|
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
|
||||||
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
|
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
|
||||||
|
// 获取token的状态文本和样式
|
||||||
|
getTokenStatus=(token)=>{if(token.is_active){return{text:'活跃',class:'bg-green-50 text-green-700'}}const reason=token.disabled_reason;if(reason==='token_invalid'||reason==='expired'){return{text:'失效',class:'bg-gray-100 text-gray-700'}}if(reason==='manual'||reason==='error_limit'){return{text:'禁用',class:'bg-gray-100 text-gray-700'}}return{text:'禁用',class:'bg-gray-100 text-gray-700'}},
|
||||||
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
|
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
|
||||||
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();updateStatusFilterOptions();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();updateStatusFilterOptions();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
||||||
updateStatusFilterOptions=()=>{const statusSet=new Set();allTokens.forEach(t=>{const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');statusSet.add(statusText)});const dropdown=$('statusFilterDropdown');if(!dropdown)return;const statuses=Array.from(statusSet).sort();dropdown.innerHTML='<div class="py-1"><button onclick="selectStatusFilter(\'\')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>'+statuses.map(s=>`<button onclick="selectStatusFilter('${s}')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="${s}">${s}</button>`).join('')+'</div>';updateStatusFilterLabel()},
|
updateStatusFilterOptions=()=>{const statusSet=new Set();allTokens.forEach(t=>{const status=getTokenStatus(t);statusSet.add(status.text)});const dropdown=$('statusFilterDropdown');if(!dropdown)return;const statuses=Array.from(statusSet).sort();dropdown.innerHTML='<div class="py-1"><button onclick="selectStatusFilter(\'\')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>'+statuses.map(s=>`<button onclick="selectStatusFilter('${s}')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="${s}">${s}</button>`).join('')+'</div>';updateStatusFilterLabel()},
|
||||||
updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}},
|
updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}},
|
||||||
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
|
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
|
||||||
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
|
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
|
||||||
applyStatusFilter=()=>{currentPage=1;renderTokens()},
|
applyStatusFilter=()=>{currentPage=1;renderTokens()},
|
||||||
getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');return statusText===currentStatusFilter})},
|
getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const status=getTokenStatus(t);return status.text===currentStatusFilter})},
|
||||||
formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`},
|
formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`},
|
||||||
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
|
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
|
||||||
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
|
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
|
||||||
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
|
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
|
||||||
renderTokens=()=>{const filteredTokens=getFilteredTokens();const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=filteredTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const remainingCount=t.sora2_remaining_count!==undefined&&t.sora2_remaining_count!==null?t.sora2_remaining_count:'-';const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');const statusClass=t.is_expired?'bg-gray-100 text-gray-700':(t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700');return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${remainingCount}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
|
renderTokens=()=>{const filteredTokens=getFilteredTokens();const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=filteredTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const remainingCount=t.sora2_remaining_count!==undefined&&t.sora2_remaining_count!==null?t.sora2_remaining_count:'-';const status=getTokenStatus(t);const statusText=status.text;const statusClass=status.class;return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${remainingCount}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
|
||||||
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
||||||
changePage=(page)=>{currentPage=page;renderTokens()},
|
changePage=(page)=>{currentPage=page;renderTokens()},
|
||||||
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
|
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
|
||||||
@@ -1091,10 +1149,10 @@
|
|||||||
updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
|
updateAPIKey=async()=>{const newKey=$('cfgNewAPIKey').value.trim();if(!newKey)return showToast('请输入新的 API Key','error');if(newKey.length<6)return showToast('API Key 至少6个字符','error');if(!confirm('确定要更新 API Key 吗?更新后需要通知所有客户端使用新密钥。'))return;try{const r=await apiRequest('/api/admin/apikey',{method:'POST',body:JSON.stringify({new_api_key:newKey})});if(!r)return;const d=await r.json();if(d.success){showToast('API Key 更新成功','success');$('cfgCurrentAPIKey').value=newKey;$('cfgNewAPIKey').value=''}else{showToast('更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('更新失败: '+e.message,'error')}},
|
||||||
toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}},
|
toggleDebugMode=async()=>{const enabled=$('cfgDebugEnabled').checked;try{const r=await apiRequest('/api/admin/debug',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r)return;const d=await r.json();if(d.success){showToast(enabled?'调试模式已开启':'调试模式已关闭','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('cfgDebugEnabled').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('cfgDebugEnabled').checked=!enabled}},
|
||||||
downloadDebugLogs=async()=>{try{const token=localStorage.getItem('adminToken');if(!token){showToast('未登录','error');return}const r=await fetch('/api/admin/logs/download',{headers:{Authorization:`Bearer ${token}`}});if(!r.ok){if(r.status===404){showToast('日志文件不存在','error')}else{showToast('下载失败','error')}return}const blob=await r.blob();const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=`logs_${new Date().toISOString().split('T')[0]}.txt`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast('日志文件下载成功','success')}catch(e){showToast('下载失败: '+e.message,'error')}},
|
downloadDebugLogs=async()=>{try{const token=localStorage.getItem('adminToken');if(!token){showToast('未登录','error');return}const r=await fetch('/api/admin/logs/download',{headers:{Authorization:`Bearer ${token}`}});if(!r.ok){if(r.status===404){showToast('日志文件不存在','error')}else{showToast('下载失败','error')}return}const blob=await r.blob();const url=URL.createObjectURL(blob);const link=document.createElement('a');link.href=url;link.download=`logs_${new Date().toISOString().split('T')[0]}.txt`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast('日志文件下载成功','success')}catch(e){showToast('下载失败: '+e.message,'error')}},
|
||||||
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
|
loadProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config');if(!r)return;const d=await r.json();$('cfgProxyEnabled').checked=d.proxy_enabled||false;$('cfgProxyUrl').value=d.proxy_url||'';$('cfgImageUploadProxyEnabled').checked=d.image_upload_proxy_enabled||false;$('cfgImageUploadProxyUrl').value=d.image_upload_proxy_url||''}catch(e){console.error('加载代理配置失败:',e)}},
|
||||||
setProxyStatus=(msg,type='muted')=>{const el=$('proxyStatusMessage');if(!el)return;if(!msg){el.textContent='';el.classList.add('hidden');return}el.textContent=msg;el.classList.remove('hidden','text-muted-foreground','text-green-600','text-red-600');if(type==='success')el.classList.add('text-green-600');else if(type==='error')el.classList.add('text-red-600');else el.classList.add('text-muted-foreground')},
|
setProxyStatus=(msg,type='muted')=>{const el=$('proxyStatusMessage');if(!el)return;if(!msg){el.textContent='';el.classList.add('hidden');return}el.textContent=msg;el.classList.remove('hidden','text-muted-foreground','text-green-600','text-red-600');if(type==='success')el.classList.add('text-green-600');else if(type==='error')el.classList.add('text-red-600');else el.classList.add('text-muted-foreground')},
|
||||||
testProxyConfig=async()=>{const enabled=$('cfgProxyEnabled').checked;const url=$('cfgProxyUrl').value.trim();const testUrl=$('cfgProxyTestUrl').value.trim()||'https://sora.chatgpt.com';if(!enabled||!url){setProxyStatus('代理未启用或地址为空','error');return}try{setProxyStatus('正在测试代理连接...','muted');const r=await apiRequest('/api/proxy/test',{method:'POST',body:JSON.stringify({test_url:testUrl})});if(!r)return;const d=await r.json();if(d.success){setProxyStatus(`✓ ${d.message||'代理可用'} - 测试域名: ${d.test_url||testUrl}`,'success')}else{setProxyStatus(`✗ ${d.message||'代理不可用'} - 测试域名: ${d.test_url||testUrl}`,'error')}}catch(e){setProxyStatus('代理测试失败: '+e.message,'error')}},
|
testProxyConfig=async()=>{const enabled=$('cfgProxyEnabled').checked;const url=$('cfgProxyUrl').value.trim();const testUrl=$('cfgProxyTestUrl').value.trim()||'https://sora.chatgpt.com';if(!enabled||!url){setProxyStatus('代理未启用或地址为空','error');return}try{setProxyStatus('正在测试代理连接...','muted');const r=await apiRequest('/api/proxy/test',{method:'POST',body:JSON.stringify({test_url:testUrl})});if(!r)return;const d=await r.json();if(d.success){setProxyStatus(`✓ ${d.message||'代理可用'} - 测试域名: ${d.test_url||testUrl}`,'success')}else{setProxyStatus(`✗ ${d.message||'代理不可用'} - 测试域名: ${d.test_url||testUrl}`,'error')}}catch(e){setProxyStatus('代理测试失败: '+e.message,'error')}},
|
||||||
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
saveProxyConfig=async()=>{try{const r=await apiRequest('/api/proxy/config',{method:'POST',body:JSON.stringify({proxy_enabled:$('cfgProxyEnabled').checked,proxy_url:$('cfgProxyUrl').value.trim(),image_upload_proxy_enabled:$('cfgImageUploadProxyEnabled').checked,image_upload_proxy_url:$('cfgImageUploadProxyUrl').value.trim()})});if(!r)return;const d=await r.json();d.success?showToast('代理配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||||||
loadWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config');if(!r)return;const d=await r.json();$('cfgWatermarkFreeEnabled').checked=d.watermark_free_enabled||false;$('cfgParseMethod').value=d.parse_method||'third_party';$('cfgCustomParseUrl').value=d.custom_parse_url||'';$('cfgCustomParseToken').value=d.custom_parse_token||'';$('cfgFallbackOnFailure').checked=d.fallback_on_failure!==false;toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}},
|
loadWatermarkFreeConfig=async()=>{try{const r=await apiRequest('/api/watermark-free/config');if(!r)return;const d=await r.json();$('cfgWatermarkFreeEnabled').checked=d.watermark_free_enabled||false;$('cfgParseMethod').value=d.parse_method||'third_party';$('cfgCustomParseUrl').value=d.custom_parse_url||'';$('cfgCustomParseToken').value=d.custom_parse_token||'';$('cfgFallbackOnFailure').checked=d.fallback_on_failure!==false;toggleWatermarkFreeOptions();toggleCustomParseOptions()}catch(e){console.error('加载无水印模式配置失败:',e)}},
|
||||||
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim(),fallbackOnFailure=$('cfgFallbackOnFailure').checked;if(enabled&&parseMethod==='custom'){if(!customUrl)return showToast('请输入解析服务器地址','error');if(!customToken)return showToast('请输入访问密钥','error')}const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:enabled,parse_method:parseMethod,custom_parse_url:customUrl||null,custom_parse_token:customToken||null,fallback_on_failure:fallbackOnFailure})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
saveWatermarkFreeConfig=async()=>{try{const enabled=$('cfgWatermarkFreeEnabled').checked,parseMethod=$('cfgParseMethod').value,customUrl=$('cfgCustomParseUrl').value.trim(),customToken=$('cfgCustomParseToken').value.trim(),fallbackOnFailure=$('cfgFallbackOnFailure').checked;if(enabled&&parseMethod==='custom'){if(!customUrl)return showToast('请输入解析服务器地址','error');if(!customToken)return showToast('请输入访问密钥','error')}const r=await apiRequest('/api/watermark-free/config',{method:'POST',body:JSON.stringify({watermark_free_enabled:enabled,parse_method:parseMethod,custom_parse_url:customUrl||null,custom_parse_token:customToken||null,fallback_on_failure:fallbackOnFailure})});if(!r)return;const d=await r.json();d.success?showToast('无水印模式配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||||||
toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'},
|
toggleWatermarkFreeOptions=()=>{const enabled=$('cfgWatermarkFreeEnabled').checked;$('watermarkFreeOptions').style.display=enabled?'block':'none'},
|
||||||
@@ -1116,11 +1174,15 @@
|
|||||||
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
|
logout=()=>{if(!confirm('确定要退出登录吗?'))return;localStorage.removeItem('adminToken');location.href='/login'},
|
||||||
loadCharacters=async()=>{try{const r=await apiRequest('/api/characters');if(!r)return;const d=await r.json();const g=$('charactersGrid');if(!d||d.length===0){g.innerHTML='<div class="col-span-full text-center py-8 text-muted-foreground">暂无角色卡</div>';return}g.innerHTML=d.map(c=>`<div class="rounded-lg border border-border bg-background p-4"><div class="flex items-start gap-3"><img src="${c.avatar_path||'/static/favicon.ico'}" class="h-14 w-14 rounded-lg object-cover" onerror="this.src='/static/favicon.ico'"/><div class="flex-1 min-w-0"><div class="font-semibold truncate">${c.display_name||c.username}</div><div class="text-xs text-muted-foreground truncate">@${c.username}</div>${c.description?`<div class="text-xs text-muted-foreground mt-1 line-clamp-2">${c.description}</div>`:''}</div></div><div class="mt-3 flex gap-2"><button onclick="deleteCharacter(${c.id})" class="flex-1 inline-flex items-center justify-center rounded-md border border-destructive text-destructive hover:bg-destructive hover:text-white h-8 px-3 text-sm transition-colors">删除</button></div></div>`).join('')}catch(e){showToast('加载失败: '+e.message,'error')}},
|
loadCharacters=async()=>{try{const r=await apiRequest('/api/characters');if(!r)return;const d=await r.json();const g=$('charactersGrid');if(!d||d.length===0){g.innerHTML='<div class="col-span-full text-center py-8 text-muted-foreground">暂无角色卡</div>';return}g.innerHTML=d.map(c=>`<div class="rounded-lg border border-border bg-background p-4"><div class="flex items-start gap-3"><img src="${c.avatar_path||'/static/favicon.ico'}" class="h-14 w-14 rounded-lg object-cover" onerror="this.src='/static/favicon.ico'"/><div class="flex-1 min-w-0"><div class="font-semibold truncate">${c.display_name||c.username}</div><div class="text-xs text-muted-foreground truncate">@${c.username}</div>${c.description?`<div class="text-xs text-muted-foreground mt-1 line-clamp-2">${c.description}</div>`:''}</div></div><div class="mt-3 flex gap-2"><button onclick="deleteCharacter(${c.id})" class="flex-1 inline-flex items-center justify-center rounded-md border border-destructive text-destructive hover:bg-destructive hover:text-white h-8 px-3 text-sm transition-colors">删除</button></div></div>`).join('')}catch(e){showToast('加载失败: '+e.message,'error')}},
|
||||||
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
||||||
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');$('cfgCallLogicMode').value=mode}else{console.error('调用逻辑配置数据格式错误:',d)}}catch(e){console.error('加载调用逻辑配置失败:',e)}},
|
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');const pollInterval=Number(d.config.poll_interval||2.5);$('cfgCallLogicMode').value=mode;$('cfgCallLogicPollInterval').value=Number.isFinite(pollInterval)&&pollInterval>0?pollInterval:2.5}else{console.error('调用逻辑配置数据格式错误:',d)}}catch(e){console.error('加载调用逻辑配置失败:',e)}},
|
||||||
saveCallLogicConfig=async()=>{try{const mode=$('cfgCallLogicMode').value||'default';const r=await apiRequest('/api/call-logic/config',{method:'POST',body:JSON.stringify({call_mode:mode})});if(!r)return;const d=await r.json();if(d.success){showToast('调用逻辑配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
|
saveCallLogicConfig=async()=>{try{const mode=$('cfgCallLogicMode').value||'default';const pollInterval=parseFloat($('cfgCallLogicPollInterval').value||'2.5');if(!Number.isFinite(pollInterval)||pollInterval<=0)return showToast('进度轮询间隔必须大于0','error');const r=await apiRequest('/api/call-logic/config',{method:'POST',body:JSON.stringify({call_mode:mode,poll_interval:pollInterval})});if(!r)return;const d=await r.json();if(d.success){showToast('调用逻辑配置保存成功(已立即生效)','success');await loadCallLogicConfig()}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||||||
loadPowProxyConfig=async()=>{try{const r=await apiRequest('/api/pow-proxy/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPowProxyEnabled').checked=d.config.pow_proxy_enabled||false;$('cfgPowProxyUrl').value=d.config.pow_proxy_url||''}else{console.error('POW代理配置数据格式错误:',d)}}catch(e){console.error('加载POW代理配置失败:',e)}},
|
loadPowConfig=async()=>{try{const r=await apiRequest('/api/pow/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPowMode').value=d.config.mode||'local';$('cfgPowUseTokenForPow').checked=d.config.use_token_for_pow||false;$('cfgPowServerUrl').value=d.config.server_url||'';$('cfgPowApiKey').value=d.config.api_key||'';$('cfgPowProxyEnabled').checked=d.config.proxy_enabled||false;$('cfgPowProxyUrl').value=d.config.proxy_url||'';togglePowFields();togglePowProxyFields()}else{console.error('POW配置数据格式错误:',d)}}catch(e){console.error('加载POW配置失败:',e)}},
|
||||||
savePowProxyConfig=async()=>{try{const r=await apiRequest('/api/pow-proxy/config',{method:'POST',body:JSON.stringify({pow_proxy_enabled:$('cfgPowProxyEnabled').checked,pow_proxy_url:$('cfgPowProxyUrl').value.trim()})});if(!r)return;const d=await r.json();if(d.success){showToast('POW代理配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
|
savePowConfig=async()=>{try{const mode=$('cfgPowMode').value;const useTokenForPow=$('cfgPowUseTokenForPow').checked;const serverUrl=$('cfgPowServerUrl').value.trim();const apiKey=$('cfgPowApiKey').value.trim();const proxyEnabled=$('cfgPowProxyEnabled').checked;const proxyUrl=$('cfgPowProxyUrl').value.trim();if(mode==='external'){if(!serverUrl)return showToast('请输入服务器地址','error');if(!apiKey)return showToast('请输入API密钥','error')}const r=await apiRequest('/api/pow/config',{method:'POST',body:JSON.stringify({mode:mode,use_token_for_pow:useTokenForPow,server_url:serverUrl||null,api_key:apiKey||null,proxy_enabled:proxyEnabled,proxy_url:proxyUrl||null})});if(!r)return;const d=await r.json();if(d.success){showToast('POW配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
|
||||||
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs','generate'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadPowProxyConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig();loadCallLogicConfig()}else if(t==='logs'){loadLogs()}};
|
loadPowProxyConfig=loadPowConfig,savePowProxyConfig=savePowConfig,loadPowServiceConfig=loadPowConfig,savePowServiceConfig=savePowConfig,
|
||||||
|
togglePowFields=()=>{const mode=$('cfgPowMode').value;const externalFields=$('powExternalFields');if(externalFields){externalFields.style.display=mode==='external'?'block':'none'}},
|
||||||
|
togglePowProxyFields=()=>{const enabled=$('cfgPowProxyEnabled').checked;const proxyUrlField=$('powProxyUrlField');if(proxyUrlField){proxyUrlField.style.display=enabled?'block':'none'}},
|
||||||
|
togglePowServiceFields=()=>{togglePowFields()},
|
||||||
|
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs','generate'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadPowProxyConfig();loadPowServiceConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig();loadCallLogicConfig()}else if(t==='logs'){loadLogs()}};
|
||||||
// 自适应生成面板 iframe 高度
|
// 自适应生成面板 iframe 高度
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
const data = event.data || {};
|
const data = event.data || {};
|
||||||
|
|||||||
Reference in New Issue
Block a user