mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-04-15 06:37:28 +08:00
Compare commits
12 Commits
50e004d722
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d91b31a7 | ||
|
|
ad554d900a | ||
|
|
404cbd44f0 | ||
|
|
8b406e4e5c | ||
|
|
29fddfa85b | ||
|
|
5a0ccbe2de | ||
|
|
fc95de0f28 | ||
|
|
92015882cc | ||
|
|
5570fa35a6 | ||
|
|
06c2bea806 | ||
|
|
576310c50c | ||
|
|
dab1f13310 |
33
Dockerfile
33
Dockerfile
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,3 +13,4 @@ toml
|
||||
faker==24.0.0
|
||||
python-dateutil==2.8.2
|
||||
APScheduler==3.10.4
|
||||
playwright==1.48.0
|
||||
295
src/api/admin.py
295
src/api/admin.py
@@ -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": "用户手动取消任务"}',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
15
src/main.py
15
src/main.py
@@ -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()
|
||||
|
||||
@@ -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_id(gen_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_id(gen_xxx)。示例:gen_xxx 流星雨")
|
||||
|
||||
clean_prompt = self._clean_generation_id_from_prompt(prompt or "")
|
||||
if not clean_prompt:
|
||||
raise Exception("视频续写模型需要提供续写提示词。示例:gen_xxx 流星雨")
|
||||
|
||||
extension_duration_s = model_config.get("extension_duration_s", 10)
|
||||
if extension_duration_s not in [10, 15]:
|
||||
raise Exception("extension_duration_s 仅支持 10 或 15")
|
||||
|
||||
yield self._format_stream_chunk(
|
||||
reasoning_content=(
|
||||
f"Submitting extension task...\n"
|
||||
f"- generation_id: {generation_id}\n"
|
||||
f"- extension_duration_s: {extension_duration_s}\n\n"
|
||||
)
|
||||
)
|
||||
|
||||
task_id = await self.sora_client.extend_video(
|
||||
generation_id=generation_id,
|
||||
prompt=clean_prompt,
|
||||
extension_duration_s=extension_duration_s,
|
||||
token=token_obj.token,
|
||||
token_id=token_obj.id
|
||||
)
|
||||
debug_logger.log_info(f"Video extension started, task_id: {task_id}")
|
||||
|
||||
task = Task(
|
||||
task_id=task_id,
|
||||
token_id=token_obj.id,
|
||||
model=model_name,
|
||||
prompt=f"extend:{generation_id} {clean_prompt}",
|
||||
status="processing",
|
||||
progress=0.0
|
||||
)
|
||||
await self.db.create_task(task)
|
||||
if log_id:
|
||||
await self.db.update_request_log_task_id(log_id, task_id)
|
||||
|
||||
await self.token_manager.record_usage(token_obj.id, is_video=True)
|
||||
|
||||
async for chunk in self._poll_task_result(task_id, token_obj.token, True, True, clean_prompt, token_obj.id):
|
||||
yield chunk
|
||||
|
||||
await self.token_manager.record_success(token_obj.id, is_video=True)
|
||||
|
||||
# Update request log on success
|
||||
if log_id:
|
||||
duration = time.time() - start_time
|
||||
task_info = await self.db.get_task(task_id)
|
||||
response_data = {
|
||||
"task_id": task_id,
|
||||
"status": "success",
|
||||
"model": model_name,
|
||||
"prompt": clean_prompt,
|
||||
"generation_id": generation_id,
|
||||
"extension_duration_s": extension_duration_s
|
||||
}
|
||||
if task_info and task_info.result_urls:
|
||||
try:
|
||||
response_data["result_urls"] = json.loads(task_info.result_urls)
|
||||
except:
|
||||
response_data["result_urls"] = task_info.result_urls
|
||||
|
||||
await self.db.update_request_log(
|
||||
log_id,
|
||||
response_body=json.dumps(response_data),
|
||||
status_code=200,
|
||||
duration=duration
|
||||
)
|
||||
log_updated = True
|
||||
|
||||
except Exception as e:
|
||||
error_response = None
|
||||
try:
|
||||
error_response = json.loads(str(e))
|
||||
except:
|
||||
pass
|
||||
|
||||
is_cf_or_429 = False
|
||||
if error_response and isinstance(error_response, dict):
|
||||
error_info = error_response.get("error", {})
|
||||
if error_info.get("code") == "cf_shield_429":
|
||||
is_cf_or_429 = True
|
||||
|
||||
if token_obj:
|
||||
error_str = str(e).lower()
|
||||
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
|
||||
if not is_cf_or_429:
|
||||
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
|
||||
|
||||
# Update request log on error
|
||||
if log_id:
|
||||
duration = time.time() - start_time
|
||||
if error_response:
|
||||
await self.db.update_request_log(
|
||||
log_id,
|
||||
response_body=json.dumps(error_response),
|
||||
status_code=429 if is_cf_or_429 else 400,
|
||||
duration=duration
|
||||
)
|
||||
else:
|
||||
await self.db.update_request_log(
|
||||
log_id,
|
||||
response_body=json.dumps({"error": str(e)}),
|
||||
status_code=500,
|
||||
duration=duration
|
||||
)
|
||||
log_updated = True
|
||||
|
||||
debug_logger.log_error(
|
||||
error_message=f"Video extension failed: {str(e)}",
|
||||
status_code=429 if is_cf_or_429 else 500,
|
||||
response_text=str(e)
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Ensure log is not stuck at in-progress
|
||||
if log_id and not log_updated:
|
||||
try:
|
||||
duration = time.time() - start_time
|
||||
await self.db.update_request_log(
|
||||
log_id,
|
||||
response_body=json.dumps({"error": "Task failed or interrupted during processing"}),
|
||||
status_code=500,
|
||||
duration=duration
|
||||
)
|
||||
except Exception as finally_error:
|
||||
debug_logger.log_error(
|
||||
error_message=f"Failed to update video extension log in finally block: {str(finally_error)}",
|
||||
status_code=500,
|
||||
response_text=str(finally_error)
|
||||
)
|
||||
|
||||
async def _poll_cameo_status(self, cameo_id: str, token: str, timeout: int = 600, poll_interval: int = 5) -> Dict[str, Any]:
|
||||
"""Poll for cameo (character) processing status
|
||||
|
||||
|
||||
134
src/services/pow_service_client.py
Normal file
134
src/services/pow_service_client.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""POW Service Client - External POW service integration (POST /api/v1/sora/sentinel-token)"""
|
||||
from typing import NamedTuple, Optional
|
||||
from curl_cffi.requests import AsyncSession
|
||||
|
||||
from ..core.config import config
|
||||
from ..core.logger import debug_logger
|
||||
|
||||
|
||||
class SentinelResult(NamedTuple):
|
||||
"""Result from external sentinel-token API."""
|
||||
|
||||
sentinel_token: str
|
||||
device_id: Optional[str]
|
||||
user_agent: Optional[str]
|
||||
cookie_header: Optional[str]
|
||||
|
||||
|
||||
class POWServiceClient:
|
||||
"""Client for external POW service API."""
|
||||
|
||||
async def get_sentinel_token(
|
||||
self,
|
||||
access_token: Optional[str] = None,
|
||||
session_token: Optional[str] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
device_type: str = "ios",
|
||||
) -> Optional[SentinelResult]:
|
||||
"""Get sentinel token from external POW service.
|
||||
|
||||
Args:
|
||||
access_token: Sora access token (optional).
|
||||
session_token: Sora session token (optional).
|
||||
proxy_url: Proxy URL for upstream solver (optional).
|
||||
device_type: Device type hint for upstream solver.
|
||||
|
||||
Returns:
|
||||
SentinelResult or None on failure.
|
||||
"""
|
||||
server_url = config.pow_service_server_url
|
||||
api_key = config.pow_service_api_key
|
||||
request_proxy = config.pow_service_proxy_url if config.pow_service_proxy_enabled else None
|
||||
|
||||
if not server_url or not api_key:
|
||||
debug_logger.log_error(
|
||||
error_message="POW service not configured: missing server_url or api_key",
|
||||
status_code=0,
|
||||
response_text="Configuration error",
|
||||
source="POWServiceClient"
|
||||
)
|
||||
return None
|
||||
|
||||
api_url = f"{server_url.rstrip('/')}/api/v1/sora/sentinel-token"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload = {"device_type": device_type}
|
||||
if access_token:
|
||||
payload["access_token"] = access_token
|
||||
if session_token:
|
||||
payload["session_token"] = session_token
|
||||
if proxy_url:
|
||||
payload["proxy_url"] = proxy_url
|
||||
|
||||
def _mask(token_value: Optional[str]) -> str:
|
||||
if not token_value:
|
||||
return "none"
|
||||
if len(token_value) <= 10:
|
||||
return "***"
|
||||
return f"{token_value[:6]}...{token_value[-4:]}"
|
||||
|
||||
debug_logger.log_info(
|
||||
f"[POW Service] POST {api_url} access_token={_mask(access_token)} proxy_url={proxy_url or 'none'}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with AsyncSession(impersonate="chrome131") as session:
|
||||
response = await session.post(
|
||||
api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
proxy=request_proxy,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
debug_logger.log_error(
|
||||
error_message=f"POW service request failed: {response.status_code}",
|
||||
status_code=response.status_code,
|
||||
response_text=response.text,
|
||||
source="POWServiceClient",
|
||||
)
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
token = data.get("sentinel_token")
|
||||
device_id = data.get("device_id")
|
||||
user_agent = data.get("user_agent")
|
||||
cookie_header = data.get("cookie_header")
|
||||
|
||||
if not token:
|
||||
debug_logger.log_error(
|
||||
error_message="POW service returned empty token",
|
||||
status_code=response.status_code,
|
||||
response_text=response.text,
|
||||
source="POWServiceClient"
|
||||
)
|
||||
return None
|
||||
|
||||
debug_logger.log_info(
|
||||
f"[POW Service] sentinel_token len={len(token)} device_id={device_id} "
|
||||
f"ua={bool(user_agent)} cookie_header={bool(cookie_header)}"
|
||||
)
|
||||
return SentinelResult(
|
||||
sentinel_token=token,
|
||||
device_id=device_id,
|
||||
user_agent=user_agent,
|
||||
cookie_header=cookie_header,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_logger.log_error(
|
||||
error_message=f"POW service request exception: {str(e)}",
|
||||
status_code=0,
|
||||
response_text=str(e),
|
||||
source="POWServiceClient"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
pow_service_client = POWServiceClient()
|
||||
@@ -36,9 +36,42 @@ class ProxyManager:
|
||||
return config.proxy_url
|
||||
return 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
@@ -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)"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user