Compare commits

..

12 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
genz27
fc95de0f28 feat: 集成轻量级Playwright sentinel_token获取方案并添加缓存复用
- 从get_sentinel_token.py同步轻量级Playwright方案
- 添加全局浏览器实例复用,减少资源消耗
- 实现sentinel_token缓存,只在nf/create返回400时刷新
- 获取oai-did时遇到403/429直接抛出错误,不再重试

Co-Authored-By: Warp <agent@warp.dev>
2026-01-29 19:55:24 +08:00
TheSmallHanCat
92015882cc feat: 新增获取Sentinel Token及POW代理配置 2026-01-28 20:58:40 +08:00
TheSmallHanCat
5570fa35a6 fix: 修复任务取消时间计算及日志状态显示逻辑 2026-01-27 00:22:10 +08:00
TheSmallHanCat
06c2bea806 fix: 修复管理配置更新缺失字段及日志状态更新机制
- 修复update_admin_config方法未更新task_retry_enabled、task_max_retries、auto_disable_on_401字段的问题
- 新增finally块确保请求日志在异常情况下也能正确更新状态,避免卡在status_code=-1
2026-01-26 20:12:20 +08:00
TheSmallHanCat
576310c50c feat: 增加状态筛选框 2026-01-25 17:53:26 +08:00
TheSmallHanCat
dab1f13310 feat: 支持纯RT导入 2026-01-25 15:27:05 +08:00
19 changed files with 2815 additions and 165 deletions

View File

@@ -8,9 +8,42 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# Install system dependencies for Playwright
RUN apt-get update && apt-get install -y \
wget \
gnupg \
ca-certificates \
fonts-liberation \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2 \
libatspi2.0-0 \
libxshmfence1 \
libnss3 \
libnspr4 \
libdbus-1-3 \
libdrm2 \
libxkbcommon0 \
libx11-6 \
libxcb1 \
libxext6 \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers
RUN playwright install chromium
COPY . .
EXPOSE 8000

View File

@@ -40,6 +40,8 @@ auto_disable_on_401 = true
[proxy]
proxy_enabled = false
proxy_url = ""
image_upload_proxy_enabled = false
image_upload_proxy_url = ""
[watermark_free]
watermark_free_enabled = false
@@ -60,3 +62,19 @@ call_mode = "default"
# 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -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_enabled = true
proxy_url = "socks5://warp:1080"
image_upload_proxy_enabled = false
image_upload_proxy_url = ""
[watermark_free]
watermark_free_enabled = false

View File

@@ -13,3 +13,4 @@ toml
faker==24.0.0
python-dateutil==2.8.2
APScheduler==3.10.4
playwright==1.48.0

View File

