Safflower/browser-use
0
1"""2Browser-Use REST API Server for Hugging Face Spaces3"""4import os5import asyncio6import base647import io8import shutil9import hmac10import hashlib11import httpx12import json13from cryptography.fernet import Fernet14from contextlib import asynccontextmanager15from datetime import datetime, timedelta16from pathlib import Path17 18# 设置超时环境变量19os.environ["TIMEOUT_BrowserStartEvent"] = "120"20os.environ["TIMEOUT_BrowserLaunchEvent"] = "120"21 22from fastapi import FastAPI, HTTPException, BackgroundTasks, Depends, Header, Query, WebSocket, WebSocketDisconnect23from fastapi.middleware.cors import CORSMiddleware24from fastapi.responses import FileResponse, StreamingResponse, Response, HTMLResponse25from pydantic import BaseModel, HttpUrl26from typing import Optional, List, Dict, Any27import uuid28 29# ==================== JSON 文件配置存储 ====================30 31# HF 配置持久化:使用 Dataset 仓库存储,避免触发 Space 重建32HF_SPACE_ID = os.environ.get("SPACE_ID", "") # HF Spaces 自动注入33# Dataset 仓库名:与 Space 同账号,名为 <space-name>-configs34_HF_OWNER = HF_SPACE_ID.split("/")[0] if HF_SPACE_ID else ""35_HF_SPACE_NAME = HF_SPACE_ID.split("/")[1] if "/" in (HF_SPACE_ID or "") else ""36HF_CONFIG_REPO = f"{_HF_OWNER}/{_HF_SPACE_NAME}-configs" if _HF_OWNER else ""37 38# 本地配置文件路径39CONFIGS_FILE = Path("/tmp/configs.json")40 41# API Key 加密密钥42def _get_fernet_key():43 """获取或生成 Fernet 密钥"""44 env_key = os.environ.get("ENCRYPTION_KEY", "")45 if env_key and len(env_key) == 44:46 return env_key.encode()47 password = os.environ.get("ACCESS_PASSWORD", "") or "browserusedefaultkey"48 key_bytes = hashlib.sha256(password.encode()).digest()49 return base64.urlsafe_b64encode(key_bytes)50 51ENCRYPTION_KEY = _get_fernet_key()52fernet = Fernet(ENCRYPTION_KEY)53 54 55def encrypt_api_key(api_key: str) -> str:56 if not api_key:57 return ""58 return fernet.encrypt(api_key.encode()).decode()59 60 61def decrypt_api_key(encrypted_key: str) -> str:62 if not encrypted_key:63 return ""64 try:65 return fernet.decrypt(encrypted_key.encode()).decode()66 except Exception:67 return encrypted_key68 69 70def _load_configs() -> list:71 """从本地文件加载配置;首次启动时从 HF Dataset 仓库拉取"""72 if not CONFIGS_FILE.exists() and HF_CONFIG_REPO:73 try:74 from huggingface_hub import hf_hub_download75 path = hf_hub_download(76 repo_id=HF_CONFIG_REPO, repo_type="dataset",77 filename="configs.json",78 )79 import shutil as _shutil80 _shutil.copy(path, str(CONFIGS_FILE))81 except Exception:82 pass83 if not CONFIGS_FILE.exists():84 return []85 try:86 return json.loads(CONFIGS_FILE.read_text(encoding="utf-8"))87 except (json.JSONDecodeError, OSError):88 return []89 90 91def _save_configs(configs: list):92 """保存配置到本地文件,并异步推送到 HF Dataset 仓库持久化"""93 content = json.dumps(configs, ensure_ascii=False, indent=2)94 CONFIGS_FILE.write_text(content, encoding="utf-8")95 if HF_CONFIG_REPO:96 import threading97 def _push():98 try:99 from huggingface_hub import HfApi100 api = HfApi()101 # 确保 Dataset 仓库存在(首次会自动创建,设为私有)102 api.create_repo(repo_id=HF_CONFIG_REPO, repo_type="dataset", private=True, exist_ok=True)103 api.upload_file(104 path_or_fileobj=content.encode("utf-8"),105 path_in_repo="configs.json",106 repo_id=HF_CONFIG_REPO,107 repo_type="dataset",108 commit_message="update configs",109 )110 except Exception:111 pass112 threading.Thread(target=_push, daemon=True).start()113 114from browser_use import Agent, Browser, BrowserProfile, ChatOpenAI115from browser_use.llm import ChatDeepSeek116from browser_use import ChatAnthropic, ChatGoogle, ChatGroq, ChatOllama117 118 119# 截图存储目录120SCREENSHOTS_DIR = Path("/tmp/browser_screenshots")121SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)122 123# 访问密码(从环境变量读取)124ACCESS_PASSWORD = os.environ.get("ACCESS_PASSWORD", "")125 126# 配置管理密码(二次验证,从环境变量读取)127CONFIG_PASSWORD = os.environ.get("CONFIG_PASSWORD", "")128 129# 并发控制130MAX_CONCURRENT_TASKS = int(os.environ.get("MAX_CONCURRENT_TASKS", "3"))131task_semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS)132 133# 任务保留时间(小时)134TASK_RETENTION_HOURS = int(os.environ.get("TASK_RETENTION_HOURS", "24"))135 136# 超时配置137TASK_TIMEOUT = int(os.environ.get("TASK_TIMEOUT", "300")) # 单个任务最大执行时间(秒),默认 5 分钟138STEP_TIMEOUT = int(os.environ.get("STEP_TIMEOUT", "30")) # 单步操作超时(秒),默认 30 秒139 140# 任务状态存储141tasks: Dict[str, Dict[str, Any]] = {}142# 运行中的任务(用于取消)143running_tasks: Dict[str, asyncio.Task] = {}144# 运行中的浏览器实例(用于取消时关闭和实时截图)145running_browsers: Dict[str, Browser] = {}146# 运行中的 Agent 实例(保留以备扩展)147running_agents: Dict[str, Agent] = {}148# 任务截图列表149task_screenshots: Dict[str, List[Dict[str, Any]]] = {}150# 任务实时日志151task_logs: Dict[str, List[Dict[str, Any]]] = {}152 153# 清理任务154cleanup_task: Optional[asyncio.Task] = None155 156 157class LLMConfig(BaseModel):158 """LLM 配置"""159 provider: str # deepseek, openai, ollama, custom160 model: str # 模型名称161 api_key: Optional[str] = None # API 密钥(Ollama 可选)162 base_url: Optional[str] = None # 自定义 API 地址(可选)163 temperature: float = 0.0164 165 166class WebhookConfig(BaseModel):167 """Webhook 配置"""168 url: HttpUrl # Webhook URL169 secret: Optional[str] = None # 用于 HMAC 签名的密钥(可选)170 events: Optional[List[str]] = None # 订阅的事件类型,None 表示全部171 172 173class TaskRequest(BaseModel):174 task: str175 headless: bool = True176 save_screenshots: bool = True177 compress_screenshots: bool = True # 是否压缩截图178 use_vision: bool = False # 是否启用视觉功能179 timeout: Optional[int] = None # 自定义超时时间(秒),默认使用环境变量 TASK_TIMEOUT180 # LLM 配置 - 必填181 llm_config: LLMConfig182 # Webhook 配置 - 可选183 webhook: Optional[WebhookConfig] = None184 185 186class TaskResponse(BaseModel):187 task_id: str188 status: str189 message: str190 191 192class StepDetail(BaseModel):193 step: int194 action: Optional[str] = None195 result: Optional[str] = None196 timestamp: Optional[str] = None197 screenshot_id: Optional[str] = None198 199 200class TaskResult(BaseModel):201 task_id: str202 status: str203 task: str204 result: Optional[str] = None205 error: Optional[str] = None206 steps: Optional[List[StepDetail]] = None207 screenshots: Optional[List[str]] = None208 llm_config: Optional[LLMConfig] = None209 created_at: Optional[str] = None210 started_at: Optional[str] = None211 completed_at: Optional[str] = None212 213 214class TaskListResponse(BaseModel):215 tasks: List[TaskResult]216 total: int217 218 219class CancelResponse(BaseModel):220 task_id: str221 status: str222 message: str223 224 225class ScreenshotInfo(BaseModel):226 screenshot_id: str227 step: int228 timestamp: str229 url: str230 231 232class ScreenshotListResponse(BaseModel):233 task_id: str234 screenshots: List[ScreenshotInfo]235 total: int236 237 238# ==================== LLM 配置管理模型 ====================239 240class LLMConfigCreate(BaseModel):241 """创建 LLM 配置请求"""242 name: str # 配置名称243 provider: str # 提供商244 model: str # 模型名称245 api_key: Optional[str] = None # API Key246 base_url: Optional[str] = None # 自定义 Base URL247 is_default: bool = False # 是否为默认配置248 249 250class LLMConfigResponse(BaseModel):251 """LLM 配置响应"""252 id: str253 name: str254 provider: str255 model: str256 api_key_masked: str # 掩码后的 API Key257 base_url: Optional[str] = None258 is_default: bool259 created_at: str260 updated_at: str261 262 263class LLMConfigWithKey(BaseModel):264 """包含完整 API Key 的配置(用于加载)"""265 id: str266 name: str267 provider: str268 model: str269 api_key: str # 完整的 API Key270 base_url: Optional[str] = None271 is_default: bool272 273 274class LLMConfigListResponse(BaseModel):275 """配置列表响应"""276 configs: List[LLMConfigResponse]277 total: int278 279 280def mask_api_key(api_key: str) -> str:281 """掩码 API Key,只显示前4位和后4位"""282 if not api_key or len(api_key) < 12:283 return "****"284 return f"{api_key[:4]}{'*' * (len(api_key) - 8)}{api_key[-4:]}"285 286 287async def periodic_cleanup():288 """定期清理过期任务"""289 global cleanup_task290 while True:291 try:292 await asyncio.sleep(3600) # 每小时执行一次293 294 cutoff_time = datetime.now() - timedelta(hours=TASK_RETENTION_HOURS)295 tasks_to_delete = []296 297 for task_id, task_info in tasks.items():298 # 跳过运行中的任务299 if task_info["status"] in ["pending", "running"]:300 continue301 302 completed_at = task_info.get("completed_at")303 if completed_at:304 try:305 completed_time = datetime.fromisoformat(completed_at)306 if completed_time < cutoff_time:307 tasks_to_delete.append(task_id)308 except:309 pass310 311 # 删除过期任务312 for task_id in tasks_to_delete:313 # 删除截图文件314 task_dir = SCREENSHOTS_DIR / task_id315 if task_dir.exists():316 shutil.rmtree(task_dir)317 318 # 删除内存数据319 tasks.pop(task_id, None)320 task_screenshots.pop(task_id, None)321 task_logs.pop(task_id, None)322 323 if tasks_to_delete:324 print(f"🧹 定期清理: 删除了 {len(tasks_to_delete)} 个过期任务")325 326 except asyncio.CancelledError:327 break328 except Exception as e:329 print(f"❌ 定期清理失败: {e}")330 331 332@asynccontextmanager333async def lifespan(app: FastAPI):334 global cleanup_task335 print("🚀 Browser-Use API Server starting...")336 print(f"⚙️ 最大并发任务数: {MAX_CONCURRENT_TASKS}")337 print(f"⏰ 任务保留时间: {TASK_RETENTION_HOURS} 小时")338 print(f"⏱️ 任务超时: {TASK_TIMEOUT} 秒")339 340 # 清理旧截图341 if SCREENSHOTS_DIR.exists():342 shutil.rmtree(SCREENSHOTS_DIR)343 SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)344 345 # 启动定期清理任务346 cleanup_task = asyncio.create_task(periodic_cleanup())347 348 yield349 350 print("👋 Browser-Use API Server shutting down...")351 352 # 停止清理任务353 if cleanup_task:354 cleanup_task.cancel()355 try:356 await cleanup_task357 except asyncio.CancelledError:358 pass359 360 # 清理所有运行中的任务361 for task_id, task in running_tasks.items():362 if not task.done():363 task.cancel()364 for browser in running_browsers.values():365 try:366 await browser.stop()367 except:368 pass369 370 371app = FastAPI(372 title="Browser-Use API",373 description="AI 浏览器自动化服务",374 version="1.2.0",375 lifespan=lifespan,376)377 378app.add_middleware(379 CORSMiddleware,380 allow_origins=["*"],381 allow_credentials=True,382 allow_methods=["*"],383 allow_headers=["*"],384)385 386 387# 历史任务保留数量388MAX_HISTORY_TASKS = 5389 390 391def verify_password(392 x_access_password: Optional[str] = Header(None, alias="X-Access-Password"),393 password: Optional[str] = Query(None)394):395 """验证访问密码"""396 if not ACCESS_PASSWORD:397 # 未设置密码,允许访问398 return True399 400 provided_password = x_access_password or password401 if provided_password != ACCESS_PASSWORD:402 raise HTTPException(status_code=401, detail="密码错误或未提供")403 return True404 405 406def verify_config_password(407 x_config_password: Optional[str] = Header(None, alias="X-Config-Password"),408 config_password: Optional[str] = Query(None, alias="config_password")409):410 """验证配置管理密码(二次验证)"""411 if not CONFIG_PASSWORD:412 # 未设置配置密码,允许访问413 return True414 415 provided_password = x_config_password or config_password416 if provided_password != CONFIG_PASSWORD:417 raise HTTPException(status_code=401, detail="配置密码错误")418 return True419 420 421async def save_screenshot(task_id: str, agent: Agent, step: int, compress: bool = False) -> Optional[str]:422 """保存浏览器截图"""423 try:424 # 生成截图ID425 screenshot_id = f"{task_id}_{step}_{datetime.now().strftime('%H%M%S%f')}"426 427 # 创建任务截图目录428 task_dir = SCREENSHOTS_DIR / task_id429 task_dir.mkdir(parents=True, exist_ok=True)430 431 # 保存截图路径432 screenshot_path = task_dir / f"{screenshot_id}.png"433 434 # 使用 agent 的 browser session 截图435 if hasattr(agent, 'browser') and agent.browser:436 browser_session = agent.browser437 # 新版本 API: 使用 take_screenshot 方法438 if hasattr(browser_session, 'take_screenshot'):439 screenshot_data = await browser_session.take_screenshot(path=str(screenshot_path))440 else:441 # 尝试旧版本 API442 return None443 else:444 return None445 446 # 压缩截图447 if compress and screenshot_path.exists():448 try:449 from PIL import Image450 img = Image.open(screenshot_path)451 # 转换为 RGB(如果是 RGBA)452 if img.mode == 'RGBA':453 img = img.convert('RGB')454 # 压缩保存为 JPEG,质量 70455 jpg_path = task_dir / f"{screenshot_id}.jpg"456 img.save(jpg_path, 'JPEG', quality=70, optimize=True)457 # 删除原始 PNG458 screenshot_path.unlink()459 screenshot_path = jpg_path460 except Exception as e:461 print(f"截图压缩失败,使用原始 PNG: {e}")462 463 # 记录截图信息464 if task_id not in task_screenshots:465 task_screenshots[task_id] = []466 467 screenshot_info = {468 "screenshot_id": screenshot_id,469 "step": step,470 "timestamp": datetime.now().isoformat(),471 "path": str(screenshot_path),472 }473 task_screenshots[task_id].append(screenshot_info)474 475 return screenshot_id476 except Exception as e:477 print(f"截图保存失败: {e}")478 return None479 480 481async def get_browser_from_pool(headless: bool) -> Browser:482 """创建新的浏览器实例483 484 注意:browser-use 库的 Browser 对象在复用时存在内部状态问题,485 会导致 BrowserStateRequestEvent 错误。因此禁用浏览器池复用,486 每次任务都创建新的浏览器实例。487 """488 clok_chrome_executable_path = os.environ.get("CKB_BINARY")489 profile = BrowserProfile(490 headless=headless,491 enable_default_extensions=False,492 executable_path=clok_chrome_executable_path,493 chromium_sandbox=False,494 )495 browser = Browser(browser_profile=profile)496 print(f"🆕 创建新浏览器实例")497 return browser498 499 500async def release_browser_to_pool(browser: Browser):501 """停止并释放浏览器实例502 503 注意:browser-use 库的 Browser 对象在复用时存在内部状态问题,504 因此每次任务完成后直接关闭浏览器,而不是放回池中复用。505 """506 try:507 await browser.stop()508 print(f"✅ 浏览器已停止并释放")509 except Exception as e:510 print(f"⚠️ 停止浏览器失败: {e}")511 512 513def cleanup_old_tasks():514 """清理旧的历史任务,只保留最近的 MAX_HISTORY_TASKS 条"""515 # 获取所有已完成的任务(completed, failed, cancelled)516 finished_tasks = [517 (task_id, task_info) for task_id, task_info in tasks.items()518 if task_info["status"] in ["completed", "failed", "cancelled"]519 ]520 521 # 按完成时间排序(最新的在前)522 finished_tasks.sort(523 key=lambda x: x[1].get("completed_at") or "",524 reverse=True525 )526 527 # 删除超出限制的任务528 tasks_to_delete = finished_tasks[MAX_HISTORY_TASKS:]529 530 for task_id, _ in tasks_to_delete:531 # 删除截图文件532 task_dir = SCREENSHOTS_DIR / task_id533 if task_dir.exists():534 shutil.rmtree(task_dir)535 task_screenshots.pop(task_id, None)536 task_logs.pop(task_id, None)537 # 删除任务记录538 del tasks[task_id]539 540 if tasks_to_delete:541 print(f"已清理 {len(tasks_to_delete)} 条旧任务")542 543 544def create_llm(config: LLMConfig):545 """根据配置创建 LLM 实例"""546 provider = config.provider.lower()547 api_key = config.api_key548 549 # 自动补全 base_url 的 /v1 后缀(Ollama 除外)550 base_url = config.base_url.rstrip("/") if config.base_url else None551 if base_url and provider != "ollama" and not base_url.endswith("/v1"):552 base_url = base_url + "/v1"553 554 # Ollama 不需要 API Key555 if provider == "ollama" and not api_key:556 api_key = "ollama"557 558 # 硅基流动 SiliconFlow (OpenAI 兼容) - 必须在 DeepSeek 之前检查559 if provider == "siliconflow":560 return ChatOpenAI(561 model=config.model,562 api_key=api_key,563 base_url=base_url or "https://api.siliconflow.cn/v1",564 temperature=config.temperature,565 )566 567 # DeepSeek568 if provider == "deepseek" or "deepseek" in config.model.lower():569 return ChatDeepSeek(570 model=config.model,571 api_key=api_key,572 base_url=base_url or "https://api.deepseek.com/v1",573 )574 575 # Anthropic (Claude)576 if provider == "anthropic":577 return ChatAnthropic(578 model=config.model,579 api_key=api_key,580 )581 582 # Google Gemini583 if provider == "google" or provider == "gemini":584 return ChatGoogle(585 model=config.model,586 api_key=api_key,587 )588 589 # Groq (快速推理)590 if provider == "groq":591 return ChatGroq(592 model=config.model,593 api_key=api_key,594 )595 596 # Ollama (本地/远程)597 if provider == "ollama":598 host = config.base_url or "http://localhost:11434"599 # 移除 /v1 后缀(ChatOllama 不需要)600 if host.endswith("/v1"):601 host = host[:-3]602 return ChatOllama(603 model=config.model,604 host=host,605 )606 607 # OpenAI 及兼容 API (custom)608 llm_kwargs = {609 "model": config.model,610 "api_key": api_key,611 "temperature": config.temperature,612 }613 if base_url:614 llm_kwargs["base_url"] = base_url615 elif provider == "openai":616 llm_kwargs["base_url"] = "https://api.openai.com/v1"617 618 return ChatOpenAI(**llm_kwargs)619 620 621def add_task_log(task_id: str, log_type: str, content: str, step: int = 0):622 """添加任务日志"""623 if task_id not in task_logs:624 task_logs[task_id] = []625 626 log_entry = {627 "type": log_type,628 "content": content,629 "step": step,630 "timestamp": datetime.now().isoformat()631 }632 task_logs[task_id].append(log_entry)633 634 635async def send_webhook(webhook_config: WebhookConfig, event_type: str, data: Dict[str, Any], max_retries: int = 3):636 """发送 Webhook 通知"""637 if not webhook_config:638 print(f"⚠️ Webhook 配置为空,跳过发送 {event_type}")639 return640 641 # 检查是否订阅了该事件642 if webhook_config.events and event_type not in webhook_config.events:643 print(f"⏭️ 事件 {event_type} 未订阅,跳过发送")644 return645 646 print(f"📤 准备发送 Webhook: {event_type} -> {webhook_config.url}")647 648 payload = {649 "event": event_type,650 "timestamp": datetime.now().isoformat(),651 "data": data652 }653 654 headers = {"Content-Type": "application/json"}655 656 # 添加 HMAC 签名657 if webhook_config.secret:658 import json659 payload_str = json.dumps(payload, separators=(',', ':'))660 signature = hmac.new(661 webhook_config.secret.encode(),662 payload_str.encode(),663 hashlib.sha256664 ).hexdigest()665 headers["X-Webhook-Signature"] = f"sha256={signature}"666 667 # 异步发送,带重试668 for attempt in range(max_retries):669 try:670 async with httpx.AsyncClient(timeout=10.0) as client:671 response = await client.post(672 str(webhook_config.url),673 json=payload,674 headers=headers675 )676 print(f"✅ Webhook 发送成功: {event_type} (状态码: {response.status_code})")677 if response.status_code < 500:678 return # 成功或客户端错误不重试679 except Exception as e:680 if attempt == max_retries - 1:681 print(f"❌ Webhook 发送失败 ({event_type}): {e}")682 else:683 print(f"⚠️ Webhook 发送失败 ({event_type}),重试 {attempt + 1}/{max_retries}: {e}")684 await asyncio.sleep(2 ** attempt) # 指数退避685 686 687async def execute_task(task_id: str, task_description: str, headless: bool, save_screenshots: bool, llm_config: LLMConfig, webhook_config: Optional[WebhookConfig] = None, use_vision: bool = True, compress_screenshots: bool = False, timeout: Optional[int] = None):688 """后台执行浏览器自动化任务"""689 task_timeout = timeout or TASK_TIMEOUT690 async with task_semaphore:691 tasks[task_id]["status"] = "running"692 tasks[task_id]["started_at"] = datetime.now().isoformat()693 task_screenshots[task_id] = []694 task_logs[task_id] = []695 696 asyncio.create_task(send_webhook(webhook_config, "task.started", {697 "task_id": task_id,698 "task": task_description,699 "started_at": tasks[task_id]["started_at"]700 }))701 702 browser = None703 try:704 async def run_task():705 nonlocal browser706 llm = create_llm(llm_config)707 browser = await get_browser_from_pool(headless)708 running_browsers[task_id] = browser709 710 step_count = [0]711 async def on_step_end(agent: Agent):712 step_count[0] += 1713 current_step = step_count[0]714 try:715 thoughts = agent.history.model_thoughts()716 if thoughts and len(thoughts) > 0:717 latest_thought = thoughts[-1]718 if hasattr(latest_thought, 'evaluation_previous_goal') and latest_thought.evaluation_previous_goal:719 add_task_log(task_id, "eval", latest_thought.evaluation_previous_goal, current_step)720 if hasattr(latest_thought, 'memory') and latest_thought.memory:721 add_task_log(task_id, "memory", latest_thought.memory, current_step)722 if hasattr(latest_thought, 'next_goal') and latest_thought.next_goal:723 add_task_log(task_id, "next_goal", latest_thought.next_goal, current_step)724 actions = agent.history.model_actions()725 if actions and len(actions) > 0:726 latest_actions = actions[-1] if isinstance(actions[-1], list) else [actions[-1]]727 for i, action in enumerate(latest_actions):728 action_str = str(action) if action else ""729 if action_str:730 add_task_log(task_id, "action", f"[{i+1}/{len(latest_actions)}] {action_str}", current_step)731 urls = agent.history.urls()732 if urls and len(urls) > 0:733 current_url = urls[-1]734 if current_url:735 add_task_log(task_id, "url", current_url, current_step)736 except Exception as e:737 add_task_log(task_id, "error", f"日志捕获错误: {str(e)}", current_step)738 739 screenshot_id = None740 if save_screenshots:741 screenshot_id = await save_screenshot(task_id, agent, current_step, compress_screenshots)742 if screenshot_id:743 if "screenshots" not in tasks[task_id]:744 tasks[task_id]["screenshots"] = []745 tasks[task_id]["screenshots"].append(screenshot_id)746 add_task_log(task_id, "screenshot", screenshot_id, current_step)747 748 step_data = {"task_id": task_id, "step": current_step, "timestamp": datetime.now().isoformat()}749 if screenshot_id:750 step_data["screenshot_id"] = screenshot_id751 asyncio.create_task(send_webhook(webhook_config, "task.step", step_data))752 753 agent = Agent(task=task_description, llm=llm, browser=browser, use_vision=use_vision)754 running_agents[task_id] = agent # 保存 agent 引用用于实时截图755 add_task_log(task_id, "info", f"开始执行任务: {task_description} (视觉: {'启用' if use_vision else '禁用'})", 0)756 history = await agent.run(on_step_end=on_step_end)757 758 steps = []759 try:760 action_names = history.action_names() if hasattr(history, 'action_names') else []761 extracted_contents = history.extracted_content() if hasattr(history, 'extracted_content') else []762 errors = history.errors() if hasattr(history, 'errors') else []763 urls = history.urls() if hasattr(history, 'urls') else []764 model_thoughts = history.model_thoughts() if hasattr(history, 'model_thoughts') else []765 num_steps = history.number_of_steps() if hasattr(history, 'number_of_steps') else len(action_names)766 767 for i in range(num_steps):768 step_detail = {"step": i + 1, "timestamp": datetime.now().isoformat()}769 if i < len(action_names):770 step_detail["action"] = action_names[i]771 if i < len(model_thoughts) and model_thoughts[i]:772 thought = model_thoughts[i]773 if hasattr(thought, 'evaluation_previous_goal'):774 step_detail["result"] = thought.evaluation_previous_goal775 elif hasattr(thought, 'memory'):776 step_detail["result"] = thought.memory777 if i < len(extracted_contents) and extracted_contents[i]:778 step_detail["result"] = extracted_contents[i]779 elif i < len(errors) and errors[i]:780 step_detail["result"] = f"Error: {errors[i]}"781 if i < len(urls) and urls[i]:782 step_detail["url"] = urls[i]783 steps.append(step_detail)784 785 if save_screenshots and browser:786 screenshot_paths = history.screenshot_paths() if hasattr(history, 'screenshot_paths') else []787 for i, path in enumerate(screenshot_paths):788 if path:789 screenshot_id = f"{task_id}_{i+1}_{datetime.now().strftime('%H%M%S%f')}"790 if i < len(steps):791 steps[i]["screenshot_id"] = screenshot_id792 if "screenshots" not in tasks[task_id]:793 tasks[task_id]["screenshots"] = []794 tasks[task_id]["screenshots"].append(screenshot_id)795 if task_id not in task_screenshots:796 task_screenshots[task_id] = []797 task_screenshots[task_id].append({798 "screenshot_id": screenshot_id,799 "step": i + 1,800 "timestamp": datetime.now().isoformat(),801 "path": path,802 })803 except Exception as e:804 print(f"提取历史数据失败: {e}")805 806 result = history.final_result()807 if save_screenshots:808 final_screenshot_id = await save_screenshot(task_id, agent, len(steps) + 1, compress_screenshots)809 if final_screenshot_id:810 if "screenshots" not in tasks[task_id]:811 tasks[task_id]["screenshots"] = []812 tasks[task_id]["screenshots"].append(final_screenshot_id)813 814 return result, steps815 816 # 执行任务并应用超时817 result, steps = await asyncio.wait_for(run_task(), timeout=task_timeout)818 819 tasks[task_id]["status"] = "completed"820 tasks[task_id]["result"] = result821 tasks[task_id]["steps"] = steps822 tasks[task_id]["completed_at"] = datetime.now().isoformat()823 824 duration = (datetime.fromisoformat(tasks[task_id]["completed_at"]) - 825 datetime.fromisoformat(tasks[task_id]["started_at"])).total_seconds()826 asyncio.create_task(send_webhook(webhook_config, "task.completed", {827 "task_id": task_id,828 "result": result,829 "steps": len(steps),830 "duration": duration,831 "completed_at": tasks[task_id]["completed_at"]832 }))833 834 except asyncio.TimeoutError:835 tasks[task_id]["status"] = "failed"836 tasks[task_id]["error"] = f"任务超时 (超过 {task_timeout} 秒)"837 tasks[task_id]["completed_at"] = datetime.now().isoformat()838 asyncio.create_task(send_webhook(webhook_config, "task.failed", {839 "task_id": task_id,840 "error": tasks[task_id]["error"],841 "failed_at": tasks[task_id]["completed_at"]842 }))843 except asyncio.CancelledError:844 tasks[task_id]["status"] = "cancelled"845 tasks[task_id]["error"] = "任务已被取消"846 tasks[task_id]["completed_at"] = datetime.now().isoformat()847 asyncio.create_task(send_webhook(webhook_config, "task.cancelled", {848 "task_id": task_id,849 "cancelled_at": tasks[task_id]["completed_at"]850 }))851 except Exception as e:852 tasks[task_id]["status"] = "failed"853 tasks[task_id]["error"] = str(e)854 tasks[task_id]["completed_at"] = datetime.now().isoformat()855 asyncio.create_task(send_webhook(webhook_config, "task.failed", {856 "task_id": task_id,857 "error": str(e),858 "failed_at": tasks[task_id]["completed_at"]859 }))860 finally:861 if browser:862 await release_browser_to_pool(browser)863 running_browsers.pop(task_id, None)864 running_tasks.pop(task_id, None)865 running_agents.pop(task_id, None)866 cleanup_old_tasks()867 868 869@app.get("/", response_class=HTMLResponse)870async def root():871 return """872<!DOCTYPE html>873<html lang="zh-CN">874<head>875 <meta charset="UTF-8">876 <meta name="viewport" content="width=device-width, initial-scale=1.0">877 <title>Browser-Use AI Agent</title>878 <style>879 * { box-sizing: border-box; margin: 0; padding: 0; }880 body {881 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;882 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);883 min-height: 100vh;884 padding: 20px;885 }886 .container { max-width: 1400px; margin: 0 auto; }887 h1 {888 color: white;889 text-align: center;890 margin-bottom: 20px;891 font-size: 2em;892 text-shadow: 2px 2px 4px rgba(0,0,0,0.2);893 }894 .header-info {895 text-align: center;896 color: rgba(255,255,255,0.9);897 margin-bottom: 30px;898 font-size: 0.95em;899 }900 .header-info a { color: #ffd700; }901 902 /* 创建任务区域 */903 .create-section {904 background: white;905 border-radius: 16px;906 padding: 24px;907 margin-bottom: 24px;908 box-shadow: 0 10px 40px rgba(0,0,0,0.2);909 }910 .create-section h2 {911 color: #333;912 margin-bottom: 16px;913 font-size: 1.3em;914 }915 .form-row {916 display: grid;917 grid-template-columns: 1fr 1fr 1fr 1fr;918 gap: 12px;919 margin-bottom: 12px;920 }921 .form-group { display: flex; flex-direction: column; }922 .form-group label {923 font-size: 0.85em;924 color: #666;925 margin-bottom: 4px;926 font-weight: 500;927 }928 .form-group input, .form-group select, .form-group textarea {929 padding: 10px 12px;930 border: 2px solid #e0e0e0;931 border-radius: 8px;932 font-size: 0.95em;933 transition: border-color 0.2s;934 }935 #modelSelect {936 max-height: 200px;937 overflow-y: auto;938 }939 .form-group input:focus, .form-group select:focus, .form-group textarea:focus {940 outline: none;941 border-color: #667eea;942 }943 .form-group.full { grid-column: 1 / -1; }944 textarea { resize: vertical; min-height: 80px; }945 946 /* Checkbox 样式 */947 .checkbox-group {948 display: flex;949 gap: 16px;950 flex-wrap: wrap;951 padding: 8px 0;952 }953 .checkbox-label {954 display: flex;955 align-items: center;956 gap: 6px;957 cursor: pointer;958 padding: 6px 12px;959 border-radius: 6px;960 background: #f8f9fa;961 border: 2px solid #e0e0e0;962 transition: all 0.2s;963 font-size: 0.9em;964 }965 .checkbox-label:hover {966 background: #e8f0fe;967 border-color: #667eea;968 }969 .checkbox-label input[type="checkbox"] {970 width: 16px;971 height: 16px;972 cursor: pointer;973 }974 .checkbox-label input[type="checkbox"]:checked + span {975 font-weight: 600;976 color: #667eea;977 }978 979 .btn {980 padding: 12px 24px;981 border: none;982 border-radius: 8px;983 font-size: 1em;984 font-weight: 600;985 cursor: pointer;986 transition: all 0.2s;987 }988 .btn-primary {989 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);990 color: white;991 }992 .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102,126,234,0.4); }993 .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }994 .btn-danger { background: #ef4444; color: white; }995 .btn-danger:hover { background: #dc2626; }996 .btn-warning { background: #f59e0b; color: white; }997 .btn-warning:hover { background: #d97706; }998 .btn-sm { padding: 6px 12px; font-size: 0.85em; }999 1000 /* 任务列表区域 */1001 .tasks-section {1002 display: grid;1003 grid-template-columns: 1fr 1fr;1004 gap: 24px;1005 }1006 .task-panel {1007 background: white;1008 border-radius: 16px;1009 padding: 20px;1010 box-shadow: 0 10px 40px rgba(0,0,0,0.2);1011 }1012 .task-panel h2 {1013 color: #333;1014 margin-bottom: 16px;1015 font-size: 1.2em;1016 display: flex;1017 align-items: center;1018 gap: 8px;1019 }1020 .task-panel h2 .count {1021 background: #667eea;1022 color: white;1023 padding: 2px 10px;1024 border-radius: 12px;1025 font-size: 0.8em;1026 }1027 .task-list { max-height: 500px; overflow-y: auto; }1028 .task-item {1029 background: #f8f9fa;1030 border-radius: 12px;1031 padding: 16px;1032 margin-bottom: 12px;1033 border-left: 4px solid #667eea;1034 cursor: pointer;1035 transition: all 0.2s;1036 }1037 .task-item:hover { background: #f0f0f0; transform: translateX(4px); }1038 .task-item.selected { background: #e8f0fe; border-left-color: #1a73e8; }1039 .task-item.status-running { border-left-color: #f59e0b; animation: pulse 2s infinite; }1040 .task-item.status-completed { border-left-color: #10b981; }1041 .task-item.status-failed { border-left-color: #ef4444; }1042 .task-item.status-cancelled { border-left-color: #6b7280; }1043 @keyframes pulse {1044 0%, 100% { opacity: 1; }1045 50% { opacity: 0.7; }1046 }1047 .task-header {1048 display: flex;1049 justify-content: space-between;1050 align-items: flex-start;1051 margin-bottom: 8px;1052 }1053 .task-desc {1054 font-weight: 500;1055 color: #333;1056 flex: 1;1057 word-break: break-word;1058 font-size: 0.95em;1059 }1060 .task-status {1061 padding: 4px 10px;1062 border-radius: 12px;1063 font-size: 0.75em;1064 font-weight: 600;1065 text-transform: uppercase;1066 margin-left: 8px;1067 white-space: nowrap;1068 }1069 .status-pending { background: #e0e7ff; color: #4338ca; }1070 .status-running { background: #fef3c7; color: #b45309; }1071 .status-completed { background: #d1fae5; color: #047857; }1072 .status-failed { background: #fee2e2; color: #b91c1c; }1073 .status-cancelled { background: #e5e7eb; color: #4b5563; }1074 .task-meta {1075 font-size: 0.8em;1076 color: #888;1077 display: flex;1078 gap: 16px;1079 flex-wrap: wrap;1080 }1081 .task-actions {1082 display: flex;1083 gap: 8px;1084 margin-top: 12px;1085 }1086 .empty-state {1087 text-align: center;1088 color: #888;1089 padding: 40px;1090 font-size: 0.95em;1091 }1092 1093 /* 任务详情区域 */1094 .detail-panel {1095 background: white;1096 border-radius: 16px;1097 padding: 20px;1098 box-shadow: 0 10px 40px rgba(0,0,0,0.2);1099 grid-column: 1 / -1;1100 margin-top: 24px;1101 display: none;1102 }1103 .detail-panel.show { display: block; }1104 .detail-header {1105 display: flex;1106 justify-content: space-between;1107 align-items: center;1108 margin-bottom: 16px;1109 padding-bottom: 16px;1110 border-bottom: 1px solid #eee;1111 }1112 .detail-header h2 { color: #333; font-size: 1.2em; }1113 .detail-content { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }1114 .detail-info h3 {1115 color: #666;1116 font-size: 0.9em;1117 margin-bottom: 8px;1118 }1119 .detail-info p {1120 color: #333;1121 background: #f8f9fa;1122 padding: 12px;1123 border-radius: 8px;1124 white-space: pre-wrap;1125 word-break: break-word;1126 font-size: 0.9em;1127 max-height: 200px;1128 overflow-y: auto;1129 }1130 .screenshots-grid {1131 display: grid;1132 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));1133 gap: 12px;1134 margin-top: 12px;1135 }1136 .screenshot-item {1137 border-radius: 8px;1138 overflow: hidden;1139 box-shadow: 0 2px 8px rgba(0,0,0,0.1);1140 cursor: pointer;1141 transition: transform 0.2s;1142 }1143 .screenshot-item:hover { transform: scale(1.02); }1144 .screenshot-item img {1145 width: 100%;1146 height: auto;1147 display: block;1148 }1149 .screenshot-label {1150 background: #333;1151 color: white;1152 padding: 6px 10px;1153 font-size: 0.8em;1154 }1155 1156 /* 模态框 */1157 .modal {1158 display: none;1159 position: fixed;1160 top: 0;1161 left: 0;1162 width: 100%;1163 height: 100%;1164 background: rgba(0,0,0,0.8);1165 z-index: 1000;1166 justify-content: center;1167 align-items: center;1168 }1169 .modal.show { display: flex; }1170 .modal img {1171 max-width: 90%;1172 max-height: 90%;1173 border-radius: 8px;1174 }1175 .modal-close {1176 position: absolute;1177 top: 20px;1178 right: 20px;1179 color: white;1180 font-size: 2em;1181 cursor: pointer;1182 }1183 1184 /* 响应式 */1185 @media (max-width: 900px) {1186 .form-row { grid-template-columns: 1fr 1fr; }1187 .tasks-section { grid-template-columns: 1fr; }1188 .detail-content { grid-template-columns: 1fr; }1189 }1190 @media (max-width: 600px) {1191 .form-row { grid-template-columns: 1fr; }1192 }1193 1194 /* Loading */1195 .loading {1196 display: inline-block;1197 width: 16px;1198 height: 16px;1199 border: 2px solid #fff;1200 border-radius: 50%;