Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions auto_update.bat
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion src/api/request_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}] 客户端关闭了请求'))
Expand Down
4 changes: 4 additions & 0 deletions src/browser/model_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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秒后重试...')
Expand Down
22 changes: 16 additions & 6 deletions src/browser/page_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -570,24 +580,24 @@ 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()
await locator.press('Backspace')
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)

Expand Down
45 changes: 13 additions & 32 deletions src/browser/selector_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions src/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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", "")
Expand Down
35 changes: 30 additions & 5 deletions src/launch_camoufox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/manager/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 7 additions & 2 deletions src/proxy/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()

Expand Down
44 changes: 44 additions & 0 deletions src/worker/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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),
Expand All @@ -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}")
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion start_cmd.bat
Original file line number Diff line number Diff line change
@@ -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
pause
Loading
Loading