mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-07 03:48:44 +08:00
Feat(browser control):Add new agent component 'browser' to control browser by AI (#14888)
### What problem does this PR solve? This PR adds a new `Browser` operator to Agent workflows, enabling prompt-driven browser automation in RAGFlow.Technically based ‘Browser-Use’ It includes: - Backend browser component execution with tenant LLM integration - Upload source support (file IDs, URLs, variables, CSV/JSON array) - Downloaded file persistence to RAGFlow storage - Frontend node/operator integration, form config, icon, and i18n updates - Unit tests for upload/download and ID parsing logic - Dependency and Docker updates for browser-use runtime support ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
716
agent/component/browser.py
Normal file
716
agent/component/browser.py
Normal file
@@ -0,0 +1,716 @@
|
||||
#
|
||||
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from abc import ABC
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import unquote, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from agent.component.base import ComponentBase
|
||||
from agent.component.llm import LLMParam
|
||||
from api.db import FileType
|
||||
from api.db.joint_services.tenant_model_service import get_model_config_by_type_and_name
|
||||
from api.db.services import duplicate_name
|
||||
from api.db.services.file_service import FileService
|
||||
from api.db.services.tenant_llm_service import TenantLLMService
|
||||
from api.utils.file_utils import filename_type
|
||||
from common import settings
|
||||
from common.connection_utils import timeout
|
||||
from common.misc_utils import get_uuid
|
||||
from rag.llm import FACTORY_DEFAULT_BASE_URL
|
||||
|
||||
|
||||
class BrowserParam(LLMParam):
|
||||
"""
|
||||
Parameters for Browser node.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.prompts = "{sys.query}"
|
||||
self.max_steps = 30
|
||||
self.headless = True
|
||||
self.enable_default_extensions = False
|
||||
self.chromium_sandbox = False
|
||||
# Reuse browser profile across runs of the same agent node by default.
|
||||
self.persist_session = True
|
||||
self.upload_sources = []
|
||||
self.outputs = {
|
||||
"content": {"type": "string", "value": ""},
|
||||
"downloaded_files": {"type": "Array<Object>", "value": []},
|
||||
}
|
||||
|
||||
def check(self):
|
||||
self.check_empty(self.llm_id, "[Browser] LLM")
|
||||
self.check_positive_integer(self.max_steps, "[Browser] Max steps")
|
||||
self.check_boolean(self.headless, "[Browser] Headless")
|
||||
self.check_boolean(self.enable_default_extensions, "[Browser] Enable default extensions")
|
||||
self.check_boolean(self.chromium_sandbox, "[Browser] Chromium sandbox")
|
||||
self.check_boolean(self.persist_session, "[Browser] Persist session")
|
||||
self.check_empty(self.prompts, "[Browser] Prompts")
|
||||
return True
|
||||
|
||||
def get_input_form(self) -> dict[str, dict]:
|
||||
return {
|
||||
"prompts": {"type": "text", "name": "Prompts"},
|
||||
"upload_sources": {"type": "line", "name": "Upload sources"},
|
||||
}
|
||||
|
||||
|
||||
class Browser(ComponentBase, ABC):
|
||||
component_name = "Browser"
|
||||
|
||||
def get_input_elements(self) -> dict[str, dict]:
|
||||
text_parts = [
|
||||
str(self._param.prompts or ""),
|
||||
json.dumps(self._param.upload_sources, ensure_ascii=False),
|
||||
]
|
||||
return self.get_input_elements_from_text("\n".join(text_parts))
|
||||
|
||||
def _resolve_param_value(self, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
direct_ref = value.strip()
|
||||
if direct_ref.startswith("{") and direct_ref.endswith("}") and self._canvas.is_reff(direct_ref):
|
||||
return self._canvas.get_variable_value(direct_ref)
|
||||
return value
|
||||
return value
|
||||
|
||||
def _extract_ids(self, value: Any) -> list[str]:
|
||||
ids: list[str] = []
|
||||
value = self._resolve_param_value(value)
|
||||
|
||||
def collect(item: Any):
|
||||
if item is None:
|
||||
return
|
||||
if isinstance(item, str):
|
||||
token = item.strip()
|
||||
if not token:
|
||||
return
|
||||
if token.startswith("{") and token.endswith("}") and self._canvas.is_reff(token):
|
||||
collect(self._canvas.get_variable_value(token))
|
||||
return
|
||||
if token.startswith("[") and token.endswith("]"):
|
||||
try:
|
||||
parsed = json.loads(token)
|
||||
collect(parsed)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if self._is_http_url(token):
|
||||
ids.append(token)
|
||||
return
|
||||
if "," in token:
|
||||
for part in token.split(","):
|
||||
collect(part)
|
||||
return
|
||||
ids.append(token)
|
||||
return
|
||||
if isinstance(item, dict):
|
||||
for k in ("file_id", "id", "url", "value"):
|
||||
if k in item:
|
||||
collect(item[k])
|
||||
return
|
||||
for v in item.values():
|
||||
collect(v)
|
||||
return
|
||||
if isinstance(item, (list, tuple, set)):
|
||||
for v in item:
|
||||
collect(v)
|
||||
return
|
||||
token = str(item).strip()
|
||||
if token:
|
||||
ids.append(token)
|
||||
|
||||
collect(value)
|
||||
deduped: list[str] = []
|
||||
visited = set()
|
||||
for item in ids:
|
||||
if item in visited:
|
||||
continue
|
||||
visited.add(item)
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
|
||||
@staticmethod
|
||||
def _is_http_url(value: str) -> bool:
|
||||
token = str(value or "").strip()
|
||||
if not token:
|
||||
return False
|
||||
parsed = urlparse(token)
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
@staticmethod
|
||||
def _extract_url_filename(url: str, headers: Any) -> str:
|
||||
content_disposition = str(getattr(headers, "get", lambda *_args, **_kwargs: "")("Content-Disposition", "") or "")
|
||||
if content_disposition:
|
||||
# Prefer RFC 5987 encoded filename*=UTF-8''... when present.
|
||||
m = re.search(r"filename\*\s*=\s*(?:UTF-8''|utf-8'')?([^;]+)", content_disposition)
|
||||
if m:
|
||||
name = unquote(m.group(1).strip().strip('"'))
|
||||
if name:
|
||||
return os.path.basename(name)
|
||||
m = re.search(r'filename\s*=\s*"([^"]+)"', content_disposition)
|
||||
if m:
|
||||
name = m.group(1).strip()
|
||||
if name:
|
||||
return os.path.basename(name)
|
||||
m = re.search(r"filename\s*=\s*([^;]+)", content_disposition)
|
||||
if m:
|
||||
name = m.group(1).strip().strip('"')
|
||||
if name:
|
||||
return os.path.basename(name)
|
||||
|
||||
parsed = urlparse(url)
|
||||
raw_name = os.path.basename(parsed.path or "")
|
||||
name = unquote(raw_name).strip()
|
||||
if name:
|
||||
return name
|
||||
return f"url_file_{get_uuid()[:8]}.bin"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_upload_url_max_bytes() -> int:
|
||||
raw = str(os.getenv("RAGFLOW_BROWSER_UPLOAD_URL_MAX_BYTES", "") or "").strip()
|
||||
default_max_bytes = 100 * 1024 * 1024
|
||||
if not raw:
|
||||
return default_max_bytes
|
||||
try:
|
||||
parsed = int(raw)
|
||||
return parsed if parsed > 0 else default_max_bytes
|
||||
except (TypeError, ValueError):
|
||||
return default_max_bytes
|
||||
|
||||
@staticmethod
|
||||
def _restore_env_var(key: str, value: str | None):
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
return
|
||||
os.environ[key] = value
|
||||
|
||||
def _prepare_upload_url_file(self, url: str, upload_dir: str) -> dict[str, Any] | None:
|
||||
max_bytes = self._resolve_upload_url_max_bytes()
|
||||
local_path = ""
|
||||
local_name = ""
|
||||
total_size = 0
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "RAGFlow-Browser-Node/1.0"})
|
||||
with urlopen(req, timeout=30) as response:
|
||||
local_name = self._extract_url_filename(url, response.headers)
|
||||
|
||||
local_path = os.path.join(upload_dir, local_name)
|
||||
index = 1
|
||||
while os.path.exists(local_path):
|
||||
stem, ext = os.path.splitext(local_name)
|
||||
local_path = os.path.join(upload_dir, f"{stem}_{index}{ext}")
|
||||
index += 1
|
||||
|
||||
with open(local_path, "wb") as f:
|
||||
while True:
|
||||
chunk = response.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total_size += len(chunk)
|
||||
if total_size > max_bytes:
|
||||
raise ValueError(f"upload url file exceeds max size limit: {max_bytes}")
|
||||
f.write(chunk)
|
||||
except (HTTPError, URLError, OSError, TimeoutError, ValueError) as e:
|
||||
if local_path and os.path.exists(local_path):
|
||||
try:
|
||||
os.remove(local_path)
|
||||
except OSError:
|
||||
pass
|
||||
logging.warning("Browser failed to fetch upload url. url=%s, error=%s", url, e)
|
||||
return None
|
||||
|
||||
if total_size <= 0:
|
||||
if local_path and os.path.exists(local_path):
|
||||
try:
|
||||
os.remove(local_path)
|
||||
except OSError:
|
||||
pass
|
||||
logging.warning("Browser upload url returned empty content: %s", url)
|
||||
return None
|
||||
|
||||
return {
|
||||
"file_id": "",
|
||||
"name": local_name,
|
||||
"size": total_size,
|
||||
"local_path": local_path,
|
||||
"source_url": url,
|
||||
}
|
||||
|
||||
def _resolve_text(self, raw_text: Any) -> str:
|
||||
text = str(self._resolve_param_value(raw_text) or "")
|
||||
vars_map = self.get_input_elements_from_text(text)
|
||||
kv = {}
|
||||
for key, meta in vars_map.items():
|
||||
val = meta.get("value", "")
|
||||
if isinstance(val, str):
|
||||
kv[key] = val
|
||||
else:
|
||||
kv[key] = json.dumps(val, ensure_ascii=False)
|
||||
return self.string_format(text, kv)
|
||||
|
||||
@staticmethod
|
||||
def _as_model_config_dict(cfg_obj: Any) -> dict[str, Any]:
|
||||
if cfg_obj is None:
|
||||
return {}
|
||||
if isinstance(cfg_obj, dict):
|
||||
return cfg_obj
|
||||
if hasattr(cfg_obj, "to_dict") and callable(cfg_obj.to_dict):
|
||||
try:
|
||||
result = cfg_obj.to_dict()
|
||||
return result if isinstance(result, dict) else {}
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return {}
|
||||
result = {}
|
||||
for key in ("model", "model_name", "llm_name", "llm_factory", "api_key", "base_url", "api_base", "temperature"):
|
||||
val = getattr(cfg_obj, key, None)
|
||||
if val not in (None, ""):
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _error_chain(exc: Exception) -> str:
|
||||
parts = []
|
||||
cur = exc
|
||||
depth = 0
|
||||
while cur is not None and depth < 6:
|
||||
parts.append(f"{type(cur).__name__}: {cur}")
|
||||
cur = cur.__cause__ or cur.__context__
|
||||
depth += 1
|
||||
return " <- ".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_browser_executable() -> str:
|
||||
explicit_candidates = [
|
||||
os.getenv("BROWSER_USE_EXECUTABLE_PATH", "").strip(),
|
||||
os.getenv("BROWSER_USE_BROWSER_BINARY_PATH", "").strip(),
|
||||
os.getenv("BROWSER_USE_CHROME_BINARY_PATH", "").strip(),
|
||||
]
|
||||
for explicit in explicit_candidates:
|
||||
if explicit and os.path.isfile(explicit) and os.access(explicit, os.X_OK):
|
||||
return explicit
|
||||
candidates = [
|
||||
"/opt/chrome/chrome",
|
||||
"/usr/local/bin/chrome",
|
||||
"/usr/local/bin/google-chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
for cmd in ("chrome", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
|
||||
path = shutil.which(cmd)
|
||||
if path and os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_model_name(model: Any) -> str:
|
||||
name = str(model or "").strip()
|
||||
if not name:
|
||||
return ""
|
||||
if name.startswith("bu-") or name.startswith("browser-use/"):
|
||||
return name
|
||||
if "@" in name:
|
||||
# RAGFlow model aliases may include provider suffix, e.g. qwen3.5-flash@Tongyi-Qianwen.
|
||||
# browser-use OpenAI-compatible adapters need the pure model name.
|
||||
name = name.split("@", 1)[0].strip()
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def _safe_path_segment(value: Any) -> str:
|
||||
token = str(value or "").strip()
|
||||
if not token:
|
||||
return "unknown"
|
||||
token = re.sub(r"[^A-Za-z0-9._-]+", "_", token)
|
||||
return token.strip("._-") or "unknown"
|
||||
|
||||
def _resolve_persistent_profile_dir(self) -> str:
|
||||
root = os.path.join(tempfile.gettempdir(), "ragflow_browser_use_profiles")
|
||||
tenant = self._safe_path_segment(self._canvas.get_tenant_id())
|
||||
raw_canvas_id = getattr(self._canvas, "_id", "")
|
||||
if not raw_canvas_id:
|
||||
graph_text = json.dumps(
|
||||
self._canvas.dsl.get("graph", {}),
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
raw_canvas_id = (
|
||||
f"dsl_{hashlib.sha1(graph_text.encode('utf-8')).hexdigest()[:12]}"
|
||||
)
|
||||
canvas_id = self._safe_path_segment(raw_canvas_id)
|
||||
node_id = self._safe_path_segment(self._id)
|
||||
return os.path.join(root, tenant, canvas_id, node_id)
|
||||
|
||||
def _should_persist_session(self) -> bool:
|
||||
return bool(self._param.persist_session)
|
||||
|
||||
def _infer_provider_name(self, cfg: dict[str, Any]) -> str:
|
||||
provider = str(cfg.get("llm_factory") or "").strip()
|
||||
if provider:
|
||||
return provider
|
||||
llm_id = str(self._param.llm_id or "")
|
||||
if "@" in llm_id:
|
||||
return llm_id.split("@", 1)[1].strip()
|
||||
return ""
|
||||
|
||||
def _resolve_openai_compatible_base_url(self, cfg: dict[str, Any]) -> str:
|
||||
explicit = str(cfg.get("base_url") or cfg.get("api_base") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
provider = self._infer_provider_name(cfg)
|
||||
fallback = str(FACTORY_DEFAULT_BASE_URL.get(provider, "")).strip()
|
||||
return fallback if fallback else ""
|
||||
|
||||
def _build_browser_llm(self):
|
||||
from browser_use.llm import ChatBrowserUse, ChatOpenAI
|
||||
|
||||
chat_model_config = get_model_config_by_type_and_name(
|
||||
self._canvas.get_tenant_id(),
|
||||
TenantLLMService.llm_id2llm_type(self._param.llm_id),
|
||||
self._param.llm_id,
|
||||
)
|
||||
cfg = self._as_model_config_dict(chat_model_config)
|
||||
model_name = self._normalize_model_name(cfg.get("model_name") or cfg.get("model") or self._param.llm_id)
|
||||
if not model_name:
|
||||
raise ValueError(f"Invalid model config for Browser llm_id={self._param.llm_id}")
|
||||
base_url = self._resolve_openai_compatible_base_url(cfg)
|
||||
|
||||
# ChatBrowserUse only supports bu-* models. For tenant models, use OpenAI-compatible adapter.
|
||||
if model_name.startswith("bu-") or model_name.startswith("browser-use/"):
|
||||
llm_kwargs = {
|
||||
"model": model_name,
|
||||
"api_key": cfg.get("api_key"),
|
||||
"base_url": base_url,
|
||||
"temperature": self._param.temperature,
|
||||
"max_retries": self._param.max_retries,
|
||||
}
|
||||
llm_kwargs = {k: v for k, v in llm_kwargs.items() if v not in (None, "")}
|
||||
return ChatBrowserUse(**llm_kwargs)
|
||||
|
||||
llm_kwargs = {
|
||||
"model": model_name,
|
||||
"api_key": cfg.get("api_key"),
|
||||
"base_url": base_url,
|
||||
"temperature": self._param.temperature,
|
||||
"max_retries": self._param.max_retries,
|
||||
}
|
||||
llm_kwargs = {k: v for k, v in llm_kwargs.items() if v not in (None, "")}
|
||||
return ChatOpenAI(**llm_kwargs)
|
||||
|
||||
async def _run_browser_use_async(
|
||||
self,
|
||||
task_text: str,
|
||||
download_dir: str,
|
||||
available_file_paths: list[str] | None = None,
|
||||
profile_dir: str | None = None,
|
||||
):
|
||||
from browser_use import Agent as BrowserUseAgent, Browser as BrowserUseBrowser
|
||||
|
||||
llm = self._build_browser_llm()
|
||||
# NOTE:
|
||||
# _invoke() uses asyncio.run(), which creates a fresh event loop per task run.
|
||||
# Reusing a Browser object created by a previous loop can deadlock/timestamp out
|
||||
# in browser-use watchdog handlers on subsequent runs.
|
||||
# We keep persistent user_data_dir for session continuity, but we do not keep
|
||||
# browser instances alive across runs.
|
||||
available_file_paths = available_file_paths or []
|
||||
agent_kwargs: dict[str, Any] = {
|
||||
"task": task_text,
|
||||
"llm": llm,
|
||||
"available_file_paths": available_file_paths,
|
||||
}
|
||||
browser_obj = None
|
||||
previous_disable_extensions = os.environ.get("BROWSER_USE_DISABLE_EXTENSIONS")
|
||||
previous_browser_binary_path = os.environ.get("BROWSER_USE_BROWSER_BINARY_PATH")
|
||||
|
||||
try:
|
||||
enable_default_extensions = bool(self._param.enable_default_extensions)
|
||||
if not enable_default_extensions:
|
||||
os.environ["BROWSER_USE_DISABLE_EXTENSIONS"] = "1"
|
||||
else:
|
||||
os.environ.pop("BROWSER_USE_DISABLE_EXTENSIONS", None)
|
||||
|
||||
executable_path = self._resolve_browser_executable()
|
||||
browser_kwargs = {
|
||||
"headless": self._param.headless,
|
||||
"downloads_path": download_dir,
|
||||
# Docker often runs as root without user namespaces; disable sandbox by default.
|
||||
"chromium_sandbox": bool(self._param.chromium_sandbox),
|
||||
# Disable runtime extension download by default for intranet/offline environments.
|
||||
# Enable only when explicitly required and extensions are pre-cached.
|
||||
"enable_default_extensions": enable_default_extensions,
|
||||
}
|
||||
if executable_path:
|
||||
browser_kwargs["executable_path"] = executable_path
|
||||
# Keep browser-use watchdog fallback in sync with our resolved path.
|
||||
os.environ["BROWSER_USE_BROWSER_BINARY_PATH"] = executable_path
|
||||
else:
|
||||
logging.warning(
|
||||
"Browser no local browser executable found. "
|
||||
"Set BROWSER_USE_EXECUTABLE_PATH or preinstall chromium in image to avoid runtime playwright install."
|
||||
)
|
||||
if profile_dir:
|
||||
browser_kwargs["user_data_dir"] = profile_dir
|
||||
# browser-use expects profile_directory to be a profile name
|
||||
# such as "Default" / "Profile 1", not an absolute path.
|
||||
browser_kwargs["profile_directory"] = "Default"
|
||||
|
||||
browser_obj = BrowserUseBrowser(**browser_kwargs)
|
||||
agent_kwargs["browser"] = browser_obj
|
||||
except (OSError, RuntimeError, TypeError, ValueError) as e:
|
||||
logging.warning("Browser browser context customization skipped: %s", e)
|
||||
|
||||
agent = BrowserUseAgent(**agent_kwargs)
|
||||
|
||||
history = None
|
||||
run_fn = getattr(agent, "run", None)
|
||||
if run_fn is None:
|
||||
raise RuntimeError("browser-use Agent does not provide run().")
|
||||
|
||||
run_kwargs = {"max_steps": self._param.max_steps}
|
||||
try:
|
||||
if inspect.iscoroutinefunction(run_fn):
|
||||
history = await run_fn(**run_kwargs)
|
||||
else:
|
||||
history = await asyncio.to_thread(run_fn, **run_kwargs)
|
||||
except Exception as e:
|
||||
logging.error("Browser agent.run failed. error_chain=%s", self._error_chain(e))
|
||||
logging.exception("Browser agent.run traceback")
|
||||
raise
|
||||
finally:
|
||||
if browser_obj:
|
||||
close_fn = getattr(browser_obj, "close", None)
|
||||
if close_fn:
|
||||
try:
|
||||
if inspect.iscoroutinefunction(close_fn):
|
||||
await close_fn()
|
||||
else:
|
||||
await asyncio.to_thread(close_fn)
|
||||
except Exception as close_err:
|
||||
logging.warning("Browser failed to close browser object cleanly: %s", close_err)
|
||||
self._restore_env_var("BROWSER_USE_DISABLE_EXTENSIONS", previous_disable_extensions)
|
||||
self._restore_env_var("BROWSER_USE_BROWSER_BINARY_PATH", previous_browser_binary_path)
|
||||
|
||||
return history
|
||||
|
||||
def _prepare_upload_files(self, upload_dir: str) -> list[dict[str, Any]]:
|
||||
upload_refs = self._extract_ids(self._param.upload_sources)
|
||||
prepared = []
|
||||
for file_ref in upload_refs:
|
||||
if self._is_http_url(file_ref):
|
||||
prepared_url_file = self._prepare_upload_url_file(file_ref, upload_dir)
|
||||
if prepared_url_file:
|
||||
prepared.append(prepared_url_file)
|
||||
continue
|
||||
|
||||
file_id = file_ref
|
||||
exists, file = FileService.get_by_id(file_id)
|
||||
if not exists:
|
||||
logging.warning("Browser upload file_id not found: %s", file_id)
|
||||
continue
|
||||
try:
|
||||
blob = settings.STORAGE_IMPL.get(file.parent_id, file.location)
|
||||
if not blob:
|
||||
logging.warning("Browser upload blob not found: %s", file_id)
|
||||
continue
|
||||
local_name = os.path.basename(file.location) if file.location else (file.name or f"{file_id}.bin")
|
||||
local_path = os.path.join(upload_dir, local_name)
|
||||
index = 1
|
||||
while os.path.exists(local_path):
|
||||
stem, ext = os.path.splitext(local_name)
|
||||
local_path = os.path.join(upload_dir, f"{stem}_{index}{ext}")
|
||||
index += 1
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(blob)
|
||||
except OSError as e:
|
||||
logging.warning("Browser failed to prepare upload file. file_id=%s, error=%s", file_id, e)
|
||||
continue
|
||||
except Exception as e:
|
||||
logging.warning("Browser failed to fetch upload blob. file_id=%s, error=%s", file_id, e)
|
||||
continue
|
||||
prepared.append(
|
||||
{
|
||||
"file_id": file.id,
|
||||
"name": file.name,
|
||||
"size": file.size,
|
||||
"local_path": local_path,
|
||||
}
|
||||
)
|
||||
return prepared
|
||||
|
||||
def _save_downloads(self, download_dir: str, parent_id: str) -> list[dict[str, Any]]:
|
||||
downloaded_files: list[dict[str, Any]] = []
|
||||
exists, folder = FileService.get_by_id(parent_id)
|
||||
if not exists or folder.type != FileType.FOLDER.value:
|
||||
raise ValueError(f"RAGFlow target folder does not exist or is not a folder: {parent_id}")
|
||||
tenant_id = self._canvas.get_tenant_id()
|
||||
storage_put = settings.STORAGE_IMPL.put
|
||||
storage_rm = getattr(settings.STORAGE_IMPL, "rm", None)
|
||||
insert_file = FileService.insert
|
||||
|
||||
for path in Path(download_dir).rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
if path.stat().st_size <= 0:
|
||||
continue
|
||||
blob = path.read_bytes()
|
||||
except OSError as e:
|
||||
logging.warning("Browser failed to read downloaded file. path=%s, error=%s", path, e)
|
||||
continue
|
||||
if not blob:
|
||||
continue
|
||||
display_name = ""
|
||||
blob_stored = False
|
||||
try:
|
||||
display_name = duplicate_name(FileService.query, name=path.name, parent_id=parent_id)
|
||||
storage_put(parent_id, display_name, blob)
|
||||
blob_stored = True
|
||||
file_data = {
|
||||
"id": get_uuid(),
|
||||
"parent_id": parent_id,
|
||||
"tenant_id": tenant_id,
|
||||
"created_by": tenant_id,
|
||||
"type": filename_type(display_name),
|
||||
"name": display_name,
|
||||
"location": display_name,
|
||||
"size": len(blob),
|
||||
}
|
||||
inserted = insert_file(file_data)
|
||||
downloaded_files.append(
|
||||
{
|
||||
"file_id": inserted.id,
|
||||
"name": inserted.name,
|
||||
"size": inserted.size,
|
||||
"parent_id": inserted.parent_id,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
if blob_stored and callable(storage_rm):
|
||||
try:
|
||||
storage_rm(parent_id, display_name)
|
||||
except Exception as rollback_err:
|
||||
logging.warning(
|
||||
"Browser rollback stored download failed. path=%s, parent_id=%s, display_name=%s, error=%s",
|
||||
path,
|
||||
parent_id,
|
||||
display_name,
|
||||
rollback_err,
|
||||
)
|
||||
logging.error(
|
||||
"Browser failed to save download. path=%s, tenant_id=%s, parent_id=%s, display_name=%s, error=%s",
|
||||
path,
|
||||
tenant_id,
|
||||
parent_id,
|
||||
display_name,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
return downloaded_files
|
||||
|
||||
@staticmethod
|
||||
def _extract_history_text(history: Any) -> str:
|
||||
if history is None:
|
||||
return ""
|
||||
|
||||
def pick_final_result(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
if isinstance(value, (int, float, bool)):
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
# Only trust browser-use's explicit final_result API/property.
|
||||
final_result_fn = getattr(history, "final_result", None)
|
||||
if callable(final_result_fn):
|
||||
try:
|
||||
final_result_value = final_result_fn()
|
||||
return pick_final_result(final_result_value)
|
||||
except Exception:
|
||||
return ""
|
||||
return pick_final_result(final_result_fn)
|
||||
|
||||
@timeout(int(os.environ.get("COMPONENT_EXEC_TIMEOUT", 20 * 60)))
|
||||
def _invoke(self, **kwargs):
|
||||
profile_dir = None
|
||||
persist_session = self._should_persist_session()
|
||||
try:
|
||||
user_prompt = self._resolve_text(kwargs.get("prompts", self._param.prompts))
|
||||
with tempfile.TemporaryDirectory(prefix="browser_use_upload_") as upload_dir, tempfile.TemporaryDirectory(
|
||||
prefix="browser_use_download_"
|
||||
) as download_dir:
|
||||
uploaded_files = self._prepare_upload_files(upload_dir)
|
||||
|
||||
upload_lines = [
|
||||
f"- file_id={item['file_id']}, name={item['name']}, local_path={item['local_path']}"
|
||||
for item in uploaded_files
|
||||
]
|
||||
task_text = user_prompt
|
||||
if upload_lines:
|
||||
task_text += (
|
||||
"\n\nYou can upload files from these local paths when operating web pages:\n"
|
||||
+ "\n".join(upload_lines)
|
||||
)
|
||||
|
||||
upload_local_paths = [item.get("local_path", "") for item in uploaded_files if item.get("local_path")]
|
||||
if persist_session:
|
||||
profile_dir = self._resolve_persistent_profile_dir()
|
||||
os.makedirs(profile_dir, exist_ok=True)
|
||||
else:
|
||||
try:
|
||||
profile_dir = tempfile.mkdtemp(prefix="browser_use_profile_")
|
||||
except OSError:
|
||||
profile_dir = None
|
||||
history = asyncio.run(
|
||||
self._run_browser_use_async(
|
||||
task_text, download_dir, upload_local_paths, profile_dir
|
||||
)
|
||||
)
|
||||
target_dir_id = FileService.get_root_folder(self._canvas.get_tenant_id())["id"]
|
||||
downloaded_files = self._save_downloads(download_dir, target_dir_id)
|
||||
|
||||
self.set_output("content", self._extract_history_text(history))
|
||||
self.set_output("downloaded_files", downloaded_files)
|
||||
return self.output()
|
||||
except Exception as e:
|
||||
logging.exception("Browser invoke failed")
|
||||
self.set_output("_ERROR", str(e))
|
||||
return self.output()
|
||||
finally:
|
||||
if profile_dir and not persist_session:
|
||||
shutil.rmtree(profile_dir, ignore_errors=True)
|
||||
|
||||
def thoughts(self) -> str:
|
||||
return "Planning and executing browser actions..."
|
||||
@@ -14,13 +14,14 @@ dependencies = [
|
||||
"audioop-lts>=0.2.1",
|
||||
"aiosmtplib>=5.0.0",
|
||||
"akshare>=1.15.78,<2.0.0",
|
||||
"anthropic==0.34.1",
|
||||
"anthropic==0.76.0",
|
||||
"arxiv==2.1.3",
|
||||
"atlassian-python-api==4.0.7",
|
||||
"azure-identity>=1.25.3",
|
||||
"azure-storage-file-datalake==12.16.0",
|
||||
"beartype>=0.20.0,<1.0.0",
|
||||
"bio==1.7.1",
|
||||
"browser-use>=0.11.1,<0.12.0",
|
||||
"boto3>=1.28.0",
|
||||
"boxsdk>=10.1.0",
|
||||
"captcha>=0.7.1",
|
||||
@@ -52,7 +53,7 @@ dependencies = [
|
||||
"google-genai>=1.41.0,<2.0.0",
|
||||
"google-search-results==2.4.2",
|
||||
"graspologic @ git+https://gitee.com/infiniflow/graspologic.git@38e680cab72bc9fb68a7992c3bcc2d53b24e42fd",
|
||||
"groq==0.9.0",
|
||||
"groq>=0.30.0,<1.0.0",
|
||||
"grpcio-status==1.67.1",
|
||||
"html-text==0.6.2",
|
||||
"infinity-sdk==0.7.0",
|
||||
@@ -125,6 +126,7 @@ dependencies = [
|
||||
"wikipedia==1.4.0",
|
||||
"word2number==1.1",
|
||||
"xgboost==1.6.0",
|
||||
"setuptools>=78.1.1,<81.0.0",
|
||||
"xpinyin==0.7.6",
|
||||
"yfinance==0.2.65",
|
||||
"zhipuai==2.0.1",
|
||||
@@ -144,7 +146,6 @@ dependencies = [
|
||||
# "requests>=2.32.3,<3.0.0",
|
||||
# "scikit-learn==1.5.0",
|
||||
# "selenium==4.22.0",
|
||||
# "setuptools>=78.1.1,<81.0.0",
|
||||
# "shapely==2.0.5",
|
||||
# "six==1.16.0",
|
||||
# "tabulate==0.9.0",
|
||||
@@ -257,9 +258,12 @@ markers = [
|
||||
]
|
||||
|
||||
# Test collection and runtime configuration
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = [
|
||||
"error", # Treat warnings as errors
|
||||
"ignore::DeprecationWarning", # Ignore specific warnings
|
||||
"ignore:pkg_resources is deprecated:UserWarning",
|
||||
]
|
||||
|
||||
# Command line options
|
||||
@@ -268,7 +272,9 @@ addopts = [
|
||||
"--strict-markers", # Enforce marker definitions
|
||||
"--tb=short", # Simplified traceback
|
||||
"--disable-warnings", # Disable warnings
|
||||
"--color=yes" # Colored output
|
||||
"--color=yes", # Colored output
|
||||
"-p",
|
||||
"no:anyio", # anyio's pytest plugin conflicts with pytest-asyncio on Py3.13
|
||||
]
|
||||
|
||||
|
||||
|
||||
212
test/unit_test/agent/component/test_browser_use_component.py
Normal file
212
test/unit_test/agent/component/test_browser_use_component.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#
|
||||
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from api.db import FileType
|
||||
|
||||
|
||||
def _install_cv2_stub_if_unavailable():
|
||||
try:
|
||||
import cv2 # noqa: F401
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
stub = types.ModuleType("cv2")
|
||||
stub.INTER_LINEAR = 1
|
||||
stub.INTER_CUBIC = 2
|
||||
stub.BORDER_CONSTANT = 0
|
||||
stub.BORDER_REPLICATE = 1
|
||||
|
||||
def _module_getattr(name):
|
||||
if name.isupper():
|
||||
return 0
|
||||
raise RuntimeError("cv2 runtime call is unavailable in this test environment")
|
||||
|
||||
stub.__getattr__ = _module_getattr
|
||||
sys.modules["cv2"] = stub
|
||||
|
||||
|
||||
_install_cv2_stub_if_unavailable()
|
||||
|
||||
from agent.component import browser as browser_use_module # noqa: E402
|
||||
|
||||
|
||||
class _FakeCanvas:
|
||||
def __init__(self, refs=None):
|
||||
self._refs = refs or {}
|
||||
|
||||
def is_reff(self, token):
|
||||
return token in self._refs
|
||||
|
||||
def get_variable_value(self, token):
|
||||
return self._refs[token]
|
||||
|
||||
def get_tenant_id(self):
|
||||
return "tenant-1"
|
||||
|
||||
|
||||
def _build_component():
|
||||
component = browser_use_module.Browser.__new__(browser_use_module.Browser)
|
||||
component._canvas = _FakeCanvas()
|
||||
component._param = SimpleNamespace(upload_sources="")
|
||||
return component
|
||||
|
||||
|
||||
def test_extract_ids_supports_mixed_literals_and_variables():
|
||||
component = browser_use_module.Browser.__new__(browser_use_module.Browser)
|
||||
component._canvas = _FakeCanvas(
|
||||
refs={
|
||||
"{begin@file_ids}": ["f2", "f3,f4"],
|
||||
"{begin@extra_file}": "f5",
|
||||
}
|
||||
)
|
||||
|
||||
file_ids = component._extract_ids("f1,{begin@file_ids},{begin@extra_file},f1")
|
||||
|
||||
assert file_ids == ["f1", "f2", "f3", "f4", "f5"]
|
||||
|
||||
|
||||
def test_extract_ids_supports_json_array_and_csv_format():
|
||||
component = browser_use_module.Browser.__new__(browser_use_module.Browser)
|
||||
component._canvas = _FakeCanvas()
|
||||
|
||||
json_ids = component._extract_ids('["1","2"]')
|
||||
csv_ids = component._extract_ids("1,2")
|
||||
|
||||
assert json_ids == ["1", "2"]
|
||||
assert csv_ids == ["1", "2"]
|
||||
|
||||
|
||||
def test_extract_ids_supports_variable_values_from_input_or_globals():
|
||||
component = browser_use_module.Browser.__new__(browser_use_module.Browser)
|
||||
component._canvas = _FakeCanvas(
|
||||
refs={
|
||||
"{begin@upload_ids}": '["10","20"]',
|
||||
"{sys@upload_id}": 30,
|
||||
"{begin@file_obj}": {"id": "40", "name": "demo.pdf"},
|
||||
}
|
||||
)
|
||||
|
||||
file_ids = component._extract_ids("{begin@upload_ids},{sys@upload_id},{begin@file_obj}")
|
||||
|
||||
assert file_ids == ["10", "20", "30", "40"]
|
||||
|
||||
|
||||
def test_extract_ids_supports_url_key_in_variable_object():
|
||||
component = browser_use_module.Browser.__new__(browser_use_module.Browser)
|
||||
component._canvas = _FakeCanvas(
|
||||
refs={
|
||||
"{begin@upload_url_obj}": {"url": "https://example.com/demo.pdf"},
|
||||
}
|
||||
)
|
||||
|
||||
refs = component._extract_ids("{begin@upload_url_obj}")
|
||||
|
||||
assert refs == ["https://example.com/demo.pdf"]
|
||||
|
||||
|
||||
def test_extract_ids_does_not_split_http_url_by_comma():
|
||||
component = browser_use_module.Browser.__new__(browser_use_module.Browser)
|
||||
component._canvas = _FakeCanvas()
|
||||
|
||||
refs = component._extract_ids("https://example.com/download?name=a,b.txt")
|
||||
|
||||
assert refs == ["https://example.com/download?name=a,b.txt"]
|
||||
|
||||
|
||||
def test_prepare_upload_files_supports_http_url(monkeypatch, tmp_path):
|
||||
component = _build_component()
|
||||
component._param.upload_sources = "https://example.com/files/demo.txt"
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Disposition": 'attachment; filename="remote_demo.txt"'}
|
||||
self._data = b"hello from url"
|
||||
self._pos = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size <= 0:
|
||||
chunk = self._data[self._pos :]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + size]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(browser_use_module, "urlopen", lambda *_args, **_kwargs: _FakeResponse())
|
||||
|
||||
prepared = component._prepare_upload_files(str(tmp_path))
|
||||
|
||||
assert len(prepared) == 1
|
||||
assert prepared[0]["file_id"] == ""
|
||||
assert prepared[0]["name"] == "remote_demo.txt"
|
||||
assert prepared[0]["source_url"] == "https://example.com/files/demo.txt"
|
||||
assert Path(prepared[0]["local_path"]).exists()
|
||||
assert Path(prepared[0]["local_path"]).read_bytes() == b"hello from url"
|
||||
|
||||
|
||||
def test_save_downloads_persists_file_records(monkeypatch, tmp_path):
|
||||
component = _build_component()
|
||||
component._canvas = _FakeCanvas()
|
||||
|
||||
download_file = tmp_path / "report.txt"
|
||||
download_file.write_text("ok", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
browser_use_module.FileService,
|
||||
"get_by_id",
|
||||
lambda _folder_id: (True, SimpleNamespace(type=FileType.FOLDER.value)),
|
||||
)
|
||||
monkeypatch.setattr(browser_use_module, "duplicate_name", lambda *_args, **_kwargs: "report.txt")
|
||||
|
||||
stored = {}
|
||||
|
||||
def _put(parent_id, location, blob):
|
||||
stored["parent_id"] = parent_id
|
||||
stored["location"] = location
|
||||
stored["blob"] = blob
|
||||
|
||||
monkeypatch.setattr(browser_use_module.settings, "STORAGE_IMPL", SimpleNamespace(put=_put))
|
||||
monkeypatch.setattr(
|
||||
browser_use_module.FileService,
|
||||
"insert",
|
||||
lambda data: SimpleNamespace(
|
||||
id="file-1",
|
||||
name=data["name"],
|
||||
size=data["size"],
|
||||
parent_id=data["parent_id"],
|
||||
),
|
||||
)
|
||||
|
||||
result = component._save_downloads(str(tmp_path), "dir-1")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["file_id"] == "file-1"
|
||||
assert result[0]["parent_id"] == "dir-1"
|
||||
assert stored["parent_id"] == "dir-1"
|
||||
assert stored["location"] == "report.txt"
|
||||
assert stored["blob"] == b"ok"
|
||||
assert Path(download_file).exists()
|
||||
@@ -131,7 +131,7 @@ def _patch_common_dependencies(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.p2
|
||||
async def test_run_task_logic_skips_empty_sync_batches(monkeypatch):
|
||||
_patch_common_dependencies(monkeypatch)
|
||||
@@ -154,7 +154,7 @@ async def test_run_task_logic_skips_empty_sync_batches(monkeypatch):
|
||||
await _FakeSync(iter(([],)))._run_task_logic(_make_task())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.p2
|
||||
async def test_run_task_logic_skips_multiple_empty_sync_batches(monkeypatch):
|
||||
_patch_common_dependencies(monkeypatch)
|
||||
@@ -177,7 +177,7 @@ async def test_run_task_logic_skips_multiple_empty_sync_batches(monkeypatch):
|
||||
await _FakeSync(iter(([], [],)))._run_task_logic(_make_task())
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.p2
|
||||
async def test_run_prune_task_logic_cleans_up_for_empty_snapshot(monkeypatch):
|
||||
cleanup_calls = []
|
||||
@@ -217,7 +217,7 @@ async def test_run_prune_task_logic_cleans_up_for_empty_snapshot(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.p2
|
||||
async def test_run_prune_task_logic_cleans_up_for_non_empty_snapshot(monkeypatch):
|
||||
cleanup_calls = []
|
||||
@@ -322,7 +322,7 @@ class _FakeRDBMSConnector:
|
||||
self.persist_sync_state_called = True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.p2
|
||||
async def test_rdbms_generate_keeps_deleted_file_snapshot_without_timestamp_column(monkeypatch):
|
||||
monkeypatch.setattr(sync_data_source, "RDBMSConnector", _FakeRDBMSConnector)
|
||||
@@ -358,7 +358,7 @@ async def test_rdbms_generate_keeps_deleted_file_snapshot_without_timestamp_colu
|
||||
assert list(document_generator) == [["full-sync"]]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.p2
|
||||
async def test_rdbms_cursor_persists_only_after_success(monkeypatch):
|
||||
monkeypatch.setattr(sync_data_source, "RDBMSConnector", _FakeRDBMSConnector)
|
||||
@@ -405,7 +405,7 @@ async def test_rdbms_cursor_persists_only_after_success(monkeypatch):
|
||||
assert connector.persist_sync_state_called is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.p2
|
||||
async def test_rdbms_cursor_does_not_persist_when_batch_is_skipped(monkeypatch):
|
||||
monkeypatch.setattr(sync_data_source, "RDBMSConnector", _FakeRDBMSConnector)
|
||||
@@ -489,7 +489,7 @@ class _FakeDropboxConnector:
|
||||
return iter((["full-sync"],))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.p2
|
||||
async def test_dropbox_generate_returns_snapshot_when_sync_deleted_enabled(monkeypatch):
|
||||
monkeypatch.setattr(sync_data_source, "DropboxConnector", _FakeDropboxConnector)
|
||||
@@ -521,7 +521,7 @@ async def test_dropbox_generate_returns_snapshot_when_sync_deleted_enabled(monke
|
||||
assert connector.poll_source_call[1] >= poll_start.timestamp()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.p2
|
||||
async def test_dropbox_generate_skips_snapshot_for_full_reindex(monkeypatch):
|
||||
monkeypatch.setattr(sync_data_source, "DropboxConnector", _FakeDropboxConnector)
|
||||
|
||||
@@ -109,6 +109,7 @@ export enum Operator {
|
||||
StringTransform = 'StringTransform',
|
||||
SearXNG = 'SearXNG',
|
||||
DocGenerator = 'DocGenerator',
|
||||
Browser = 'Browser',
|
||||
Placeholder = 'Placeholder',
|
||||
DataOperations = 'DataOperations',
|
||||
ListOperations = 'ListOperations',
|
||||
|
||||
@@ -1836,6 +1836,20 @@ Best for: Documents with flowing, contextually connected content — such as boo
|
||||
maxRounds: 'Max reflection rounds',
|
||||
delayAfterError: 'Delay after error',
|
||||
maxRetries: 'Max retry rounds',
|
||||
maxSteps: 'Max steps',
|
||||
headless: 'Headless',
|
||||
enableDefaultExtensions: 'Enable default extensions',
|
||||
enableDefaultExtensionsTip:
|
||||
'Enable browser-use default extensions (uBlock, cookie handling, ClearURLs). Disable this to avoid runtime extension downloads.',
|
||||
chromiumSandbox: 'Chromium sandbox',
|
||||
chromiumSandboxTip:
|
||||
'Whether to enable Chromium sandbox. In Docker root environments this is usually disabled; on regular hosts it is recommended to enable.',
|
||||
persistSession: 'Persist session',
|
||||
persistSessionTip:
|
||||
'When enabled, this Browser node reuses its browser session to avoid repeated logins.',
|
||||
uploadSources: 'Upload sources',
|
||||
uploadSourcesTip:
|
||||
'Supports file IDs, file URLs, or variables. You can separate multiple values with commas or use a JSON array format (for example ["id1","https://example.com/a.pdf"]).',
|
||||
advancedSettings: 'Advanced settings',
|
||||
addTools: 'Add tools',
|
||||
sysPromptDefaultValue: `
|
||||
@@ -1938,6 +1952,9 @@ Best for: Documents with flowing, contextually connected content — such as boo
|
||||
'A component that searches via your provided SearXNG instance URL. Specify TopN and the instance URL.',
|
||||
docGenerator: 'Doc Generator',
|
||||
docGeneratorDescription: `Generate a file from Markdown content.`,
|
||||
browser: 'Browser',
|
||||
browserDescription:
|
||||
'Automate browser tasks. Supports model configuration and prompt-driven actions. Upload sources support file IDs and URLs, and downloaded files can be saved to a target folder.',
|
||||
subtitle: 'Subtitle',
|
||||
logoImage: 'Logo Image',
|
||||
logoPosition: 'Logo Position',
|
||||
|
||||
@@ -208,16 +208,19 @@ export default {
|
||||
'Geçersiz beceri: "name" küçük harfli ve URL uyumlu olmalıdır (yalnızca harf, rakam ve tire).',
|
||||
invalid_version:
|
||||
'Geçersiz beceri: "version" geçerli semver olmalıdır (örn. 1.0.0).',
|
||||
invalid_metadata: 'Geçersiz beceri: meta veriler geçersiz alanlar içeriyor.',
|
||||
invalid_metadata:
|
||||
'Geçersiz beceri: meta veriler geçersiz alanlar içeriyor.',
|
||||
invalid_file_type:
|
||||
'Geçersiz beceri: Yalnızca metin tabanlı dosyalara izin verilir.',
|
||||
invalid_path: 'Geçersiz beceri: Dosya yolu geçersiz karakterler içeriyor.',
|
||||
invalid_path:
|
||||
'Geçersiz beceri: Dosya yolu geçersiz karakterler içeriyor.',
|
||||
file_too_large:
|
||||
'Geçersiz beceri: Tekil dosya boyutu 5MB sınırını aşıyor.',
|
||||
total_size_exceeded:
|
||||
'Geçersiz beceri: Toplam paket boyutu 50MB sınırını aşıyor.',
|
||||
no_files: 'Dosya seçilmedi. Lütfen bir beceri klasörü seçin.',
|
||||
noValidFiles: 'Geçerli dosya bulunamadı. Lütfen seçiminizi kontrol edin.',
|
||||
noValidFiles:
|
||||
'Geçerli dosya bulunamadı. Lütfen seçiminizi kontrol edin.',
|
||||
junkFilesFound:
|
||||
'Geçici dosyalar algılandı (örn. .DS_Store). Lütfen yüklemeden önce bunları kaldırın.',
|
||||
read_failed: 'Geçersiz beceri: SKILL.md dosyası okunamadı.',
|
||||
@@ -232,8 +235,8 @@ export default {
|
||||
upload: 'Yükle',
|
||||
importFromGit: "Git'ten içe aktar",
|
||||
gitPlatform: 'Platform',
|
||||
repoUrl: 'Depo URL\'si',
|
||||
repoUrlHelp: 'İsteğe bağlı yol içeren depo URL\'sini destekler',
|
||||
repoUrl: "Depo URL'si",
|
||||
repoUrlHelp: "İsteğe bağlı yol içeren depo URL'sini destekler",
|
||||
accessToken: 'Erişim Tokenı',
|
||||
githubTokenHelp:
|
||||
'Özel depolar veya daha yüksek hız limitleri için (5000 istek/saat)',
|
||||
@@ -250,7 +253,8 @@ export default {
|
||||
},
|
||||
skillSearch: {
|
||||
configTitle: 'Beceri Arama Yapılandırması',
|
||||
configDesc: 'Becerilerin nasıl indeksleneceğini ve aranacağını yapılandırın',
|
||||
configDesc:
|
||||
'Becerilerin nasıl indeksleneceğini ve aranacağını yapılandırın',
|
||||
embeddingModel: 'Embedding Modeli',
|
||||
embeddingModelPlaceholder: 'Bir embedding modeli seçin',
|
||||
vectorSimilarityWeight: 'Vektör Benzerlik Ağırlığı',
|
||||
|
||||
@@ -1529,6 +1529,20 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
|
||||
maxRounds: '最大反思轮数',
|
||||
delayAfterError: '错误后延迟',
|
||||
maxRetries: '最大重试轮数',
|
||||
maxSteps: '最大步数',
|
||||
headless: '无头模式',
|
||||
enableDefaultExtensions: '启用默认扩展',
|
||||
enableDefaultExtensionsTip:
|
||||
'启用 browser-use 默认扩展(uBlock、Cookie 处理、ClearURLs)。关闭后可避免运行时下载扩展。',
|
||||
chromiumSandbox: 'Chromium 沙箱',
|
||||
chromiumSandboxTip:
|
||||
'是否启用 Chromium 沙箱。Docker root 环境通常需要关闭,普通宿主机环境建议开启。',
|
||||
persistSession: '持久化登录态',
|
||||
persistSessionTip:
|
||||
'开启后将复用该 Browser 节点的浏览器会话,避免重复登录。',
|
||||
uploadSources: '上传来源',
|
||||
uploadSourcesTip:
|
||||
'支持文件 ID、文件 URL 或变量。可用逗号分隔多个值,也支持 JSON 数组格式(如 ["id1","https://example.com/a.pdf"])。',
|
||||
advancedSettings: '高级设置',
|
||||
addTools: '添加工具',
|
||||
sysPromptDefultValue: `
|
||||
@@ -1643,6 +1657,9 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
|
||||
'该组件通过您提供的 SearXNG 实例地址进行搜索。请设置 Top N 和实例 URL。',
|
||||
docGenerator: '文档生成器',
|
||||
docGeneratorDescription: `从 Markdown 内容生成文件。`,
|
||||
browser: 'Browser',
|
||||
browserDescription:
|
||||
'使用 Browser 自动执行浏览器任务。支持模型与提示词配置,上传来源支持文件 ID 与 URL,并可将下载文件保存到指定目录。',
|
||||
subtitle: '副标题',
|
||||
logoImage: '标志图片',
|
||||
logoPosition: '标志位置',
|
||||
|
||||
@@ -123,6 +123,7 @@ export function AccordionOperators({
|
||||
Operator.WenCai,
|
||||
Operator.SearXNG,
|
||||
Operator.DocGenerator,
|
||||
Operator.Browser,
|
||||
]}
|
||||
isCustomDropdown={isCustomDropdown}
|
||||
mousePosition={mousePosition}
|
||||
|
||||
@@ -698,6 +698,7 @@ export const RestrictedUpstreamMap = {
|
||||
[Operator.LoopStart]: [Operator.Begin],
|
||||
[Operator.ExitLoop]: [Operator.Begin],
|
||||
[Operator.DocGenerator]: [Operator.Begin],
|
||||
[Operator.Browser]: [Operator.Begin],
|
||||
};
|
||||
|
||||
export const NodeMap = {
|
||||
@@ -749,6 +750,7 @@ export const NodeMap = {
|
||||
[Operator.ExitLoop]: 'exitLoopNode',
|
||||
[Operator.ExcelProcessor]: 'ragNode',
|
||||
[Operator.DocGenerator]: 'ragNode',
|
||||
[Operator.Browser]: 'ragNode',
|
||||
};
|
||||
|
||||
export enum BeginQueryType {
|
||||
@@ -980,6 +982,21 @@ export const initialDocGeneratorValues = {
|
||||
},
|
||||
};
|
||||
|
||||
export const initialBrowserValues = {
|
||||
...initialLlmBaseValues,
|
||||
prompts: `{${AgentGlobals.SysQuery}}`,
|
||||
max_steps: 30,
|
||||
headless: true,
|
||||
enable_default_extensions: false,
|
||||
chromium_sandbox: false,
|
||||
persist_session: true,
|
||||
upload_sources: '',
|
||||
outputs: {
|
||||
content: { type: 'string', value: '' },
|
||||
downloaded_files: { type: 'Array<Object>', value: [] },
|
||||
},
|
||||
};
|
||||
|
||||
export enum WebhookMethod {
|
||||
Post = 'POST',
|
||||
Get = 'GET',
|
||||
|
||||
@@ -3,6 +3,7 @@ import AgentForm from '../form/agent-form';
|
||||
import ArXivForm from '../form/arxiv-form';
|
||||
import BeginForm from '../form/begin-form';
|
||||
import BingForm from '../form/bing-form';
|
||||
import BrowserForm from '../form/browser-use-form';
|
||||
import CategorizeForm from '../form/categorize-form';
|
||||
import CodeForm from '../form/code-form';
|
||||
import CrawlerForm from '../form/crawler-form';
|
||||
@@ -114,6 +115,9 @@ export const FormConfigMap = {
|
||||
[Operator.DocGenerator]: {
|
||||
component: DocGeneratorForm,
|
||||
},
|
||||
[Operator.Browser]: {
|
||||
component: BrowserForm,
|
||||
},
|
||||
[Operator.Note]: {
|
||||
component: () => <></>,
|
||||
},
|
||||
|
||||
117
web/src/pages/agent/form/browser-use-form/index.tsx
Normal file
117
web/src/pages/agent/form/browser-use-form/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { NextLLMSelect } from '@/components/llm-select/next';
|
||||
import { RAGFlowFormItem } from '@/components/ragflow-form';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import { NumberInput } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { memo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
import { initialBrowserValues } from '../../constant';
|
||||
import { useFormValues } from '../../hooks/use-form-values';
|
||||
import { useWatchFormChange } from '../../hooks/use-watch-form-change';
|
||||
import { INextOperatorForm } from '../../interface';
|
||||
import { FormWrapper } from '../components/form-wrapper';
|
||||
import { PromptEditor } from '../components/prompt-editor';
|
||||
|
||||
const FormSchema = z.object({
|
||||
llm_id: z.string(),
|
||||
prompts: z.string(),
|
||||
max_steps: z.coerce.number().min(1),
|
||||
headless: z.boolean(),
|
||||
enable_default_extensions: z.boolean(),
|
||||
chromium_sandbox: z.boolean(),
|
||||
persist_session: z.boolean(),
|
||||
upload_sources: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormSchemaType = z.infer<typeof FormSchema>;
|
||||
|
||||
function BrowserForm({ node }: INextOperatorForm) {
|
||||
const { t } = useTranslation();
|
||||
const defaultValues = useFormValues(initialBrowserValues, node);
|
||||
const form = useForm<FormSchemaType>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(FormSchema),
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
useWatchFormChange(node?.id, form);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormWrapper>
|
||||
<RAGFlowFormItem label={t('chat.model')} name="llm_id">
|
||||
<NextLLMSelect></NextLLMSelect>
|
||||
</RAGFlowFormItem>
|
||||
<RAGFlowFormItem label={t('flow.userPrompt')} name="prompts">
|
||||
<PromptEditor showToolbar={true}></PromptEditor>
|
||||
</RAGFlowFormItem>
|
||||
<RAGFlowFormItem label={t('flow.maxSteps')} name="max_steps">
|
||||
{(field) => <NumberInput min={1} {...field}></NumberInput>}
|
||||
</RAGFlowFormItem>
|
||||
<RAGFlowFormItem label={t('flow.headless')} name="headless">
|
||||
{(field) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
></Switch>
|
||||
)}
|
||||
</RAGFlowFormItem>
|
||||
<RAGFlowFormItem
|
||||
label={t('flow.enableDefaultExtensions')}
|
||||
tooltip={t('flow.enableDefaultExtensionsTip')}
|
||||
name="enable_default_extensions"
|
||||
>
|
||||
{(field) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
></Switch>
|
||||
)}
|
||||
</RAGFlowFormItem>
|
||||
<RAGFlowFormItem
|
||||
label={t('flow.chromiumSandbox')}
|
||||
tooltip={t('flow.chromiumSandboxTip')}
|
||||
name="chromium_sandbox"
|
||||
>
|
||||
{(field) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
></Switch>
|
||||
)}
|
||||
</RAGFlowFormItem>
|
||||
<RAGFlowFormItem
|
||||
label={t('flow.persistSession')}
|
||||
tooltip={t('flow.persistSessionTip')}
|
||||
name="persist_session"
|
||||
>
|
||||
{(field) => (
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
></Switch>
|
||||
)}
|
||||
</RAGFlowFormItem>
|
||||
<RAGFlowFormItem
|
||||
label={t('flow.uploadSources')}
|
||||
tooltip={t('flow.uploadSourcesTip')}
|
||||
name="upload_sources"
|
||||
>
|
||||
{(field) => (
|
||||
<PromptEditor
|
||||
{...field}
|
||||
showToolbar
|
||||
multiLine={false}
|
||||
placeholder="file_id,https://example.com/a.pdf,{node@files.0.id}"
|
||||
></PromptEditor>
|
||||
)}
|
||||
</RAGFlowFormItem>
|
||||
</FormWrapper>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(BrowserForm);
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
initialArXivValues,
|
||||
initialBeginValues,
|
||||
initialBingValues,
|
||||
initialBrowserValues,
|
||||
initialCategorizeValues,
|
||||
initialCodeValues,
|
||||
initialCrawlerValues,
|
||||
@@ -181,6 +182,7 @@ export const useInitializeOperatorParams = () => {
|
||||
[Operator.LoopStart]: {},
|
||||
[Operator.ExitLoop]: {},
|
||||
[Operator.DocGenerator]: initialDocGeneratorValues,
|
||||
[Operator.Browser]: { ...initialBrowserValues, llm_id: llmId },
|
||||
[Operator.ExcelProcessor]: {},
|
||||
};
|
||||
}, [llmId]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils';
|
||||
import {
|
||||
FileCode,
|
||||
FileText,
|
||||
Globe,
|
||||
HousePlus,
|
||||
Infinity as InfinityIcon,
|
||||
LogOut,
|
||||
@@ -57,6 +58,7 @@ export const LucideIconMap = {
|
||||
[Operator.Loop]: InfinityIcon,
|
||||
[Operator.ExitLoop]: LogOut,
|
||||
[Operator.DocGenerator]: FileText,
|
||||
[Operator.Browser]: Globe,
|
||||
};
|
||||
|
||||
const Empty = () => {
|
||||
|
||||
@@ -132,7 +132,11 @@ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
|
||||
<FormItem>
|
||||
<FormLabel>{t('chunk.chunk')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} autoSize={{ minRows: 4, maxRows: 10 }} resize="vertical" />
|
||||
<Textarea
|
||||
{...field}
|
||||
autoSize={{ minRows: 4, maxRows: 10 }}
|
||||
resize="vertical"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
Reference in New Issue
Block a user