Compare commits

...

4 Commits

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

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

View File

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

View File

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

View File

@@ -167,6 +167,10 @@ class UpdateCallLogicConfigRequest(BaseModel):
call_mode: Optional[str] = None # "default" or "polling"
polling_mode_enabled: Optional[bool] = None # Legacy support
class UpdatePowProxyConfigRequest(BaseModel):
pow_proxy_enabled: bool
pow_proxy_url: Optional[str] = None
class BatchDisableRequest(BaseModel):
token_ids: List[int]
@@ -1369,6 +1373,36 @@ async def update_call_logic_config(
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to update call logic configuration: {str(e)}")
# POW proxy config endpoints
@router.get("/api/pow-proxy/config")
async def get_pow_proxy_config(token: str = Depends(verify_admin_token)) -> dict:
"""Get POW proxy configuration"""
config_obj = await db.get_pow_proxy_config()
return {
"success": True,
"config": {
"pow_proxy_enabled": config_obj.pow_proxy_enabled,
"pow_proxy_url": config_obj.pow_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"""
try:
await db.update_pow_proxy_config(request.pow_proxy_enabled, request.pow_proxy_url)
config.set_pow_proxy_enabled(request.pow_proxy_enabled)
config.set_pow_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)}")
# Task management endpoints
@router.post("/api/tasks/{task_id}/cancel")
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
@@ -1391,7 +1425,26 @@ async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):
for log in logs:
if log.get("task_id") == task_id and log.get("status_code") == -1:
import time
duration = time.time() - (log.get("created_at").timestamp() if log.get("created_at") else time.time())
from datetime import datetime
# Calculate duration
created_at = log.get("created_at")
if created_at:
# If created_at is a string, parse it
if isinstance(created_at, str):
try:
created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
duration = time.time() - created_at.timestamp()
except:
duration = 0
# If it's already a datetime object
elif isinstance(created_at, datetime):
duration = time.time() - created_at.timestamp()
else:
duration = 0
else:
duration = 0
await db.update_request_log(
log.get("id"),
response_body='{"error": "用户手动取消任务"}',

View File

@@ -236,5 +236,27 @@ class Config:
self._config["call_logic"]["call_mode"] = normalized
self._config["call_logic"]["polling_mode_enabled"] = normalized == "polling"
@property
def pow_proxy_enabled(self) -> bool:
"""Get POW proxy enabled status"""
return self._config.get("pow_proxy", {}).get("pow_proxy_enabled", False)
def set_pow_proxy_enabled(self, enabled: bool):
"""Set POW proxy enabled/disabled"""
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"""
return self._config.get("pow_proxy", {}).get("pow_proxy_url", "")
def set_pow_proxy_url(self, url: str):
"""Set POW proxy URL"""
if "pow_proxy" not in self._config:
self._config["pow_proxy"] = {}
self._config["pow_proxy"]["pow_proxy_url"] = url
# Global config instance
config = Config()

View File

@@ -180,6 +180,50 @@ class Database:
VALUES (1, ?)
""", (at_auto_refresh_enabled,))
# Ensure call_logic_config has a row
cursor = await db.execute("SELECT COUNT(*) FROM call_logic_config")
count = await cursor.fetchone()
if count[0] == 0:
# Get call logic config from config_dict if provided, otherwise use defaults
call_mode = "default"
polling_mode_enabled = False
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))
async def check_and_migrate_db(self, config_dict: dict = None):
"""Check database integrity and perform migrations if needed
@@ -386,6 +430,9 @@ class Database:
admin_password TEXT DEFAULT 'admin',
api_key TEXT DEFAULT 'han1234',
error_ban_threshold INTEGER DEFAULT 3,
task_retry_enabled BOOLEAN DEFAULT 1,
task_max_retries INTEGER DEFAULT 3,
auto_disable_on_401 BOOLEAN DEFAULT 1,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
@@ -459,6 +506,17 @@ 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 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_status ON tasks(status)")
@@ -1010,9 +1068,12 @@ class Database:
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE admin_config
SET admin_username = ?, admin_password = ?, api_key = ?, error_ban_threshold = ?, updated_at = CURRENT_TIMESTAMP
SET admin_username = ?, admin_password = ?, api_key = ?, error_ban_threshold = ?,
task_retry_enabled = ?, task_max_retries = ?, auto_disable_on_401 = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = 1
""", (config.admin_username, config.admin_password, config.api_key, config.error_ban_threshold))
""", (config.admin_username, config.admin_password, config.api_key, config.error_ban_threshold,
config.task_retry_enabled, config.task_max_retries, config.auto_disable_on_401))
await db.commit()
# Proxy config operations
@@ -1196,10 +1257,32 @@ class Database:
normalized = "polling" if call_mode == "polling" else "default"
polling_mode_enabled = normalized == "polling"
async with aiosqlite.connect(self.db_path) as db:
# Use INSERT OR REPLACE to ensure the row exists
await db.execute("""
UPDATE call_logic_config
SET polling_mode_enabled = ?, call_mode = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1
""", (polling_mode_enabled, normalized))
INSERT OR REPLACE INTO call_logic_config (id, call_mode, polling_mode_enabled, updated_at)
VALUES (1, ?, ?, CURRENT_TIMESTAMP)
""", (normalized, polling_mode_enabled))
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 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()

View File

@@ -146,6 +146,14 @@ class CallLogicConfig(BaseModel):
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class PowProxyConfig(BaseModel):
"""POW proxy configuration"""
id: int = 1
pow_proxy_enabled: bool = False # Whether to enable POW proxy
pow_proxy_url: Optional[str] = None # POW proxy URL (e.g., http://127.0.0.1:7890)
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
# API Request/Response models
class ChatMessage(BaseModel):
role: str

View File

@@ -521,6 +521,7 @@ class GenerationHandler:
task_id = None
is_first_chunk = True # Track if this is the first chunk
log_id = None # Initialize log_id
log_updated = False # Track if log has been updated
try:
# Create initial log entry BEFORE submitting task to upstream
@@ -680,6 +681,7 @@ class GenerationHandler:
status_code=200,
duration=duration
)
log_updated = True # Mark log as updated
except Exception as e:
# Release lock for image generation on error
@@ -727,6 +729,7 @@ class GenerationHandler:
status_code=status_code,
duration=duration
)
log_updated = True # Mark log as updated
else:
# Generic error
await self.db.update_request_log(
@@ -735,12 +738,35 @@ class GenerationHandler:
status_code=500,
duration=duration
)
log_updated = True # Mark log as updated
# Wrap exception with token_id information
if token_obj:
raise GenerationError(str(e), token_id=token_obj.id)
else:
raise e
finally:
# Ensure log is updated even if exception handling fails
# This prevents logs from being stuck at status_code = -1
if log_id and not log_updated:
try:
# Log was not updated in try or except blocks, update it now
duration = time.time() - start_time
await self.db.update_request_log(
log_id,
response_body=json.dumps({"error": "Task failed or interrupted during processing"}),
status_code=500,
duration=duration
)
debug_logger.log_info(f"Updated stuck log entry {log_id} from status -1 to 500 in finally block")
except Exception as finally_error:
# Don't let finally block errors break the flow
debug_logger.log_error(
error_message=f"Failed to update log in finally block: {str(finally_error)}",
status_code=500,
response_text=str(finally_error)
)
async def handle_generation_with_retry(self, model: str, prompt: str,
image: Optional[str] = None,
video: Optional[str] = None,

View File

@@ -19,14 +19,272 @@ from .proxy_manager import ProxyManager
from ..core.config import config
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
# 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_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 = [
"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 = [
"mimeTypes[object MimeTypeArray]",
"userAgentData[object NavigatorUAData]",
"scheduling[object Scheduling]",
"keyboard[object Keyboard]",
"webkitPersistentStorage[object DeprecatedStorageQuota]",
"registerProtocolHandlerfunction registerProtocolHandler() { [native code] }",
"storage[object StorageManager]",
"locks[object LockManager]",
@@ -41,12 +299,31 @@ POW_NAVIGATOR_KEYS = [
"hardwareConcurrency32",
"onLinetrue",
]
POW_DOCUMENT_KEYS = ["_reactListeningo743lnnpvdg", "location"]
POW_DOCUMENT_KEYS = [
"__reactContainer$3k0e9yog4o3",
"__reactContainer$ft149nhgior",
"__reactResources$9nnifsagitb",
"_reactListeningou2wvttp2d9",
"_reactListeningu9qurgpwsme",
"_reactListeningo743lnnpvdg",
"location",
"body",
]
POW_WINDOW_KEYS = [
"getSelection",
"btoa",
"__next_s",
"crossOriginIsolated",
"print",
"0", "window", "self", "document", "name", "location",
"navigator", "screen", "innerWidth", "innerHeight",
"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
@@ -74,7 +351,7 @@ class SoraClient:
"""Sora API client with proxy support"""
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):
self.proxy_manager = proxy_manager
@@ -83,32 +360,50 @@ class SoraClient:
@staticmethod
def _get_pow_parse_time() -> str:
"""Generate time string for PoW (EST timezone)"""
now = datetime.now(timezone(timedelta(hours=-5)))
return now.strftime("%a %b %d %Y %H:%M:%S") + " GMT-0500 (Eastern Standard Time)"
"""Generate time string for PoW (local timezone)"""
now = datetime.now()
# 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
def _get_pow_config(user_agent: str) -> list:
"""Generate PoW config array with browser fingerprint"""
lang = random.choice(POW_LANGUAGES)
perf_time = random.uniform(10000, 100000)
return [
random.choice([1920 + 1080, 2560 + 1440, 1920 + 1200, 2560 + 1600]),
SoraClient._get_pow_parse_time(),
4294705152,
0, # [3] dynamic
user_agent,
random.choice(POW_SCRIPTS) if POW_SCRIPTS else "",
random.choice(POW_DPL) if POW_DPL else None,
"en-US",
"en-US,es-US,en,es",
0, # [9] dynamic
random.choice(POW_NAVIGATOR_KEYS),
random.choice(POW_DOCUMENT_KEYS),
random.choice(POW_WINDOW_KEYS),
time.perf_counter() * 1000,
str(uuid4()),
"",
random.choice(POW_CORES),
time.time() * 1000 - (time.perf_counter() * 1000),
random.choice(POW_SCREEN_SIZES), # [0] screen size
SoraClient._get_pow_parse_time(), # [1] time string (local timezone)
random.choice([4294967296, 4294705152, 2147483648]), # [2] jsHeapSizeLimit
0, # [3] iteration count (dynamic)
user_agent, # [4] UA
random.choice(POW_SCRIPTS) if POW_SCRIPTS else "", # [5] sora cdn script
None, # [6] must be null
lang[0], # [7] language
lang[1], # [8] languages
random.randint(2, 10), # [9] random initial value for dynamic calc
random.choice(POW_NAVIGATOR_KEYS), # [10] navigator key
random.choice(POW_DOCUMENT_KEYS), # [11] document key
random.choice(POW_WINDOW_KEYS), # [12] window key
perf_time, # [13] perf time (random)
str(uuid4()), # [14] UUID
"", # [15] empty
random.choice(POW_CORES), # [16] cores
time.time() * 1000 - perf_time, # [17] time origin
]
@staticmethod
@@ -121,10 +416,12 @@ class SoraClient:
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_part3 = (',' + json.dumps(config_list[10:], separators=(',', ':'), ensure_ascii=False)[1:]).encode()
initial_j = config_list[9]
for i in range(POW_MAX_ITERATION):
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
b64_encoded = base64.b64encode(final_json)
@@ -167,7 +464,7 @@ class SoraClient:
solution, success = SoraClient._solve_pow(seed, difficulty, config_list)
final_pow_token = "gAAAAAB" + solution
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"):
final_pow_token = final_pow_token + "~S"
@@ -203,18 +500,130 @@ class SoraClient:
except URLError as exc:
raise Exception(f"URL Error: {exc}") from exc
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]:
url = f"{self.base_url}/nf/create"
user_agent = random.choice(MOBILE_USER_AGENTS)
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,
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"
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 = {
"Authorization": f"Bearer {token}",
"openai-sentinel-token": sentinel_token,
"OpenAI-Sentinel-Token": sentinel_token,
"Content-Type": "application/json",
"User-Agent": user_agent,
"Origin": "https://sora.chatgpt.com",
"Referer": "https://sora.chatgpt.com/",
"OAI-Language": "en-US",
"OAI-Device-Id": device_id,
}
try:
@@ -223,39 +632,88 @@ class SoraClient:
)
return result
except Exception as e:
error_str = str(e)
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,
response_text=str(e),
response_text=error_str,
source="Server"
)
raise
async def _generate_sentinel_token(self, token: Optional[str] = None) -> str:
@staticmethod
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"""
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)
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()
# Request sentinel/req endpoint
url = f"{self.CHATGPT_BASE_URL}/backend-api/sentinel/req"
payload = {"p": pow_token, "flow": self.SENTINEL_FLOW, "id": req_id}
headers = {
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Origin": "https://sora.chatgpt.com",
"Referer": "https://sora.chatgpt.com/",
"User-Agent": user_agent,
request_payload = {
"p": pow_token,
"id": req_id,
"flow": "sora_init"
}
request_body = json.dumps(request_payload, separators=(',', ':'))
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:
resp = await asyncio.to_thread(
self._post_json_sync, url, headers, payload, 10, proxy_url
)
async with AsyncSession(impersonate="chrome131") as session:
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:
debug_logger.log_error(
error_message=f"Sentinel request failed: {str(e)}",
@@ -269,7 +727,12 @@ class SoraClient:
sentinel_token = self._build_sentinel_token(
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
def is_storyboard_prompt(prompt: str) -> bool:
@@ -358,7 +821,9 @@ class SoraClient:
# 只在生成请求时添加 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:
headers["Content-Type"] = "application/json"
@@ -557,11 +1022,70 @@ class SoraClient:
"style_id": style_id
}
# 生成请求需要添加 sentinel token
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)
return result["id"]
# Get POW proxy from configuration
pow_proxy_url = None
if config.pow_proxy_enabled:
pow_proxy_url = config.pow_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]:
"""Get recent image generation tasks"""
@@ -969,8 +1493,8 @@ class SoraClient:
# Generate sentinel token and call /nf/create using urllib
proxy_url = await self.proxy_manager.get_proxy_url()
sentinel_token = await self._generate_sentinel_token(token)
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url)
sentinel_token, user_agent = await self._generate_sentinel_token(token)
result = await self._nf_create_urllib(token, json_data, sentinel_token, proxy_url, user_agent=user_agent)
return result.get("id")
async def generate_storyboard(self, prompt: str, token: str, orientation: str = "landscape",

View File

@@ -390,6 +390,26 @@
</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="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="cfgPowProxyEnabled" 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>
<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="savePowProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
</div>
</div>
<!-- 错误处理配置 -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-lg font-semibold mb-4">错误处理配置</h3>
@@ -1086,9 +1106,9 @@
saveGenerationTimeout=async()=>{const imageTimeout=parseInt($('cfgImageTimeout').value)||300,videoTimeout=parseInt($('cfgVideoTimeout').value)||1500;console.log('保存生成超时配置:',{imageTimeout,videoTimeout});if(imageTimeout<60||imageTimeout>3600)return showToast('图片超时时间必须在 60-3600 秒之间','error');if(videoTimeout<60||videoTimeout>7200)return showToast('视频超时时间必须在 60-7200 秒之间','error');try{const r=await apiRequest('/api/generation/timeout',{method:'POST',body:JSON.stringify({image_timeout:imageTimeout,video_timeout:videoTimeout})});if(!r){console.error('保存请求失败');return}const d=await r.json();console.log('保存结果:',d);if(d.success){showToast('生成超时配置保存成功','success');await new Promise(r=>setTimeout(r,200));await loadGenerationTimeout()}else{console.error('保存失败:',d);showToast('保存失败','error')}}catch(e){console.error('保存失败:',e);showToast('保存失败: '+e.message,'error')}},
toggleATAutoRefresh=async()=>{try{const enabled=$('atAutoRefreshToggle').checked;const r=await apiRequest('/api/token-refresh/enabled',{method:'POST',body:JSON.stringify({enabled:enabled})});if(!r){$('atAutoRefreshToggle').checked=!enabled;return}const d=await r.json();if(d.success){showToast(enabled?'AT自动刷新已启用':'AT自动刷新已禁用','success')}else{showToast('操作失败: '+(d.detail||'未知错误'),'error');$('atAutoRefreshToggle').checked=!enabled}}catch(e){showToast('操作失败: '+e.message,'error');$('atAutoRefreshToggle').checked=!enabled}},
loadATAutoRefreshConfig=async()=>{try{const r=await apiRequest('/api/token-refresh/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('atAutoRefreshToggle').checked=d.config.at_auto_refresh_enabled||false}else{console.error('AT自动刷新配置数据格式错误:',d)}}catch(e){console.error('加载AT自动刷新配置失败:',e)}},
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>{const isProcessing=l.status_code===-1&&l.task_status==='processing';const isFailed=l.task_status==='failed';const isCompleted=l.task_status==='completed';const statusText=isProcessing?'处理中':l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='<span class="text-xs text-muted-foreground">-</span>';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`<div class="flex flex-col gap-1"><div class="flex items-center gap-2"><div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden"><div class="h-full bg-blue-500 transition-all" style="width:${progress}%"></div></div><span class="text-xs text-blue-600">${progress.toFixed(0)}%</span></div><span class="text-xs text-muted-foreground">${taskStatusText}</span></div>`}else if(isFailed){progressHtml='<span class="text-xs text-red-600">失败</span>'}else if(isCompleted&&l.status_code===200){progressHtml='<span class="text-xs text-green-600">已完成</span>'}let actionHtml='<button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button>';if(isProcessing&&l.task_id){actionHtml='<div class="flex gap-1"><button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button><button onclick="cancelTask(\''+l.task_id+'\')" class="inline-flex items-center justify-center rounded-md hover:bg-red-50 hover:text-red-700 h-7 px-2 text-xs">终止</button></div>'}return `<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}">${statusText}</span></td><td class="py-2.5 px-3">${progressHtml}</td><td class="py-2.5 px-3">${l.duration===-1?'处理中':l.duration.toFixed(2)+'秒'}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3">${actionHtml}</td></tr>`}).join('')}catch(e){console.error('加载日志失败:',e)}},
loadLogs=async()=>{try{const r=await apiRequest('/api/logs?limit=100');if(!r)return;const logs=await r.json();window.allLogs=logs;const tb=$('logsTableBody');tb.innerHTML=logs.map(l=>{const isProcessing=l.status_code===-1;const isFailed=l.task_status==='failed';const isCompleted=l.task_status==='completed';const progress=l.progress||0;const statusText=isProcessing?(progress>0?'生成中':'排队中'):l.status_code;const statusClass=isProcessing?'bg-blue-50 text-blue-700':l.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700';let progressHtml='<span class="text-xs text-muted-foreground">-</span>';if(isProcessing&&l.task_status){const taskStatusMap={processing:'生成中',completed:'已完成',failed:'失败'};const taskStatusText=taskStatusMap[l.task_status]||l.task_status;const progress=l.progress||0;progressHtml=`<div class="flex flex-col gap-1"><div class="flex items-center gap-2"><div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden"><div class="h-full bg-blue-500 transition-all" style="width:${progress}%"></div></div><span class="text-xs text-blue-600">${progress.toFixed(0)}%</span></div><span class="text-xs text-muted-foreground">${taskStatusText}</span></div>`}else if(isFailed){progressHtml='<span class="text-xs text-red-600">失败</span>'}else if(isCompleted&&l.status_code===200){progressHtml='<span class="text-xs text-green-600">已完成</span>'}let actionHtml='<button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button>';if(isProcessing&&l.task_id){actionHtml='<div class="flex gap-1"><button onclick="showLogDetail('+l.id+')" class="inline-flex items-center justify-center rounded-md hover:bg-blue-50 hover:text-blue-700 h-7 px-2 text-xs">查看</button><button onclick="cancelTask(\''+l.task_id+'\')" class="inline-flex items-center justify-center rounded-md hover:bg-red-50 hover:text-red-700 h-7 px-2 text-xs">终止</button></div>'}return `<tr><td class="py-2.5 px-3">${l.operation}</td><td class="py-2.5 px-3"><span class="text-xs ${l.token_email?'text-blue-600':'text-muted-foreground'}">${l.token_email||'未知'}</span></td><td class="py-2.5 px-3"><span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${statusClass}">${statusText}</span></td><td class="py-2.5 px-3">${progressHtml}</td><td class="py-2.5 px-3">${l.duration===-1?'处理中':l.duration.toFixed(2)+'秒'}</td><td class="py-2.5 px-3 text-xs text-muted-foreground">${l.created_at?new Date(l.created_at).toLocaleString('zh-CN'):'-'}</td><td class="py-2.5 px-3">${actionHtml}</td></tr>`}).join('')}catch(e){console.error('加载日志失败:',e)}},
refreshLogs=async()=>{await loadLogs()},
showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===-1){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-blue-600">生成进度</h4><div class="rounded-md border border-blue-200 p-3 bg-blue-50"><p class="text-sm text-blue-700">任务正在生成中...</p>${log.task_status?`<p class="text-xs text-blue-600 mt-1">状态: ${log.task_status}</p>`:''}</div></div>`}else if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${item.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${item.url}</a></div></div>`}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应信息</h4><p class="text-sm text-muted-foreground">无响应数据</p></div>`}}catch(e){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${log.response_body||'无'}</pre></div>`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.error){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误原因</h4><div class="rounded-md border border-red-200 p-3 bg-red-50"><p class="text-sm text-red-700">${responseBody.error.message||responseBody.error||'未知错误'}</p></div></div>`}else if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}catch(e){if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}}detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${log.operation}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===-1?'bg-blue-50 text-blue-700':log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code===-1?'生成中':log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${log.duration===-1?'生成中':log.duration.toFixed(2)+'秒'}</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')},
showLogDetail=(logId)=>{const log=window.allLogs.find(l=>l.id===logId);if(!log){showToast('日志不存在','error');return}const content=$('logDetailContent');let detailHtml='';if(log.status_code===-1){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-blue-600">生成进度</h4><div class="rounded-md border border-blue-200 p-3 bg-blue-50"><p class="text-sm text-blue-700">任务正在生成中...</p>${log.task_status?`<p class="text-xs text-blue-600 mt-1">状态: ${log.task_status}</p>`:''}</div></div>`}else if(log.status_code===200){try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody){if(responseBody.data&&responseBody.data.length>0){const item=responseBody.data[0];if(item.url){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">生成结果</h4><div class="rounded-md border border-border p-3 bg-muted/30"><p class="text-sm mb-2"><span class="font-medium">文件URL:</span></p><a href="${item.url}" target="_blank" class="text-blue-600 hover:underline text-xs break-all">${item.url}</a></div></div>`}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${JSON.stringify(responseBody,null,2)}</pre></div>`}}else{detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应信息</h4><p class="text-sm text-muted-foreground">无响应数据</p></div>`}}catch(e){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm">响应数据</h4><pre class="rounded-md border border-border p-3 bg-muted/30 text-xs overflow-x-auto">${log.response_body||'无'}</pre></div>`}}else{try{const responseBody=log.response_body?JSON.parse(log.response_body):null;if(responseBody&&responseBody.error){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误原因</h4><div class="rounded-md border border-red-200 p-3 bg-red-50"><p class="text-sm text-red-700">${responseBody.error.message||responseBody.error||'未知错误'}</p></div></div>`}else if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}catch(e){if(log.response_body&&log.response_body!=='{}'){detailHtml+=`<div class="space-y-2"><h4 class="font-medium text-sm text-red-600">错误信息</h4><pre class="rounded-md border border-red-200 p-3 bg-red-50 text-xs overflow-x-auto">${log.response_body}</pre></div>`}}}detailHtml+=`<div class="space-y-2 pt-4 border-t border-border"><h4 class="font-medium text-sm">基本信息</h4><div class="grid grid-cols-2 gap-2 text-sm"><div><span class="text-muted-foreground">操作:</span> ${log.operation}</div><div><span class="text-muted-foreground">状态码:</span> <span class="inline-flex items-center rounded px-2 py-0.5 text-xs ${log.status_code===-1?'bg-blue-50 text-blue-700':log.status_code===200?'bg-green-50 text-green-700':'bg-red-50 text-red-700'}">${log.status_code===-1?((log.progress||0)>0?'生成中':'排队中'):log.status_code}</span></div><div><span class="text-muted-foreground">耗时:</span> ${log.duration===-1?'生成中':log.duration.toFixed(2)+'秒'}</div><div><span class="text-muted-foreground">时间:</span> ${log.created_at?new Date(log.created_at).toLocaleString('zh-CN'):'-'}</div></div></div>`;content.innerHTML=detailHtml;$('logDetailModal').classList.remove('hidden')},
closeLogDetailModal=()=>{$('logDetailModal').classList.add('hidden')},
clearAllLogs=async()=>{if(!confirm('确定要清空所有日志吗?此操作不可恢复!'))return;try{const r=await apiRequest('/api/logs',{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('日志已清空','success');await loadLogs()}else{showToast('清空失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('清空失败: '+e.message,'error')}},
cancelTask=async(taskId)=>{if(!confirm('确定要终止这个任务吗?'))return;try{const r=await apiRequest(`/api/tasks/${taskId}/cancel`,{method:'POST'});if(!r)return;const d=await r.json();if(d.success){showToast('任务已终止','success');await loadLogs()}else{showToast('终止失败: '+(d.message||'未知错误'),'error')}}catch(e){showToast('终止失败: '+e.message,'error')}},
@@ -1098,7 +1118,9 @@
deleteCharacter=async(id)=>{if(!confirm('确定要删除这个角色卡吗?'))return;try{const r=await apiRequest(`/api/characters/${id}`,{method:'DELETE'});if(!r)return;const d=await r.json();if(d.success){showToast('删除成功','success');await loadCharacters()}else{showToast('删除失败','error')}}catch(e){showToast('删除失败: '+e.message,'error')}},
loadCallLogicConfig=async()=>{try{const r=await apiRequest('/api/call-logic/config');if(!r)return;const d=await r.json();if(d.success&&d.config){const mode=d.config.call_mode||((d.config.polling_mode_enabled||false)?'polling':'default');$('cfgCallLogicMode').value=mode}else{console.error('调用逻辑配置数据格式错误:',d)}}catch(e){console.error('加载调用逻辑配置失败:',e)}},
saveCallLogicConfig=async()=>{try{const mode=$('cfgCallLogicMode').value||'default';const r=await apiRequest('/api/call-logic/config',{method:'POST',body:JSON.stringify({call_mode:mode})});if(!r)return;const d=await r.json();if(d.success){showToast('调用逻辑配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs','generate'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig();loadCallLogicConfig()}else if(t==='logs'){loadLogs()}};
loadPowProxyConfig=async()=>{try{const r=await apiRequest('/api/pow-proxy/config');if(!r)return;const d=await r.json();if(d.success&&d.config){$('cfgPowProxyEnabled').checked=d.config.pow_proxy_enabled||false;$('cfgPowProxyUrl').value=d.config.pow_proxy_url||''}else{console.error('POW代理配置数据格式错误:',d)}}catch(e){console.error('加载POW代理配置失败:',e)}},
savePowProxyConfig=async()=>{try{const r=await apiRequest('/api/pow-proxy/config',{method:'POST',body:JSON.stringify({pow_proxy_enabled:$('cfgPowProxyEnabled').checked,pow_proxy_url:$('cfgPowProxyUrl').value.trim()})});if(!r)return;const d=await r.json();if(d.success){showToast('POW代理配置保存成功','success')}else{showToast('保存失败','error')}}catch(e){showToast('保存失败: '+e.message,'error')}},
switchTab=t=>{const cap=n=>n.charAt(0).toUpperCase()+n.slice(1);['tokens','settings','logs','generate'].forEach(n=>{const active=n===t;$(`panel${cap(n)}`).classList.toggle('hidden',!active);$(`tab${cap(n)}`).classList.toggle('border-primary',active);$(`tab${cap(n)}`).classList.toggle('text-primary',active);$(`tab${cap(n)}`).classList.toggle('border-transparent',!active);$(`tab${cap(n)}`).classList.toggle('text-muted-foreground',!active)});if(t==='settings'){loadAdminConfig();loadProxyConfig();loadPowProxyConfig();loadWatermarkFreeConfig();loadCacheConfig();loadGenerationTimeout();loadATAutoRefreshConfig();loadCallLogicConfig()}else if(t==='logs'){loadLogs()}};
// 自适应生成面板 iframe 高度
window.addEventListener('message', (event) => {
const data = event.data || {};