CoolFace
Apppublic

Safflower/browser-use

sourceHugging Facemitupdated 6mo agoView on Hugging Face
0likes
app.py3560 linesDownload Raw Back to root
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%;

Showing the first 1,200 of 3560 lines. Download the file for the rest.