@@ -115,6 +115,13 @@ class ImportTokensRequest(BaseModel):
tokens: List[ImportTokenItem]
mode: str = "at" # Import mode: offline/at/st/rt
class PureRtImportRequest(BaseModel):
refresh_tokens: List[str] # List of Refresh Tokens
client_id: str # Client ID (required)
proxy_url: Optional[str] = None # Proxy URL (optional)
image_concurrency: int = 1 # Image concurrency limit (default: 1)
video_concurrency: int = 3 # Video concurrency limit (default: 3)
class UpdateAdminConfigRequest(BaseModel):
error_ban_threshold: int
task_retry_enabled: Optional[bool] = None
@@ -122,8 +129,10 @@ class UpdateAdminConfigRequest(BaseModel):
auto_disable_on_401: Optional[bool] = None
class UpdateProxyConfigRequest(BaseModel):
proxy_enabled: bool
proxy_enabled: Optional[bool] = None
proxy_url: Optional[str] = None
image_upload_proxy_enabled: Optional[bool] = None
image_upload_proxy_url: Optional[str] = None
class TestProxyRequest(BaseModel):
test_url: Optional[str] = "https://sora.chatgpt.com"
@@ -159,6 +168,19 @@ class UpdateWatermarkFreeConfigRequest(BaseModel):
class UpdateCallLogicConfigRequest(BaseModel):
call_mode: Optional[str] = None # "default" or "polling"
polling_mode_enabled: Optional[bool] = None # Legacy support
poll_interval: Optional[float] = None # Progress polling interval (seconds)
class UpdatePowProxyConfigRequest(BaseModel):
pow_proxy_enabled: bool
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):
token_ids: List[int]
@@ -231,7 +253,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
"video_enabled": token.video_enabled,
# 并发限制
"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
@@ -662,6 +687,111 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif
"results": results
}
@router.post("/api/tokens/import/pure-rt")
async def import_pure_rt(request: PureRtImportRequest, token: str = Depends(verify_admin_token)):
"""Import tokens using pure RT mode (batch RT conversion and import)"""
added_count = 0
updated_count = 0
failed_count = 0
results = []
for rt in request.refresh_tokens:
try:
# Step 1: Use RT + client_id + proxy to refresh and get AT
rt_result = await token_manager.rt_to_at(
rt,
client_id=request.client_id,
proxy_url=request.proxy_url
)
access_token = rt_result.get("access_token")
new_refresh_token = rt_result.get("refresh_token", rt) # Use new RT if returned, else use original
if not access_token:
raise ValueError("Failed to get access_token from RT conversion")
# Step 2: Parse AT to get user info (email)
# The rt_to_at already includes email in the response
email = rt_result.get("email")
# If email not in rt_result, parse it from access_token
if not email:
import jwt
try:
decoded = jwt.decode(access_token, options={"verify_signature": False})
email = decoded.get("https://api.openai.com/profile", {}).get("email")
except Exception as e:
raise ValueError(f"Failed to parse email from access_token: {str(e)}")
if not email:
raise ValueError("Failed to extract email from access_token")
# Step 3: Check if token with this email already exists
existing_token = await db.get_token_by_email(email)
if existing_token:
# Update existing token
await token_manager.update_token(
token_id=existing_token.id,
token=access_token,
st=None, # No ST in pure RT mode
rt=new_refresh_token, # Use refreshed RT
client_id=request.client_id,
proxy_url=request.proxy_url,
remark=None, # Keep existing remark
image_enabled=True,
video_enabled=True,
image_concurrency=request.image_concurrency,
video_concurrency=request.video_concurrency,
skip_status_update=False # Update status with new AT
)
updated_count += 1
results.append({
"email": email,
"status": "updated",
"message": "Token updated successfully"
})
else:
# Add new token
new_token = await token_manager.add_token(
token_value=access_token,
st=None, # No ST in pure RT mode
rt=new_refresh_token, # Use refreshed RT
client_id=request.client_id,
proxy_url=request.proxy_url,
remark=None,
update_if_exists=False,
image_enabled=True,
video_enabled=True,
image_concurrency=request.image_concurrency,
video_concurrency=request.video_concurrency,
skip_status_update=False, # Update status with new AT
email=email # Pass email for new token
)
added_count += 1
results.append({
"email": email,
"status": "added",
"message": "Token added successfully"
})
except Exception as e:
failed_count += 1
results.append({
"email": "unknown",
"status": "failed",
"message": str(e)
})
return {
"success": True,
"message": f"Pure RT import completed: {added_count} added, {updated_count} updated, {failed_count} failed",
"added": added_count,
"updated": updated_count,
"failed": failed_count,
"results": results
}
@router.put("/api/tokens/{token_id}")
async def update_token(
token_id: int,
@@ -816,7 +946,9 @@ async def get_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
config = await proxy_manager.get_proxy_config()
return {
"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")
@@ -826,7 +958,26 @@ async def update_proxy_config(
):
"""Update proxy configuration"""
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"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -1223,11 +1374,19 @@ async def get_call_logic_config(token: str = Depends(verify_admin_token)) -> dic
call_mode = getattr(config_obj, "call_mode", None)
if call_mode not in ("default", "polling"):
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 {
"success": True,
"config": {
"call_mode": call_mode,
"polling_mode_enabled": call_mode == "polling"
"polling_mode_enabled": call_mode == "polling",
"poll_interval": poll_interval
}
}
@@ -1244,19 +1403,120 @@ async def update_call_logic_config(
if call_mode is None:
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)
if poll_interval is not None:
config.set_poll_interval(poll_interval)
return {
"success": True,
"message": "Call logic configuration updated",
"call_mode": call_mode,
"polling_mode_enabled": call_mode == "polling"
"polling_mode_enabled": call_mode == "polling",
"poll_interval": config.poll_interval
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update call logic configuration: {str(e)}")
# POW proxy config endpoints (redirected to pow_service config for unified management)
@router.get("/api/pow-proxy/config")
async def get_pow_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
"""Get POW proxy configuration (unified with pow_service config)"""
# Read from pow_service config for unified management
config_obj = await db.get_pow_service_config()
return {
"success": True,
"config": {
"pow_proxy_enabled": config_obj.proxy_enabled,
"pow_proxy_url": config_obj.proxy_url or ""
}
}
@router.post("/api/pow-proxy/config")
async def update_pow_proxy_config(
request: UpdatePowProxyConfigRequest,
token: str = Depends(verify_admin_token)
):
"""Update POW proxy configuration (unified with pow_service config)"""
try:
# Update pow_service config instead for unified management
config_obj = await db.get_pow_service_config()
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 {
"success": True,
"message": "POW proxy configuration updated"
}
except Exception as 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
@router.post("/api/tasks/{task_id}/cancel")
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
@@ -1279,7 +1539,26 @@ async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
for log in logs:
if log.get("task_id") == task_id and log.get("status_code") == -1:
import time
duration = time.time() - (log.get("created_at").timestamp() if log.get("created_at") else time.time())
from datetime import datetime
# Calculate duration
created_at = log.get("created_at")
if created_at:
# If created_at is a string, parse it
if isinstance(created_at, str):
try:
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
duration = time.time() - created_at.timestamp()
except:
duration = 0
# If it's already a datetime object
elif isinstance(created_at, datetime):
duration = time.time() - created_at.timestamp()
else:
duration = 0
else:
duration = 0
await db.update_request_log(
log.get("id"),
response_body='{"error": "用户手动取消任务"}',

View File

@@ -54,7 +54,12 @@ async def list_models(api_key: str = Depends(verify_api_key_header)):
if config['type'] == 'image':
description += f" - {config['width']}x{config['height']}"
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':
description += f" - {config['expansion_level']} ({config['duration_s']}s)"
@@ -105,18 +110,22 @@ async def create_chat_completion(
if isinstance(content, str):
# Simple string format
prompt = content
# Extract remix_target_id from prompt if not already provided
if not remix_target_id:
remix_target_id = _extract_remix_id(prompt)
# Extract sora id from prompt if not already provided
extracted_id = _extract_remix_id(prompt)
if extracted_id:
if not remix_target_id:
remix_target_id = extracted_id
elif isinstance(content, list):
# Array format (OpenAI multimodal)
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
prompt = item.get("text", "")
# Extract remix_target_id from prompt if not already provided
if not remix_target_id:
remix_target_id = _extract_remix_id(prompt)
# Extract sora id from prompt if not already provided
extracted_id = _extract_remix_id(prompt)
if extracted_id:
if not remix_target_id:
remix_target_id = extracted_id
elif item.get("type") == "image_url":
# Extract base64 image from data URI
image_url = item.get("image_url", {})
@@ -149,7 +158,7 @@ async def create_chat_completion(
# Check if this is a video 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
if is_video_model and (video_data or remix_target_id):

View File

@@ -56,6 +56,12 @@ class Config:
@property
def poll_interval(self) -> float:
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
def max_poll_attempts(self) -> int:
@@ -236,5 +242,109 @@ class Config:
self._config["call_logic"]["call_mode"] = normalized
self._config["call_logic"]["polling_mode_enabled"] = normalized == "polling"
@property
def pow_proxy_enabled(self) -> bool:
"""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)
def set_pow_proxy_enabled(self, enabled: bool):
"""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:
self._config["pow_proxy"] = {}
self._config["pow_proxy"]["pow_proxy_enabled"] = enabled
@property
def pow_proxy_url(self) -> str:
"""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", "")
def set_pow_proxy_url(self, url: str):
"""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:
self._config["pow_proxy"] = {}
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
config = Config()

View File

@@ -83,18 +83,25 @@ class Database:
# Get proxy config from config_dict if provided, otherwise use defaults
proxy_enabled = False
proxy_url = None
image_upload_proxy_enabled = False
image_upload_proxy_url = None
if config_dict:
proxy_config = config_dict.get("proxy", {})
proxy_enabled = proxy_config.get("proxy_enabled", False)
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
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("""
INSERT INTO proxy_config (id, proxy_enabled, proxy_url)
VALUES (1, ?, ?)
""", (proxy_enabled, proxy_url))
INSERT INTO proxy_config (
id, proxy_enabled, proxy_url, image_upload_proxy_enabled, image_upload_proxy_url
)
VALUES (1, ?, ?, ?, ?)
""", (proxy_enabled, proxy_url, image_upload_proxy_enabled, image_upload_proxy_url))
# Ensure watermark_free_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM watermark_free_config")
@@ -180,6 +187,93 @@ class Database:
VALUES (1, ?)
""", (at_auto_refresh_enabled,))
# Ensure call_logic_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM call_logic_config")
count = await cursor.fetchone()
if count[0] == 0:
# Get call logic config from config_dict if provided, otherwise use defaults
call_mode = "default"
polling_mode_enabled = False
poll_interval = 2.5
if config_dict:
call_logic_config = config_dict.get("call_logic", {})
call_mode = call_logic_config.get("call_mode", "default")
# Normalize call_mode
if call_mode not in ("default", "polling"):
# Check legacy polling_mode_enabled field
polling_mode_enabled = call_logic_config.get("polling_mode_enabled", False)
call_mode = "polling" if polling_mode_enabled else "default"
else:
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("""
INSERT INTO call_logic_config (id, call_mode, polling_mode_enabled, poll_interval)
VALUES (1, ?, ?, ?)
""", (call_mode, polling_mode_enabled, poll_interval))
# Ensure pow_proxy_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM pow_proxy_config")
count = await cursor.fetchone()
if count[0] == 0:
# Get POW proxy config from config_dict if provided, otherwise use defaults
pow_proxy_enabled = False
pow_proxy_url = None
if config_dict:
pow_proxy_config = config_dict.get("pow_proxy", {})
pow_proxy_enabled = pow_proxy_config.get("pow_proxy_enabled", False)
pow_proxy_url = pow_proxy_config.get("pow_proxy_url", "")
# Convert empty string to None
pow_proxy_url = pow_proxy_url if pow_proxy_url else None
await db.execute("""
INSERT INTO pow_proxy_config (id, pow_proxy_enabled, pow_proxy_url)
VALUES (1, ?, ?)
""", (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):
"""Check database integrity and perform migrations if needed
@@ -247,6 +341,103 @@ class Database:
except Exception as 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
if await self._table_exists(db, "watermark_free_config"):
columns_to_add = [
@@ -286,8 +477,13 @@ class Database:
await db.commit()
print("Database migration check completed.")
async def init_db(self):
"""Initialize database tables - creates all tables and ensures data integrity"""
async def init_db(self, config_dict: dict = None):
"""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:
# Tokens table
await db.execute("""
@@ -321,7 +517,8 @@ class Database:
video_enabled BOOLEAN DEFAULT 1,
image_concurrency INTEGER DEFAULT -1,
video_concurrency INTEGER DEFAULT -1,
is_expired BOOLEAN DEFAULT 0
is_expired BOOLEAN DEFAULT 0,
disabled_reason TEXT
)
""")
@@ -386,6 +583,9 @@ class Database:
admin_password TEXT DEFAULT 'admin',
api_key TEXT DEFAULT 'han1234',
error_ban_threshold INTEGER DEFAULT 3,
task_retry_enabled BOOLEAN DEFAULT 1,
task_max_retries INTEGER DEFAULT 3,
auto_disable_on_401 BOOLEAN DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
@@ -396,6 +596,8 @@ class Database:
id INTEGER PRIMARY KEY DEFAULT 1,
proxy_enabled BOOLEAN DEFAULT 0,
proxy_url TEXT,
image_upload_proxy_enabled BOOLEAN DEFAULT 0,
image_upload_proxy_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
@@ -454,6 +656,33 @@ class Database:
id INTEGER PRIMARY KEY DEFAULT 1,
call_mode TEXT DEFAULT 'default',
polling_mode_enabled BOOLEAN DEFAULT 0,
poll_interval REAL DEFAULT 2.5,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# POW proxy config table
await db.execute("""
CREATE TABLE IF NOT EXISTS pow_proxy_config (
id INTEGER PRIMARY KEY DEFAULT 1,
pow_proxy_enabled BOOLEAN DEFAULT 0,
pow_proxy_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 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
)
@@ -486,6 +715,44 @@ class Database:
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")
# 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()
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
@@ -598,27 +865,35 @@ class Database:
""", (token_id,))
await db.commit()
async def update_token_status(self, token_id: int, is_active: bool):
"""Update token status"""
async def update_token_status(self, token_id: int, is_active: bool, disabled_reason: Optional[str] = None):
"""Update token status and disabled reason"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_active = ? WHERE id = ?
""", (is_active, token_id))
UPDATE tokens SET is_active = ?, disabled_reason = ? WHERE id = ?
""", (is_active, disabled_reason, token_id))
await db.commit()
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:
await db.execute("""
UPDATE tokens SET is_expired = 1, is_active = 0 WHERE id = ?
""", (token_id,))
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE 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()
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:
await db.execute("""
UPDATE tokens SET is_expired = 0 WHERE id = ?
UPDATE tokens SET is_expired = 0, disabled_reason = NULL WHERE id = ?
""", (token_id,))
await db.commit()
@@ -1010,9 +1285,12 @@ class Database:
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE admin_config
SET admin_username = ?, admin_password = ?, api_key = ?, error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP
SET admin_username = ?, admin_password = ?, api_key = ?, error_ban_threshold = ?,
task_retry_enabled = ?, task_max_retries = ?, auto_disable_on_401 = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
""", (config.admin_username, config.admin_password, config.api_key, config.error_ban_threshold))
""", (config.admin_username, config.admin_password, config.api_key, config.error_ban_threshold,
config.task_retry_enabled, config.task_max_retries, config.auto_disable_on_401))
await db.commit()
# Proxy config operations
@@ -1028,14 +1306,26 @@ class Database:
# This should not happen in normal operation as _ensure_config_rows should create it
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"""
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:
await db.execute("""
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
""", (enabled, proxy_url))
""", (enabled, proxy_url, image_upload_proxy_enabled, image_upload_proxy_url))
await db.commit()
# Watermark-free config operations
@@ -1188,18 +1478,104 @@ class Database:
row_dict = dict(row)
if not row_dict.get("call_mode"):
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(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"""
normalized = "polling" if call_mode == "polling" else "default"
polling_mode_enabled = normalized == "polling"
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
await db.execute("""
UPDATE call_logic_config
SET polling_mode_enabled = ?, call_mode = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1
""", (polling_mode_enabled, normalized))
INSERT OR REPLACE INTO call_logic_config (id, call_mode, polling_mode_enabled, poll_interval, updated_at)
VALUES (1, ?, ?, ?, CURRENT_TIMESTAMP)
""", (normalized, polling_mode_enabled, effective_poll_interval))
await db.commit()
# POW proxy config operations
async def get_pow_proxy_config(self) -> "PowProxyConfig":
"""Get POW proxy configuration"""
from .models import PowProxyConfig
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM pow_proxy_config WHERE id = 1")
row = await cursor.fetchone()
if row:
return PowProxyConfig(**dict(row))
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):
"""Update POW proxy 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_proxy_config (id, pow_proxy_enabled, pow_proxy_url, updated_at)
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
""", (pow_proxy_enabled, pow_proxy_url))
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:
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
debug_logger = DebugLogger()

View File

@@ -40,6 +40,8 @@ class Token(BaseModel):
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
# 过期标记
is_expired: bool = False # Token是否已过期401 token_invalidated
# 禁用原因: manual=手动禁用, error_limit=错误次数超限, token_invalid=Token失效, expired=过期失效
disabled_reason: Optional[str] = None
class TokenStats(BaseModel):
"""Token statistics"""
@@ -100,6 +102,8 @@ class ProxyConfig(BaseModel):
id: int = 1
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
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
updated_at: Optional[datetime] = None
@@ -143,6 +147,27 @@ class CallLogicConfig(BaseModel):
id: int = 1
call_mode: str = "default" # "default" or "polling"
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
updated_at: Optional[datetime] = None
class PowProxyConfig(BaseModel):
"""POW proxy configuration"""
id: int = 1
pow_proxy_enabled: bool = False # Whether to enable POW proxy
pow_proxy_url: Optional[str] = None # POW proxy URL (e.g., http://127.0.0.1:7890)
created_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

View File

@@ -103,7 +103,7 @@ async def startup_event():
is_first_startup = not db.db_exists()
# Initialize database tables
await db.init_db()
await db.init_db(config_dict)
# Handle database initialization based on startup type
if is_first_startup:
@@ -142,7 +142,18 @@ async def startup_event():
# Load call logic configuration from database
call_logic_config = await db.get_call_logic_config()
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
all_tokens = await db.get_all_tokens()

View File

@@ -63,6 +63,17 @@ MODEL_CONFIG = {
"orientation": "portrait",
"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
"sora2-landscape-25s": {
"type": "video",
@@ -207,6 +218,12 @@ MODEL_CONFIG = {
"type": "prompt_enhance",
"expansion_level": "long",
"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
if "429" in error_str or "rate limit" in error_str:
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
@@ -299,6 +323,29 @@ class GenerationHandler:
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:
"""Remove remix link from prompt
@@ -429,9 +476,10 @@ class GenerationHandler:
raise ValueError(f"Invalid model: {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_prompt_enhance = model_config["type"] == "prompt_enhance"
is_avatar_create = model_config["type"] == "avatar_create"
# Handle prompt enhancement
if is_prompt_enhance:
@@ -445,40 +493,56 @@ class GenerationHandler:
if available:
if is_image:
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:
message = "All tokens available for video generation. Please enable streaming to use the generation feature."
else:
if is_image:
message = "No available models for image generation"
elif is_avatar_create:
message = "No available tokens for avatar creation"
else:
message = "No available models for video generation"
yield self._format_non_stream_response(message, is_availability_check=True)
return
# Handle character creation and remix flows for video models
if is_video:
# Handle avatar creation model (character creation only)
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
if remix_target_id:
async for chunk in self._handle_remix(remix_target_id, prompt, model_config):
yield chunk
return
# Character creation flow: video provided
# Character creation has been isolated into avatar-create model
if video:
# Decode video if it's base64
video_data = self._decode_base64_video(video) if video.startswith("data:") or not video.startswith("http") else video
raise Exception("角色创建已独立为 avatar-create 模型,请切换模型后重试。")
# If no prompt, just create character and return
if not prompt:
async for chunk in self._handle_character_creation_only(video_data, model_config):
yield chunk
return
else:
# If prompt provided, create character and generate video
async for chunk in self._handle_character_and_video_generation(video_data, prompt, model_config):
yield chunk
return
# Handle video extension flow
if model_config.get("mode") == "video_extension":
async for chunk in self._handle_video_extension(prompt, model_config, model):
yield chunk
return
# Streaming mode: proceed with actual generation
# Check if model requires Pro subscription
@@ -521,6 +585,7 @@ class GenerationHandler:
task_id = None
is_first_chunk = True # Track if this is the first chunk
log_id = None # Initialize log_id
log_updated = False # Track if log has been updated
try:
# Create initial log entry BEFORE submitting task to upstream
@@ -546,7 +611,11 @@ class GenerationHandler:
is_first_chunk = False
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:
yield self._format_stream_chunk(
@@ -680,6 +749,7 @@ class GenerationHandler:
status_code=200,
duration=duration
)
log_updated = True # Mark log as updated
except Exception as e:
# Release lock for image generation on error
@@ -727,6 +797,7 @@ class GenerationHandler:
status_code=status_code,
duration=duration
)
log_updated = True # Mark log as updated
else:
# Generic error
await self.db.update_request_log(
@@ -735,12 +806,35 @@ class GenerationHandler:
status_code=500,
duration=duration
)
log_updated = True # Mark log as updated
# Wrap exception with token_id information
if token_obj:
raise GenerationError(str(e), token_id=token_obj.id)
else:
raise e
finally:
# Ensure log is updated even if exception handling fails
# This prevents logs from being stuck at status_code = -1
if log_id and not log_updated:
try:
# Log was not updated in try or except blocks, update it now
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
)
debug_logger.log_info(f"Updated stuck log entry {log_id} from status -1 to 500 in finally block")
except Exception as finally_error:
# Don't let finally block errors break the flow
debug_logger.log_error(
error_message=f"Failed to update log in finally block: {str(finally_error)}",
status_code=500,
response_text=str(finally_error)
)
async def handle_generation_with_retry(self, model: str, prompt: str,
image: Optional[str] = None,
video: Optional[str] = None,
@@ -771,7 +865,15 @@ class GenerationHandler:
# Try generation
# Only show init message on first attempt (not on retries)
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
# If successful, return
return
@@ -924,7 +1026,7 @@ class GenerationHandler:
last_status_output_time = current_time
debug_logger.log_info(f"Task {task_id} progress: {progress_pct}% (status: {status})")
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
@@ -1614,7 +1716,11 @@ class GenerationHandler:
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)
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
@@ -1643,6 +1749,17 @@ class GenerationHandler:
# Log successful character creation
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(
token_id=token_obj.id,
operation="character_only",
@@ -1652,18 +1769,31 @@ class GenerationHandler:
},
response_data={
"success": True,
"username": username,
"display_name": display_name,
"character_id": character_id,
"cameo_id": cameo_id
"card": character_card
},
status_code=200,
duration=duration
)
# Step 7: Return success message
# Step 7: Return structured character card
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"
)
yield "data: [DONE]\n\n"
@@ -1715,6 +1845,189 @@ class GenerationHandler:
)
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]:
"""Handle character creation and video generation
@@ -1795,7 +2108,11 @@ class GenerationHandler:
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)
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
@@ -2044,6 +2361,169 @@ class GenerationHandler:
)
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]:
"""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 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"""
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:
"""Get proxy configuration"""

File diff suppressed because it is too large Load Diff

View File

@@ -946,19 +946,21 @@ class TokenManager:
async def update_token_status(self, token_id: int, is_active: bool):
"""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):
"""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)
await self.db.reset_error_count(token_id)
# Clear expired flag when enabling
await self.db.clear_token_expired(token_id)
async def disable_token(self, token_id: int):
"""Disable a token"""
await self.db.update_token_status(token_id, False)
"""Disable a token (manual disable)"""
await self.db.update_token_status(token_id, False, "manual")
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)"""
@@ -1048,6 +1050,14 @@ class TokenManager:
"valid": False,
"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 {
"valid": False,
"message": f"Token is invalid: {error_msg}"
@@ -1077,7 +1087,8 @@ class TokenManager:
admin_config = await self.db.get_admin_config()
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):
"""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-10s">竖屏视频 10 秒 (Pro HD)</option>
</optgroup>
<optgroup label="角色创建">
<option value="avatar-create">角色创建(视频优先 / 支持提示词generation_id</option>
</optgroup>
<optgroup label="图片">
<option value="gpt-image">方图 360×360</option>
<option value="gpt-image-landscape">横图 540×360</option>

View File

@@ -4771,9 +4771,13 @@
}
});
} else if (batchType === 'character') {
// 角色卡模式:只需要视频文件,不需要提示词
if (model !== 'avatar-create') {
showToast('角色卡模式请先切换模型为“角色创建(视频优先 / 支持提示词generation_id/avatar-create”', 'warn', { title: '模型不匹配', duration: 4200 });
return;
}
// 角色卡模式:只使用视频文件(提示词内 generation_id 请走普通模式)
if (!files.length) {
showToast('角色卡模式:请上传视频文件', 'warn', { title: '缺少视频', duration: 3600 });
showToast('角色卡模式:请上传视频文件提示词generation_id请用普通模式', 'warn', { title: '缺少视频', duration: 3600 });
return;
}
const videoFile = files.find((f) => (f.type || '').startsWith('video'));

View File

@@ -79,7 +79,27 @@
<!-- Token 列表 -->
<div class="rounded-lg border border-border bg-background">
<div class="flex items-center justify-between gap-4 p-4 border-b border-border">
<h3 class="text-lg font-semibold">Token 列表</h3>
<div class="flex items-center gap-2">
<h3 class="text-lg font-semibold">Token 列表</h3>
<!-- 状态筛选按钮 -->
<div class="relative">
<button id="statusFilterBtn" onclick="toggleStatusFilterDropdown()" class="inline-flex items-center justify-center gap-1.5 rounded-md border border-input bg-background px-3 h-8 text-sm transition-colors hover:bg-accent hover:border-primary" title="筛选状态">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
<span id="statusFilterLabel">全部</span>
<svg class="h-3 w-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
<!-- 下拉菜单 -->
<div id="statusFilterDropdown" class="hidden absolute left-0 mt-2 w-32 rounded-md border border-border bg-background shadow-lg z-50">
<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>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- 自动刷新AT标签和开关 -->
<div class="flex items-center gap-2">
@@ -354,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">
<p class="text-xs text-muted-foreground mt-1">支持 HTTP 和 SOCKS5 代理</p>
</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>
<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">
@@ -370,6 +412,55 @@
</div>
</div>
<!-- POW配置 -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-lg font-semibold mb-4">POW配置</h3>
<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>
<label class="inline-flex items-center gap-2 cursor-pointer">
<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>
</label>
<p class="text-xs text-muted-foreground mt-1">获取 Sentinel Token 时使用的代理</p>
</div>
<div id="powProxyUrlField" style="display: none;">
<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">
<p class="text-xs text-muted-foreground mt-1">用于获取 POW Token 的代理地址</p>
</div>
<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 class="rounded-lg border border-border bg-background p-6">
<h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
@@ -516,6 +607,11 @@
</select>
<p class="text-xs text-muted-foreground mt-2">随机轮询:随机选择可用账号;逐个轮询:每个活跃账号只使用一次,全部使用过后再开始下一轮</p>
</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>
</div>
</div>
@@ -840,11 +936,41 @@
</button>
</div>
<div class="p-5 space-y-4">
<div>
<div id="jsonFileSection">
<label class="text-sm font-medium mb-2 block">选择 JSON 文件</label>
<input type="file" id="importFile" accept=".json" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<p class="text-xs text-muted-foreground mt-1">选择导出的 Token JSON 文件进行导入</p>
</div>
<!-- 纯RT导入输入框区域 -->
<div id="pureRtSection" class="hidden space-y-2.5">
<div>
<label class="text-sm font-medium mb-1.5 block">Refresh Token 列表</label>
<textarea id="pureRtInput" rows="3" placeholder="每行一个 RT" class="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm resize-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-2.5">
<div>
<label class="text-sm font-medium mb-1.5 block">Client ID(可选)</label>
<input type="text" id="pureRtClientId" placeholder="留空使用默认值" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
</div>
<div>
<label class="text-sm font-medium mb-1.5 block">代理地址(可选)</label>
<input type="text" id="pureRtProxy" placeholder="http://127.0.0.1:7890" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
</div>
</div>
<div class="grid grid-cols-2 gap-2.5">
<div>
<label class="text-sm font-medium mb-1.5 block">图片并发</label>
<input type="number" id="pureRtImageConcurrency" value="1" min="-1" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
</div>
<div>
<label class="text-sm font-medium mb-1.5 block">视频并发</label>
<input type="number" id="pureRtVideoConcurrency" value="3" min="-1" class="flex h-8 w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm">
</div>
</div>
<p class="text-xs text-muted-foreground">💡 提示:自动刷新并批量导入,并发 -1 表示不限制</p>
</div>
<div>
<label class="text-sm font-medium mb-2 block">选择导入模式</label>
<select id="importMode" onchange="updateImportModeHint()" class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
@@ -852,10 +978,11 @@
<option value="offline">离线导入(不更新账号状态)</option>
<option value="st">优先使用ST导入</option>
<option value="rt">优先使用RT导入</option>
<option value="pure_rt">纯RT导入</option>
</select>
<p id="importModeHint" class="text-xs text-muted-foreground mt-1">使用AT更新账号状态订阅信息、Sora2次数等</p>
</div>
<div class="rounded-md bg-gray-50 dark:bg-gray-900/20 p-3 border border-gray-200 dark:border-gray-800">
<div id="importModeHelpSection" class="rounded-md bg-gray-50 dark:bg-gray-900/20 p-3 border border-gray-200 dark:border-gray-800">
<p class="text-xs font-semibold text-gray-900 dark:text-gray-100 mb-2">📋 导入模式说明</p>
<div class="space-y-1.5 text-xs text-gray-700 dark:text-gray-300">
<div class="flex items-start gap-2">
@@ -874,6 +1001,10 @@
<span class="font-medium min-w-[100px]">RT导入</span>
<span>适用于只有RT没有AT自动转换为AT</span>
</div>
<div class="flex items-start gap-2">
<span class="font-medium min-w-[100px]">纯RT导入</span>
<span>手动输入RT列表一行一个自动转换并批量导入</span>
</div>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
💡 提示:离线导入后可使用"测试"按钮更新账号信息功能不稳定有bug问猫猫
@@ -957,21 +1088,29 @@
</div>
<script>
let allTokens=[],currentPage=1,pageSize=20,selectedTokenIds=new Set();
let allTokens=[],currentPage=1,pageSize=20,selectedTokenIds=new Set(),currentStatusFilter='';
const $=(id)=>document.getElementById(id),
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},
// 获取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)}},
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();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 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||'全部'}},
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
applyStatusFilter=()=>{currentPage=1;renderTokens()},
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}`},
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>`},
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 start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=allTokens.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()},
changePage=(page)=>{currentPage=page;renderTokens()},
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
renderPagination=()=>{const totalPages=Math.ceil(allTokens.length/pageSize);const container=$('paginationContainer');if(!container)return;let html='<div class="flex items-center justify-between px-4 py-3 border-t border-border"><div class="flex items-center gap-2"><span class="text-sm text-muted-foreground">每页显示</span><select onchange="changePageSize(this.value)" class="h-8 rounded-md border border-input bg-background px-2 text-sm"><option value="20"'+(pageSize===20?' selected':'')+'>20</option><option value="50"'+(pageSize===50?' selected':'')+'>50</option><option value="100"'+(pageSize===100?' selected':'')+'>100</option><option value="200"'+(pageSize===200?' selected':'')+'>200</option><option value="500"'+(pageSize===500?' selected':'')+'>500</option></select><span class="text-sm text-muted-foreground">共 '+allTokens.length+'</span></div><div class="flex items-center gap-2">';if(totalPages>1){html+='<button onclick="changePage(1)" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">首页</button>';html+='<button onclick="changePage('+(currentPage-1)+')" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">上一页</button>';html+='<span class="text-sm text-muted-foreground">第 '+currentPage+' / '+totalPages+' 页</span>';html+='<button onclick="changePage('+(currentPage+1)+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">下一页</button>';html+='<button onclick="changePage('+totalPages+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">末页</button>'}html+='</div></div>';container.innerHTML=html},
renderPagination=()=>{const filteredTokens=getFilteredTokens();const totalPages=Math.ceil(filteredTokens.length/pageSize);const container=$('paginationContainer');if(!container)return;let html='<div class="flex items-center justify-between px-4 py-3 border-t border-border"><div class="flex items-center gap-2"><span class="text-sm text-muted-foreground">每页显示</span><select onchange="changePageSize(this.value)" class="h-8 rounded-md border border-input bg-background px-2 text-sm"><option value="20"'+(pageSize===20?' selected':'')+'>20</option><option value="50"'+(pageSize===50?' selected':'')+'>50</option><option value="100"'+(pageSize===100?' selected':'')+'>100</option><option value="200"'+(pageSize===200?' selected':'')+'>200</option><option value="500"'+(pageSize===500?' selected':'')+'>500</option></select><span class="text-sm text-muted-foreground">共 '+filteredTokens.length+' 条'+(currentStatusFilter?' (筛选后)':' (总数: '+allTokens.length+')')+'</span></div><div class="flex items-center gap-2">';if(totalPages>1){html+='<button onclick="changePage(1)" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">首页</button>';html+='<button onclick="changePage('+(currentPage-1)+')" '+(currentPage===1?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">上一页</button>';html+='<span class="text-sm text-muted-foreground">第 '+currentPage+' / '+totalPages+' 页</span>';html+='<button onclick="changePage('+(currentPage+1)+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">下一页</button>';html+='<button onclick="changePage('+totalPages+')" '+(currentPage===totalPages?'disabled':'')+' class="inline-flex items-center justify-center rounded-md border border-input bg-background hover:bg-accent h-8 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed">末页</button>'}html+='</div></div>';container.innerHTML=html},
openAddModal=()=>$('addModal').classList.remove('hidden'),
closeAddModal=()=>{$('addModal').classList.add('hidden');$('addTokenAT').value='';$('addTokenST').value='';$('addTokenRT').value='';$('addTokenClientId').value='';$('addTokenProxyUrl').value='';$('addTokenRemark').value='';$('addTokenImageEnabled').checked=true;$('addTokenVideoEnabled').checked=true;$('addTokenImageConcurrency').value='1';$('addTokenVideoConcurrency').value='3';$('addRTRefreshHint').classList.add('hidden')},
openEditModal=(id)=>{const token=allTokens.find(t=>t.id===id);if(!token)return showToast('Token不存在','error');$('editTokenId').value=token.id;$('editTokenAT').value=token.token||'';$('editTokenST').value=token.st||'';$('editTokenRT').value=token.rt||'';$('editTokenClientId').value=token.client_id||'';$('editTokenProxyUrl').value=token.proxy_url||'';$('editTokenRemark').value=token.remark||'';$('editTokenImageEnabled').checked=token.image_enabled!==false;$('editTokenVideoEnabled').checked=token.video_enabled!==false;$('editTokenImageConcurrency').value=token.image_concurrency||'-1';$('editTokenVideoConcurrency').value=token.video_concurrency||'-1';$('editModal').classList.remove('hidden')},
@@ -993,7 +1132,7 @@
openBatchProxyModal=()=>{if(selectedTokenIds.size===0){showToast('请先选择要修改的Token','info');return}$('batchProxyCount').textContent=selectedTokenIds.size;$('batchProxyUrl').value='';$('batchProxyModal').classList.remove('hidden')},
closeBatchProxyModal=()=>{$('batchProxyModal').classList.add('hidden');$('batchProxyUrl').value=''},
submitBatchProxy=async()=>{const proxyUrl=$('batchProxyUrl').value.trim();const btn=$('batchProxyBtn'),btnText=$('batchProxyBtnText'),btnSpinner=$('batchProxyBtnSpinner');btn.disabled=true;btnText.textContent='修改中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/batch/update-proxy',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds),proxy_url:proxyUrl})});if(!r){btn.disabled=false;btnText.textContent='确认修改';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeBatchProxyModal();selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('修改失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('修改失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='确认修改';btnSpinner.classList.add('hidden')}},
showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`<div class="text-sm"><strong>导入完成</strong><br/>新增: ${added} | 更新: ${updated} | 失败: ${failed}</div>`;list.innerHTML=results.map(r=>{const statusColor=r.success?(r.status==='added'?'text-green-600':'text-blue-600'):'text-red-600';const statusText=r.status==='added'?'新增':r.status==='updated'?'更新':'失败';return`<div class="p-3 rounded-md border ${r.success?'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800':'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800'}"><div class="flex items-center justify-between"><span class="font-medium">${r.email}</span><span class="${statusColor} text-sm font-medium">${statusText}</span></div>${r.error?`<div class="text-xs text-red-600 dark:text-red-400 mt-1">${r.error}</div>`:''}</div>`}).join('');openImportProgressModal()},
showImportProgress=(results,added,updated,failed)=>{const summary=$('importProgressSummary'),list=$('importProgressList');summary.innerHTML=`<div class="text-sm"><strong>导入完成</strong><br/>新增: ${added} | 更新: ${updated} | 失败: ${failed}</div>`;list.innerHTML=results.map(r=>{const isFailed=r.status==='failed';const isAdded=r.status==='added';const statusColor=isFailed?'text-red-600':(isAdded?'text-green-600':'text-blue-600');const statusText=isAdded?'新增':(r.status==='updated'?'更新':'失败');const bgColor=isFailed?'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800':(isAdded?'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800':'bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800');const errorMsg=r.message&&isFailed?r.message:(r.error||'');return`<div class="p-3 rounded-md border ${bgColor}"><div class="flex items-center justify-between"><span class="font-medium">${r.email}</span><span class="${statusColor} text-sm font-medium">${statusText}</span></div>${errorMsg?`<div class="text-xs text-red-600 dark:text-red-400 mt-1 whitespace-pre-wrap">${errorMsg}</div>`:''}</div>`}).join('');openImportProgressModal()},
exportTokens=()=>{if(allTokens.length===0){showToast('没有Token可导出','error');return}const exportData=allTokens.map(t=>({email:t.email,access_token:t.token,session_token:t.st||null,refresh_token:t.rt||null,client_id:t.client_id||null,proxy_url:t.proxy_url||null,remark:t.remark||null,is_active:t.is_active,image_enabled:t.image_enabled!==false,video_enabled:t.video_enabled!==false,image_concurrency:t.image_concurrency||(-1),video_concurrency:t.video_concurrency||(-1)}));const dataStr=JSON.stringify(exportData,null,2);const dataBlob=new Blob([dataStr],{type:'application/json'});const url=URL.createObjectURL(dataBlob);const link=document.createElement('a');link.href=url;link.download=`tokens_${new Date().toISOString().split('T')[0]}.json`;document.body.appendChild(link);link.click();document.body.removeChild(link);URL.revokeObjectURL(url);showToast(`已导出 ${allTokens.length} 个Token`,'success')},
batchTestUpdate=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要测试的Token','info');return}if(!confirm(`⚠️ 警告\n\n此操作将请求上游获取选中的 ${selectedTokenIds.size} 个Token的状态信息可能需要较长时间。\n\n确定要继续吗?`)){return}showToast('正在测试更新选中的Token...','info');try{const r=await apiRequest('/api/tokens/batch/test-update',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('测试更新失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('测试更新失败: '+e.message,'error')}},
batchEnableAll=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要启用的Token','info');return}if(!confirm(`确定要启用选中的 ${selectedTokenIds.size} 个Token吗\n\n此操作将重置这些Token的错误计数。`)){return}showToast('正在批量启用Token...','info');try{const r=await apiRequest('/api/tokens/batch/enable-all',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量启用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量启用失败: '+e.message,'error')}},
@@ -1002,18 +1141,18 @@
toggleSelectAll=()=>{const checkbox=$('selectAllCheckbox');const checkboxes=document.querySelectorAll('.token-checkbox');if(checkbox.checked){checkboxes.forEach(cb=>{cb.checked=true;const tokenId=parseInt(cb.getAttribute('data-token-id'));selectedTokenIds.add(tokenId)})}else{checkboxes.forEach(cb=>{cb.checked=false});selectedTokenIds.clear()}},
toggleTokenSelection=(tokenId,checked)=>{if(checked){selectedTokenIds.add(tokenId)}else{selectedTokenIds.delete(tokenId)}const allCheckboxes=document.querySelectorAll('.token-checkbox');const allChecked=Array.from(allCheckboxes).every(cb=>cb.checked);$('selectAllCheckbox').checked=allChecked},
batchDisableSelected=async()=>{if(selectedTokenIds.size===0){showToast('请先选择要禁用的Token','info');return}if(!confirm(`确定要禁用选中的 ${selectedTokenIds.size} 个Token吗`)){return}showToast('正在批量禁用Token...','info');try{const r=await apiRequest('/api/tokens/batch/disable-selected',{method:'POST',body:JSON.stringify({token_ids:Array.from(selectedTokenIds)})});if(!r)return;const d=await r.json();if(d.success){selectedTokenIds.clear();await refreshTokens();showToast(d.message,'success')}else{showToast('批量禁用失败: '+(d.detail||'未知错误'),'error')}}catch(e){showToast('批量禁用失败: '+e.message,'error')}},
updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),hints={at:'使用AT更新账号状态订阅信息、Sora2次数等',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT然后更新账号状态',rt:'自动将RT转换为AT并刷新RT然后更新账号状态'};hint.textContent=hints[mode]||''},
submitImportTokens=async()=>{const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}const mode=$('importMode').value;try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}for(let item of importData){if(!item.email){showToast('导入数据缺少必填字段: email','error');return}if(mode==='offline'||mode==='at'){if(!item.access_token){showToast(`${item.email} 缺少必填字段: access_token`,'error');return}}else if(mode==='st'){if(!item.session_token){showToast(`${item.email} 缺少必填字段: session_token`,'error');return}}else if(mode==='rt'){if(!item.refresh_token){showToast(`${item.email} 缺少必填字段: refresh_token`,'error');return}}}const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData,mode:mode})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}},
updateImportModeHint=()=>{const mode=$('importMode').value,hint=$('importModeHint'),jsonSection=$('jsonFileSection'),pureRtSection=$('pureRtSection'),helpSection=$('importModeHelpSection'),hints={at:'使用AT更新账号状态订阅信息、Sora2次数等',offline:'离线导入,不更新账号状态,动态字段显示为-',st:'自动将ST转换为AT然后更新账号状态',rt:'自动将RT转换为AT并刷新RT然后更新账号状态',pure_rt:'手动输入RT列表自动刷新并批量导入'};hint.textContent=hints[mode]||'';if(mode==='pure_rt'){jsonSection.classList.add('hidden');pureRtSection.classList.remove('hidden');helpSection.classList.add('hidden')}else{jsonSection.classList.remove('hidden');pureRtSection.classList.add('hidden');helpSection.classList.remove('hidden')}},
submitImportTokens=async()=>{const mode=$('importMode').value;const btn=$('importBtn'),btnText=$('importBtnText'),btnSpinner=$('importBtnSpinner');if(mode==='pure_rt'){const rtInput=$('pureRtInput').value.trim();if(!rtInput){showToast('请输入 Refresh Token','error');return}const clientId=$('pureRtClientId').value.trim()||'app_LlGpXReQgckcGGUo2JrYvtJK';const proxy=$('pureRtProxy').value.trim()||null;const imageConcurrency=parseInt($('pureRtImageConcurrency').value)||1;const videoConcurrency=parseInt($('pureRtVideoConcurrency').value)||3;const rtList=rtInput.split('\n').map(rt=>rt.trim()).filter(rt=>rt.length>0);if(rtList.length===0){showToast('请输入至少一个 Refresh Token','error');return}btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import/pure-rt',{method:'POST',body:JSON.stringify({refresh_tokens:rtList,client_id:clientId,proxy_url:proxy,image_concurrency:imageConcurrency,video_concurrency:videoConcurrency})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}return}const fileInput=$('importFile');if(!fileInput.files||fileInput.files.length===0){showToast('请选择文件','error');return}const file=fileInput.files[0];if(!file.name.endsWith('.json')){showToast('请选择JSON文件','error');return}try{const fileContent=await file.text();const importData=JSON.parse(fileContent);if(!Array.isArray(importData)){showToast('JSON格式错误应为数组','error');return}if(importData.length===0){showToast('JSON文件为空','error');return}for(let item of importData){if(!item.email){showToast('导入数据缺少必填字段: email','error');return}if(mode==='offline'||mode==='at'){if(!item.access_token){showToast(`${item.email} 缺少必填字段: access_token`,'error');return}}else if(mode==='st'){if(!item.session_token){showToast(`${item.email} 缺少必填字段: session_token`,'error');return}}else if(mode==='rt'){if(!item.refresh_token){showToast(`${item.email} 缺少必填字段: refresh_token`,'error');return}}}btn.disabled=true;btnText.textContent='导入中...';btnSpinner.classList.remove('hidden');try{const r=await apiRequest('/api/tokens/import',{method:'POST',body:JSON.stringify({tokens:importData,mode:mode})});if(!r){btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden');return}const d=await r.json();if(d.success){closeImportModal();await refreshTokens();showImportProgress(d.results||[],d.added||0,d.updated||0,d.failed||0)}else{showToast('导入失败: '+(d.detail||d.message||'未知错误'),'error')}}catch(e){showToast('导入失败: '+e.message,'error')}finally{btn.disabled=false;btnText.textContent='导入';btnSpinner.classList.add('hidden')}}catch(e){showToast('文件解析失败: '+e.message,'error')}},
loadAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config');if(!r)return;const d=await r.json();$('cfgErrorBan').value=d.error_ban_threshold||3;$('cfgTaskRetryEnabled').checked=d.task_retry_enabled||false;$('cfgTaskMaxRetries').value=d.task_max_retries||3;$('cfgAutoDisableOn401').checked=d.auto_disable_on_401||false;$('cfgAdminUsername').value=d.admin_username||'admin';$('cfgCurrentAPIKey').value=d.api_key||'';$('cfgDebugEnabled').checked=d.debug_enabled||false}catch(e){console.error('加载配置失败:',e)}},
saveAdminConfig=async()=>{try{const r=await apiRequest('/api/admin/config',{method:'POST',body:JSON.stringify({error_ban_threshold:parseInt($('cfgErrorBan').value)||3,task_retry_enabled:$('cfgTaskRetryEnabled').checked,task_max_retries:parseInt($('cfgTaskMaxRetries').value)||3,auto_disable_on_401:$('cfgAutoDisableOn401').checked})});if(!r)return;const d=await r.json();d.success?showToast('配置保存成功','success'):showToast('保存失败','error')}catch(e){showToast('保存失败: '+e.message,'error')}},
updateAdminPassword=async()=>{const username=$('cfgAdminUsername').value.trim(),oldPwd=$('cfgOldPassword').value.trim(),newPwd=$('cfgNewPassword').value.trim();if(!oldPwd||!newPwd)return showToast('请输入旧密码和新密码','error');if(newPwd.length<4)return showToast('新密码至少4个字符','error');try{const r=await apiRequest('/api/admin/password',{method:'POST',body:JSON.stringify({username:username||undefined,old_password:oldPwd,new_password:newPwd})});if(!r)return;const d=await r.json();if(d.success){showToast('密码修改成功,请重新登录','success');setTimeout(()=>{localStorage.removeItem('adminToken');location.href='/login'},2000)}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}},
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')},
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)}},
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'},
@@ -1025,9 +1164,9 @@
saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}},
loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}},
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>{const isProcessing=l.status_code===-1&&l.task_status==='processing';const isFailed=l.task_status==='failed';const isCompleted=l.task_status==='completed';const statusText=isProcessing?'处理中':l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='<span class="text-xs text-muted-foreground">-</span>';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`<div class="flex flex-col gap-1"><div class="flex items-center gap-2"><div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden"><div class="h-full bg-blue-500 transition-all" style="width:${progress}%"></div></div><span class="text-xs text-blue-600">${progress.toFixed(0)}%</span></div><span class="text-xs text-muted-foreground">${taskStatusText}</span></div>`}else if(isFailed){progressHtml='<span class="text-xs text-red-600">失败</span>'}else if(isCompleted&&l.status_code===200){progressHtml='<span class="text-xs text-green-600">已完成</span>'}let actionHtml='<button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button>';if(isProcessing&&l.task_id){actionHtml='<div class="flex gap-1"><button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button><button onclick="cancelTask(\''+l.task_id+'\')" class="inline-flex items-center justify-center rounded-md hover:bg-red-50 hover:text-red-700 h-7 px-2 text-xs">终止</button></div>'}return `<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></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">${progressHtml}</td><td class="py-2.5 px-3">${l.duration===-1?'处理中':l.duration.toFixed(2)+'秒'}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3">${actionHtml}</td></tr>`}).join('')}catch(e){console.error('加载日志失败:',e)}},
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>{const isProcessing=l.status_code===-1;const isFailed=l.task_status==='failed';const isCompleted=l.task_status==='completed';const progress=l.progress||0;const statusText=isProcessing?(progress>0?'生成中':'排队中'):l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='<span class="text-xs text-muted-foreground">-</span>';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`<div class="flex flex-col gap-1"><div class="flex items-center gap-2"><div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden"><div class="h-full bg-blue-500 transition-all" style="width:${progress}%"></div></div><span class="text-xs text-blue-600">${progress.toFixed(0)}%</span></div><span class="text-xs text-muted-foreground">${taskStatusText}</span></div>`}else if(isFailed){progressHtml='<span class="text-xs text-red-600">失败</span>'}else if(isCompleted&&l.status_code===200){progressHtml='<span class="text-xs text-green-600">已完成</span>'}let actionHtml='<button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button>';if(isProcessing&&l.task_id){actionHtml='<div class="flex gap-1"><button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button><button onclick="cancelTask(\''+l.task_id+'\')" class="inline-flex items-center justify-center rounded-md hover:bg-red-50 hover:text-red-700 h-7 px-2 text-xs">终止</button></div>'}return `<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></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">${progressHtml}</td><td class="py-2.5 px-3">${l.duration===-1?'处理中':l.duration.toFixed(2)+'秒'}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3">${actionHtml}</td></tr>`}).join('')}catch(e){console.error('加载日志失败:',e)}},
refreshLogs=async()=>{await loadLogs()},
showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===-1){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-blue-600">生成进度</h4><div class="rounded-md border border-blue-200 p-3 bg-blue-50"><p class="text-sm text-blue-700">任务正在生成中...</p>${log.task_status?`<p class="text-xs text-blue-600 mt-1">状态: ${log.task_status}</p>`:''}</div></div>`}else if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${item.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${item.url}</a></div></div>`}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应信息</h4><p class="text-sm text-muted-foreground">无响应数据</p></div>`}}catch(e){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${log.response_body||'无'}</pre></div>`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.error){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误原因</h4><div class="rounded-md border border-red-200 p-3 bg-red-50"><p class="text-sm text-red-700">${responseBody.error.message||responseBody.error||'未知错误'}</p></div></div>`}else if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}catch(e){if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}}detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${log.operation}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===-1?'bg-blue-50 text-blue-700':log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code===-1?'生成中':log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${log.duration===-1?'生成中':log.duration.toFixed(2)+'秒'}</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')},
showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===-1){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-blue-600">生成进度</h4><div class="rounded-md border border-blue-200 p-3 bg-blue-50"><p class="text-sm text-blue-700">任务正在生成中...</p>${log.task_status?`<p class="text-xs text-blue-600 mt-1">状态: ${log.task_status}</p>`:''}</div></div>`}else if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${item.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${item.url}</a></div></div>`}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应信息</h4><p class="text-sm text-muted-foreground">无响应数据</p></div>`}}catch(e){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${log.response_body||'无'}</pre></div>`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.error){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误原因</h4><div class="rounded-md border border-red-200 p-3 bg-red-50"><p class="text-sm text-red-700">${responseBody.error.message||responseBody.error||'未知错误'}</p></div></div>`}else if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}catch(e){if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}}detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${log.operation}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===-1?'bg-blue-50 text-blue-700':log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code===-1?((log.progress||0)>0?'生成中':'排队中'):log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${log.duration===-1?'生成中':log.duration.toFixed(2)+'秒'}</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')},
closeLogDetailModal=()=>{$('logDetailModal').classList.add('hidden')},
clearAllLogs=async()=>{if(!confirm('确定要清空所有日志吗?此操作不可恢复!'))return;try{const r=await apiRequest('/api/logs',{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('日志已清空','success');await loadLogs()}else{showToast('清空失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('清空失败: '+e.message,'error')}},
cancelTask=async(taskId)=>{if(!confirm('确定要终止这个任务吗?'))return;try{const r=await apiRequest(`/api/tasks/${taskId}/cancel`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast('任务已终止','success');await loadLogs()}else{showToast('终止失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('终止失败: '+e.message,'error')}},
@@ -1035,9 +1174,15 @@
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')}},
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)}},
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')}},
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();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig();loadCallLogicConfig()}else if(t==='logs'){loadLogs()}};
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 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')}},
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)}},
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')}},
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 高度
window.addEventListener('message', (event) => {
const data = event.data || {};
@@ -1049,6 +1194,15 @@
}
}
});
// 点击页面其他地方关闭筛选下拉菜单
document.addEventListener('click', (e) => {
const dropdown = $('statusFilterDropdown');
const btn = $('statusFilterBtn');
if (!dropdown || !btn) return;
if (!dropdown.classList.contains('hidden') && !btn.contains(e.target) && !dropdown.contains(e.target)) {
dropdown.classList.add('hidden');
}
});
window.addEventListener('DOMContentLoaded',()=>{checkAuth();refreshTokens();loadATAutoRefreshConfig()});
</script>
</body>