diff --git a/src/api/admin.py b/src/api/admin.py
index 4fe70d0..644a9d6 100644
--- a/src/api/admin.py
+++ b/src/api/admin.py
@@ -97,6 +97,8 @@ class ImportTokenItem(BaseModel):
access_token: str # Access Token (AT)
session_token: Optional[str] = None # Session Token (ST)
refresh_token: Optional[str] = None # Refresh Token (RT)
+ proxy_url: Optional[str] = None # Proxy URL (optional, for compatibility)
+ remark: Optional[str] = None # Remark (optional, for compatibility)
is_active: bool = True # Active status
image_enabled: bool = True # Enable image generation
video_enabled: bool = True # Enable video generation
@@ -345,11 +347,13 @@ async def delete_token(token_id: int, token: str = Depends(verify_admin_token)):
@router.post("/api/tokens/import")
async def import_tokens(request: ImportTokensRequest, token: str = Depends(verify_admin_token)):
"""Import tokens in append mode (update if exists, add if not)"""
- try:
- added_count = 0
- updated_count = 0
+ added_count = 0
+ updated_count = 0
+ failed_count = 0
+ results = []
- for import_item in request.tokens:
+ for import_item in request.tokens:
+ try:
# Check if token with this email already exists
existing_token = await db.get_token_by_email(import_item.email)
@@ -360,6 +364,8 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif
token=import_item.access_token,
st=import_item.session_token,
rt=import_item.refresh_token,
+ proxy_url=import_item.proxy_url,
+ remark=import_item.remark,
image_enabled=import_item.image_enabled,
video_enabled=import_item.video_enabled,
image_concurrency=import_item.image_concurrency,
@@ -375,12 +381,19 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif
video_concurrency=import_item.video_concurrency
)
updated_count += 1
+ results.append({
+ "email": import_item.email,
+ "status": "updated",
+ "success": True
+ })
else:
# Add new token
new_token = await token_manager.add_token(
token_value=import_item.access_token,
st=import_item.session_token,
rt=import_item.refresh_token,
+ proxy_url=import_item.proxy_url,
+ remark=import_item.remark,
update_if_exists=False,
image_enabled=import_item.image_enabled,
video_enabled=import_item.video_enabled,
@@ -398,15 +411,28 @@ async def import_tokens(request: ImportTokensRequest, token: str = Depends(verif
video_concurrency=import_item.video_concurrency
)
added_count += 1
+ results.append({
+ "email": import_item.email,
+ "status": "added",
+ "success": True
+ })
+ except Exception as e:
+ failed_count += 1
+ results.append({
+ "email": import_item.email,
+ "status": "failed",
+ "success": False,
+ "error": str(e)
+ })
- return {
- "success": True,
- "message": f"Import completed: {added_count} added, {updated_count} updated",
- "added": added_count,
- "updated": updated_count
- }
- except Exception as e:
- raise HTTPException(status_code=400, detail=f"Import failed: {str(e)}")
+ return {
+ "success": True,
+ "message": f"Import completed: {added_count} added, {updated_count} updated, {failed_count} failed",
+ "added": added_count,
+ "updated": updated_count,
+ "failed": failed_count,
+ "results": results
+ }
@router.put("/api/tokens/{token_id}")
async def update_token(
@@ -654,12 +680,12 @@ async def activate_sora2(
if result.get("success"):
# Get new invite code after activation
- sora2_info = await token_manager.get_sora2_invite_code(token_obj.token)
+ sora2_info = await token_manager.get_sora2_invite_code(token_obj.token, token_id)
# Get remaining count
sora2_remaining_count = 0
try:
- remaining_info = await token_manager.get_sora2_remaining_count(token_obj.token)
+ remaining_info = await token_manager.get_sora2_remaining_count(token_obj.token, token_id)
if remaining_info.get("success"):
sora2_remaining_count = remaining_info.get("remaining_count", 0)
except Exception as e:
@@ -697,20 +723,34 @@ async def activate_sora2(
# Logs endpoints
@router.get("/api/logs")
async def get_logs(limit: int = 100, token: str = Depends(verify_admin_token)):
- """Get recent logs with token email"""
+ """Get recent logs with token email and task progress"""
logs = await db.get_recent_logs(limit)
- return [{
- "id": log.get("id"),
- "token_id": log.get("token_id"),
- "token_email": log.get("token_email"),
- "token_username": log.get("token_username"),
- "operation": log.get("operation"),
- "status_code": log.get("status_code"),
- "duration": log.get("duration"),
- "created_at": log.get("created_at"),
- "request_body": log.get("request_body"),
- "response_body": log.get("response_body")
- } for log in logs]
+ result = []
+ for log in logs:
+ log_data = {
+ "id": log.get("id"),
+ "token_id": log.get("token_id"),
+ "token_email": log.get("token_email"),
+ "token_username": log.get("token_username"),
+ "operation": log.get("operation"),
+ "status_code": log.get("status_code"),
+ "duration": log.get("duration"),
+ "created_at": log.get("created_at"),
+ "request_body": log.get("request_body"),
+ "response_body": log.get("response_body"),
+ "task_id": log.get("task_id")
+ }
+
+ # If task_id exists and status is in-progress, get task progress
+ if log.get("task_id") and log.get("status_code") == -1:
+ task = await db.get_task(log.get("task_id"))
+ if task:
+ log_data["progress"] = task.progress
+ log_data["task_status"] = task.status
+
+ result.append(log_data)
+
+ return result
@router.delete("/api/logs")
async def clear_logs(token: str = Depends(verify_admin_token)):
diff --git a/src/api/routes.py b/src/api/routes.py
index 99cf53f..c515a1a 100644
--- a/src/api/routes.py
+++ b/src/api/routes.py
@@ -181,15 +181,27 @@ async def create_chat_completion(
):
yield chunk
except Exception as e:
+ # Try to parse structured error (JSON format)
+ error_data = None
+ try:
+ error_data = json_module.loads(str(e))
+ except:
+ pass
+
# Return OpenAI-compatible error format
- error_response = {
- "error": {
- "message": str(e),
- "type": "server_error",
- "param": None,
- "code": None
+ if error_data and isinstance(error_data, dict) and "error" in error_data:
+ # Structured error (e.g., unsupported_country_code)
+ error_response = error_data
+ else:
+ # Generic error
+ error_response = {
+ "error": {
+ "message": str(e),
+ "type": "server_error",
+ "param": None,
+ "code": None
+ }
}
- }
error_chunk = f'data: {json_module.dumps(error_response)}\n\n'
yield error_chunk
yield 'data: [DONE]\n\n'
diff --git a/src/core/database.py b/src/core/database.py
index 677687b..cdb2feb 100644
--- a/src/core/database.py
+++ b/src/core/database.py
@@ -254,6 +254,21 @@ class Database:
except Exception as e:
print(f" ✗ Failed to add column '{col_name}': {e}")
+ # Check and add missing columns to request_logs table
+ if await self._table_exists(db, "request_logs"):
+ columns_to_add = [
+ ("task_id", "TEXT"),
+ ("updated_at", "TIMESTAMP"),
+ ]
+
+ for col_name, col_type in columns_to_add:
+ if not await self._column_exists(db, "request_logs", col_name):
+ try:
+ await db.execute(f"ALTER TABLE request_logs ADD COLUMN {col_name} {col_type}")
+ print(f" ✓ Added column '{col_name}' to request_logs table")
+ except Exception as e:
+ print(f" ✗ Failed to add column '{col_name}': {e}")
+
# Ensure all config tables have their default rows
# Pass config_dict if available to initialize from setting.toml
await self._ensure_config_rows(db, config_dict)
@@ -340,12 +355,14 @@ class Database:
CREATE TABLE IF NOT EXISTS request_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_id INTEGER,
+ task_id TEXT,
operation TEXT NOT NULL,
request_body TEXT,
response_body TEXT,
status_code INTEGER NOT NULL,
duration FLOAT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP,
FOREIGN KEY (token_id) REFERENCES tokens(id)
)
""")
@@ -848,15 +865,40 @@ class Database:
return None
# Request log operations
- async def log_request(self, log: RequestLog):
- """Log a request"""
+ async def log_request(self, log: RequestLog) -> int:
+ """Log a request and return log ID"""
async with aiosqlite.connect(self.db_path) as db:
- await db.execute("""
- INSERT INTO request_logs (token_id, operation, request_body, response_body, status_code, duration)
- VALUES (?, ?, ?, ?, ?, ?)
- """, (log.token_id, log.operation, log.request_body, log.response_body,
+ cursor = await db.execute("""
+ INSERT INTO request_logs (token_id, task_id, operation, request_body, response_body, status_code, duration)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """, (log.token_id, log.task_id, log.operation, log.request_body, log.response_body,
log.status_code, log.duration))
await db.commit()
+ return cursor.lastrowid
+
+ async def update_request_log(self, log_id: int, response_body: Optional[str] = None,
+ status_code: Optional[int] = None, duration: Optional[float] = None):
+ """Update request log with completion data"""
+ async with aiosqlite.connect(self.db_path) as db:
+ updates = []
+ params = []
+
+ if response_body is not None:
+ updates.append("response_body = ?")
+ params.append(response_body)
+ if status_code is not None:
+ updates.append("status_code = ?")
+ params.append(status_code)
+ if duration is not None:
+ updates.append("duration = ?")
+ params.append(duration)
+
+ if updates:
+ updates.append("updated_at = CURRENT_TIMESTAMP")
+ params.append(log_id)
+ query = f"UPDATE request_logs SET {', '.join(updates)} WHERE id = ?"
+ await db.execute(query, params)
+ await db.commit()
async def get_recent_logs(self, limit: int = 100) -> List[dict]:
"""Get recent logs with token email"""
@@ -866,13 +908,15 @@ class Database:
SELECT
rl.id,
rl.token_id,
+ rl.task_id,
rl.operation,
rl.request_body,
rl.response_body,
rl.status_code,
rl.duration,
rl.created_at,
- t.email as token_email
+ t.email as token_email,
+ t.username as token_username
FROM request_logs rl
LEFT JOIN tokens t ON rl.token_id = t.id
ORDER BY rl.created_at DESC
diff --git a/src/core/models.py b/src/core/models.py
index 1501b92..ec1c30a 100644
--- a/src/core/models.py
+++ b/src/core/models.py
@@ -71,12 +71,14 @@ class RequestLog(BaseModel):
"""Request log model"""
id: Optional[int] = None
token_id: Optional[int] = None
+ task_id: Optional[str] = None # Link to task for progress tracking
operation: str
request_body: Optional[str] = None
response_body: Optional[str] = None
- status_code: int
- duration: float
+ status_code: int # -1 for in-progress
+ duration: float # -1.0 for in-progress
created_at: Optional[datetime] = None
+ updated_at: Optional[datetime] = None
class AdminConfig(BaseModel):
"""Admin configuration"""
diff --git a/src/services/generation_handler.py b/src/services/generation_handler.py
index 5b01e61..e8fb79e 100644
--- a/src/services/generation_handler.py
+++ b/src/services/generation_handler.py
@@ -332,6 +332,8 @@ class GenerationHandler:
stream: Whether to stream response
"""
start_time = time.time()
+ log_id = None # Initialize log_id to avoid reference before assignment
+ token_obj = None # Initialize token_obj to avoid reference before assignment
# Validate model
if model not in MODEL_CONFIG:
@@ -515,7 +517,18 @@ class GenerationHandler:
progress=0.0
)
await self.db.create_task(task)
-
+
+ # Create initial log entry (status_code=-1, duration=-1.0 means in-progress)
+ log_id = await self._log_request(
+ token_obj.id,
+ f"generate_{model_config['type']}",
+ {"model": model, "prompt": prompt, "has_image": image is not None},
+ {}, # Empty response initially
+ -1, # -1 means in-progress
+ -1.0, # -1.0 means in-progress
+ task_id=task_id
+ )
+
# Record usage
await self.token_manager.record_usage(token_obj.id, is_video=is_video)
@@ -557,14 +570,14 @@ class GenerationHandler:
except:
response_data["result_urls"] = task_info.result_urls
- await self._log_request(
- token_obj.id,
- f"generate_{model_config['type']}",
- {"model": model, "prompt": prompt, "has_image": image is not None},
- response_data,
- 200,
- duration
- )
+ # Update log entry with completion data
+ if log_id:
+ await self.db.update_request_log(
+ log_id,
+ response_body=json.dumps(response_data),
+ status_code=200,
+ duration=duration
+ )
except Exception as e:
# Release lock for image generation on error
@@ -584,16 +597,33 @@ class GenerationHandler:
is_overload = "heavy_load" in error_str or "under heavy load" in error_str
await self.token_manager.record_error(token_obj.id, is_overload=is_overload)
- # Log failed request
+ # Parse error message to check if it's a structured error (JSON)
+ error_response = None
+ try:
+ import json
+ error_response = json.loads(str(e))
+ except:
+ pass
+
+ # Update log entry with error data
duration = time.time() - start_time
- await self._log_request(
- token_obj.id if token_obj else None,
- f"generate_{model_config['type'] if model_config else 'unknown'}",
- {"model": model, "prompt": prompt, "has_image": image is not None},
- {"error": str(e)},
- 500,
- duration
- )
+ if log_id:
+ if error_response:
+ # Structured error (e.g., unsupported_country_code)
+ await self.db.update_request_log(
+ log_id,
+ response_body=json.dumps(error_response),
+ status_code=400,
+ duration=duration
+ )
+ else:
+ # Generic error
+ await self.db.update_request_log(
+ log_id,
+ response_body=json.dumps({"error": str(e)}),
+ status_code=500,
+ duration=duration
+ )
raise e
async def _poll_task_result(self, task_id: str, token: str, is_video: bool,
@@ -1136,21 +1166,23 @@ class GenerationHandler:
async def _log_request(self, token_id: Optional[int], operation: str,
request_data: Dict[str, Any], response_data: Dict[str, Any],
- status_code: int, duration: float):
- """Log request to database"""
+ status_code: int, duration: float, task_id: Optional[str] = None) -> Optional[int]:
+ """Log request to database and return log ID"""
try:
log = RequestLog(
token_id=token_id,
+ task_id=task_id,
operation=operation,
request_body=json.dumps(request_data),
response_body=json.dumps(response_data),
status_code=status_code,
duration=duration
)
- await self.db.log_request(log)
+ return await self.db.log_request(log)
except Exception as e:
# Don't fail the request if logging fails
print(f"Failed to log request: {e}")
+ return None
# ==================== Character Creation and Remix Handlers ====================
@@ -1171,6 +1203,7 @@ class GenerationHandler:
if not token_obj:
raise Exception("No available tokens for character creation")
+ start_time = time.time()
try:
yield self._format_stream_chunk(
reasoning_content="**Character Creation Begins**\n\nInitializing character creation...\n",
@@ -1255,6 +1288,26 @@ class GenerationHandler:
await self.sora_client.set_character_public(cameo_id, token_obj.token)
debug_logger.log_info(f"Character set as public")
+ # Log successful character creation
+ duration = time.time() - start_time
+ await self._log_request(
+ token_id=token_obj.id,
+ operation="character_only",
+ request_data={
+ "type": "character_creation",
+ "has_video": True
+ },
+ response_data={
+ "success": True,
+ "username": username,
+ "display_name": display_name,
+ "character_id": character_id,
+ "cameo_id": cameo_id
+ },
+ status_code=200,
+ duration=duration
+ )
+
# Step 7: Return success message
yield self._format_stream_chunk(
content=f"角色创建成功,角色名@{username}",
@@ -1263,6 +1316,23 @@ class GenerationHandler:
yield "data: [DONE]\n\n"
except Exception as e:
+ # Log failed character creation
+ duration = time.time() - start_time
+ await self._log_request(
+ token_id=token_obj.id if token_obj else None,
+ operation="character_only",
+ request_data={
+ "type": "character_creation",
+ "has_video": True
+ },
+ response_data={
+ "success": False,
+ "error": str(e)
+ },
+ status_code=500,
+ duration=duration
+ )
+
debug_logger.log_error(
error_message=f"Character creation failed: {str(e)}",
status_code=500,
@@ -1289,6 +1359,10 @@ class GenerationHandler:
raise Exception("No available tokens for video generation")
character_id = None
+ start_time = time.time()
+ username = None
+ display_name = None
+ cameo_id = None
try:
yield self._format_stream_chunk(
reasoning_content="**Character Creation and Video Generation Begins**\n\nInitializing...\n",
@@ -1366,6 +1440,28 @@ class GenerationHandler:
)
debug_logger.log_info(f"Character finalized, character_id: {character_id}")
+ # Log successful character creation (before video generation)
+ character_creation_duration = time.time() - start_time
+ await self._log_request(
+ token_id=token_obj.id,
+ operation="character_with_video",
+ request_data={
+ "type": "character_creation_with_video",
+ "has_video": True,
+ "prompt": prompt
+ },
+ response_data={
+ "success": True,
+ "username": username,
+ "display_name": display_name,
+ "character_id": character_id,
+ "cameo_id": cameo_id,
+ "stage": "character_created"
+ },
+ status_code=200,
+ duration=character_creation_duration
+ )
+
# Step 6: Generate video with character
yield self._format_stream_chunk(
reasoning_content="**Video Generation Process Begins**\n\nGenerating video with character...\n"
@@ -1414,6 +1510,28 @@ class GenerationHandler:
await self.token_manager.record_success(token_obj.id, is_video=True)
except Exception as e:
+ # Log failed character creation
+ duration = time.time() - start_time
+ await self._log_request(
+ token_id=token_obj.id if token_obj else None,
+ operation="character_with_video",
+ request_data={
+ "type": "character_creation_with_video",
+ "has_video": True,
+ "prompt": prompt
+ },
+ response_data={
+ "success": False,
+ "username": username,
+ "display_name": display_name,
+ "character_id": character_id,
+ "cameo_id": cameo_id,
+ "error": str(e)
+ },
+ status_code=500,
+ duration=duration
+ )
+
# Record error (check if it's an overload error)
if token_obj:
error_str = str(e).lower()
@@ -1553,6 +1671,16 @@ class GenerationHandler:
debug_logger.log_info(f"Cameo status: {current_status} (message: {status_message}) (attempt {attempt + 1}/{max_attempts})")
+ # Check if processing failed
+ if current_status == "failed":
+ error_message = status_message or "Character creation failed"
+ debug_logger.log_error(
+ error_message=f"Cameo processing failed: {error_message}",
+ status_code=500,
+ response_text=error_message
+ )
+ raise Exception(f"角色创建失败: {error_message}")
+
# Check if processing is complete
# Primary condition: status_message == "Completed" means processing is done
if status_message == "Completed":
@@ -1568,6 +1696,11 @@ class GenerationHandler:
consecutive_errors += 1
error_msg = str(e)
+ # Check if it's a character creation failure (not a network error)
+ # These should be raised immediately, not retried
+ if "角色创建失败" in error_msg:
+ raise
+
# Log error with context
debug_logger.log_error(
error_message=f"Failed to get cameo status (attempt {attempt + 1}/{max_attempts}, consecutive errors: {consecutive_errors}): {error_msg}",
diff --git a/src/services/proxy_manager.py b/src/services/proxy_manager.py
index 4b07d14..9ccf946 100644
--- a/src/services/proxy_manager.py
+++ b/src/services/proxy_manager.py
@@ -9,16 +9,21 @@ class ProxyManager:
def __init__(self, db: Database):
self.db = db
- async def get_proxy_url(self, token_id: Optional[int] = None) -> Optional[str]:
+ async def get_proxy_url(self, token_id: Optional[int] = None, proxy_url: Optional[str] = None) -> Optional[str]:
"""Get proxy URL for a token, with fallback to global proxy
Args:
token_id: Token ID (optional). If provided, returns token-specific proxy if set,
otherwise falls back to global proxy.
+ proxy_url: Direct proxy URL (optional). If provided, returns this proxy URL directly.
Returns:
Proxy URL string or None
"""
+ # If proxy_url is directly provided, use it
+ if proxy_url:
+ return proxy_url
+
# If token_id is provided, try to get token-specific proxy first
if token_id is not None:
token = await self.db.get_token(token_id)
diff --git a/src/services/sora_client.py b/src/services/sora_client.py
index c8e1f93..8122e60 100644
--- a/src/services/sora_client.py
+++ b/src/services/sora_client.py
@@ -180,6 +180,29 @@ class SoraClient:
# Check status
if response.status_code not in [200, 201]:
+ # Parse error response
+ error_data = None
+ try:
+ error_data = response.json()
+ except:
+ pass
+
+ # Check for unsupported_country_code error
+ if error_data and isinstance(error_data, dict):
+ error_info = error_data.get("error", {})
+ if error_info.get("code") == "unsupported_country_code":
+ # Create structured error with full error data
+ import json
+ error_msg = json.dumps(error_data)
+ debug_logger.log_error(
+ error_message=f"Unsupported country: {error_msg}",
+ status_code=response.status_code,
+ response_text=error_msg
+ )
+ # Raise exception with structured error data
+ raise Exception(error_msg)
+
+ # Generic error handling
error_msg = f"API request failed: {response.status_code} - {response.text}"
debug_logger.log_error(
error_message=error_msg,
diff --git a/src/services/token_manager.py b/src/services/token_manager.py
index 99612d6..041659b 100644
--- a/src/services/token_manager.py
+++ b/src/services/token_manager.py
@@ -59,9 +59,9 @@ class TokenManager:
# 转换为小写
return format_choice.lower()
- async def get_user_info(self, access_token: str) -> dict:
+ async def get_user_info(self, access_token: str, token_id: Optional[int] = None, proxy_url: Optional[str] = None) -> dict:
"""Get user info from Sora API"""
- proxy_url = await self.proxy_manager.get_proxy_url()
+ proxy_url = await self.proxy_manager.get_proxy_url(token_id, proxy_url)
async with AsyncSession() as session:
headers = {
@@ -90,7 +90,7 @@ class TokenManager:
return response.json()
- async def get_subscription_info(self, token: str) -> Dict[str, Any]:
+ async def get_subscription_info(self, token: str, token_id: Optional[int] = None, proxy_url: Optional[str] = None) -> Dict[str, Any]:
"""Get subscription information from Sora API
Returns:
@@ -101,7 +101,7 @@ class TokenManager:
}
"""
print(f"🔍 开始获取订阅信息...")
- proxy_url = await self.proxy_manager.get_proxy_url()
+ proxy_url = await self.proxy_manager.get_proxy_url(token_id, proxy_url)
headers = {
"Authorization": f"Bearer {token}"
@@ -163,9 +163,9 @@ class TokenManager:
raise Exception(f"Failed to get subscription info: {response.status_code}")
- async def get_sora2_invite_code(self, access_token: str) -> dict:
+ async def get_sora2_invite_code(self, access_token: str, token_id: Optional[int] = None, proxy_url: Optional[str] = None) -> dict:
"""Get Sora2 invite code"""
- proxy_url = await self.proxy_manager.get_proxy_url()
+ proxy_url = await self.proxy_manager.get_proxy_url(token_id, proxy_url)
print(f"🔍 开始获取Sora2邀请码...")
@@ -263,7 +263,7 @@ class TokenManager:
"invite_code": None
}
- async def get_sora2_remaining_count(self, access_token: str) -> dict:
+ async def get_sora2_remaining_count(self, access_token: str, token_id: Optional[int] = None, proxy_url: Optional[str] = None) -> dict:
"""Get Sora2 remaining video count
Returns:
@@ -273,7 +273,7 @@ class TokenManager:
"access_resets_in_seconds": 46833
}
"""
- proxy_url = await self.proxy_manager.get_proxy_url()
+ proxy_url = await self.proxy_manager.get_proxy_url(token_id, proxy_url)
print(f"🔍 开始获取Sora2剩余次数...")
@@ -692,7 +692,7 @@ class TokenManager:
# Get user info from Sora API
try:
- user_info = await self.get_user_info(token_value)
+ user_info = await self.get_user_info(token_value, proxy_url=proxy_url)
email = user_info.get("email", jwt_email or "")
name = user_info.get("name") or ""
except Exception as e:
@@ -705,7 +705,7 @@ class TokenManager:
plan_title = None
subscription_end = None
try:
- sub_info = await self.get_subscription_info(token_value)
+ sub_info = await self.get_subscription_info(token_value, proxy_url=proxy_url)
plan_type = sub_info.get("plan_type")
plan_title = sub_info.get("plan_title")
# Parse subscription end time
@@ -727,7 +727,7 @@ class TokenManager:
sora2_total_count = 0
sora2_remaining_count = 0
try:
- sora2_info = await self.get_sora2_invite_code(token_value)
+ sora2_info = await self.get_sora2_invite_code(token_value, proxy_url=proxy_url)
sora2_supported = sora2_info.get("supported", False)
sora2_invite_code = sora2_info.get("invite_code")
sora2_redeemed_count = sora2_info.get("redeemed_count", 0)
@@ -736,7 +736,7 @@ class TokenManager:
# If Sora2 is supported, get remaining count
if sora2_supported:
try:
- remaining_info = await self.get_sora2_remaining_count(token_value)
+ remaining_info = await self.get_sora2_remaining_count(token_value, proxy_url=proxy_url)
if remaining_info.get("success"):
sora2_remaining_count = remaining_info.get("remaining_count", 0)
print(f"✅ Sora2剩余次数: {sora2_remaining_count}")
@@ -753,7 +753,7 @@ class TokenManager:
# Check and set username if needed
try:
# Get fresh user info to check username
- user_info = await self.get_user_info(token_value)
+ user_info = await self.get_user_info(token_value, proxy_url=proxy_url)
username = user_info.get("username")
# If username is null, need to set one
@@ -931,10 +931,10 @@ class TokenManager:
try:
# Try to get user info from Sora API
- user_info = await self.get_user_info(token_data.token)
+ user_info = await self.get_user_info(token_data.token, token_id)
# Refresh Sora2 invite code and counts
- sora2_info = await self.get_sora2_invite_code(token_data.token)
+ sora2_info = await self.get_sora2_invite_code(token_data.token, token_id)
sora2_supported = sora2_info.get("supported", False)
sora2_invite_code = sora2_info.get("invite_code")
sora2_redeemed_count = sora2_info.get("redeemed_count", 0)
@@ -944,7 +944,7 @@ class TokenManager:
# If Sora2 is supported, get remaining count
if sora2_supported:
try:
- remaining_info = await self.get_sora2_remaining_count(token_data.token)
+ remaining_info = await self.get_sora2_remaining_count(token_data.token, token_id)
if remaining_info.get("success"):
sora2_remaining_count = remaining_info.get("remaining_count", 0)
except Exception as e:
@@ -1012,19 +1012,22 @@ class TokenManager:
try:
token_data = await self.db.get_token(token_id)
if token_data and token_data.sora2_supported:
- remaining_info = await self.get_sora2_remaining_count(token_data.token)
+ remaining_info = await self.get_sora2_remaining_count(token_data.token, token_id)
if remaining_info.get("success"):
remaining_count = remaining_info.get("remaining_count", 0)
await self.db.update_token_sora2_remaining(token_id, remaining_count)
print(f"✅ 更新Token {token_id} 的Sora2剩余次数: {remaining_count}")
- # If remaining count is 0, set cooldown
- if remaining_count == 0:
+ # If remaining count is 1 or less, disable token and set cooldown
+ if remaining_count <= 1:
reset_seconds = remaining_info.get("access_resets_in_seconds", 0)
if reset_seconds > 0:
cooldown_until = datetime.now() + timedelta(seconds=reset_seconds)
await self.db.update_token_sora2_cooldown(token_id, cooldown_until)
- print(f"⏱️ Token {token_id} 剩余次数为0,设置冷却时间至: {cooldown_until}")
+ print(f"⏱️ Token {token_id} 剩余次数为{remaining_count},设置冷却时间至: {cooldown_until}")
+ # Disable token
+ await self.disable_token(token_id)
+ print(f"🚫 Token {token_id} 剩余次数为{remaining_count},已自动禁用")
except Exception as e:
print(f"Failed to update Sora2 remaining count: {e}")
@@ -1040,7 +1043,7 @@ class TokenManager:
print(f"🔄 Token {token_id} Sora2冷却已过期,正在刷新剩余次数...")
try:
- remaining_info = await self.get_sora2_remaining_count(token_data.token)
+ remaining_info = await self.get_sora2_remaining_count(token_data.token, token_id)
if remaining_info.get("success"):
remaining_count = remaining_info.get("remaining_count", 0)
await self.db.update_token_sora2_remaining(token_id, remaining_count)
diff --git a/static/generate.html b/static/generate.html
new file mode 100644
index 0000000..a290b95
--- /dev/null
+++ b/static/generate.html
@@ -0,0 +1,1867 @@
+
+
+
+
+
+ Sora2 生成面板
+
+
+
+
+
+
+
左侧拖拽/挂载角色卡,中间编写提示词并上传素材,右侧查看任务进度与结果预览。
+
+
+
+
+
+
+
+
+
+
+
上传素材
+
单次:只使用 1 个文件;同提示批量:多文件共享同一提示词(每个文件可生成多份)
+
+
+
+
+
+
+
+
+
+ 拖拽文件到这里,或点击选择(支持多文件)
+
+
+
+
+
+
+
+ 未选择文件
+ 素材
+
+
+
+
+
+
+
+
+
+
+
+
+ 视频建议 3~10 秒;批量时可多选文件;同提示多文件:共享同一提示;多提示批量:文本每行一条。
+
+
+
+
+
+
+
+
+ 过滤
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
下载
+
+
+
+
+
+
+
+
+
+
+
+
+
修改分镜提示词(仅影响当前分镜任务)
+
+
+
+
+
+
+
+ 只会重试当前分镜,不会影响其它分镜。快捷键:Ctrl + Enter 保存并重试。
+
+
+
+
+
+
+
+
+
+
diff --git a/static/js/generate.js b/static/js/generate.js
new file mode 100644
index 0000000..b5081e2
--- /dev/null
+++ b/static/js/generate.js
@@ -0,0 +1,6831 @@
+(() => {
+ const $ = (id) => document.getElementById(id);
+
+ const btnSend = $('btnSend');
+ const btnClear = $('btnClear');
+ const btnCopyLog = $('btnCopyLog'); // 可能不存在(已移除全局日志按钮)
+ // 旧版日志容器可能不存在,兜底创建隐藏节点以避免空引用
+ const out =
+ $('output') ||
+ (() => {
+ const el = document.createElement('pre');
+ el.id = 'output';
+ el.style.display = 'none';
+ document.body.appendChild(el);
+ return el;
+ })();
+ const logTaskPanel = $('tabPanelLog');
+ const logListContainer = $('logListContainer');
+ const logDetailId = $('logDetailId');
+ const logDetailStatus = $('logDetailStatus');
+ const logDetailMeta = $('logDetailMeta');
+ const logDetailContent = $('logDetailContent');
+ const btnCopyTaskLog = $('btnCopyTaskLog');
+ const previewGrid = $('previewGrid');
+ const previewFilterBar = $('previewFilterBar');
+ const previewCount = $('previewCount');
+ const btnPreviewBatchDownload = $('btnPreviewBatchDownload');
+ const previewModal = $('previewModal');
+ const previewModalMedia = $('previewModalMedia');
+ const previewModalTaskId = $('previewModalTaskId');
+ const previewModalStoryboard = $('previewModalStoryboard');
+ const previewModalWatermark = $('previewModalWatermark');
+ const previewModalMeta = $('previewModalMeta');
+ const btnPreviewClose = $('btnPreviewClose');
+ const btnPreviewOpenNew = $('btnPreviewOpenNew');
+ const btnPreviewCopyLink = $('btnPreviewCopyLink');
+ const btnPreviewCopyHtml = $('btnPreviewCopyHtml');
+ const previewModalDownload = $('previewModalDownload');
+ const btnPreviewLocateTask = $('btnPreviewLocateTask');
+ const editStoryboardModal = $('editStoryboardModal');
+ const editStoryboardModalBadge = $('editStoryboardModalBadge');
+ const editStoryboardModalMeta = $('editStoryboardModalMeta');
+ const editStoryboardTextarea = $('editStoryboardTextarea');
+ const btnEditStoryboardCancel = $('btnEditStoryboardCancel');
+ const btnEditStoryboardRetry = $('btnEditStoryboardRetry');
+ const taskList = $('taskList');
+ const taskCount = $('taskCount');
+ const dropzone = $('dropzone');
+ const fileInput = $('file');
+ const filePreviewBox = $('filePreviewBox');
+ const filePreviewMedia = $('filePreviewMedia');
+ const filePreviewName = $('filePreviewName');
+ const filePreviewKind = $('filePreviewKind');
+ const filePreviewMeta = $('filePreviewMeta');
+ const filePreviewHints = $('filePreviewHints');
+ const filePreviewList = $('filePreviewList');
+ const btnUseRecommendedModel = $('btnUseRecommendedModel');
+ const btnClearFiles = $('btnClearFiles');
+ const uxBanner = $('uxBanner');
+ const toastHost = $('toastHost') || document.getElementById('toastHost');
+ const promptBox = $('prompt');
+ const tagBar = $('tagBar');
+ const roleList = $('roleList');
+ const roleSearch = $('roleSearch');
+ const roleSearchClear = $('roleSearchClear');
+ const roleCountEl = $('roleCount');
+ const roleFilterBar = $('roleFilterBar');
+ const roleSort = $('roleSort');
+ const btnReloadRoles = $('btnReloadRoles');
+ const btnRoleDense = $('btnRoleDense');
+ const attachedRolesBox = $('attachedRoles');
+ const btnClearMainRoles = document.getElementById('btnClearMainRoles');
+ const multiGlobalRolesBar = document.getElementById('multiGlobalRolesBar');
+ const multiAttachedRolesBox = document.getElementById('multiAttachedRoles');
+ const btnMultiClearRoles = document.getElementById('btnMultiClearRoles');
+ const storyboardAttachedRolesBox = document.getElementById('storyboardAttachedRoles');
+ const btnStoryboardScopeRoles = document.getElementById('btnStoryboardScopeRoles');
+ const btnStoryboardClearRoles = document.getElementById('btnStoryboardClearRoles');
+ const formStorageKey = 'gen_form_v1';
+ const btnClearDone = $('btnClearDone');
+ const btnClearAll = $('btnClearAll');
+ const taskStorageKey = 'gen_tasks_v1';
+ // 角色挂载:按模式隔离,避免“分镜挂载影响单次/同提示”的错觉
+ const roleStorageKeyLegacy = 'gen_roles_v1';
+ const roleStorageKeyMain = 'gen_roles_main_v1';
+ const roleStorageKeyMulti = 'gen_roles_multi_v1';
+ const roleStorageKeyStoryboard = 'gen_roles_storyboard_v1';
+ const ROLE_UI_KEY = 'gen_role_ui_v2';
+ const ROLE_FAV_KEY = 'gen_role_fav_v1';
+ const ROLE_USED_KEY = 'gen_role_used_v1';
+ const authHeaderKey = 'adminToken';
+ const batchPromptList = $('batchPromptList');
+ const batchModeBar = $('batchModeBar');
+ const batchConcurrencyInput = $('batchConcurrency');
+ const btnApplyGlobalCountToAll = $('btnApplyGlobalCountToAll');
+ const batchMetaActions = document.getElementById('batchMetaActions');
+ const btnExportBatch = $('btnExportBatch');
+ const btnImportBatch = $('btnImportBatch');
+ const importBatchFile = $('importBatchFile');
+ const multiPromptList = document.getElementById('multiPromptList');
+ const btnAddPrompt = document.getElementById('btnAddPrompt');
+ const multiPromptActions = document.getElementById('multiPromptActions');
+ const storyboardBox = document.getElementById('storyboardBox');
+ const storyboardTitle = document.getElementById('storyboardTitle');
+ const storyboardShotCount = document.getElementById('storyboardShotCount');
+ const btnApplyStoryboardCount = document.getElementById('btnApplyStoryboardCount');
+ const storyboardSequential = document.getElementById('storyboardSequential');
+ const btnStoryboardFromPrompt = document.getElementById('btnStoryboardFromPrompt');
+ const btnStoryboardClear = document.getElementById('btnStoryboardClear');
+ const storyboardContext = document.getElementById('storyboardContext');
+ const storyboardList = document.getElementById('storyboardList');
+ const globalCountLabel = document.getElementById('globalCountLabel');
+ const uploadCard = document.getElementById('uploadCard');
+ const dropzoneWrap = document.getElementById('dropzoneWrap');
+ const btnSendPrimary = document.getElementById('btnSendPrimary');
+ const btnClearPrimary = document.getElementById('btnClearPrimary');
+ const quickModeBar = document.getElementById('quickModeBar');
+ const btnOpenMoreModes = document.getElementById('btnOpenMoreModes');
+ const quickCountWrap = document.getElementById('quickCountWrap');
+ const quickCountInput = document.getElementById('quickCount');
+ const quickCountDec = document.getElementById('quickCountDec');
+ const quickCountInc = document.getElementById('quickCountInc');
+ const quickPlan = document.getElementById('quickPlan');
+ const btnToggleAdvanced = $('btnToggleAdvanced');
+ const advancedBox = $('advancedBox');
+ const btnOnlyRunning = $('btnOnlyRunning');
+ const btnPreviewDense = $('btnPreviewDense');
+ const btnLogBottom = $('btnLogBottom');
+ const concurrencyDec = $('concurrencyDec');
+ const concurrencyInc = $('concurrencyInc');
+ const rightTabButtons = Array.from(document.querySelectorAll('[data-tab]'));
+ const tabPanelTasks = $('tabPanelTasks');
+ const tabPanelPreview = $('tabPanelPreview');
+ const tabPanelLog = $('tabPanelLog');
+ const RIGHT_TAB_KEY = 'gen_right_tab';
+ const PREVIEW_SEEN_KEY = 'gen_preview_seen_v1';
+ const PREVIEW_FILTER_KEY = 'gen_preview_filter_v1';
+ const PREVIEW_DENSE_KEY = 'gen_preview_dense_v1';
+ const ADV_OPEN_KEY = 'gen_adv_open';
+ const LOG_MAX_CHARS = 4000;
+ const LOG_MAX_LINES = 120;
+ const LOG_STORE_LIMIT = 20000;
+ const DRAFT_KEY = 'gen_prompt_draft_v1';
+ let draftTimer = null;
+ let previewHintTimer = null;
+ let applyingMainFiles = false; // 防止 set files 触发 change 后递归
+ // 高级设置默认常驻显示:减少“展开/收起”这种额外操作(更符合自用高频工作流)
+ let advancedOpen = true;
+ // “生成份数/默认份数”按模式隔离:避免单次/同提示的份数污染分镜默认份数(分镜默认应为 1)
+ let batchConcurrencyByType = {};
+
+ let tasks = [];
+ let taskIdCounter = 1;
+ let roles = [];
+ let roleUi = { query: '', filter: 'all', sort: 'smart', dense: false };
+ let roleFavs = new Set(); // username set
+ let roleUsed = {}; // { [username]: lastUsedTs }
+ let attachedRoles = [];
+ let attachedRolesMulti = [];
+ let attachedRolesStoryboard = [];
+ let multiPrompts = [];
+ const multiPromptRoles = {};
+ // storyboardShots: { text, count, fileDataUrl, fileName, roles: [], useGlobalRoles?: boolean }
+ // useGlobalRoles=false 表示该分镜被手动排除:不再自动挂载“全局角色”(后续全局变更也不会影响它)
+ let storyboardShots = [];
+ const STORYBOARD_RUN_KEY = 'gen_storyboard_run_v1';
+ let storyboardRunCounter = parseInt(localStorage.getItem(STORYBOARD_RUN_KEY) || '0', 10) || 0;
+ let tagFilter = '';
+
+ // 上传文件预览状态(用于“模型/横竖/提示词为空”即时提醒)
+ let previewObjectUrl = null;
+ let lastPreviewSignature = '';
+ let lastPreviewInfo = null; // { w, h, orientation, isImage, isVideo }
+ let currentRecommendedModel = null;
+
+ const getAuthHeaders = () => {
+ const t = localStorage.getItem(authHeaderKey);
+ return t ? { Authorization: `Bearer ${t}` } : {};
+ };
+
+ const escapeAttr = (str = '') =>
+ str
+ .replace(/"/g, "'")
+ .replace(/'/g, ''')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ const escapeHtml = (str = '') => {
+ const s = String(str || '');
+ return s
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ };
+
+ // 默认头像:纯本地 data URI(避免外链占位图被拦截/离线不可用)
+ const DEFAULT_ROLE_AVATAR = (() => {
+ const svg =
+ '
';
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
+ })();
+
+ // URL 白名单:Sora/OpenAI 域名或常见媒体扩展名
+ const isValidMediaUrl = (u) => {
+ if (!u) return false;
+ const s = u.toString();
+ const domainOk = /(?:^https?:\/\/)?(?:videos\.openai\.com|oscdn\d*\.dyysy\.com)/i.test(s);
+ const extOk = /\.(mp4|webm|mov|m4v|mpg|mpeg|avi|gif|png|jpg|jpeg|webp)(?:\?|#|$)/i.test(s);
+ return domainOk || extOk;
+ };
+
+ // ===== 下载友好命名 & 同源 /tmp 重写(解决“哈希英文名 + 手动改名”痛点) =====
+ const padNum = (n, width = 2) => {
+ const v = Math.max(0, parseInt(String(n ?? '0'), 10) || 0);
+ const s = String(v);
+ return s.length >= width ? s : `${'0'.repeat(width)}${s}`.slice(-width);
+ };
+
+ const sanitizeFilename = (name, fallback = 'download') => {
+ let s = String(name || '').trim();
+ if (!s) return fallback;
+ // 去掉控制字符,避免 Windows/浏览器保存失败
+ s = s.replace(/[\u0000-\u001f\u007f]/g, '');
+ // Windows 禁用字符:\ / : * ? " < > |
+ s = s.replace(/[\\/:*?"<>|]/g, '-');
+ // 合并空白
+ s = s.replace(/\s+/g, ' ').trim();
+ // 不允许以点或空格结尾(Windows)
+ s = s.replace(/[. ]+$/g, '');
+ if (!s) return fallback;
+ // 控制长度,避免过长导致系统截断/失败(保守)
+ if (s.length > 120) s = s.slice(0, 120).trim();
+ return s || fallback;
+ };
+
+ const mediaExtFromUrl = (url, type = 'video') => {
+ const s = String(url || '');
+ const m = s.match(/\.([a-zA-Z0-9]{2,6})(?:[?#]|$)/);
+ const ext = m ? String(m[1]).toLowerCase() : '';
+ const ok = new Set(['mp4', 'mov', 'm4v', 'webm', 'gif', 'png', 'jpg', 'jpeg', 'webp']);
+ if (ok.has(ext)) return ext;
+ return type === 'image' ? 'png' : 'mp4';
+ };
+
+ const normalizeTmpDownloadUrl = (url) => {
+ // 目标:把 `http://127.0.0.1:8000/tmp/xxx.mp4` 统一重写成 `/tmp/xxx.mp4`
+ // 这样无论用户用 127.0.0.1 / 局域网 IP / 域名访问,都能同源下载并应用 download 文件名。
+ try {
+ const u = new URL(String(url || ''), window.location.href);
+ if (u && u.pathname && u.pathname.startsWith('/tmp/')) {
+ return u.pathname + (u.search || '');
+ }
+ } catch (_) {
+ /* ignore */
+ }
+ return String(url || '');
+ };
+
+ const buildDownloadFilename = (task, url, type = 'video', ordinal = 1) => {
+ const ty = String(type || '').toLowerCase() === 'image' ? 'image' : 'video';
+ const ext = mediaExtFromUrl(url, ty);
+ const id = task && typeof task.id === 'number' ? task.id : null;
+
+ // 分镜任务:按“分镜组标题 + 镜号/总数 + 第几份 + 任务ID”命名,便于批量后按名称排序
+ if (task && task.storyboard) {
+ const sb = task.storyboard || {};
+ const run = parseInt(String(sb.run || '0'), 10) || 0;
+ const idx = parseInt(String(sb.idx || '0'), 10) || 0;
+ const total = parseInt(String(sb.total || '0'), 10) || 0;
+ const take = parseInt(String(sb.take || '1'), 10) || 1;
+ const takes = parseInt(String(sb.takes || '1'), 10) || 1;
+
+ const titleRaw = String(sb.title || (run ? `分镜组${run}` : '分镜')).trim();
+ const title = sanitizeFilename(titleRaw, run ? `分镜组${run}` : '分镜');
+ const shotPart = idx ? `分镜${padNum(idx, 2)}${total ? `of${padNum(total, 2)}` : ''}` : `分镜${padNum(ordinal, 2)}`;
+ const takePart = takes > 1 ? `第${take}份` : '';
+ const idPart = id ? `T${id}` : '';
+ const parts = [title, shotPart, takePart, idPart].filter(Boolean);
+ return `${sanitizeFilename(parts.join('_'), '分镜')}.${ext}`;
+ }
+
+ // 普通任务:任务ID + 提示词片段(可选)
+ const prefix = id ? `任务${id}` : `${ty === 'image' ? '图片' : '视频'}${padNum(ordinal, 3)}`;
+ const hintRaw = task && task.promptSnippet ? String(task.promptSnippet).trim() : '';
+ const hint = hintRaw ? sanitizeFilename(hintRaw.slice(0, 26), '') : '';
+ return `${sanitizeFilename(hint ? `${prefix}_${hint}` : prefix, prefix)}.${ext}`;
+ };
+
+ const triggerBrowserDownload = (url, filename) => {
+ const href = normalizeTmpDownloadUrl(url);
+ if (!href) return false;
+ try {
+ const a = document.createElement('a');
+ a.href = href;
+ if (filename) a.download = String(filename);
+ a.rel = 'noreferrer';
+ // 不强制新标签:避免被浏览器当作“弹窗”拦截
+ a.target = '';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ return true;
+ } catch (_) {
+ return false;
+ }
+ };
+
+ const showToast = (msg, type = 'info', opts = {}) => {
+ const host = toastHost || document.body;
+ const safeType = ['info', 'success', 'error', 'warn'].includes(type) ? type : 'info';
+
+ const el = document.createElement('div');
+ el.className = `toast toast-${safeType}`;
+
+ const title = document.createElement('div');
+ title.className = 'title';
+ title.textContent =
+ opts.title ||
+ (safeType === 'success' ? '成功' : safeType === 'error' ? '出错了' : safeType === 'warn' ? '注意' : '提示');
+
+ const desc = document.createElement('div');
+ desc.className = 'desc';
+ desc.textContent = String(msg || '');
+
+ el.appendChild(title);
+ el.appendChild(desc);
+
+ const duration = typeof opts.duration === 'number' ? opts.duration : 1800;
+ let closed = false;
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ el.classList.remove('show');
+ setTimeout(() => el.parentNode && el.parentNode.removeChild(el), 220);
+ };
+ const timer = setTimeout(close, duration);
+
+ // 可选操作按钮:用于“轻提醒”,不打断输入流
+ if (opts.action && typeof opts.action === 'object' && opts.action.text && typeof opts.action.onClick === 'function') {
+ const actions = document.createElement('div');
+ actions.className = 'actions';
+ const btn = document.createElement('button');
+ btn.className = 'toast-action-btn';
+ btn.type = 'button';
+ btn.textContent = String(opts.action.text);
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ clearTimeout(timer);
+ try {
+ opts.action.onClick();
+ } catch (_) {
+ /* ignore */
+ }
+ close();
+ });
+ actions.appendChild(btn);
+ el.appendChild(actions);
+ }
+
+ host.appendChild(el);
+ requestAnimationFrame(() => el.classList.add('show'));
+
+ el.addEventListener('click', () => {
+ clearTimeout(timer);
+ close();
+ });
+ };
+
+ const copyTextSafe = async (text) => {
+ const content = text || '';
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(content);
+ return true;
+ }
+ } catch (_) {
+ /* fallback below */
+ }
+ // 兼容 HTTP / 非安全环境:使用隐藏 textarea
+ const ta = document.createElement('textarea');
+ ta.value = content;
+ ta.setAttribute('readonly', 'readonly');
+ ta.style.position = 'fixed';
+ ta.style.left = '-9999px';
+ ta.style.top = '-9999px';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ let ok = false;
+ try {
+ ok = document.execCommand('copy');
+ } catch (_) {
+ ok = false;
+ }
+ ta.parentNode && document.body.removeChild(ta);
+ return ok;
+ };
+
+ let previewModalState = null; // { url, type, taskId }
+ let editStoryboardModalState = null; // { taskId }
+
+ const buildEmbedHtml = (url, type) => {
+ const u = String(url || '');
+ if (!u) return '';
+ return type === 'image'
+ ? `

`
+ : `
`;
+ };
+
+ const closePreviewModal = () => {
+ if (!previewModal) return;
+ previewModal.classList.remove('open');
+ previewModal.setAttribute('aria-hidden', 'true');
+ if (previewModalMedia) previewModalMedia.innerHTML = '';
+ previewModalState = null;
+ };
+
+ const openPreviewModal = (url, type = 'video', taskId = null) => {
+ if (!previewModal || !previewModalMedia) return;
+ if (!url || !isValidMediaUrl(url)) {
+ showToast('无效的预览链接', 'warn');
+ return;
+ }
+
+ const tid = taskId ? parseInt(String(taskId), 10) : null;
+ const t = tid ? tasks.find((x) => x.id === tid) : null;
+ const metaText = t && t.meta ? [t.meta.resolution, t.meta.duration, t.meta.info].filter(Boolean).join(' · ') : '';
+ const stage = t && t.wmStage ? String(t.wmStage) : '';
+ const attempt =
+ t && typeof t.wmAttempt === 'number' ? t.wmAttempt : t ? parseInt(String(t.wmAttempt || '0'), 10) || 0 : 0;
+
+ previewModalState = { url: String(url), type: type === 'image' ? 'image' : 'video', taskId: tid };
+
+ // Head: badges
+ if (previewModalTaskId) {
+ if (tid) {
+ previewModalTaskId.style.display = 'inline-flex';
+ previewModalTaskId.textContent = `任务 ${tid}`;
+ } else {
+ previewModalTaskId.style.display = 'none';
+ previewModalTaskId.textContent = '';
+ }
+ }
+ if (previewModalStoryboard) {
+ const sbLabel = t && t.storyboard && t.storyboard.label ? String(t.storyboard.label) : '';
+ if (sbLabel) {
+ previewModalStoryboard.style.display = 'inline-flex';
+ previewModalStoryboard.textContent = sbLabel;
+ } else {
+ previewModalStoryboard.style.display = 'none';
+ previewModalStoryboard.textContent = '';
+ }
+ }
+ if (previewModalWatermark) {
+ if (stage) {
+ previewModalWatermark.style.display = 'inline-flex';
+ previewModalWatermark.textContent =
+ stage === 'cancelled'
+ ? '已取消去水印'
+ : stage === 'ready'
+ ? '无水印'
+ : `去水印中${attempt > 0 ? ` · ${attempt}` : ''}`;
+ } else {
+ previewModalWatermark.style.display = 'none';
+ previewModalWatermark.textContent = '';
+ }
+ }
+
+ // Head: meta line (kept simple; full URL still available via copy/open)
+ if (previewModalMeta) {
+ previewModalMeta.textContent = (metaText ? `${metaText} · ` : '') + String(url);
+ }
+
+ // Actions
+ if (previewModalDownload) {
+ const href = normalizeTmpDownloadUrl(String(url));
+ previewModalDownload.setAttribute('href', href);
+ try {
+ const filename = buildDownloadFilename(t, href, previewModalState.type, 1);
+ previewModalDownload.setAttribute('download', filename);
+ previewModalDownload.title = filename;
+ } catch (_) {
+ // 至少保证有 download 属性(无值时浏览器会用 URL 文件名)
+ previewModalDownload.setAttribute('download', '');
+ previewModalDownload.title = '下载';
+ }
+ }
+ if (btnPreviewLocateTask) {
+ btnPreviewLocateTask.disabled = !tid;
+ }
+
+ // 兜底:无论用户是否切到“预览”Tab,只要打开了预览弹层,就视为已读(避免红点反复冒出来)
+ if (tid) {
+ try {
+ markPreviewSeen(tid);
+ } catch (_) {
+ /* ignore */
+ }
+ updateUnreadDots();
+ }
+
+ // Body: media
+ previewModalMedia.innerHTML = '';
+ if (previewModalState.type === 'image') {
+ const img = document.createElement('img');
+ img.src = String(url);
+ img.alt = 'preview';
+ previewModalMedia.appendChild(img);
+ } else {
+ const v = document.createElement('video');
+ v.src = String(url);
+ v.controls = true;
+ v.autoplay = true;
+ v.muted = true;
+ v.loop = true;
+ v.playsInline = true;
+ previewModalMedia.appendChild(v);
+ }
+
+ // Open
+ previewModal.classList.add('open');
+ previewModal.setAttribute('aria-hidden', 'false');
+ };
+
+ const closeEditStoryboardModal = () => {
+ if (!editStoryboardModal) return;
+ editStoryboardModal.classList.remove('open');
+ editStoryboardModal.setAttribute('aria-hidden', 'true');
+ editStoryboardModalState = null;
+ if (editStoryboardTextarea) editStoryboardTextarea.value = '';
+ };
+
+ const rebuildStoryboardPromptSend = (oldSend, oldShotText, newShotText) => {
+ const send = String(oldSend || '');
+ const oldShot = String(oldShotText || '');
+ const next = String(newShotText || '');
+ if (!send) return next;
+
+ const sendTrim = send.replace(/\s+$/, '');
+ const oldTrim = oldShot.replace(/\s+$/, '');
+ if (oldTrim && sendTrim.endsWith(oldTrim)) {
+ return sendTrim.slice(0, sendTrim.length - oldTrim.length) + next;
+ }
+ if (oldTrim) {
+ const idx = sendTrim.lastIndexOf(oldTrim);
+ if (idx >= 0) {
+ return sendTrim.slice(0, idx) + next + sendTrim.slice(idx + oldTrim.length);
+ }
+ }
+ // Fallback: append as a new final segment, keeping old context intact.
+ return sendTrim + (sendTrim ? '\n\n' : '') + next;
+ };
+
+ const openEditStoryboardModal = (taskId) => {
+ if (!editStoryboardModal || !editStoryboardTextarea) return;
+ const tid = taskId ? parseInt(String(taskId), 10) : 0;
+ const t = tid ? tasks.find((x) => x.id === tid) : null;
+ if (!t || !t.storyboard) {
+ showToast('未找到该分镜任务', 'warn');
+ return;
+ }
+ const sbLabel = t.storyboard && t.storyboard.label ? String(t.storyboard.label) : '';
+ if (editStoryboardModalBadge) {
+ if (sbLabel) {
+ editStoryboardModalBadge.style.display = 'inline-flex';
+ editStoryboardModalBadge.textContent = sbLabel;
+ } else {
+ editStoryboardModalBadge.style.display = 'none';
+ editStoryboardModalBadge.textContent = '';
+ }
+ }
+ if (editStoryboardModalMeta) {
+ editStoryboardModalMeta.textContent = sbLabel ? `修改分镜提示词(${sbLabel})` : '修改分镜提示词(仅影响当前分镜任务)';
+ }
+ editStoryboardModalState = { taskId: tid };
+ editStoryboardTextarea.value = String(t.promptUser || '');
+ editStoryboardModal.classList.add('open');
+ editStoryboardModal.setAttribute('aria-hidden', 'false');
+ setTimeout(() => {
+ try {
+ editStoryboardTextarea.focus();
+ const len = editStoryboardTextarea.value.length;
+ editStoryboardTextarea.setSelectionRange(len, len);
+ } catch (_) {
+ /* ignore */
+ }
+ }, 0);
+ };
+
+ const submitEditStoryboardModal = async () => {
+ if (!editStoryboardModalState || !editStoryboardTextarea) return;
+ const tid = editStoryboardModalState && editStoryboardModalState.taskId ? parseInt(String(editStoryboardModalState.taskId), 10) : 0;
+ const t = tid ? tasks.find((x) => x.id === tid) : null;
+ if (!t) {
+ closeEditStoryboardModal();
+ return;
+ }
+
+ const nextShotText = String(editStoryboardTextarea.value || '').trim();
+ if (!nextShotText) {
+ showToast('请先修改分镜提示词(不能为空)', 'warn');
+ return;
+ }
+
+ const apiKey = $('apiKey').value.trim();
+ const baseUrl = getBaseUrl();
+ if (!apiKey || !baseUrl) {
+ showToast('请先填写 API Key 和服务器地址');
+ return;
+ }
+
+ const nextSend = rebuildStoryboardPromptSend(t.promptSend, t.promptUser, nextShotText);
+ closeEditStoryboardModal();
+ showToast('已提交修改,正在重试该分镜…', 'info', { title: '分镜重试' });
+ await runJobs(
+ [
+ {
+ taskId: tid,
+ promptSend: nextSend,
+ promptUser: nextShotText,
+ file: null,
+ model: t.model || $('model').value,
+ storyboard: t.storyboard || null
+ }
+ ],
+ apiKey,
+ baseUrl,
+ 1
+ );
+ };
+
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+ const sleepCancellable = async (ms, shouldStop) => {
+ const end = Date.now() + Math.max(0, ms || 0);
+ while (Date.now() < end) {
+ if (shouldStop && shouldStop()) return false;
+ const left = end - Date.now();
+ await sleep(Math.min(250, Math.max(0, left)));
+ }
+ return !(shouldStop && shouldStop());
+ };
+
+ const formatBytes = (bytes) => {
+ const n = Number(bytes) || 0;
+ if (n <= 0) return '0B';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ const idx = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
+ const val = n / Math.pow(1024, idx);
+ return `${val.toFixed(idx === 0 ? 0 : 2)}${units[idx]}`;
+ };
+
+ const detectOrientation = (w, h) => {
+ const ww = Number(w) || 0;
+ const hh = Number(h) || 0;
+ if (!ww || !hh) return '';
+ if (Math.abs(ww - hh) <= 2) return 'square';
+ return ww > hh ? 'landscape' : 'portrait';
+ };
+
+ const parseModelId = (m = '') => {
+ const model = String(m || '');
+ const isVideo = model.startsWith('sora-video');
+ const isImage = model.startsWith('sora-image');
+ const orientation = /portrait/i.test(model) ? 'portrait' : /landscape/i.test(model) ? 'landscape' : '';
+ const duration = /15s/i.test(model) ? '15s' : /10s/i.test(model) ? '10s' : '';
+ return { isVideo, isImage, orientation, duration };
+ };
+
+ const getSelectedModelLabel = () => {
+ const sel = $('model');
+ return sel && sel.selectedOptions && sel.selectedOptions[0] ? sel.selectedOptions[0].textContent.trim() : $('model')?.value || '';
+ };
+
+ const setBannerText = (text) => {
+ if (!uxBanner) return;
+ const msg = (text || '').trim();
+ if (!msg) {
+ uxBanner.style.display = 'none';
+ uxBanner.textContent = '';
+ return;
+ }
+ uxBanner.textContent = msg;
+ uxBanner.style.display = 'block';
+ };
+
+ const clearPreviewObjectUrl = () => {
+ try {
+ if (previewObjectUrl) URL.revokeObjectURL(previewObjectUrl);
+ } catch (_) {
+ /* ignore */
+ }
+ previewObjectUrl = null;
+ lastPreviewSignature = '';
+ lastPreviewInfo = null;
+ };
+
+ const getImageSize = (src) =>
+ new Promise((resolve) => {
+ const img = new Image();
+ img.onload = () => resolve({ w: img.naturalWidth || 0, h: img.naturalHeight || 0 });
+ img.onerror = () => resolve(null);
+ img.src = src;
+ });
+
+ const renderChips = (el, items) => {
+ if (!el) return;
+ el.innerHTML = '';
+ (items || []).forEach((it) => {
+ const chip = document.createElement('span');
+ const cls = it.kind ? `chip ${it.kind}` : 'chip';
+ chip.className = cls;
+ chip.textContent = it.text || '';
+ el.appendChild(chip);
+ });
+ };
+
+ const humanizeUpstreamError = (raw) => {
+ const text = String(raw?.message || raw?.error?.message || raw || '').trim();
+
+ // 尝试从 “API request failed: 400 - {json}” 中提取 JSON
+ let inner = null;
+ const jsonStart = text.indexOf('{');
+ if (jsonStart >= 0) {
+ const maybe = text.slice(jsonStart);
+ try {
+ inner = JSON.parse(maybe);
+ } catch (_) {
+ inner = null;
+ }
+ }
+
+ const err = inner && inner.error ? inner.error : raw && raw.error ? raw.error : null;
+ const code = err && err.code ? String(err.code) : '';
+ const param = err && err.param ? String(err.param) : '';
+ const msg = err && err.message ? String(err.message) : '';
+ const merged = (msg || text || '').trim();
+
+ // 典型:地区限制(用户最常见困惑点之一)
+ const ccFromText = (() => {
+ const m = merged.match(/\(([A-Za-z]{2})\)/);
+ return m ? m[1] : '';
+ })();
+ if (
+ code === 'unsupported_country_code' ||
+ /not available in your country/i.test(merged) ||
+ /国家\/地区不可用|地区不可用|Sora.*不可用/i.test(merged)
+ ) {
+ const cc = param || ccFromText || '未知';
+ return {
+ type: 'error',
+ title: '地区限制',
+ message: `Sora 在你当前网络出口地区不可用(${cc})。\n解决:切换代理/机房到支持地区后再试。`
+ };
+ }
+
+ // 典型:Cloudflare challenge(Sora 网页端经常触发)
+ if (/Just a moment|Enable JavaScript and cookies to continue|__cf_bm|cloudflare/i.test(text)) {
+ return {
+ type: 'error',
+ title: 'Cloudflare 拦截',
+ message: '触发 Cloudflare 风控拦截。\n解决:更换更“干净”的出口 IP/代理,或降低并发与请求频率。'
+ };
+ }
+
+ // 兜底:把 JSON 里的 error.message 拿出来
+ if (merged) {
+ return {
+ type: /warn|limit|blocked|guardrail|违规|不支持|限制/i.test(merged) ? 'warn' : 'error',
+ title: '生成失败',
+ message: merged
+ };
+ }
+
+ return { type: 'error', title: '生成失败', message: '未知错误(上游未返回可读信息)' };
+ };
+
+ // 内容政策/审查命中:用于分镜兜底(出现审查报错时提供“修改分镜提示词”按钮)
+ const isContentPolicyViolation = (raw) => {
+ const s = String(raw || '').trim();
+ if (!s) return false;
+ return (
+ /Content Policy Violation/i.test(s) ||
+ /may violate our content policies/i.test(s) ||
+ /content policies?/i.test(s) && /violate|violation/i.test(s) ||
+ /内容.*(政策|审核|审查)/.test(s) ||
+ /审核未通过|审查未通过|内容不合规|内容违规/.test(s)
+ );
+ };
+
+ const renderFilePreview = async () => {
+ if (!filePreviewBox || !filePreviewMedia || !filePreviewName || !filePreviewKind || !filePreviewMeta || !filePreviewHints) return;
+
+ const files = Array.from((fileInput?.files && fileInput.files.length ? fileInput.files : []) || []);
+ const promptText = (promptBox?.value || '').trim();
+ const modelId = $('model')?.value || '';
+ const modelInfo = parseModelId(modelId);
+
+ currentRecommendedModel = null;
+ if (btnUseRecommendedModel) btnUseRecommendedModel.style.display = 'none';
+
+ if (!files.length) {
+ filePreviewBox.style.display = 'none';
+ filePreviewMedia.innerHTML = '';
+ filePreviewName.textContent = '未选择文件';
+ filePreviewKind.textContent = '素材';
+ filePreviewMeta.textContent = '';
+ renderChips(filePreviewHints, []);
+ setBannerText('');
+ clearPreviewObjectUrl();
+ notifyHeight();
+ return;
+ }
+
+ filePreviewBox.style.display = 'flex';
+
+ const imgCount = files.filter((f) => (f.type || '').startsWith('image')).length;
+ const vidCount = files.filter((f) => (f.type || '').startsWith('video')).length;
+ const mixed = imgCount > 0 && vidCount > 0;
+
+ const first = files[0];
+ const name = first?.name || '未命名文件';
+ filePreviewName.textContent = files.length > 1 ? `${files.length} 个文件(${name} 等)` : name;
+
+ // 素材类型标签
+ const kindText = mixed ? `混合(${imgCount}图/${vidCount}视频)` : vidCount ? `视频(${vidCount})` : `图片(${imgCount})`;
+ filePreviewKind.textContent = kindText;
+
+ const signature = `${files.length}:${name}:${first.size}:${first.lastModified}:${first.type}`;
+ const isImage = (first.type || '').startsWith('image');
+ const isVideo = (first.type || '').startsWith('video');
+ const needReload = signature !== lastPreviewSignature || !previewObjectUrl || !filePreviewMedia.firstChild;
+
+ let w = 0;
+ let h = 0;
+ let orientation = '';
+
+ // 预览媒体:只有文件变化才重新创建 objectURL,避免输入提示词时闪烁/浪费
+ if (needReload) {
+ // 清理旧预览
+ if (previewObjectUrl) {
+ try {
+ URL.revokeObjectURL(previewObjectUrl);
+ } catch (_) {
+ /* ignore */
+ }
+ }
+ previewObjectUrl = URL.createObjectURL(first);
+ lastPreviewSignature = signature;
+ lastPreviewInfo = null;
+ filePreviewMedia.innerHTML = '';
+
+ if (isImage) {
+ const imgEl = document.createElement('img');
+ imgEl.src = previewObjectUrl;
+ imgEl.alt = 'upload preview';
+ filePreviewMedia.appendChild(imgEl);
+
+ const size = await getImageSize(previewObjectUrl);
+ if (size) {
+ w = size.w;
+ h = size.h;
+ orientation = detectOrientation(w, h);
+ }
+ lastPreviewInfo = { w, h, orientation, isImage: true, isVideo: false };
+ } else if (isVideo) {
+ const v = document.createElement('video');
+ v.src = previewObjectUrl;
+ v.controls = true;
+ v.muted = true;
+ v.playsInline = true;
+ v.preload = 'metadata';
+ filePreviewMedia.appendChild(v);
+ lastPreviewInfo = { w: 0, h: 0, orientation: '', isImage: false, isVideo: true };
+ // 尽力拿到分辨率(不阻塞 UI)
+ v.addEventListener(
+ 'loadedmetadata',
+ () => {
+ const vw = v.videoWidth || 0;
+ const vh = v.videoHeight || 0;
+ const o = detectOrientation(vw, vh);
+ const base = filePreviewMeta.textContent || '';
+ const extra = vw && vh ? ` · ${vw}x${vh}${o ? `(${o === 'portrait' ? '竖' : o === 'landscape' ? '横' : '方'})` : ''}` : '';
+ if (extra && !base.includes(`${vw}x${vh}`)) {
+ filePreviewMeta.textContent = base + extra;
+ notifyHeight();
+ }
+ },
+ { once: true }
+ );
+ } else {
+ filePreviewMedia.innerHTML = `
无法预览该文件类型
`;
+ lastPreviewInfo = { w: 0, h: 0, orientation: '', isImage: false, isVideo: false };
+ }
+ } else if (lastPreviewInfo) {
+ w = lastPreviewInfo.w || 0;
+ h = lastPreviewInfo.h || 0;
+ orientation = lastPreviewInfo.orientation || '';
+ }
+
+ const sizeText = formatBytes(first.size);
+ const dimText = w && h ? `${w}x${h}` : '';
+ const orientationText = orientation === 'portrait' ? '竖' : orientation === 'landscape' ? '横' : orientation === 'square' ? '方' : '';
+ const modelLabel = getSelectedModelLabel();
+
+ filePreviewMeta.textContent = [
+ `当前模型:${modelLabel}`,
+ `文件:${sizeText}`,
+ dimText ? `分辨率:${dimText}${orientationText ? `(${orientationText})` : ''}` : ''
+ ]
+ .filter(Boolean)
+ .join(' · ');
+
+ // 推荐模型:仅对“图片首帧”特别提示横竖匹配(最常见困惑点)
+ if (isImage && orientation) {
+ if (modelInfo.isVideo) {
+ const dur = modelInfo.duration || '15s';
+ if (orientation === 'portrait') currentRecommendedModel = `sora-video-portrait-${dur}`;
+ if (orientation === 'landscape') currentRecommendedModel = `sora-video-landscape-${dur}`;
+ // square 不强推
+ } else if (modelInfo.isImage) {
+ if (orientation === 'portrait') currentRecommendedModel = 'sora-image-portrait';
+ if (orientation === 'landscape') currentRecommendedModel = 'sora-image-landscape';
+ if (orientation === 'square') currentRecommendedModel = 'sora-image';
+ }
+ if (currentRecommendedModel && currentRecommendedModel !== modelId && btnUseRecommendedModel) {
+ btnUseRecommendedModel.style.display = 'inline-flex';
+ }
+ }
+
+ const chips = [];
+ if (mixed) chips.push({ text: '混合选择:建议不要图/视频混用(容易跑偏)', kind: 'warn' });
+ if (modelInfo.isImage && vidCount > 0) chips.push({ text: '图片模型 + 视频素材:视频不会被使用', kind: 'warn' });
+ if (modelInfo.isVideo && imgCount > 0 && !promptText) chips.push({ text: '图片首帧但提示词为空:结果可能与图无关', kind: 'warn' });
+ if (currentRecommendedModel && currentRecommendedModel !== modelId) chips.push({ text: `推荐模型:${currentRecommendedModel}`, kind: 'info' });
+ if (!chips.length) chips.push({ text: '已就绪', kind: 'ok' });
+ renderChips(filePreviewHints, chips);
+
+ // Banner:只保留最关键一句,避免信息噪声
+ if (modelInfo.isVideo && imgCount > 0 && !promptText) {
+ setBannerText('提示:你上传了图片但没写提示词。图片只是“参考/首帧”,建议补一句你希望画面发生什么(动作/镜头/风格),否则容易跑偏。');
+ } else if (modelInfo.isImage && vidCount > 0) {
+ setBannerText('提示:你上传的是视频,但当前模型是“图片”。视频不会参与生成;请切换到视频模型或换成图片文件。');
+ } else if (mixed) {
+ setBannerText('提示:你同时选了图片和视频。建议分开跑(同一批只放同类型文件),可减少异常与不相关结果。');
+ } else {
+ setBannerText('');
+ }
+
+ notifyHeight();
+ };
+
+ const showBubble = (msg, anchor) => {
+ const host = document.getElementById('logActions') || anchor?.parentElement || document.body;
+ const bubble = document.createElement('div');
+ bubble.className = 'bubble-toast';
+ bubble.textContent = msg;
+ host.appendChild(bubble);
+ requestAnimationFrame(() => bubble.classList.add('show'));
+ setTimeout(() => {
+ bubble.classList.remove('show');
+ setTimeout(() => bubble.parentNode && bubble.parentNode.removeChild(bubble), 180);
+ }, 1200);
+ };
+
+ const notifyHeight = () => {
+ try {
+ const page = document.querySelector('.page');
+ const h = page
+ ? Math.ceil((page.getBoundingClientRect()?.height || 0) + (page.offsetTop || 0))
+ : Math.max(document.documentElement?.scrollHeight || 0, document.body?.scrollHeight || 0);
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage({ type: 'sora-generate-height', height: h }, '*');
+ }
+ } catch (_) {
+ /* ignore */
+ }
+ };
+
+ // ===== 预览未读红点:基于“任务 id 是否已看过” =====
+ const getCurrentPreviewTaskIds = () =>
+ (Array.isArray(tasks) ? tasks : [])
+ .filter((t) => t && t.url)
+ .map((t) => t.id)
+ .filter((id) => typeof id === 'number' && id > 0);
+
+ const prunePreviewSeenTaskIds = () => {
+ const existing = new Set((Array.isArray(tasks) ? tasks : []).map((t) => t.id).filter((id) => typeof id === 'number'));
+ previewSeenTaskIds = new Set(Array.from(previewSeenTaskIds).filter((id) => existing.has(id)));
+ };
+
+ const persistPreviewSeenTaskIds = () => {
+ try {
+ prunePreviewSeenTaskIds();
+ localStorage.setItem(PREVIEW_SEEN_KEY, JSON.stringify(Array.from(previewSeenTaskIds.values())));
+ } catch (_) {
+ /* ignore */
+ }
+ };
+
+ const loadPreviewSeenTaskIds = () => {
+ try {
+ const raw = localStorage.getItem(PREVIEW_SEEN_KEY) || '[]';
+ const arr = JSON.parse(raw);
+ previewSeenTaskIds = new Set(
+ Array.isArray(arr)
+ ? arr
+ .map((x) => parseInt(String(x), 10))
+ .filter((n) => !isNaN(n) && n > 0)
+ : []
+ );
+ } catch (_) {
+ previewSeenTaskIds = new Set();
+ }
+ prunePreviewSeenTaskIds();
+ };
+
+ const markPreviewSeen = (taskId) => {
+ const id = typeof taskId === 'number' ? taskId : parseInt(String(taskId || '0'), 10);
+ if (!id) return;
+ previewSeenTaskIds.add(id);
+ persistPreviewSeenTaskIds();
+ };
+
+ const markAllPreviewsSeen = () => {
+ getCurrentPreviewTaskIds().forEach((id) => previewSeenTaskIds.add(id));
+ persistPreviewSeenTaskIds();
+ };
+
+ const hasUnseenPreviews = () => getCurrentPreviewTaskIds().some((id) => !previewSeenTaskIds.has(id));
+
+ // ===== 预览过滤(全部/视频/图片/分镜)=====
+ const normalizePreviewFilter = (v) => {
+ const s = String(v || '').toLowerCase();
+ return s === 'video' || s === 'image' || s === 'storyboard' ? s : 'all';
+ };
+ const previewFilterLabel = (f) =>
+ f === 'video' ? '视频' : f === 'image' ? '图片' : f === 'storyboard' ? '分镜' : '全部';
+ let previewFilter = normalizePreviewFilter(localStorage.getItem(PREVIEW_FILTER_KEY) || 'all');
+
+ const taskMatchesPreviewFilter = (t, f) => {
+ const filter = normalizePreviewFilter(f);
+ if (!t) return false;
+ if (filter === 'all') return true;
+ if (filter === 'storyboard') return (t.tag || '') === 'storyboard' || !!t.storyboard;
+ const ty = String(t.type || '').toLowerCase();
+ return filter === 'video' ? ty === 'video' : filter === 'image' ? ty === 'image' : true;
+ };
+
+ const syncPreviewFilterButtons = () => {
+ if (!previewFilterBar) return;
+ previewFilterBar.querySelectorAll('[data-preview-filter]').forEach((btn) => {
+ const val = normalizePreviewFilter(btn.getAttribute('data-preview-filter') || 'all');
+ btn.classList.toggle('active', val === previewFilter);
+ });
+ };
+
+ const setPreviewFilter = (next, opts = {}) => {
+ const persist = !(opts && opts.persist === false);
+ const render = !(opts && opts.render === false);
+ const toast = !!(opts && opts.toast);
+ const f = normalizePreviewFilter(next);
+ if (f === previewFilter) return;
+ previewFilter = f;
+ if (persist) {
+ try {
+ localStorage.setItem(PREVIEW_FILTER_KEY, previewFilter);
+ } catch (_) {
+ /* ignore */
+ }
+ }
+ syncPreviewFilterButtons();
+ if (render) renderPreviews();
+ if (toast) showToast(`预览过滤:${previewFilterLabel(previewFilter)}`, 'info', { duration: 1400 });
+ };
+
+ const updateUnreadDots = () => {
+ const setDot = (tab, on) => {
+ const btn = rightTabButtons.find((b) => b.getAttribute('data-tab') === tab);
+ const dot = btn?.querySelector('.dot');
+ btn?.classList.toggle('has-unread', on);
+ if (dot) dot.style.display = on ? 'block' : 'none';
+ };
+ const previewUnread = hasUnseenPreviews() && currentRightTab !== 'preview';
+ const logUnread = logVersion > logSeenVersion && currentRightTab !== 'log';
+ setDot('tasks', unread.tasks && currentRightTab !== 'tasks');
+ setDot('preview', previewUnread);
+ setDot('log', logUnread);
+ };
+
+ const appendLog = (text) => {
+ const line = `[${new Date().toLocaleTimeString()}] ${text}`;
+ const existing = (out.textContent || '').split('\n').filter(Boolean);
+ existing.push(line);
+ const trimmed = existing.slice(-LOG_MAX_LINES).join('\n');
+ out.textContent = trimmed.slice(-LOG_MAX_CHARS) + '\n';
+ out.scrollTop = out.scrollHeight;
+ logVersion += 1;
+ if (currentRightTab === 'log') {
+ logSeenVersion = logVersion;
+ }
+ updateUnreadDots();
+ };
+
+ const log = (msg) => appendLog(msg);
+
+ const logTask = (taskId, msg) => {
+ appendLog(`任务#${taskId} | ${msg}`);
+ taskLogBuffer[taskId] = (taskLogBuffer[taskId] || '') + `[${new Date().toLocaleTimeString()}] ${msg}\n`;
+ const t = tasks.find((x) => x.id === taskId);
+ if (t) {
+ const mergedLog = (t.logFull || '') + '\n' + `[${new Date().toLocaleTimeString()}] ${msg}`;
+ updateTask(taskId, { logFull: mergedLog });
+ }
+ };
+
+ const getTaskLogText = (t) => {
+ if (!t) return '';
+ const merged =
+ (taskLogBuffer[t.id] || '')
+ .split('\n')
+ .filter(Boolean)
+ .join('\n') ||
+ t.logFull ||
+ t.logTail ||
+ '';
+ return merged.trim();
+ };
+
+ const renderLogPanel = () => {
+ if (!logListContainer || !logDetailContent) return;
+ if (!tasks.length) {
+ logListContainer.innerHTML = '
暂无任务
';
+ logDetailId.textContent = '';
+ logDetailStatus.textContent = '';
+ logDetailMeta.textContent = '';
+ logDetailContent.textContent = '暂无日志';
+ return;
+ }
+
+ // 确保当前选中任务合法
+ if (!currentLogTaskId || !tasks.find((t) => t.id === currentLogTaskId)) {
+ currentLogTaskId = tasks[0].id;
+ }
+
+ // 渲染左侧列表
+ const statusMap = {
+ queue: '排队中',
+ running: '生成中',
+ retrying: '重试中',
+ done: '已完成',
+ error: '失败',
+ stalled: '中断',
+ character_done: '角色卡成功',
+ character_error: '角色卡失败'
+ };
+ logListContainer.innerHTML = tasks
+ .map((t) => {
+ const active = t.id === currentLogTaskId;
+ const statusText =
+ t.type === 'character'
+ ? t.status === 'done'
+ ? statusMap.character_done
+ : statusMap.character_error
+ : statusMap[t.status] || '未知';
+ const msg = t.message || '';
+ return `
+
+
+ #${t.id}
+ ${statusText}
+
+
+
+ ${escapeHtml(t.promptSnippet || '(空提示)')}
+
+ ${msg ? `
${escapeHtml(msg)}
` : ''}
+
+
+ `;
+ })
+ .join('');
+
+ logListContainer.querySelectorAll('[data-logitem]').forEach((el) => {
+ el.addEventListener('click', () => {
+ const id = parseInt(el.getAttribute('data-logitem'), 10);
+ if (!isNaN(id)) {
+ currentLogTaskId = id;
+ renderLogPanel();
+ }
+ });
+ });
+
+ // 渲染右侧详情
+ const current = tasks.find((t) => t.id === currentLogTaskId) || tasks[0];
+ if (current) {
+ const statusText =
+ current.type === 'character'
+ ? current.status === 'done'
+ ? statusMap.character_done
+ : statusMap.character_error
+ : statusMap[current.status] || '未知';
+ logDetailId.textContent = `#${current.id}`;
+ logDetailStatus.textContent = statusText;
+ logDetailMeta.textContent =
+ (current.meta && [current.meta.resolution, current.meta.duration, current.meta.info].filter(Boolean).join(' · ')) ||
+ current.message ||
+ '';
+ logDetailContent.textContent = getTaskLogText(current) || '暂无日志';
+ logDetailContent.scrollTop = logDetailContent.scrollHeight;
+ }
+ };
+
+ const renderTaskLogContent = renderLogPanel;
+ const renderTaskLogList = renderLogPanel;
+
+ const setTaskCount = () => {
+ taskCount.textContent = `${tasks.length} 个任务`;
+ };
+
+ const renderTasks = () => {
+ const baseList = onlyRunning
+ ? tasks.filter((t) => t.status === 'running' || t.status === 'retrying' || t.status === 'queue' || t.status === 'stalled')
+ : tasks;
+ const byTag = tagFilter
+ ? baseList.filter((t) => (tagFilter === 'storyboard' ? (t.tag === 'storyboard' || !!t.storyboard) : false))
+ : baseList;
+ const filtered = statusFilter
+ ? byTag.filter((t) =>
+ statusFilter === 'running' ? t.status === 'running' || t.status === 'retrying' : t.status === statusFilter
+ )
+ : byTag;
+ const counts = {
+ running: tasks.filter((t) => t.status === 'running' || t.status === 'retrying').length,
+ queue: tasks.filter((t) => t.status === 'queue').length,
+ done: tasks.filter((t) => t.status === 'done').length,
+ error: tasks.filter((t) => t.status === 'error').length
+ };
+ const tagCounts = {
+ storyboard: tasks.filter((t) => t.tag === 'storyboard' || !!t.storyboard).length
+ };
+ const totalCount = tasks.length;
+ const hiddenCount = baseList.length - filtered.length;
+ const groupBar = `
+
+
+
+
+
+
+
+
+ 标签
+
+
+
+ ${hiddenCount > 0 ? `
已隐藏 ${hiddenCount} 条不匹配的任务
` : ''}
+ `;
+
+ const html = filtered
+ .map((t) => {
+ const statusText =
+ t.timedOut
+ ? '网络超时'
+ : t.type === 'character' && t.status === 'done'
+ ? '角色卡创建成功'
+ : t.type === 'character' && t.status === 'error'
+ ? '角色卡创建失败'
+ : (() => {
+ const retryCount =
+ typeof t.retryCount === 'number' ? t.retryCount : parseInt(String(t.retryCount || '0'), 10) || 0;
+ const statusMap = {
+ queue: '排队中',
+ running: '生成中',
+ retrying: `重试中${retryCount > 0 ? ` · ${retryCount}` : ''}`,
+ done: '已完成',
+ error: '失败',
+ stalled: '中断'
+ };
+ return statusMap[t.status] || '未知';
+ })();
+ const statusClass = `status ${t.timedOut ? 'timedout' : t.status}`;
+ const msg = t.message || '';
+ const msgColor = t.status === 'retrying' ? '#b45309' : '#f87171';
+ const metaText = t.meta ? [t.meta.resolution, t.meta.duration].filter(Boolean).join(' · ') : '';
+ const stepIdx = t.status === 'queue' ? 1 : t.status === 'running' || t.status === 'retrying' ? 2 : 3;
+ const stepClass = t.status === 'error' ? 'error' : 'active';
+ const missingUrlWarn =
+ t.type !== 'character' && t.status === 'done' && !t.url
+ ? '
未返回视频链接,可能生成失败或后端未返回地址
'
+ : '';
+ const progress = t.progress ?? (t.status === 'done' ? 100 : 0);
+ const safeTitle = escapeAttr(t.promptUser || t.promptSnippet || '-');
+ const displayTitle = escapeHtml(t.promptSnippet || '-');
+ const safeMsg = escapeHtml(msg);
+ const metaChip = metaText ? `
${escapeHtml(metaText)}` : '';
+ const sb = t.storyboard;
+ const policyHit =
+ t.status === 'error' &&
+ (t.errorKind === 'policy' ||
+ isContentPolicyViolation(t.message || '') ||
+ isContentPolicyViolation(t.logTail || '') ||
+ isContentPolicyViolation(String(t.logFull || '').slice(-800)));
+ const canEditStoryboardPrompt = !!(policyHit && sb && sb.label);
+ const sbChip =
+ sb && sb.label
+ ? `
${escapeHtml(sb.label)}`
+ : '';
+ const sbTitleChip =
+ sb && sb.title
+ ? `
${escapeHtml(sb.title)}`
+ : '';
+ const wmStage = t.wmStage ? String(t.wmStage) : '';
+ const wmAttempt =
+ typeof t.wmAttempt === 'number' ? t.wmAttempt : parseInt(String(t.wmAttempt || '0'), 10) || 0;
+ const wmLabel = wmStage
+ ? wmStage === 'cancelled'
+ ? '已取消去水印'
+ : wmStage === 'ready'
+ ? '无水印已就绪'
+ : '等待去水印'
+ : '';
+ const wmChip = wmStage
+ ? `
${wmLabel}${wmAttempt > 0 ? ` · ${wmAttempt}` : ''}`
+ : '';
+ const progressWidth = Math.max(0, Math.min(100, progress));
+ if (t.collapsed && t.status === 'done') {
+ return `
+
+
+
+
任务 ${t.id}
+ ${sbChip}
+ ${wmChip}
+
${statusText}
+ ${metaChip}
+ ${sbTitleChip}
+
+
${displayTitle}
+
已折叠,点击展开查看详情
+
+
+ ${t.url ? `` : ''}
+
+
+
+ `;
+ }
+ return `
+
+
+
+
任务 ${t.id}
+ ${sbChip}
+ ${wmChip}
+
${statusText}
+ ${metaChip}
+ ${sbTitleChip}
+
+
${displayTitle}
+
${safeMsg}
+ ${missingUrlWarn}
+
+
+
+ 进度:${progress}%
+ 排队 · 生成 · 完成
+
+
+
+
+
+ ${t.url ? `` : ''}
+ ${
+ t.status === 'running' && t.wmCanCancel && t.remoteTaskId
+ ? ``
+ : ''
+ }
+ ${canEditStoryboardPrompt ? `` : ''}
+ ${
+ t.status === 'retrying' &&
+ t.retryMode === 'submit' &&
+ (typeof t.retryCount === 'number' ? t.retryCount : parseInt(String(t.retryCount || '0'), 10) || 0) >= 3
+ ? ``
+ : ''
+ }
+ ${t.timedOut || t.status === 'error' || (!t.url && t.status === 'done') ? `` : ''}
+ ${t.status === 'stalled' ? `` : ''}
+ ${t.promptUser ? `` : ''}
+
+
+
+ `;
+ })
+ .join('');
+ taskList.innerHTML = groupBar + (html || '
暂无任务
');
+
+ const flashCard = (btn) => {
+ const card = btn.closest('.task-card');
+ if (!card) return;
+ card.classList.add('flash', 'flash-bg');
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ setTimeout(() => card.classList.remove('flash', 'flash-bg'), 800);
+ };
+ const smoothFocus = (el) => {
+ if (!el) return;
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ if (el.focus) el.focus({ preventScroll: true });
+ el.classList.add('flash-bg');
+ setTimeout(() => el.classList.remove('flash-bg'), 600);
+ };
+ const flashPreview = (url, info = null) => {
+ setRightTab('preview');
+ try {
+ // 若当前过滤会把目标结果隐藏,则自动切换到可见的过滤条件(避免“点了查看但预览空白”)
+ const tid = info && typeof info.taskId === 'number' ? info.taskId : null;
+ const hintType = info && info.type ? String(info.type) : '';
+ const t = tid ? tasks.find((x) => x.id === tid) : tasks.find((x) => x && x.url === url);
+ const desired =
+ t && ((t.tag || '') === 'storyboard' || t.storyboard)
+ ? 'storyboard'
+ : String((t && t.type) || hintType || '').toLowerCase() === 'image'
+ ? 'image'
+ : 'video';
+ if (t && !taskMatchesPreviewFilter(t, previewFilter)) {
+ setPreviewFilter(desired, { toast: true });
+ } else {
+ // 兜底:确保 DOM 已按当前过滤重建
+ renderPreviews();
+ }
+ } catch (_) {
+ renderPreviews();
+ }
+
+ requestAnimationFrame(() => {
+ const cards = Array.from(previewGrid.querySelectorAll('.preview-card'));
+ const target = cards.find((c) => {
+ const media = c.querySelector('video,img');
+ return media && media.getAttribute('src') === url;
+ });
+ const el = target || previewGrid;
+ cards.forEach((c) => c.classList.remove('spotlight'));
+ el.classList.add('spotlight');
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ setTimeout(() => el.classList.remove('spotlight'), 1300);
+ });
+ };
+
+ taskList.querySelectorAll('.link-btn[data-url]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const url = btn.getAttribute('data-url');
+ const type = btn.getAttribute('data-type');
+ const card = btn.closest('.task-card');
+ const tid = card ? parseInt(card.getAttribute('data-id'), 10) : null;
+ flashPreview(url, { taskId: !isNaN(tid) ? tid : null, type });
+ flashCard(btn);
+ });
+ });
+ taskList.querySelectorAll('[data-reuse]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.getAttribute('data-reuse'), 10);
+ const t = tasks.find((x) => x.id === id);
+ if (t && t.promptUser) {
+ promptBox.value = t.promptUser;
+ analyzePromptHints();
+ showToast('提示已填充');
+ smoothFocus(promptBox);
+ flashCard(btn);
+ }
+ });
+ });
+ taskList.querySelectorAll('[data-edit-storyboard]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.getAttribute('data-edit-storyboard'), 10);
+ if (!id) return;
+ openEditStoryboardModal(id);
+ flashCard(btn);
+ });
+ });
+ taskList.querySelectorAll('[data-abort-retry]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.getAttribute('data-abort-retry'), 10);
+ const ctl = taskRetryControls.get(id);
+ if (ctl) {
+ ctl.cancelled = true;
+ try {
+ if (typeof ctl.abortFetch === 'function') ctl.abortFetch();
+ } catch (_) {
+ /* ignore */
+ }
+ }
+ updateTask(id, { status: 'error', message: '已中断自动重试(可点击“重试”再次发起)' });
+ showToast('已中断自动重试', 'warn', { title: '已中断' });
+ flashCard(btn);
+ });
+ });
+ taskList.querySelectorAll('[data-retry]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const id = parseInt(btn.getAttribute('data-retry'), 10);
+ const t = tasks.find((x) => x.id === id);
+ const apiKey = $('apiKey').value.trim();
+ const baseUrl = getBaseUrl();
+ if (!apiKey || !baseUrl) {
+ showToast('请先填写 API Key 和服务器地址');
+ return;
+ }
+ if (!t) {
+ showToast('未找到该任务,无法重试', 'error', { title: '重试失败', duration: 2600 });
+ return;
+ }
+ const job = {
+ taskId: id,
+ promptSend: t.promptSend || '',
+ promptUser: t.promptUser || '',
+ // 允许“空提示词 + 仅素材”的任务重试:素材仅保留在内存(刷新后不保证存在)
+ file: t._inputFile || null,
+ fileDataUrl: t._inputFileDataUrl || null,
+ model: t.model || $('model').value,
+ storyboard: t.storyboard || null
+ };
+ if (!job.promptSend && !job.file && !job.fileDataUrl) {
+ showToast('该任务没有可复用的提示词/素材,仍将尝试重试(可能失败)', 'warn', {
+ title: '空输入重试',
+ duration: 4200
+ });
+ } else if (!job.promptSend && (job.file || job.fileDataUrl)) {
+ showToast('空提示词重试:将只带素材提交(允许)', 'info', { title: '正在重试', duration: 2200 });
+ } else {
+ showToast('正在重试该任务', 'info');
+ }
+ await runJobs(
+ [job],
+ apiKey,
+ baseUrl,
+ 1
+ );
+ flashCard(btn);
+ });
+ });
+ taskList.querySelectorAll('[data-continue]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const id = parseInt(btn.getAttribute('data-continue'), 10);
+ const t = tasks.find((x) => x.id === id);
+ const apiKey = $('apiKey').value.trim();
+ const baseUrl = getBaseUrl();
+ if (!apiKey || !baseUrl) {
+ showToast('请先填写 API Key 和服务器地址');
+ return;
+ }
+ if (!t) {
+ showToast('未找到该任务,无法继续', 'error', { title: '继续失败', duration: 2600 });
+ return;
+ }
+ const job = {
+ taskId: id,
+ promptSend: t.promptSend || '',
+ promptUser: t.promptUser || '',
+ file: t._inputFile || null,
+ fileDataUrl: t._inputFileDataUrl || null,
+ model: t.model || $('model').value,
+ storyboard: t.storyboard || null
+ };
+ if (!job.promptSend && !job.file && !job.fileDataUrl) {
+ showToast('该任务没有可复用的提示词/素材,仍将尝试继续(可能失败)', 'warn', {
+ title: '空输入继续',
+ duration: 4200
+ });
+ } else if (!job.promptSend && (job.file || job.fileDataUrl)) {
+ showToast('空提示词继续:将只带素材提交(允许)', 'info', { title: '正在继续', duration: 2200 });
+ } else {
+ showToast('正在继续该任务', 'info');
+ }
+ await runJobs(
+ [job],
+ apiKey,
+ baseUrl,
+ 1
+ );
+ flashCard(btn);
+ });
+ });
+ taskList.querySelectorAll('[data-log]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.getAttribute('data-log'), 10);
+ const t = tasks.find((x) => x.id === id);
+ if (t) {
+ currentLogTaskId = t.id;
+ renderTaskLogList();
+ renderTaskLogContent();
+ setRightTab('log');
+ smoothFocus(logTaskPanel || out);
+ } else {
+ showToast('未找到该任务日志');
+ }
+ flashCard(btn);
+ });
+ });
+ taskList.querySelectorAll('[data-cancel-wm]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const id = parseInt(btn.getAttribute('data-cancel-wm'), 10);
+ const t = tasks.find((x) => x.id === id);
+ if (!t || !t.remoteTaskId) {
+ showToast('缺少 task_id,无法取消去水印等待');
+ return;
+ }
+ const apiKey = $('apiKey').value.trim();
+ const baseUrl = getBaseUrl();
+ if (!apiKey || !baseUrl) {
+ showToast('请先填写 API Key 和服务器地址');
+ return;
+ }
+ if (t.wmCancelling) return;
+
+ updateTask(id, { wmCancelling: true });
+ try {
+ const resp = await fetch(
+ `${baseUrl}/v1/tasks/${encodeURIComponent(t.remoteTaskId)}/watermark/cancel`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer ' + apiKey,
+ 'Content-Type': 'application/json'
+ }
+ }
+ );
+ if (!resp.ok) {
+ throw new Error('HTTP ' + resp.status);
+ }
+ showToast('已发送取消去水印请求', 'success');
+ } catch (e) {
+ updateTask(id, { wmCancelling: false });
+ showToast(`取消失败: ${e?.message || String(e)}`, 'error');
+ }
+ flashCard(btn);
+ });
+ });
+ taskList.querySelectorAll('[data-expand]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const id = parseInt(btn.getAttribute('data-expand'), 10);
+ updateTask(id, { collapsed: false });
+ });
+ });
+ taskList.querySelectorAll('[data-filter]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const target = btn.getAttribute('data-filter') || '';
+ statusFilter = statusFilter === target ? '' : target;
+ renderTasks();
+ });
+ });
+ taskList.querySelectorAll('[data-tag]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const target = btn.getAttribute('data-tag') || '';
+ tagFilter = tagFilter === target ? '' : target;
+ renderTasks();
+ });
+ });
+
+ setTaskCount();
+ updateTaskBubble();
+ // 日志面板只有在用户正在查看时才更新,避免流式更新导致每个 chunk 都重绘日志列表
+ if (currentRightTab === 'log') renderLogPanel();
+ // 任务状态同步给管理台(任务球/抽屉),用节流发送避免流式每个 chunk 都跨 iframe 重绘
+ schedulePostTaskState({ immediate: true });
+ };
+
+ const renderPreviews = () => {
+ if (!previewGrid) return;
+ const fullList = tasks.filter((t) => t && t.url && isValidMediaUrl(t.url));
+ const list = fullList.filter((t) => taskMatchesPreviewFilter(t, previewFilter));
+ previewGrid.innerHTML = '';
+ // 防止 URL 去重集合无限增长(任务多、URL 长时会占内存)
+ try {
+ const limit = 1200;
+ while (previewKnown.size > limit) {
+ const first = previewKnown.values().next().value;
+ previewKnown.delete(first);
+ }
+ } catch (_) {
+ /* ignore */
+ }
+
+ if (previewCount) {
+ const nextText = !fullList.length
+ ? ''
+ : `显示 ${list.length}/${fullList.length}${previewFilter === 'all' ? '' : ` · ${previewFilterLabel(previewFilter)}`}`;
+ const prevText = previewCountLastText || (previewCount.textContent || '');
+ if (prevText !== nextText) {
+ previewCount.textContent = nextText;
+ previewCountLastText = nextText;
+ try {
+ const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (reduce) throw new Error('reduced-motion');
+ if (nextText) {
+ // 轻防抖:避免流式频繁重绘导致“闪烁噪声”
+ if (previewCountFlashTimer) clearTimeout(previewCountFlashTimer);
+ previewCount.classList.remove('count-flash');
+ void previewCount.offsetWidth;
+ previewCount.classList.add('count-flash');
+ previewCountFlashTimer = setTimeout(() => {
+ try {
+ previewCount.classList.remove('count-flash');
+ } catch (_) {}
+ previewCountFlashTimer = null;
+ }, 1900);
+ }
+ } catch (_) {
+ /* ignore */
+ }
+ }
+ }
+
+ if (fullList.length === 0) {
+ // 预览为空:清空 URL 去重集合即可;未读红点由“已看过的任务 id 集合”控制
+ previewGrid.innerHTML = '
暂无预览结果。生成完成后会在这里出现。
';
+ previewsHydrated = true;
+ updateUnreadDots();
+ return;
+ }
+
+ if (list.length === 0) {
+ previewGrid.innerHTML =
+ '
当前过滤条件下暂无结果。可切换到“全部”查看。
';
+ previewsHydrated = true;
+ updateUnreadDots();
+ return;
+ }
+
+ // Tasks are stored newest-first (unshift). We render oldest-first and prepend each card,
+ // so the final DOM order stays newest-first.
+ list
+ .slice()
+ .reverse()
+ .forEach((t) => {
+ const metaText = t.meta ? [t.meta.resolution, t.meta.duration, t.meta.info].filter(Boolean).join(' · ') : '';
+ addPreviewCard(t.url, t.type, false, metaText || null, t.id);
+ });
+
+ previewsHydrated = true;
+ updateUnreadDots();
+ };
+
+ const addPreviewCard = (url, type = 'video', push = true, meta = null, taskId = null) => {
+ if (!url || !isValidMediaUrl(url)) return false;
+ const exists = Array.from(previewGrid.querySelectorAll('.preview-card')).some((card) => {
+ const el = card.querySelector('video,img');
+ const src = el ? el.getAttribute('src') : '';
+ return src === url;
+ });
+ if (exists) return false;
+ const isNew = !previewKnown.has(url);
+ previewKnown.add(url);
+ const card = document.createElement('div');
+ card.className = 'preview-card';
+ try {
+ // Set 有插入顺序:只保留最近一段时间的 URL,避免无上限增长
+ const limit = 1200;
+ while (previewKnown.size > limit) {
+ const first = previewKnown.values().next().value;
+ previewKnown.delete(first);
+ }
+ } catch (_) {
+ /* ignore */
+ }
+ if (previewsHydrated && isNew) {
+ card.classList.add('preview-new');
+ setTimeout(() => {
+ try {
+ card.classList.remove('preview-new');
+ } catch (_) {}
+ }, 3600);
+ }
+ // Escape URLs for HTML attributes/text (avoid `&bar` style entity decoding).
+ const safeUrlAttr = escapeHtml(url);
+ const safeUrlText = safeUrlAttr;
+ if (type === 'image') {
+ card.innerHTML = `

`;
+ } else {
+ card.innerHTML = `
`;
+ }
+ if (taskId) {
+ const wrap = document.createElement('div');
+ wrap.style.position = 'absolute';
+ wrap.style.top = '10px';
+ wrap.style.left = '10px';
+ wrap.style.zIndex = '2';
+ wrap.style.display = 'flex';
+ wrap.style.flexDirection = 'column';
+ wrap.style.gap = '6px';
+
+ const badge = document.createElement('div');
+ badge.className = 'task-id-pill'; // 统一编号视觉
+ badge.textContent = `任务 ${taskId}`;
+ badge.style.cursor = 'pointer';
+ badge.title = '点击定位到任务卡片';
+ wrap.appendChild(badge);
+
+ const t = tasks.find((x) => x.id === taskId);
+ const sbLabel = t && t.storyboard && t.storyboard.label ? String(t.storyboard.label) : '';
+ if (sbLabel) {
+ const sb = document.createElement('div');
+ sb.className = 'task-tag-chip storyboard';
+ sb.textContent = sbLabel;
+ wrap.appendChild(sb);
+ }
+ const wmStage = t && t.wmStage ? String(t.wmStage) : '';
+ const wmAttempt =
+ t && typeof t.wmAttempt === 'number' ? t.wmAttempt : t ? parseInt(String(t.wmAttempt || '0'), 10) || 0 : 0;
+ if (wmStage) {
+ const wm = document.createElement('div');
+ wm.className = 'task-tag-chip watermark';
+ wm.textContent =
+ wmStage === 'cancelled'
+ ? '已取消去水印'
+ : wmStage === 'ready'
+ ? '无水印'
+ : `去水印中${wmAttempt > 0 ? ` · ${wmAttempt}` : ''}`;
+ wrap.appendChild(wm);
+ }
+ card.style.position = 'relative';
+ card.appendChild(wrap);
+
+ // Clicking the task badge focuses the corresponding task card.
+ badge.addEventListener('click', (e) => {
+ e.stopPropagation();
+ setRightTab('tasks');
+ requestAnimationFrame(() => {
+ const el = taskList?.querySelector(`.task-card[data-id="${taskId}"]`);
+ if (!el) return;
+ el.classList.add('spotlight');
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ setTimeout(() => el.classList.remove('spotlight'), 1300);
+ });
+ });
+ }
+ const info = document.createElement('div');
+ info.className = 'preview-info';
+ const downloadHrefRaw = normalizeTmpDownloadUrl(url);
+ const downloadHref = escapeHtml(downloadHrefRaw);
+ let downloadName = '';
+ try {
+ const t = taskId ? tasks.find((x) => x.id === taskId) : null;
+ downloadName = buildDownloadFilename(t, downloadHrefRaw, type, 1);
+ } catch (_) {
+ downloadName = '';
+ }
+ info.innerHTML = `
+
${safeUrlText}
+ ${meta ? `
${escapeHtml(meta)}` : ''}
+
+
+ ${taskId ? `
` : ''}
+
下载
+
+
+ `;
+ card.appendChild(info);
+ previewGrid.prepend(card);
+
+ // 如果用户正在看“预览”页,新产出的预览默认视为已读(避免离开后红点又冒出来)
+ if (taskId && currentRightTab === 'preview') {
+ markPreviewSeen(taskId);
+ }
+ updateUnreadDots();
+
+ // 隐藏原生控件后仍支持点击播放/暂停
+ if (type !== 'image') {
+ const v = card.querySelector('video');
+ if (v) {
+ v.controls = false;
+ v.addEventListener('click', () => {
+ if (v.paused) v.play();
+ else v.pause();
+ });
+ }
+ }
+
+ card.querySelectorAll('[data-copy]').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ navigator.clipboard.writeText(btn.getAttribute('data-copy')).then(
+ () => showToast('已复制链接'),
+ () => showToast('复制失败')
+ );
+ });
+ });
+
+ card.querySelectorAll('[data-focus-task]').forEach((btn) => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const tid = parseInt(btn.getAttribute('data-focus-task') || '0', 10);
+ if (!tid) return;
+ setRightTab('tasks');
+ requestAnimationFrame(() => {
+ const el = taskList?.querySelector(`.task-card[data-id="${tid}"]`);
+ if (!el) return;
+ el.classList.add('spotlight');
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ setTimeout(() => el.classList.remove('spotlight'), 1300);
+ });
+ });
+ });
+
+ card.querySelectorAll('[data-open]').forEach((btn) => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ openPreviewModal(url, type, taskId);
+ });
+ });
+
+ if (push) {
+ // 预览仅用于展示,不写回任务
+ }
+ return isNew;
+ };
+
+ const syncRoleCardToLibrary = (card) => {
+ if (!card) return;
+ const username = card.username || card.display_name || '';
+ if (!username) return;
+ const exists = roles.some((r) => (r.username || r.display_name) === username);
+ if (exists) return;
+ roles.unshift({
+ username,
+ display_name: card.display_name || username,
+ description: card.bio || card.instruction_set || card.description || '',
+ avatar_path: card.avatar || card.avatar_url || ''
+ });
+ renderRoles();
+ };
+
+ const persistTasks = () => {
+ const compact = tasks
+ .slice(0, 20)
+ .map(
+ ({
+ id,
+ status,
+ promptSnippet,
+ promptUser,
+ promptSend,
+ url,
+ type,
+ message,
+ meta,
+ logTail,
+ logFull,
+ progress,
+ collapsed,
+ tag,
+ storyboard
+ }) => ({
+ id,
+ status,
+ promptSnippet,
+ promptUser,
+ promptSend,
+ url,
+ type,
+ message,
+ meta,
+ logTail,
+ logFull: (logFull || '').slice(-LOG_MAX_CHARS),
+ progress,
+ collapsed: !!collapsed,
+ tag: tag || '',
+ storyboard: storyboard || null
+ })
+ );
+ localStorage.setItem(taskStorageKey, JSON.stringify(compact));
+ };
+
+ const loadTasksFromStorage = () => {
+ try {
+ const saved = JSON.parse(localStorage.getItem(taskStorageKey) || '[]');
+ if (Array.isArray(saved)) {
+ tasks = saved.map((t) => {
+ const base = {
+ ...t,
+ promptUser: t.promptUser ?? t.promptFull ?? '',
+ promptSend: t.promptSend ?? t.promptFull ?? '',
+ promptFull: undefined,
+ logFull: t.logFull || '',
+ collapsed: !!t.collapsed,
+ tag: t.tag || '',
+ storyboard: t.storyboard || null
+ };
+ if (base.status === 'running' || base.status === 'queue') {
+ return { ...base, status: 'stalled', message: '刷新后任务可能中断,请点击继续或重试', progress: base.progress ?? 0 };
+ }
+ return base;
+ });
+ if (tasks.length) {
+ taskIdCounter = Math.max(...tasks.map((t) => t.id)) + 1;
+ if (currentLogTaskId === null) currentLogTaskId = tasks[0].id;
+ }
+ }
+ } catch (_) {
+ tasks = [];
+ }
+ };
+
+ const persistRoles = () => {
+ try {
+ localStorage.setItem(roleStorageKeyMain, JSON.stringify(attachedRoles));
+ } catch (_) {
+ /* ignore */
+ }
+ };
+
+ const persistRolesMulti = () => {
+ try {
+ localStorage.setItem(roleStorageKeyMulti, JSON.stringify(attachedRolesMulti));
+ } catch (_) {
+ /* ignore */
+ }
+ };
+
+ const persistRolesStoryboard = () => {
+ try {
+ localStorage.setItem(roleStorageKeyStoryboard, JSON.stringify(attachedRolesStoryboard));
+ } catch (_) {
+ /* ignore */
+ }
+ };
+
+ const loadRolesFromStorage = () => {
+ // 主提示(单次/同提示)全局挂载:兼容旧 key,避免升级后丢失
+ try {
+ const rawMain = localStorage.getItem(roleStorageKeyMain);
+ const rawLegacy = localStorage.getItem(roleStorageKeyLegacy);
+ const parsed = JSON.parse((rawMain || rawLegacy || '[]').toString());
+ attachedRoles = Array.isArray(parsed) ? parsed : [];
+ // 首次迁移:把 legacy 写回 main,后续就只读 main
+ if (!rawMain && rawLegacy) {
+ try {
+ localStorage.setItem(roleStorageKeyMain, JSON.stringify(attachedRoles));
+ } catch (_) {
+ /* ignore */
+ }
+ }
+ } catch (_) {
+ attachedRoles = [];
+ }
+
+ // 多提示/分镜:各自独立的“本模式全局角色”
+ try {
+ const parsed = JSON.parse((localStorage.getItem(roleStorageKeyMulti) || '[]').toString());
+ attachedRolesMulti = Array.isArray(parsed) ? parsed : [];
+ } catch (_) {
+ attachedRolesMulti = [];
+ }
+ try {
+ const parsed = JSON.parse((localStorage.getItem(roleStorageKeyStoryboard) || '[]').toString());
+ attachedRolesStoryboard = Array.isArray(parsed) ? parsed : [];
+ } catch (_) {
+ attachedRolesStoryboard = [];
+ }
+ };
+
+ const addTask = (promptSnippet, promptUser, promptSend, extra = null) => {
+ const modelId = extra && extra.model ? String(extra.model) : '';
+ const modelInfo = parseModelId(modelId);
+ const t = {
+ id: taskIdCounter++,
+ status: 'queue',
+ model: modelId,
+ promptSnippet,
+ promptUser: promptUser || '',
+ promptSend: promptSend || '',
+ url: null,
+ // 预设 mediaType:用于预览区正确选择 img/video 组件(避免“生成图片但套用视频逻辑”)
+ // 后续会在流式解析出真实 URL 后再次校正。
+ type: modelInfo.isImage ? 'image' : modelInfo.isVideo ? 'video' : 'video',
+ meta: null,
+ logTail: '',
+ logFull: '',
+ // Retry UX (submit retry / manual retry). Kept lightweight and persisted.
+ retryMode: '',
+ retryCount: 0,
+ // Used to decide whether to show special “edit storyboard prompt” button, etc.
+ errorKind: '',
+ // Sora task_id (from backend) - used for watermark-free cancel endpoint.
+ remoteTaskId: null,
+ // Watermark-free waiting UI state (filled from streaming delta.wm).
+ wmStage: '',
+ wmAttempt: 0,
+ wmCanCancel: false,
+ wmCancelling: false,
+ // 任务标签/分组:用于“分镜”筛选与编号展示
+ tag: extra && extra.storyboard ? 'storyboard' : '',
+ storyboard: extra && extra.storyboard ? extra.storyboard : null
+ };
+ tasks.unshift(t);
+ // 流式/并发下 addTask 也可能很频繁:用节流渲染与节流持久化避免卡顿
+ scheduleRender({ tasks: true, previews: false });
+ // 占位卡创建属于“对象恒常性”关键节点:尽量立即落盘,避免用户刷新后丢失
+ schedulePersistTasks({ immediate: true });
+ if (currentRightTab !== 'tasks') {
+ unread.tasks = true;
+ }
+ updateUnreadDots();
+ return t.id;
+ };
+
+ const collapseTimers = new Map();
+ // 任务级“自动重试/中断重试”控制柄(避免 UI 与运行时脱钩)
+ // Map
void) }>
+ const taskRetryControls = new Map();
+
+ // ===== 渲染/持久化节流(关键:解决流式每 chunk 全量重绘导致的卡顿) =====
+ let renderQueued = false;
+ let needRenderTasks = false;
+ let needRenderPreviews = false;
+
+ const scheduleRender = (opts = { tasks: true, previews: false }) => {
+ if (opts && opts.tasks) needRenderTasks = true;
+ if (opts && opts.previews) needRenderPreviews = true;
+ if (renderQueued) return;
+ renderQueued = true;
+ requestAnimationFrame(() => {
+ renderQueued = false;
+ const doTasks = needRenderTasks;
+ const doPreviews = needRenderPreviews;
+ needRenderTasks = false;
+ needRenderPreviews = false;
+ if (doTasks) renderTasks();
+ if (doPreviews) renderPreviews();
+ updateUnreadDots();
+ });
+ };
+
+ let persistTasksTimer = null;
+ const schedulePersistTasks = (opts = { immediate: false }) => {
+ if (opts && opts.immediate) {
+ if (persistTasksTimer) clearTimeout(persistTasksTimer);
+ persistTasksTimer = null;
+ persistTasks();
+ return;
+ }
+ if (persistTasksTimer) return;
+ // 轻微延迟把多次 updateTask 合并成一次 localStorage 写入
+ persistTasksTimer = setTimeout(() => {
+ persistTasksTimer = null;
+ persistTasks();
+ }, 400);
+ };
+
+ // ===== 任务卡“增量 DOM 更新”(关键:解决流式每个 chunk 全量重绘导致的卡顿) =====
+ let taskDomSyncQueued = false;
+ const taskDomSyncMap = new Map(); // Map
+
+ const syncTaskCardDom = (t) => {
+ if (!taskList || !t) return;
+ const id = parseInt(String(t.id || '0'), 10) || 0;
+ if (!id) return;
+ const card = taskList.querySelector(`.task-card[data-id="${id}"]`);
+ if (!card) return;
+
+ // 进度条(仅更新数值/宽度,不重建整卡)
+ const progress = Math.max(0, Math.min(100, parseInt(String(t.progress ?? (t.status === 'done' ? 100 : 0)), 10) || 0));
+ const bar = card.querySelector('[data-task-progress-bar="1"]');
+ if (bar) bar.style.width = `${progress}%`;
+ const shell = card.querySelector('[data-task-progress-shell="1"]');
+ if (shell) shell.setAttribute('aria-valuenow', String(progress));
+ const pText = card.querySelector('[data-task-progress-text="1"]');
+ if (pText) pText.textContent = `进度:${progress}%`;
+
+ // 任务消息(运行中/重试中会变化很频繁,改为只更新这一行)
+ const msgEl = card.querySelector('[data-task-msg="1"]');
+ if (msgEl) {
+ const msg = String(t.message || '');
+ if (msg) {
+ msgEl.textContent = msg;
+ msgEl.style.display = '';
+ msgEl.style.color = t.status === 'retrying' ? '#b45309' : '#f87171';
+ } else {
+ msgEl.textContent = '';
+ msgEl.style.display = 'none';
+ }
+ }
+ };
+
+ const scheduleTaskCardDomSync = (taskId, taskSnapshot) => {
+ if (!taskList) return;
+ const id = parseInt(String(taskId || '0'), 10) || 0;
+ if (!id) return;
+ taskDomSyncMap.set(id, taskSnapshot);
+ if (taskDomSyncQueued) return;
+ taskDomSyncQueued = true;
+ requestAnimationFrame(() => {
+ taskDomSyncQueued = false;
+ taskDomSyncMap.forEach((t) => {
+ try {
+ syncTaskCardDom(t);
+ } catch (_) {
+ /* ignore */
+ }
+ });
+ taskDomSyncMap.clear();
+ });
+ };
+
+ // 日志 Tab:仅当用户正在查看时才更新,并做 rAF 合并,避免 logFull 每条都重绘
+ let logPanelSyncQueued = false;
+ const scheduleLogPanelSync = () => {
+ if (logPanelSyncQueued) return;
+ logPanelSyncQueued = true;
+ requestAnimationFrame(() => {
+ logPanelSyncQueued = false;
+ try {
+ if (currentRightTab === 'log') renderLogPanel();
+ } catch (_) {
+ /* ignore */
+ }
+ });
+ };
+
+ const updateTask = (id, patch) => {
+ const idx = tasks.findIndex((t) => t && t.id === id);
+ if (idx < 0) return;
+ const base = tasks[idx];
+ const merged = { ...base, ...patch };
+ // 若后续补打的 message 表明角色卡成功,则校正状态
+ if (patch.message && /角色卡创建成功/.test(patch.message)) {
+ merged.status = 'done';
+ merged.type = merged.type || 'character';
+ }
+ // 合并日志:保留完整日志并截断
+ if (patch.logTail !== undefined) {
+ merged.logTail = patch.logTail;
+ }
+ if (patch.logFull !== undefined) {
+ merged.logFull = (patch.logFull || '').slice(-LOG_STORE_LIMIT);
+ }
+ if (patch.timedOut !== undefined) {
+ merged.timedOut = patch.timedOut;
+ }
+ tasks[idx] = merged;
+ const changed = merged;
+ if (patch.status === 'done' && changed && !changed.collapsed) {
+ if (!collapseTimers.has(id)) {
+ const timer = setTimeout(() => {
+ tasks = tasks.map((t) => (t.id === id ? { ...t, collapsed: true } : t));
+ collapseTimers.delete(id);
+ scheduleRender({ tasks: true, previews: false });
+ schedulePersistTasks();
+ }, 3000);
+ collapseTimers.set(id, timer);
+ }
+ }
+ // 任务列表基本每次都要更新(进度/状态/消息),但预览墙只在 url/meta/tag 等关键字段变化时更新
+ const affectsPreview =
+ patch.url !== undefined ||
+ patch.type !== undefined ||
+ patch.meta !== undefined ||
+ patch.wmStage !== undefined ||
+ patch.wmAttempt !== undefined ||
+ patch.storyboard !== undefined ||
+ patch.tag !== undefined;
+
+ // “全量重绘任务列表”很贵:流式输出时只做“增量 DOM 更新”,把全量 render 留给结构性变化
+ const patchKeys = patch && typeof patch === 'object' ? Object.keys(patch) : [];
+ const onlyLogPatch =
+ patchKeys.length > 0 && patchKeys.every((k) => k === 'logFull' || k === 'logTail');
+ const heavyKeys = new Set([
+ 'status',
+ 'url',
+ 'type',
+ 'meta',
+ 'tag',
+ 'storyboard',
+ 'collapsed',
+ 'retryMode',
+ 'retryCount',
+ 'timedOut',
+ 'wmStage',
+ 'wmAttempt',
+ 'wmCanCancel',
+ 'wmCancelling',
+ 'remoteTaskId'
+ ]);
+ let needFullTasksRender = patchKeys.some((k) => heavyKeys.has(k));
+ // 兜底:某些情况下会通过 message 修正 status(例如“角色卡创建成功”)
+ if ((merged && merged.status) !== (base && base.status)) needFullTasksRender = true;
+ if ((merged && !!merged.timedOut) !== (base && !!base.timedOut)) needFullTasksRender = true;
+
+ if (needFullTasksRender) {
+ scheduleRender({ tasks: true, previews: affectsPreview });
+ schedulePostTaskState({ immediate: true });
+ } else {
+ // 增量更新:只更新该任务卡的进度/消息(不卡 UI、不重绑事件)
+ const needDom = patch.progress !== undefined || patch.message !== undefined;
+ if (needDom) scheduleTaskCardDomSync(id, merged);
+ if (affectsPreview) scheduleRender({ tasks: false, previews: true });
+ // 日志 Tab:用户正在查看时才刷新(logFull 每条都更新会非常卡)
+ if (
+ currentRightTab === 'log' &&
+ (patch.logFull !== undefined || patch.logTail !== undefined || patch.message !== undefined)
+ ) {
+ scheduleLogPanelSync();
+ }
+ // 给管理台任务抽屉同步:logFull/logTail 不需要(抽屉不展示日志),避免无意义跨 iframe 重绘
+ if (!onlyLogPatch) schedulePostTaskState({ immediate: false });
+ }
+ // 同步内存日志缓存,便于复制与展示
+ if (patch.logFull !== undefined || patch.logTail !== undefined) {
+ const logText =
+ (patch.logFull || patch.logTail || taskLogBuffer[id] || '').slice(-LOG_STORE_LIMIT);
+ taskLogBuffer[id] = logText;
+ }
+ schedulePersistTasks();
+ };
+
+ const updateTaskBubble = () => {
+ const running = tasks.filter((t) => t.status === 'running' || t.status === 'retrying' || t.status === 'queue').length;
+ const total = tasks.length;
+ try {
+ if (window.parent && window.parent !== window) {
+ window.parent.postMessage({ type: 'task_count', running, total }, '*');
+ }
+ } catch (_) {}
+ };
+
+ // 任务列表状态(给管理台任务抽屉用):节流发送,避免流式每个 chunk 都触发父页面重渲染
+ let postTaskStateTimer = null;
+ const postTaskStateNow = () => {
+ try {
+ if (!(window.parent && window.parent !== window)) return;
+ const summary = tasks.map((t) => ({
+ id: t.id,
+ status: t.status,
+ prompt: t.promptSnippet,
+ url: t.url,
+ meta: t.meta,
+ message: t.message,
+ progress: t.progress ?? 0,
+ tag: t.tag || '',
+ storyboard: t.storyboard || null
+ }));
+ window.parent.postMessage({ type: 'task_state', tasks: summary }, '*');
+ } catch (_) {
+ /* ignore */
+ }
+ };
+ const schedulePostTaskState = (opts = { immediate: false }) => {
+ const immediate = !!(opts && opts.immediate);
+ if (immediate) {
+ if (postTaskStateTimer) clearTimeout(postTaskStateTimer);
+ postTaskStateTimer = null;
+ postTaskStateNow();
+ return;
+ }
+ if (postTaskStateTimer) return;
+ postTaskStateTimer = setTimeout(() => {
+ postTaskStateTimer = null;
+ postTaskStateNow();
+ }, 450);
+ };
+
+ // 右侧 tab 切换
+ let currentRightTab = localStorage.getItem(RIGHT_TAB_KEY) || 'tasks';
+ const unread = { tasks: false, preview: false, log: false };
+ let onlyRunning = false;
+ let densePreview = localStorage.getItem(PREVIEW_DENSE_KEY) === '1';
+ let statusFilter = '';
+ // 预览未读:用“已看过的任务 id”做集合判定,避免 URL 变化/重渲染导致红点反复出现
+ let previewSeenTaskIds = new Set();
+ let logVersion = 0;
+ let logSeenVersion = 0;
+ const previewKnown = new Set(); // 仅用于避免同一 URL 重复加卡
+ let previewsHydrated = false;
+ let previewCountLastText = '';
+ let previewCountFlashTimer = null;
+ let currentLogTaskId = null;
+ let taskLogBuffer = {};
+ const setRightTab = (tab) => {
+ currentRightTab = tab;
+ localStorage.setItem(RIGHT_TAB_KEY, tab);
+ rightTabButtons.forEach((btn) => btn.classList.toggle('active', btn.getAttribute('data-tab') === tab));
+ rightTabButtons.forEach((btn) => btn.classList.toggle('has-unread', unread[btn.getAttribute('data-tab')] && tab !== btn.getAttribute('data-tab')));
+ tabPanelTasks.classList.toggle('active', tab === 'tasks');
+ tabPanelPreview.classList.toggle('active', tab === 'preview');
+ tabPanelLog.classList.toggle('active', tab === 'log');
+ if (tab === 'tasks') unread.tasks = false;
+ if (tab === 'preview') markAllPreviewsSeen();
+ if (tab === 'log') {
+ logSeenVersion = logVersion;
+ renderTaskLogList();
+ renderTaskLogContent();
+ }
+ unread[tab] = false;
+ updateUnreadDots();
+ };
+
+ // 核心:执行一组任务(支持并发)
+ const runJobs = async (jobs, apiKey, baseUrl, concurrency = 1) => {
+ if (!jobs || !jobs.length) return;
+ const poolSize = Math.min(concurrency, jobs.length);
+ let cursor = 0;
+
+ const runJob = async (job) => {
+ const promptSend = job.promptSend ?? job.prompt ?? '';
+ const promptUser = job.promptUser ?? job.prompt ?? '';
+
+ const promptSnippet = promptUser.slice(0, 80) || (job.file ? job.file.name : '(空提示)');
+ const extra = { storyboard: job.storyboard || null, model: job.model };
+
+ // 任务热启动:先创建占位任务,避免并发时日志串号 & 增强“对象恒常性”
+ // 但“重试/继续”要求不改变任务卡位置:允许复用现有 taskId,原地变为“重试中/生成中”。
+ let taskId =
+ typeof job.taskId === 'number' ? job.taskId : parseInt(String(job.taskId || ''), 10) || null;
+ if (taskId && !tasks.find((t) => t && t.id === taskId)) {
+ taskId = null;
+ }
+
+ if (!taskId) {
+ taskId = addTask(promptSnippet, promptUser, promptSend, extra);
+ } else {
+ // 若同一任务正在跑(比如用户连续点“重试”),先中断旧的,再启动新的。
+ const prev = taskRetryControls.get(taskId);
+ if (prev) {
+ prev.cancelled = true;
+ try {
+ if (typeof prev.abortFetch === 'function') prev.abortFetch();
+ } catch (_) {}
+ }
+ taskLogBuffer[taskId] = '';
+ updateTask(taskId, {
+ status: 'queue',
+ progress: 0,
+ timedOut: false,
+ message: '准备中…',
+ model: job.model,
+ // 复用 taskId 时同步刷新媒体类型:避免上一轮是视频,本轮切到图片后预览仍按视频渲染
+ type: parseModelId(job.model).isImage ? 'image' : 'video',
+ promptSnippet,
+ promptUser,
+ promptSend,
+ url: null,
+ meta: null,
+ logTail: '',
+ logFull: '',
+ retryMode: 'manual',
+ retryCount: 0,
+ errorKind: '',
+ remoteTaskId: null,
+ wmStage: '',
+ wmAttempt: 0,
+ wmCanCancel: false,
+ wmCancelling: false
+ });
+ if (extra && extra.storyboard) {
+ updateTask(taskId, { tag: 'storyboard', storyboard: extra.storyboard });
+ }
+ }
+
+ // 占位态:让用户立刻看到“任务已入队”,避免误以为只生成了分镜 1。
+ updateTask(taskId, { status: 'queue', model: job.model, errorKind: '', progress: 0, timedOut: false, message: '准备中…' });
+
+ // 记录本次任务的输入素材(用于“空提示也能重试/继续”)。
+ // 注意:这里只保留在内存中,避免把大文件 dataURL 写进 localStorage(防卡顿/超额)。
+ try {
+ const tRef = tasks.find((x) => x && x.id === taskId);
+ if (tRef) {
+ if (job.file) {
+ tRef._inputFile = job.file;
+ tRef._inputFileName = job.file.name || '';
+ if (tRef._inputFileDataUrl) tRef._inputFileDataUrl = null;
+ } else if (job.fileDataUrl) {
+ tRef._inputFile = null;
+ tRef._inputFileName = '';
+ tRef._inputFileDataUrl = job.fileDataUrl;
+ }
+ tRef._inputHasFile = !!(job.file || job.fileDataUrl);
+ }
+ } catch (_) {
+ /* ignore */
+ }
+
+ const contentArr = [];
+ if (promptSend) contentArr.push({ type: 'text', text: promptSend });
+
+ // 读文件(可能比较慢)
+ try {
+ if (job.file) {
+ logTask(taskId, `读取文件: ${job.file.name}`);
+ const dataUrl = await fileToDataUrl(job.file);
+ if ((job.file.type || '').startsWith('video')) {
+ contentArr.push({ type: 'video_url', video_url: { url: dataUrl } });
+ } else {
+ contentArr.push({ type: 'image_url', image_url: { url: dataUrl } });
+ }
+ } else if (job.fileDataUrl) {
+ const url = job.fileDataUrl;
+ const isVideo = url.startsWith('data:video') || /\.(mp4|mov|m4v|webm)$/i.test(url);
+ if (isVideo) {
+ contentArr.push({ type: 'video_url', video_url: { url } });
+ } else {
+ contentArr.push({ type: 'image_url', image_url: { url } });
+ }
+ }
+ } catch (_) {
+ updateTask(taskId, { status: 'error', message: '读取文件失败(请重试或更换文件)', progress: 0 });
+ showToast('读取文件失败(请重试或更换文件)', 'error', { title: '文件读取失败', duration: 4200 });
+ return;
+ }
+
+ const body = {
+ model: job.model,
+ stream: true,
+ messages: [
+ {
+ role: 'user',
+ content: contentArr.length ? contentArr : promptSend
+ }
+ ]
+ };
+
+ // 手动“重试/继续”必须原地变为“重试中”标签(不再保留失败标签)
+ if (job.taskId) {
+ updateTask(taskId, { status: 'retrying', retryMode: 'manual', retryCount: 0, progress: 0, message: '' });
+ } else {
+ updateTask(taskId, { status: 'running', retryMode: '', retryCount: 0, progress: 0, message: '' });
+ }
+
+ const url = `${baseUrl}/v1/chat/completions`;
+ const isRetryable = (errMsg) =>
+ /timeout|timed out|HTTP\s*5\d\d|503|502|504|bad gateway|gateway time-out|ENETUNREACH|ECONNRESET|ECONNABORTED|ETIMEDOUT|Failed to connect|network|cloudflare|curl|connection closed|closed abruptly/i.test(
+ errMsg || ''
+ );
+
+ const retryCtl = { cancelled: false, abortFetch: null };
+ taskRetryControls.set(taskId, retryCtl);
+
+ try {
+ // 提交上游阶段:不轻易判失败(自动重试,3 次后提供"中断重试"按钮)
+ const MAX_RETRY = 9999;
+ for (let attempt = 1; attempt <= MAX_RETRY + 1; attempt++) {
+ let lastChunk = '';
+ let contentAccumulated = ''; // 累积所有 content 字段
+ let characterCreated = false;
+ let characterCardInfo = null;
+ let hadError = false;
+ let finished = false;
+ let logBufferAttempt = '';
+ let watermarkWaitSeen = false; // once seen, disable the 10-min hard timeout and rely on explicit cancel
+ let progressMarkerSeen = false; // once seen, do NOT auto-resubmit (avoid duplicates)
+ const controller = new AbortController();
+ retryCtl.abortFetch = () => controller.abort();
+ const HARD_TIMEOUT = 600000; // 10 分钟总超时
+ let hardTimer = null;
+ const clearTimers = () => {
+ if (hardTimer) clearTimeout(hardTimer);
+ };
+
+ try {
+ if (retryCtl.cancelled) {
+ updateTask(taskId, { status: 'error', message: '已中断自动重试(可点击“重试”再次发起)' });
+ return;
+ }
+ // attempt=1:正常生成(或手动重试的首次尝试)
+ // attempt>1:仅用于“提交上游失败”类可重试错误的自动重试
+ if (attempt > 1) {
+ updateTask(taskId, {
+ status: 'retrying',
+ retryMode: 'submit',
+ retryCount: attempt - 1,
+ timedOut: false,
+ progress: 0
+ });
+ } else if (job.taskId) {
+ updateTask(taskId, { status: 'retrying', retryMode: 'manual', retryCount: 0, timedOut: false, progress: 0 });
+ } else {
+ updateTask(taskId, { status: 'running', timedOut: false, progress: 0 });
+ }
+
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer ' + apiKey,
+ 'Content-Type': 'application/json',
+ Accept: 'text/event-stream'
+ },
+ body: JSON.stringify(body),
+ signal: controller.signal
+ });
+
+ if (!resp.ok || !resp.body) {
+ throw new Error('HTTP ' + resp.status);
+ }
+
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let mediaUrl = null;
+ // 默认按模型推断:避免 URL 无扩展名时误判(图片任务却用 video 预览)
+ let mediaType = parseModelId(job.model).isImage ? 'image' : 'video';
+ let mediaMeta = null;
+
+ hardTimer = setTimeout(() => controller.abort(), HARD_TIMEOUT);
+
+ logTask(taskId, '连接成功,开始接收流...');
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ const chunk = decoder.decode(value, { stream: true });
+ lastChunk = chunk || lastChunk;
+ chunk.split(/\n\n/).forEach((line) => {
+ if (!line.startsWith('data:')) return;
+ const data = line.replace(/^data:\s*/, '');
+ if (data === '[DONE]') {
+ logTask(taskId, '[DONE]');
+ finished = true;
+ return;
+ }
+ logTask(taskId, data);
+ logBufferAttempt = (logBufferAttempt + data + '\n').slice(-LOG_STORE_LIMIT);
+ try {
+ const obj = JSON.parse(data);
+ const choice = (obj.choices && obj.choices[0]) || {};
+ const delta = choice.delta || {};
+ if (obj.error) {
+ const pretty = humanizeUpstreamError(obj.error);
+ const errMsg = pretty.message || obj.error.message || obj.error.code || '生成失败';
+ // 仅“提交上游失败/网络瞬断(未进入进度阶段)”自动重试;避免已提交后重复下单
+ if (isRetryable(errMsg) && !progressMarkerSeen && !watermarkWaitSeen) {
+ const retryErr = new Error(errMsg);
+ retryErr.__submitRetryable = true;
+ throw retryErr;
+ }
+ // 内容审查命中:不要自动重试,直接给“可修改分镜提示词”的兜底入口
+ if (isContentPolicyViolation(errMsg)) {
+ hadError = true;
+ const isSb = !!(job.storyboard && job.storyboard.label);
+ const msg = isSb ? '内容审查未通过(可修改分镜提示词后重试)' : '内容审查未通过(请调整提示词后重试)';
+ updateTask(taskId, {
+ status: 'error',
+ errorKind: 'policy',
+ message: msg,
+ logTail: lastChunk,
+ logFull: logBufferAttempt,
+ progress: 0
+ });
+ showToast(msg, 'warn', { title: '内容审查未通过', duration: 5200 });
+ return;
+ }
+ hadError = true;
+ updateTask(taskId, { status: 'error', message: errMsg, logTail: lastChunk, logFull: logBufferAttempt });
+ showToast(errMsg || '生成失败', pretty.type === 'warn' ? 'warn' : 'error', {
+ title: pretty.title || '生成失败',
+ duration: 4200
+ });
+ return;
+ }
+ const rc = delta.reasoning_content || (choice.message && choice.message.content) || '';
+
+ // Watermark-free waiting (structured, from backend delta.wm)
+ if (delta && delta.wm && typeof delta.wm === 'object') {
+ const wm = delta.wm || {};
+ const stage = wm.stage ? String(wm.stage) : '';
+ const attempt =
+ typeof wm.attempt === 'number' ? wm.attempt : parseInt(String(wm.attempt || '0'), 10) || 0;
+ const canCancel = !!wm.can_cancel;
+ const remoteTaskId = wm.task_id ? String(wm.task_id) : '';
+ const patch = { wmStage: stage, wmAttempt: attempt, wmCanCancel: canCancel };
+ if (remoteTaskId) patch.remoteTaskId = remoteTaskId;
+ updateTask(taskId, patch);
+
+ // Once we enter watermark-free waiting, do not enforce the 10-min hard timeout.
+ if (!watermarkWaitSeen) {
+ watermarkWaitSeen = true;
+ if (hardTimer) {
+ clearTimeout(hardTimer);
+ hardTimer = null;
+ }
+ }
+ }
+
+ // 解析 delta.content 里嵌入的 JSON(character_card)
+ const rawContent =
+ delta.content ||
+ (choice.message && choice.message.content) ||
+ obj.content ||
+ '';
+ const finishReason = choice.finish_reason || choice.native_finish_reason || delta.finish_reason;
+ const deltaContent = typeof delta.content === 'string' ? delta.content : '';
+ const deltaReasoning = typeof delta.reasoning_content === 'string' ? delta.reasoning_content : '';
+
+ // 累积 content 字段
+ if (deltaContent) {
+ contentAccumulated += deltaContent;
+ }
+
+ // 内容审查:Sora 可能以 reasoning/content 形式返回(不一定走 obj.error)
+ const policyText = [deltaReasoning, deltaContent, rc, rawContent].filter(Boolean).join('\n');
+ if (!hadError && isContentPolicyViolation(policyText)) {
+ hadError = true;
+ const isSb = !!(job.storyboard && job.storyboard.label);
+ const msg = isSb ? '内容审查未通过(可修改分镜提示词后重试)' : '内容审查未通过(请调整提示词后重试)';
+ updateTask(taskId, {
+ status: 'error',
+ errorKind: 'policy',
+ message: msg,
+ logTail: lastChunk,
+ logFull: logBufferAttempt,
+ progress: 0
+ });
+ showToast(msg, 'warn', { title: '内容审查未通过', duration: 5200 });
+ return;
+ }
+ const characterFailHit =
+ /角色卡创建失败|Character creation failed/i.test(deltaContent) ||
+ /角色卡创建失败|Character creation failed/i.test(deltaReasoning) ||
+ /角色卡创建失败|Character creation failed/i.test(rawContent || '') ||
+ (/character_card/i.test(rawContent || '') && finishReason === 'STOP' && !characterCreated && !mediaUrl);
+ if (!hadError && characterFailHit) {
+ const msg =
+ (deltaContent || deltaReasoning || rawContent || '角色卡创建失败')
+ .replace(/^❌\s*/, '')
+ .trim();
+ hadError = true;
+ updateTask(taskId, {
+ status: 'error',
+ type: 'character',
+ message: msg,
+ logTail: lastChunk,
+ logFull: logBufferAttempt,
+ progress: 0
+ });
+ return;
+ }
+ let innerObj = null;
+ if (typeof rawContent === 'string' && rawContent.trim().startsWith('{')) {
+ try {
+ innerObj = JSON.parse(rawContent);
+ } catch (_) {
+ innerObj = null;
+ }
+ }
+
+ if (typeof rc === 'string' && /(blocked|guardrail|违规|不支持|限制)/i.test(rc)) {
+ hadError = true;
+ const pretty = humanizeUpstreamError(rc);
+ updateTask(taskId, {
+ status: 'error',
+ message: pretty.message || rc.trim(),
+ logTail: lastChunk,
+ logFull: logBufferAttempt
+ });
+ showToast(pretty.message || rc.trim(), pretty.type === 'warn' ? 'warn' : 'error', {
+ title: pretty.title || '生成失败',
+ duration: 4200
+ });
+ return;
+ }
+ // 角色卡事件:直接标记为角色卡成功
+ const cardPayload = obj.event === 'character_card' || obj.card ? obj : innerObj && innerObj.event === 'character_card' ? innerObj : null;
+ if (!cardPayload && typeof data === 'string' && data.includes('"character_card"')) {
+ try {
+ const temp = JSON.parse(data);
+ if (temp && (temp.event === 'character_card' || temp.card)) {
+ cardPayload = temp;
+ }
+ } catch (_) {}
+ }
+ if (cardPayload && (cardPayload.event === 'character_card' || cardPayload.card)) {
+ const card = cardPayload.card || {};
+ characterCreated = true;
+ characterCardInfo = card;
+ syncRoleCardToLibrary(card);
+ showToast(`角色卡创建成功:@${card.username || card.display_name || '角色'}`);
+ updateTask(taskId, {
+ status: 'done',
+ type: 'character',
+ message: `角色卡创建成功:@${card.username || '角色'}`,
+ meta: { display: card.display_name || card.username || '' },
+ logTail: lastChunk,
+ logFull: logBufferAttempt
+ });
+ return;
+ }
+ // 进度:结构化字段或 reasoning_content 中的百分比
+ const currentProgress =
+ tasks.find((t) => t.id === taskId && !isNaN(parseFloat(t.progress)))?.progress ?? 0;
+ let progressVal = null;
+ const pctMatch = data.match(/(\d{1,3})%/);
+ if (pctMatch) progressMarkerSeen = true;
+ if (obj.progress !== undefined && !isNaN(parseFloat(obj.progress))) {
+ progressVal = parseFloat(obj.progress);
+ progressMarkerSeen = true;
+ }
+ if (obj.delta && typeof obj.delta.reasoning_content === 'string') {
+ const m = obj.delta.reasoning_content.match(/(\d{1,3})%/);
+ if (m) progressVal = Math.max(progressVal ?? 0, parseFloat(m[1]));
+ if (m) progressMarkerSeen = true;
+ }
+ if (!progressVal && pctMatch) {
+ progressVal = Math.min(100, parseFloat(pctMatch[1]));
+ }
+ if (!isNaN(progressVal)) {
+ const merged = Math.max(currentProgress, progressVal);
+ updateTask(taskId, { progress: merged });
+ }
+
+ // 结构化字段优先
+ const output0 = (obj.output && obj.output[0]) || null;
+ const deltaOut0 = (delta.output && delta.output[0]) || null;
+ // 上游有时会给出明确 type(image/video),即使 URL 没有扩展名也应信任它。
+ const declaredTypeRaw = (output0 && output0.type) || (deltaOut0 && deltaOut0.type) || obj.type || '';
+ const declaredType = String(declaredTypeRaw || '').toLowerCase();
+ const declaredHint = declaredType === 'image' || declaredType === 'video' ? declaredType : '';
+ const typeHintFromFields =
+ declaredHint ||
+ (obj.image_url && obj.image_url.url ? 'image' : '') ||
+ (obj.video_url && obj.video_url.url ? 'video' : '') ||
+ (output0 && output0.image_url ? 'image' : '') ||
+ (output0 && output0.video_url ? 'video' : '') ||
+ (deltaOut0 && deltaOut0.image_url ? 'image' : '') ||
+ (deltaOut0 && deltaOut0.video_url ? 'video' : '') ||
+ '';
+ const candidates = [
+ obj.url,
+ obj.video_url && obj.video_url.url,
+ obj.image_url && obj.image_url.url,
+ output0 && (output0.url || output0.video_url || output0.image_url),
+ deltaOut0 && (deltaOut0.url || deltaOut0.video_url || deltaOut0.image_url)
+ ].filter(Boolean);
+
+ // Capture remote task_id from delta.output if present (used by watermark cancel button)
+ if (delta.output && delta.output[0] && delta.output[0].task_id) {
+ updateTask(taskId, { remoteTaskId: String(delta.output[0].task_id) });
+ progressMarkerSeen = true;
+ }
+
+ let extractedUrl = candidates[0];
+
+ // content/markdown 中的
@@ -379,6 +380,11 @@
+
+
+
+
+
+
+
+
+
导入进度
+
+
+
+
+
+
+
+
+