Compare commits

...

2 Commits

Author SHA1 Message Date
TheSmallHanCat
29fddfa85b feat: 增强Token禁用状态管理,区分失效与禁用 2026-02-07 20:36:41 +08:00
TheSmallHanCat
5a0ccbe2de feat: 新增外部pow获取 2026-02-02 12:57:54 +08:00
11 changed files with 628 additions and 62 deletions

View File

@@ -60,3 +60,15 @@ call_mode = "default"
# 常用时区:中国/新加坡 +8, 日本/韩国 +9, 印度 +5.5, 伦敦 0, 纽约 -5, 洛杉矶 -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 = ""

View File

@@ -171,6 +171,13 @@ 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):
token_ids: List[int]
@@ -242,7 +249,10 @@ async def get_tokens(token: str = Depends(verify_admin_token)) -> List[dict]:
"video_enabled": token.video_enabled,
# 并发限制
"image_concurrency": token.image_concurrency,
"video_concurrency": token.video_concurrency
"video_concurrency": token.video_concurrency,
# 过期和禁用信息
"is_expired": token.is_expired,
"disabled_reason": token.disabled_reason
})
return result
@@ -1373,16 +1383,17 @@ 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
# 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"""
config_obj = await db.get_pow_proxy_config()
"""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.pow_proxy_enabled,
"pow_proxy_url": config_obj.pow_proxy_url or ""
"pow_proxy_enabled": config_obj.proxy_enabled,
"pow_proxy_url": config_obj.proxy_url or ""
}
}
@@ -1391,11 +1402,20 @@ async def update_pow_proxy_config(
request: UpdatePowProxyConfigRequest,
token: str = Depends(verify_admin_token)
):
"""Update POW proxy configuration"""
"""Update POW proxy configuration (unified with pow_service config)"""
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 "")
# 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"
@@ -1403,6 +1423,50 @@ async def update_pow_proxy_config(
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
@router.post("/api/tasks/{task_id}/cancel")
async def cancel_task(task_id: str, token: str = Depends(verify_admin_token)):

View File

@@ -238,25 +238,96 @@ class Config:
@property
def pow_proxy_enabled(self) -> bool:
"""Get POW proxy enabled status"""
"""Get POW proxy enabled status
DEPRECATED: This configuration is deprecated. Use pow_service_proxy_enabled instead.
All POW proxy settings are now unified under [pow_service] section.
"""
return self._config.get("pow_proxy", {}).get("pow_proxy_enabled", False)
def set_pow_proxy_enabled(self, enabled: bool):
"""Set POW proxy enabled/disabled"""
"""Set POW proxy enabled/disabled
DEPRECATED: This configuration is deprecated. Use set_pow_service_proxy_enabled instead.
All POW proxy settings are now unified under [pow_service] section.
"""
if "pow_proxy" not in self._config:
self._config["pow_proxy"] = {}
self._config["pow_proxy"]["pow_proxy_enabled"] = enabled
@property
def pow_proxy_url(self) -> str:
"""Get POW proxy URL"""
"""Get POW proxy URL
DEPRECATED: This configuration is deprecated. Use pow_service_proxy_url instead.
All POW proxy settings are now unified under [pow_service] section.
"""
return self._config.get("pow_proxy", {}).get("pow_proxy_url", "")
def set_pow_proxy_url(self, url: str):
"""Set POW proxy URL"""
"""Set POW proxy URL
DEPRECATED: This configuration is deprecated. Use set_pow_service_proxy_url instead.
All POW proxy settings are now unified under [pow_service] section.
"""
if "pow_proxy" not in self._config:
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
config = Config()

View File

@@ -224,6 +224,34 @@ class Database:
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):
"""Check database integrity and perform migrations if needed
@@ -365,7 +393,8 @@ class Database:
video_enabled BOOLEAN DEFAULT 1,
image_concurrency INTEGER DEFAULT -1,
video_concurrency INTEGER DEFAULT -1,
is_expired BOOLEAN DEFAULT 0
is_expired BOOLEAN DEFAULT 0,
disabled_reason TEXT
)
""")
@@ -517,6 +546,20 @@ class Database:
)
""")
# Create pow_service_config table
await db.execute("""
CREATE TABLE IF NOT EXISTS pow_service_config (
id INTEGER PRIMARY KEY DEFAULT 1,
mode TEXT DEFAULT 'local',
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
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)")
@@ -544,6 +587,22 @@ class Database:
if not await self._column_exists(db, "admin_config", "auto_disable_on_401"):
await db.execute("ALTER TABLE admin_config ADD COLUMN auto_disable_on_401 BOOLEAN DEFAULT 1")
# Migration: Add disabled_reason column to tokens table if it doesn't exist
if not await self._column_exists(db, "tokens", "disabled_reason"):
await db.execute("ALTER TABLE tokens ADD COLUMN disabled_reason TEXT")
# For existing disabled tokens without a reason, set to 'manual'
await db.execute("""
UPDATE tokens
SET disabled_reason = 'manual'
WHERE is_active = 0 AND disabled_reason IS NULL
""")
# For existing expired tokens, set to 'expired'
await db.execute("""
UPDATE tokens
SET disabled_reason = 'expired'
WHERE is_expired = 1 AND disabled_reason IS NULL
""")
await db.commit()
async def init_config_from_toml(self, config_dict: dict, is_first_startup: bool = True):
@@ -656,27 +715,35 @@ class Database:
""", (token_id,))
await db.commit()
async def update_token_status(self, token_id: int, is_active: bool):
"""Update token status"""
async def update_token_status(self, token_id: int, is_active: bool, disabled_reason: Optional[str] = None):
"""Update token status and disabled reason"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_active = ? WHERE id = ?
""", (is_active, token_id))
UPDATE tokens SET is_active = ?, disabled_reason = ? WHERE id = ?
""", (is_active, disabled_reason, token_id))
await db.commit()
async def mark_token_expired(self, token_id: int):
"""Mark token as expired and disable it"""
"""Mark token as expired and disable it with reason"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_expired = 1, is_active = 0 WHERE id = ?
""", (token_id,))
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
""", ("expired", token_id))
await db.commit()
async def mark_token_invalid(self, token_id: int):
"""Mark token as invalid (401 error) and disable it"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_expired = 1, is_active = 0, disabled_reason = ? WHERE id = ?
""", ("token_invalid", token_id))
await db.commit()
async def clear_token_expired(self, token_id: int):
"""Clear token expired flag"""
"""Clear token expired flag and disabled reason"""
async with aiosqlite.connect(self.db_path) as db:
await db.execute("""
UPDATE tokens SET is_expired = 0 WHERE id = ?
UPDATE tokens SET is_expired = 0, disabled_reason = NULL WHERE id = ?
""", (token_id,))
await db.commit()
@@ -1276,6 +1343,23 @@ class Database:
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:
@@ -1286,3 +1370,21 @@ class Database:
""", (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()

View File

@@ -270,6 +270,18 @@ class DebugLogger:
except Exception as e:
self.logger.error(f"Error logging info: {e}")
def log_warning(self, message: str):
"""Log warning message to log.txt"""
# Check if debug mode is enabled
if not config.debug_enabled:
return
try:
self.logger.warning(f"⚠️ [{self._format_timestamp()}] {message}")
except Exception as e:
self.logger.error(f"Error logging warning: {e}")
# Global debug logger instance
debug_logger = DebugLogger()

View File

@@ -40,6 +40,8 @@ class Token(BaseModel):
video_concurrency: int = -1 # 视频并发数限制,-1表示不限制
# 过期标记
is_expired: bool = False # Token是否已过期401 token_invalidated
# 禁用原因: manual=手动禁用, error_limit=错误次数超限, token_invalid=Token失效, expired=过期失效
disabled_reason: Optional[str] = None
class TokenStats(BaseModel):
"""Token statistics"""
@@ -154,6 +156,17 @@ class PowProxyConfig(BaseModel):
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
class ChatMessage(BaseModel):
role: str

View File

@@ -144,6 +144,15 @@ async def startup_event():
config.set_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
all_tokens = await db.get_all_tokens()
await concurrency_manager.initialize(all_tokens)

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

View File

@@ -16,6 +16,7 @@ from urllib.error import HTTPError, URLError
from curl_cffi.requests import AsyncSession
from curl_cffi import CurlMime
from .proxy_manager import ProxyManager
from .pow_service_client import pow_service_client
from ..core.config import config
from ..core.logger import debug_logger
@@ -232,32 +233,47 @@ async def _generate_sentinel_token_lightweight(proxy_url: str = None, device_id:
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
@@ -602,10 +618,10 @@ class SoraClient:
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
"""
@@ -616,20 +632,85 @@ class SoraClient:
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, # 使用小写,与成功的 curl 请求一致
"Content-Type": "application/json",
"User-Agent": user_agent,
"OAI-Language": "en-US",
"OAI-Device-Id": device_id,
"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",
"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:
result = await asyncio.to_thread(
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
except Exception as e:
error_str = str(e)
@@ -664,13 +745,40 @@ class SoraClient:
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"""
"""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())
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,
@@ -688,7 +796,7 @@ class SoraClient:
"flow": "sora_init"
}
request_body = json.dumps(request_payload, separators=(',', ':'))
headers = {
"Accept": "*/*",
"Content-Type": "text/plain;charset=UTF-8",
@@ -712,7 +820,7 @@ class SoraClient:
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(
@@ -727,11 +835,11 @@ class SoraClient:
sentinel_token = self._build_sentinel_token(
self.SENTINEL_FLOW, req_id, pow_token, resp, user_agent
)
# 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
@@ -1024,10 +1132,10 @@ class SoraClient:
proxy_url = await self.proxy_manager.get_proxy_url(token_id)
# Get POW proxy from configuration
# Get POW proxy from configuration (unified with pow_service config)
pow_proxy_url = None
if config.pow_proxy_enabled:
pow_proxy_url = config.pow_proxy_url or 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"

View File

@@ -946,19 +946,21 @@ class TokenManager:
async def update_token_status(self, token_id: int, is_active: bool):
"""Update token active status"""
await self.db.update_token_status(token_id, is_active)
# When manually changing status, set appropriate disabled_reason
disabled_reason = None if is_active else "manual"
await self.db.update_token_status(token_id, is_active, disabled_reason)
async def enable_token(self, token_id: int):
"""Enable a token and reset error count"""
await self.db.update_token_status(token_id, True)
await self.db.update_token_status(token_id, True, None) # Clear disabled_reason
# Reset error count when enabling (in token_stats table)
await self.db.reset_error_count(token_id)
# Clear expired flag when enabling
await self.db.clear_token_expired(token_id)
async def disable_token(self, token_id: int):
"""Disable a token"""
await self.db.update_token_status(token_id, False)
"""Disable a token (manual disable)"""
await self.db.update_token_status(token_id, False, "manual")
async def test_token(self, token_id: int) -> dict:
"""Test if a token is valid by calling Sora API and refresh account info (subscription + Sora2)"""
@@ -1048,6 +1050,14 @@ class TokenManager:
"valid": False,
"message": "Token已过期token_invalidated"
}
# Check if error is "Failed to get user info:401"
if "Failed to get user info:401" in error_msg or "Failed to get user info: 401" in error_msg:
# Mark token as invalid and disable it
await self.db.mark_token_invalid(token_id)
return {
"valid": False,
"message": "Token无效: Token is invalid: Failed to get user info:401"
}
return {
"valid": False,
"message": f"Token is invalid: {error_msg}"
@@ -1077,7 +1087,8 @@ class TokenManager:
admin_config = await self.db.get_admin_config()
if stats and stats.consecutive_error_count >= admin_config.error_ban_threshold:
await self.db.update_token_status(token_id, False)
# Disable token with error_limit reason
await self.db.update_token_status(token_id, False, "error_limit")
async def record_success(self, token_id: int, is_video: bool = False):
"""Record successful request (reset error count)"""

View File

@@ -390,23 +390,45 @@
</div>
</div>
<!-- POW代理配置 -->
<!-- POW配置 -->
<div class="rounded-lg border border-border bg-background p-6">
<h3 class="text-lg font-semibold mb-4">POW代理配置</h3>
<h3 class="text-lg font-semibold mb-4">POW配置</h3>
<div class="space-y-4">
<div>
<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" class="h-4 w-4 rounded border-input">
<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>
<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="savePowProxyConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
<button onclick="savePowConfig()" class="inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 w-full">保存配置</button>
</div>
</div>
@@ -1036,19 +1058,21 @@
const $=(id)=>document.getElementById(id),
checkAuth=()=>{const t=localStorage.getItem('adminToken');return t||(location.href='/login',null),t},
apiRequest=async(url,opts={})=>{const t=checkAuth();if(!t)return null;const r=await fetch(url,{...opts,headers:{...opts.headers,Authorization:`Bearer ${t}`,'Content-Type':'application/json'}});return r.status===401?(localStorage.removeItem('adminToken'),location.href='/login',null):r},
// 获取token的状态文本和样式
getTokenStatus=(token)=>{if(token.is_active){return{text:'活跃',class:'bg-green-50 text-green-700'}}const reason=token.disabled_reason;if(reason==='token_invalid'||reason==='expired'){return{text:'失效',class:'bg-gray-100 text-gray-700'}}if(reason==='manual'||reason==='error_limit'){return{text:'禁用',class:'bg-gray-100 text-gray-700'}}return{text:'禁用',class:'bg-gray-100 text-gray-700'}},
loadStats=async()=>{try{const r=await apiRequest('/api/stats');if(!r)return;const d=await r.json();$('statTotal').textContent=d.total_tokens||0;$('statActive').textContent=d.active_tokens||0;$('statImages').textContent=(d.today_images||0)+'/'+(d.total_images||0);$('statVideos').textContent=(d.today_videos||0)+'/'+(d.total_videos||0);$('statErrors').textContent=(d.today_errors||0)+'/'+(d.total_errors||0)}catch(e){console.error('加载统计失败:',e)}},
loadTokens=async()=>{try{const r=await apiRequest('/api/tokens');if(!r)return;allTokens=await r.json();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||'全部'}},
toggleStatusFilterDropdown=()=>{const dropdown=$('statusFilterDropdown');if(!dropdown)return;dropdown.classList.toggle('hidden')},
selectStatusFilter=(status)=>{currentStatusFilter=status;currentPage=1;updateStatusFilterLabel();toggleStatusFilterDropdown();renderTokens()},
applyStatusFilter=()=>{currentPage=1;renderTokens()},
getFilteredTokens=()=>{if(!currentStatusFilter)return allTokens;return allTokens.filter(t=>{const 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}`},
formatPlanType=type=>{if(!type)return'-';const typeMap={'chatgpt_team':'Team','chatgpt_plus':'Plus','chatgpt_pro':'Pro','chatgpt_free':'Free'};return typeMap[type]||type},
formatPlanTypeWithTooltip=(t)=>{const tooltipText=t.subscription_end?`套餐到期: ${new Date(t.subscription_end).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'}).replace(/\//g,'-')} ${new Date(t.subscription_end).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',hour12:false})}`:'';return`<span class="inline-flex items-center rounded px-2 py-0.5 text-xs bg-blue-50 text-blue-700 cursor-pointer" title="${tooltipText||t.plan_title||'-'}">${formatPlanType(t.plan_type)}</span>`},
formatClientId=(clientId)=>{if(!clientId)return'-';const short=clientId.substring(0,8)+'...';return`<span class="text-xs font-mono cursor-pointer hover:text-primary" title="${clientId}" onclick="navigator.clipboard.writeText('${clientId}').then(()=>showToast('已复制','success'))">${short}</span>`},
renderTokens=()=>{const 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()},
changePage=(page)=>{currentPage=page;renderTokens()},
changePageSize=(size)=>{pageSize=parseInt(size);currentPage=1;renderTokens()},
@@ -1118,9 +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')}},
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')}},
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()}};
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 高度
window.addEventListener('message', (event) => {
const data = event.data || {};