Spaces:
Paused
Paused
Upload 7 files
Browse files- browser_utils/__init__.py +47 -0
- browser_utils/initialization.py +589 -0
- browser_utils/model_management.py +392 -0
- browser_utils/more_modles.js +305 -0
- browser_utils/operations.py +773 -0
- browser_utils/page_controller.py +638 -0
- browser_utils/script_manager.py +183 -0
browser_utils/__init__.py
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/__init__.py ---
|
| 2 |
+
# 浏览器操作工具模块
|
| 3 |
+
from .initialization import _initialize_page_logic, _close_page_logic, signal_camoufox_shutdown
|
| 4 |
+
from .operations import (
|
| 5 |
+
_handle_model_list_response,
|
| 6 |
+
detect_and_extract_page_error,
|
| 7 |
+
save_error_snapshot,
|
| 8 |
+
get_response_via_edit_button,
|
| 9 |
+
get_response_via_copy_button,
|
| 10 |
+
_wait_for_response_completion,
|
| 11 |
+
_get_final_response_content,
|
| 12 |
+
get_raw_text_content
|
| 13 |
+
)
|
| 14 |
+
from .model_management import (
|
| 15 |
+
switch_ai_studio_model,
|
| 16 |
+
load_excluded_models,
|
| 17 |
+
_handle_initial_model_state_and_storage,
|
| 18 |
+
_set_model_from_page_display
|
| 19 |
+
)
|
| 20 |
+
from .script_manager import ScriptManager, script_manager
|
| 21 |
+
|
| 22 |
+
__all__ = [
|
| 23 |
+
# 初始化相关
|
| 24 |
+
'_initialize_page_logic',
|
| 25 |
+
'_close_page_logic',
|
| 26 |
+
'signal_camoufox_shutdown',
|
| 27 |
+
|
| 28 |
+
# 页面操作相关
|
| 29 |
+
'_handle_model_list_response',
|
| 30 |
+
'detect_and_extract_page_error',
|
| 31 |
+
'save_error_snapshot',
|
| 32 |
+
'get_response_via_edit_button',
|
| 33 |
+
'get_response_via_copy_button',
|
| 34 |
+
'_wait_for_response_completion',
|
| 35 |
+
'_get_final_response_content',
|
| 36 |
+
'get_raw_text_content',
|
| 37 |
+
|
| 38 |
+
# 模型管理相关
|
| 39 |
+
'switch_ai_studio_model',
|
| 40 |
+
'load_excluded_models',
|
| 41 |
+
'_handle_initial_model_state_and_storage',
|
| 42 |
+
'_set_model_from_page_display',
|
| 43 |
+
|
| 44 |
+
# 脚本管理相关
|
| 45 |
+
'ScriptManager',
|
| 46 |
+
'script_manager'
|
| 47 |
+
]
|
browser_utils/initialization.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/initialization.py ---
|
| 2 |
+
# 浏览器初始化相关功能模块
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Optional, Any, Dict, Tuple
|
| 10 |
+
|
| 11 |
+
from playwright.async_api import Page as AsyncPage, Browser as AsyncBrowser, BrowserContext as AsyncBrowserContext, Error as PlaywrightAsyncError, expect as expect_async
|
| 12 |
+
|
| 13 |
+
# 导入配置和模型
|
| 14 |
+
from config import *
|
| 15 |
+
from models import ClientDisconnectedError
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def _setup_network_interception_and_scripts(context: AsyncBrowserContext):
|
| 21 |
+
"""设置网络拦截和脚本注入"""
|
| 22 |
+
try:
|
| 23 |
+
from config.settings import ENABLE_SCRIPT_INJECTION
|
| 24 |
+
|
| 25 |
+
if not ENABLE_SCRIPT_INJECTION:
|
| 26 |
+
logger.info("脚本注入功能已禁用")
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
# 设置网络拦截
|
| 30 |
+
await _setup_model_list_interception(context)
|
| 31 |
+
|
| 32 |
+
# 可选:仍然注入脚本作为备用方案
|
| 33 |
+
await _add_init_scripts_to_context(context)
|
| 34 |
+
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"设置网络拦截和脚本注入时发生错误: {e}")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def _setup_model_list_interception(context: AsyncBrowserContext):
|
| 40 |
+
"""设置模型列表网络拦截"""
|
| 41 |
+
try:
|
| 42 |
+
async def handle_model_list_route(route):
|
| 43 |
+
"""处理模型列表请求的路由"""
|
| 44 |
+
request = route.request
|
| 45 |
+
|
| 46 |
+
# 检查是否是模型列表请求
|
| 47 |
+
if 'alkalimakersuite' in request.url and 'ListModels' in request.url:
|
| 48 |
+
logger.info(f"🔍 拦截到模型列表请求: {request.url}")
|
| 49 |
+
|
| 50 |
+
# 继续原始请求
|
| 51 |
+
response = await route.fetch()
|
| 52 |
+
|
| 53 |
+
# 获取原始响应
|
| 54 |
+
original_body = await response.body()
|
| 55 |
+
|
| 56 |
+
# 修改响应
|
| 57 |
+
modified_body = await _modify_model_list_response(original_body, request.url)
|
| 58 |
+
|
| 59 |
+
# 返回修改后的响应
|
| 60 |
+
await route.fulfill(
|
| 61 |
+
response=response,
|
| 62 |
+
body=modified_body
|
| 63 |
+
)
|
| 64 |
+
else:
|
| 65 |
+
# 对于其他请求,直接继续
|
| 66 |
+
await route.continue_()
|
| 67 |
+
|
| 68 |
+
# 注册路由拦截器
|
| 69 |
+
await context.route("**/*", handle_model_list_route)
|
| 70 |
+
logger.info("✅ 已设置模型列表网络拦截")
|
| 71 |
+
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.error(f"设置模型列表网络拦截时发生错误: {e}")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def _modify_model_list_response(original_body: bytes, url: str) -> bytes:
|
| 77 |
+
"""修改模型列表响应"""
|
| 78 |
+
try:
|
| 79 |
+
# 解码响应体
|
| 80 |
+
original_text = original_body.decode('utf-8')
|
| 81 |
+
|
| 82 |
+
# 处理反劫持前缀
|
| 83 |
+
ANTI_HIJACK_PREFIX = ")]}'\n"
|
| 84 |
+
has_prefix = False
|
| 85 |
+
if original_text.startswith(ANTI_HIJACK_PREFIX):
|
| 86 |
+
original_text = original_text[len(ANTI_HIJACK_PREFIX):]
|
| 87 |
+
has_prefix = True
|
| 88 |
+
|
| 89 |
+
# 解析JSON
|
| 90 |
+
import json
|
| 91 |
+
json_data = json.loads(original_text)
|
| 92 |
+
|
| 93 |
+
# 注入模型
|
| 94 |
+
modified_data = await _inject_models_to_response(json_data, url)
|
| 95 |
+
|
| 96 |
+
# 序列化回JSON
|
| 97 |
+
modified_text = json.dumps(modified_data, separators=(',', ':'))
|
| 98 |
+
|
| 99 |
+
# 重新添加前缀
|
| 100 |
+
if has_prefix:
|
| 101 |
+
modified_text = ANTI_HIJACK_PREFIX + modified_text
|
| 102 |
+
|
| 103 |
+
logger.info("✅ 成功修改模型列表响应")
|
| 104 |
+
return modified_text.encode('utf-8')
|
| 105 |
+
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"修改模型列表响应时发生错误: {e}")
|
| 108 |
+
return original_body
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
async def _inject_models_to_response(json_data: dict, url: str) -> dict:
|
| 112 |
+
"""向响应中注入模型"""
|
| 113 |
+
try:
|
| 114 |
+
from .operations import _get_injected_models
|
| 115 |
+
|
| 116 |
+
# 获取要注入的模型
|
| 117 |
+
injected_models = _get_injected_models()
|
| 118 |
+
if not injected_models:
|
| 119 |
+
logger.info("没有要注入的模型")
|
| 120 |
+
return json_data
|
| 121 |
+
|
| 122 |
+
# 查找模型数组
|
| 123 |
+
models_array = _find_model_list_array(json_data)
|
| 124 |
+
if not models_array:
|
| 125 |
+
logger.warning("未找到模型数组结构")
|
| 126 |
+
return json_data
|
| 127 |
+
|
| 128 |
+
# 找到模板模型
|
| 129 |
+
template_model = _find_template_model(models_array)
|
| 130 |
+
if not template_model:
|
| 131 |
+
logger.warning("未找到模板模型")
|
| 132 |
+
return json_data
|
| 133 |
+
|
| 134 |
+
# 注入模型
|
| 135 |
+
for model in reversed(injected_models): # 反向以保持顺序
|
| 136 |
+
model_name = model['raw_model_path']
|
| 137 |
+
|
| 138 |
+
# 检查模型是否已存在
|
| 139 |
+
if not any(m[0] == model_name for m in models_array if isinstance(m, list) and len(m) > 0):
|
| 140 |
+
# 创建新模型条目
|
| 141 |
+
new_model = json.loads(json.dumps(template_model)) # 深拷贝
|
| 142 |
+
new_model[0] = model_name # name
|
| 143 |
+
new_model[3] = model['display_name'] # display name
|
| 144 |
+
new_model[4] = model['description'] # description
|
| 145 |
+
|
| 146 |
+
# 添加到开头
|
| 147 |
+
models_array.insert(0, new_model)
|
| 148 |
+
logger.info(f"✅ 注入模型: {model['display_name']}")
|
| 149 |
+
|
| 150 |
+
return json_data
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.error(f"注入模型到响应时发生错误: {e}")
|
| 154 |
+
return json_data
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _find_model_list_array(obj):
|
| 158 |
+
"""递归查找模型列表数组"""
|
| 159 |
+
if not obj:
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
# 检查是否是模型数组
|
| 163 |
+
if isinstance(obj, list) and len(obj) > 0:
|
| 164 |
+
if all(isinstance(item, list) and len(item) > 0 and
|
| 165 |
+
isinstance(item[0], str) and item[0].startswith('models/')
|
| 166 |
+
for item in obj):
|
| 167 |
+
return obj
|
| 168 |
+
|
| 169 |
+
# 递归搜索
|
| 170 |
+
if isinstance(obj, dict):
|
| 171 |
+
for value in obj.values():
|
| 172 |
+
result = _find_model_list_array(value)
|
| 173 |
+
if result:
|
| 174 |
+
return result
|
| 175 |
+
elif isinstance(obj, list):
|
| 176 |
+
for item in obj:
|
| 177 |
+
result = _find_model_list_array(item)
|
| 178 |
+
if result:
|
| 179 |
+
return result
|
| 180 |
+
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _find_template_model(models_array):
|
| 185 |
+
"""查找模板模型"""
|
| 186 |
+
if not models_array:
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
# 寻找包含 'flash' 或 'pro' 的模型作为模板
|
| 190 |
+
for model in models_array:
|
| 191 |
+
if isinstance(model, list) and len(model) > 7:
|
| 192 |
+
model_name = model[0] if len(model) > 0 else ""
|
| 193 |
+
if 'flash' in model_name.lower() or 'pro' in model_name.lower():
|
| 194 |
+
return model
|
| 195 |
+
|
| 196 |
+
# 如果没找到,返回第一个有效模型
|
| 197 |
+
for model in models_array:
|
| 198 |
+
if isinstance(model, list) and len(model) > 7:
|
| 199 |
+
return model
|
| 200 |
+
|
| 201 |
+
return None
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
async def _add_init_scripts_to_context(context: AsyncBrowserContext):
|
| 205 |
+
"""在浏览器上下文中添加初始化脚本(备用方案)"""
|
| 206 |
+
try:
|
| 207 |
+
from config.settings import USERSCRIPT_PATH
|
| 208 |
+
|
| 209 |
+
# 检查脚本文件是否存在
|
| 210 |
+
if not os.path.exists(USERSCRIPT_PATH):
|
| 211 |
+
logger.info(f"脚本文件不存在,跳过脚本注入: {USERSCRIPT_PATH}")
|
| 212 |
+
return
|
| 213 |
+
|
| 214 |
+
# 读取脚本内容
|
| 215 |
+
with open(USERSCRIPT_PATH, 'r', encoding='utf-8') as f:
|
| 216 |
+
script_content = f.read()
|
| 217 |
+
|
| 218 |
+
# 清理UserScript头部
|
| 219 |
+
cleaned_script = _clean_userscript_headers(script_content)
|
| 220 |
+
|
| 221 |
+
# 添加到上下文的初始化脚本
|
| 222 |
+
await context.add_init_script(cleaned_script)
|
| 223 |
+
logger.info(f"✅ 已将脚本添加到浏览器上下文初始化脚本: {os.path.basename(USERSCRIPT_PATH)}")
|
| 224 |
+
|
| 225 |
+
except Exception as e:
|
| 226 |
+
logger.error(f"添加初始化脚本到上下文时发生错误: {e}")
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _clean_userscript_headers(script_content: str) -> str:
|
| 230 |
+
"""清理UserScript头部信息"""
|
| 231 |
+
lines = script_content.split('\n')
|
| 232 |
+
cleaned_lines = []
|
| 233 |
+
in_userscript_block = False
|
| 234 |
+
|
| 235 |
+
for line in lines:
|
| 236 |
+
if line.strip().startswith('// ==UserScript=='):
|
| 237 |
+
in_userscript_block = True
|
| 238 |
+
continue
|
| 239 |
+
elif line.strip().startswith('// ==/UserScript=='):
|
| 240 |
+
in_userscript_block = False
|
| 241 |
+
continue
|
| 242 |
+
elif in_userscript_block:
|
| 243 |
+
continue
|
| 244 |
+
else:
|
| 245 |
+
cleaned_lines.append(line)
|
| 246 |
+
|
| 247 |
+
return '\n'.join(cleaned_lines)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
async def _initialize_page_logic(browser: AsyncBrowser):
|
| 251 |
+
"""初始化页面逻辑,连接到现有浏览器"""
|
| 252 |
+
logger.info("--- 初始化页面逻辑 (连接到现有浏览器) ---")
|
| 253 |
+
temp_context: Optional[AsyncBrowserContext] = None
|
| 254 |
+
storage_state_path_to_use: Optional[str] = None
|
| 255 |
+
launch_mode = os.environ.get('LAUNCH_MODE', 'debug')
|
| 256 |
+
logger.info(f" 检测到启动模式: {launch_mode}")
|
| 257 |
+
loop = asyncio.get_running_loop()
|
| 258 |
+
|
| 259 |
+
if launch_mode == 'headless' or launch_mode == 'virtual_headless':
|
| 260 |
+
auth_filename = os.environ.get('ACTIVE_AUTH_JSON_PATH')
|
| 261 |
+
if auth_filename:
|
| 262 |
+
constructed_path = auth_filename
|
| 263 |
+
if os.path.exists(constructed_path):
|
| 264 |
+
storage_state_path_to_use = constructed_path
|
| 265 |
+
logger.info(f" 无头模式将使用的认证文件: {constructed_path}")
|
| 266 |
+
else:
|
| 267 |
+
logger.error(f"{launch_mode} 模式认证文件无效或不存在: '{constructed_path}'")
|
| 268 |
+
raise RuntimeError(f"{launch_mode} 模式认证文件无效: '{constructed_path}'")
|
| 269 |
+
else:
|
| 270 |
+
logger.error(f"{launch_mode} 模式需要 ACTIVE_AUTH_JSON_PATH 环境变量,但未设置或为空。")
|
| 271 |
+
raise RuntimeError(f"{launch_mode} 模式需要 ACTIVE_AUTH_JSON_PATH。")
|
| 272 |
+
elif launch_mode == 'debug':
|
| 273 |
+
logger.info(f" 调试模式: 尝试从环境变量 ACTIVE_AUTH_JSON_PATH 加载认证文件...")
|
| 274 |
+
auth_filepath_from_env = os.environ.get('ACTIVE_AUTH_JSON_PATH')
|
| 275 |
+
if auth_filepath_from_env and os.path.exists(auth_filepath_from_env):
|
| 276 |
+
storage_state_path_to_use = auth_filepath_from_env
|
| 277 |
+
logger.info(f" 调试模式将使用的认证文件 (来自环境变量): {storage_state_path_to_use}")
|
| 278 |
+
elif auth_filepath_from_env:
|
| 279 |
+
logger.warning(f" 调试模式下环境变量 ACTIVE_AUTH_JSON_PATH 指向的文件不存在: '{auth_filepath_from_env}'。不加载认证文件。")
|
| 280 |
+
else:
|
| 281 |
+
logger.info(" 调试模式下未通过环境变量提供认证文件。将使用浏览器当前状态。")
|
| 282 |
+
elif launch_mode == "direct_debug_no_browser":
|
| 283 |
+
logger.info(" direct_debug_no_browser 模式:不加载 storage_state,不进行浏览器操作。")
|
| 284 |
+
else:
|
| 285 |
+
logger.warning(f" ⚠️ 警告: 未知的启动模式 '{launch_mode}'。不加载 storage_state。")
|
| 286 |
+
|
| 287 |
+
try:
|
| 288 |
+
logger.info("创建新的浏览器上下文...")
|
| 289 |
+
context_options: Dict[str, Any] = {'viewport': {'width': 460, 'height': 800}}
|
| 290 |
+
if storage_state_path_to_use:
|
| 291 |
+
context_options['storage_state'] = storage_state_path_to_use
|
| 292 |
+
logger.info(f" (使用 storage_state='{os.path.basename(storage_state_path_to_use)}')")
|
| 293 |
+
else:
|
| 294 |
+
logger.info(" (不使用 storage_state)")
|
| 295 |
+
|
| 296 |
+
# 代理设置需要从server模块中获取
|
| 297 |
+
import server
|
| 298 |
+
if server.PLAYWRIGHT_PROXY_SETTINGS:
|
| 299 |
+
context_options['proxy'] = server.PLAYWRIGHT_PROXY_SETTINGS
|
| 300 |
+
logger.info(f" (浏览器上下文将使用代理: {server.PLAYWRIGHT_PROXY_SETTINGS['server']})")
|
| 301 |
+
else:
|
| 302 |
+
logger.info(" (浏览器上下文不使用显式代理配置)")
|
| 303 |
+
|
| 304 |
+
context_options['ignore_https_errors'] = True
|
| 305 |
+
logger.info(" (浏览器上下文将忽略 HTTPS 错误)")
|
| 306 |
+
|
| 307 |
+
temp_context = await browser.new_context(**context_options)
|
| 308 |
+
|
| 309 |
+
# 设置网络拦截和脚本注入
|
| 310 |
+
await _setup_network_interception_and_scripts(temp_context)
|
| 311 |
+
|
| 312 |
+
found_page: Optional[AsyncPage] = None
|
| 313 |
+
pages = temp_context.pages
|
| 314 |
+
target_url_base = f"https://{AI_STUDIO_URL_PATTERN}"
|
| 315 |
+
target_full_url = f"{target_url_base}prompts/new_chat"
|
| 316 |
+
login_url_pattern = 'accounts.google.com'
|
| 317 |
+
current_url = ""
|
| 318 |
+
|
| 319 |
+
# 导入_handle_model_list_response - 需要延迟导入避免循环引用
|
| 320 |
+
from .operations import _handle_model_list_response
|
| 321 |
+
|
| 322 |
+
for p_iter in pages:
|
| 323 |
+
try:
|
| 324 |
+
page_url_to_check = p_iter.url
|
| 325 |
+
if not p_iter.is_closed() and target_url_base in page_url_to_check and "/prompts/" in page_url_to_check:
|
| 326 |
+
found_page = p_iter
|
| 327 |
+
current_url = page_url_to_check
|
| 328 |
+
logger.info(f" 找到已打开的 AI Studio 页面: {current_url}")
|
| 329 |
+
if found_page:
|
| 330 |
+
logger.info(f" 为已存在的页面 {found_page.url} 添加模型列表响应监听器。")
|
| 331 |
+
found_page.on("response", _handle_model_list_response)
|
| 332 |
+
break
|
| 333 |
+
except PlaywrightAsyncError as pw_err_url:
|
| 334 |
+
logger.warning(f" 检查页面 URL 时出现 Playwright 错误: {pw_err_url}")
|
| 335 |
+
except AttributeError as attr_err_url:
|
| 336 |
+
logger.warning(f" 检查页面 URL 时出现属性错误: {attr_err_url}")
|
| 337 |
+
except Exception as e_url_check:
|
| 338 |
+
logger.warning(f" 检查页面 URL 时出现其他未预期错误: {e_url_check} (类型: {type(e_url_check).__name__})")
|
| 339 |
+
|
| 340 |
+
if not found_page:
|
| 341 |
+
logger.info(f"-> 未找到合适的现有页面,正在打开新页面并导航到 {target_full_url}...")
|
| 342 |
+
found_page = await temp_context.new_page()
|
| 343 |
+
if found_page:
|
| 344 |
+
logger.info(f" 为新创建的页面添加模型列表响应监听器 (导航前)。")
|
| 345 |
+
found_page.on("response", _handle_model_list_response)
|
| 346 |
+
try:
|
| 347 |
+
await found_page.goto(target_full_url, wait_until="domcontentloaded", timeout=90000)
|
| 348 |
+
current_url = found_page.url
|
| 349 |
+
logger.info(f"-> 新页面导航尝试完成。当前 URL: {current_url}")
|
| 350 |
+
except Exception as new_page_nav_err:
|
| 351 |
+
# 导入save_error_snapshot函数
|
| 352 |
+
from .operations import save_error_snapshot
|
| 353 |
+
await save_error_snapshot("init_new_page_nav_fail")
|
| 354 |
+
error_str = str(new_page_nav_err)
|
| 355 |
+
if "NS_ERROR_NET_INTERRUPT" in error_str:
|
| 356 |
+
logger.error("\n" + "="*30 + " 网络导航错误提示 " + "="*30)
|
| 357 |
+
logger.error(f"❌ 导航到 '{target_full_url}' 失败,出现网络中断错误 (NS_ERROR_NET_INTERRUPT)。")
|
| 358 |
+
logger.error(" 这通常表示浏览器在尝试加载页面时连接被意外断开。")
|
| 359 |
+
logger.error(" 可能的原因及排查建议:")
|
| 360 |
+
logger.error(" 1. 网络连接: 请检查你的本地网络连接是否稳定,并尝试在普通浏览器中访问目标网址。")
|
| 361 |
+
logger.error(" 2. AI Studio 服务: 确认 aistudio.google.com 服务本身是否可用。")
|
| 362 |
+
logger.error(" 3. 防火墙/代理/VPN: 检查本地防火墙、杀毒软件、代理或 VPN 设置。")
|
| 363 |
+
logger.error(" 4. Camoufox 服务: 确认 launch_camoufox.py 脚本是否正常运行。")
|
| 364 |
+
logger.error(" 5. 系统资源问题: 确保系统有足够的内存和 CPU 资源。")
|
| 365 |
+
logger.error("="*74 + "\n")
|
| 366 |
+
raise RuntimeError(f"导航新页面失败: {new_page_nav_err}") from new_page_nav_err
|
| 367 |
+
|
| 368 |
+
if login_url_pattern in current_url:
|
| 369 |
+
if launch_mode == 'headless':
|
| 370 |
+
logger.error("无头模式下检测到重定向至登录页面,认证可能已失效。请更新认证文件。")
|
| 371 |
+
raise RuntimeError("无头模式认证失败,需要更新认证文件。")
|
| 372 |
+
else:
|
| 373 |
+
print(f"\n{'='*20} 需要操作 {'='*20}", flush=True)
|
| 374 |
+
login_prompt = " 检测到可能需要登录。如果浏览器显示登录页面,请在浏览器窗口中完成 Google 登录,然后在此处按 Enter 键继续..."
|
| 375 |
+
print(USER_INPUT_START_MARKER_SERVER, flush=True)
|
| 376 |
+
await loop.run_in_executor(None, input, login_prompt)
|
| 377 |
+
print(USER_INPUT_END_MARKER_SERVER, flush=True)
|
| 378 |
+
logger.info(" 用户已操作,正在检查登录状态...")
|
| 379 |
+
try:
|
| 380 |
+
await found_page.wait_for_url(f"**/{AI_STUDIO_URL_PATTERN}**", timeout=180000)
|
| 381 |
+
current_url = found_page.url
|
| 382 |
+
if login_url_pattern in current_url:
|
| 383 |
+
logger.error("手动登录尝试后,页面似乎仍停留在登录页面。")
|
| 384 |
+
raise RuntimeError("手动登录尝试后仍在登录页面。")
|
| 385 |
+
logger.info(" ✅ 登录成功!请不要操作浏览器窗口,等待后续提示。")
|
| 386 |
+
|
| 387 |
+
# 等待模型列表响应,确认登录成功
|
| 388 |
+
await _wait_for_model_list_and_handle_auth_save(temp_context, launch_mode, loop)
|
| 389 |
+
except Exception as wait_login_err:
|
| 390 |
+
from .operations import save_error_snapshot
|
| 391 |
+
await save_error_snapshot("init_login_wait_fail")
|
| 392 |
+
logger.error(f"登录提示后未能检测到 AI Studio URL 或保存状态时出错: {wait_login_err}", exc_info=True)
|
| 393 |
+
raise RuntimeError(f"登录提示后未能检测到 AI Studio URL: {wait_login_err}") from wait_login_err
|
| 394 |
+
elif target_url_base not in current_url or "/prompts/" not in current_url:
|
| 395 |
+
from .operations import save_error_snapshot
|
| 396 |
+
await save_error_snapshot("init_unexpected_page")
|
| 397 |
+
logger.error(f"初始导航后页面 URL 意外: {current_url}。期望包含 '{target_url_base}' 和 '/prompts/'。")
|
| 398 |
+
raise RuntimeError(f"初始导航后出现意外页面: {current_url}。")
|
| 399 |
+
|
| 400 |
+
logger.info(f"-> 确认当前位于 AI Studio 对话页面: {current_url}")
|
| 401 |
+
await found_page.bring_to_front()
|
| 402 |
+
|
| 403 |
+
try:
|
| 404 |
+
input_wrapper_locator = found_page.locator('ms-prompt-input-wrapper')
|
| 405 |
+
await expect_async(input_wrapper_locator).to_be_visible(timeout=35000)
|
| 406 |
+
await expect_async(found_page.locator(INPUT_SELECTOR)).to_be_visible(timeout=10000)
|
| 407 |
+
logger.info("-> ✅ 核心输入区域可见。")
|
| 408 |
+
|
| 409 |
+
model_name_locator = found_page.locator('mat-select[data-test-ms-model-selector] div.model-option-content span.gmat-body-medium')
|
| 410 |
+
try:
|
| 411 |
+
model_name_on_page = await model_name_locator.first.inner_text(timeout=5000)
|
| 412 |
+
logger.info(f"-> 🤖 页面检测到的当前模型: {model_name_on_page}")
|
| 413 |
+
except PlaywrightAsyncError as e:
|
| 414 |
+
logger.error(f"获取模型名称时出错 (model_name_locator): {e}")
|
| 415 |
+
raise
|
| 416 |
+
|
| 417 |
+
result_page_instance = found_page
|
| 418 |
+
result_page_ready = True
|
| 419 |
+
|
| 420 |
+
# 脚本注入已在上下文创建时完成,无需在此处重复注入
|
| 421 |
+
|
| 422 |
+
logger.info(f"✅ 页面逻辑初始化成功。")
|
| 423 |
+
return result_page_instance, result_page_ready
|
| 424 |
+
except Exception as input_visible_err:
|
| 425 |
+
from .operations import save_error_snapshot
|
| 426 |
+
await save_error_snapshot("init_fail_input_timeout")
|
| 427 |
+
logger.error(f"页面初始化失败:核心输入区域未在预期时间内变为可见。最后的 URL 是 {found_page.url}", exc_info=True)
|
| 428 |
+
raise RuntimeError(f"页面初始化失败��核心输入区域未在预期时间内变为可见。最后的 URL 是 {found_page.url}") from input_visible_err
|
| 429 |
+
except Exception as e_init_page:
|
| 430 |
+
logger.critical(f"❌ 页面逻辑初始化期间发生严重意外错误: {e_init_page}", exc_info=True)
|
| 431 |
+
if temp_context:
|
| 432 |
+
try:
|
| 433 |
+
logger.info(f" 尝试关闭临时的浏览器上下文 due to initialization error.")
|
| 434 |
+
await temp_context.close()
|
| 435 |
+
logger.info(" ✅ 临时浏览器上下文已关闭。")
|
| 436 |
+
except Exception as close_err:
|
| 437 |
+
logger.warning(f" ⚠️ 关闭临时浏览器上下文时出错: {close_err}")
|
| 438 |
+
from .operations import save_error_snapshot
|
| 439 |
+
await save_error_snapshot("init_unexpected_error")
|
| 440 |
+
raise RuntimeError(f"页面初始化意外错误: {e_init_page}") from e_init_page
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
async def _close_page_logic():
|
| 444 |
+
"""关闭页面逻辑"""
|
| 445 |
+
# 需要访问全局变量
|
| 446 |
+
import server
|
| 447 |
+
logger.info("--- 运行页面逻辑关闭 --- ")
|
| 448 |
+
if server.page_instance and not server.page_instance.is_closed():
|
| 449 |
+
try:
|
| 450 |
+
await server.page_instance.close()
|
| 451 |
+
logger.info(" ✅ 页面已关闭")
|
| 452 |
+
except PlaywrightAsyncError as pw_err:
|
| 453 |
+
logger.warning(f" ⚠️ 关闭页面时出现Playwright错误: {pw_err}")
|
| 454 |
+
except asyncio.TimeoutError as timeout_err:
|
| 455 |
+
logger.warning(f" ⚠️ 关闭页面时超时: {timeout_err}")
|
| 456 |
+
except Exception as other_err:
|
| 457 |
+
logger.error(f" ⚠️ 关闭页面时出现意外错误: {other_err} (类型: {type(other_err).__name__})", exc_info=True)
|
| 458 |
+
server.page_instance = None
|
| 459 |
+
server.is_page_ready = False
|
| 460 |
+
logger.info("页面逻辑状态已重置。")
|
| 461 |
+
return None, False
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
async def signal_camoufox_shutdown():
|
| 465 |
+
"""发送关闭信号到Camoufox服务器"""
|
| 466 |
+
logger.info(" 尝试发送关闭信号到 Camoufox 服务器 (此功能可能已由父进程处理)...")
|
| 467 |
+
ws_endpoint = os.environ.get('CAMOUFOX_WS_ENDPOINT')
|
| 468 |
+
if not ws_endpoint:
|
| 469 |
+
logger.warning(" ⚠️ 无法发送关闭信号:未找到 CAMOUFOX_WS_ENDPOINT 环境变量。")
|
| 470 |
+
return
|
| 471 |
+
|
| 472 |
+
# 需要访问全局浏览器实例
|
| 473 |
+
import server
|
| 474 |
+
if not server.browser_instance or not server.browser_instance.is_connected():
|
| 475 |
+
logger.warning(" ⚠️ 浏览器实例已断开或未初始化,跳过关闭信号发送。")
|
| 476 |
+
return
|
| 477 |
+
try:
|
| 478 |
+
await asyncio.sleep(0.2)
|
| 479 |
+
logger.info(" ✅ (模拟) 关闭信号已处理。")
|
| 480 |
+
except Exception as e:
|
| 481 |
+
logger.error(f" ⚠️ 发送关闭信号过程中捕获异常: {e}", exc_info=True)
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
async def _wait_for_model_list_and_handle_auth_save(temp_context, launch_mode, loop):
|
| 485 |
+
"""等待模型列表响应并处理认证保存"""
|
| 486 |
+
import server
|
| 487 |
+
|
| 488 |
+
# 等待模型列表响应,确认登录成功
|
| 489 |
+
logger.info(" 等待模型列表响应以确认登录成功...")
|
| 490 |
+
try:
|
| 491 |
+
# 等待模型列表事件,最多等待30秒
|
| 492 |
+
await asyncio.wait_for(server.model_list_fetch_event.wait(), timeout=30.0)
|
| 493 |
+
logger.info(" ✅ 检测到模型列表响应,登录确认成功!")
|
| 494 |
+
except asyncio.TimeoutError:
|
| 495 |
+
logger.warning(" ⚠️ 等待模型列表响应超时,但继续处理认证保存...")
|
| 496 |
+
|
| 497 |
+
# 检查是否启用自动确认
|
| 498 |
+
if AUTO_CONFIRM_LOGIN:
|
| 499 |
+
print("\n" + "="*50, flush=True)
|
| 500 |
+
print(" ✅ 登录成功!检测到模型列表响应。", flush=True)
|
| 501 |
+
print(" 🤖 自动确认模式已启用,将自动保存认证状态...", flush=True)
|
| 502 |
+
|
| 503 |
+
# 自动保存认证状态
|
| 504 |
+
await _handle_auth_file_save_auto(temp_context)
|
| 505 |
+
print("="*50 + "\n", flush=True)
|
| 506 |
+
return
|
| 507 |
+
|
| 508 |
+
# 手动确认模式
|
| 509 |
+
print("\n" + "="*50, flush=True)
|
| 510 |
+
print(" 【用户交互】需要您的输入!", flush=True)
|
| 511 |
+
print(" ✅ 登录成功!检测到模型列表响应。", flush=True)
|
| 512 |
+
|
| 513 |
+
should_save_auth_choice = ''
|
| 514 |
+
if AUTO_SAVE_AUTH and launch_mode == 'debug':
|
| 515 |
+
logger.info(" 自动保存认证模式已启用,将自动保存认证状态...")
|
| 516 |
+
should_save_auth_choice = 'y'
|
| 517 |
+
else:
|
| 518 |
+
save_auth_prompt = " 是否要将当前的浏览器认证状态保存到文件? (y/N): "
|
| 519 |
+
print(USER_INPUT_START_MARKER_SERVER, flush=True)
|
| 520 |
+
try:
|
| 521 |
+
auth_save_input_future = loop.run_in_executor(None, input, save_auth_prompt)
|
| 522 |
+
should_save_auth_choice = await asyncio.wait_for(auth_save_input_future, timeout=AUTH_SAVE_TIMEOUT)
|
| 523 |
+
except asyncio.TimeoutError:
|
| 524 |
+
print(f" 输入等待超时({AUTH_SAVE_TIMEOUT}秒)。默认不保存认证状态。", flush=True)
|
| 525 |
+
should_save_auth_choice = 'n'
|
| 526 |
+
finally:
|
| 527 |
+
print(USER_INPUT_END_MARKER_SERVER, flush=True)
|
| 528 |
+
|
| 529 |
+
if should_save_auth_choice.strip().lower() == 'y':
|
| 530 |
+
await _handle_auth_file_save(temp_context, loop)
|
| 531 |
+
else:
|
| 532 |
+
print(" 好的,不保存认证状态。", flush=True)
|
| 533 |
+
|
| 534 |
+
print("="*50 + "\n", flush=True)
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
async def _handle_auth_file_save(temp_context, loop):
|
| 538 |
+
"""处理认证文件保存(手动模式)"""
|
| 539 |
+
os.makedirs(SAVED_AUTH_DIR, exist_ok=True)
|
| 540 |
+
default_auth_filename = f"auth_state_{int(time.time())}.json"
|
| 541 |
+
|
| 542 |
+
print(USER_INPUT_START_MARKER_SERVER, flush=True)
|
| 543 |
+
filename_prompt_str = f" 请输入保存的文件名 (默认为: {default_auth_filename},输入 'cancel' 取消保存): "
|
| 544 |
+
chosen_auth_filename = ''
|
| 545 |
+
|
| 546 |
+
try:
|
| 547 |
+
filename_input_future = loop.run_in_executor(None, input, filename_prompt_str)
|
| 548 |
+
chosen_auth_filename = await asyncio.wait_for(filename_input_future, timeout=AUTH_SAVE_TIMEOUT)
|
| 549 |
+
except asyncio.TimeoutError:
|
| 550 |
+
print(f" 输入文件名等待超时({AUTH_SAVE_TIMEOUT}秒)。将使用默认文件名: {default_auth_filename}", flush=True)
|
| 551 |
+
chosen_auth_filename = default_auth_filename
|
| 552 |
+
finally:
|
| 553 |
+
print(USER_INPUT_END_MARKER_SERVER, flush=True)
|
| 554 |
+
|
| 555 |
+
# 检查用户是否选择取消
|
| 556 |
+
if chosen_auth_filename.strip().lower() == 'cancel':
|
| 557 |
+
print(" 用户选择取消保存认证状态。", flush=True)
|
| 558 |
+
return
|
| 559 |
+
|
| 560 |
+
final_auth_filename = chosen_auth_filename.strip() or default_auth_filename
|
| 561 |
+
if not final_auth_filename.endswith(".json"):
|
| 562 |
+
final_auth_filename += ".json"
|
| 563 |
+
|
| 564 |
+
auth_save_path = os.path.join(SAVED_AUTH_DIR, final_auth_filename)
|
| 565 |
+
|
| 566 |
+
try:
|
| 567 |
+
await temp_context.storage_state(path=auth_save_path)
|
| 568 |
+
print(f" ✅ 认证状态已成功保存到: {auth_save_path}", flush=True)
|
| 569 |
+
except Exception as save_state_err:
|
| 570 |
+
logger.error(f" ❌ 保存认证状态失败: {save_state_err}", exc_info=True)
|
| 571 |
+
print(f" ❌ 保存认证状态失败: {save_state_err}", flush=True)
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
async def _handle_auth_file_save_auto(temp_context):
|
| 575 |
+
"""处理认证文件保存(自动模式)"""
|
| 576 |
+
os.makedirs(SAVED_AUTH_DIR, exist_ok=True)
|
| 577 |
+
|
| 578 |
+
# 生成基于时间戳的文件名
|
| 579 |
+
timestamp = int(time.time())
|
| 580 |
+
auto_auth_filename = f"auth_auto_{timestamp}.json"
|
| 581 |
+
auth_save_path = os.path.join(SAVED_AUTH_DIR, auto_auth_filename)
|
| 582 |
+
|
| 583 |
+
try:
|
| 584 |
+
await temp_context.storage_state(path=auth_save_path)
|
| 585 |
+
print(f" ✅ 认证状态已自动保存到: {auth_save_path}", flush=True)
|
| 586 |
+
logger.info(f" 自动保存认证状态成功: {auth_save_path}")
|
| 587 |
+
except Exception as save_state_err:
|
| 588 |
+
logger.error(f" ❌ 自动保存认证状态失败: {save_state_err}", exc_info=True)
|
| 589 |
+
print(f" ❌ 自动保存认证状态失败: {save_state_err}", flush=True)
|
browser_utils/model_management.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/model_management.py ---
|
| 2 |
+
# 浏览器模型管理相关功能模块
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import logging
|
| 8 |
+
import time
|
| 9 |
+
from typing import Optional, Set
|
| 10 |
+
|
| 11 |
+
from playwright.async_api import Page as AsyncPage, expect as expect_async, Error as PlaywrightAsyncError
|
| 12 |
+
|
| 13 |
+
# 导入配置和模型
|
| 14 |
+
from config import *
|
| 15 |
+
from models import ClientDisconnectedError
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 18 |
+
|
| 19 |
+
async def switch_ai_studio_model(page: AsyncPage, model_id: str, req_id: str) -> bool:
|
| 20 |
+
"""切换AI Studio模型"""
|
| 21 |
+
logger.info(f"[{req_id}] 开始切换模型到: {model_id}")
|
| 22 |
+
original_prefs_str: Optional[str] = None
|
| 23 |
+
original_prompt_model: Optional[str] = None
|
| 24 |
+
new_chat_url = f"https://{AI_STUDIO_URL_PATTERN}prompts/new_chat"
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
original_prefs_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 28 |
+
if original_prefs_str:
|
| 29 |
+
try:
|
| 30 |
+
original_prefs_obj = json.loads(original_prefs_str)
|
| 31 |
+
original_prompt_model = original_prefs_obj.get("promptModel")
|
| 32 |
+
logger.info(f"[{req_id}] 切换前 localStorage.promptModel 为: {original_prompt_model or '未设置'}")
|
| 33 |
+
except json.JSONDecodeError:
|
| 34 |
+
logger.warning(f"[{req_id}] 无法解析原始的 aiStudioUserPreference JSON 字符串。")
|
| 35 |
+
original_prefs_str = None
|
| 36 |
+
|
| 37 |
+
current_prefs_for_modification = json.loads(original_prefs_str) if original_prefs_str else {}
|
| 38 |
+
full_model_path = f"models/{model_id}"
|
| 39 |
+
|
| 40 |
+
if current_prefs_for_modification.get("promptModel") == full_model_path:
|
| 41 |
+
logger.info(f"[{req_id}] 模型已经设置为 {model_id} (localStorage 中已是目标值),无需切换")
|
| 42 |
+
if page.url != new_chat_url:
|
| 43 |
+
logger.info(f"[{req_id}] 当前 URL 不是 new_chat ({page.url}),导航到 {new_chat_url}")
|
| 44 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=30000)
|
| 45 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=30000)
|
| 46 |
+
return True
|
| 47 |
+
|
| 48 |
+
logger.info(f"[{req_id}] 从 {current_prefs_for_modification.get('promptModel', '未知')} 更新 localStorage.promptModel 为 {full_model_path}")
|
| 49 |
+
current_prefs_for_modification["promptModel"] = full_model_path
|
| 50 |
+
await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", json.dumps(current_prefs_for_modification))
|
| 51 |
+
|
| 52 |
+
# 强制设置配置选项
|
| 53 |
+
logger.info(f"[{req_id}] 强制设置配置选项:isAdvancedOpen=true, areToolsOpen=false")
|
| 54 |
+
current_prefs_for_modification["isAdvancedOpen"] = True
|
| 55 |
+
current_prefs_for_modification["areToolsOpen"] = False
|
| 56 |
+
await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", json.dumps(current_prefs_for_modification))
|
| 57 |
+
|
| 58 |
+
logger.info(f"[{req_id}] localStorage 已更新,导航到 '{new_chat_url}' 应用新模型...")
|
| 59 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=30000)
|
| 60 |
+
|
| 61 |
+
input_field = page.locator(INPUT_SELECTOR)
|
| 62 |
+
await expect_async(input_field).to_be_visible(timeout=30000)
|
| 63 |
+
logger.info(f"[{req_id}] 页面已导航到新聊天并加载完成,输入框可见")
|
| 64 |
+
|
| 65 |
+
final_prefs_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 66 |
+
final_prompt_model_in_storage: Optional[str] = None
|
| 67 |
+
if final_prefs_str:
|
| 68 |
+
try:
|
| 69 |
+
final_prefs_obj = json.loads(final_prefs_str)
|
| 70 |
+
final_prompt_model_in_storage = final_prefs_obj.get("promptModel")
|
| 71 |
+
except json.JSONDecodeError:
|
| 72 |
+
logger.warning(f"[{req_id}] 无法解析刷新后的 aiStudioUserPreference JSON 字符串。")
|
| 73 |
+
|
| 74 |
+
if final_prompt_model_in_storage == full_model_path:
|
| 75 |
+
logger.info(f"[{req_id}] ✅ AI Studio localStorage 中模型已成功设置为: {full_model_path}")
|
| 76 |
+
|
| 77 |
+
page_display_match = False
|
| 78 |
+
expected_display_name_for_target_id = None
|
| 79 |
+
actual_displayed_model_name_on_page = "无法读取"
|
| 80 |
+
|
| 81 |
+
# 获取parsed_model_list
|
| 82 |
+
import server
|
| 83 |
+
parsed_model_list = getattr(server, 'parsed_model_list', [])
|
| 84 |
+
|
| 85 |
+
if parsed_model_list:
|
| 86 |
+
for m_obj in parsed_model_list:
|
| 87 |
+
if m_obj.get("id") == model_id:
|
| 88 |
+
expected_display_name_for_target_id = m_obj.get("display_name")
|
| 89 |
+
break
|
| 90 |
+
|
| 91 |
+
if not expected_display_name_for_target_id:
|
| 92 |
+
logger.warning(f"[{req_id}] 无法在parsed_model_list中找到目标ID '{model_id}' 的显示名称,跳过页面显示名称验证。这可能不准确。")
|
| 93 |
+
page_display_match = True
|
| 94 |
+
else:
|
| 95 |
+
try:
|
| 96 |
+
model_name_locator = page.locator('mat-select[data-test-ms-model-selector] div.model-option-content span.gmat-body-medium')
|
| 97 |
+
actual_displayed_model_name_on_page_raw = await model_name_locator.first.inner_text(timeout=5000)
|
| 98 |
+
actual_displayed_model_name_on_page = actual_displayed_model_name_on_page_raw.strip()
|
| 99 |
+
normalized_actual_display = actual_displayed_model_name_on_page.lower()
|
| 100 |
+
normalized_expected_display = expected_display_name_for_target_id.strip().lower()
|
| 101 |
+
|
| 102 |
+
if normalized_actual_display == normalized_expected_display:
|
| 103 |
+
page_display_match = True
|
| 104 |
+
logger.info(f"[{req_id}] ✅ 页面显示模型 ('{actual_displayed_model_name_on_page}') 与期望 ('{expected_display_name_for_target_id}') 一致。")
|
| 105 |
+
else:
|
| 106 |
+
logger.error(f"[{req_id}] ❌ 页面显示模型 ('{actual_displayed_model_name_on_page}') 与期望 ('{expected_display_name_for_target_id}') 不一致。(Raw page: '{actual_displayed_model_name_on_page_raw}')")
|
| 107 |
+
except Exception as e_disp:
|
| 108 |
+
logger.warning(f"[{req_id}] 读取页面显示的当前模型名称时出错: {e_disp}。将无法验证页面显示。")
|
| 109 |
+
|
| 110 |
+
if page_display_match:
|
| 111 |
+
return True
|
| 112 |
+
else:
|
| 113 |
+
logger.error(f"[{req_id}] ❌ 模型切换失败,因为页面显示的模型与期望不符 (即使localStorage可能已更改)。")
|
| 114 |
+
else:
|
| 115 |
+
logger.error(f"[{req_id}] ❌ AI Studio 未接受模型更改 (localStorage)。期望='{full_model_path}', 实际='{final_prompt_model_in_storage or '未设置或无效'}'.")
|
| 116 |
+
|
| 117 |
+
logger.info(f"[{req_id}] 模型切换失败。尝试恢复到页面当前实际显示的模型的状态...")
|
| 118 |
+
current_displayed_name_for_revert_raw = "无法读取"
|
| 119 |
+
current_displayed_name_for_revert_stripped = "无法读取"
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
model_name_locator_revert = page.locator('mat-select[data-test-ms-model-selector] div.model-option-content span.gmat-body-medium')
|
| 123 |
+
current_displayed_name_for_revert_raw = await model_name_locator_revert.first.inner_text(timeout=5000)
|
| 124 |
+
current_displayed_name_for_revert_stripped = current_displayed_name_for_revert_raw.strip()
|
| 125 |
+
logger.info(f"[{req_id}] 恢复:页面当前显示的模型名称 (原始: '{current_displayed_name_for_revert_raw}', 清理后: '{current_displayed_name_for_revert_stripped}')")
|
| 126 |
+
except Exception as e_read_disp_revert:
|
| 127 |
+
logger.warning(f"[{req_id}] 恢复:读取页面当前显示模型名称失败: {e_read_disp_revert}。将尝试回退到原始localStorage。")
|
| 128 |
+
if original_prefs_str:
|
| 129 |
+
logger.info(f"[{req_id}] 恢复:由于无法读取当前页面显示,尝试将 localStorage 恢复到原始状态: '{original_prompt_model or '未设置'}'")
|
| 130 |
+
await page.evaluate("(origPrefs) => localStorage.setItem('aiStudioUserPreference', origPrefs)", original_prefs_str)
|
| 131 |
+
logger.info(f"[{req_id}] 恢复:导航到 '{new_chat_url}' 以应用恢复的原始 localStorage 设置...")
|
| 132 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=20000)
|
| 133 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=20000)
|
| 134 |
+
logger.info(f"[{req_id}] 恢复:页面已导航到新聊天并加载,已尝试应用原始 localStorage。")
|
| 135 |
+
else:
|
| 136 |
+
logger.warning(f"[{req_id}] 恢复:无有效的原始 localStorage 状态可恢复,也无法读取当前页面显示。")
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
model_id_to_revert_to = None
|
| 140 |
+
if parsed_model_list and current_displayed_name_for_revert_stripped != "无法读取":
|
| 141 |
+
normalized_current_display_for_revert = current_displayed_name_for_revert_stripped.lower()
|
| 142 |
+
for m_obj in parsed_model_list:
|
| 143 |
+
parsed_list_display_name = m_obj.get("display_name", "").strip().lower()
|
| 144 |
+
if parsed_list_display_name == normalized_current_display_for_revert:
|
| 145 |
+
model_id_to_revert_to = m_obj.get("id")
|
| 146 |
+
logger.info(f"[{req_id}] 恢复:页面显示名称 '{current_displayed_name_for_revert_stripped}' 对应模型ID: {model_id_to_revert_to}")
|
| 147 |
+
break
|
| 148 |
+
|
| 149 |
+
if not model_id_to_revert_to:
|
| 150 |
+
logger.warning(f"[{req_id}] 恢复:无法在 parsed_model_list 中找到与页面显示名称 '{current_displayed_name_for_revert_stripped}' 匹配的模型ID。")
|
| 151 |
+
else:
|
| 152 |
+
if current_displayed_name_for_revert_stripped == "无法读取":
|
| 153 |
+
logger.warning(f"[{req_id}] 恢复:因无法读取页面显示名称,故不能从 parsed_model_list ���换ID。")
|
| 154 |
+
else:
|
| 155 |
+
logger.warning(f"[{req_id}] 恢复:parsed_model_list 为空,无法从显示名称 '{current_displayed_name_for_revert_stripped}' 转换模型ID。")
|
| 156 |
+
|
| 157 |
+
if model_id_to_revert_to:
|
| 158 |
+
base_prefs_for_final_revert = {}
|
| 159 |
+
try:
|
| 160 |
+
current_ls_content_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 161 |
+
if current_ls_content_str:
|
| 162 |
+
base_prefs_for_final_revert = json.loads(current_ls_content_str)
|
| 163 |
+
elif original_prefs_str:
|
| 164 |
+
base_prefs_for_final_revert = json.loads(original_prefs_str)
|
| 165 |
+
except json.JSONDecodeError:
|
| 166 |
+
logger.warning(f"[{req_id}] 恢复:解析现有 localStorage 以构建恢复偏好失败。")
|
| 167 |
+
|
| 168 |
+
path_to_revert_to = f"models/{model_id_to_revert_to}"
|
| 169 |
+
base_prefs_for_final_revert["promptModel"] = path_to_revert_to
|
| 170 |
+
# 强制设置配置选项
|
| 171 |
+
base_prefs_for_final_revert["isAdvancedOpen"] = True
|
| 172 |
+
base_prefs_for_final_revert["areToolsOpen"] = False
|
| 173 |
+
logger.info(f"[{req_id}] 恢复:准备将 localStorage.promptModel 设置回页面实际显示的模型的路径: '{path_to_revert_to}',并强制设置配置选项")
|
| 174 |
+
await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", json.dumps(base_prefs_for_final_revert))
|
| 175 |
+
logger.info(f"[{req_id}] 恢复:导航到 '{new_chat_url}' 以应用恢复到 '{model_id_to_revert_to}' 的 localStorage 设置...")
|
| 176 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=30000)
|
| 177 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=30000)
|
| 178 |
+
logger.info(f"[{req_id}] 恢复:页面已导航到新聊天并加载。localStorage 应已设置为反映模型 '{model_id_to_revert_to}'。")
|
| 179 |
+
else:
|
| 180 |
+
logger.error(f"[{req_id}] 恢复:无法将模型恢复到页面显示的状态,因为未能从显示名称 '{current_displayed_name_for_revert_stripped}' 确定有效模型ID。")
|
| 181 |
+
if original_prefs_str:
|
| 182 |
+
logger.warning(f"[{req_id}] 恢复:作为最终后备,尝试恢复到原始 localStorage: '{original_prompt_model or '未设置'}'")
|
| 183 |
+
await page.evaluate("(origPrefs) => localStorage.setItem('aiStudioUserPreference', origPrefs)", original_prefs_str)
|
| 184 |
+
logger.info(f"[{req_id}] 恢复:导航到 '{new_chat_url}' 以应用最终后备的原始 localStorage。")
|
| 185 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=20000)
|
| 186 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=20000)
|
| 187 |
+
logger.info(f"[{req_id}] 恢复:页面已导航到新聊天并加载,已应用最终后备的原始 localStorage。")
|
| 188 |
+
else:
|
| 189 |
+
logger.warning(f"[{req_id}] 恢复:无有效的原始 localStorage 状态可作为最终后备。")
|
| 190 |
+
|
| 191 |
+
return False
|
| 192 |
+
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.exception(f"[{req_id}] ❌ 切换模型过程中发生严重错误")
|
| 195 |
+
# 导入save_error_snapshot函数
|
| 196 |
+
from .operations import save_error_snapshot
|
| 197 |
+
await save_error_snapshot(f"model_switch_error_{req_id}")
|
| 198 |
+
try:
|
| 199 |
+
if original_prefs_str:
|
| 200 |
+
logger.info(f"[{req_id}] 发生异常,尝试恢复 localStorage 至: {original_prompt_model or '未设置'}")
|
| 201 |
+
await page.evaluate("(origPrefs) => localStorage.setItem('aiStudioUserPreference', origPrefs)", original_prefs_str)
|
| 202 |
+
logger.info(f"[{req_id}] 异常恢复:导航到 '{new_chat_url}' 以应用恢复的 localStorage。")
|
| 203 |
+
await page.goto(new_chat_url, wait_until="domcontentloaded", timeout=15000)
|
| 204 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=15000)
|
| 205 |
+
except Exception as recovery_err:
|
| 206 |
+
logger.error(f"[{req_id}] 异常后恢复 localStorage 失败: {recovery_err}")
|
| 207 |
+
return False
|
| 208 |
+
|
| 209 |
+
def load_excluded_models(filename: str):
|
| 210 |
+
"""加载排除的模型列表"""
|
| 211 |
+
import server
|
| 212 |
+
excluded_model_ids = getattr(server, 'excluded_model_ids', set())
|
| 213 |
+
|
| 214 |
+
excluded_file_path = os.path.join(os.path.dirname(__file__), '..', filename)
|
| 215 |
+
try:
|
| 216 |
+
if os.path.exists(excluded_file_path):
|
| 217 |
+
with open(excluded_file_path, 'r', encoding='utf-8') as f:
|
| 218 |
+
loaded_ids = {line.strip() for line in f if line.strip()}
|
| 219 |
+
if loaded_ids:
|
| 220 |
+
excluded_model_ids.update(loaded_ids)
|
| 221 |
+
server.excluded_model_ids = excluded_model_ids
|
| 222 |
+
logger.info(f"✅ 从 '{filename}' 加载了 {len(loaded_ids)} 个模型到排除列表: {excluded_model_ids}")
|
| 223 |
+
else:
|
| 224 |
+
logger.info(f"'{filename}' 文件为空或不包含有效的模型 ID,排除列表未更改。")
|
| 225 |
+
else:
|
| 226 |
+
logger.info(f"模型排除列表文件 '{filename}' 未找到,排除列表为空。")
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logger.error(f"❌ 从 '{filename}' 加载排除模型列表时出错: {e}", exc_info=True)
|
| 229 |
+
|
| 230 |
+
async def _handle_initial_model_state_and_storage(page: AsyncPage):
|
| 231 |
+
"""处理初始模型状态和存储"""
|
| 232 |
+
import server
|
| 233 |
+
current_ai_studio_model_id = getattr(server, 'current_ai_studio_model_id', None)
|
| 234 |
+
parsed_model_list = getattr(server, 'parsed_model_list', [])
|
| 235 |
+
model_list_fetch_event = getattr(server, 'model_list_fetch_event', None)
|
| 236 |
+
|
| 237 |
+
logger.info("--- (新) 处理初始模型状态, localStorage 和 isAdvancedOpen ---")
|
| 238 |
+
needs_reload_and_storage_update = False
|
| 239 |
+
reason_for_reload = ""
|
| 240 |
+
|
| 241 |
+
try:
|
| 242 |
+
initial_prefs_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 243 |
+
if not initial_prefs_str:
|
| 244 |
+
needs_reload_and_storage_update = True
|
| 245 |
+
reason_for_reload = "localStorage.aiStudioUserPreference 未找到。"
|
| 246 |
+
logger.info(f" 判定需要刷新和存储更新: {reason_for_reload}")
|
| 247 |
+
else:
|
| 248 |
+
logger.info(" localStorage 中找到 'aiStudioUserPreference'。正在解析...")
|
| 249 |
+
try:
|
| 250 |
+
pref_obj = json.loads(initial_prefs_str)
|
| 251 |
+
prompt_model_path = pref_obj.get("promptModel")
|
| 252 |
+
is_advanced_open_in_storage = pref_obj.get("isAdvancedOpen")
|
| 253 |
+
is_prompt_model_valid = isinstance(prompt_model_path, str) and prompt_model_path.strip()
|
| 254 |
+
|
| 255 |
+
if not is_prompt_model_valid:
|
| 256 |
+
needs_reload_and_storage_update = True
|
| 257 |
+
reason_for_reload = "localStorage.promptModel 无效或未设置。"
|
| 258 |
+
logger.info(f" 判定需要刷新和存储更新: {reason_for_reload}")
|
| 259 |
+
elif is_advanced_open_in_storage is not True:
|
| 260 |
+
needs_reload_and_storage_update = True
|
| 261 |
+
reason_for_reload = f"localStorage.isAdvancedOpen ({is_advanced_open_in_storage}) 不为 True。"
|
| 262 |
+
logger.info(f" 判定需要刷新和存储更新: {reason_for_reload}")
|
| 263 |
+
else:
|
| 264 |
+
server.current_ai_studio_model_id = prompt_model_path.split('/')[-1]
|
| 265 |
+
logger.info(f" ✅ localStorage 有效且 isAdvancedOpen=true。初始模型 ID 从 localStorage 设置为: {server.current_ai_studio_model_id}")
|
| 266 |
+
except json.JSONDecodeError:
|
| 267 |
+
needs_reload_and_storage_update = True
|
| 268 |
+
reason_for_reload = "解析 localStorage.aiStudioUserPreference JSON 失败。"
|
| 269 |
+
logger.error(f" 判定需要刷新和存储更新: {reason_for_reload}")
|
| 270 |
+
|
| 271 |
+
if needs_reload_and_storage_update:
|
| 272 |
+
logger.info(f" 执行刷新和存储更新流程,原因: {reason_for_reload}")
|
| 273 |
+
logger.info(" 步骤 1: 调用 _set_model_from_page_display(set_storage=True) 更新 localStorage 和全局模型 ID...")
|
| 274 |
+
await _set_model_from_page_display(page, set_storage=True)
|
| 275 |
+
|
| 276 |
+
current_page_url = page.url
|
| 277 |
+
logger.info(f" 步骤 2: 重新加载页面 ({current_page_url}) 以应用 isAdvancedOpen=true...")
|
| 278 |
+
max_retries = 3
|
| 279 |
+
for attempt in range(max_retries):
|
| 280 |
+
try:
|
| 281 |
+
logger.info(f" 尝试重新加载页面 (第 {attempt + 1}/{max_retries} 次): {current_page_url}")
|
| 282 |
+
await page.goto(current_page_url, wait_until="domcontentloaded", timeout=40000)
|
| 283 |
+
await expect_async(page.locator(INPUT_SELECTOR)).to_be_visible(timeout=30000)
|
| 284 |
+
logger.info(f" ✅ 页面已成功重新加载到: {page.url}")
|
| 285 |
+
break # 成功则跳出循环
|
| 286 |
+
except Exception as reload_err:
|
| 287 |
+
logger.warning(f" ⚠️ 页面重新加载尝试 {attempt + 1}/{max_retries} 失败: {reload_err}")
|
| 288 |
+
if attempt < max_retries - 1:
|
| 289 |
+
logger.info(f" 将在5秒后重试...")
|
| 290 |
+
await asyncio.sleep(5)
|
| 291 |
+
else:
|
| 292 |
+
logger.error(f" ❌ 页面重新加载在 {max_retries} 次尝试后最终失败: {reload_err}. 后续模型状态可能不准确。", exc_info=True)
|
| 293 |
+
from .operations import save_error_snapshot
|
| 294 |
+
await save_error_snapshot(f"initial_storage_reload_fail_attempt_{attempt+1}")
|
| 295 |
+
|
| 296 |
+
logger.info(" 步骤 3: 重新加载后,再次调用 _set_model_from_page_display(set_storage=False) 以同步全局模型 ID...")
|
| 297 |
+
await _set_model_from_page_display(page, set_storage=False)
|
| 298 |
+
logger.info(f" ✅ 刷新和存储更新流程完成。最终全局模型 ID: {server.current_ai_studio_model_id}")
|
| 299 |
+
else:
|
| 300 |
+
logger.info(" localStorage 状态良好 (isAdvancedOpen=true, promptModel有效),无需刷新页面。")
|
| 301 |
+
except Exception as e:
|
| 302 |
+
logger.error(f"❌ (新) 处理初始模型状态和 localStorage 时发生严重错误: {e}", exc_info=True)
|
| 303 |
+
try:
|
| 304 |
+
logger.warning(" 由于发生错误,尝试回退仅从页面显示设置全局模型 ID (不写入localStorage)...")
|
| 305 |
+
await _set_model_from_page_display(page, set_storage=False)
|
| 306 |
+
except Exception as fallback_err:
|
| 307 |
+
logger.error(f" 回退设置模型ID也失败: {fallback_err}")
|
| 308 |
+
|
| 309 |
+
async def _set_model_from_page_display(page: AsyncPage, set_storage: bool = False):
|
| 310 |
+
"""从页面显示设置模型"""
|
| 311 |
+
import server
|
| 312 |
+
current_ai_studio_model_id = getattr(server, 'current_ai_studio_model_id', None)
|
| 313 |
+
parsed_model_list = getattr(server, 'parsed_model_list', [])
|
| 314 |
+
model_list_fetch_event = getattr(server, 'model_list_fetch_event', None)
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
logger.info(" 尝试从页面显示元素读取当前模型名称...")
|
| 318 |
+
model_name_locator = page.locator('mat-select[data-test-ms-model-selector] div.model-option-content span.gmat-body-medium')
|
| 319 |
+
displayed_model_name_from_page_raw = await model_name_locator.first.inner_text(timeout=7000)
|
| 320 |
+
displayed_model_name = displayed_model_name_from_page_raw.strip()
|
| 321 |
+
logger.info(f" 页面当前显示模型名称 (原始: '{displayed_model_name_from_page_raw}', 清理后: '{displayed_model_name}')")
|
| 322 |
+
|
| 323 |
+
found_model_id_from_display = None
|
| 324 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 325 |
+
logger.info(" 等待模型列表数据 (最多5秒) 以便转换显示名称...")
|
| 326 |
+
try:
|
| 327 |
+
await asyncio.wait_for(model_list_fetch_event.wait(), timeout=5.0)
|
| 328 |
+
except asyncio.TimeoutError:
|
| 329 |
+
logger.warning(" 等待模型列表超时,可能无法准确转换显示名称为ID。")
|
| 330 |
+
|
| 331 |
+
if parsed_model_list:
|
| 332 |
+
for model_obj in parsed_model_list:
|
| 333 |
+
if model_obj.get("display_name") and model_obj.get("display_name").strip() == displayed_model_name:
|
| 334 |
+
found_model_id_from_display = model_obj.get("id")
|
| 335 |
+
logger.info(f" 显示名称 '{displayed_model_name}' 对应模型 ID: {found_model_id_from_display}")
|
| 336 |
+
break
|
| 337 |
+
|
| 338 |
+
if not found_model_id_from_display:
|
| 339 |
+
logger.warning(f" 未在已知模型列表中找到与显示名称 '{displayed_model_name}' 匹配的 ID。")
|
| 340 |
+
else:
|
| 341 |
+
logger.warning(" 模型列表尚不可用,无法将显示名称转换为ID。")
|
| 342 |
+
|
| 343 |
+
new_model_value = found_model_id_from_display if found_model_id_from_display else displayed_model_name
|
| 344 |
+
if server.current_ai_studio_model_id != new_model_value:
|
| 345 |
+
server.current_ai_studio_model_id = new_model_value
|
| 346 |
+
logger.info(f" 全局 current_ai_studio_model_id 已更新为: {server.current_ai_studio_model_id}")
|
| 347 |
+
else:
|
| 348 |
+
logger.info(f" 全局 current_ai_studio_model_id ('{server.current_ai_studio_model_id}') 与从页面获取的值一致,未更改。")
|
| 349 |
+
|
| 350 |
+
if set_storage:
|
| 351 |
+
logger.info(f" 准备为页面状态设置 localStorage (确保 isAdvancedOpen=true)...")
|
| 352 |
+
existing_prefs_for_update_str = await page.evaluate("() => localStorage.getItem('aiStudioUserPreference')")
|
| 353 |
+
prefs_to_set = {}
|
| 354 |
+
if existing_prefs_for_update_str:
|
| 355 |
+
try:
|
| 356 |
+
prefs_to_set = json.loads(existing_prefs_for_update_str)
|
| 357 |
+
except json.JSONDecodeError:
|
| 358 |
+
logger.warning(" 解析现有 localStorage.aiStudioUserPreference 失败,将创建新的偏好设置。")
|
| 359 |
+
|
| 360 |
+
prefs_to_set["isAdvancedOpen"] = True
|
| 361 |
+
logger.info(f" 强制 isAdvancedOpen: true")
|
| 362 |
+
prefs_to_set["areToolsOpen"] = False
|
| 363 |
+
logger.info(f" 强制 areToolsOpen: false")
|
| 364 |
+
|
| 365 |
+
if found_model_id_from_display:
|
| 366 |
+
new_prompt_model_path = f"models/{found_model_id_from_display}"
|
| 367 |
+
prefs_to_set["promptModel"] = new_prompt_model_path
|
| 368 |
+
logger.info(f" 设置 promptModel 为: {new_prompt_model_path} (基于找到的ID)")
|
| 369 |
+
elif "promptModel" not in prefs_to_set:
|
| 370 |
+
logger.warning(f" 无法从页面显示 '{displayed_model_name}' 找到模型ID,且 localStorage 中无现有 promptModel。promptModel 将不会被主动设置以避免潜在问题。")
|
| 371 |
+
|
| 372 |
+
default_keys_if_missing = {
|
| 373 |
+
"bidiModel": "models/gemini-1.0-pro-001",
|
| 374 |
+
"isSafetySettingsOpen": False,
|
| 375 |
+
"hasShownSearchGroundingTos": False,
|
| 376 |
+
"autosaveEnabled": True,
|
| 377 |
+
"theme": "system",
|
| 378 |
+
"bidiOutputFormat": 3,
|
| 379 |
+
"isSystemInstructionsOpen": False,
|
| 380 |
+
"warmWelcomeDisplayed": True,
|
| 381 |
+
"getCodeLanguage": "Node.js",
|
| 382 |
+
"getCodeHistoryToggle": False,
|
| 383 |
+
"fileCopyrightAcknowledged": True
|
| 384 |
+
}
|
| 385 |
+
for key, val_default in default_keys_if_missing.items():
|
| 386 |
+
if key not in prefs_to_set:
|
| 387 |
+
prefs_to_set[key] = val_default
|
| 388 |
+
|
| 389 |
+
await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", json.dumps(prefs_to_set))
|
| 390 |
+
logger.info(f" ✅ localStorage.aiStudioUserPreference 已更新。isAdvancedOpen: {prefs_to_set.get('isAdvancedOpen')}, areToolsOpen: {prefs_to_set.get('areToolsOpen')}, promptModel: '{prefs_to_set.get('promptModel', '未设置/保留原样')}'。")
|
| 391 |
+
except Exception as e_set_disp:
|
| 392 |
+
logger.error(f" 尝试从页面显示设置模型时出错: {e_set_disp}", exc_info=True)
|
browser_utils/more_modles.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ==UserScript==
|
| 2 |
+
// @name Google AI Studio Model Injector (Multi-Model) - XHR+Fetch+ArrayStructure
|
| 3 |
+
// @namespace http://tampermonkey.net/
|
| 4 |
+
// @version 1.6
|
| 5 |
+
// @description Inject multiple custom models with themed emojis (Kingfall, Gemini, Goldmane, etc.) into the model list on Google AI Studio. Intercepts XHR/fetch, handles array-of-arrays JSON structure.
|
| 6 |
+
// @author Generated by AI / HCPTangHY / Mozi
|
| 7 |
+
// @match https://aistudio.google.com/*
|
| 8 |
+
// @icon https://www.google.com/s2/favicons?sz=64&domain=aistudio.google.com
|
| 9 |
+
// @grant none
|
| 10 |
+
// @run-at document-start
|
| 11 |
+
// @license MIT
|
| 12 |
+
// ==/UserScript==
|
| 13 |
+
|
| 14 |
+
(function() {
|
| 15 |
+
'use strict';
|
| 16 |
+
|
| 17 |
+
// ================== 定义要注入的模型列表 =====================
|
| 18 |
+
// ↓↓↓↓↓↓ 版本号 / EMOJIS 更新 ↓↓↓↓↓↓
|
| 19 |
+
const SCRIPT_VERSION = "v1.6";
|
| 20 |
+
// ↓↓↓↓↓↓ 模型列表 EMOJIS 已更新 ↓↓↓↓↓↓
|
| 21 |
+
const MODELS_TO_INJECT = [
|
| 22 |
+
{
|
| 23 |
+
name: 'models/kingfall-ab-test',
|
| 24 |
+
displayName: `👑 Kingfall (Script ${SCRIPT_VERSION})`, // 👑 King
|
| 25 |
+
description: `Model injected by script ${SCRIPT_VERSION}`
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
name: 'models/gemini-2.5-pro-preview-03-25',
|
| 29 |
+
displayName: `✨ Gemini 2.5 Pro 03-25 (Script ${SCRIPT_VERSION})`, // ✨ Magic/AI
|
| 30 |
+
description: `Model injected by script ${SCRIPT_VERSION}`
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
name: 'models/goldmane-ab-test',
|
| 34 |
+
displayName: `🦁 Goldmane (Script ${SCRIPT_VERSION})`, // 🦁 Gold Mane
|
| 35 |
+
description: `Model injected by script ${SCRIPT_VERSION}`
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
name: 'models/claybrook-ab-test',
|
| 39 |
+
displayName: `💧 Claybrook (Script ${SCRIPT_VERSION})`, // 💧 Brook
|
| 40 |
+
description: `Model injected by script ${SCRIPT_VERSION}`
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
name: 'models/frostwind-ab-test',
|
| 44 |
+
displayName: `❄️ Frostwind (Script ${SCRIPT_VERSION})`, // ❄️ Frost
|
| 45 |
+
description: `Model injected by script ${SCRIPT_VERSION}`
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
name: 'models/calmriver-ab-test',
|
| 49 |
+
displayName: `🌊 Calmriver (Script ${SCRIPT_VERSION})`, // 🌊 River
|
| 50 |
+
description: `Model injected by script ${SCRIPT_VERSION}`
|
| 51 |
+
},
|
| 52 |
+
// 可以在此按格式继续添加更多模型
|
| 53 |
+
];
|
| 54 |
+
// ↑↑↑↑↑↑ 模型列表 EMOJIS 已更新 ↑↑↑↑↑↑
|
| 55 |
+
// ==========================================================
|
| 56 |
+
|
| 57 |
+
const LOG_PREFIX = `[AI Studio Injector ${SCRIPT_VERSION}]:`;
|
| 58 |
+
const ANTI_HIJACK_PREFIX = ")]}'\n";
|
| 59 |
+
|
| 60 |
+
// --- 关键索引定义 (基于您的JSON结构) ---
|
| 61 |
+
const NAME_IDX = 0;
|
| 62 |
+
const DISPLAY_NAME_IDX = 3;
|
| 63 |
+
const DESC_IDX = 4;
|
| 64 |
+
const METHODS_IDX = 7;
|
| 65 |
+
// ------------------------------------
|
| 66 |
+
|
| 67 |
+
console.log(LOG_PREFIX, 'Script active. Patching Fetch and XHR...');
|
| 68 |
+
|
| 69 |
+
function isTargetURL(url) {
|
| 70 |
+
return url && typeof url === 'string' && url.includes('alkalimakersuite') && url.includes('/ListModels');
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// 递归查找包含模型数组的数组: 寻找 `[ [model1_array], [model2_array], ... ]`
|
| 74 |
+
function findModelListArray(obj) {
|
| 75 |
+
if (!obj) return null;
|
| 76 |
+
// 检查 obj 是否是我们寻找的那个包含多个模型数组的数组
|
| 77 |
+
if (Array.isArray(obj) && obj.length > 0 && obj.every(
|
| 78 |
+
item => Array.isArray(item) && typeof item[NAME_IDX] === 'string' && String(item[NAME_IDX]).startsWith('models/')
|
| 79 |
+
)) {
|
| 80 |
+
// console.log(LOG_PREFIX, "Target model list array FOUND."); // Reduce log noise
|
| 81 |
+
return obj;
|
| 82 |
+
}
|
| 83 |
+
// 递归搜索
|
| 84 |
+
if (typeof obj === 'object') {
|
| 85 |
+
for (const key in obj) {
|
| 86 |
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
| 87 |
+
if(typeof obj[key] === 'object' && obj[key] !== null){ // check not null
|
| 88 |
+
const result = findModelListArray(obj[key]);
|
| 89 |
+
if (result) return result;
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
return null;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
// 核心:处理并修改 JSON 数据 - 使用索引访问,遍历多个模型
|
| 99 |
+
function processJsonData(jsonData, url) {
|
| 100 |
+
let modificationMade = false;
|
| 101 |
+
const modelsArray = findModelListArray(jsonData); // [ [模型1数组], [模型2数组], ...]
|
| 102 |
+
|
| 103 |
+
if(modelsArray && Array.isArray(modelsArray)){
|
| 104 |
+
// console.log(LOG_PREFIX, 'Processing models array (length:', modelsArray.length, ") for URL:", url); // Reduce log noise
|
| 105 |
+
|
| 106 |
+
// *** 只寻找一次模板 ***
|
| 107 |
+
const templateModel = modelsArray.find(m => Array.isArray(m) && m[NAME_IDX] && String(m[NAME_IDX]).includes('flash') && Array.isArray(m[METHODS_IDX]) )
|
| 108 |
+
|| modelsArray.find(m => Array.isArray(m) && m[NAME_IDX] && String(m[NAME_IDX]).includes('pro') && Array.isArray(m[METHODS_IDX]) )
|
| 109 |
+
|| modelsArray.find(m => Array.isArray(m) && m[NAME_IDX] && Array.isArray(m[METHODS_IDX]) ); // 找第一个有方法的
|
| 110 |
+
|
| 111 |
+
const templateName = (templateModel && templateModel[NAME_IDX]) ? templateModel[NAME_IDX] : 'unknown';
|
| 112 |
+
if(templateModel){
|
| 113 |
+
// console.log(LOG_PREFIX, `Using template: ${templateName}`); // Reduce log noise
|
| 114 |
+
} else {
|
| 115 |
+
console.warn(LOG_PREFIX, 'Could not find a suitable template model array. Cannot inject new models, but can update existing ones.');
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// *** 遍历所有需要注入的模型 ***
|
| 119 |
+
// 使用 reverse, 使得 MODELS_TO_INJECT 数组中的顺序和最终显示在顶部的顺序一致
|
| 120 |
+
[...MODELS_TO_INJECT].reverse().forEach(modelToInject => {
|
| 121 |
+
const modelExists = modelsArray.some(model => Array.isArray(model) && model[NAME_IDX] === modelToInject.name);
|
| 122 |
+
|
| 123 |
+
if (!modelExists) {
|
| 124 |
+
if(!templateModel) {
|
| 125 |
+
console.warn(LOG_PREFIX, `Cannot inject ${modelToInject.name}: No template found.`);
|
| 126 |
+
return; // Skip this model if no template
|
| 127 |
+
}
|
| 128 |
+
// !!!关键: 必须深拷贝数组!!!
|
| 129 |
+
const newModel = JSON.parse(JSON.stringify(templateModel)); // Deep Clone from template
|
| 130 |
+
|
| 131 |
+
// !!!关键: 使用索引修改 !!!
|
| 132 |
+
newModel[NAME_IDX] = modelToInject.name;
|
| 133 |
+
newModel[DISPLAY_NAME_IDX] = modelToInject.displayName;
|
| 134 |
+
newModel[DESC_IDX] = `${modelToInject.description} (Structure based on ${templateName})`;
|
| 135 |
+
if(!Array.isArray(newModel[METHODS_IDX])){
|
| 136 |
+
newModel[METHODS_IDX] = ["generateContent", "countTokens","createCachedContent","batchGenerateContent"];
|
| 137 |
+
}
|
| 138 |
+
modelsArray.unshift(newModel); // 添加到开头
|
| 139 |
+
modificationMade = true;
|
| 140 |
+
console.log(LOG_PREFIX, `Successfully INJECTED: ${modelToInject.displayName}`);
|
| 141 |
+
|
| 142 |
+
} else {
|
| 143 |
+
// console.log(LOG_PREFIX, `Model ALREADY EXISTS: ${modelToInject.name}. Checking displayName.`); // Reduce log noise
|
| 144 |
+
const existing = modelsArray.find(model => Array.isArray(model) && model[NAME_IDX] === modelToInject.name);
|
| 145 |
+
// 如果存在,但名字不是脚本设定的名字,且不包含当前版本号,则更新名字
|
| 146 |
+
// 如果模型已存在,且名字就是我们设定的(例如刷新页面),我们也更新一下,确保emoji和版本号是最新的
|
| 147 |
+
if(existing && existing[DISPLAY_NAME_IDX] !== modelToInject.displayName) {
|
| 148 |
+
// 检查是否只是版本号或emoji不同
|
| 149 |
+
const baseExistingName = String(existing[DISPLAY_NAME_IDX]).replace(/ \(Script v\d+\.\d+(-beta\d*)?\)/, '').replace(/^[👑✨🦁💧❄️🌊]\s*/,'').trim();
|
| 150 |
+
const baseInjectName = modelToInject.displayName.replace(/ \(Script v\d+\.\d+(-beta\d*)?\)/, '').replace(/^[👑✨🦁💧❄️🌊]\s*/,'').trim();
|
| 151 |
+
|
| 152 |
+
if (baseExistingName === baseInjectName) {
|
| 153 |
+
// 基础名字相同,只更新emoji和版本号
|
| 154 |
+
existing[DISPLAY_NAME_IDX] = modelToInject.displayName;
|
| 155 |
+
console.log(LOG_PREFIX, `Updated Emoji/Version for ${modelToInject.displayName}.`);
|
| 156 |
+
} else {
|
| 157 |
+
// 基础名字不同,说明是官方自带的或其他来源,加上 (Orig)
|
| 158 |
+
existing[DISPLAY_NAME_IDX] = modelToInject.displayName + " (Orig)";
|
| 159 |
+
console.log(LOG_PREFIX, `Updated displayName for existing official ${modelToInject.name} to (Orig).`);
|
| 160 |
+
}
|
| 161 |
+
modificationMade = true;
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
}); // End forEach
|
| 165 |
+
|
| 166 |
+
} else {
|
| 167 |
+
console.warn(LOG_PREFIX, 'URL matched, but no valid model list array structure found in JSON for:', url);
|
| 168 |
+
}
|
| 169 |
+
return { data: jsonData, modified: modificationMade };
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
// 统一处理响应体文本 (解析, 处理, 序列化)
|
| 173 |
+
function modifyResponseBody(originalText, url) {
|
| 174 |
+
if (!originalText || typeof originalText !== 'string') return originalText; // Add type check
|
| 175 |
+
try {
|
| 176 |
+
let textBody = originalText;
|
| 177 |
+
let hasPrefix = false;
|
| 178 |
+
if (textBody.startsWith(ANTI_HIJACK_PREFIX)) {
|
| 179 |
+
textBody = textBody.substring(ANTI_HIJACK_PREFIX.length);
|
| 180 |
+
hasPrefix = true;
|
| 181 |
+
}
|
| 182 |
+
if(!textBody.trim()) return originalText;
|
| 183 |
+
const jsonData = JSON.parse(textBody);
|
| 184 |
+
const result = processJsonData(jsonData, url);
|
| 185 |
+
if (result.modified) {
|
| 186 |
+
let newBody = JSON.stringify(result.data);
|
| 187 |
+
if(hasPrefix){
|
| 188 |
+
newBody = ANTI_HIJACK_PREFIX + newBody;
|
| 189 |
+
}
|
| 190 |
+
// console.log(LOG_PREFIX, "Returning MODIFIED response body."); // Reduce log noise
|
| 191 |
+
return newBody;
|
| 192 |
+
}
|
| 193 |
+
} catch (error) {
|
| 194 |
+
console.error(LOG_PREFIX, 'Error processing response body for:', url, error, "\nOriginal Text snippet:", String(originalText).substring(0, 300) + "...");
|
| 195 |
+
}
|
| 196 |
+
// console.log(LOG_PREFIX, "Returning ORIGINAL response body (no modification or error)."); // Reduce log noise
|
| 197 |
+
return originalText;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
//==================================
|
| 201 |
+
// 拦截 Fetch (保留)
|
| 202 |
+
//==================================
|
| 203 |
+
const originalFetch = window.fetch;
|
| 204 |
+
window.fetch = async function(...args) {
|
| 205 |
+
const resource = args[0];
|
| 206 |
+
const url = (resource instanceof Request) ? resource.url : String(resource);
|
| 207 |
+
const response = await originalFetch.apply(this, args);
|
| 208 |
+
if (isTargetURL(url) && response.ok) {
|
| 209 |
+
console.log(LOG_PREFIX, '[Fetch] Intercepting:', url);
|
| 210 |
+
try {
|
| 211 |
+
const cloneResponse = response.clone();
|
| 212 |
+
const originalText = await cloneResponse.text();
|
| 213 |
+
const newBody = modifyResponseBody(originalText, url);
|
| 214 |
+
if(newBody !== originalText){
|
| 215 |
+
return new Response(newBody, { status: response.status, statusText: response.statusText, headers: response.headers });
|
| 216 |
+
}
|
| 217 |
+
} catch(e) { console.error(LOG_PREFIX, '[Fetch] Error:', e); }
|
| 218 |
+
}
|
| 219 |
+
return response;
|
| 220 |
+
};
|
| 221 |
+
console.log(LOG_PREFIX, 'Fetch patch applied.');
|
| 222 |
+
|
| 223 |
+
//==================================
|
| 224 |
+
// 拦截 XMLHttpRequest (XHR)
|
| 225 |
+
//==================================
|
| 226 |
+
const xhrProto = XMLHttpRequest.prototype;
|
| 227 |
+
const originalOpen = xhrProto.open;
|
| 228 |
+
const originalResponseTextDescriptor = Object.getOwnPropertyDescriptor(xhrProto, 'responseText');
|
| 229 |
+
const originalResponseDescriptor = Object.getOwnPropertyDescriptor(xhrProto, 'response');
|
| 230 |
+
let interceptionCount = 0;
|
| 231 |
+
|
| 232 |
+
xhrProto.open = function(method, url) {
|
| 233 |
+
this._interceptorUrl = url;
|
| 234 |
+
this._isTargetXHR = isTargetURL(url);
|
| 235 |
+
if(this._isTargetXHR){
|
| 236 |
+
interceptionCount++;
|
| 237 |
+
console.log(LOG_PREFIX, `[XHR] Open detected (${interceptionCount}) for:`, url);
|
| 238 |
+
}
|
| 239 |
+
return originalOpen.apply(this, arguments);
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
const handleXHRResponse = (xhr, originalValue, type = 'text') => {
|
| 243 |
+
if (xhr._isTargetXHR && xhr.readyState === 4 && xhr.status === 200) {
|
| 244 |
+
const cacheKey = '_modifiedResponseCache_' + type;
|
| 245 |
+
if(xhr[cacheKey] === undefined){
|
| 246 |
+
// console.log(LOG_PREFIX, `[XHR] Processing response[${type}] for:`, xhr._interceptorUrl); // Reduce log noise
|
| 247 |
+
const originalText = (type === 'text' || typeof originalValue !== 'object' || originalValue === null) ? String(originalValue || '') : JSON.stringify(originalValue) ;
|
| 248 |
+
// Cache the result of modifyResponseBody
|
| 249 |
+
xhr[cacheKey] = modifyResponseBody(originalText, xhr._interceptorUrl);
|
| 250 |
+
}
|
| 251 |
+
// Use the cached result
|
| 252 |
+
const cachedResponse = xhr[cacheKey];
|
| 253 |
+
try{
|
| 254 |
+
// 如果是对象类型,且缓存的是字符串,返回时需要反序列化
|
| 255 |
+
if (type === 'json' && typeof cachedResponse === 'string') {
|
| 256 |
+
// Ensure the string is not empty before parsing
|
| 257 |
+
const textToParse = cachedResponse.replace(ANTI_HIJACK_PREFIX,'');
|
| 258 |
+
if (textToParse) {
|
| 259 |
+
return JSON.parse(textToParse);
|
| 260 |
+
}
|
| 261 |
+
return null; // or originalValue if parsing empty string is an issue
|
| 262 |
+
}
|
| 263 |
+
} catch(e){
|
| 264 |
+
console.error(LOG_PREFIX, `[XHR] Error parsing cached JSON for type 'json': `, e, `Cache content: ${String(cachedResponse).substring(0,100)}...`);
|
| 265 |
+
return originalValue; // fallback
|
| 266 |
+
}
|
| 267 |
+
return cachedResponse; // text type or already object or empty string
|
| 268 |
+
}
|
| 269 |
+
return originalValue;
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
if(originalResponseTextDescriptor && originalResponseTextDescriptor.get) {
|
| 274 |
+
Object.defineProperty(xhrProto, 'responseText', {
|
| 275 |
+
get: function() {
|
| 276 |
+
const originalText = originalResponseTextDescriptor.get.call(this);
|
| 277 |
+
// Only handle if responseType is text or default ""
|
| 278 |
+
if (this.responseType && this.responseType !== 'text' && this.responseType !== "") return originalText;
|
| 279 |
+
return handleXHRResponse(this, originalText, 'text');
|
| 280 |
+
}, configurable: true
|
| 281 |
+
});
|
| 282 |
+
console.log(LOG_PREFIX, 'XHR responseText patch applied.');
|
| 283 |
+
} else { console.error(LOG_PREFIX, 'XHR: Failed to get original responseText descriptor!'); }
|
| 284 |
+
|
| 285 |
+
if(originalResponseDescriptor && originalResponseDescriptor.get) {
|
| 286 |
+
Object.defineProperty(xhrProto, 'response', {
|
| 287 |
+
get: function() {
|
| 288 |
+
const originalResponse = originalResponseDescriptor.get.call(this);
|
| 289 |
+
if (this.responseType === 'json') {
|
| 290 |
+
return handleXHRResponse(this, originalResponse, 'json');
|
| 291 |
+
}
|
| 292 |
+
// When responseType is "" or "text", originalResponse is the text itself
|
| 293 |
+
if (!this.responseType || this.responseType === 'text' || this.responseType === "") {
|
| 294 |
+
return handleXHRResponse(this, originalResponse, 'text');
|
| 295 |
+
}
|
| 296 |
+
return originalResponse; // other types like blob, arraybuffer
|
| 297 |
+
}, configurable: true
|
| 298 |
+
});
|
| 299 |
+
console.log(LOG_PREFIX, 'XHR response patch applied.');
|
| 300 |
+
} else {
|
| 301 |
+
console.error(LOG_PREFIX, 'XHR: Failed to get original response descriptor!');
|
| 302 |
+
}
|
| 303 |
+
console.log(LOG_PREFIX, 'XHR open patch applied.');
|
| 304 |
+
|
| 305 |
+
})();
|
browser_utils/operations.py
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/operations.py ---
|
| 2 |
+
# 浏览器页面操作相关功能模块
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import time
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Optional, Any, List, Dict, Callable, Set
|
| 11 |
+
|
| 12 |
+
from playwright.async_api import Page as AsyncPage, Locator, Error as PlaywrightAsyncError
|
| 13 |
+
|
| 14 |
+
# 导入配置和模型
|
| 15 |
+
from config import *
|
| 16 |
+
from models import ClientDisconnectedError
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 19 |
+
|
| 20 |
+
async def get_raw_text_content(response_element: Locator, previous_text: str, req_id: str) -> str:
|
| 21 |
+
"""从响应元素获取原始文本内容"""
|
| 22 |
+
raw_text = previous_text
|
| 23 |
+
try:
|
| 24 |
+
await response_element.wait_for(state='attached', timeout=1000)
|
| 25 |
+
pre_element = response_element.locator('pre').last
|
| 26 |
+
pre_found_and_visible = False
|
| 27 |
+
try:
|
| 28 |
+
await pre_element.wait_for(state='visible', timeout=250)
|
| 29 |
+
pre_found_and_visible = True
|
| 30 |
+
except PlaywrightAsyncError:
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
if pre_found_and_visible:
|
| 34 |
+
try:
|
| 35 |
+
raw_text = await pre_element.inner_text(timeout=500)
|
| 36 |
+
except PlaywrightAsyncError as pre_err:
|
| 37 |
+
if DEBUG_LOGS_ENABLED:
|
| 38 |
+
logger.debug(f"[{req_id}] (获取原始文本) 获取 pre 元素内部文本失败: {pre_err}")
|
| 39 |
+
else:
|
| 40 |
+
try:
|
| 41 |
+
raw_text = await response_element.inner_text(timeout=500)
|
| 42 |
+
except PlaywrightAsyncError as e_parent:
|
| 43 |
+
if DEBUG_LOGS_ENABLED:
|
| 44 |
+
logger.debug(f"[{req_id}] (获取原始文本) 获取响应元素内部文本失败: {e_parent}")
|
| 45 |
+
except PlaywrightAsyncError as e_parent:
|
| 46 |
+
if DEBUG_LOGS_ENABLED:
|
| 47 |
+
logger.debug(f"[{req_id}] (获取原始文本) 响应元素未准备好: {e_parent}")
|
| 48 |
+
except Exception as e_unexpected:
|
| 49 |
+
logger.warning(f"[{req_id}] (获取原始文本) 意外错误: {e_unexpected}")
|
| 50 |
+
|
| 51 |
+
if raw_text != previous_text:
|
| 52 |
+
if DEBUG_LOGS_ENABLED:
|
| 53 |
+
preview = raw_text[:100].replace('\n', '\\n')
|
| 54 |
+
logger.debug(f"[{req_id}] (获取原始文本) 文本已更新,长度: {len(raw_text)},预览: '{preview}...'")
|
| 55 |
+
return raw_text
|
| 56 |
+
|
| 57 |
+
def _parse_userscript_models(script_content: str):
|
| 58 |
+
"""从油猴脚本中解析模型列表 - 使用JSON解析方式"""
|
| 59 |
+
try:
|
| 60 |
+
# 查找脚本版本号
|
| 61 |
+
version_pattern = r'const\s+SCRIPT_VERSION\s*=\s*[\'"]([^\'"]+)[\'"]'
|
| 62 |
+
version_match = re.search(version_pattern, script_content)
|
| 63 |
+
script_version = version_match.group(1) if version_match else "v1.6"
|
| 64 |
+
|
| 65 |
+
# 查找 MODELS_TO_INJECT 数组的内容
|
| 66 |
+
models_array_pattern = r'const\s+MODELS_TO_INJECT\s*=\s*(\[.*?\]);'
|
| 67 |
+
models_match = re.search(models_array_pattern, script_content, re.DOTALL)
|
| 68 |
+
|
| 69 |
+
if not models_match:
|
| 70 |
+
logger.warning("未找到 MODELS_TO_INJECT 数组")
|
| 71 |
+
return []
|
| 72 |
+
|
| 73 |
+
models_js_code = models_match.group(1)
|
| 74 |
+
|
| 75 |
+
# 将JavaScript数组转换为JSON格式
|
| 76 |
+
# 1. 替换模板字符串中的变量
|
| 77 |
+
models_js_code = models_js_code.replace('${SCRIPT_VERSION}', script_version)
|
| 78 |
+
|
| 79 |
+
# 2. 移除JavaScript注释
|
| 80 |
+
models_js_code = re.sub(r'//.*?$', '', models_js_code, flags=re.MULTILINE)
|
| 81 |
+
|
| 82 |
+
# 3. 将JavaScript对象转换为JSON格式
|
| 83 |
+
# 移除尾随逗号
|
| 84 |
+
models_js_code = re.sub(r',\s*([}\]])', r'\1', models_js_code)
|
| 85 |
+
|
| 86 |
+
# 替换单引号为双引号
|
| 87 |
+
models_js_code = re.sub(r"(\w+):\s*'([^']*)'", r'"\1": "\2"', models_js_code)
|
| 88 |
+
# 替换反引号为双引号
|
| 89 |
+
models_js_code = re.sub(r'(\w+):\s*`([^`]*)`', r'"\1": "\2"', models_js_code)
|
| 90 |
+
# 确保属性名用双引号
|
| 91 |
+
models_js_code = re.sub(r'(\w+):', r'"\1":', models_js_code)
|
| 92 |
+
|
| 93 |
+
# 4. 解析JSON
|
| 94 |
+
import json
|
| 95 |
+
models_data = json.loads(models_js_code)
|
| 96 |
+
|
| 97 |
+
models = []
|
| 98 |
+
for model_obj in models_data:
|
| 99 |
+
if isinstance(model_obj, dict) and 'name' in model_obj:
|
| 100 |
+
models.append({
|
| 101 |
+
'name': model_obj.get('name', ''),
|
| 102 |
+
'displayName': model_obj.get('displayName', ''),
|
| 103 |
+
'description': model_obj.get('description', '')
|
| 104 |
+
})
|
| 105 |
+
|
| 106 |
+
logger.info(f"成功解析 {len(models)} 个模型从油猴脚本")
|
| 107 |
+
return models
|
| 108 |
+
|
| 109 |
+
except Exception as e:
|
| 110 |
+
logger.error(f"解析油猴脚本模型列表失败: {e}")
|
| 111 |
+
return []
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _get_injected_models():
|
| 115 |
+
"""从油猴脚本中获取注入的模型列表,转换为API格式"""
|
| 116 |
+
try:
|
| 117 |
+
# 直接读取环境变量,避免复杂的导入
|
| 118 |
+
enable_injection = os.environ.get('ENABLE_SCRIPT_INJECTION', 'true').lower() in ('true', '1', 'yes')
|
| 119 |
+
|
| 120 |
+
if not enable_injection:
|
| 121 |
+
return []
|
| 122 |
+
|
| 123 |
+
# 获取脚本文件路径
|
| 124 |
+
script_path = os.environ.get('USERSCRIPT_PATH', 'browser_utils/more_modles.js')
|
| 125 |
+
|
| 126 |
+
# 检查脚本文件是否存在
|
| 127 |
+
if not os.path.exists(script_path):
|
| 128 |
+
# 脚本文件不存在,静默返回空列表
|
| 129 |
+
return []
|
| 130 |
+
|
| 131 |
+
# 读取油猴脚本内容
|
| 132 |
+
with open(script_path, 'r', encoding='utf-8') as f:
|
| 133 |
+
script_content = f.read()
|
| 134 |
+
|
| 135 |
+
# 从脚本中解析模型列表
|
| 136 |
+
models = _parse_userscript_models(script_content)
|
| 137 |
+
|
| 138 |
+
if not models:
|
| 139 |
+
return []
|
| 140 |
+
|
| 141 |
+
# 转换为API格式
|
| 142 |
+
injected_models = []
|
| 143 |
+
for model in models:
|
| 144 |
+
model_name = model.get('name', '')
|
| 145 |
+
if not model_name:
|
| 146 |
+
continue # 跳过没有名称的模型
|
| 147 |
+
|
| 148 |
+
if model_name.startswith('models/'):
|
| 149 |
+
simple_id = model_name[7:] # 移除 'models/' 前缀
|
| 150 |
+
else:
|
| 151 |
+
simple_id = model_name
|
| 152 |
+
|
| 153 |
+
display_name = model.get('displayName', model.get('display_name', simple_id))
|
| 154 |
+
description = model.get('description', f'Injected model: {simple_id}')
|
| 155 |
+
|
| 156 |
+
# 注意:不再清理显示名称,保留原始的emoji和版本信息
|
| 157 |
+
|
| 158 |
+
model_entry = {
|
| 159 |
+
"id": simple_id,
|
| 160 |
+
"object": "model",
|
| 161 |
+
"created": int(time.time()),
|
| 162 |
+
"owned_by": "ai_studio_injected",
|
| 163 |
+
"display_name": display_name,
|
| 164 |
+
"description": description,
|
| 165 |
+
"raw_model_path": model_name,
|
| 166 |
+
"default_temperature": 1.0,
|
| 167 |
+
"default_max_output_tokens": 65536,
|
| 168 |
+
"supported_max_output_tokens": 65536,
|
| 169 |
+
"default_top_p": 0.95,
|
| 170 |
+
"injected": True # 标记为注入的模型
|
| 171 |
+
}
|
| 172 |
+
injected_models.append(model_entry)
|
| 173 |
+
|
| 174 |
+
return injected_models
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
# 静默处理错误,不输出日志,返回空列表
|
| 178 |
+
return []
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
async def _handle_model_list_response(response: Any):
|
| 182 |
+
"""处理模型列表响应"""
|
| 183 |
+
# 需要访问全局变量
|
| 184 |
+
import server
|
| 185 |
+
global_model_list_raw_json = getattr(server, 'global_model_list_raw_json', None)
|
| 186 |
+
parsed_model_list = getattr(server, 'parsed_model_list', [])
|
| 187 |
+
model_list_fetch_event = getattr(server, 'model_list_fetch_event', None)
|
| 188 |
+
excluded_model_ids = getattr(server, 'excluded_model_ids', set())
|
| 189 |
+
|
| 190 |
+
if MODELS_ENDPOINT_URL_CONTAINS in response.url and response.ok:
|
| 191 |
+
# 检查是否在登录流程中
|
| 192 |
+
launch_mode = os.environ.get('LAUNCH_MODE', 'debug')
|
| 193 |
+
is_in_login_flow = launch_mode in ['debug'] and not getattr(server, 'is_page_ready', False)
|
| 194 |
+
|
| 195 |
+
if is_in_login_flow:
|
| 196 |
+
# 在登录流程中,静默处理,不输出干扰信息
|
| 197 |
+
pass # 静默处理,避免干扰用户输入
|
| 198 |
+
else:
|
| 199 |
+
logger.info(f"捕获到潜在的模型列表响应来自: {response.url} (状态: {response.status})")
|
| 200 |
+
try:
|
| 201 |
+
data = await response.json()
|
| 202 |
+
models_array_container = None
|
| 203 |
+
if isinstance(data, list) and data:
|
| 204 |
+
if isinstance(data[0], list) and data[0] and isinstance(data[0][0], list):
|
| 205 |
+
if not is_in_login_flow:
|
| 206 |
+
logger.info("检测到三层列表结构 data[0][0] is list. models_array_container 设置为 data[0]。")
|
| 207 |
+
models_array_container = data[0]
|
| 208 |
+
elif isinstance(data[0], list) and data[0] and isinstance(data[0][0], str):
|
| 209 |
+
if not is_in_login_flow:
|
| 210 |
+
logger.info("检测到两层列表结构 data[0][0] is str. models_array_container 设置为 data。")
|
| 211 |
+
models_array_container = data
|
| 212 |
+
elif isinstance(data[0], dict):
|
| 213 |
+
if not is_in_login_flow:
|
| 214 |
+
logger.info("检测到根列表,元素为字典。直接使用 data 作为 models_array_container。")
|
| 215 |
+
models_array_container = data
|
| 216 |
+
else:
|
| 217 |
+
logger.warning(f"未知的列表嵌套结构。data[0] 类型: {type(data[0]) if data else 'N/A'}。data[0] 预览: {str(data[0])[:200] if data else 'N/A'}")
|
| 218 |
+
elif isinstance(data, dict):
|
| 219 |
+
if 'data' in data and isinstance(data['data'], list):
|
| 220 |
+
models_array_container = data['data']
|
| 221 |
+
elif 'models' in data and isinstance(data['models'], list):
|
| 222 |
+
models_array_container = data['models']
|
| 223 |
+
else:
|
| 224 |
+
for key, value in data.items():
|
| 225 |
+
if isinstance(value, list) and len(value) > 0 and isinstance(value[0], (dict, list)):
|
| 226 |
+
models_array_container = value
|
| 227 |
+
logger.info(f"模型列表数据在 '{key}' 键下通过启发式搜索找到。")
|
| 228 |
+
break
|
| 229 |
+
if models_array_container is None:
|
| 230 |
+
logger.warning("在字典响应中未能自动定位模型列表数组。")
|
| 231 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 232 |
+
model_list_fetch_event.set()
|
| 233 |
+
return
|
| 234 |
+
else:
|
| 235 |
+
logger.warning(f"接收到的模型列表数据既不是列表也不是字典: {type(data)}")
|
| 236 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 237 |
+
model_list_fetch_event.set()
|
| 238 |
+
return
|
| 239 |
+
|
| 240 |
+
if models_array_container is not None:
|
| 241 |
+
new_parsed_list = []
|
| 242 |
+
for entry_in_container in models_array_container:
|
| 243 |
+
model_fields_list = None
|
| 244 |
+
if isinstance(entry_in_container, dict):
|
| 245 |
+
potential_id = entry_in_container.get('id', entry_in_container.get('model_id', entry_in_container.get('modelId')))
|
| 246 |
+
if potential_id:
|
| 247 |
+
model_fields_list = entry_in_container
|
| 248 |
+
else:
|
| 249 |
+
model_fields_list = list(entry_in_container.values())
|
| 250 |
+
elif isinstance(entry_in_container, list):
|
| 251 |
+
model_fields_list = entry_in_container
|
| 252 |
+
else:
|
| 253 |
+
logger.debug(f"Skipping entry of unknown type: {type(entry_in_container)}")
|
| 254 |
+
continue
|
| 255 |
+
|
| 256 |
+
if not model_fields_list:
|
| 257 |
+
logger.debug("Skipping entry because model_fields_list is empty or None.")
|
| 258 |
+
continue
|
| 259 |
+
|
| 260 |
+
model_id_path_str = None
|
| 261 |
+
display_name_candidate = ""
|
| 262 |
+
description_candidate = "N/A"
|
| 263 |
+
default_max_output_tokens_val = None
|
| 264 |
+
default_top_p_val = None
|
| 265 |
+
default_temperature_val = 1.0
|
| 266 |
+
supported_max_output_tokens_val = None
|
| 267 |
+
current_model_id_for_log = "UnknownModelYet"
|
| 268 |
+
|
| 269 |
+
try:
|
| 270 |
+
if isinstance(model_fields_list, list):
|
| 271 |
+
if not (len(model_fields_list) > 0 and isinstance(model_fields_list[0], (str, int, float))):
|
| 272 |
+
logger.debug(f"Skipping list-based model_fields due to invalid first element: {str(model_fields_list)[:100]}")
|
| 273 |
+
continue
|
| 274 |
+
model_id_path_str = str(model_fields_list[0])
|
| 275 |
+
current_model_id_for_log = model_id_path_str.split('/')[-1] if model_id_path_str and '/' in model_id_path_str else model_id_path_str
|
| 276 |
+
display_name_candidate = str(model_fields_list[3]) if len(model_fields_list) > 3 else ""
|
| 277 |
+
description_candidate = str(model_fields_list[4]) if len(model_fields_list) > 4 else "N/A"
|
| 278 |
+
|
| 279 |
+
if len(model_fields_list) > 6 and model_fields_list[6] is not None:
|
| 280 |
+
try:
|
| 281 |
+
val_int = int(model_fields_list[6])
|
| 282 |
+
default_max_output_tokens_val = val_int
|
| 283 |
+
supported_max_output_tokens_val = val_int
|
| 284 |
+
except (ValueError, TypeError):
|
| 285 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将列表索引6的值 '{model_fields_list[6]}' 解析为 max_output_tokens。")
|
| 286 |
+
|
| 287 |
+
if len(model_fields_list) > 9 and model_fields_list[9] is not None:
|
| 288 |
+
try:
|
| 289 |
+
raw_top_p = float(model_fields_list[9])
|
| 290 |
+
if not (0.0 <= raw_top_p <= 1.0):
|
| 291 |
+
logger.warning(f"模型 {current_model_id_for_log}: 原始 top_p值 {raw_top_p} (来自列表索引9) 超出 [0,1] 范围,将裁剪。")
|
| 292 |
+
default_top_p_val = max(0.0, min(1.0, raw_top_p))
|
| 293 |
+
else:
|
| 294 |
+
default_top_p_val = raw_top_p
|
| 295 |
+
except (ValueError, TypeError):
|
| 296 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将列表索引9的值 '{model_fields_list[9]}' 解析为 top_p。")
|
| 297 |
+
|
| 298 |
+
elif isinstance(model_fields_list, dict):
|
| 299 |
+
model_id_path_str = str(model_fields_list.get('id', model_fields_list.get('model_id', model_fields_list.get('modelId'))))
|
| 300 |
+
current_model_id_for_log = model_id_path_str.split('/')[-1] if model_id_path_str and '/' in model_id_path_str else model_id_path_str
|
| 301 |
+
display_name_candidate = str(model_fields_list.get('displayName', model_fields_list.get('display_name', model_fields_list.get('name', ''))))
|
| 302 |
+
description_candidate = str(model_fields_list.get('description', "N/A"))
|
| 303 |
+
|
| 304 |
+
mot_parsed = model_fields_list.get('maxOutputTokens', model_fields_list.get('defaultMaxOutputTokens', model_fields_list.get('outputTokenLimit')))
|
| 305 |
+
if mot_parsed is not None:
|
| 306 |
+
try:
|
| 307 |
+
val_int = int(mot_parsed)
|
| 308 |
+
default_max_output_tokens_val = val_int
|
| 309 |
+
supported_max_output_tokens_val = val_int
|
| 310 |
+
except (ValueError, TypeError):
|
| 311 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将字典值 '{mot_parsed}' 解析为 max_output_tokens。")
|
| 312 |
+
|
| 313 |
+
top_p_parsed = model_fields_list.get('topP', model_fields_list.get('defaultTopP'))
|
| 314 |
+
if top_p_parsed is not None:
|
| 315 |
+
try:
|
| 316 |
+
raw_top_p = float(top_p_parsed)
|
| 317 |
+
if not (0.0 <= raw_top_p <= 1.0):
|
| 318 |
+
logger.warning(f"模型 {current_model_id_for_log}: 原始 top_p值 {raw_top_p} (来自字典) 超出 [0,1] 范围,将裁剪。")
|
| 319 |
+
default_top_p_val = max(0.0, min(1.0, raw_top_p))
|
| 320 |
+
else:
|
| 321 |
+
default_top_p_val = raw_top_p
|
| 322 |
+
except (ValueError, TypeError):
|
| 323 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将字典值 '{top_p_parsed}' 解析为 top_p。")
|
| 324 |
+
|
| 325 |
+
temp_parsed = model_fields_list.get('temperature', model_fields_list.get('defaultTemperature'))
|
| 326 |
+
if temp_parsed is not None:
|
| 327 |
+
try:
|
| 328 |
+
default_temperature_val = float(temp_parsed)
|
| 329 |
+
except (ValueError, TypeError):
|
| 330 |
+
logger.warning(f"模型 {current_model_id_for_log}: 无法将字典值 '{temp_parsed}' 解析为 temperature。")
|
| 331 |
+
else:
|
| 332 |
+
logger.debug(f"Skipping entry because model_fields_list is not list or dict: {type(model_fields_list)}")
|
| 333 |
+
continue
|
| 334 |
+
except Exception as e_parse_fields:
|
| 335 |
+
logger.error(f"解析模型字段时出错 for entry {str(entry_in_container)[:100]}: {e_parse_fields}")
|
| 336 |
+
continue
|
| 337 |
+
|
| 338 |
+
if model_id_path_str and model_id_path_str.lower() != "none":
|
| 339 |
+
simple_model_id_str = model_id_path_str.split('/')[-1] if '/' in model_id_path_str else model_id_path_str
|
| 340 |
+
if simple_model_id_str in excluded_model_ids:
|
| 341 |
+
if not is_in_login_flow:
|
| 342 |
+
logger.info(f"模型 '{simple_model_id_str}' 在排除列表 excluded_model_ids 中,已跳过。")
|
| 343 |
+
continue
|
| 344 |
+
|
| 345 |
+
final_display_name_str = display_name_candidate if display_name_candidate else simple_model_id_str.replace("-", " ").title()
|
| 346 |
+
model_entry_dict = {
|
| 347 |
+
"id": simple_model_id_str,
|
| 348 |
+
"object": "model",
|
| 349 |
+
"created": int(time.time()),
|
| 350 |
+
"owned_by": "ai_studio",
|
| 351 |
+
"display_name": final_display_name_str,
|
| 352 |
+
"description": description_candidate,
|
| 353 |
+
"raw_model_path": model_id_path_str,
|
| 354 |
+
"default_temperature": default_temperature_val,
|
| 355 |
+
"default_max_output_tokens": default_max_output_tokens_val,
|
| 356 |
+
"supported_max_output_tokens": supported_max_output_tokens_val,
|
| 357 |
+
"default_top_p": default_top_p_val
|
| 358 |
+
}
|
| 359 |
+
new_parsed_list.append(model_entry_dict)
|
| 360 |
+
else:
|
| 361 |
+
logger.debug(f"Skipping entry due to invalid model_id_path: {model_id_path_str} from entry {str(entry_in_container)[:100]}")
|
| 362 |
+
|
| 363 |
+
if new_parsed_list:
|
| 364 |
+
# 尝试添加注入的模型到解析列表
|
| 365 |
+
injected_models = _get_injected_models()
|
| 366 |
+
if injected_models:
|
| 367 |
+
new_parsed_list.extend(injected_models)
|
| 368 |
+
if not is_in_login_flow:
|
| 369 |
+
logger.info(f"添加了 {len(injected_models)} 个注入的模型到API模型列表")
|
| 370 |
+
|
| 371 |
+
server.parsed_model_list = sorted(new_parsed_list, key=lambda m: m.get('display_name', '').lower())
|
| 372 |
+
server.global_model_list_raw_json = json.dumps({"data": server.parsed_model_list, "object": "list"})
|
| 373 |
+
if DEBUG_LOGS_ENABLED:
|
| 374 |
+
log_output = f"成功解析和更新模型列表。总共解析模型数: {len(server.parsed_model_list)}.\n"
|
| 375 |
+
for i, item in enumerate(server.parsed_model_list[:min(3, len(server.parsed_model_list))]):
|
| 376 |
+
log_output += f" Model {i+1}: ID={item.get('id')}, Name={item.get('display_name')}, Temp={item.get('default_temperature')}, MaxTokDef={item.get('default_max_output_tokens')}, MaxTokSup={item.get('supported_max_output_tokens')}, TopP={item.get('default_top_p')}\n"
|
| 377 |
+
logger.info(log_output)
|
| 378 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 379 |
+
model_list_fetch_event.set()
|
| 380 |
+
elif not server.parsed_model_list:
|
| 381 |
+
logger.warning("解析后模型列表仍然为空。")
|
| 382 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 383 |
+
model_list_fetch_event.set()
|
| 384 |
+
else:
|
| 385 |
+
logger.warning("models_array_container 为 None,无法解析模型列表。")
|
| 386 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 387 |
+
model_list_fetch_event.set()
|
| 388 |
+
except json.JSONDecodeError as json_err:
|
| 389 |
+
logger.error(f"解析模型列表JSON失败: {json_err}. 响应 (前500字): {await response.text()[:500]}")
|
| 390 |
+
except Exception as e_handle_list_resp:
|
| 391 |
+
logger.exception(f"处理模型列表响应时发生未知错误: {e_handle_list_resp}")
|
| 392 |
+
finally:
|
| 393 |
+
if model_list_fetch_event and not model_list_fetch_event.is_set():
|
| 394 |
+
logger.info("处理模型列表响应结束,强制设置 model_list_fetch_event。")
|
| 395 |
+
model_list_fetch_event.set()
|
| 396 |
+
|
| 397 |
+
async def detect_and_extract_page_error(page: AsyncPage, req_id: str) -> Optional[str]:
|
| 398 |
+
"""检测并提取页面错误"""
|
| 399 |
+
error_toast_locator = page.locator(ERROR_TOAST_SELECTOR).last
|
| 400 |
+
try:
|
| 401 |
+
await error_toast_locator.wait_for(state='visible', timeout=500)
|
| 402 |
+
message_locator = error_toast_locator.locator('span.content-text')
|
| 403 |
+
error_message = await message_locator.text_content(timeout=500)
|
| 404 |
+
if error_message:
|
| 405 |
+
logger.error(f"[{req_id}] 检测到并提取错误消息: {error_message}")
|
| 406 |
+
return error_message.strip()
|
| 407 |
+
else:
|
| 408 |
+
logger.warning(f"[{req_id}] 检测到错误提示框,但无法提取消息。")
|
| 409 |
+
return "检测到错误提示框,但无法提取特定消息。"
|
| 410 |
+
except PlaywrightAsyncError:
|
| 411 |
+
return None
|
| 412 |
+
except Exception as e:
|
| 413 |
+
logger.warning(f"[{req_id}] 检查页面错误时出错: {e}")
|
| 414 |
+
return None
|
| 415 |
+
|
| 416 |
+
async def save_error_snapshot(error_name: str = 'error'):
|
| 417 |
+
"""保存错误快照"""
|
| 418 |
+
import server
|
| 419 |
+
name_parts = error_name.split('_')
|
| 420 |
+
req_id = name_parts[-1] if len(name_parts) > 1 and len(name_parts[-1]) == 7 else None
|
| 421 |
+
base_error_name = error_name if not req_id else '_'.join(name_parts[:-1])
|
| 422 |
+
log_prefix = f"[{req_id}]" if req_id else "[无请求ID]"
|
| 423 |
+
page_to_snapshot = server.page_instance
|
| 424 |
+
|
| 425 |
+
if not server.browser_instance or not server.browser_instance.is_connected() or not page_to_snapshot or page_to_snapshot.is_closed():
|
| 426 |
+
logger.warning(f"{log_prefix} 无法保存快照 ({base_error_name}),浏览器/页面不可用。")
|
| 427 |
+
return
|
| 428 |
+
|
| 429 |
+
logger.info(f"{log_prefix} 尝试保存错误快照 ({base_error_name})...")
|
| 430 |
+
timestamp = int(time.time() * 1000)
|
| 431 |
+
error_dir = os.path.join(os.path.dirname(__file__), '..', 'errors_py')
|
| 432 |
+
|
| 433 |
+
try:
|
| 434 |
+
os.makedirs(error_dir, exist_ok=True)
|
| 435 |
+
filename_suffix = f"{req_id}_{timestamp}" if req_id else f"{timestamp}"
|
| 436 |
+
filename_base = f"{base_error_name}_{filename_suffix}"
|
| 437 |
+
screenshot_path = os.path.join(error_dir, f"{filename_base}.png")
|
| 438 |
+
html_path = os.path.join(error_dir, f"{filename_base}.html")
|
| 439 |
+
|
| 440 |
+
try:
|
| 441 |
+
await page_to_snapshot.screenshot(path=screenshot_path, full_page=True, timeout=15000)
|
| 442 |
+
logger.info(f"{log_prefix} 快照已保存到: {screenshot_path}")
|
| 443 |
+
except Exception as ss_err:
|
| 444 |
+
logger.error(f"{log_prefix} 保存屏幕截图失败 ({base_error_name}): {ss_err}")
|
| 445 |
+
|
| 446 |
+
try:
|
| 447 |
+
content = await page_to_snapshot.content()
|
| 448 |
+
f = None
|
| 449 |
+
try:
|
| 450 |
+
f = open(html_path, 'w', encoding='utf-8')
|
| 451 |
+
f.write(content)
|
| 452 |
+
logger.info(f"{log_prefix} HTML 已保存到: {html_path}")
|
| 453 |
+
except Exception as write_err:
|
| 454 |
+
logger.error(f"{log_prefix} 保存 HTML 失败 ({base_error_name}): {write_err}")
|
| 455 |
+
finally:
|
| 456 |
+
if f:
|
| 457 |
+
try:
|
| 458 |
+
f.close()
|
| 459 |
+
logger.debug(f"{log_prefix} HTML 文件已正确关闭")
|
| 460 |
+
except Exception as close_err:
|
| 461 |
+
logger.error(f"{log_prefix} 关闭 HTML 文件时出错: {close_err}")
|
| 462 |
+
except Exception as html_err:
|
| 463 |
+
logger.error(f"{log_prefix} 获取页面内容失败 ({base_error_name}): {html_err}")
|
| 464 |
+
except Exception as dir_err:
|
| 465 |
+
logger.error(f"{log_prefix} 创建错误目录或保存快照时发生其他错误 ({base_error_name}): {dir_err}")
|
| 466 |
+
|
| 467 |
+
async def get_response_via_edit_button(
|
| 468 |
+
page: AsyncPage,
|
| 469 |
+
req_id: str,
|
| 470 |
+
check_client_disconnected: Callable
|
| 471 |
+
) -> Optional[str]:
|
| 472 |
+
"""通过编辑按钮获取响应"""
|
| 473 |
+
logger.info(f"[{req_id}] (Helper) 尝试通过编辑按钮获取响应...")
|
| 474 |
+
last_message_container = page.locator('ms-chat-turn').last
|
| 475 |
+
edit_button = last_message_container.get_by_label("Edit")
|
| 476 |
+
finish_edit_button = last_message_container.get_by_label("Stop editing")
|
| 477 |
+
autosize_textarea_locator = last_message_container.locator('ms-autosize-textarea')
|
| 478 |
+
actual_textarea_locator = autosize_textarea_locator.locator('textarea')
|
| 479 |
+
|
| 480 |
+
try:
|
| 481 |
+
logger.info(f"[{req_id}] - 尝试悬停最后一条消息以显示 'Edit' 按钮...")
|
| 482 |
+
try:
|
| 483 |
+
# 对消息容器执行悬停操作
|
| 484 |
+
await last_message_container.hover(timeout=CLICK_TIMEOUT_MS / 2) # 使用一半的点击超时作为悬停超时
|
| 485 |
+
await asyncio.sleep(0.3) # 等待悬停效果生效
|
| 486 |
+
check_client_disconnected("编辑响应 - 悬停后: ")
|
| 487 |
+
except Exception as hover_err:
|
| 488 |
+
logger.warning(f"[{req_id}] - (get_response_via_edit_button) 悬停最后一条消息失败 (忽略): {type(hover_err).__name__}")
|
| 489 |
+
# 即使悬停失败,也继续尝试后续操作,Playwright的expect_async可能会处理
|
| 490 |
+
|
| 491 |
+
logger.info(f"[{req_id}] - 定位并点击 'Edit' 按钮...")
|
| 492 |
+
try:
|
| 493 |
+
from playwright.async_api import expect as expect_async
|
| 494 |
+
await expect_async(edit_button).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 495 |
+
check_client_disconnected("编辑响应 - 'Edit' 按钮可见后: ")
|
| 496 |
+
await edit_button.click(timeout=CLICK_TIMEOUT_MS)
|
| 497 |
+
logger.info(f"[{req_id}] - 'Edit' 按钮已点击。")
|
| 498 |
+
except Exception as edit_btn_err:
|
| 499 |
+
logger.error(f"[{req_id}] - 'Edit' 按钮不可见或点击失败: {edit_btn_err}")
|
| 500 |
+
await save_error_snapshot(f"edit_response_edit_button_failed_{req_id}")
|
| 501 |
+
return None
|
| 502 |
+
|
| 503 |
+
check_client_disconnected("编辑响应 - 点击 'Edit' 按钮后: ")
|
| 504 |
+
await asyncio.sleep(0.3)
|
| 505 |
+
check_client_disconnected("编辑响应 - 点击 'Edit' 按钮后延时后: ")
|
| 506 |
+
|
| 507 |
+
logger.info(f"[{req_id}] - 从文本区域获取内容...")
|
| 508 |
+
response_content = None
|
| 509 |
+
textarea_failed = False
|
| 510 |
+
|
| 511 |
+
try:
|
| 512 |
+
await expect_async(autosize_textarea_locator).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 513 |
+
check_client_disconnected("编辑响应 - autosize-textarea 可见后: ")
|
| 514 |
+
|
| 515 |
+
try:
|
| 516 |
+
data_value_content = await autosize_textarea_locator.get_attribute("data-value")
|
| 517 |
+
check_client_disconnected("编辑响应 - get_attribute data-value 后: ")
|
| 518 |
+
if data_value_content is not None:
|
| 519 |
+
response_content = str(data_value_content)
|
| 520 |
+
logger.info(f"[{req_id}] - 从 data-value 获取内容成功。")
|
| 521 |
+
except Exception as data_val_err:
|
| 522 |
+
logger.warning(f"[{req_id}] - 获取 data-value 失败: {data_val_err}")
|
| 523 |
+
check_client_disconnected("编辑响应 - get_attribute data-value 错误后: ")
|
| 524 |
+
|
| 525 |
+
if response_content is None:
|
| 526 |
+
logger.info(f"[{req_id}] - data-value 获取失败或为None,尝试从内部 textarea 获取 input_value...")
|
| 527 |
+
try:
|
| 528 |
+
await expect_async(actual_textarea_locator).to_be_visible(timeout=CLICK_TIMEOUT_MS/2)
|
| 529 |
+
input_val_content = await actual_textarea_locator.input_value(timeout=CLICK_TIMEOUT_MS/2)
|
| 530 |
+
check_client_disconnected("编辑响应 - input_value 后: ")
|
| 531 |
+
if input_val_content is not None:
|
| 532 |
+
response_content = str(input_val_content)
|
| 533 |
+
logger.info(f"[{req_id}] - 从 input_value 获取内容成功。")
|
| 534 |
+
except Exception as input_val_err:
|
| 535 |
+
logger.warning(f"[{req_id}] - 获取 input_value 也失败: {input_val_err}")
|
| 536 |
+
check_client_disconnected("编辑响应 - input_value 错误后: ")
|
| 537 |
+
|
| 538 |
+
if response_content is not None:
|
| 539 |
+
response_content = response_content.strip()
|
| 540 |
+
content_preview = response_content[:100].replace('\\n', '\\\\n')
|
| 541 |
+
logger.info(f"[{req_id}] - ✅ 最终获取内容 (长度={len(response_content)}): '{content_preview}...'")
|
| 542 |
+
else:
|
| 543 |
+
logger.warning(f"[{req_id}] - 所有方法 (data-value, input_value) 内容获取均失败或返回 None。")
|
| 544 |
+
textarea_failed = True
|
| 545 |
+
|
| 546 |
+
except Exception as textarea_err:
|
| 547 |
+
logger.error(f"[{req_id}] - 定位或处理文本区域时失败: {textarea_err}")
|
| 548 |
+
textarea_failed = True
|
| 549 |
+
response_content = None
|
| 550 |
+
check_client_disconnected("编辑响应 - 获取文本区域错误后: ")
|
| 551 |
+
|
| 552 |
+
if not textarea_failed:
|
| 553 |
+
logger.info(f"[{req_id}] - 定位并点击 'Stop editing' 按钮...")
|
| 554 |
+
try:
|
| 555 |
+
await expect_async(finish_edit_button).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 556 |
+
check_client_disconnected("编辑响应 - 'Stop editing' 按钮可见后: ")
|
| 557 |
+
await finish_edit_button.click(timeout=CLICK_TIMEOUT_MS)
|
| 558 |
+
logger.info(f"[{req_id}] - 'Stop editing' 按钮已点击。")
|
| 559 |
+
except Exception as finish_btn_err:
|
| 560 |
+
logger.warning(f"[{req_id}] - 'Stop editing' 按钮不可见或点击失败: {finish_btn_err}")
|
| 561 |
+
await save_error_snapshot(f"edit_response_finish_button_failed_{req_id}")
|
| 562 |
+
check_client_disconnected("编辑响应 - 点击 'Stop editing' 后: ")
|
| 563 |
+
await asyncio.sleep(0.2)
|
| 564 |
+
check_client_disconnected("编辑响应 - 点击 'Stop editing' 后延时后: ")
|
| 565 |
+
else:
|
| 566 |
+
logger.info(f"[{req_id}] - 跳过点击 'Stop editing' 按钮,因为文本区域读取失败。")
|
| 567 |
+
|
| 568 |
+
return response_content
|
| 569 |
+
|
| 570 |
+
except ClientDisconnectedError:
|
| 571 |
+
logger.info(f"[{req_id}] (Helper Edit) 客户端断开连接。")
|
| 572 |
+
raise
|
| 573 |
+
except Exception as e:
|
| 574 |
+
logger.exception(f"[{req_id}] 通过编辑按钮获取响应过程中发生意外错误")
|
| 575 |
+
await save_error_snapshot(f"edit_response_unexpected_error_{req_id}")
|
| 576 |
+
return None
|
| 577 |
+
|
| 578 |
+
async def get_response_via_copy_button(
|
| 579 |
+
page: AsyncPage,
|
| 580 |
+
req_id: str,
|
| 581 |
+
check_client_disconnected: Callable
|
| 582 |
+
) -> Optional[str]:
|
| 583 |
+
"""通过复制按钮获取响应"""
|
| 584 |
+
logger.info(f"[{req_id}] (Helper) 尝试通过复制按钮获取响应...")
|
| 585 |
+
last_message_container = page.locator('ms-chat-turn').last
|
| 586 |
+
more_options_button = last_message_container.get_by_label("Open options")
|
| 587 |
+
copy_markdown_button = page.get_by_role("menuitem", name="Copy markdown")
|
| 588 |
+
|
| 589 |
+
try:
|
| 590 |
+
logger.info(f"[{req_id}] - 尝试悬停最后一条消息以显示选项...")
|
| 591 |
+
await last_message_container.hover(timeout=CLICK_TIMEOUT_MS)
|
| 592 |
+
check_client_disconnected("复制响应 - 悬停后: ")
|
| 593 |
+
await asyncio.sleep(0.5)
|
| 594 |
+
check_client_disconnected("复制响应 - 悬停后延时后: ")
|
| 595 |
+
logger.info(f"[{req_id}] - 已悬停。")
|
| 596 |
+
|
| 597 |
+
logger.info(f"[{req_id}] - 定位并点击 '更多选项' 按钮...")
|
| 598 |
+
try:
|
| 599 |
+
from playwright.async_api import expect as expect_async
|
| 600 |
+
await expect_async(more_options_button).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 601 |
+
check_client_disconnected("复制响应 - 更多选项按钮可见后: ")
|
| 602 |
+
await more_options_button.click(timeout=CLICK_TIMEOUT_MS)
|
| 603 |
+
logger.info(f"[{req_id}] - '更多选项' 已点击 (通过 get_by_label)。")
|
| 604 |
+
except Exception as more_opts_err:
|
| 605 |
+
logger.error(f"[{req_id}] - '更多选项' 按钮 (通过 get_by_label) 不可见或点击失败: {more_opts_err}")
|
| 606 |
+
await save_error_snapshot(f"copy_response_more_options_failed_{req_id}")
|
| 607 |
+
return None
|
| 608 |
+
|
| 609 |
+
check_client_disconnected("复制响应 - 点击更多选项后: ")
|
| 610 |
+
await asyncio.sleep(0.5)
|
| 611 |
+
check_client_disconnected("复制响应 - 点击更多选项后延时后: ")
|
| 612 |
+
|
| 613 |
+
logger.info(f"[{req_id}] - 定位并点击 '复制 Markdown' 按钮...")
|
| 614 |
+
copy_success = False
|
| 615 |
+
try:
|
| 616 |
+
await expect_async(copy_markdown_button).to_be_visible(timeout=CLICK_TIMEOUT_MS)
|
| 617 |
+
check_client_disconnected("复制响应 - 复制按钮可见后: ")
|
| 618 |
+
await copy_markdown_button.click(timeout=CLICK_TIMEOUT_MS, force=True)
|
| 619 |
+
copy_success = True
|
| 620 |
+
logger.info(f"[{req_id}] - 已点击 '复制 Markdown' (通过 get_by_role)。")
|
| 621 |
+
except Exception as copy_err:
|
| 622 |
+
logger.error(f"[{req_id}] - '复制 Markdown' 按钮 (通过 get_by_role) 点击失败: {copy_err}")
|
| 623 |
+
await save_error_snapshot(f"copy_response_copy_button_failed_{req_id}")
|
| 624 |
+
return None
|
| 625 |
+
|
| 626 |
+
if not copy_success:
|
| 627 |
+
logger.error(f"[{req_id}] - 未能点击 '复制 Markdown' 按钮。")
|
| 628 |
+
return None
|
| 629 |
+
|
| 630 |
+
check_client_disconnected("复制响应 - 点击复制按钮后: ")
|
| 631 |
+
await asyncio.sleep(0.5)
|
| 632 |
+
check_client_disconnected("复制响应 - 点击复制按钮后延时后: ")
|
| 633 |
+
|
| 634 |
+
logger.info(f"[{req_id}] - 正在读取剪贴板内容...")
|
| 635 |
+
try:
|
| 636 |
+
clipboard_content = await page.evaluate('navigator.clipboard.readText()')
|
| 637 |
+
check_client_disconnected("复制响应 - 读取剪贴板后: ")
|
| 638 |
+
if clipboard_content:
|
| 639 |
+
content_preview = clipboard_content[:100].replace('\n', '\\\\n')
|
| 640 |
+
logger.info(f"[{req_id}] - ✅ 成功获取剪贴板内容 (长度={len(clipboard_content)}): '{content_preview}...'")
|
| 641 |
+
return clipboard_content
|
| 642 |
+
else:
|
| 643 |
+
logger.error(f"[{req_id}] - 剪贴板内容为空。")
|
| 644 |
+
return None
|
| 645 |
+
except Exception as clipboard_err:
|
| 646 |
+
if "clipboard-read" in str(clipboard_err):
|
| 647 |
+
logger.error(f"[{req_id}] - 读取剪贴板失败: 可能是权限问题。错误: {clipboard_err}")
|
| 648 |
+
else:
|
| 649 |
+
logger.error(f"[{req_id}] - 读取剪贴板失败: {clipboard_err}")
|
| 650 |
+
await save_error_snapshot(f"copy_response_clipboard_read_failed_{req_id}")
|
| 651 |
+
return None
|
| 652 |
+
|
| 653 |
+
except ClientDisconnectedError:
|
| 654 |
+
logger.info(f"[{req_id}] (Helper Copy) 客户端断开连接。")
|
| 655 |
+
raise
|
| 656 |
+
except Exception as e:
|
| 657 |
+
logger.exception(f"[{req_id}] 复制响应过程中发生意外错误")
|
| 658 |
+
await save_error_snapshot(f"copy_response_unexpected_error_{req_id}")
|
| 659 |
+
return None
|
| 660 |
+
|
| 661 |
+
async def _wait_for_response_completion(
|
| 662 |
+
page: AsyncPage,
|
| 663 |
+
prompt_textarea_locator: Locator,
|
| 664 |
+
submit_button_locator: Locator,
|
| 665 |
+
edit_button_locator: Locator,
|
| 666 |
+
req_id: str,
|
| 667 |
+
check_client_disconnected_func: Callable,
|
| 668 |
+
current_chat_id: Optional[str],
|
| 669 |
+
timeout_ms=RESPONSE_COMPLETION_TIMEOUT,
|
| 670 |
+
initial_wait_ms=INITIAL_WAIT_MS_BEFORE_POLLING
|
| 671 |
+
) -> bool:
|
| 672 |
+
"""等待响应完成"""
|
| 673 |
+
from playwright.async_api import TimeoutError
|
| 674 |
+
|
| 675 |
+
logger.info(f"[{req_id}] (WaitV3) 开始等待响应完成... (超时: {timeout_ms}ms)")
|
| 676 |
+
await asyncio.sleep(initial_wait_ms / 1000) # Initial brief wait
|
| 677 |
+
|
| 678 |
+
start_time = time.time()
|
| 679 |
+
wait_timeout_ms_short = 3000 # 3 seconds for individual element checks
|
| 680 |
+
|
| 681 |
+
consecutive_empty_input_submit_disabled_count = 0
|
| 682 |
+
|
| 683 |
+
while True:
|
| 684 |
+
try:
|
| 685 |
+
check_client_disconnected_func("等待响应完成 - 循环开始")
|
| 686 |
+
except ClientDisconnectedError:
|
| 687 |
+
logger.info(f"[{req_id}] (WaitV3) 客户端断开连接,中止等待。")
|
| 688 |
+
return False
|
| 689 |
+
|
| 690 |
+
current_time_elapsed_ms = (time.time() - start_time) * 1000
|
| 691 |
+
if current_time_elapsed_ms > timeout_ms:
|
| 692 |
+
logger.error(f"[{req_id}] (WaitV3) 等待响应完成超时 ({timeout_ms}ms)。")
|
| 693 |
+
await save_error_snapshot(f"wait_completion_v3_overall_timeout_{req_id}")
|
| 694 |
+
return False
|
| 695 |
+
|
| 696 |
+
try:
|
| 697 |
+
check_client_disconnected_func("等待响应完成 - 超时检查后")
|
| 698 |
+
except ClientDisconnectedError:
|
| 699 |
+
return False
|
| 700 |
+
|
| 701 |
+
# --- 主要条件: 输入框空 & 提交按钮禁用 ---
|
| 702 |
+
is_input_empty = await prompt_textarea_locator.input_value() == ""
|
| 703 |
+
is_submit_disabled = False
|
| 704 |
+
try:
|
| 705 |
+
is_submit_disabled = await submit_button_locator.is_disabled(timeout=wait_timeout_ms_short)
|
| 706 |
+
except TimeoutError:
|
| 707 |
+
logger.warning(f"[{req_id}] (WaitV3) 检查提交按钮是否禁用超时。为本次检查假定其未禁用。")
|
| 708 |
+
|
| 709 |
+
try:
|
| 710 |
+
check_client_disconnected_func("等待响应完成 - 按钮状态检查后")
|
| 711 |
+
except ClientDisconnectedError:
|
| 712 |
+
return False
|
| 713 |
+
|
| 714 |
+
if is_input_empty and is_submit_disabled:
|
| 715 |
+
consecutive_empty_input_submit_disabled_count += 1
|
| 716 |
+
if DEBUG_LOGS_ENABLED:
|
| 717 |
+
logger.debug(f"[{req_id}] (WaitV3) 主要条件满足: 输入框空,提交按钮禁用 (计数: {consecutive_empty_input_submit_disabled_count})。")
|
| 718 |
+
|
| 719 |
+
# --- 最终确认: 编辑按钮可见 ---
|
| 720 |
+
try:
|
| 721 |
+
if await edit_button_locator.is_visible(timeout=wait_timeout_ms_short):
|
| 722 |
+
logger.info(f"[{req_id}] (WaitV3) ✅ 响应完成: 输入框空,提交按钮禁用,编辑按钮可见。")
|
| 723 |
+
return True # 明确完成
|
| 724 |
+
except TimeoutError:
|
| 725 |
+
if DEBUG_LOGS_ENABLED:
|
| 726 |
+
logger.debug(f"[{req_id}] (WaitV3) 主要条件满足���,检查编辑按钮可见性超时。")
|
| 727 |
+
|
| 728 |
+
try:
|
| 729 |
+
check_client_disconnected_func("等待响应完成 - 编辑按钮检查后")
|
| 730 |
+
except ClientDisconnectedError:
|
| 731 |
+
return False
|
| 732 |
+
|
| 733 |
+
# 启发式完成: 如果主要条件持续满足,但编辑按钮仍未出现
|
| 734 |
+
if consecutive_empty_input_submit_disabled_count >= 3: # 例如,大约 1.5秒 (3 * 0.5秒轮询)
|
| 735 |
+
logger.warning(f"[{req_id}] (WaitV3) 响应可能已完成 (启发式): 输入框空,提交按钮禁用,但在 {consecutive_empty_input_submit_disabled_count} 次检查后编辑按钮仍未出现。假定完成。后续若内容获取失败,可能与此有关。")
|
| 736 |
+
return True # 启发式完成
|
| 737 |
+
else: # 主要条件 (输入框空 & 提交按钮禁用) 未满足
|
| 738 |
+
consecutive_empty_input_submit_disabled_count = 0 # 重置计数器
|
| 739 |
+
if DEBUG_LOGS_ENABLED:
|
| 740 |
+
reasons = []
|
| 741 |
+
if not is_input_empty:
|
| 742 |
+
reasons.append("输入框非空")
|
| 743 |
+
if not is_submit_disabled:
|
| 744 |
+
reasons.append("提交按钮非禁用")
|
| 745 |
+
logger.debug(f"[{req_id}] (WaitV3) 主要条件未满足 ({', '.join(reasons)}). 继续轮询...")
|
| 746 |
+
|
| 747 |
+
await asyncio.sleep(0.5) # 轮询间隔
|
| 748 |
+
|
| 749 |
+
async def _get_final_response_content(
|
| 750 |
+
page: AsyncPage,
|
| 751 |
+
req_id: str,
|
| 752 |
+
check_client_disconnected: Callable
|
| 753 |
+
) -> Optional[str]:
|
| 754 |
+
"""获取最终响应内容"""
|
| 755 |
+
logger.info(f"[{req_id}] (Helper GetContent) 开始获取最终响应内容...")
|
| 756 |
+
response_content = await get_response_via_edit_button(
|
| 757 |
+
page, req_id, check_client_disconnected
|
| 758 |
+
)
|
| 759 |
+
if response_content is not None:
|
| 760 |
+
logger.info(f"[{req_id}] (Helper GetContent) ✅ 成功通过编辑按钮获取内容。")
|
| 761 |
+
return response_content
|
| 762 |
+
|
| 763 |
+
logger.warning(f"[{req_id}] (Helper GetContent) 编辑按钮方法失败或返回空,回退到复制按钮方法...")
|
| 764 |
+
response_content = await get_response_via_copy_button(
|
| 765 |
+
page, req_id, check_client_disconnected
|
| 766 |
+
)
|
| 767 |
+
if response_content is not None:
|
| 768 |
+
logger.info(f"[{req_id}] (Helper GetContent) ✅ 成功通过复制按钮获取内容。")
|
| 769 |
+
return response_content
|
| 770 |
+
|
| 771 |
+
logger.error(f"[{req_id}] (Helper GetContent) 所有获取响应内容的方法均失败。")
|
| 772 |
+
await save_error_snapshot(f"get_content_all_methods_failed_{req_id}")
|
| 773 |
+
return None
|
browser_utils/page_controller.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PageController模块
|
| 3 |
+
封装了所有与Playwright页面直接交互的复杂逻辑。
|
| 4 |
+
"""
|
| 5 |
+
import asyncio
|
| 6 |
+
from typing import Callable, List, Dict, Any
|
| 7 |
+
|
| 8 |
+
from playwright.async_api import Page as AsyncPage, expect as expect_async, TimeoutError
|
| 9 |
+
|
| 10 |
+
from config import (
|
| 11 |
+
TEMPERATURE_INPUT_SELECTOR, MAX_OUTPUT_TOKENS_SELECTOR, STOP_SEQUENCE_INPUT_SELECTOR,
|
| 12 |
+
MAT_CHIP_REMOVE_BUTTON_SELECTOR, TOP_P_INPUT_SELECTOR, SUBMIT_BUTTON_SELECTOR,
|
| 13 |
+
CLEAR_CHAT_BUTTON_SELECTOR, CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR, OVERLAY_SELECTOR,
|
| 14 |
+
PROMPT_TEXTAREA_SELECTOR, RESPONSE_CONTAINER_SELECTOR, RESPONSE_TEXT_SELECTOR,
|
| 15 |
+
EDIT_MESSAGE_BUTTON_SELECTOR
|
| 16 |
+
)
|
| 17 |
+
from config import (
|
| 18 |
+
CLICK_TIMEOUT_MS, WAIT_FOR_ELEMENT_TIMEOUT_MS, CLEAR_CHAT_VERIFY_TIMEOUT_MS,
|
| 19 |
+
DEFAULT_TEMPERATURE, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_STOP_SEQUENCES, DEFAULT_TOP_P
|
| 20 |
+
)
|
| 21 |
+
from models import ClientDisconnectedError
|
| 22 |
+
from .operations import save_error_snapshot, _wait_for_response_completion, _get_final_response_content
|
| 23 |
+
|
| 24 |
+
class PageController:
|
| 25 |
+
"""封装了与AI Studio页面交互的所有操作。"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, page: AsyncPage, logger, req_id: str):
|
| 28 |
+
self.page = page
|
| 29 |
+
self.logger = logger
|
| 30 |
+
self.req_id = req_id
|
| 31 |
+
|
| 32 |
+
async def _check_disconnect(self, check_client_disconnected: Callable, stage: str):
|
| 33 |
+
"""检查客户端是否断开连接。"""
|
| 34 |
+
if check_client_disconnected(stage):
|
| 35 |
+
raise ClientDisconnectedError(f"[{self.req_id}] Client disconnected at stage: {stage}")
|
| 36 |
+
|
| 37 |
+
async def adjust_parameters(self, request_params: Dict[str, Any], page_params_cache: Dict[str, Any], params_cache_lock: asyncio.Lock, model_id_to_use: str, parsed_model_list: List[Dict[str, Any]], check_client_disconnected: Callable):
|
| 38 |
+
"""调整所有请求参数。"""
|
| 39 |
+
self.logger.info(f"[{self.req_id}] 开始调整所有请求参数...")
|
| 40 |
+
await self._check_disconnect(check_client_disconnected, "Start Parameter Adjustment")
|
| 41 |
+
|
| 42 |
+
# 调整温度
|
| 43 |
+
temp_to_set = request_params.get('temperature', DEFAULT_TEMPERATURE)
|
| 44 |
+
await self._adjust_temperature(temp_to_set, page_params_cache, params_cache_lock, check_client_disconnected)
|
| 45 |
+
await self._check_disconnect(check_client_disconnected, "After Temperature Adjustment")
|
| 46 |
+
|
| 47 |
+
# 调整最大Token
|
| 48 |
+
max_tokens_to_set = request_params.get('max_output_tokens', DEFAULT_MAX_OUTPUT_TOKENS)
|
| 49 |
+
await self._adjust_max_tokens(max_tokens_to_set, page_params_cache, params_cache_lock, model_id_to_use, parsed_model_list, check_client_disconnected)
|
| 50 |
+
await self._check_disconnect(check_client_disconnected, "After Max Tokens Adjustment")
|
| 51 |
+
|
| 52 |
+
# 调整停止序列
|
| 53 |
+
stop_to_set = request_params.get('stop', DEFAULT_STOP_SEQUENCES)
|
| 54 |
+
await self._adjust_stop_sequences(stop_to_set, page_params_cache, params_cache_lock, check_client_disconnected)
|
| 55 |
+
await self._check_disconnect(check_client_disconnected, "After Stop Sequences Adjustment")
|
| 56 |
+
|
| 57 |
+
# 调整Top P
|
| 58 |
+
top_p_to_set = request_params.get('top_p', DEFAULT_TOP_P)
|
| 59 |
+
await self._adjust_top_p(top_p_to_set, check_client_disconnected)
|
| 60 |
+
await self._check_disconnect(check_client_disconnected, "End Parameter Adjustment")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
async def _adjust_temperature(self, temperature: float, page_params_cache: dict, params_cache_lock: asyncio.Lock, check_client_disconnected: Callable):
|
| 64 |
+
"""调整温度参数。"""
|
| 65 |
+
async with params_cache_lock:
|
| 66 |
+
self.logger.info(f"[{self.req_id}] 检查并调整温度设置...")
|
| 67 |
+
clamped_temp = max(0.0, min(2.0, temperature))
|
| 68 |
+
if clamped_temp != temperature:
|
| 69 |
+
self.logger.warning(f"[{self.req_id}] 请求的温度 {temperature} 超出范围 [0, 2],已调整为 {clamped_temp}")
|
| 70 |
+
|
| 71 |
+
cached_temp = page_params_cache.get("temperature")
|
| 72 |
+
if cached_temp is not None and abs(cached_temp - clamped_temp) < 0.001:
|
| 73 |
+
self.logger.info(f"[{self.req_id}] 温度 ({clamped_temp}) 与缓存值 ({cached_temp}) 一致。跳过页面交互。")
|
| 74 |
+
return
|
| 75 |
+
|
| 76 |
+
self.logger.info(f"[{self.req_id}] 请求温度 ({clamped_temp}) 与缓存值 ({cached_temp}) 不一致或缓存中无值。需要与页面交互。")
|
| 77 |
+
temp_input_locator = self.page.locator(TEMPERATURE_INPUT_SELECTOR)
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
await expect_async(temp_input_locator).to_be_visible(timeout=5000)
|
| 81 |
+
await self._check_disconnect(check_client_disconnected, "温度调整 - 输入框可见后")
|
| 82 |
+
|
| 83 |
+
current_temp_str = await temp_input_locator.input_value(timeout=3000)
|
| 84 |
+
await self._check_disconnect(check_client_disconnected, "温度调整 - 读取输入框值后")
|
| 85 |
+
|
| 86 |
+
current_temp_float = float(current_temp_str)
|
| 87 |
+
self.logger.info(f"[{self.req_id}] 页面当前温度: {current_temp_float}, 请求调整后温度: {clamped_temp}")
|
| 88 |
+
|
| 89 |
+
if abs(current_temp_float - clamped_temp) < 0.001:
|
| 90 |
+
self.logger.info(f"[{self.req_id}] 页面当前温度 ({current_temp_float}) 与请求温度 ({clamped_temp}) 一致。更新缓存并跳过写入。")
|
| 91 |
+
page_params_cache["temperature"] = current_temp_float
|
| 92 |
+
else:
|
| 93 |
+
self.logger.info(f"[{self.req_id}] 页面温度 ({current_temp_float}) 与请求温度 ({clamped_temp}) 不同,正在更新...")
|
| 94 |
+
await temp_input_locator.fill(str(clamped_temp), timeout=5000)
|
| 95 |
+
await self._check_disconnect(check_client_disconnected, "温度调整 - 填充输入框后")
|
| 96 |
+
|
| 97 |
+
await asyncio.sleep(0.1)
|
| 98 |
+
new_temp_str = await temp_input_locator.input_value(timeout=3000)
|
| 99 |
+
new_temp_float = float(new_temp_str)
|
| 100 |
+
|
| 101 |
+
if abs(new_temp_float - clamped_temp) < 0.001:
|
| 102 |
+
self.logger.info(f"[{self.req_id}] ✅ 温度已成功更新为: {new_temp_float}。更新缓存。")
|
| 103 |
+
page_params_cache["temperature"] = new_temp_float
|
| 104 |
+
else:
|
| 105 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 温度更新后验证失败。页面显示: {new_temp_float}, 期望: {clamped_temp}。清除缓存中的温度。")
|
| 106 |
+
page_params_cache.pop("temperature", None)
|
| 107 |
+
await save_error_snapshot(f"temperature_verify_fail_{self.req_id}")
|
| 108 |
+
|
| 109 |
+
except ValueError as ve:
|
| 110 |
+
self.logger.error(f"[{self.req_id}] 转换温度值为浮点数时出错. 错误: {ve}。清除缓存中的温度。")
|
| 111 |
+
page_params_cache.pop("temperature", None)
|
| 112 |
+
await save_error_snapshot(f"temperature_value_error_{self.req_id}")
|
| 113 |
+
except Exception as pw_err:
|
| 114 |
+
self.logger.error(f"[{self.req_id}] ❌ 操作温度输入框时发生错误: {pw_err}。清除缓存中的温度。")
|
| 115 |
+
page_params_cache.pop("temperature", None)
|
| 116 |
+
await save_error_snapshot(f"temperature_playwright_error_{self.req_id}")
|
| 117 |
+
if isinstance(pw_err, ClientDisconnectedError):
|
| 118 |
+
raise
|
| 119 |
+
|
| 120 |
+
async def _adjust_max_tokens(self, max_tokens: int, page_params_cache: dict, params_cache_lock: asyncio.Lock, model_id_to_use: str, parsed_model_list: list, check_client_disconnected: Callable):
|
| 121 |
+
"""调整最大输出Token参数。"""
|
| 122 |
+
async with params_cache_lock:
|
| 123 |
+
self.logger.info(f"[{self.req_id}] 检查并调整最大输出 Token 设置...")
|
| 124 |
+
min_val_for_tokens = 1
|
| 125 |
+
max_val_for_tokens_from_model = 65536
|
| 126 |
+
|
| 127 |
+
if model_id_to_use and parsed_model_list:
|
| 128 |
+
current_model_data = next((m for m in parsed_model_list if m.get("id") == model_id_to_use), None)
|
| 129 |
+
if current_model_data and current_model_data.get("supported_max_output_tokens") is not None:
|
| 130 |
+
try:
|
| 131 |
+
supported_tokens = int(current_model_data["supported_max_output_tokens"])
|
| 132 |
+
if supported_tokens > 0:
|
| 133 |
+
max_val_for_tokens_from_model = supported_tokens
|
| 134 |
+
else:
|
| 135 |
+
self.logger.warning(f"[{self.req_id}] 模型 {model_id_to_use} supported_max_output_tokens 无效: {supported_tokens}")
|
| 136 |
+
except (ValueError, TypeError):
|
| 137 |
+
self.logger.warning(f"[{self.req_id}] 模型 {model_id_to_use} supported_max_output_tokens 解析失败")
|
| 138 |
+
|
| 139 |
+
clamped_max_tokens = max(min_val_for_tokens, min(max_val_for_tokens_from_model, max_tokens))
|
| 140 |
+
if clamped_max_tokens != max_tokens:
|
| 141 |
+
self.logger.warning(f"[{self.req_id}] 请求的最大输出 Tokens {max_tokens} 超出模型范围,已调整为 {clamped_max_tokens}")
|
| 142 |
+
|
| 143 |
+
cached_max_tokens = page_params_cache.get("max_output_tokens")
|
| 144 |
+
if cached_max_tokens is not None and cached_max_tokens == clamped_max_tokens:
|
| 145 |
+
self.logger.info(f"[{self.req_id}] 最大输出 Tokens ({clamped_max_tokens}) 与缓存值一致。跳过页面交互。")
|
| 146 |
+
return
|
| 147 |
+
|
| 148 |
+
max_tokens_input_locator = self.page.locator(MAX_OUTPUT_TOKENS_SELECTOR)
|
| 149 |
+
|
| 150 |
+
try:
|
| 151 |
+
await expect_async(max_tokens_input_locator).to_be_visible(timeout=5000)
|
| 152 |
+
await self._check_disconnect(check_client_disconnected, "最大输出Token调整 - 输入框可见后")
|
| 153 |
+
|
| 154 |
+
current_max_tokens_str = await max_tokens_input_locator.input_value(timeout=3000)
|
| 155 |
+
current_max_tokens_int = int(current_max_tokens_str)
|
| 156 |
+
|
| 157 |
+
if current_max_tokens_int == clamped_max_tokens:
|
| 158 |
+
self.logger.info(f"[{self.req_id}] 页面当前最大输出 Tokens ({current_max_tokens_int}) 与请求值 ({clamped_max_tokens}) 一致。更新缓存并跳过写入。")
|
| 159 |
+
page_params_cache["max_output_tokens"] = current_max_tokens_int
|
| 160 |
+
else:
|
| 161 |
+
self.logger.info(f"[{self.req_id}] 页面最大输出 Tokens ({current_max_tokens_int}) 与请求值 ({clamped_max_tokens}) 不同,正在更新...")
|
| 162 |
+
await max_tokens_input_locator.fill(str(clamped_max_tokens), timeout=5000)
|
| 163 |
+
await self._check_disconnect(check_client_disconnected, "最大输出Token调整 - 填充输入框后")
|
| 164 |
+
|
| 165 |
+
await asyncio.sleep(0.1)
|
| 166 |
+
new_max_tokens_str = await max_tokens_input_locator.input_value(timeout=3000)
|
| 167 |
+
new_max_tokens_int = int(new_max_tokens_str)
|
| 168 |
+
|
| 169 |
+
if new_max_tokens_int == clamped_max_tokens:
|
| 170 |
+
self.logger.info(f"[{self.req_id}] ✅ 最大输出 Tokens 已成功更新为: {new_max_tokens_int}")
|
| 171 |
+
page_params_cache["max_output_tokens"] = new_max_tokens_int
|
| 172 |
+
else:
|
| 173 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 最大输出 Tokens 更新后验证失败。页面显示: {new_max_tokens_int}, 期望: {clamped_max_tokens}。清除缓存。")
|
| 174 |
+
page_params_cache.pop("max_output_tokens", None)
|
| 175 |
+
await save_error_snapshot(f"max_tokens_verify_fail_{self.req_id}")
|
| 176 |
+
|
| 177 |
+
except (ValueError, TypeError) as ve:
|
| 178 |
+
self.logger.error(f"[{self.req_id}] 转换最大输出 Tokens 值时出错: {ve}。清除缓存。")
|
| 179 |
+
page_params_cache.pop("max_output_tokens", None)
|
| 180 |
+
await save_error_snapshot(f"max_tokens_value_error_{self.req_id}")
|
| 181 |
+
except Exception as e:
|
| 182 |
+
self.logger.error(f"[{self.req_id}] ❌ 调整最大输出 Tokens 时出错: {e}。清除缓存。")
|
| 183 |
+
page_params_cache.pop("max_output_tokens", None)
|
| 184 |
+
await save_error_snapshot(f"max_tokens_error_{self.req_id}")
|
| 185 |
+
if isinstance(e, ClientDisconnectedError):
|
| 186 |
+
raise
|
| 187 |
+
|
| 188 |
+
async def _adjust_stop_sequences(self, stop_sequences, page_params_cache: dict, params_cache_lock: asyncio.Lock, check_client_disconnected: Callable):
|
| 189 |
+
"""调整停止序列参数。"""
|
| 190 |
+
async with params_cache_lock:
|
| 191 |
+
self.logger.info(f"[{self.req_id}] 检查并设置停止序列...")
|
| 192 |
+
|
| 193 |
+
# 处理不同类型的stop_sequences输入
|
| 194 |
+
normalized_requested_stops = set()
|
| 195 |
+
if stop_sequences is not None:
|
| 196 |
+
if isinstance(stop_sequences, str):
|
| 197 |
+
# 单个字符串
|
| 198 |
+
if stop_sequences.strip():
|
| 199 |
+
normalized_requested_stops.add(stop_sequences.strip())
|
| 200 |
+
elif isinstance(stop_sequences, list):
|
| 201 |
+
# 字符串列表
|
| 202 |
+
for s in stop_sequences:
|
| 203 |
+
if isinstance(s, str) and s.strip():
|
| 204 |
+
normalized_requested_stops.add(s.strip())
|
| 205 |
+
|
| 206 |
+
cached_stops_set = page_params_cache.get("stop_sequences")
|
| 207 |
+
|
| 208 |
+
if cached_stops_set is not None and cached_stops_set == normalized_requested_stops:
|
| 209 |
+
self.logger.info(f"[{self.req_id}] 请求的停止序列与缓存值一致。跳过页面交互。")
|
| 210 |
+
return
|
| 211 |
+
|
| 212 |
+
stop_input_locator = self.page.locator(STOP_SEQUENCE_INPUT_SELECTOR)
|
| 213 |
+
remove_chip_buttons_locator = self.page.locator(MAT_CHIP_REMOVE_BUTTON_SELECTOR)
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
# 清空已有的停止序列
|
| 217 |
+
initial_chip_count = await remove_chip_buttons_locator.count()
|
| 218 |
+
removed_count = 0
|
| 219 |
+
max_removals = initial_chip_count + 5
|
| 220 |
+
|
| 221 |
+
while await remove_chip_buttons_locator.count() > 0 and removed_count < max_removals:
|
| 222 |
+
await self._check_disconnect(check_client_disconnected, "停止序列清除 - 循环开始")
|
| 223 |
+
try:
|
| 224 |
+
await remove_chip_buttons_locator.first.click(timeout=2000)
|
| 225 |
+
removed_count += 1
|
| 226 |
+
await asyncio.sleep(0.15)
|
| 227 |
+
except Exception:
|
| 228 |
+
break
|
| 229 |
+
|
| 230 |
+
# 添加新的停止序列
|
| 231 |
+
if normalized_requested_stops:
|
| 232 |
+
await expect_async(stop_input_locator).to_be_visible(timeout=5000)
|
| 233 |
+
for seq in normalized_requested_stops:
|
| 234 |
+
await stop_input_locator.fill(seq, timeout=3000)
|
| 235 |
+
await stop_input_locator.press("Enter", timeout=3000)
|
| 236 |
+
await asyncio.sleep(0.2)
|
| 237 |
+
|
| 238 |
+
page_params_cache["stop_sequences"] = normalized_requested_stops
|
| 239 |
+
self.logger.info(f"[{self.req_id}] ✅ 停止序列已成功设置。缓存已更新。")
|
| 240 |
+
|
| 241 |
+
except Exception as e:
|
| 242 |
+
self.logger.error(f"[{self.req_id}] ❌ 设置停止序列时出错: {e}")
|
| 243 |
+
page_params_cache.pop("stop_sequences", None)
|
| 244 |
+
await save_error_snapshot(f"stop_sequence_error_{self.req_id}")
|
| 245 |
+
if isinstance(e, ClientDisconnectedError):
|
| 246 |
+
raise
|
| 247 |
+
|
| 248 |
+
async def _adjust_top_p(self, top_p: float, check_client_disconnected: Callable):
|
| 249 |
+
"""调整Top P参数。"""
|
| 250 |
+
self.logger.info(f"[{self.req_id}] 检查并调整 Top P 设置...")
|
| 251 |
+
clamped_top_p = max(0.0, min(1.0, top_p))
|
| 252 |
+
|
| 253 |
+
if abs(clamped_top_p - top_p) > 1e-9:
|
| 254 |
+
self.logger.warning(f"[{self.req_id}] 请求的 Top P {top_p} 超出范围 [0, 1],已调整为 {clamped_top_p}")
|
| 255 |
+
|
| 256 |
+
top_p_input_locator = self.page.locator(TOP_P_INPUT_SELECTOR)
|
| 257 |
+
try:
|
| 258 |
+
await expect_async(top_p_input_locator).to_be_visible(timeout=5000)
|
| 259 |
+
await self._check_disconnect(check_client_disconnected, "Top P 调整 - 输入框可见后")
|
| 260 |
+
|
| 261 |
+
current_top_p_str = await top_p_input_locator.input_value(timeout=3000)
|
| 262 |
+
current_top_p_float = float(current_top_p_str)
|
| 263 |
+
|
| 264 |
+
if abs(current_top_p_float - clamped_top_p) > 1e-9:
|
| 265 |
+
self.logger.info(f"[{self.req_id}] 页面 Top P ({current_top_p_float}) 与请求值 ({clamped_top_p}) 不同,正在更新...")
|
| 266 |
+
await top_p_input_locator.fill(str(clamped_top_p), timeout=5000)
|
| 267 |
+
await self._check_disconnect(check_client_disconnected, "Top P 调整 - 填充输入框后")
|
| 268 |
+
|
| 269 |
+
# 验证设置是否成功
|
| 270 |
+
await asyncio.sleep(0.1)
|
| 271 |
+
new_top_p_str = await top_p_input_locator.input_value(timeout=3000)
|
| 272 |
+
new_top_p_float = float(new_top_p_str)
|
| 273 |
+
|
| 274 |
+
if abs(new_top_p_float - clamped_top_p) <= 1e-9:
|
| 275 |
+
self.logger.info(f"[{self.req_id}] ✅ Top P 已成功更新为: {new_top_p_float}")
|
| 276 |
+
else:
|
| 277 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ Top P 更新后验证失败。页面显示: {new_top_p_float}, 期望: {clamped_top_p}")
|
| 278 |
+
await save_error_snapshot(f"top_p_verify_fail_{self.req_id}")
|
| 279 |
+
else:
|
| 280 |
+
self.logger.info(f"[{self.req_id}] 页面 Top P ({current_top_p_float}) 与请求值 ({clamped_top_p}) 一致,无需更改")
|
| 281 |
+
|
| 282 |
+
except (ValueError, TypeError) as ve:
|
| 283 |
+
self.logger.error(f"[{self.req_id}] 转换 Top P 值时出错: {ve}")
|
| 284 |
+
await save_error_snapshot(f"top_p_value_error_{self.req_id}")
|
| 285 |
+
except Exception as e:
|
| 286 |
+
self.logger.error(f"[{self.req_id}] ❌ 调整 Top P 时出错: {e}")
|
| 287 |
+
await save_error_snapshot(f"top_p_error_{self.req_id}")
|
| 288 |
+
if isinstance(e, ClientDisconnectedError):
|
| 289 |
+
raise
|
| 290 |
+
|
| 291 |
+
async def clear_chat_history(self, check_client_disconnected: Callable):
|
| 292 |
+
"""清空聊天记录。"""
|
| 293 |
+
self.logger.info(f"[{self.req_id}] 开始清空聊天记录...")
|
| 294 |
+
await self._check_disconnect(check_client_disconnected, "Start Clear Chat")
|
| 295 |
+
|
| 296 |
+
try:
|
| 297 |
+
# 一般是使用流式代理时遇到,流式输出已结束,但页面上AI仍回复个不停,此时会锁住清空按钮,但页面仍是/new_chat,而跳过后续清空操作
|
| 298 |
+
# 导致后续请求无法发出而卡住,故先检查并点击发送按钮(此时是停止功能)
|
| 299 |
+
submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR)
|
| 300 |
+
try:
|
| 301 |
+
self.logger.info(f"[{self.req_id}] 尝试检查发送按钮状态...")
|
| 302 |
+
# 使用较短的超时时间(1秒),避免长时间阻塞,因为这不是清空流程的常见步骤
|
| 303 |
+
await expect_async(submit_button_locator).to_be_enabled(timeout=1000)
|
| 304 |
+
self.logger.info(f"[{self.req_id}] 发送按钮可用,尝试点击并等待1秒...")
|
| 305 |
+
await submit_button_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 306 |
+
await asyncio.sleep(1.0)
|
| 307 |
+
self.logger.info(f"[{self.req_id}] 发送按钮点击并等待完成。")
|
| 308 |
+
except Exception as e_submit:
|
| 309 |
+
# 如果发送按钮不可用、超时或发生Playwright相关错误,记录日志并继续
|
| 310 |
+
self.logger.info(f"[{self.req_id}] 发送按钮不可用或检查/点击时发生Playwright错误。符合预期,继续检查清空按钮。")
|
| 311 |
+
|
| 312 |
+
clear_chat_button_locator = self.page.locator(CLEAR_CHAT_BUTTON_SELECTOR)
|
| 313 |
+
confirm_button_locator = self.page.locator(CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR)
|
| 314 |
+
overlay_locator = self.page.locator(OVERLAY_SELECTOR)
|
| 315 |
+
|
| 316 |
+
can_attempt_clear = False
|
| 317 |
+
try:
|
| 318 |
+
await expect_async(clear_chat_button_locator).to_be_enabled(timeout=3000)
|
| 319 |
+
can_attempt_clear = True
|
| 320 |
+
self.logger.info(f"[{self.req_id}] \"清空聊天\"按钮可用,继续清空流程。")
|
| 321 |
+
except Exception as e_enable:
|
| 322 |
+
is_new_chat_url = '/prompts/new_chat' in self.page.url.rstrip('/')
|
| 323 |
+
if is_new_chat_url:
|
| 324 |
+
self.logger.info(f"[{self.req_id}] \"清空聊天\"按钮不可用 (预期,因为在 new_chat 页面)。跳过清空操作。")
|
| 325 |
+
else:
|
| 326 |
+
self.logger.warning(f"[{self.req_id}] 等待\"清空聊天\"按钮可用失败: {e_enable}。清空操作可能无法执行。")
|
| 327 |
+
|
| 328 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - \"清空聊天\"按钮可用性检查后")
|
| 329 |
+
|
| 330 |
+
if can_attempt_clear:
|
| 331 |
+
await self._execute_chat_clear(clear_chat_button_locator, confirm_button_locator, overlay_locator, check_client_disconnected)
|
| 332 |
+
await self._verify_chat_cleared(check_client_disconnected)
|
| 333 |
+
|
| 334 |
+
except Exception as e_clear:
|
| 335 |
+
self.logger.error(f"[{self.req_id}] 清空聊天过程中发生错误: {e_clear}")
|
| 336 |
+
if not (isinstance(e_clear, ClientDisconnectedError) or (hasattr(e_clear, 'name') and 'Disconnect' in e_clear.name)):
|
| 337 |
+
await save_error_snapshot(f"clear_chat_error_{self.req_id}")
|
| 338 |
+
raise
|
| 339 |
+
|
| 340 |
+
async def _execute_chat_clear(self, clear_chat_button_locator, confirm_button_locator, overlay_locator, check_client_disconnected: Callable):
|
| 341 |
+
"""执行清空聊天操作"""
|
| 342 |
+
overlay_initially_visible = False
|
| 343 |
+
try:
|
| 344 |
+
if await overlay_locator.is_visible(timeout=1000):
|
| 345 |
+
overlay_initially_visible = True
|
| 346 |
+
self.logger.info(f"[{self.req_id}] 清空聊天确认遮罩层已可见。直接点击\"继续\"。")
|
| 347 |
+
except TimeoutError:
|
| 348 |
+
self.logger.info(f"[{self.req_id}] 清空聊天确认遮罩层初始不可见 (检查超时或未找到)。")
|
| 349 |
+
overlay_initially_visible = False
|
| 350 |
+
except Exception as e_vis_check:
|
| 351 |
+
self.logger.warning(f"[{self.req_id}] 检查遮罩层可见性时发生错误: {e_vis_check}。假定不可见。")
|
| 352 |
+
overlay_initially_visible = False
|
| 353 |
+
|
| 354 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - 初始遮罩层检查后")
|
| 355 |
+
|
| 356 |
+
if overlay_initially_visible:
|
| 357 |
+
self.logger.info(f"[{self.req_id}] 点击\"继续\"按钮 (遮罩层已存在): {CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR}")
|
| 358 |
+
await confirm_button_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 359 |
+
else:
|
| 360 |
+
self.logger.info(f"[{self.req_id}] 点击\"清空聊天\"按钮: {CLEAR_CHAT_BUTTON_SELECTOR}")
|
| 361 |
+
await clear_chat_button_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 362 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - 点击\"清空聊天\"后")
|
| 363 |
+
|
| 364 |
+
try:
|
| 365 |
+
self.logger.info(f"[{self.req_id}] 等待清空聊天确认遮罩层出现: {OVERLAY_SELECTOR}")
|
| 366 |
+
await expect_async(overlay_locator).to_be_visible(timeout=WAIT_FOR_ELEMENT_TIMEOUT_MS)
|
| 367 |
+
self.logger.info(f"[{self.req_id}] 清空聊天确认遮罩层已出现。")
|
| 368 |
+
except TimeoutError:
|
| 369 |
+
error_msg = f"等待清空聊天确认遮罩层超时 (点击清空按钮后)。请求 ID: {self.req_id}"
|
| 370 |
+
self.logger.error(error_msg)
|
| 371 |
+
await save_error_snapshot(f"clear_chat_overlay_timeout_{self.req_id}")
|
| 372 |
+
raise Exception(error_msg)
|
| 373 |
+
|
| 374 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - 遮罩层出现后")
|
| 375 |
+
self.logger.info(f"[{self.req_id}] 点击\"继续\"按钮 (在对话框中): {CLEAR_CHAT_CONFIRM_BUTTON_SELECTOR}")
|
| 376 |
+
await confirm_button_locator.click(timeout=CLICK_TIMEOUT_MS)
|
| 377 |
+
|
| 378 |
+
await self._check_disconnect(check_client_disconnected, "清空聊天 - 点击\"继续\"后")
|
| 379 |
+
|
| 380 |
+
# 等待对话框消失
|
| 381 |
+
max_retries_disappear = 3
|
| 382 |
+
for attempt_disappear in range(max_retries_disappear):
|
| 383 |
+
try:
|
| 384 |
+
self.logger.info(f"[{self.req_id}] 等待清空聊天确认按钮/对话框消失 (尝试 {attempt_disappear + 1}/{max_retries_disappear})...")
|
| 385 |
+
await expect_async(confirm_button_locator).to_be_hidden(timeout=CLEAR_CHAT_VERIFY_TIMEOUT_MS)
|
| 386 |
+
await expect_async(overlay_locator).to_be_hidden(timeout=1000)
|
| 387 |
+
self.logger.info(f"[{self.req_id}] ✅ 清空聊天确认对话框已成功消失。")
|
| 388 |
+
break
|
| 389 |
+
except TimeoutError:
|
| 390 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 等待清空聊天确认对话框消失超时 (尝试 {attempt_disappear + 1}/{max_retries_disappear})。")
|
| 391 |
+
if attempt_disappear < max_retries_disappear - 1:
|
| 392 |
+
await asyncio.sleep(1.0)
|
| 393 |
+
await self._check_disconnect(check_client_disconnected, f"清空聊天 - 重试消失检查 {attempt_disappear + 1} 前")
|
| 394 |
+
continue
|
| 395 |
+
else:
|
| 396 |
+
error_msg = f"达到最大重试次数。清空聊天确认对话框未消失。请求 ID: {self.req_id}"
|
| 397 |
+
self.logger.error(error_msg)
|
| 398 |
+
await save_error_snapshot(f"clear_chat_dialog_disappear_timeout_{self.req_id}")
|
| 399 |
+
raise Exception(error_msg)
|
| 400 |
+
except ClientDisconnectedError:
|
| 401 |
+
self.logger.info(f"[{self.req_id}] 客户端在等待清空确认对话框消失时断开连接。")
|
| 402 |
+
raise
|
| 403 |
+
except Exception as other_err:
|
| 404 |
+
self.logger.warning(f"[{self.req_id}] 等待清空确认对话框消失时发生其他错误: {other_err}")
|
| 405 |
+
if attempt_disappear < max_retries_disappear - 1:
|
| 406 |
+
await asyncio.sleep(1.0)
|
| 407 |
+
continue
|
| 408 |
+
else:
|
| 409 |
+
raise
|
| 410 |
+
|
| 411 |
+
await self._check_disconnect(check_client_disconnected, f"清空聊天 - 消失检查尝试 {attempt_disappear + 1} 后")
|
| 412 |
+
|
| 413 |
+
async def _verify_chat_cleared(self, check_client_disconnected: Callable):
|
| 414 |
+
"""验证聊天已清空"""
|
| 415 |
+
last_response_container = self.page.locator(RESPONSE_CONTAINER_SELECTOR).last
|
| 416 |
+
await asyncio.sleep(0.5)
|
| 417 |
+
await self._check_disconnect(check_client_disconnected, "After Clear Post-Delay")
|
| 418 |
+
try:
|
| 419 |
+
await expect_async(last_response_container).to_be_hidden(timeout=CLEAR_CHAT_VERIFY_TIMEOUT_MS - 500)
|
| 420 |
+
self.logger.info(f"[{self.req_id}] ✅ 聊天已成功清空 (验证通过 - 最后响应容器隐藏)。")
|
| 421 |
+
except Exception as verify_err:
|
| 422 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 警告: 清空聊天验证失败 (最后响应容器未隐藏): {verify_err}")
|
| 423 |
+
|
| 424 |
+
async def submit_prompt(self, prompt: str, check_client_disconnected: Callable):
|
| 425 |
+
"""提交提示到页面。"""
|
| 426 |
+
self.logger.info(f"[{self.req_id}] 填充并提交提示 ({len(prompt)} chars)...")
|
| 427 |
+
prompt_textarea_locator = self.page.locator(PROMPT_TEXTAREA_SELECTOR)
|
| 428 |
+
autosize_wrapper_locator = self.page.locator('ms-prompt-input-wrapper ms-autosize-textarea')
|
| 429 |
+
submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR)
|
| 430 |
+
|
| 431 |
+
try:
|
| 432 |
+
await expect_async(prompt_textarea_locator).to_be_visible(timeout=5000)
|
| 433 |
+
await self._check_disconnect(check_client_disconnected, "After Input Visible")
|
| 434 |
+
|
| 435 |
+
# 使用 JavaScript 填充文本
|
| 436 |
+
await prompt_textarea_locator.evaluate(
|
| 437 |
+
'''
|
| 438 |
+
(element, text) => {
|
| 439 |
+
element.value = text;
|
| 440 |
+
element.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
|
| 441 |
+
element.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
|
| 442 |
+
}
|
| 443 |
+
''',
|
| 444 |
+
prompt
|
| 445 |
+
)
|
| 446 |
+
await autosize_wrapper_locator.evaluate('(element, text) => { element.setAttribute("data-value", text); }', prompt)
|
| 447 |
+
await self._check_disconnect(check_client_disconnected, "After Input Fill")
|
| 448 |
+
|
| 449 |
+
# 等待发送按钮启用
|
| 450 |
+
wait_timeout_ms_submit_enabled = 40000
|
| 451 |
+
try:
|
| 452 |
+
await self._check_disconnect(check_client_disconnected, "填充提示后等待发送按钮启用 - 前置检查")
|
| 453 |
+
await expect_async(submit_button_locator).to_be_enabled(timeout=wait_timeout_ms_submit_enabled)
|
| 454 |
+
self.logger.info(f"[{self.req_id}] ✅ 发送按钮已启用。")
|
| 455 |
+
except Exception as e_pw_enabled:
|
| 456 |
+
self.logger.error(f"[{self.req_id}] ❌ 等待发送按钮启用超时或错误: {e_pw_enabled}")
|
| 457 |
+
await save_error_snapshot(f"submit_button_enable_timeout_{self.req_id}")
|
| 458 |
+
raise
|
| 459 |
+
|
| 460 |
+
await self._check_disconnect(check_client_disconnected, "After Submit Button Enabled")
|
| 461 |
+
await asyncio.sleep(0.3)
|
| 462 |
+
|
| 463 |
+
# 尝试使用快捷键提交
|
| 464 |
+
submitted_successfully = await self._try_shortcut_submit(prompt_textarea_locator, check_client_disconnected)
|
| 465 |
+
|
| 466 |
+
# 如果快捷键失败,使用按钮点击
|
| 467 |
+
if not submitted_successfully:
|
| 468 |
+
self.logger.info(f"[{self.req_id}] 快捷键提交失败,尝试点击提交按钮...")
|
| 469 |
+
try:
|
| 470 |
+
await submit_button_locator.click(timeout=5000)
|
| 471 |
+
self.logger.info(f"[{self.req_id}] ✅ 提交按钮点击完成。")
|
| 472 |
+
except Exception as click_err:
|
| 473 |
+
self.logger.error(f"[{self.req_id}] ❌ 提交按钮点击失败: {click_err}")
|
| 474 |
+
await save_error_snapshot(f"submit_button_click_fail_{self.req_id}")
|
| 475 |
+
raise
|
| 476 |
+
|
| 477 |
+
await self._check_disconnect(check_client_disconnected, "After Submit")
|
| 478 |
+
|
| 479 |
+
except Exception as e_input_submit:
|
| 480 |
+
self.logger.error(f"[{self.req_id}] 输入和提交过程中发生错误: {e_input_submit}")
|
| 481 |
+
if not isinstance(e_input_submit, ClientDisconnectedError):
|
| 482 |
+
await save_error_snapshot(f"input_submit_error_{self.req_id}")
|
| 483 |
+
raise
|
| 484 |
+
|
| 485 |
+
async def _try_shortcut_submit(self, prompt_textarea_locator, check_client_disconnected: Callable) -> bool:
|
| 486 |
+
"""尝试使用快捷键提交"""
|
| 487 |
+
import os
|
| 488 |
+
try:
|
| 489 |
+
# 检测操作系统
|
| 490 |
+
host_os_from_launcher = os.environ.get('HOST_OS_FOR_SHORTCUT')
|
| 491 |
+
is_mac_determined = False
|
| 492 |
+
|
| 493 |
+
if host_os_from_launcher == "Darwin":
|
| 494 |
+
is_mac_determined = True
|
| 495 |
+
elif host_os_from_launcher in ["Windows", "Linux"]:
|
| 496 |
+
is_mac_determined = False
|
| 497 |
+
else:
|
| 498 |
+
# 使用浏览器检测
|
| 499 |
+
try:
|
| 500 |
+
user_agent_data_platform = await self.page.evaluate("() => navigator.userAgentData?.platform || ''")
|
| 501 |
+
except Exception:
|
| 502 |
+
user_agent_string = await self.page.evaluate("() => navigator.userAgent || ''")
|
| 503 |
+
user_agent_string_lower = user_agent_string.lower()
|
| 504 |
+
if "macintosh" in user_agent_string_lower or "mac os x" in user_agent_string_lower:
|
| 505 |
+
user_agent_data_platform = "macOS"
|
| 506 |
+
else:
|
| 507 |
+
user_agent_data_platform = "Other"
|
| 508 |
+
|
| 509 |
+
is_mac_determined = "mac" in user_agent_data_platform.lower()
|
| 510 |
+
|
| 511 |
+
shortcut_modifier = "Meta" if is_mac_determined else "Control"
|
| 512 |
+
shortcut_key = "Enter"
|
| 513 |
+
|
| 514 |
+
self.logger.info(f"[{self.req_id}] 使用快捷键: {shortcut_modifier}+{shortcut_key}")
|
| 515 |
+
|
| 516 |
+
await prompt_textarea_locator.focus(timeout=5000)
|
| 517 |
+
await self._check_disconnect(check_client_disconnected, "After Input Focus")
|
| 518 |
+
await asyncio.sleep(0.1)
|
| 519 |
+
|
| 520 |
+
# 记录提交前的输入框内容,用于验证
|
| 521 |
+
original_content = ""
|
| 522 |
+
try:
|
| 523 |
+
original_content = await prompt_textarea_locator.input_value(timeout=2000) or ""
|
| 524 |
+
except Exception:
|
| 525 |
+
# 如果无法获取原始内容,仍然尝试提交
|
| 526 |
+
pass
|
| 527 |
+
|
| 528 |
+
try:
|
| 529 |
+
await self.page.keyboard.press(f'{shortcut_modifier}+{shortcut_key}')
|
| 530 |
+
except Exception:
|
| 531 |
+
# 尝试分步按键
|
| 532 |
+
await self.page.keyboard.down(shortcut_modifier)
|
| 533 |
+
await asyncio.sleep(0.05)
|
| 534 |
+
await self.page.keyboard.press(shortcut_key)
|
| 535 |
+
await asyncio.sleep(0.05)
|
| 536 |
+
await self.page.keyboard.up(shortcut_modifier)
|
| 537 |
+
|
| 538 |
+
await self._check_disconnect(check_client_disconnected, "After Shortcut Press")
|
| 539 |
+
|
| 540 |
+
# 等待更长时间让提交完成
|
| 541 |
+
await asyncio.sleep(2.0)
|
| 542 |
+
|
| 543 |
+
# 多种方式验证提交是否成功
|
| 544 |
+
submission_success = False
|
| 545 |
+
|
| 546 |
+
try:
|
| 547 |
+
# 方法1: 检查原始输入框是否清空
|
| 548 |
+
current_content = await prompt_textarea_locator.input_value(timeout=2000) or ""
|
| 549 |
+
if original_content and not current_content.strip():
|
| 550 |
+
self.logger.info(f"[{self.req_id}] 验证方法1: 输入框已清空,快捷键提交成功")
|
| 551 |
+
submission_success = True
|
| 552 |
+
|
| 553 |
+
# 方法2: 检查提交按钮状态
|
| 554 |
+
if not submission_success:
|
| 555 |
+
submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR)
|
| 556 |
+
try:
|
| 557 |
+
is_disabled = await submit_button_locator.is_disabled(timeout=2000)
|
| 558 |
+
if is_disabled:
|
| 559 |
+
self.logger.info(f"[{self.req_id}] 验证方法2: 提交按钮已禁用,快捷键提交成功")
|
| 560 |
+
submission_success = True
|
| 561 |
+
except Exception:
|
| 562 |
+
pass
|
| 563 |
+
|
| 564 |
+
# 方法3: 检查是否有响应容器出现
|
| 565 |
+
if not submission_success:
|
| 566 |
+
try:
|
| 567 |
+
response_container = self.page.locator(RESPONSE_CONTAINER_SELECTOR)
|
| 568 |
+
container_count = await response_container.count()
|
| 569 |
+
if container_count > 0:
|
| 570 |
+
# 检查最后一个容器是否是新的
|
| 571 |
+
last_container = response_container.last
|
| 572 |
+
if await last_container.is_visible(timeout=1000):
|
| 573 |
+
self.logger.info(f"[{self.req_id}] 验证方法3: 检测到响应容器,快捷键提交成功")
|
| 574 |
+
submission_success = True
|
| 575 |
+
except Exception:
|
| 576 |
+
pass
|
| 577 |
+
|
| 578 |
+
except Exception as verify_err:
|
| 579 |
+
self.logger.warning(f"[{self.req_id}] 快捷键提交验证过程出错: {verify_err}")
|
| 580 |
+
# 出错时假定提交成功,让后续流程继续
|
| 581 |
+
submission_success = True
|
| 582 |
+
|
| 583 |
+
if submission_success:
|
| 584 |
+
self.logger.info(f"[{self.req_id}] ✅ 快捷键提交成功")
|
| 585 |
+
return True
|
| 586 |
+
else:
|
| 587 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 快捷键提交验证失败")
|
| 588 |
+
return False
|
| 589 |
+
|
| 590 |
+
except Exception as shortcut_err:
|
| 591 |
+
self.logger.warning(f"[{self.req_id}] 快捷键提交失败: {shortcut_err}")
|
| 592 |
+
return False
|
| 593 |
+
|
| 594 |
+
async def get_response(self, check_client_disconnected: Callable) -> str:
|
| 595 |
+
"""获取响应内容。"""
|
| 596 |
+
self.logger.info(f"[{self.req_id}] 等待并获取响应...")
|
| 597 |
+
|
| 598 |
+
try:
|
| 599 |
+
# 等待响应容器出现
|
| 600 |
+
response_container_locator = self.page.locator(RESPONSE_CONTAINER_SELECTOR).last
|
| 601 |
+
response_element_locator = response_container_locator.locator(RESPONSE_TEXT_SELECTOR)
|
| 602 |
+
|
| 603 |
+
self.logger.info(f"[{self.req_id}] 等待响应元素附加到DOM...")
|
| 604 |
+
await expect_async(response_element_locator).to_be_attached(timeout=90000)
|
| 605 |
+
await self._check_disconnect(check_client_disconnected, "获取响应 - 响应元素已附加")
|
| 606 |
+
|
| 607 |
+
# 等待响应完成
|
| 608 |
+
submit_button_locator = self.page.locator(SUBMIT_BUTTON_SELECTOR)
|
| 609 |
+
edit_button_locator = self.page.locator(EDIT_MESSAGE_BUTTON_SELECTOR)
|
| 610 |
+
input_field_locator = self.page.locator(PROMPT_TEXTAREA_SELECTOR)
|
| 611 |
+
|
| 612 |
+
self.logger.info(f"[{self.req_id}] 等待响应完成...")
|
| 613 |
+
completion_detected = await _wait_for_response_completion(
|
| 614 |
+
self.page, input_field_locator, submit_button_locator, edit_button_locator, self.req_id, check_client_disconnected, None
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
if not completion_detected:
|
| 618 |
+
self.logger.warning(f"[{self.req_id}] 响应完成检测失败,尝试获取当前内容")
|
| 619 |
+
else:
|
| 620 |
+
self.logger.info(f"[{self.req_id}] ✅ 响应完成检测成功")
|
| 621 |
+
|
| 622 |
+
# 获取最终响应内容
|
| 623 |
+
final_content = await _get_final_response_content(self.page, self.req_id, check_client_disconnected)
|
| 624 |
+
|
| 625 |
+
if not final_content or not final_content.strip():
|
| 626 |
+
self.logger.warning(f"[{self.req_id}] ⚠️ 获取到的响应内容为空")
|
| 627 |
+
await save_error_snapshot(f"empty_response_{self.req_id}")
|
| 628 |
+
# 不抛出异常,返回空内容让上层处理
|
| 629 |
+
return ""
|
| 630 |
+
|
| 631 |
+
self.logger.info(f"[{self.req_id}] ✅ 成功获取响应内容 ({len(final_content)} chars)")
|
| 632 |
+
return final_content
|
| 633 |
+
|
| 634 |
+
except Exception as e:
|
| 635 |
+
self.logger.error(f"[{self.req_id}] ❌ 获取响应时出错: {e}")
|
| 636 |
+
if not isinstance(e, ClientDisconnectedError):
|
| 637 |
+
await save_error_snapshot(f"get_response_error_{self.req_id}")
|
| 638 |
+
raise
|
browser_utils/script_manager.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- browser_utils/script_manager.py ---
|
| 2 |
+
# 油猴脚本管理模块 - 动态挂载和注入脚本功能
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, List, Optional, Any
|
| 8 |
+
from playwright.async_api import Page as AsyncPage
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger("AIStudioProxyServer")
|
| 11 |
+
|
| 12 |
+
class ScriptManager:
|
| 13 |
+
"""油猴脚本管理器 - 负责动态加载和注入脚本"""
|
| 14 |
+
|
| 15 |
+
def __init__(self, script_dir: str = "browser_utils"):
|
| 16 |
+
self.script_dir = script_dir
|
| 17 |
+
self.loaded_scripts: Dict[str, str] = {}
|
| 18 |
+
self.model_configs: Dict[str, List[Dict[str, Any]]] = {}
|
| 19 |
+
|
| 20 |
+
def load_script(self, script_name: str) -> Optional[str]:
|
| 21 |
+
"""加载指定的JavaScript脚本文件"""
|
| 22 |
+
script_path = os.path.join(self.script_dir, script_name)
|
| 23 |
+
|
| 24 |
+
if not os.path.exists(script_path):
|
| 25 |
+
logger.error(f"脚本文件不存在: {script_path}")
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
with open(script_path, 'r', encoding='utf-8') as f:
|
| 30 |
+
script_content = f.read()
|
| 31 |
+
self.loaded_scripts[script_name] = script_content
|
| 32 |
+
logger.info(f"成功加载脚本: {script_name}")
|
| 33 |
+
return script_content
|
| 34 |
+
except Exception as e:
|
| 35 |
+
logger.error(f"加载脚本失败 {script_name}: {e}")
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
+
def load_model_config(self, config_path: str) -> Optional[List[Dict[str, Any]]]:
|
| 39 |
+
"""加载模型配置文件"""
|
| 40 |
+
if not os.path.exists(config_path):
|
| 41 |
+
logger.warning(f"模型配置文件不存在: {config_path}")
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
with open(config_path, 'r', encoding='utf-8') as f:
|
| 46 |
+
config_data = json.load(f)
|
| 47 |
+
models = config_data.get('models', [])
|
| 48 |
+
self.model_configs[config_path] = models
|
| 49 |
+
logger.info(f"成功加载模型配置: {len(models)} 个模型")
|
| 50 |
+
return models
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.error(f"加载模型配置失败 {config_path}: {e}")
|
| 53 |
+
return None
|
| 54 |
+
|
| 55 |
+
def generate_dynamic_script(self, base_script: str, models: List[Dict[str, Any]],
|
| 56 |
+
script_version: str = "dynamic") -> str:
|
| 57 |
+
"""基于模型配置动态生成脚本内容"""
|
| 58 |
+
try:
|
| 59 |
+
# 构建模型列表的JavaScript代码
|
| 60 |
+
models_js = "const MODELS_TO_INJECT = [\n"
|
| 61 |
+
for model in models:
|
| 62 |
+
name = model.get('name', '')
|
| 63 |
+
display_name = model.get('displayName', model.get('display_name', ''))
|
| 64 |
+
description = model.get('description', f'Model injected by script {script_version}')
|
| 65 |
+
|
| 66 |
+
# 如果displayName中没有包含版本信息,添加版本信息
|
| 67 |
+
if f"(Script {script_version})" not in display_name:
|
| 68 |
+
display_name = f"{display_name} (Script {script_version})"
|
| 69 |
+
|
| 70 |
+
models_js += f""" {{
|
| 71 |
+
name: '{name}',
|
| 72 |
+
displayName: `{display_name}`,
|
| 73 |
+
description: `{description}`
|
| 74 |
+
}},\n"""
|
| 75 |
+
|
| 76 |
+
models_js += " ];"
|
| 77 |
+
|
| 78 |
+
# 替换脚本中的模型定义部分
|
| 79 |
+
# 查找模型定义的开始和结束标记
|
| 80 |
+
start_marker = "const MODELS_TO_INJECT = ["
|
| 81 |
+
end_marker = "];"
|
| 82 |
+
|
| 83 |
+
start_idx = base_script.find(start_marker)
|
| 84 |
+
if start_idx == -1:
|
| 85 |
+
logger.error("未找到模型定义开始标记")
|
| 86 |
+
return base_script
|
| 87 |
+
|
| 88 |
+
# 找到对应的结束标记
|
| 89 |
+
bracket_count = 0
|
| 90 |
+
end_idx = start_idx + len(start_marker)
|
| 91 |
+
found_end = False
|
| 92 |
+
|
| 93 |
+
for i in range(end_idx, len(base_script)):
|
| 94 |
+
if base_script[i] == '[':
|
| 95 |
+
bracket_count += 1
|
| 96 |
+
elif base_script[i] == ']':
|
| 97 |
+
if bracket_count == 0:
|
| 98 |
+
end_idx = i + 1
|
| 99 |
+
found_end = True
|
| 100 |
+
break
|
| 101 |
+
bracket_count -= 1
|
| 102 |
+
|
| 103 |
+
if not found_end:
|
| 104 |
+
logger.error("未找到模型定义结束标记")
|
| 105 |
+
return base_script
|
| 106 |
+
|
| 107 |
+
# 替换模型定义部分
|
| 108 |
+
new_script = (base_script[:start_idx] +
|
| 109 |
+
models_js +
|
| 110 |
+
base_script[end_idx:])
|
| 111 |
+
|
| 112 |
+
# 更新版本号
|
| 113 |
+
new_script = new_script.replace(
|
| 114 |
+
f'const SCRIPT_VERSION = "v1.6";',
|
| 115 |
+
f'const SCRIPT_VERSION = "{script_version}";'
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
logger.info(f"成功生成动态脚本,包含 {len(models)} 个模型")
|
| 119 |
+
return new_script
|
| 120 |
+
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"生成动态脚本失败: {e}")
|
| 123 |
+
return base_script
|
| 124 |
+
|
| 125 |
+
async def inject_script_to_page(self, page: AsyncPage, script_content: str,
|
| 126 |
+
script_name: str = "injected_script") -> bool:
|
| 127 |
+
"""将脚本注入到页面中"""
|
| 128 |
+
try:
|
| 129 |
+
# 移除UserScript头部信息,因为我们是直接注入而不是通过油猴
|
| 130 |
+
cleaned_script = self._clean_userscript_headers(script_content)
|
| 131 |
+
|
| 132 |
+
# 注入脚本
|
| 133 |
+
await page.add_init_script(cleaned_script)
|
| 134 |
+
logger.info(f"成功注入脚本到页面: {script_name}")
|
| 135 |
+
return True
|
| 136 |
+
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logger.error(f"注入脚本到页面失败 {script_name}: {e}")
|
| 139 |
+
return False
|
| 140 |
+
|
| 141 |
+
def _clean_userscript_headers(self, script_content: str) -> str:
|
| 142 |
+
"""清理UserScript头部信息"""
|
| 143 |
+
lines = script_content.split('\n')
|
| 144 |
+
cleaned_lines = []
|
| 145 |
+
in_userscript_block = False
|
| 146 |
+
|
| 147 |
+
for line in lines:
|
| 148 |
+
if line.strip().startswith('// ==UserScript=='):
|
| 149 |
+
in_userscript_block = True
|
| 150 |
+
continue
|
| 151 |
+
elif line.strip().startswith('// ==/UserScript=='):
|
| 152 |
+
in_userscript_block = False
|
| 153 |
+
continue
|
| 154 |
+
elif in_userscript_block:
|
| 155 |
+
continue
|
| 156 |
+
else:
|
| 157 |
+
cleaned_lines.append(line)
|
| 158 |
+
|
| 159 |
+
return '\n'.join(cleaned_lines)
|
| 160 |
+
|
| 161 |
+
async def setup_model_injection(self, page: AsyncPage,
|
| 162 |
+
script_name: str = "more_modles.js") -> bool:
|
| 163 |
+
"""设置模型注入 - 直接注入油猴脚本"""
|
| 164 |
+
|
| 165 |
+
# 检查脚本文件是否存在
|
| 166 |
+
script_path = os.path.join(self.script_dir, script_name)
|
| 167 |
+
if not os.path.exists(script_path):
|
| 168 |
+
# 脚本文件不存在,静默跳过注入
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
logger.info("开始设置模型注入...")
|
| 172 |
+
|
| 173 |
+
# 加载油猴脚本
|
| 174 |
+
script_content = self.load_script(script_name)
|
| 175 |
+
if not script_content:
|
| 176 |
+
return False
|
| 177 |
+
|
| 178 |
+
# 直接注入原始脚本(不修改内容)
|
| 179 |
+
return await self.inject_script_to_page(page, script_content, script_name)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# 全局脚本管理器实例
|
| 183 |
+
script_manager = ScriptManager()
|