hins111 commited on
Commit
ac029f2
·
verified ·
1 Parent(s): e424723

Upload 7 files

Browse files
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()