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 生成面板 + + + + +
+
+

Sora2 生成面板

+ @kongt +
+

左侧拖拽/挂载角色卡,中间编写提示词并上传素材,右侧查看任务进度与结果预览。

+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ 模式 +
+ + + + + + + + + + + + + + +
+
+
+ +
+ + + +
+
+ +
+
+
+ +
+
快捷标签
+
+ + + + + +
+
批量工具 / 模板
+
+
+ 默认份数 + 导入/导出模板、套用到全部 +
+
+ 生成份数 + + + + +
+ +
+ + + + +
+
+ +
+
+
+
上传素材
+
单次:只使用 1 个文件;同提示批量:多文件共享同一提示词(每个文件可生成多份)
+
+
+ + +
+
+
+
+ +
+
+
+
+
+ 拖拽文件到这里,或点击选择(支持多文件) +
+
+ + + +
+ 视频建议 3~10 秒;批量时可多选文件;同提示多文件:共享同一提示;多提示批量:文本每行一条。 +
+
+ +
+
+
+
+ 任务进度 / 结果预览 + 0 个任务 +
+
+
+
+ + + +
+
+ + + + +
+
+
+
+
+
+
+
+ 过滤 + + + + + + +
+
+
+
+
+
任务日志
+
+ +
+
+
+
+
+
+ + + +
+
+
暂无日志
+
+
+
+
+
+
+
+
+
+ + +
+ + + + 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' + ? `preview` + : ``; + }; + + 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 ? `` : ''} + `; + + 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 = `preview`; + } 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 @@ + + + + +