mirror of
https://github.com/TheSmallHanCat/sora2api.git
synced 2026-02-19 22:44:45 +08:00
Compare commits
6 Commits
576310c50c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29fddfa85b | ||
|
|
5a0ccbe2de | ||
|
|
fc95de0f28 | ||
|
|
92015882cc | ||
|
|
5570fa35a6 | ||
|
|
06c2bea806 |
33
Dockerfile
33
Dockerfile
@@ -8,9 +8,42 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
|||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Install Playwright browsers
|
||||||
|
RUN playwright install chromium
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -60,3 +60,15 @@ call_mode = "default"
|
|||||||
# 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -8
|
# 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -8
|
||||||
timezone_offset = 8
|
timezone_offset = 8
|
||||||
|
|
||||||
|
[pow_service]
|
||||||
|
# beta测试,目前仍处于测试阶段
|
||||||
|
# POW 计算模式:local(本地计算)或 external(外部服务)
|
||||||
|
mode = "external"
|
||||||
|
# 外部 POW 服务地址(仅在 external 模式下使用)
|
||||||
|
server_url = "http://localhost:8002"
|
||||||
|
# 外部 POW 服务访问密钥(仅在 external 模式下使用)
|
||||||
|
api_key = "your-secure-api-key-here"
|
||||||
|
# POW 代理配置
|
||||||
|
proxy_enabled = false
|
||||||
|
proxy_url = ""
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ toml
|
|||||||
faker==24.0.0
|
faker==24.0.0
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
APScheduler==3.10.4
|
APScheduler==3.10.4
|
||||||
|
playwright==1.48.0
|
||||||
121
src/api/admin.py
121
src/api/admin.py
@@ -167,6 +167,17 @@ class UpdateCallLogicConfigRequest(BaseModel):
|
|||||||
call_mode: Optional[str] = None # "default" or "polling"
|
call_mode: Optional[str] = None # "default" or "polling"
|
||||||
polling_mode_enabled: Optional[bool] = None # Legacy support
|
polling_mode_enabled: Optional[bool] = None # Legacy support
|
||||||
|
|
||||||
|
class UpdatePowProxyConfigRequest(BaseModel):
|
||||||
|
pow_proxy_enabled: bool
|
||||||
|
pow_proxy_url: Optional[str] = None
|
||||||
|
|
||||||
|
class UpdatePowServiceConfigRequest(BaseModel):
|
||||||
|
mode: str # "local" or "external"
|
||||||
|
server_url: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
proxy_enabled: Optional[bool] = None
|
||||||
|
proxy_url: Optional[str] = None
|
||||||
|
|
||||||
class BatchDisableRequest(BaseModel):
|
class BatchDisableRequest(BaseModel):
|
||||||
token_ids: List[int]
|
token_ids: List[int]
|
||||||
|
|
||||||
@@ -238,7 +249,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
|
|||||||
"video_enabled": token.video_enabled,
|
"video_enabled": token.video_enabled,
|
||||||
# 并发限制
|
# 并发限制
|
||||||
"image_concurrency": token.image_concurrency,
|
"image_concurrency": token.image_concurrency,
|
||||||
"video_concurrency": token.video_concurrency
|
"video_concurrency": token.video_concurrency,
|
||||||
|
# 过期和禁用信息
|
||||||
|
"is_expired": token.is_expired,
|
||||||
|
"disabled_reason": token.disabled_reason
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -1369,6 +1383,90 @@ async def update_call_logic_config(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to update call logic configuration: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to update call logic configuration: {str(e)}")
|
||||||
|
|
||||||
|
# POW proxy config endpoints (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,
|
||||||
|
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,
|
||||||
|
"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,
|
||||||
|
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_server_url(request.server_url or "")
|
||||||
|
config.set_pow_service_api_key(request.api_key or "")
|
||||||
|
config.set_pow_service_proxy_enabled(request.proxy_enabled or False)
|
||||||
|
config.set_pow_service_proxy_url(request.proxy_url or "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "POW service configuration updated"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update POW service configuration: {str(e)}")
|
||||||
|
|
||||||
# Task management endpoints
|
# Task management endpoints
|
||||||
@router.post("/api/tasks/{task_id}/cancel")
|
@router.post("/api/tasks/{task_id}/cancel")
|
||||||
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
|
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
|
||||||
@@ -1391,7 +1489,26 @@ async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
|
|||||||
for log in logs:
|
for log in logs:
|
||||||
if log.get("task_id") == task_id and log.get("status_code") == -1:
|
if log.get("task_id") == task_id and log.get("status_code") == -1:
|
||||||
import time
|
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(
|
await db.update_request_log(
|
||||||
log.get("id"),
|
log.get("id"),
|
||||||
response_body='{"error": "用户手动取消任务"}',
|
response_body='{"error": "用户手动取消任务"}',
|
||||||
|
|||||||
@@ -236,5 +236,98 @@ class Config:
|
|||||||
self._config["call_logic"]["call_mode"] = normalized
|
self._config["call_logic"]["call_mode"] = normalized
|
||||||
self._config["call_logic"]["polling_mode_enabled"] = normalized == "polling"
|
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_server_url(self) -> str:
|
||||||
|
"""Get POW service server URL"""
|
||||||
|
return self._config.get("pow_service", {}).get("server_url", "")
|
||||||
|
|
||||||
|
def set_pow_service_server_url(self, url: str):
|
||||||
|
"""Set POW service server URL"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["server_url"] = url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_api_key(self) -> str:
|
||||||
|
"""Get POW service API key"""
|
||||||
|
return self._config.get("pow_service", {}).get("api_key", "")
|
||||||
|
|
||||||
|
def set_pow_service_api_key(self, api_key: str):
|
||||||
|
"""Set POW service API key"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["api_key"] = api_key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_proxy_enabled(self) -> bool:
|
||||||
|
"""Get POW service proxy enabled status"""
|
||||||
|
return self._config.get("pow_service", {}).get("proxy_enabled", False)
|
||||||
|
|
||||||
|
def set_pow_service_proxy_enabled(self, enabled: bool):
|
||||||
|
"""Set POW service proxy enabled status"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["proxy_enabled"] = enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pow_service_proxy_url(self) -> str:
|
||||||
|
"""Get POW service proxy URL"""
|
||||||
|
return self._config.get("pow_service", {}).get("proxy_url", "")
|
||||||
|
|
||||||
|
def set_pow_service_proxy_url(self, url: str):
|
||||||
|
"""Set POW service proxy URL"""
|
||||||
|
if "pow_service" not in self._config:
|
||||||
|
self._config["pow_service"] = {}
|
||||||
|
self._config["pow_service"]["proxy_url"] = url
|
||||||
|
|
||||||
# Global config instance
|
# Global config instance
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|||||||
@@ -180,6 +180,78 @@ class Database:
|
|||||||
VALUES (1, ?)
|
VALUES (1, ?)
|
||||||
""", (at_auto_refresh_enabled,))
|
""", (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
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO call_logic_config (id, call_mode, polling_mode_enabled)
|
||||||
|
VALUES (1, ?, ?)
|
||||||
|
""", (call_mode, polling_mode_enabled))
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
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")
|
||||||
|
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, server_url, api_key, proxy_enabled, proxy_url)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?)
|
||||||
|
""", (mode, server_url, api_key, proxy_enabled, proxy_url))
|
||||||
|
|
||||||
|
|
||||||
async def check_and_migrate_db(self, config_dict: dict = None):
|
async def check_and_migrate_db(self, config_dict: dict = None):
|
||||||
"""Check database integrity and perform migrations if needed
|
"""Check database integrity and perform migrations if needed
|
||||||
@@ -321,7 +393,8 @@ class Database:
|
|||||||
video_enabled BOOLEAN DEFAULT 1,
|
video_enabled BOOLEAN DEFAULT 1,
|
||||||
image_concurrency INTEGER DEFAULT -1,
|
image_concurrency INTEGER DEFAULT -1,
|
||||||
video_concurrency INTEGER DEFAULT -1,
|
video_concurrency INTEGER DEFAULT -1,
|
||||||
is_expired BOOLEAN DEFAULT 0
|
is_expired BOOLEAN DEFAULT 0,
|
||||||
|
disabled_reason TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -386,6 +459,9 @@ class Database:
|
|||||||
admin_password TEXT DEFAULT 'admin',
|
admin_password TEXT DEFAULT 'admin',
|
||||||
api_key TEXT DEFAULT 'han1234',
|
api_key TEXT DEFAULT 'han1234',
|
||||||
error_ban_threshold INTEGER DEFAULT 3,
|
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
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
@@ -459,6 +535,31 @@ class Database:
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# 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',
|
||||||
|
server_url TEXT,
|
||||||
|
api_key TEXT,
|
||||||
|
proxy_enabled BOOLEAN DEFAULT 0,
|
||||||
|
proxy_url TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
# Create indexes
|
# Create indexes
|
||||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_id ON tasks(task_id)")
|
||||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_status ON tasks(status)")
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_task_status ON tasks(status)")
|
||||||
@@ -486,6 +587,22 @@ class Database:
|
|||||||
if not await self._column_exists(db, "admin_config", "auto_disable_on_401"):
|
if not await self._column_exists(db, "admin_config", "auto_disable_on_401"):
|
||||||
await db.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1")
|
await db.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1")
|
||||||
|
|
||||||
|
# Migration: Add disabled_reason column to tokens table if it doesn't exist
|
||||||
|
if not await self._column_exists(db, "tokens", "disabled_reason"):
|
||||||
|
await db.execute("ALTER TABLE tokens ADD COLUMN disabled_reason TEXT")
|
||||||
|
# For existing disabled tokens without a reason, set to 'manual'
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE tokens
|
||||||
|
SET disabled_reason = 'manual'
|
||||||
|
WHERE is_active = 0 AND disabled_reason IS NULL
|
||||||
|
""")
|
||||||
|
# For existing expired tokens, set to 'expired'
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE tokens
|
||||||
|
SET disabled_reason = 'expired'
|
||||||
|
WHERE is_expired = 1 AND disabled_reason IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
|
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
|
||||||
@@ -598,27 +715,35 @@ class Database:
|
|||||||
""", (token_id,))
|
""", (token_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def update_token_status(self, token_id: int, is_active: bool):
|
async def update_token_status(self, token_id: int, is_active: bool, disabled_reason: Optional[str] = None):
|
||||||
"""Update token status"""
|
"""Update token status and disabled reason"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE tokens SET is_active = ? WHERE id = ?
|
UPDATE tokens SET is_active = ?, disabled_reason = ? WHERE id = ?
|
||||||
""", (is_active, token_id))
|
""", (is_active, disabled_reason, token_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def mark_token_expired(self, token_id: int):
|
async def mark_token_expired(self, token_id: int):
|
||||||
"""Mark token as expired and disable it"""
|
"""Mark token as expired and disable it with reason"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE tokens SET is_expired = 1, is_active = 0 WHERE id = ?
|
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
|
||||||
""", (token_id,))
|
""", ("expired", token_id))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def mark_token_invalid(self, token_id: int):
|
||||||
|
"""Mark token as invalid (401 error) and disable it"""
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute("""
|
||||||
|
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
|
||||||
|
""", ("token_invalid", token_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
async def clear_token_expired(self, token_id: int):
|
async def clear_token_expired(self, token_id: int):
|
||||||
"""Clear token expired flag"""
|
"""Clear token expired flag and disabled reason"""
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE tokens SET is_expired = 0 WHERE id = ?
|
UPDATE tokens SET is_expired = 0, disabled_reason = NULL WHERE id = ?
|
||||||
""", (token_id,))
|
""", (token_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -1010,9 +1135,12 @@ class Database:
|
|||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE admin_config
|
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
|
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()
|
await db.commit()
|
||||||
|
|
||||||
# Proxy config operations
|
# Proxy config operations
|
||||||
@@ -1196,10 +1324,67 @@ class Database:
|
|||||||
normalized = "polling" if call_mode == "polling" else "default"
|
normalized = "polling" if call_mode == "polling" else "default"
|
||||||
polling_mode_enabled = normalized == "polling"
|
polling_mode_enabled = normalized == "polling"
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
# Use INSERT OR REPLACE to ensure the row exists
|
||||||
await db.execute("""
|
await db.execute("""
|
||||||
UPDATE call_logic_config
|
INSERT OR REPLACE INTO call_logic_config (id, call_mode, polling_mode_enabled, updated_at)
|
||||||
SET polling_mode_enabled = ?, call_mode = ?, updated_at = CURRENT_TIMESTAMP
|
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
|
||||||
WHERE id = 1
|
""", (normalized, polling_mode_enabled))
|
||||||
""", (polling_mode_enabled, normalized))
|
|
||||||
await db.commit()
|
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",
|
||||||
|
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,
|
||||||
|
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, server_url, api_key, proxy_enabled, proxy_url, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""", (mode, server_url, api_key, proxy_enabled, proxy_url))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,18 @@ class DebugLogger:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error logging info: {e}")
|
self.logger.error(f"Error logging info: {e}")
|
||||||
|
|
||||||
|
def log_warning(self, message: str):
|
||||||
|
"""Log warning message to log.txt"""
|
||||||
|
|
||||||
|
# Check if debug mode is enabled
|
||||||
|
if not config.debug_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.warning(f"⚠️ [{self._format_timestamp()}] {message}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error logging warning: {e}")
|
||||||
|
|
||||||
# Global debug logger instance
|
# Global debug logger instance
|
||||||
debug_logger = DebugLogger()
|
debug_logger = DebugLogger()
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class Token(BaseModel):
|
|||||||
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
|
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
|
||||||
# 过期标记
|
# 过期标记
|
||||||
is_expired: bool = False # Token是否已过期(401 token_invalidated)
|
is_expired: bool = False # Token是否已过期(401 token_invalidated)
|
||||||
|
# 禁用原因: manual=手动禁用, error_limit=错误次数超限, token_invalid=Token失效, expired=过期失效
|
||||||
|
disabled_reason: Optional[str] = None
|
||||||
|
|
||||||
class TokenStats(BaseModel):
|
class TokenStats(BaseModel):
|
||||||
"""Token statistics"""
|
"""Token statistics"""
|
||||||
@@ -146,6 +148,25 @@ class CallLogicConfig(BaseModel):
|
|||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_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"
|
||||||
|
server_url: Optional[str] = None # External POW service URL
|
||||||
|
api_key: Optional[str] = None # External POW service API key
|
||||||
|
proxy_enabled: bool = False # Whether to enable proxy for POW service
|
||||||
|
proxy_url: Optional[str] = None # Proxy URL for POW service
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
# API Request/Response models
|
# API Request/Response models
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(BaseModel):
|
||||||
role: str
|
role: str
|
||||||
|
|||||||
@@ -144,6 +144,15 @@ async def startup_event():
|
|||||||
config.set_call_logic_mode(call_logic_config.call_mode)
|
config.set_call_logic_mode(call_logic_config.call_mode)
|
||||||
print(f"✓ Call logic mode: {call_logic_config.call_mode}")
|
print(f"✓ Call logic mode: {call_logic_config.call_mode}")
|
||||||
|
|
||||||
|
# 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_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}")
|
||||||
|
|
||||||
# Initialize concurrency manager with all tokens
|
# Initialize concurrency manager with all tokens
|
||||||
all_tokens = await db.get_all_tokens()
|
all_tokens = await db.get_all_tokens()
|
||||||
await concurrency_manager.initialize(all_tokens)
|
await concurrency_manager.initialize(all_tokens)
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ class GenerationHandler:
|
|||||||
task_id = None
|
task_id = None
|
||||||
is_first_chunk = True # Track if this is the first chunk
|
is_first_chunk = True # Track if this is the first chunk
|
||||||
log_id = None # Initialize log_id
|
log_id = None # Initialize log_id
|
||||||
|
log_updated = False # Track if log has been updated
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create initial log entry BEFORE submitting task to upstream
|
# Create initial log entry BEFORE submitting task to upstream
|
||||||
@@ -680,6 +681,7 @@ class GenerationHandler:
|
|||||||
status_code=200,
|
status_code=200,
|
||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
|
log_updated = True # Mark log as updated
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Release lock for image generation on error
|
# Release lock for image generation on error
|
||||||
@@ -727,6 +729,7 @@ class GenerationHandler:
|
|||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
|
log_updated = True # Mark log as updated
|
||||||
else:
|
else:
|
||||||
# Generic error
|
# Generic error
|
||||||
await self.db.update_request_log(
|
await self.db.update_request_log(
|
||||||
@@ -735,12 +738,35 @@ class GenerationHandler:
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
duration=duration
|
duration=duration
|
||||||
)
|
)
|
||||||
|
log_updated = True # Mark log as updated
|
||||||
# Wrap exception with token_id information
|
# Wrap exception with token_id information
|
||||||
if token_obj:
|
if token_obj:
|
||||||
raise GenerationError(str(e), token_id=token_obj.id)
|
raise GenerationError(str(e), token_id=token_obj.id)
|
||||||
else:
|
else:
|
||||||
raise e
|
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,
|
async def handle_generation_with_retry(self, model: str, prompt: str,
|
||||||
image: Optional[str] = None,
|
image: Optional[str] = None,
|
||||||
video: Optional[str] = None,
|
video: Optional[str] = None,
|
||||||
|
|||||||
136
src/services/pow_service_client.py
Normal file
136
src/services/pow_service_client.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""POW Service Client - External POW service integration"""
|
||||||
|
import json
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
||||||
|
from ..core.config import config
|
||||||
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
|
|
||||||
|
class POWServiceClient:
|
||||||
|
"""Client for external POW service API"""
|
||||||
|
|
||||||
|
async def get_sentinel_token(self) -> Optional[Tuple[str, str, str]]:
|
||||||
|
"""Get sentinel token from external POW service
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (sentinel_token, device_id, user_agent) or None on failure
|
||||||
|
"""
|
||||||
|
# Read configuration dynamically on each call
|
||||||
|
server_url = config.pow_service_server_url
|
||||||
|
api_key = config.pow_service_api_key
|
||||||
|
proxy_enabled = config.pow_service_proxy_enabled
|
||||||
|
proxy_url = config.pow_service_proxy_url if 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
|
||||||
|
|
||||||
|
# Construct API endpoint
|
||||||
|
api_url = f"{server_url.rstrip('/')}/api/pow/token"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
debug_logger.log_info(f"[POW Service] Requesting token from {api_url}")
|
||||||
|
|
||||||
|
async with AsyncSession(impersonate="chrome131") as session:
|
||||||
|
response = await session.get(
|
||||||
|
api_url,
|
||||||
|
headers=headers,
|
||||||
|
proxy=proxy_url,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
error_msg = f"POW service request failed: {response.status_code}"
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=error_msg,
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_text=response.text,
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if not data.get("success"):
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message="POW service returned success=false",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_text=response.text,
|
||||||
|
source="POWServiceClient"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = data.get("token")
|
||||||
|
device_id = data.get("device_id")
|
||||||
|
user_agent = data.get("user_agent")
|
||||||
|
cached = data.get("cached", False)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Parse token to extract device_id if not provided
|
||||||
|
token_data = None
|
||||||
|
if not device_id:
|
||||||
|
try:
|
||||||
|
token_data = json.loads(token)
|
||||||
|
device_id = token_data.get("id")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 记录详细的 token 信息
|
||||||
|
cache_status = "cached" if cached else "fresh"
|
||||||
|
debug_logger.log_info("=" * 100)
|
||||||
|
debug_logger.log_info(f"[POW Service] Token obtained successfully ({cache_status})")
|
||||||
|
debug_logger.log_info(f"[POW Service] Token length: {len(token)}")
|
||||||
|
debug_logger.log_info(f"[POW Service] Device ID: {device_id}")
|
||||||
|
debug_logger.log_info(f"[POW Service] User Agent: {user_agent}")
|
||||||
|
|
||||||
|
# 解析并显示 token 结构
|
||||||
|
if not token_data:
|
||||||
|
try:
|
||||||
|
token_data = json.loads(token)
|
||||||
|
except:
|
||||||
|
debug_logger.log_info(f"[POW Service] Token is not valid JSON")
|
||||||
|
token_data = None
|
||||||
|
|
||||||
|
if token_data:
|
||||||
|
debug_logger.log_info(f"[POW Service] Token structure keys: {list(token_data.keys())}")
|
||||||
|
for key, value in token_data.items():
|
||||||
|
if isinstance(value, str) and len(value) > 100:
|
||||||
|
debug_logger.log_info(f"[POW Service] Token[{key}]: <string, length={len(value)}>")
|
||||||
|
else:
|
||||||
|
debug_logger.log_info(f"[POW Service] Token[{key}]: {value}")
|
||||||
|
|
||||||
|
debug_logger.log_info("=" * 100)
|
||||||
|
|
||||||
|
return token, device_id, user_agent
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
pow_service_client = POWServiceClient()
|
||||||
@@ -16,17 +16,291 @@ from urllib.error import HTTPError, URLError
|
|||||||
from curl_cffi.requests import AsyncSession
|
from curl_cffi.requests import AsyncSession
|
||||||
from curl_cffi import CurlMime
|
from curl_cffi import CurlMime
|
||||||
from .proxy_manager import ProxyManager
|
from .proxy_manager import ProxyManager
|
||||||
|
from .pow_service_client import pow_service_client
|
||||||
from ..core.config import config
|
from ..core.config import config
|
||||||
from ..core.logger import debug_logger
|
from ..core.logger import debug_logger
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
PLAYWRIGHT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PLAYWRIGHT_AVAILABLE = False
|
||||||
|
|
||||||
|
# Global browser instance for reuse (lightweight Playwright approach)
|
||||||
|
_browser = None
|
||||||
|
_playwright = None
|
||||||
|
_current_proxy = None
|
||||||
|
|
||||||
|
# Sentinel token cache
|
||||||
|
_cached_sentinel_token = None
|
||||||
|
_cached_device_id = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_browser(proxy_url: str = None):
|
||||||
|
"""Get or create browser instance (reuses existing browser)"""
|
||||||
|
global _browser, _playwright, _current_proxy
|
||||||
|
|
||||||
|
# If proxy changed, restart browser
|
||||||
|
if _browser is not None and _current_proxy != proxy_url:
|
||||||
|
await _browser.close()
|
||||||
|
_browser = None
|
||||||
|
|
||||||
|
if _browser is None:
|
||||||
|
_playwright = await async_playwright().start()
|
||||||
|
launch_args = {
|
||||||
|
'headless': True,
|
||||||
|
'args': [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-extensions',
|
||||||
|
'--disable-plugins',
|
||||||
|
'--disable-images',
|
||||||
|
'--disable-default-apps',
|
||||||
|
'--disable-sync',
|
||||||
|
'--disable-translate',
|
||||||
|
'--disable-background-networking',
|
||||||
|
'--disable-software-rasterizer',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if proxy_url:
|
||||||
|
launch_args['proxy'] = {'server': proxy_url}
|
||||||
|
_browser = await _playwright.chromium.launch(**launch_args)
|
||||||
|
_current_proxy = proxy_url
|
||||||
|
return _browser
|
||||||
|
|
||||||
|
|
||||||
|
async def _close_browser():
|
||||||
|
"""Close browser instance"""
|
||||||
|
global _browser, _playwright
|
||||||
|
if _browser:
|
||||||
|
await _browser.close()
|
||||||
|
_browser = None
|
||||||
|
if _playwright:
|
||||||
|
await _playwright.stop()
|
||||||
|
_playwright = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_oai_did(proxy_url: str = None, max_retries: int = 3) -> str:
|
||||||
|
"""Fetch oai-did using curl_cffi (lightweight approach)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If 403 or 429 response received
|
||||||
|
"""
|
||||||
|
debug_logger.log_info(f"[Sentinel] Fetching oai-did...")
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
async with AsyncSession(impersonate="chrome120") as session:
|
||||||
|
response = await session.get(
|
||||||
|
"https://chatgpt.com/",
|
||||||
|
proxy=proxy_url,
|
||||||
|
timeout=30,
|
||||||
|
allow_redirects=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for 403/429 errors - don't retry, just fail
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise Exception("403 Forbidden - Access denied when fetching oai-did")
|
||||||
|
if response.status_code == 429:
|
||||||
|
raise Exception("429 Too Many Requests - Rate limited when fetching oai-did")
|
||||||
|
|
||||||
|
oai_did = response.cookies.get("oai-did")
|
||||||
|
if oai_did:
|
||||||
|
debug_logger.log_info(f"[Sentinel] oai-did: {oai_did}")
|
||||||
|
return oai_did
|
||||||
|
|
||||||
|
set_cookie = response.headers.get("set-cookie", "")
|
||||||
|
match = re.search(r'oai-did=([a-f0-9-]{36})', set_cookie)
|
||||||
|
if match:
|
||||||
|
oai_did = match.group(1)
|
||||||
|
debug_logger.log_info(f"[Sentinel] oai-did: {oai_did}")
|
||||||
|
return oai_did
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
# Re-raise 403/429 errors immediately
|
||||||
|
if "403" in error_str or "429" in error_str:
|
||||||
|
raise
|
||||||
|
debug_logger.log_info(f"[Sentinel] oai-did fetch failed: {e}")
|
||||||
|
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id: str = None) -> str:
|
||||||
|
"""Generate sentinel token using lightweight Playwright approach
|
||||||
|
|
||||||
|
Uses route interception and SDK injection for minimal resource usage.
|
||||||
|
Reuses browser instance across calls.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxy_url: Optional proxy URL
|
||||||
|
device_id: Optional pre-fetched oai-did
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sentinel token string or None on failure
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If 403/429 when fetching oai-did
|
||||||
|
"""
|
||||||
|
global _cached_device_id
|
||||||
|
|
||||||
|
if not PLAYWRIGHT_AVAILABLE:
|
||||||
|
debug_logger.log_info("[Sentinel] Playwright not available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get oai-did
|
||||||
|
if not device_id:
|
||||||
|
device_id = await _fetch_oai_did(proxy_url)
|
||||||
|
|
||||||
|
if not device_id:
|
||||||
|
debug_logger.log_info("[Sentinel] Failed to get oai-did")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_cached_device_id = device_id
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[Sentinel] Starting browser...")
|
||||||
|
browser = await _get_browser(proxy_url)
|
||||||
|
|
||||||
|
context = await browser.new_context(
|
||||||
|
viewport={'width': 800, 'height': 600},
|
||||||
|
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
bypass_csp=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set cookie
|
||||||
|
await context.add_cookies([{
|
||||||
|
'name': 'oai-did',
|
||||||
|
'value': device_id,
|
||||||
|
'domain': 'sora.chatgpt.com',
|
||||||
|
'path': '/'
|
||||||
|
}])
|
||||||
|
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
# Route interception - inject SDK
|
||||||
|
inject_html = '''<!DOCTYPE html><html><head><script src="https://chatgpt.com/backend-api/sentinel/sdk.js"></script></head><body></body></html>'''
|
||||||
|
|
||||||
|
async def handle_route(route):
|
||||||
|
url = route.request.url
|
||||||
|
if "__sentinel__" in url:
|
||||||
|
await route.fulfill(status=200, content_type="text/html", body=inject_html)
|
||||||
|
elif "/sentinel/" in url or "chatgpt.com" in url:
|
||||||
|
await route.continue_()
|
||||||
|
else:
|
||||||
|
await route.abort()
|
||||||
|
|
||||||
|
await page.route("**/*", handle_route)
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[Sentinel] Loading SDK...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load SDK via hack (must be under sora.chatgpt.com domain)
|
||||||
|
await page.goto("https://sora.chatgpt.com/__sentinel__", wait_until="load", timeout=30000)
|
||||||
|
|
||||||
|
# Wait for SDK to load
|
||||||
|
await page.wait_for_function("typeof SentinelSDK !== 'undefined' && typeof SentinelSDK.token === 'function'", timeout=15000)
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[Sentinel] Getting token...")
|
||||||
|
|
||||||
|
# Call SDK
|
||||||
|
token = await page.evaluate(f'''
|
||||||
|
async () => {{
|
||||||
|
try {{
|
||||||
|
return await SentinelSDK.token('sora_2_create_task', '{device_id}');
|
||||||
|
}} catch (e) {{
|
||||||
|
return 'ERROR: ' + e.message;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
''')
|
||||||
|
|
||||||
|
if token and not token.startswith('ERROR'):
|
||||||
|
debug_logger.log_info(f"[Sentinel] Token obtained successfully")
|
||||||
|
return token
|
||||||
|
else:
|
||||||
|
debug_logger.log_info(f"[Sentinel] Token error: {token}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_info(f"[Sentinel] Error: {e}")
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_cached_sentinel_token(proxy_url: str = None, force_refresh: bool = False) -> str:
|
||||||
|
"""Get sentinel token with caching support
|
||||||
|
|
||||||
|
Args:
|
||||||
|
proxy_url: Optional proxy URL
|
||||||
|
force_refresh: Force refresh token (e.g., after 400 error)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sentinel token string or None
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If 403/429 when fetching oai-did
|
||||||
|
"""
|
||||||
|
global _cached_sentinel_token
|
||||||
|
|
||||||
|
# Check if external POW service is configured
|
||||||
|
if config.pow_service_mode == "external":
|
||||||
|
debug_logger.log_info("[POW] Using external POW service (cached sentinel)")
|
||||||
|
from .pow_service_client import pow_service_client
|
||||||
|
result = await pow_service_client.get_sentinel_token()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
sentinel_token, device_id, service_user_agent = result
|
||||||
|
debug_logger.log_info("[POW] External service returned sentinel token successfully")
|
||||||
|
return sentinel_token
|
||||||
|
else:
|
||||||
|
# Fallback to local mode if external service fails
|
||||||
|
debug_logger.log_info("[POW] External service failed, falling back to local mode")
|
||||||
|
|
||||||
|
# Local mode (original logic)
|
||||||
|
# Return cached token if available and not forcing refresh
|
||||||
|
if _cached_sentinel_token and not force_refresh:
|
||||||
|
debug_logger.log_info("[Sentinel] Using cached token")
|
||||||
|
return _cached_sentinel_token
|
||||||
|
|
||||||
|
# Generate new token
|
||||||
|
debug_logger.log_info("[Sentinel] Generating new token...")
|
||||||
|
token = await _generate_sentinel_token_lightweight(proxy_url)
|
||||||
|
|
||||||
|
if token:
|
||||||
|
_cached_sentinel_token = token
|
||||||
|
debug_logger.log_info("[Sentinel] Token cached successfully")
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_sentinel_cache():
|
||||||
|
"""Invalidate cached sentinel token (call after 400 error)"""
|
||||||
|
global _cached_sentinel_token
|
||||||
|
_cached_sentinel_token = None
|
||||||
|
debug_logger.log_info("[Sentinel] Cache invalidated")
|
||||||
|
|
||||||
|
|
||||||
# PoW related constants
|
# PoW related constants
|
||||||
POW_MAX_ITERATION = 500000
|
POW_MAX_ITERATION = 500000
|
||||||
POW_CORES = [8, 16, 24, 32]
|
POW_CORES = [4, 8, 12, 16, 24, 32]
|
||||||
|
|
||||||
|
POW_SCREEN_SIZES = [1266, 1536, 1920, 2560, 3000, 3072, 3120, 3840]
|
||||||
POW_SCRIPTS = [
|
POW_SCRIPTS = [
|
||||||
"https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3"
|
"https://sora-cdn.oaistatic.com/_next/static/chunks/polyfills-42372ed130431b0a.js",
|
||||||
|
"https://sora-cdn.oaistatic.com/_next/static/chunks/6974-eaafbe7db9c73c96.js",
|
||||||
|
"https://sora-cdn.oaistatic.com/_next/static/chunks/main-app-5f0c58611778fb36.js",
|
||||||
|
"https://chatgpt.com/backend-api/sentinel/sdk.js",
|
||||||
]
|
]
|
||||||
POW_DPL = ["prod-f501fe933b3edf57aea882da888e1a544df99840"]
|
|
||||||
POW_NAVIGATOR_KEYS = [
|
POW_NAVIGATOR_KEYS = [
|
||||||
|
"mimeTypes−[object MimeTypeArray]",
|
||||||
|
"userAgentData−[object NavigatorUAData]",
|
||||||
|
"scheduling−[object Scheduling]",
|
||||||
|
"keyboard−[object Keyboard]",
|
||||||
|
"webkitPersistentStorage−[object DeprecatedStorageQuota]",
|
||||||
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
|
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
|
||||||
"storage−[object StorageManager]",
|
"storage−[object StorageManager]",
|
||||||
"locks−[object LockManager]",
|
"locks−[object LockManager]",
|
||||||
@@ -41,12 +315,31 @@ POW_NAVIGATOR_KEYS = [
|
|||||||
"hardwareConcurrency−32",
|
"hardwareConcurrency−32",
|
||||||
"onLine−true",
|
"onLine−true",
|
||||||
]
|
]
|
||||||
POW_DOCUMENT_KEYS = ["_reactListeningo743lnnpvdg", "location"]
|
POW_DOCUMENT_KEYS = [
|
||||||
|
"__reactContainer$3k0e9yog4o3",
|
||||||
|
"__reactContainer$ft149nhgior",
|
||||||
|
"__reactResources$9nnifsagitb",
|
||||||
|
"_reactListeningou2wvttp2d9",
|
||||||
|
"_reactListeningu9qurgpwsme",
|
||||||
|
"_reactListeningo743lnnpvdg",
|
||||||
|
"location",
|
||||||
|
"body",
|
||||||
|
]
|
||||||
POW_WINDOW_KEYS = [
|
POW_WINDOW_KEYS = [
|
||||||
|
"getSelection",
|
||||||
|
"btoa",
|
||||||
|
"__next_s",
|
||||||
|
"crossOriginIsolated",
|
||||||
|
"print",
|
||||||
"0", "window", "self", "document", "name", "location",
|
"0", "window", "self", "document", "name", "location",
|
||||||
"navigator", "screen", "innerWidth", "innerHeight",
|
"navigator", "screen", "innerWidth", "innerHeight",
|
||||||
"localStorage", "sessionStorage", "crypto", "performance",
|
"localStorage", "sessionStorage", "crypto", "performance",
|
||||||
"fetch", "setTimeout", "setInterval", "console",
|
]
|
||||||
|
POW_LANGUAGES = [
|
||||||
|
("zh-CN", "zh-CN,zh"),
|
||||||
|
("en-US", "en-US,en"),
|
||||||
|
("ja-JP", "ja-JP,ja,en"),
|
||||||
|
("ko-KR", "ko-KR,ko,en"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# User-Agent pools
|
# User-Agent pools
|
||||||
@@ -74,7 +367,7 @@ class SoraClient:
|
|||||||
"""Sora API client with proxy support"""
|
"""Sora API client with proxy support"""
|
||||||
|
|
||||||
CHATGPT_BASE_URL = "https://chatgpt.com"
|
CHATGPT_BASE_URL = "https://chatgpt.com"
|
||||||
SENTINEL_FLOW = "sora_2_create_task"
|
SENTINEL_FLOW = "sora_2_create_task__auto"
|
||||||
|
|
||||||
def __init__(self, proxy_manager: ProxyManager):
|
def __init__(self, proxy_manager: ProxyManager):
|
||||||
self.proxy_manager = proxy_manager
|
self.proxy_manager = proxy_manager
|
||||||
@@ -83,32 +376,50 @@ class SoraClient:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_pow_parse_time() -> str:
|
def _get_pow_parse_time() -> str:
|
||||||
"""Generate time string for PoW (EST timezone)"""
|
"""Generate time string for PoW (local timezone)"""
|
||||||
now = datetime.now(timezone(timedelta(hours=-5)))
|
now = datetime.now()
|
||||||
return now.strftime("%a %b %d %Y %H:%M:%S") + " GMT-0500 (Eastern Standard Time)"
|
|
||||||
|
# Get local timezone offset (seconds)
|
||||||
|
if time.daylight and time.localtime().tm_isdst > 0:
|
||||||
|
utc_offset_seconds = -time.altzone
|
||||||
|
else:
|
||||||
|
utc_offset_seconds = -time.timezone
|
||||||
|
|
||||||
|
# Format as +0800 or -0500
|
||||||
|
offset_hours = utc_offset_seconds // 3600
|
||||||
|
offset_minutes = abs(utc_offset_seconds % 3600) // 60
|
||||||
|
offset_sign = '+' if offset_hours >= 0 else '-'
|
||||||
|
offset_str = f"{offset_sign}{abs(offset_hours):02d}{offset_minutes:02d}"
|
||||||
|
|
||||||
|
# Get timezone name
|
||||||
|
tz_name = time.tzname[1] if time.daylight and time.localtime().tm_isdst > 0 else time.tzname[0]
|
||||||
|
|
||||||
|
return now.strftime("%a %b %d %Y %H:%M:%S") + f" GMT{offset_str} ({tz_name})"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_pow_config(user_agent: str) -> list:
|
def _get_pow_config(user_agent: str) -> list:
|
||||||
"""Generate PoW config array with browser fingerprint"""
|
"""Generate PoW config array with browser fingerprint"""
|
||||||
|
lang = random.choice(POW_LANGUAGES)
|
||||||
|
perf_time = random.uniform(10000, 100000)
|
||||||
return [
|
return [
|
||||||
random.choice([1920 + 1080, 2560 + 1440, 1920 + 1200, 2560 + 1600]),
|
random.choice(POW_SCREEN_SIZES), # [0] screen size
|
||||||
SoraClient._get_pow_parse_time(),
|
SoraClient._get_pow_parse_time(), # [1] time string (local timezone)
|
||||||
4294705152,
|
random.choice([4294967296, 4294705152, 2147483648]), # [2] jsHeapSizeLimit
|
||||||
0, # [3] dynamic
|
0, # [3] iteration count (dynamic)
|
||||||
user_agent,
|
user_agent, # [4] UA
|
||||||
random.choice(POW_SCRIPTS) if POW_SCRIPTS else "",
|
random.choice(POW_SCRIPTS) if POW_SCRIPTS else "", # [5] sora cdn script
|
||||||
random.choice(POW_DPL) if POW_DPL else None,
|
None, # [6] must be null
|
||||||
"en-US",
|
lang[0], # [7] language
|
||||||
"en-US,es-US,en,es",
|
lang[1], # [8] languages
|
||||||
0, # [9] dynamic
|
random.randint(2, 10), # [9] random initial value for dynamic calc
|
||||||
random.choice(POW_NAVIGATOR_KEYS),
|
random.choice(POW_NAVIGATOR_KEYS), # [10] navigator key
|
||||||
random.choice(POW_DOCUMENT_KEYS),
|
random.choice(POW_DOCUMENT_KEYS), # [11] document key
|
||||||
random.choice(POW_WINDOW_KEYS),
|
random.choice(POW_WINDOW_KEYS), # [12] window key
|
||||||
time.perf_counter() * 1000,
|
perf_time, # [13] perf time (random)
|
||||||
str(uuid4()),
|
str(uuid4()), # [14] UUID
|
||||||
"",
|
"", # [15] empty
|
||||||
random.choice(POW_CORES),
|
random.choice(POW_CORES), # [16] cores
|
||||||
time.time() * 1000 - (time.perf_counter() * 1000),
|
time.time() * 1000 - perf_time, # [17] time origin
|
||||||
]
|
]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -121,10 +432,12 @@ class SoraClient:
|
|||||||
static_part1 = (json.dumps(config_list[:3], separators=(',', ':'), ensure_ascii=False)[:-1] + ',').encode()
|
static_part1 = (json.dumps(config_list[:3], separators=(',', ':'), ensure_ascii=False)[:-1] + ',').encode()
|
||||||
static_part2 = (',' + json.dumps(config_list[4:9], separators=(',', ':'), ensure_ascii=False)[1:-1] + ',').encode()
|
static_part2 = (',' + json.dumps(config_list[4:9], separators=(',', ':'), ensure_ascii=False)[1:-1] + ',').encode()
|
||||||
static_part3 = (',' + json.dumps(config_list[10:], separators=(',', ':'), ensure_ascii=False)[1:]).encode()
|
static_part3 = (',' + json.dumps(config_list[10:], separators=(',', ':'), ensure_ascii=False)[1:]).encode()
|
||||||
|
initial_j = config_list[9]
|
||||||
|
|
||||||
for i in range(POW_MAX_ITERATION):
|
for i in range(POW_MAX_ITERATION):
|
||||||
dynamic_i = str(i).encode()
|
dynamic_i = str(i).encode()
|
||||||
dynamic_j = str(i >> 1).encode()
|
|
||||||
|
dynamic_j = str(initial_j + (i + 29) // 30).encode()
|
||||||
|
|
||||||
final_json = static_part1 + dynamic_i + static_part2 + dynamic_j + static_part3
|
final_json = static_part1 + dynamic_i + static_part2 + dynamic_j + static_part3
|
||||||
b64_encoded = base64.b64encode(final_json)
|
b64_encoded = base64.b64encode(final_json)
|
||||||
@@ -167,7 +480,7 @@ class SoraClient:
|
|||||||
solution, success = SoraClient._solve_pow(seed, difficulty, config_list)
|
solution, success = SoraClient._solve_pow(seed, difficulty, config_list)
|
||||||
final_pow_token = "gAAAAAB" + solution
|
final_pow_token = "gAAAAAB" + solution
|
||||||
if not success:
|
if not success:
|
||||||
debug_logger.log_warning("PoW calculation failed, using error token")
|
debug_logger.log_info("[Warning] PoW calculation failed, using error token")
|
||||||
|
|
||||||
if not final_pow_token.endswith("~S"):
|
if not final_pow_token.endswith("~S"):
|
||||||
final_pow_token = final_pow_token + "~S"
|
final_pow_token = final_pow_token + "~S"
|
||||||
@@ -203,59 +516,312 @@ class SoraClient:
|
|||||||
except URLError as exc:
|
except URLError as exc:
|
||||||
raise Exception(f"URL Error: {exc}") from exc
|
raise Exception(f"URL Error: {exc}") from exc
|
||||||
|
|
||||||
|
async def _get_sentinel_token_via_browser(self, proxy_url: Optional[str] = None) -> Optional[str]:
|
||||||
|
if not PLAYWRIGHT_AVAILABLE:
|
||||||
|
debug_logger.log_info("[Warning] Playwright not available, cannot use browser fallback")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_playwright() as p:
|
||||||
|
launch_args = {
|
||||||
|
"headless": True,
|
||||||
|
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||||
|
}
|
||||||
|
|
||||||
|
if proxy_url:
|
||||||
|
launch_args["proxy"] = {"server": proxy_url}
|
||||||
|
|
||||||
|
browser = await p.chromium.launch(**launch_args)
|
||||||
|
context = await browser.new_context(
|
||||||
|
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[Browser] Navigating to sora.chatgpt.com...")
|
||||||
|
await page.goto("https://sora.chatgpt.com", wait_until="domcontentloaded", timeout=90000)
|
||||||
|
|
||||||
|
cookies = await context.cookies()
|
||||||
|
device_id = None
|
||||||
|
for cookie in cookies:
|
||||||
|
if cookie.get("name") == "oai-did":
|
||||||
|
device_id = cookie.get("value")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not device_id:
|
||||||
|
device_id = str(uuid4())
|
||||||
|
debug_logger.log_info(f"[Browser] No oai-did cookie, generated: {device_id}")
|
||||||
|
else:
|
||||||
|
debug_logger.log_info(f"[Browser] Got oai-did from cookie: {device_id}")
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[Browser] Waiting for SentinelSDK...")
|
||||||
|
for _ in range(120):
|
||||||
|
try:
|
||||||
|
sdk_ready = await page.evaluate("() => typeof window.SentinelSDK !== 'undefined'")
|
||||||
|
if sdk_ready:
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
else:
|
||||||
|
debug_logger.log_info("[Browser] SentinelSDK load timeout")
|
||||||
|
await browser.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[Browser] SentinelSDK ready, getting token...")
|
||||||
|
|
||||||
|
# 尝试获取 token,最多重试 3 次
|
||||||
|
for attempt in range(3):
|
||||||
|
debug_logger.log_info(f"[Browser] Getting token, attempt {attempt + 1}/3...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = await page.evaluate(
|
||||||
|
"(deviceId) => window.SentinelSDK.token('sora_2_create_task__auto', deviceId)",
|
||||||
|
device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if token:
|
||||||
|
debug_logger.log_info(f"[Browser] Token obtained successfully")
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
if isinstance(token, str):
|
||||||
|
token_data = json.loads(token)
|
||||||
|
else:
|
||||||
|
token_data = token
|
||||||
|
|
||||||
|
if "id" not in token_data or not token_data.get("id"):
|
||||||
|
token_data["id"] = device_id
|
||||||
|
|
||||||
|
return json.dumps(token_data, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
else:
|
||||||
|
debug_logger.log_info(f"[Browser] Token is empty")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_info(f"[Browser] Token exception: {str(e)}")
|
||||||
|
|
||||||
|
if attempt < 2:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"Browser sentinel token failed: {str(e)}",
|
||||||
|
status_code=0,
|
||||||
|
response_text=str(e),
|
||||||
|
source="Server"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def _nf_create_urllib(self, token: str, payload: dict, sentinel_token: str,
|
async def _nf_create_urllib(self, token: str, payload: dict, sentinel_token: str,
|
||||||
proxy_url: Optional[str], token_id: Optional[int] = None) -> Dict[str, Any]:
|
proxy_url: Optional[str], token_id: Optional[int] = None,
|
||||||
|
user_agent: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Make nf/create request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict on success
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: With error info, including '400' in message for sentinel token errors
|
||||||
|
"""
|
||||||
url = f"{self.base_url}/nf/create"
|
url = f"{self.base_url}/nf/create"
|
||||||
user_agent = random.choice(MOBILE_USER_AGENTS)
|
if not user_agent:
|
||||||
|
user_agent = random.choice(DESKTOP_USER_AGENTS)
|
||||||
|
|
||||||
|
import json as json_mod
|
||||||
|
sentinel_data = json_mod.loads(sentinel_token)
|
||||||
|
device_id = sentinel_data.get("id", str(uuid4()))
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
"openai-sentinel-token": sentinel_token,
|
"openai-sentinel-token": sentinel_token, # 使用小写,与成功的 curl 请求一致
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": user_agent,
|
"User-Agent": user_agent,
|
||||||
|
"oai-language": "en-US", # 使用小写
|
||||||
|
"oai-device-id": device_id, # 使用小写
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
"Origin": "https://sora.chatgpt.com",
|
"Origin": "https://sora.chatgpt.com",
|
||||||
"Referer": "https://sora.chatgpt.com/",
|
"Referer": "https://sora.chatgpt.com/explore",
|
||||||
|
"Sec-Ch-Ua": '"Not(A:Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"',
|
||||||
|
"Sec-Ch-Ua-Mobile": "?0",
|
||||||
|
"Sec-Ch-Ua-Platform": '"Windows"',
|
||||||
|
"Sec-Fetch-Dest": "empty",
|
||||||
|
"Sec-Fetch-Mode": "cors",
|
||||||
|
"Sec-Fetch-Site": "same-origin",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Priority": "u=1, i",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 添加 Cookie 头(关键修复)
|
||||||
|
if token_id:
|
||||||
|
try:
|
||||||
|
from src.core.database import Database
|
||||||
|
db = Database()
|
||||||
|
token_obj = await db.get_token(token_id)
|
||||||
|
if token_obj and token_obj.st:
|
||||||
|
# 添加 session token cookie
|
||||||
|
headers["Cookie"] = f"__Secure-next-auth.session-token={token_obj.st}"
|
||||||
|
debug_logger.log_info(f"[nf/create] Added session token cookie (length: {len(token_obj.st)})")
|
||||||
|
else:
|
||||||
|
debug_logger.log_warning("[nf/create] No session token (st) found for this token")
|
||||||
|
except Exception as e:
|
||||||
|
debug_logger.log_warning(f"[nf/create] Failed to get session token: {e}")
|
||||||
|
|
||||||
|
# 记录详细的 Sentinel Token 信息
|
||||||
|
debug_logger.log_info(f"[nf/create] Preparing request to {url}")
|
||||||
|
debug_logger.log_info(f"[nf/create] Device ID: {device_id}")
|
||||||
|
|
||||||
|
# Sentinel Token 前100字符和后50字符
|
||||||
|
if len(sentinel_token) > 150:
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token (first 100 chars): {sentinel_token[:100]}...")
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token (last 50 chars): ...{sentinel_token[-50:]}")
|
||||||
|
else:
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token: {sentinel_token}")
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token length: {len(sentinel_token)}")
|
||||||
|
|
||||||
|
# Sentinel Token 结构信息
|
||||||
|
debug_logger.log_info(f"[nf/create] Sentinel Token structure:")
|
||||||
|
if "p" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - p (POW) length: {len(sentinel_data['p'])}")
|
||||||
|
if "t" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - t (Turnstile) length: {len(sentinel_data['t'])}")
|
||||||
|
if "c" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - c (Challenge) length: {len(sentinel_data['c'])}")
|
||||||
|
if "id" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - id: {sentinel_data['id']}")
|
||||||
|
if "flow" in sentinel_data:
|
||||||
|
debug_logger.log_info(f" - flow: {sentinel_data['flow']}")
|
||||||
|
|
||||||
|
# 使用 log_request 方法记录完整的请求详情
|
||||||
|
debug_logger.log_request(
|
||||||
|
method="POST",
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
body=payload,
|
||||||
|
proxy=proxy_url,
|
||||||
|
source="Server"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
self._post_json_sync, url, headers, payload, 30, proxy_url
|
self._post_json_sync, url, headers, payload, 30, proxy_url
|
||||||
)
|
)
|
||||||
|
debug_logger.log_info(f"[nf/create] Request succeeded, task_id: {result.get('id', 'N/A')}")
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
debug_logger.log_error(
|
debug_logger.log_error(
|
||||||
error_message=f"nf/create request failed: {str(e)}",
|
error_message=f"nf/create request failed: {error_str}",
|
||||||
status_code=0,
|
status_code=0,
|
||||||
response_text=str(e),
|
response_text=error_str,
|
||||||
source="Server"
|
source="Server"
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def _generate_sentinel_token(self, token: Optional[str] = None) -> str:
|
@staticmethod
|
||||||
"""Generate openai-sentinel-token by calling /backend-api/sentinel/req and solving PoW"""
|
def _post_text_sync(url: str, headers: dict, body: str, timeout: int, proxy: Optional[str]) -> Dict[str, Any]:
|
||||||
|
data = body.encode("utf-8")
|
||||||
|
req = Request(url, data=data, headers=headers, method="POST")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if proxy:
|
||||||
|
opener = build_opener(ProxyHandler({"http": proxy, "https": proxy}))
|
||||||
|
resp = opener.open(req, timeout=timeout)
|
||||||
|
else:
|
||||||
|
resp = urlopen(req, timeout=timeout)
|
||||||
|
|
||||||
|
resp_text = resp.read().decode("utf-8")
|
||||||
|
if resp.status not in (200, 201):
|
||||||
|
raise Exception(f"Request failed: {resp.status} {resp_text}")
|
||||||
|
return json.loads(resp_text)
|
||||||
|
except HTTPError as exc:
|
||||||
|
body_text = exc.read().decode("utf-8", errors="ignore")
|
||||||
|
raise Exception(f"HTTP Error: {exc.code} {body_text}") from exc
|
||||||
|
except URLError as exc:
|
||||||
|
raise Exception(f"URL Error: {exc}") from exc
|
||||||
|
|
||||||
|
async def _generate_sentinel_token(self, token: Optional[str] = None, user_agent: Optional[str] = None) -> Tuple[str, str]:
|
||||||
|
"""Generate openai-sentinel-token by calling /backend-api/sentinel/req and solving PoW
|
||||||
|
|
||||||
|
Supports two modes:
|
||||||
|
- external: Get complete sentinel token from external POW service
|
||||||
|
- local: Generate POW locally and call sentinel/req endpoint
|
||||||
|
"""
|
||||||
|
# Check if external POW service is configured
|
||||||
|
if config.pow_service_mode == "external":
|
||||||
|
debug_logger.log_info("[Sentinel] Using external POW service...")
|
||||||
|
result = await pow_service_client.get_sentinel_token()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
sentinel_token, device_id, service_user_agent = result
|
||||||
|
# Use service user agent if provided, otherwise use default
|
||||||
|
final_user_agent = service_user_agent if service_user_agent else (
|
||||||
|
user_agent if user_agent else
|
||||||
|
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
debug_logger.log_info(f"[Sentinel] Got token from external service")
|
||||||
|
debug_logger.log_info(f"[Sentinel] Token cached successfully (external)")
|
||||||
|
return sentinel_token, final_user_agent
|
||||||
|
else:
|
||||||
|
# Fallback to local mode if external service fails
|
||||||
|
debug_logger.log_info("[Sentinel] External service failed, falling back to local mode")
|
||||||
|
|
||||||
|
# Local mode (original logic)
|
||||||
|
debug_logger.log_info("[POW] Using local POW generation")
|
||||||
req_id = str(uuid4())
|
req_id = str(uuid4())
|
||||||
user_agent = random.choice(DESKTOP_USER_AGENTS)
|
if not user_agent:
|
||||||
|
user_agent = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36"
|
||||||
|
|
||||||
pow_token = self._get_pow_token(user_agent)
|
pow_token = self._get_pow_token(user_agent)
|
||||||
|
|
||||||
|
init_payload = {
|
||||||
|
"p": pow_token,
|
||||||
|
"id": req_id,
|
||||||
|
"flow": "sora_init"
|
||||||
|
}
|
||||||
|
ua_with_pow = f"{user_agent} {json.dumps(init_payload, separators=(',', ':'))}"
|
||||||
|
|
||||||
proxy_url = await self.proxy_manager.get_proxy_url()
|
proxy_url = await self.proxy_manager.get_proxy_url()
|
||||||
|
|
||||||
# Request sentinel/req endpoint
|
# Request sentinel/req endpoint
|
||||||
url = f"{self.CHATGPT_BASE_URL}/backend-api/sentinel/req"
|
url = f"{self.CHATGPT_BASE_URL}/backend-api/sentinel/req"
|
||||||
payload = {"p": pow_token, "flow": self.SENTINEL_FLOW, "id": req_id}
|
request_payload = {
|
||||||
headers = {
|
"p": pow_token,
|
||||||
"Accept": "application/json, text/plain, */*",
|
"id": req_id,
|
||||||
"Content-Type": "application/json",
|
"flow": "sora_init"
|
||||||
"Origin": "https://sora.chatgpt.com",
|
}
|
||||||
"Referer": "https://sora.chatgpt.com/",
|
request_body = json.dumps(request_payload, separators=(',', ':'))
|
||||||
"User-Agent": user_agent,
|
|
||||||
|
headers = {
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Content-Type": "text/plain;charset=UTF-8",
|
||||||
|
"Origin": "https://chatgpt.com",
|
||||||
|
"Referer": "https://chatgpt.com/backend-api/sentinel/frame.html",
|
||||||
|
"User-Agent": ua_with_pow,
|
||||||
|
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="131", "Google Chrome";v="131"',
|
||||||
|
"sec-ch-ua-mobile": "?1",
|
||||||
|
"sec-ch-ua-platform": '"Android"',
|
||||||
}
|
}
|
||||||
if token:
|
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await asyncio.to_thread(
|
async with AsyncSession(impersonate="chrome131") as session:
|
||||||
self._post_json_sync, url, headers, payload, 10, proxy_url
|
response = await session.post(
|
||||||
)
|
url,
|
||||||
|
headers=headers,
|
||||||
|
data=request_body,
|
||||||
|
proxy=proxy_url,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Sentinel request failed: {response.status_code} {response.text}")
|
||||||
|
resp = response.json()
|
||||||
|
|
||||||
|
debug_logger.log_info(f"Sentinel response: turnstile.dx={bool(resp.get('turnstile', {}).get('dx'))}, token={bool(resp.get('token'))}, pow_required={resp.get('proofofwork', {}).get('required')}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
debug_logger.log_error(
|
debug_logger.log_error(
|
||||||
error_message=f"Sentinel request failed: {str(e)}",
|
error_message=f"Sentinel request failed: {str(e)}",
|
||||||
@@ -269,7 +835,12 @@ class SoraClient:
|
|||||||
sentinel_token = self._build_sentinel_token(
|
sentinel_token = self._build_sentinel_token(
|
||||||
self.SENTINEL_FLOW, req_id, pow_token, resp, user_agent
|
self.SENTINEL_FLOW, req_id, pow_token, resp, user_agent
|
||||||
)
|
)
|
||||||
return sentinel_token
|
|
||||||
|
# Log final token for debugging
|
||||||
|
parsed = json.loads(sentinel_token)
|
||||||
|
debug_logger.log_info(f"Final sentinel: p_prefix={parsed['p'][:10]}, p_suffix={parsed['p'][-5:]}, t_len={len(parsed['t'])}, c_len={len(parsed['c'])}, flow={parsed['flow']}")
|
||||||
|
|
||||||
|
return sentinel_token, user_agent
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_storyboard_prompt(prompt: str) -> bool:
|
def is_storyboard_prompt(prompt: str) -> bool:
|
||||||
@@ -358,7 +929,9 @@ class SoraClient:
|
|||||||
|
|
||||||
# 只在生成请求时添加 sentinel token
|
# 只在生成请求时添加 sentinel token
|
||||||
if add_sentinel_token:
|
if add_sentinel_token:
|
||||||
headers["openai-sentinel-token"] = await self._generate_sentinel_token(token)
|
sentinel_token, ua = await self._generate_sentinel_token(token)
|
||||||
|
headers["openai-sentinel-token"] = sentinel_token
|
||||||
|
headers["User-Agent"] = ua
|
||||||
|
|
||||||
if not multipart:
|
if not multipart:
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
@@ -557,11 +1130,70 @@ class SoraClient:
|
|||||||
"style_id": style_id
|
"style_id": style_id
|
||||||
}
|
}
|
||||||
|
|
||||||
# 生成请求需要添加 sentinel token
|
|
||||||
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
|
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
|
||||||
sentinel_token = await self._generate_sentinel_token(token)
|
|
||||||
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, token_id)
|
# Get POW proxy from configuration (unified with pow_service config)
|
||||||
return result["id"]
|
pow_proxy_url = None
|
||||||
|
if config.pow_service_proxy_enabled:
|
||||||
|
pow_proxy_url = config.pow_service_proxy_url or None
|
||||||
|
|
||||||
|
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
# Try to get cached sentinel token first (using lightweight Playwright approach)
|
||||||
|
try:
|
||||||
|
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=False)
|
||||||
|
except Exception as e:
|
||||||
|
# 403/429 errors from oai-did fetch - don't retry, just fail
|
||||||
|
error_str = str(e)
|
||||||
|
if "403" in error_str or "429" in error_str:
|
||||||
|
debug_logger.log_error(
|
||||||
|
error_message=f"Failed to get sentinel token: {error_str}",
|
||||||
|
status_code=403 if "403" in error_str else 429,
|
||||||
|
response_text=error_str,
|
||||||
|
source="Server"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
sentinel_token = None
|
||||||
|
|
||||||
|
if not sentinel_token:
|
||||||
|
# Fallback to manual POW if lightweight approach fails
|
||||||
|
debug_logger.log_info("[Warning] Lightweight sentinel token failed, falling back to manual POW")
|
||||||
|
sentinel_token, user_agent = await self._generate_sentinel_token(token)
|
||||||
|
|
||||||
|
# First attempt with cached/generated token
|
||||||
|
try:
|
||||||
|
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, token_id, user_agent)
|
||||||
|
return result["id"]
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
|
||||||
|
# Check if it's a 400 error (sentinel token invalid)
|
||||||
|
if "400" in error_str or "sentinel" in error_str.lower() or "invalid" in error_str.lower():
|
||||||
|
debug_logger.log_info("[Sentinel] Got 400 error, refreshing token and retrying...")
|
||||||
|
|
||||||
|
# Invalidate cache and get fresh token
|
||||||
|
_invalidate_sentinel_cache()
|
||||||
|
|
||||||
|
try:
|
||||||
|
sentinel_token = await _get_cached_sentinel_token(pow_proxy_url, force_refresh=True)
|
||||||
|
except Exception as refresh_e:
|
||||||
|
# 403/429 errors - don't continue
|
||||||
|
error_str = str(refresh_e)
|
||||||
|
if "403" in error_str or "429" in error_str:
|
||||||
|
raise refresh_e
|
||||||
|
sentinel_token = None
|
||||||
|
|
||||||
|
if not sentinel_token:
|
||||||
|
# Fallback to manual POW
|
||||||
|
debug_logger.log_info("[Warning] Refresh failed, falling back to manual POW")
|
||||||
|
sentinel_token, user_agent = await self._generate_sentinel_token(token)
|
||||||
|
|
||||||
|
# Retry with fresh token
|
||||||
|
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, token_id, user_agent)
|
||||||
|
return result["id"]
|
||||||
|
|
||||||
|
# For other errors, just re-raise
|
||||||
|
raise
|
||||||
|
|
||||||
async def get_image_tasks(self, token: str, limit: int = 20, token_id: Optional[int] = None) -> Dict[str, Any]:
|
async def get_image_tasks(self, token: str, limit: int = 20, token_id: Optional[int] = None) -> Dict[str, Any]:
|
||||||
"""Get recent image generation tasks"""
|
"""Get recent image generation tasks"""
|
||||||
@@ -969,8 +1601,8 @@ class SoraClient:
|
|||||||
|
|
||||||
# Generate sentinel token and call /nf/create using urllib
|
# Generate sentinel token and call /nf/create using urllib
|
||||||
proxy_url = await self.proxy_manager.get_proxy_url()
|
proxy_url = await self.proxy_manager.get_proxy_url()
|
||||||
sentinel_token = await self._generate_sentinel_token(token)
|
sentinel_token, user_agent = await self._generate_sentinel_token(token)
|
||||||
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url)
|
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, user_agent=user_agent)
|
||||||
return result.get("id")
|
return result.get("id")
|
||||||
|
|
||||||
async def generate_storyboard(self, prompt: str, token: str, orientation: str = "landscape",
|
async def generate_storyboard(self, prompt: str, token: str, orientation: str = "landscape",
|
||||||
|
|||||||
@@ -946,19 +946,21 @@ class TokenManager:
|
|||||||
|
|
||||||
async def update_token_status(self, token_id: int, is_active: bool):
|
async def update_token_status(self, token_id: int, is_active: bool):
|
||||||
"""Update token active status"""
|
"""Update token active status"""
|
||||||
await self.db.update_token_status(token_id, is_active)
|
# When manually changing status, set appropriate disabled_reason
|
||||||
|
disabled_reason = None if is_active else "manual"
|
||||||
|
await self.db.update_token_status(token_id, is_active, disabled_reason)
|
||||||
|
|
||||||
async def enable_token(self, token_id: int):
|
async def enable_token(self, token_id: int):
|
||||||
"""Enable a token and reset error count"""
|
"""Enable a token and reset error count"""
|
||||||
await self.db.update_token_status(token_id, True)
|
await self.db.update_token_status(token_id, True, None) # Clear disabled_reason
|
||||||
# Reset error count when enabling (in token_stats table)
|
# Reset error count when enabling (in token_stats table)
|
||||||
await self.db.reset_error_count(token_id)
|
await self.db.reset_error_count(token_id)
|
||||||
# Clear expired flag when enabling
|
# Clear expired flag when enabling
|
||||||
await self.db.clear_token_expired(token_id)
|
await self.db.clear_token_expired(token_id)
|
||||||
|
|
||||||
async def disable_token(self, token_id: int):
|
async def disable_token(self, token_id: int):
|
||||||
"""Disable a token"""
|
"""Disable a token (manual disable)"""
|
||||||
await self.db.update_token_status(token_id, False)
|
await self.db.update_token_status(token_id, False, "manual")
|
||||||
|
|
||||||
async def test_token(self, token_id: int) -> dict:
|
async def test_token(self, token_id: int) -> dict:
|
||||||
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
|
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
|
||||||
@@ -1048,6 +1050,14 @@ class TokenManager:
|
|||||||
"valid": False,
|
"valid": False,
|
||||||
"message": "Token已过期(token_invalidated)"
|
"message": "Token已过期(token_invalidated)"
|
||||||
}
|
}
|
||||||
|
# Check if error is "Failed to get user info:401"
|
||||||
|
if "Failed to get user info:401" in error_msg or "Failed to get user info: 401" in error_msg:
|
||||||
|
# Mark token as invalid and disable it
|
||||||
|
await self.db.mark_token_invalid(token_id)
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"message": "Token无效: Token is invalid: Failed to get user info:401"
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"valid": False,
|
"valid": False,
|
||||||
"message": f"Token is invalid: {error_msg}"
|
"message": f"Token is invalid: {error_msg}"
|
||||||
@@ -1077,7 +1087,8 @@ class TokenManager:
|
|||||||
admin_config = await self.db.get_admin_config()
|
admin_config = await self.db.get_admin_config()
|
||||||
|
|
||||||
if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
|
if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
|
||||||
await self.db.update_token_status(token_id, False)
|
# Disable token with error_limit reason
|
||||||
|
await self.db.update_token_status(token_id, False, "error_limit")
|
||||||
|
|
||||||
async def record_success(self, token_id: int, is_video: bool = False):
|
async def record_success(self, token_id: int, is_video: bool = False):
|
||||||
"""Record successful request (reset error count)"""
|
"""Record successful request (reset error count)"""
|
||||||
|
|||||||
@@ -390,6 +390,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 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">
|
<div class="rounded-lg border border-border bg-background p-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
|
<h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
|
||||||
@@ -1016,19 +1058,21 @@
|
|||||||
const $=(id)=>document.getElementById(id),
|
const $=(id)=>document.getElementById(id),
|
||||||
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
|
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
|
||||||
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
|
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
|
||||||
|
// 获取token的状态文本和样式
|
||||||
|
getTokenStatus=(token)=>{if(token.is_active){return{text:'活跃',class:'bg-green-50 text-green-700'}}const reason=token.disabled_reason;if(reason==='token_invalid'||reason==='expired'){return{text:'失效',class:'bg-gray-100 text-gray-700'}}if(reason==='manual'||reason==='error_limit'){return{text:'禁用',class:'bg-gray-100 text-gray-700'}}return{text:'禁用',class:'bg-gray-100 text-gray-700'}},
|
||||||
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
|
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
|
||||||
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();updateStatusFilterOptions();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();updateStatusFilterOptions();renderTokens()}catch(e){console.error('加载Token失败:',e)}},
|
||||||
updateStatusFilterOptions=()=>{const statusSet=new Set();allTokens.forEach(t=>{const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');statusSet.add(statusText)});const dropdown=$('statusFilterDropdown');if(!dropdown)return;const statuses=Array.from(statusSet).sort();dropdown.innerHTML='<div class="py-1"><button onclick="selectStatusFilter(\'\')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>'+statuses.map(s=>`<button onclick="selectStatusFilter('${s}')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="${s}">${s}</button>`).join('')+'</div>';updateStatusFilterLabel()},
|
updateStatusFilterOptions=()=>{const statusSet=new Set();allTokens.forEach(t=>{const status=getTokenStatus(t);statusSet.add(status.text)});const dropdown=$('statusFilterDropdown');if(!dropdown)return;const statuses=Array.from(statusSet).sort();dropdown.innerHTML='<div class="py-1"><button onclick="selectStatusFilter(\'\')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="">全部</button>'+statuses.map(s=>`<button onclick="selectStatusFilter('${s}')" class="w-full text-left px-4 py-2 text-sm hover:bg-accent transition-colors" data-filter-option="${s}">${s}</button>`).join('')+'</div>';updateStatusFilterLabel()},
|
||||||
updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}},
|
updateStatusFilterLabel=()=>{const label=$('statusFilterLabel');if(label){label.textContent=currentStatusFilter||'全部'}},
|
||||||
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
|
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
|
||||||
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
|
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
|
||||||
applyStatusFilter=()=>{currentPage=1;renderTokens()},
|
applyStatusFilter=()=>{currentPage=1;renderTokens()},
|
||||||
getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');return statusText===currentStatusFilter})},
|
getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const status=getTokenStatus(t);return status.text===currentStatusFilter})},
|
||||||
formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`},
|
formatExpiry=exp=>{if(!exp)return'-';const d=new Date(exp),now=new Date(),diff=d-now;const dateStr=d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-');const timeStr=d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false});if(diff<0)return`<span class="text-red-600">${dateStr} ${timeStr}</span>`;const days=Math.floor(diff/864e5);if(days<7)return`<span class="text-orange-600">${dateStr} ${timeStr}</span>`;return`${dateStr} ${timeStr}`},
|
||||||
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
|
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
|
||||||
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
|
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
|
||||||
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
|
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
|
||||||
renderTokens=()=>{const filteredTokens=getFilteredTokens();const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=filteredTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const remainingCount=t.sora2_remaining_count!==undefined&&t.sora2_remaining_count!==null?t.sora2_remaining_count:'-';const statusText=t.is_expired?'已过期':(t.is_active?'活跃':'禁用');const statusClass=t.is_expired?'bg-gray-100 text-gray-700':(t.is_active?'bg-green-50 text-green-700':'bg-gray-100 text-gray-700');return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${remainingCount}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
|
renderTokens=()=>{const filteredTokens=getFilteredTokens();const start=(currentPage-1)*pageSize,end=start+pageSize,paginatedTokens=filteredTokens.slice(start,end);const tb=$('tokenTableBody');tb.innerHTML=paginatedTokens.map(t=>{const imageDisplay=t.image_enabled?`${t.image_count||0}`:'-';const videoDisplay=t.video_enabled?`${t.video_count||0}`:'-';const remainingCount=t.sora2_remaining_count!==undefined&&t.sora2_remaining_count!==null?t.sora2_remaining_count:'-';const status=getTokenStatus(t);const statusText=status.text;const statusClass=status.class;return`<tr><td class=\"py-2.5 px-3\"><input type=\"checkbox\" class=\"token-checkbox h-4 w-4 rounded border-gray-300\" data-token-id=\"${t.id}\" onchange=\"toggleTokenSelection(${t.id},this.checked)\" ${selectedTokenIds.has(t.id)?'checked':''}></td><td class=\"py-2.5 px-3\">${t.email}</td><td class=\"py-2.5 px-3\"><span class=\"inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}\">${statusText}</span></td><td class="py-2.5 px-3">${formatClientId(t.client_id)}</td><td class="py-2.5 px-3 text-xs">${formatExpiry(t.expiry_time)}</td><td class="py-2.5 px-3 text-xs">${formatPlanTypeWithTooltip(t)}</td><td class="py-2.5 px-3">${remainingCount}</td><td class="py-2.5 px-3">${imageDisplay}</td><td class="py-2.5 px-3">${videoDisplay}</td><td class="py-2.5 px-3">${t.error_count||0}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${t.remark||'-'}</td><td class="py-2.5 px-3 text-right"><button onclick="testToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs mr-1">测试</button><button onclick="openEditModal(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-green-50 hover:text-green-700 h-7 px-2 text-xs mr-1">编辑</button><button onclick="toggleToken(${t.id},${t.is_active})" class="inline-flex items-center justify-center rounded-md hover:bg-accent h-7 px-2 text-xs mr-1">${t.is_active?'禁用':'启用'}</button><button onclick="deleteToken(${t.id})" class="inline-flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive h-7 px-2 text-xs">删除</button></td></tr>`}).join('');renderPagination()},
|
||||||
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
refreshTokens=async()=>{await loadTokens();await loadStats()},
|
||||||
changePage=(page)=>{currentPage=page;renderTokens()},
|
changePage=(page)=>{currentPage=page;renderTokens()},
|
||||||
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
|
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
|
||||||
@@ -1086,9 +1130,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')}},
|
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}},
|
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)}},
|
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()},
|
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')},
|
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')}},
|
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')}},
|
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')}},
|
||||||
@@ -1098,7 +1142,13 @@
|
|||||||
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
|
||||||
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');$('cfgCallLogicMode').value=mode}else{console.error('调用逻辑配置数据格式错误:',d)}}catch(e){console.error('加载调用逻辑配置失败:',e)}},
|
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');$('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')}},
|
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()}};
|
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';$('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 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,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 高度
|
// 自适应生成面板 iframe 高度
|
||||||
window.addEventListener('message', (event) => {
|
window.addEventListener('message', (event) => {
|
||||||
const data = event.data || {};
|
const data = event.data || {};
|
||||||
|
|||||||
Reference in New Issue
Block a user