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:
天海蒼灆
2026-05-21 14:09:06 +08:00
committed by Jin Hai
parent da112e3db0
commit 3e5b11a523
16 changed files with 4033 additions and 29 deletions

716
agent/component/browser.py Normal file
View 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..."

View File

@@ -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
]

View 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()

View File

@@ -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)

2902
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -109,6 +109,7 @@ export enum Operator {
StringTransform = 'StringTransform',
SearXNG = 'SearXNG',
DocGenerator = 'DocGenerator',
Browser = 'Browser',
Placeholder = 'Placeholder',
DataOperations = 'DataOperations',
ListOperations = 'ListOperations',

View File

@@ -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',

View File

@@ -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ııyor.',
total_size_exceeded:
'Geçersiz beceri: Toplam paket boyutu 50MB sınırınıı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ığı',

View File

@@ -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: '标志位置',

View File

@@ -123,6 +123,7 @@ export function AccordionOperators({
Operator.WenCai,
Operator.SearXNG,
Operator.DocGenerator,
Operator.Browser,
]}
isCustomDropdown={isCustomDropdown}
mousePosition={mousePosition}

View File

@@ -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',

View File

@@ -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: () => <></>,
},

View 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);

View File

@@ -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]);

View File

@@ -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 = () => {

View File

@@ -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>