diff --git a/auto_update.bat b/auto_update.bat new file mode 100644 index 0000000..c05c211 --- /dev/null +++ b/auto_update.bat @@ -0,0 +1,41 @@ +@echo off +cd /d "%~dp0" + +where git >nul 2>&1 +if %errorlevel% neq 0 ( + echo Git not found, skipping update check. + exit /b 0 +) + +echo Checking for updates... + +git fetch origin main --quiet 2>nul +if %errorlevel% neq 0 ( + echo Could not reach GitHub, skipping update check. + exit /b 0 +) + +for /f %%i in ('git rev-parse HEAD') do set LOCAL=%%i +for /f %%i in ('git rev-parse origin/main') do set REMOTE=%%i + +if "%LOCAL%"=="%REMOTE%" ( + echo Already up to date. + exit /b 0 +) + +echo Update available! Applying... +git pull origin main --quiet +if %errorlevel% neq 0 ( + echo Update failed. Please run 'git pull' manually. + exit /b 1 +) + +echo Running uv sync... +uv sync --quiet +if %errorlevel% neq 0 ( + echo Dependency update failed. + exit /b 1 +) + +echo Update complete! +exit /b 0 diff --git a/src/api/request_processor.py b/src/api/request_processor.py index 9768474..39a455e 100644 --- a/src/api/request_processor.py +++ b/src/api/request_processor.py @@ -200,7 +200,7 @@ async def listen_for_disconnect(): # 直接等待 ASGI 消息,不再轮询 message = await http_request.receive() if message['type'] == 'http.disconnect': - logger.warning(f'[{req_id}] 🔌 收到 http.disconnect 信号') + logger.debug(f'[{req_id}] 🔌 收到 http.disconnect 信号') client_disconnected_event.set() if not result_future.done(): result_future.set_exception(HTTPException(status_code=499, detail=f'[{req_id}] 客户端关闭了请求')) diff --git a/src/browser/model_management.py b/src/browser/model_management.py index 68e91fd..2ac7d30 100644 --- a/src/browser/model_management.py +++ b/src/browser/model_management.py @@ -383,6 +383,10 @@ async def _handle_initial_model_state_and_storage(page: AsyncPage): logger.warning(f' ⚠️ 重新加载后UI状态验证失败') break 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' ⚠️ 浏览器已关闭,跳过重新加载。') + return logger.warning(f' ⚠️ 页面重新加载尝试 {attempt + 1}/{max_retries} 失败: {reload_err}') if attempt < max_retries - 1: logger.info(f' 将在5秒后重试...') diff --git a/src/browser/page_controller.py b/src/browser/page_controller.py index 6a9fa67..8a6b4b1 100644 --- a/src/browser/page_controller.py +++ b/src/browser/page_controller.py @@ -105,6 +105,16 @@ async def set_system_instructions(self, system_prompt: str, check_client_disconn self.logger.warning(f'[{self.req_id}] ⚠️ 系统指令填充可能不完整 (期望: {len(system_prompt)}, 实际: {len(filled_value)})') for close_attempt in range(1, 4): try: + if not await sys_prompt_textarea.is_visible(): + self.logger.info(f'[{self.req_id}] ✅ 系统指令面板已关闭。') + break + # Try clicking the button again to close the panel + await sys_prompt_button.click(timeout=2000) + await asyncio.sleep(DELAY_AFTER_FILL) + if not await sys_prompt_textarea.is_visible(): + self.logger.info(f'[{self.req_id}] ✅ 系统指令面板已关闭。') + break + # Fallback: Escape key await self.page.keyboard.press("Escape") await asyncio.sleep(DELAY_AFTER_FILL) if not await sys_prompt_textarea.is_visible(): @@ -570,12 +580,17 @@ def is_equal(val1, val2): await asyncio.sleep(DELAY_AFTER_TOGGLE) 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 asyncio.sleep(DELAY_AFTER_FILL) + await locator.press('Enter') + elif attempt == 1: strategy_name = "Standard Fill" await locator.focus() await locator.fill(str(target_value)) await locator.dispatch_event('change') await locator.press('Enter') - elif attempt == 1: + else: strategy_name = "Select & Type" await locator.focus() await locator.select_text() @@ -583,11 +598,6 @@ def is_equal(val1, val2): await asyncio.sleep(SLEEP_TICK) await locator.type(str(target_value), delay=50) await locator.press('Enter') - else: - 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 asyncio.sleep(DELAY_AFTER_FILL) - await locator.press('Enter') await asyncio.sleep(SLEEP_LONG) diff --git a/src/browser/selector_utils.py b/src/browser/selector_utils.py index 72a15a5..d212ea5 100644 --- a/src/browser/selector_utils.py +++ b/src/browser/selector_utils.py @@ -9,41 +9,22 @@ async def wait_for_any_selector( timeout: int = 5000, state: str = 'visible' ) -> Tuple[Optional[Locator], Optional[str]]: - async def check_one(selector: str) -> Tuple[bool, str]: - try: - locator = page.locator(selector) - await locator.wait_for(state=state, timeout=timeout) - return (True, selector) - except: - return (False, selector) - - tasks = [asyncio.create_task(check_one(sel)) for sel in selectors] - - async def cancel_tasks(pending_tasks): - for p in pending_tasks: - p.cancel() - await asyncio.gather(*pending_tasks, return_exceptions=True) - + combined = ", ".join(selectors) try: - done, pending = await asyncio.wait( - tasks, - return_when=asyncio.FIRST_COMPLETED, - timeout=timeout / 1000 + 1 - ) - - for task in done: - success, selector = task.result() - if success: - await cancel_tasks(pending) - return (page.locator(selector), selector) - - await cancel_tasks(pending) - return (None, None) - - except asyncio.TimeoutError: - await cancel_tasks(tasks) + await page.locator(combined).first.wait_for(state=state, timeout=timeout) + except Exception: return (None, None) + for selector in selectors: + try: + loc = page.locator(selector) + if await loc.count() > 0 and await loc.first.is_visible(): + return (loc, selector) + except Exception: + continue + + return (page.locator(combined), combined) + async def get_first_visible_locator( diff --git a/src/gateway.py b/src/gateway.py index b11c9ba..1ae5d2b 100644 --- a/src/gateway.py +++ b/src/gateway.py @@ -8,6 +8,7 @@ import aiohttp import uvicorn from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response, StreamingResponse @@ -117,6 +118,13 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + @app.get("/", tags=["System"], summary="网关状态") async def root(): @@ -151,6 +159,8 @@ async def models(): async def chat_completions(request: Request): await refresh_workers() body = await request.body() + if not body: + raise HTTPException(status_code=400, detail="Request body is empty") body_json = json.loads(body) is_stream = body_json.get("stream", False) model_id = body_json.get("model", "") diff --git a/src/launch_camoufox.py b/src/launch_camoufox.py index 1d8ed3c..60b3ff0 100644 --- a/src/launch_camoufox.py +++ b/src/launch_camoufox.py @@ -32,7 +32,7 @@ launch_server = None DefaultAddons = None PYTHON_EXECUTABLE = sys.executable -ENDPOINT_CAPTURE_TIMEOUT = int(os.environ.get('ENDPOINT_CAPTURE_TIMEOUT', '45')) +ENDPOINT_CAPTURE_TIMEOUT = int(os.environ.get('ENDPOINT_CAPTURE_TIMEOUT', '90')) DEFAULT_SERVER_PORT = int(os.environ.get('DEFAULT_FASTAPI_PORT', '2048')) DEFAULT_CAMOUFOX_PORT = int(os.environ.get('DEFAULT_CAMOUFOX_PORT', '9222')) DEFAULT_STREAM_PORT = int(os.environ.get('STREAM_PORT', '3120')) @@ -129,7 +129,7 @@ def cleanup(): camoufox_proc.terminate() elif sys.platform == 'win32': logger.info(f'进程树 (PID: {pid}) 发送终止请求') - subprocess.call(['taskkill', '/T', '/PID', str(pid)]) + subprocess.call(['taskkill', '/F', '/T', '/PID', str(pid)]) else: logger.info(f' 向 Camoufox (PID: {pid}) 发送 SIGTERM 信号...') camoufox_proc.terminate() @@ -465,17 +465,42 @@ def determine_proxy_configuration(internal_camoufox_proxy_arg=None): print(f'--- [内部Camoufox启动] 正在调用 camoufox.server.launch_server ... ---', flush=True) try: memory_optimization_prefs = { + # Disable memory cache 'browser.cache.memory.enable': False, 'browser.cache.memory.capacity': 0, - 'browser.sessionhistory.max_entries': 3, + # Minimal session history + 'browser.sessionhistory.max_entries': 2, 'browser.sessionhistory.max_total_viewers': 0, + # JS memory limits 'javascript.options.mem.gc_frequency': 300, - 'javascript.options.mem.high_water_mark': 128, + 'javascript.options.mem.high_water_mark': 32, + 'javascript.options.mem.nursery.max_kb': 2048, + # Single process mode 'dom.ipc.processCount': 1, + 'dom.ipc.processCount.webIsolated': 1, + 'browser.tabs.remote.autostart': False, + 'browser.tabs.remote.autostart.2': False, + # Disable unused features 'layout.css.grid-template-masonry-value.enabled': False, 'toolkit.cosmeticAnimations.enabled': False, + 'media.memory_cache_max_size': 256, + 'image.mem.max_ms_before_yield': 50, + # Disable telemetry/background services + 'datareporting.healthreport.uploadEnabled': False, + 'datareporting.policy.dataSubmissionEnabled': False, + 'browser.ping-centre.telemetry': False, + 'toolkit.telemetry.enabled': False, + 'toolkit.telemetry.unified': False, + # Disable disk cache + 'browser.cache.disk.enable': False, + 'browser.cache.offline.enable': False, + # Disable prefetch + 'network.prefetch-next': False, + 'network.dns.disablePrefetch': True, + # Reduce font memory + 'gfx.font_rendering.fontconfig.max_generic_substitutions': 3, } - launch_args_for_internal_camoufox = {'port': camoufox_port_internal, 'addons': [], 'exclude_addons': [DefaultAddons.UBO], 'window': (1920, 1080), 'firefox_user_prefs': memory_optimization_prefs} + launch_args_for_internal_camoufox = {'port': camoufox_port_internal, 'addons': [], 'exclude_addons': [DefaultAddons.UBO], 'window': (1280, 720), 'firefox_user_prefs': memory_optimization_prefs} if camoufox_proxy_internal: launch_args_for_internal_camoufox['proxy'] = {'server': camoufox_proxy_internal} if auth_file: diff --git a/src/manager/service.py b/src/manager/service.py index f06d6cd..40ff713 100644 --- a/src/manager/service.py +++ b/src/manager/service.py @@ -450,7 +450,7 @@ async def _start_worker_mode( started_count += 1 logger.info(f"启动Worker {worker_id} (端口:{worker.port})") if index < len(worker_ids) - 1: - await asyncio.sleep(3) + await asyncio.sleep(15) if self.stop_event.is_set(): self.service_status = "stopped" diff --git a/src/proxy/server.py b/src/proxy/server.py index 9bd2174..4f7bb62 100644 --- a/src/proxy/server.py +++ b/src/proxy/server.py @@ -55,15 +55,19 @@ def _parse_headers(header_bytes: bytes) -> Dict[str, str]: "bad record mac", "Connection reset", "Connection aborted", + "ConnectionResetError", "EOF occurred in violation", "WRONG_VERSION_NUMBER", "\u9023\u7dda\u5df2\u88ab\u60a8\u4e3b\u6a5f\u4e0a\u7684\u8edf\u9ad4\u4e2d\u6b62", "\u9060\u7aef\u4e3b\u6a5f\u5df2\u5f37\u5236\u95dc\u9589", + # Japanese locale (WinError 10053 / 10054) + "\u78ba\u7acb\u3055\u308c\u305f\u63a5\u7d9a\u304c\u30db\u30b9\u30c8", + "\u30ea\u30e2\u30fc\u30c8 \u30db\u30b9\u30c8\u306b\u3088\u3063\u3066\u5f37\u5236\u7684\u306b\u9589\u3058\u3089\u308c", ) @classmethod def _should_ignore_connection_error(cls, exc: BaseException) -> bool: - err_str = str(exc) + err_str = f"{type(exc).__name__}: {exc}" return any(marker in err_str for marker in cls._IGNORABLE_ERRORS) async def _run_relay_tasks(self, *coroutines) -> None: @@ -121,7 +125,8 @@ async def accept_client( if method == "CONNECT": await self._process_tunnel(reader, writer, target) except Exception as e: - self.log.error(f"Client error: {e}") + if not self._should_ignore_connection_error(e): + self.log.error(f"Client error: {e!r}", exc_info=True) finally: writer.close() diff --git a/src/worker/pool.py b/src/worker/pool.py index 290eb42..75755b3 100644 --- a/src/worker/pool.py +++ b/src/worker/pool.py @@ -244,6 +244,36 @@ def _cancel_restart(self, worker_id: str): if task and not task.done(): task.cancel() + def _free_port(self, port: int) -> None: + """Kill any process occupying the given TCP port.""" + try: + if platform.system() == "Windows": + result = subprocess.run( + ["netstat", "-ano"], + capture_output=True, text=True + ) + for line in result.stdout.splitlines(): + if f":{port} " in line and "LISTENING" in line: + parts = line.split() + pid = parts[-1] + if pid.isdigit(): + subprocess.run( + ["taskkill", "/PID", pid, "/F"], + capture_output=True + ) + logger.info(f"Freed port {port} (killed PID {pid})") + else: + result = subprocess.run( + ["lsof", "-ti", f"tcp:{port}"], + capture_output=True, text=True + ) + for pid in result.stdout.strip().splitlines(): + if pid.isdigit(): + subprocess.run(["kill", "-9", pid], capture_output=True) + logger.info(f"Freed port {port} (killed PID {pid})") + except Exception as e: + logger.warning(f"Failed to free port {port}: {e}") + def start_worker(self, worker_id: str) -> tuple[bool, str]: if worker_id not in self.workers: return False, "Worker not found" @@ -255,6 +285,11 @@ def start_worker(self, worker_id: str) -> tuple[bool, str]: ): return False, "Worker already running" self._cancel_restart(worker_id) + self._free_port(worker.camoufox_port) + self._free_port(worker.port) + stream_port = self._resolve_stream_port(worker) + if stream_port: + self._free_port(stream_port) try: worker.process = subprocess.Popen( self._build_worker_command(worker), @@ -269,6 +304,7 @@ def start_worker(self, worker_id: str) -> tuple[bool, str]: worker.health_failures = 0 worker.last_health_check = None worker.last_error = None + worker._start_time = time.time() self._notify_process_started(worker) self._notify_status_change(worker, "started") logger.info(f"Started worker {worker_id} on port {worker.port}") @@ -520,6 +556,13 @@ async def health_check( for worker in list(self.workers.values()): if worker.status != "running": continue + # Grace period: skip health check for 60s after start + if worker.last_health_check is None and worker.process is not None: + start_time = getattr(worker, '_start_time', None) + if start_time is None or time.time() - start_time < 60: + if start_time is None: + worker._start_time = time.time() + continue is_healthy, error = await self._probe_worker_health(worker) worker.last_health_check = time.time() if is_healthy: @@ -538,6 +581,7 @@ async def health_check( self._schedule_restart(worker.id) async def health_check_loop(self): + await asyncio.sleep(90) while True: try: await self.health_check() diff --git a/start_cmd.bat b/start_cmd.bat index 03fcbf3..cc78d7d 100644 --- a/start_cmd.bat +++ b/start_cmd.bat @@ -1,5 +1,8 @@ @echo off cd /d "%~dp0" + +call auto_update.bat + set PYTHONPATH=%~dp0src;%PYTHONPATH% uv run python src/launch_camoufox.py -pause \ No newline at end of file +pause diff --git a/start_webui.bat b/start_webui.bat index 2b4457e..5bbad48 100644 --- a/start_webui.bat +++ b/start_webui.bat @@ -3,6 +3,8 @@ cd /d "%~dp0" echo Starting AI Studio Proxy Manager... +call auto_update.bat + set PYTHONPATH=%~dp0src;%PYTHONPATH% call uv run python src/app_launcher.py @@ -11,4 +13,4 @@ if %errorlevel% neq 0 ( echo Error occurred. echo Please make sure you have installed dependencies with 'uv sync'. pause -) \ No newline at end of file +)