Compare commits

..

6 Commits

Author SHA1 Message Date
TheSmallHanCat
e7d91b31a7 feat: 新增图片上传专用代理,完善轮询与POW调用链路,重构POW与token传递 2026-03-07 12:57:21 +08:00
TheSmallHanCat
ad554d900a feat(pow): 新增计算pow支持传入token 2026-03-02 01:28:37 +08:00
TheSmallHanCat
404cbd44f0 feat: 新增视频续写模型 2026-02-24 01:59:58 +08:00
TheSmallHanCat
8b406e4e5c feat: 独立角色创建模型,完善角色创建结果信息;改进错误重试逻辑;集成POW服务 2026-02-24 01:42:36 +08:00
TheSmallHanCat
29fddfa85b feat: 增强Token禁用状态管理,区分失效与禁用 2026-02-07 20:36:41 +08:00
TheSmallHanCat
5a0ccbe2de feat: 新增外部pow获取 2026-02-02 12:57:54 +08:00
17 changed files with 1879 additions and 183 deletions

View File

@@ -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 = ""

View File

@@ -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

View File

@@ -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)):

View File

@@ -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
if not remix_target_id: extracted_id = _extract_remix_id(prompt)
remix_target_id = _extract_remix_id(prompt) if extracted_id:
if not remix_target_id:
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
if not remix_target_id: extracted_id = _extract_remix_id(prompt)
remix_target_id = _extract_remix_id(prompt) if extracted_id:
if not remix_target_id:
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):

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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,40 +493,56 @@ 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_idgen_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 yield chunk
return 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
return
# Streaming mode: proceed with actual generation # Streaming mode: proceed with actual generation
# Check if model requires Pro subscription # Check if model requires Pro subscription
@@ -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_idgen_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

View 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()

View File

@@ -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"""

View File

@@ -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 = {
debug_logger.log_info("[Sentinel] Token cached successfully") "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")
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,8 +1102,12 @@ 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
""" """
proxy_url = await self.proxy_manager.get_proxy_url(token_id) 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)
headers = { headers = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
@@ -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",

View File

@@ -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)"""

View File

@@ -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>

View File

@@ -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'));

View File

@@ -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 || {};