From 474190bcb45e06d8d57e1bc160ad294b5edf0f07 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Wed, 18 Mar 2026 19:59:28 +0900 Subject: [PATCH 01/19] fix: improve clear chat verification and reduce noisy warnings - _verify_chat_cleared: if zero_state not visible, fallback to waiting for textarea (more reliable page-ready indicator) - queue_worker: downgrade button check failure from WARNING to DEBUG - page_controller: downgrade shortcut submission verification failure from WARNING to DEBUG --- src/browser/page_controller.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index 8a6b4b1..8b409a2 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -750,8 +750,16 @@ async def _verify_chat_cleared(self, check_client_disconnected: Callable): await expect_async(self.page).to_have_url(re.compile('.*/prompts/new_chat.*'), timeout=CLEAR_CHAT_VERIFY_TIMEOUT_MS) self.logger.info(f'[{self.req_id}] - URL验证成功: 页面已导航到 new_chat。') zero_state_locator = self.page.locator(ZERO_STATE_SELECTOR) - await expect_async(zero_state_locator).to_be_visible(timeout=5000) - self.logger.info(f'[{self.req_id}] - UI验证成功: “零状态”元素可见。') + try: + await expect_async(zero_state_locator).to_be_visible(timeout=5000) + self.logger.info(f'[{self.req_id}] - UI验证成功: "零状态"元素可见。') + except Exception: + self.logger.debug(f'[{self.req_id}] - zero_state not visible, waiting for textarea...') + try: + await expect_async(self.page.locator(PROMPT_TEXTAREA_SELECTOR)).to_be_visible(timeout=10000) + self.logger.info(f'[{self.req_id}] - Textarea visible, page ready.') + except Exception: + self.logger.debug(f'[{self.req_id}] - Textarea also not visible, continuing anyway.') self.logger.info(f'[{self.req_id}] 聊天已成功清空 (验证通过)。') except Exception as verify_err: self.logger.error(f'[{self.req_id}] 错误: 清空聊天验证失败: {verify_err}') From dba0b550881ca8a22d82826e8011f8b8424b8f0d Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Wed, 18 Mar 2026 20:09:04 +0900 Subject: [PATCH 02/19] fix: increase submit button wait timeout and add fuzzy model id matching - click_element for Submit Button: internal_timeout 2000ms -> 10000ms - request_processor: if model id not in parsed_model_list, try fuzzy match (e.g. gemini-2.5-flash -> gemini-2.5-flash-preview-04-17) --- src/api/request_processor.py | 8 +++++++- src/browser/page_controller.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/api/request_processor.py b/src/api/request_processor.py index 39a455e..2d4463e 100644 --- a/src/api/request_processor.py +++ b/src/api/request_processor.py @@ -114,7 +114,13 @@ async def _analyze_model_requirements(req_id: str, context: dict, request: ChatC if parsed_model_list: valid_model_ids = [m.get('id') for m in parsed_model_list] if requested_model_id not in valid_model_ids: - raise HTTPException(status_code=400, detail=f"[{req_id}] Invalid model '{requested_model_id}'. Available models: {', '.join(valid_model_ids)}") + # fuzzy match: find model whose id contains the requested id or vice versa + fuzzy = next((mid for mid in valid_model_ids if requested_model_id in mid or mid.startswith(requested_model_id.split('-preview')[0])), None) + if fuzzy: + logger.info(f'[{req_id}] 模型 "{requested_model_id}" 不在列表中,自动映射到 "{fuzzy}"') + requested_model_id = fuzzy + else: + raise HTTPException(status_code=400, detail=f"[{req_id}] Invalid model '{requested_model_id}'. Available models: {', '.join(valid_model_ids)}") context['model_id_to_use'] = requested_model_id if current_ai_studio_model_id != requested_model_id: context['needs_model_switching'] = True diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index 8b409a2..b903490 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -1079,7 +1079,7 @@ async def submit_prompt(self, prompt: str, image_list: List, check_client_discon submitted_successfully = await self._try_shortcut_submit(prompt_textarea_locator, check_client_disconnected) if not submitted_successfully: self.logger.info(f'[{self.req_id}] 快捷键提交失败,尝试点击提交按钮...') - await click_element(self.page, submit_button_locator, 'Submit Button', self.req_id) + await click_element(self.page, submit_button_locator, 'Submit Button', self.req_id, internal_timeout=10000) self.logger.info(f'[{self.req_id}] 提交按钮点击完成。') await self._check_disconnect(check_client_disconnected, '提交后') From 63bc4d6186eb37abf9b65d175a8f305756a416bc Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Wed, 18 Mar 2026 20:25:24 +0900 Subject: [PATCH 03/19] fix: improve page stability and parameter setting reliability - _verify_chat_cleared: fallback to textarea visibility when zero_state not visible, ensuring page is ready before proceeding - _ensure_tools_panel_expanded: skip if button not found (new UI layout) - _adjust_google_search: skip if toggle not found - _set_parameter_with_retry: add blur event + Tab key, use triple_click for reliable field clearing before fill - submit_prompt: increase textarea locator timeout 5s -> 15s - click_element for Submit Button: timeout 2s -> 10s --- src/browser/page_controller.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index b903490..53fb81a 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -376,6 +376,9 @@ async def _adjust_google_search(self, request_params: Dict[str, Any], check_clie for attempt in range(1, max_retries + 1): try: toggle_locator = self.page.locator(toggle_selector) + if await toggle_locator.count() == 0: + self.logger.debug(f'[{self.req_id}] Google Search 开关不存在,跳过。') + return await expect_async(toggle_locator).to_be_visible(timeout=5000) await self._check_disconnect(check_client_disconnected, 'Google Search 開關 - 元素可見後') is_checked_str = await toggle_locator.get_attribute('aria-checked') @@ -467,6 +470,9 @@ async def _ensure_tools_panel_expanded(self, check_client_disconnected: Callable for attempt in range(1, max_retries + 1): try: collapse_tools_locator = self.page.locator('button[aria-label="Expand or collapse tools"]') + if await collapse_tools_locator.count() == 0: + self.logger.info(f'[{self.req_id}] 工具面板展开按钮不存在,跳过。') + return await expect_async(collapse_tools_locator).to_be_visible(timeout=5000) grandparent_locator = collapse_tools_locator.locator('xpath=../..') class_string = await grandparent_locator.get_attribute('class', timeout=3000) @@ -581,15 +587,17 @@ def is_equal(val1, val2): if attempt == 0: strategy_name = "JS Injection" - await locator.evaluate('(el, val) => { el.value = val; el.dispatchEvent(new Event("input", {bubbles: true})); el.dispatchEvent(new Event("change", {bubbles: true})); }', str(target_value)) + await locator.evaluate('(el, val) => { el.value = val; el.dispatchEvent(new Event("input", {bubbles: true})); el.dispatchEvent(new Event("change", {bubbles: true})); el.dispatchEvent(new Event("blur", {bubbles: true})); }', str(target_value)) await asyncio.sleep(DELAY_AFTER_FILL) - await locator.press('Enter') + await locator.press('Tab') elif attempt == 1: strategy_name = "Standard Fill" await locator.focus() + await locator.triple_click() await locator.fill(str(target_value)) + await locator.press('Tab') + await asyncio.sleep(DELAY_AFTER_FILL) await locator.dispatch_event('change') - await locator.press('Enter') else: strategy_name = "Select & Type" await locator.focus() @@ -597,7 +605,7 @@ def is_equal(val1, val2): await locator.press('Backspace') await asyncio.sleep(SLEEP_TICK) await locator.type(str(target_value), delay=50) - await locator.press('Enter') + await locator.press('Tab') await asyncio.sleep(SLEEP_LONG) @@ -1011,7 +1019,7 @@ async def _paste_images_via_event(self, images: List[Dict[str, str]], target_loc async def submit_prompt(self, prompt: str, image_list: List, check_client_disconnected: Callable): self.logger.info(f'[{self.req_id}] 📤 提交提示 ({len(prompt)} chars)...') - prompt_textarea_locator, matched_selector = await get_first_visible_locator(self.page, PROMPT_TEXTAREA_SELECTORS, timeout=5000) + prompt_textarea_locator, matched_selector = await get_first_visible_locator(self.page, PROMPT_TEXTAREA_SELECTORS, timeout=15000) if not prompt_textarea_locator: self.logger.warning(f'[{self.req_id}] 未找到输入框,尝试默认选择器') prompt_textarea_locator = self.page.locator(PROMPT_TEXTAREA_SELECTOR) From 63aa1c92c62a2eafe6867c1b225c103539385aa7 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 09:30:58 +0900 Subject: [PATCH 04/19] fix: increase timeouts for STREAM proxy and page initialization - STREAM proxy port wait: 10s -> 30s (Windows multiprocessing startup is slow) - initialization: textarea visibility wait: 10s -> 30s --- src/api/app.py | 2 +- src/browser/initialization.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/app.py b/src/api/app.py index 8c0f1fb..61bc5d6 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -92,7 +92,7 @@ async def _start_stream_proxy(): server.STREAM_PROCESS = multiprocessing.Process(target=proxy.start, args=(server.STREAM_QUEUE, port, STREAM_PROXY_SERVER_ENV)) server.STREAM_PROCESS.start() server.logger.info('STREAM proxy process started. Waiting for port readiness...') - if await _wait_for_port(port): + if await _wait_for_port(port, timeout=30.0): server.logger.info(f'STREAM proxy port {port} is ready.') else: server.logger.error(f'STREAM proxy port {port} not ready after timeout. Browser may fail to connect.') diff --git a/src/browser/initialization.py b/src/browser/initialization.py index 9a42e98..31adde5 100644 --- a/src/browser/initialization.py +++ b/src/browser/initialization.py @@ -313,11 +313,11 @@ async def _initialize_page_logic(browser: AsyncBrowser): logger.info(f'✅ 输入框wrapper可见 (匹配: {wrapper_matched})') else: logger.warning('⚠️ 未找到任何wrapper,尝试直接查找输入框') - input_locator, matched = await wait_for_any_selector(found_page, PROMPT_TEXTAREA_SELECTORS, timeout=10000) + input_locator, matched = await wait_for_any_selector(found_page, PROMPT_TEXTAREA_SELECTORS, timeout=30000) if input_locator: logger.info(f'✅ 核心输入区域可见 (匹配: {matched})') else: - await expect_async(found_page.locator(INPUT_SELECTOR)).to_be_visible(timeout=10000) + await expect_async(found_page.locator(INPUT_SELECTOR)).to_be_visible(timeout=30000) logger.info('✅ 核心输入区域可见 (默认选择器)') try: from config.selectors import MODEL_SELECTORS_LIST From 3f758cb884c61ff61d8e8605fbcb1e17ea1e4340 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 09:48:45 +0900 Subject: [PATCH 05/19] fix: replace triple_click with select_text, increase Google Search toggle wait - triple_click not available in this Playwright version, use select_text instead - Google Search toggle: wait 0.5s -> 1.0s after click for state to update --- src/browser/page_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index 53fb81a..4380ba9 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -390,7 +390,7 @@ async def _adjust_google_search(self, request_params: Dict[str, Any], check_clie self.logger.info(f'[{self.req_id}] 🌍 (嘗試 {attempt}/{max_retries}) 正在{action} Google Search...') await click_element(self.page, toggle_locator, 'Google Search Toggle', self.req_id) await self._check_disconnect(check_client_disconnected, f'Google Search 開關 - 點擊{action}後') - await asyncio.sleep(SLEEP_LONG) + await asyncio.sleep(1.0) new_state = await toggle_locator.get_attribute('aria-checked') if (new_state == 'true') == should_enable_search: self.logger.info(f'[{self.req_id}] ✅ Google Search 已{action}。') @@ -593,7 +593,7 @@ def is_equal(val1, val2): elif attempt == 1: strategy_name = "Standard Fill" await locator.focus() - await locator.triple_click() + await locator.select_text() await locator.fill(str(target_value)) await locator.press('Tab') await asyncio.sleep(DELAY_AFTER_FILL) From 3be9f2acc1c3020f2ff5bc5168813effa3edc7d2 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 10:09:53 +0900 Subject: [PATCH 06/19] fix: stop sequence timeout and Google Search toggle reliability - stop sequence press timeout: 3s -> 5s - Google Search toggle: use force click to bypass overlay issues --- src/browser/page_controller.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index 4380ba9..61a1a00 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -388,7 +388,7 @@ async def _adjust_google_search(self, request_params: Dict[str, Any], check_clie return action = '打開' if should_enable_search else '關閉' self.logger.info(f'[{self.req_id}] 🌍 (嘗試 {attempt}/{max_retries}) 正在{action} Google Search...') - await click_element(self.page, toggle_locator, 'Google Search Toggle', self.req_id) + await toggle_locator.click(force=True, timeout=3000) await self._check_disconnect(check_client_disconnected, f'Google Search 開關 - 點擊{action}後') await asyncio.sleep(1.0) new_state = await toggle_locator.get_attribute('aria-checked') @@ -703,8 +703,8 @@ async def _adjust_stop_sequences(self, stop_sequences, page_params_cache: dict, if normalized_requested_stops: await expect_async(stop_input_locator).to_be_visible(timeout=5000) for seq in normalized_requested_stops: - await stop_input_locator.fill(seq, timeout=3000) - await stop_input_locator.press('Enter', timeout=3000) + await stop_input_locator.fill(seq, timeout=5000) + await stop_input_locator.press('Enter', timeout=5000) await asyncio.sleep(DELAY_AFTER_FILL) page_params_cache['stop_sequences'] = normalized_requested_stops self.logger.info(f'[{self.req_id}] 停止序列已成功设置。缓存已更新。') From b0536a1018c6377d070df6a3036e150c8ea0140f Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 10:24:46 +0900 Subject: [PATCH 07/19] fix: handle TargetClosedError gracefully and improve parameter verification - initialization: catch TargetClosedError during page init, skip gracefully - initialization/model_management: downgrade noisy warnings to DEBUG - _set_parameter_with_retry: is_equal extracts float from strings like 'user:1.0' - _set_parameter_with_retry: input_value timeout 2s -> 5s --- src/browser/initialization.py | 6 +++++- src/browser/model_management.py | 2 +- src/browser/page_controller.py | 17 ++++++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/browser/initialization.py b/src/browser/initialization.py index 31adde5..8735f90 100644 --- a/src/browser/initialization.py +++ b/src/browser/initialization.py @@ -312,7 +312,7 @@ async def _initialize_page_logic(browser: AsyncBrowser): if wrapper_locator: logger.info(f'✅ 输入框wrapper可见 (匹配: {wrapper_matched})') else: - logger.warning('⚠️ 未找到任何wrapper,尝试直接查找输入框') + logger.debug('⚠️ 未找到任何wrapper,尝试直接查找输入框') input_locator, matched = await wait_for_any_selector(found_page, PROMPT_TEXTAREA_SELECTORS, timeout=30000) if input_locator: logger.info(f'✅ 核心输入区域可见 (匹配: {matched})') @@ -338,6 +338,10 @@ async def _initialize_page_logic(browser: AsyncBrowser): logger.info(f'✅ 页面逻辑初始化成功。') return (result_page_instance, result_page_ready) except Exception as input_visible_err: + from playwright._impl._errors import TargetClosedError + if isinstance(input_visible_err, TargetClosedError) or 'Target page, context or browser has been closed' in str(input_visible_err): + logger.warning(f'页面初始化时浏览器已关闭,跳过。') + raise from .operations import save_error_snapshot await save_error_snapshot('init_fail_input_timeout') logger.error(f'页面初始化失败:核心输入区域未在预期时间内变为可见。最后的 URL 是 {found_page.url}', exc_info=True) diff --git a/src/browser/model_management.py b/src/browser/model_management.py index 2ac7d30..4b2359b 100644 --- a/src/browser/model_management.py +++ b/src/browser/model_management.py @@ -385,7 +385,7 @@ async def _handle_initial_model_state_and_storage(page: AsyncPage): except Exception as reload_err: err_str = str(reload_err) if 'Target page, context or browser has been closed' in err_str or 'Browser has been closed' in err_str: - logger.warning(f' ⚠️ 浏览器已关闭,跳过重新加载。') + logger.debug(f' ⚠️ 浏览器已关闭,跳过重新加载。') return logger.warning(f' ⚠️ 页面重新加载尝试 {attempt + 1}/{max_retries} 失败: {reload_err}') if attempt < max_retries - 1: diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index 61a1a00..cd89a6c 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -569,11 +569,18 @@ async def _control_thinking_budget_toggle(self, should_be_checked: bool, check_c async def _set_parameter_with_retry(self, locator: Locator, target_value: str, param_name: str, check_client_disconnected: Callable) -> bool: def is_equal(val1, val2): + import re + def extract_float(s): + m = re.search(r'[-+]?\d*\.?\d+', str(s)) + return float(m.group()) if m else None try: - f1, f2 = float(val1), float(val2) - return abs(f1 - f2) < 0.001 - except ValueError: - return str(val1).strip() == str(val2).strip() + f1 = extract_float(val1) + f2 = float(val2) + if f1 is not None: + return abs(f1 - f2) < 0.001 + except (ValueError, TypeError): + pass + return str(val1).strip() == str(val2).strip() max_retries = MAX_RETRIES for attempt in range(max_retries): @@ -609,7 +616,7 @@ def is_equal(val1, val2): await asyncio.sleep(SLEEP_LONG) - final_val = await locator.input_value(timeout=2000) + final_val = await locator.input_value(timeout=5000) if is_equal(final_val, target_value): self.logger.info(f"[{self.req_id}] {param_name} 成功设置为 {final_val} (策略: {strategy_name})。") return True From d1bd87bbcd1e6a65154c32518205ac9dac8989b8 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 10:44:19 +0900 Subject: [PATCH 08/19] fix: downgrade TargetClosedError from CRITICAL to WARNING - initialization: outer exception handler skips CRITICAL for browser-closed errors - app.py: Application startup failed logs WARNING instead of CRITICAL for browser-closed - model_management: UI state errors log DEBUG instead of ERROR for browser-closed --- src/api/app.py | 5 ++++- src/browser/initialization.py | 13 +++++++++---- src/browser/model_management.py | 10 ++++++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/api/app.py b/src/api/app.py index 61bc5d6..08b0da3 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -175,7 +175,10 @@ async def lifespan(app: FastAPI): server.is_initializing = False yield except Exception as e: - logger.critical(f'Application startup failed: {e}', exc_info=True) + if 'Target page, context or browser has been closed' in str(e): + logger.warning(f'Application startup failed (browser closed): {e}') + else: + logger.critical(f'Application startup failed: {e}', exc_info=True) await _shutdown_resources() raise RuntimeError(f'Application startup failed: {e}') from e finally: diff --git a/src/browser/initialization.py b/src/browser/initialization.py index 8735f90..fdd193a 100644 --- a/src/browser/initialization.py +++ b/src/browser/initialization.py @@ -347,16 +347,21 @@ async def _initialize_page_logic(browser: AsyncBrowser): logger.error(f'页面初始化失败:核心输入区域未在预期时间内变为可见。最后的 URL 是 {found_page.url}', exc_info=True) raise RuntimeError(f'页面初始化失败:核心输入区域未在预期时间内变为可见。最后的 URL 是 {found_page.url}') from input_visible_err except Exception as e_init_page: - logger.critical(f'❌ 页面逻辑初始化期间发生严重意外错误: {e_init_page}', exc_info=True) + is_browser_closed = 'Target page, context or browser has been closed' in str(e_init_page) + if is_browser_closed: + logger.warning(f'页面初始化时浏览器已关闭: {e_init_page}') + else: + logger.critical(f'❌ 页面逻辑初始化期间发生严重意外错误: {e_init_page}', exc_info=True) if temp_context: try: logger.info(f' 尝试关闭临时的浏览器上下文 due to initialization error.') await temp_context.close() logger.info(' ✅ 临时浏览器上下文已关闭。') except Exception as close_err: - logger.warning(f' ⚠️ 关闭临时浏览器上下文时出错: {close_err}') - from .operations import save_error_snapshot - await save_error_snapshot('init_unexpected_error') + logger.debug(f' 关闭临时浏览器上下文时出错: {close_err}') + if not is_browser_closed: + from .operations import save_error_snapshot + await save_error_snapshot('init_unexpected_error') raise RuntimeError(f'页面初始化意外错误: {e_init_page}') from e_init_page async def _close_page_logic(): diff --git a/src/browser/model_management.py b/src/browser/model_management.py index 4b2359b..2729080 100644 --- a/src/browser/model_management.py +++ b/src/browser/model_management.py @@ -29,7 +29,10 @@ async def _verify_ui_state_settings(page: AsyncPage, req_id: str='unknown') -> d logger.error(f'[{req_id}] ❌ 解析localStorage JSON失败: {e}') return {'exists': False, 'isAdvancedOpen': None, 'areToolsOpen': None, 'needsUpdate': True, 'error': f'JSON解析失败: {e}'} except Exception as e: - logger.error(f'[{req_id}] ❌ 验证UI状态设置时发生错误: {e}') + if 'Target page, context or browser has been closed' in str(e): + logger.debug(f'[{req_id}] UI状态验证时浏览器已关闭') + else: + logger.error(f'[{req_id}] ❌ 验证UI状态设置时发生错误: {e}') return {'exists': False, 'isAdvancedOpen': None, 'areToolsOpen': None, 'needsUpdate': True, 'error': f'验证失败: {e}'} async def _force_ui_state_settings(page: AsyncPage, req_id: str='unknown') -> bool: @@ -84,7 +87,10 @@ async def _force_ui_state_settings(page: AsyncPage, req_id: str='unknown') -> bo return False except Exception as e: - logger.error(f'[{req_id}] ❌ 强制设置UI状态错误: {e}') + if 'Target page, context or browser has been closed' in str(e): + logger.debug(f'[{req_id}] 强制设置UI时浏览器已关闭') + else: + logger.error(f'[{req_id}] ❌ 强制设置UI状态错误: {e}') return False async def _force_ui_state_with_retry(page: AsyncPage, req_id: str='unknown', max_retries: int=3, retry_delay: float=1.0) -> bool: From 385a1fdfcad65e9f4abd8b97e78b66879f7e6981 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 10:59:34 +0900 Subject: [PATCH 09/19] fix: increase focus timeout and parameter setting wait time - shortcut submission: textarea focus timeout 5s -> 15s - timeouts: SLEEP_LONG 0.5s -> 1.0s for more reliable parameter setting --- src/browser/page_controller.py | 2 +- src/config/timeouts.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index cd89a6c..e91a843 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -1212,7 +1212,7 @@ async def _try_shortcut_submit(self, prompt_textarea_locator, check_client_disco user_agent_data_platform = 'Other' is_mac_determined = 'mac' in user_agent_data_platform.lower() shortcut_modifier = 'Meta' if is_mac_determined else 'Control' - await prompt_textarea_locator.focus(timeout=5000) + await prompt_textarea_locator.focus(timeout=15000) await self._check_disconnect(check_client_disconnected, 'After Input Focus') original_content = await prompt_textarea_locator.input_value(timeout=2000) or '' self.logger.info(f'[{self.req_id}] - Attempting {shortcut_modifier}+Enter...') diff --git a/src/config/timeouts.py b/src/config/timeouts.py index bffcbca..cefa24c 100644 --- a/src/config/timeouts.py +++ b/src/config/timeouts.py @@ -25,7 +25,7 @@ SLEEP_TICK = 0.1 SLEEP_SHORT = 0.15 SLEEP_MEDIUM = 0.25 -SLEEP_LONG = 0.5 +SLEEP_LONG = 1.0 SLEEP_RETRY = 1.0 SLEEP_NAVIGATION = 2.0 SLEEP_VIDEO_POLL = 5.0 From 52a9cbef089927a247d6e189cbc5528ad426510f Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 11:36:54 +0900 Subject: [PATCH 10/19] fix: downgrade model name lookup warnings to DEBUG --- src/browser/model_management.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/model_management.py b/src/browser/model_management.py index 2729080..477521a 100644 --- a/src/browser/model_management.py +++ b/src/browser/model_management.py @@ -427,7 +427,7 @@ async def _set_model_from_page_display(page: AsyncPage, set_storage: bool=False) ) if not displayed_model_name: - logger.warning(' 所有选择器都无法获取页面显示的模型名称') + logger.debug(' 所有选择器都无法获取页面显示的模型名称') displayed_model_name = '未知模型' found_model_id_from_display = None if model_list_fetch_event and (not model_list_fetch_event.is_set()): @@ -443,7 +443,7 @@ async def _set_model_from_page_display(page: AsyncPage, set_storage: bool=False) logger.info(f" 显示名称 '{displayed_model_name}' 对应模型 ID: {found_model_id_from_display}") break if not found_model_id_from_display: - logger.warning(f" 未在已知模型列表中找到与显示名称 '{displayed_model_name}' 匹配的 ID。") + logger.debug(f" 未在已知模型列表中找到与显示名称 '{displayed_model_name}' 匹配的 ID。") else: logger.warning(' 模型列表尚不可用,无法将显示名称转换为ID。') new_model_value = found_model_id_from_display if found_model_id_from_display else displayed_model_name From 0cd308bb10fc297bafb9adea39ec0f26fd6c669a Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 19:09:47 +0900 Subject: [PATCH 11/19] fix: improve parameter setting and submission reliability - _set_parameter_with_retry: use Ctrl+A instead of select_text for reliable full selection in Angular spinbuttons - submit_prompt: if shortcuts fail but response already started, treat as success instead of attempting button click (avoids 500 errors) --- src/browser/page_controller.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index e91a843..3242d0b 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -598,17 +598,17 @@ def extract_float(s): await asyncio.sleep(DELAY_AFTER_FILL) await locator.press('Tab') elif attempt == 1: - strategy_name = "Standard Fill" - await locator.focus() - await locator.select_text() - await locator.fill(str(target_value)) + strategy_name = "Ctrl+A Fill" + await locator.click() + await locator.press('Control+a') + await locator.type(str(target_value), delay=30) await locator.press('Tab') await asyncio.sleep(DELAY_AFTER_FILL) await locator.dispatch_event('change') else: strategy_name = "Select & Type" await locator.focus() - await locator.select_text() + await locator.press('Control+a') await locator.press('Backspace') await asyncio.sleep(SLEEP_TICK) await locator.type(str(target_value), delay=50) @@ -1093,9 +1093,14 @@ async def submit_prompt(self, prompt: str, image_list: List, check_client_discon await asyncio.sleep(SLEEP_TICK) submitted_successfully = await self._try_shortcut_submit(prompt_textarea_locator, check_client_disconnected) if not submitted_successfully: - self.logger.info(f'[{self.req_id}] 快捷键提交失败,尝试点击提交按钮...') - await click_element(self.page, submit_button_locator, 'Submit Button', self.req_id, internal_timeout=10000) - self.logger.info(f'[{self.req_id}] 提交按钮点击完成。') + # Check if response already started (submission may have succeeded despite verification failure) + response_container = self.page.locator(RESPONSE_CONTAINER_SELECTOR) + if await response_container.count() > 0 and await response_container.last.is_visible(timeout=2000): + self.logger.info(f'[{self.req_id}] 快捷键验证失败但响应已开始,视为提交成功。') + else: + self.logger.info(f'[{self.req_id}] 快捷键提交失败,尝试点击提交按钮...') + await click_element(self.page, submit_button_locator, 'Submit Button', self.req_id, internal_timeout=10000) + self.logger.info(f'[{self.req_id}] 提交按钮点击完成。') await self._check_disconnect(check_client_disconnected, '提交后') except Exception as e_input_submit: From 53e31a3e4dcba91935aa4b5a078c8dc673eab7bd Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 19:21:37 +0900 Subject: [PATCH 12/19] fix: downgrade model TargetClosedError to debug, retry stream proxy on port failure - model_management: TargetClosedError in set_model_from_display -> debug - app.py: retry stream proxy up to 3 times with port increment on failure - server.py: add STREAM_PORT_ACTUAL global --- src/api/app.py | 27 ++++++++++++++++----------- src/browser/model_management.py | 6 +++++- src/server.py | 1 + 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/api/app.py b/src/api/app.py index 08b0da3..c90a55e 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -88,18 +88,23 @@ async def _start_stream_proxy(): port = int(STREAM_PORT or 3120) STREAM_PROXY_SERVER_ENV = os.environ.get('UNIFIED_PROXY_CONFIG') or os.environ.get('HTTPS_PROXY') or os.environ.get('HTTP_PROXY') server.logger.info(f'Starting STREAM proxy on port {port} with upstream proxy: {STREAM_PROXY_SERVER_ENV}') - server.STREAM_QUEUE = multiprocessing.Queue() - server.STREAM_PROCESS = multiprocessing.Process(target=proxy.start, args=(server.STREAM_QUEUE, port, STREAM_PROXY_SERVER_ENV)) - server.STREAM_PROCESS.start() - server.logger.info('STREAM proxy process started. Waiting for port readiness...') - if await _wait_for_port(port, timeout=30.0): - server.logger.info(f'STREAM proxy port {port} is ready.') - else: - server.logger.error(f'STREAM proxy port {port} not ready after timeout. Browser may fail to connect.') - if server.STREAM_PROCESS and server.STREAM_PROCESS.is_alive(): - server.logger.warning('STREAM proxy process is alive but port not listening.') + for attempt in range(3): + current_port = port + attempt + server.STREAM_QUEUE = multiprocessing.Queue() + server.STREAM_PROCESS = multiprocessing.Process(target=proxy.start, args=(server.STREAM_QUEUE, current_port, STREAM_PROXY_SERVER_ENV)) + server.STREAM_PROCESS.start() + server.logger.info(f'STREAM proxy process started on port {current_port}. Waiting for port readiness...') + if await _wait_for_port(current_port, timeout=30.0): + server.STREAM_PORT_ACTUAL = current_port + server.logger.info(f'STREAM proxy port {current_port} is ready.') + if current_port != port: + server.logger.warning(f'STREAM proxy using fallback port {current_port} (requested {port}).') + return else: - server.logger.error(f'STREAM proxy process died. Exit code: {server.STREAM_PROCESS.exitcode}') + server.logger.warning(f'STREAM proxy port {current_port} not ready, killing process...') + server.STREAM_PROCESS.terminate() + server.STREAM_PROCESS.join(timeout=3) + server.logger.error(f'STREAM proxy failed to start after 3 attempts.') async def _initialize_browser_and_page(): import server diff --git a/src/browser/model_management.py b/src/browser/model_management.py index 477521a..9cc46dd 100644 --- a/src/browser/model_management.py +++ b/src/browser/model_management.py @@ -484,4 +484,8 @@ async def _set_model_from_page_display(page: AsyncPage, set_storage: bool=False) await page.evaluate("(prefsStr) => localStorage.setItem('aiStudioUserPreference', prefsStr)", json.dumps(prefs_to_set)) logger.info(f" ✅ localStorage.aiStudioUserPreference 已更新。isAdvancedOpen: {prefs_to_set.get('isAdvancedOpen')}, areToolsOpen: {prefs_to_set.get('areToolsOpen')} (期望: True), promptModel: '{prefs_to_set.get('promptModel', '未设置/保留原样')}'。") except Exception as e_set_disp: - logger.error(f' 尝试从页面显示设置模型时出错: {e_set_disp}', exc_info=True) \ No newline at end of file + from playwright._impl._errors import TargetClosedError + if isinstance(e_set_disp, TargetClosedError): + logger.debug(f' 尝试从页面显示设置模型时出错 (browser closed): {e_set_disp}') + else: + logger.error(f' 尝试从页面显示设置模型时出错: {e_set_disp}', exc_info=True) \ No newline at end of file diff --git a/src/server.py b/src/server.py index 75470f4..ff960c6 100644 --- a/src/server.py +++ b/src/server.py @@ -35,6 +35,7 @@ from api import generate_sse_chunk, generate_sse_stop_chunk, generate_sse_error_chunk, use_helper_get_response, use_stream_response, clear_stream_queue, prepare_combined_prompt, validate_chat_request, _process_request_refactored, create_app, queue_worker STREAM_QUEUE: Optional[multiprocessing.Queue] = None STREAM_PROCESS = None +STREAM_PORT_ACTUAL: Optional[int] = None playwright_manager: Optional[AsyncPlaywright] = None browser_instance: Optional[AsyncBrowser] = None page_instance: Optional[AsyncPage] = None From a8b7aecbac9e2fbba9fee4001a82b801f4d41fe9 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 19:25:49 +0900 Subject: [PATCH 13/19] fix: retry Camoufox launch up to 3 times on WebSocket endpoint timeout If Camoufox fails to output WebSocket endpoint within timeout, kill the process and retry up to 3 times with 3s delay between attempts. --- src/launch_camoufox.py | 101 +++++++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 45 deletions(-) diff --git a/src/launch_camoufox.py b/src/launch_camoufox.py index 60b3ff0..a3b146f 100644 --- a/src/launch_camoufox.py +++ b/src/launch_camoufox.py @@ -719,55 +719,66 @@ def determine_proxy_configuration(internal_camoufox_proxy_arg=None): camoufox_popen_kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW try: logger.info(f" 将执行 Camoufox 内部启动命令: {' '.join(camoufox_internal_cmd_args)}") - camoufox_proc = subprocess.Popen(camoufox_internal_cmd_args, **camoufox_popen_kwargs) - logger.info(f' Camoufox 内部进程已启动 (PID: {camoufox_proc.pid})。正在等待 WebSocket 端点输出 (最长 {ENDPOINT_CAPTURE_TIMEOUT} 秒)...') - camoufox_output_q = queue.Queue() - camoufox_stdout_reader = threading.Thread(target=_enqueue_output, args=(camoufox_proc.stdout, 'stdout', camoufox_output_q, camoufox_proc.pid), daemon=True) - camoufox_stderr_reader = threading.Thread(target=_enqueue_output, args=(camoufox_proc.stderr, 'stderr', camoufox_output_q, camoufox_proc.pid), daemon=True) - camoufox_stdout_reader.start() - camoufox_stderr_reader.start() - ws_capture_start_time = time.time() - camoufox_ended_streams_count = 0 - while time.time() - ws_capture_start_time < ENDPOINT_CAPTURE_TIMEOUT: - if camoufox_proc.poll() is not None: - logger.error(f' Camoufox 内部进程 (PID: {camoufox_proc.pid}) 在等待 WebSocket 端点期间已意外退出,退出码: {camoufox_proc.poll()}。') - break - try: - stream_name, line_from_camoufox = camoufox_output_q.get(timeout=0.2) - if line_from_camoufox is None: - camoufox_ended_streams_count += 1 - logger.debug(f' [InternalCamoufox-{stream_name}-PID:{camoufox_proc.pid}] 输出流已关闭 (EOF)。') - if camoufox_ended_streams_count >= 2: - logger.info(f' Camoufox 内部进程 (PID: {camoufox_proc.pid}) 的所有输出流均已关闭。') + MAX_CAMOUFOX_RETRIES = 3 + for camoufox_attempt in range(MAX_CAMOUFOX_RETRIES): + if camoufox_attempt > 0: + logger.warning(f' 🔄 重试启动 Camoufox (第 {camoufox_attempt + 1}/{MAX_CAMOUFOX_RETRIES} 次)...') + camoufox_proc = subprocess.Popen(camoufox_internal_cmd_args, **camoufox_popen_kwargs) + logger.info(f' Camoufox 内部进程已启动 (PID: {camoufox_proc.pid})。正在等待 WebSocket 端点输出 (最长 {ENDPOINT_CAPTURE_TIMEOUT} 秒)...') + camoufox_output_q = queue.Queue() + camoufox_stdout_reader = threading.Thread(target=_enqueue_output, args=(camoufox_proc.stdout, 'stdout', camoufox_output_q, camoufox_proc.pid), daemon=True) + camoufox_stderr_reader = threading.Thread(target=_enqueue_output, args=(camoufox_proc.stderr, 'stderr', camoufox_output_q, camoufox_proc.pid), daemon=True) + camoufox_stdout_reader.start() + camoufox_stderr_reader.start() + ws_capture_start_time = time.time() + camoufox_ended_streams_count = 0 + while time.time() - ws_capture_start_time < ENDPOINT_CAPTURE_TIMEOUT: + if camoufox_proc.poll() is not None: + logger.error(f' Camoufox 内部进程 (PID: {camoufox_proc.pid}) 在等待 WebSocket 端点期间已意外退出,退出码: {camoufox_proc.poll()}。') + break + try: + stream_name, line_from_camoufox = camoufox_output_q.get(timeout=0.2) + if line_from_camoufox is None: + camoufox_ended_streams_count += 1 + logger.debug(f' [InternalCamoufox-{stream_name}-PID:{camoufox_proc.pid}] 输出流已关闭 (EOF)。') + if camoufox_ended_streams_count >= 2: + logger.info(f' Camoufox 内部进程 (PID: {camoufox_proc.pid}) 的所有输出流均已关闭。') + break + continue + log_line_content = f'[InternalCamoufox-{stream_name}-PID:{camoufox_proc.pid}]: {line_from_camoufox.rstrip()}' + if stream_name == 'stderr' or 'ERROR' in line_from_camoufox.upper() or '❌' in line_from_camoufox: + logger.warning(log_line_content) + else: + logger.info(log_line_content) + ws_match = ws_regex.search(line_from_camoufox) + if ws_match: + captured_ws_endpoint = ws_match.group(1) + logger.info(f' ✅ 成功从 Camoufox 内部进程捕获到 WebSocket 端点: {captured_ws_endpoint[:40]}...') break + except queue.Empty: continue - log_line_content = f'[InternalCamoufox-{stream_name}-PID:{camoufox_proc.pid}]: {line_from_camoufox.rstrip()}' - if stream_name == 'stderr' or 'ERROR' in line_from_camoufox.upper() or '❌' in line_from_camoufox: - logger.warning(log_line_content) - else: - logger.info(log_line_content) - ws_match = ws_regex.search(line_from_camoufox) - if ws_match: - captured_ws_endpoint = ws_match.group(1) - logger.info(f' ✅ 成功从 Camoufox 内部进程捕获到 WebSocket 端点: {captured_ws_endpoint[:40]}...') - break - except queue.Empty: - continue - if camoufox_stdout_reader.is_alive(): - camoufox_stdout_reader.join(timeout=1.0) - if camoufox_stderr_reader.is_alive(): - camoufox_stderr_reader.join(timeout=1.0) - if not captured_ws_endpoint and (camoufox_proc and camoufox_proc.poll() is None): - logger.error(f' ❌ 未能在 {ENDPOINT_CAPTURE_TIMEOUT} 秒内从 Camoufox 内部进程 (PID: {camoufox_proc.pid}) 捕获到 WebSocket 端点。') - logger.error(' Camoufox 内部进程仍在运行,但未输出预期的 WebSocket 端点。请检查其日志或行为。') + if camoufox_stdout_reader.is_alive(): + camoufox_stdout_reader.join(timeout=1.0) + if camoufox_stderr_reader.is_alive(): + camoufox_stderr_reader.join(timeout=1.0) + if captured_ws_endpoint: + break + # Failed - kill process and retry + if camoufox_proc and camoufox_proc.poll() is None: + logger.warning(f' ❌ 未能在 {ENDPOINT_CAPTURE_TIMEOUT} 秒内捕获到 WebSocket 端点,终止进程 (PID: {camoufox_proc.pid})...') + camoufox_proc.terminate() + try: + camoufox_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + camoufox_proc.kill() + else: + logger.warning(f' ❌ Camoufox 内部进程已退出,未能捕获到 WebSocket 端点。') + if camoufox_attempt < MAX_CAMOUFOX_RETRIES - 1: + time.sleep(3) + if not captured_ws_endpoint: + logger.error(f' ❌ Camoufox 在 {MAX_CAMOUFOX_RETRIES} 次尝试后均未能输出 WebSocket 端点。') cleanup() sys.exit(1) - elif not captured_ws_endpoint and (camoufox_proc and camoufox_proc.poll() is not None): - logger.error(f' ❌ Camoufox 内部进程已退出,且未能捕获到 WebSocket 端点。') - sys.exit(1) - elif not captured_ws_endpoint: - logger.error(f' ❌ 未能捕获到 WebSocket 端点。') - sys.exit(1) except Exception as e_launch_camoufox_internal: logger.critical(f' ❌ 在内部启动 Camoufox 或捕获其 WebSocket 端点时发生致命错误: {e_launch_camoufox_internal}', exc_info=True) cleanup() From 56239dfe3c2cda7e366382faf005a9c5f78ee759 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 19:51:02 +0900 Subject: [PATCH 14/19] fix: use incremented port on Camoufox retry to avoid EADDRINUSE Previous retry attempts all failed because the killed process hadn't released port 9231 yet. Each retry now uses port+attempt to avoid conflict. --- src/launch_camoufox.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/launch_camoufox.py b/src/launch_camoufox.py index a3b146f..d70abff 100644 --- a/src/launch_camoufox.py +++ b/src/launch_camoufox.py @@ -722,7 +722,13 @@ def determine_proxy_configuration(internal_camoufox_proxy_arg=None): MAX_CAMOUFOX_RETRIES = 3 for camoufox_attempt in range(MAX_CAMOUFOX_RETRIES): if camoufox_attempt > 0: - logger.warning(f' 🔄 重试启动 Camoufox (第 {camoufox_attempt + 1}/{MAX_CAMOUFOX_RETRIES} 次)...') + # Use a different port to avoid EADDRINUSE from previous attempt + retry_port = args.camoufox_debug_port + camoufox_attempt + for i, arg in enumerate(camoufox_internal_cmd_args): + if arg == '--internal-camoufox-port' and i + 1 < len(camoufox_internal_cmd_args): + camoufox_internal_cmd_args[i + 1] = str(retry_port) + break + logger.warning(f' 🔄 重试启动 Camoufox (第 {camoufox_attempt + 1}/{MAX_CAMOUFOX_RETRIES} 次, 端口: {retry_port})...') camoufox_proc = subprocess.Popen(camoufox_internal_cmd_args, **camoufox_popen_kwargs) logger.info(f' Camoufox 内部进程已启动 (PID: {camoufox_proc.pid})。正在等待 WebSocket 端点输出 (最长 {ENDPOINT_CAPTURE_TIMEOUT} 秒)...') camoufox_output_q = queue.Queue() From 884d758b7c90ca5de48f438ca2f457f3de78bbb0 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 19:52:54 +0900 Subject: [PATCH 15/19] fix: Google Search JS fallback click, stop sequence click before press - _adjust_google_search: try JS label click as fallback when force click fails - _adjust_stop_sequences: click input before fill/press to ensure focus --- src/browser/page_controller.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index 3242d0b..5ba998c 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -395,10 +395,16 @@ async def _adjust_google_search(self, request_params: Dict[str, Any], check_clie if (new_state == 'true') == should_enable_search: self.logger.info(f'[{self.req_id}] ✅ Google Search 已{action}。') return - else: - self.logger.warning(f"[{self.req_id}] ⚠️ Google Search {action}失敗 (嘗試 {attempt}): '{new_state}'") - if attempt < max_retries: - await asyncio.sleep(DELAY_AFTER_TOGGLE) + # Force via JS click on parent label + await toggle_locator.evaluate('el => (el.closest("label") || el).click()') + await asyncio.sleep(1.0) + new_state = await toggle_locator.get_attribute('aria-checked') + if (new_state == 'true') == should_enable_search: + self.logger.info(f'[{self.req_id}] ✅ Google Search 已{action} (JS)。') + return + self.logger.warning(f"[{self.req_id}] ⚠️ Google Search {action}失敗 (嘗試 {attempt}): '{new_state}'") + if attempt < max_retries: + await asyncio.sleep(DELAY_AFTER_TOGGLE) except Exception as e: if isinstance(e, ClientDisconnectedError): raise @@ -710,6 +716,7 @@ async def _adjust_stop_sequences(self, stop_sequences, page_params_cache: dict, if normalized_requested_stops: await expect_async(stop_input_locator).to_be_visible(timeout=5000) for seq in normalized_requested_stops: + await stop_input_locator.click(timeout=3000) await stop_input_locator.fill(seq, timeout=5000) await stop_input_locator.press('Enter', timeout=5000) await asyncio.sleep(DELAY_AFTER_FILL) From 13e812e0a18a353bc4cfb287c226f940df95aed7 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 19:59:20 +0900 Subject: [PATCH 16/19] fix: scroll into view for Google Search/stop sequence, find free port for Camoufox - Google Search toggle: scroll_into_view_if_needed before click - Stop sequence input: scroll_into_view_if_needed before click - Camoufox: find free port on every attempt (not just retries) --- src/browser/page_controller.py | 2 ++ src/launch_camoufox.py | 25 ++++++++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index 5ba998c..f99960c 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -388,6 +388,7 @@ async def _adjust_google_search(self, request_params: Dict[str, Any], check_clie return action = '打開' if should_enable_search else '關閉' self.logger.info(f'[{self.req_id}] 🌍 (嘗試 {attempt}/{max_retries}) 正在{action} Google Search...') + await toggle_locator.scroll_into_view_if_needed(timeout=3000) await toggle_locator.click(force=True, timeout=3000) await self._check_disconnect(check_client_disconnected, f'Google Search 開關 - 點擊{action}後') await asyncio.sleep(1.0) @@ -715,6 +716,7 @@ async def _adjust_stop_sequences(self, stop_sequences, page_params_cache: dict, break if normalized_requested_stops: await expect_async(stop_input_locator).to_be_visible(timeout=5000) + await stop_input_locator.scroll_into_view_if_needed(timeout=3000) for seq in normalized_requested_stops: await stop_input_locator.click(timeout=3000) await stop_input_locator.fill(seq, timeout=5000) diff --git a/src/launch_camoufox.py b/src/launch_camoufox.py index d70abff..3104bdc 100644 --- a/src/launch_camoufox.py +++ b/src/launch_camoufox.py @@ -719,16 +719,27 @@ def determine_proxy_configuration(internal_camoufox_proxy_arg=None): camoufox_popen_kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW try: logger.info(f" 将执行 Camoufox 内部启动命令: {' '.join(camoufox_internal_cmd_args)}") + def _find_free_port(start_port: int, max_tries: int = 10) -> int: + for p in range(start_port, start_port + max_tries): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('', p)) + return p + except OSError: + continue + return start_port MAX_CAMOUFOX_RETRIES = 3 for camoufox_attempt in range(MAX_CAMOUFOX_RETRIES): + # Always find a free port (avoids EADDRINUSE on first and retry attempts) + free_port = _find_free_port(args.camoufox_debug_port + camoufox_attempt) + for i, arg in enumerate(camoufox_internal_cmd_args): + if arg == '--internal-camoufox-port' and i + 1 < len(camoufox_internal_cmd_args): + camoufox_internal_cmd_args[i + 1] = str(free_port) + break if camoufox_attempt > 0: - # Use a different port to avoid EADDRINUSE from previous attempt - retry_port = args.camoufox_debug_port + camoufox_attempt - for i, arg in enumerate(camoufox_internal_cmd_args): - if arg == '--internal-camoufox-port' and i + 1 < len(camoufox_internal_cmd_args): - camoufox_internal_cmd_args[i + 1] = str(retry_port) - break - logger.warning(f' 🔄 重试启动 Camoufox (第 {camoufox_attempt + 1}/{MAX_CAMOUFOX_RETRIES} 次, 端口: {retry_port})...') + logger.warning(f' 🔄 重试启动 Camoufox (第 {camoufox_attempt + 1}/{MAX_CAMOUFOX_RETRIES} 次, 端口: {free_port})...') + else: + logger.info(f' 使用端口 {free_port} 启动 Camoufox...') camoufox_proc = subprocess.Popen(camoufox_internal_cmd_args, **camoufox_popen_kwargs) logger.info(f' Camoufox 内部进程已启动 (PID: {camoufox_proc.pid})。正在等待 WebSocket 端点输出 (最长 {ENDPOINT_CAPTURE_TIMEOUT} 秒)...') camoufox_output_q = queue.Queue() From f0e2a812982c7fe071023b1fc3ae11f8a2c53614 Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 20:49:40 +0900 Subject: [PATCH 17/19] fix: early exit on EADDRINUSE, improve free port detection - Break immediately on EADDRINUSE instead of waiting full 90s timeout - Use 127.0.0.1 bind and SO_REUSEADDR=0 for more accurate port availability check - Increase max_tries to 20 to skip wider reserved port ranges --- src/launch_camoufox.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/launch_camoufox.py b/src/launch_camoufox.py index 3104bdc..b3638bb 100644 --- a/src/launch_camoufox.py +++ b/src/launch_camoufox.py @@ -719,15 +719,16 @@ def determine_proxy_configuration(internal_camoufox_proxy_arg=None): camoufox_popen_kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW try: logger.info(f" 将执行 Camoufox 内部启动命令: {' '.join(camoufox_internal_cmd_args)}") - def _find_free_port(start_port: int, max_tries: int = 10) -> int: + def _find_free_port(start_port: int, max_tries: int = 20) -> int: for p in range(start_port, start_port + max_tries): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(('', p)) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0) + s.bind(('127.0.0.1', p)) return p except OSError: continue - return start_port + return start_port + max_tries MAX_CAMOUFOX_RETRIES = 3 for camoufox_attempt in range(MAX_CAMOUFOX_RETRIES): # Always find a free port (avoids EADDRINUSE on first and retry attempts) @@ -767,6 +768,10 @@ def _find_free_port(start_port: int, max_tries: int = 10) -> int: logger.warning(log_line_content) else: logger.info(log_line_content) + # Early exit on port conflict - no need to wait full timeout + if 'EADDRINUSE' in line_from_camoufox: + logger.warning(f' ⚡ 检测到端口冲突,立即终止并重试...') + break ws_match = ws_regex.search(line_from_camoufox) if ws_match: captured_ws_endpoint = ws_match.group(1) From cdcb5a0ecded4d2cbd9ce8b421a44f9c811992eb Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 20:51:53 +0900 Subject: [PATCH 18/19] fix: downgrade TargetClosedError in model localStorage init to debug --- src/browser/model_management.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/browser/model_management.py b/src/browser/model_management.py index 9cc46dd..1a34849 100644 --- a/src/browser/model_management.py +++ b/src/browser/model_management.py @@ -407,6 +407,10 @@ async def _handle_initial_model_state_and_storage(page: AsyncPage): else: logger.info(' localStorage 状态良好 (isAdvancedOpen=true, promptModel有效),无需刷新页面。') except Exception as e: + from playwright._impl._errors import TargetClosedError + if isinstance(e, TargetClosedError): + logger.debug(f'处理初始模型状态时浏览器已关闭: {e}') + return logger.error(f'❌ (新) 处理初始模型状态和 localStorage 时发生严重错误: {e}', exc_info=True) try: logger.warning(' 由于发生错误,尝试回退仅从页面显示设置全局模型 ID (不写入localStorage)...') From 8633d16e3a321dc9ec2e1797b6e8099e4e15448a Mon Sep 17 00:00:00 2001 From: maebahesioru Date: Thu, 19 Mar 2026 20:53:39 +0900 Subject: [PATCH 19/19] fix: use fill() instead of type() for spinbutton parameters type() simulates keystrokes which causes decimal point to be ignored in number inputs (e.g. '1.0' -> '10' -> clamped to max '2'). fill() sets value directly, avoiding this issue. --- src/browser/page_controller.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index f99960c..bee463c 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -608,17 +608,17 @@ def extract_float(s): strategy_name = "Ctrl+A Fill" await locator.click() await locator.press('Control+a') - await locator.type(str(target_value), delay=30) + await locator.fill(str(target_value)) + await locator.dispatch_event('input') + await locator.dispatch_event('change') await locator.press('Tab') await asyncio.sleep(DELAY_AFTER_FILL) - await locator.dispatch_event('change') else: - strategy_name = "Select & Type" - await locator.focus() - await locator.press('Control+a') - await locator.press('Backspace') - await asyncio.sleep(SLEEP_TICK) - await locator.type(str(target_value), delay=50) + strategy_name = "Triple Click Fill" + await locator.click(click_count=3) + await locator.fill(str(target_value)) + await locator.dispatch_event('input') + await locator.dispatch_event('change') await locator.press('Tab') await asyncio.sleep(SLEEP_LONG)