diff --git a/agent/tools/sofya.py b/agent/tools/sofya.py new file mode 100644 index 0000000000..e7b8a6417a --- /dev/null +++ b/agent/tools/sofya.py @@ -0,0 +1,290 @@ +# +# 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 logging +import os +import time +from abc import ABC + +import requests + +from agent.tools.base import ToolBase, ToolMeta, ToolParamBase +from common.connection_utils import timeout + +SOFYA_SEARCH_URL = "https://sofya.co/v1/search" +# Sofya returns at most 20 results per search. +SOFYA_MAX_RESULTS = 20 +# "basic" fetches the result pages and returns their content; "snippets" +# returns the search snippets only, which is faster and cheaper. +SOFYA_SEARCH_DEPTHS = ["basic", "snippets"] +SOFYA_DEFAULT_SEARCH_DEPTH = "basic" +SOFYA_TOPICS = ["general", "news"] +SOFYA_DEFAULT_TOPIC = "general" +# Sofya also accepts a date range for freshness. Only the named windows are +# offered here, so the value can be checked against a fixed list. +SOFYA_FRESHNESS_ANY = "any" +SOFYA_FRESHNESS_VALUES = [SOFYA_FRESHNESS_ANY, "day", "week", "month", "year"] +# Statuses worth another attempt. Anything else (bad request, bad key, no +# credits) will fail the same way again, so it is returned at once. +SOFYA_RETRY_STATUSES = {429, 500, 502, 503, 504} + + +def _search(api_key: str, payload: dict, timeout_s: int = 30) -> dict: + """POST a search to Sofya and return the decoded body.""" + headers = { + "Authorization": f"Bearer {(api_key or '').strip()}", + "Content-Type": "application/json", + "Accept": "application/json", + # Identifies RAGFlow to Sofya. + "User-Agent": "RAGFlow sofya-integration/infiniflow-ragflow", + } + response = requests.post(SOFYA_SEARCH_URL, headers=headers, json=payload, timeout=timeout_s) + response.raise_for_status() + return response.json() + + +def _is_transient(error: Exception) -> bool: + """Whether a failed request might succeed if repeated.""" + if isinstance(error, requests.HTTPError): + response = getattr(error, "response", None) + return getattr(response, "status_code", None) in SOFYA_RETRY_STATUSES + return isinstance(error, requests.RequestException) + + +def _search_depth(value) -> str: + """Normalize the node's search depth. + + This one is set in the node config rather than by the caller, so an unknown + value falls back to the default instead of failing the run. + """ + depth = str(value or "").strip().lower() + if depth in SOFYA_SEARCH_DEPTHS: + return depth + if depth: + logging.warning(f"Sofya search depth {depth} is not supported, using {SOFYA_DEFAULT_SEARCH_DEPTH}") + return SOFYA_DEFAULT_SEARCH_DEPTH + + +def _topic(value) -> str: + """Normalize a topic. Blank means the default, anything else must be known.""" + topic = str(value or "").strip().lower() + if not topic: + return SOFYA_DEFAULT_TOPIC + if topic not in SOFYA_TOPICS: + raise ValueError("Topic {} is not supported, it should be in {}".format(topic, SOFYA_TOPICS)) + return topic + + +def _freshness(value) -> str: + """Normalize a freshness value to a Sofya window, or "" for no limit. + + Blank and `any` both mean no restriction. Anything else has to be one of the + named windows, because the value is forwarded to Sofya as-is. + """ + freshness = str(value or "").strip().lower() + if not freshness or freshness == SOFYA_FRESHNESS_ANY: + return "" + if freshness not in SOFYA_FRESHNESS_VALUES: + raise ValueError("Freshness {} is not supported, it should be in {}".format(freshness, SOFYA_FRESHNESS_VALUES)) + return freshness + + +def _max_results(value, fallback) -> int: + """Result count: the caller's if it gave one, else the node's Top N. + + A blank or unparseable caller value is treated as "not given" so a bad + argument falls back rather than failing the run. + """ + for candidate in (value, fallback): + try: + count = int(candidate) + except (TypeError, ValueError): + continue + return min(max(1, count), SOFYA_MAX_RESULTS) + return 1 + + +def _result_content(result: dict) -> str: + """Prefer the page content, falling back to the search snippet. + + `content` carries the text of the result page and is empty when the page + was not read; `description` is the search snippet. Either can be missing, so + a result with no text at all returns "" and is dropped rather than stored as + a blank chunk. + """ + content = " ".join(str(result.get("content") or "").split()) + if content: + return content + return " ".join(str(result.get("description") or "").split()) + + +def _results(data, top_n: int) -> list[dict]: + """Pull the result list out of the response body.""" + if not isinstance(data, dict): + return [] + results = data.get("results") + if not isinstance(results, list): + return [] + return [r for r in results if isinstance(r, dict)][:top_n] + + +class SofyaSearchParam(ToolParamBase): + """ + Define the Sofya search component parameters. + """ + + def __init__(self): + self.meta: ToolMeta = { + "name": "sofya_search", + "description": """ +Sofya is a web search API for AI agents. It returns the content of the result +pages, not just a snippet, so results carry usable context without a separate +fetch. An API key is required. +When searching: + - Use a focused query of the most important terms (and synonyms). + - Use the news topic for current events, and the general topic otherwise. + - Optionally restrict results by how recently they were published. + """, + "parameters": { + "query": { + "type": "string", + "description": "The search keywords to execute with Sofya. The keywords should be the most important words/terms(includes synonyms) from the original request.", + "default": "{sys.query}", + "required": True, + }, + "topic": { + "type": "string", + "description": "default:'general'. The category of the search. 'news' searches news sources and suits current events; 'general' is a broader web search.", + "enum": SOFYA_TOPICS, + "default": SOFYA_DEFAULT_TOPIC, + "required": False, + }, + "freshness": { + "type": "string", + "description": "default:'any'. Restrict results by recency. One of 'day', 'week', 'month', 'year', or 'any' for no limit.", + "enum": SOFYA_FRESHNESS_VALUES, + "default": SOFYA_FRESHNESS_ANY, + "required": False, + }, + "max_results": { + "type": "integer", + "description": "Number of results to return, 1 to 20. Leave it out to use the Top N set on the node.", + "default": "", + "required": False, + }, + }, + } + super().__init__() + self.api_key = "" + self.search_depth = SOFYA_DEFAULT_SEARCH_DEPTH + self.top_n = 10 + + def check(self): + self.check_empty(self.api_key, "Sofya API key") + self.check_valid_value(self.search_depth, "Sofya search depth should be in 'basic/snippets'", ["basic", "snippets"]) + self.check_positive_integer(self.top_n, "Top N") + + def get_input_form(self) -> dict[str, dict]: + return { + "query": { + "name": "Query", + "type": "line", + }, + "topic": { + "name": "Topic", + "type": "options", + "value": SOFYA_DEFAULT_TOPIC, + "options": SOFYA_TOPICS, + }, + "freshness": { + "name": "Freshness", + "type": "options", + "value": SOFYA_FRESHNESS_ANY, + "options": SOFYA_FRESHNESS_VALUES, + }, + } + + +class SofyaSearch(ToolBase, ABC): + component_name = "SofyaSearch" + + @timeout(int(os.environ.get("COMPONENT_EXEC_TIMEOUT", 12))) + def _invoke(self, **kwargs): + if self.check_if_canceled("SofyaSearch processing"): + return + + if not kwargs.get("query"): + self.set_output("formalized_content", "") + return "" + + search_depth = _search_depth(self._param.search_depth) + top_n = _max_results(kwargs.get("max_results"), self._param.top_n) + payload = { + "query": kwargs["query"], + "search_depth": search_depth, + "max_results": top_n, + "topic": _topic(kwargs.get("topic")), + } + freshness = _freshness(kwargs.get("freshness")) + if freshness: + payload["freshness"] = freshness + + logging.info(f"SofyaSearch: starting search (search_depth={search_depth}, max_results={top_n})") + last_e = None + attempts = self._param.max_retries + 1 + for attempt in range(attempts): + if self.check_if_canceled("SofyaSearch processing"): + logging.info("SofyaSearch: cancelled before request") + return + + try: + data = _search(self._param.api_key, payload) + if self.check_if_canceled("SofyaSearch processing"): + logging.info("SofyaSearch: cancelled after request") + return + + results = _results(data, top_n) + self._retrieve_chunks( + results, + get_title=lambda r: str(r.get("title") or ""), + get_url=lambda r: str(r.get("url") or ""), + get_content=_result_content, + ) + self.set_output("json", results) + logging.info(f"SofyaSearch: returned {len(results)} results") + return self.output("formalized_content") + except Exception as e: + if self.check_if_canceled("SofyaSearch processing"): + return + + # Only the exception type is recorded, so the query and the key + # stay out of the log whatever the error message holds. + last_e = e + logging.error(f"Sofya error: {type(e).__name__}") + if not _is_transient(e): + break + if attempt < attempts - 1: + time.sleep(self._param.delay_after_error) + + if last_e: + return f"Sofya error: {type(last_e).__name__}" + + assert False, self.output() + + def thoughts(self) -> str: + return """ +Keywords: {} +Looking for the most relevant articles. + """.format(self.get_input().get("query", "-_-!")) diff --git a/test/unit_test/agent/tools/test_sofya_unit.py b/test/unit_test/agent/tools/test_sofya_unit.py new file mode 100644 index 0000000000..8a8d6567cc --- /dev/null +++ b/test/unit_test/agent/tools/test_sofya_unit.py @@ -0,0 +1,412 @@ +# +# 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 agent.tools.sofya as sofya_module +from agent.tools.sofya import SofyaSearch, SofyaSearchParam + +SEARCH_URL = "https://sofya.co/v1/search" + + +class _FakeResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + self.url = SEARCH_URL + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise sofya_module.requests.HTTPError( + f"{self.status_code} Client Error for url: {self.url}", + response=self, + ) + + +def _make_tool(api_key="sofya-key"): + tool = SofyaSearch.__new__(SofyaSearch) + param = SofyaSearchParam() + param.api_key = api_key + param.delay_after_error = 0 + param.max_retries = 0 + tool._param = param + tool.check_if_canceled = lambda *args, **kwargs: False + + captured = {} + outputs = {} + + def fake_retrieve(results, get_title, get_url, get_content): + captured["references"] = [{"title": get_title(item), "url": get_url(item), "content": get_content(item)} for item in results] + outputs["formalized_content"] = "FORMALIZED" + + tool._retrieve_chunks = fake_retrieve + tool.set_output = lambda key, value: outputs.__setitem__(key, value) + tool.output = lambda key=None: outputs.get(key) if key else outputs + return tool, captured, outputs + + +def _payload(): + return { + "results": [ + { + "url": "https://example.com/a", + "title": "A", + "description": "Snippet for A.", + "content": "Page text for A.", + }, + { + "url": "https://example.com/b", + "title": "B", + "description": "Snippet for B.", + "content": "", + }, + ] + } + + +def _capture_post(monkeypatch, response=None): + calls = [] + + def fake_post(url, headers=None, json=None, timeout=None): + calls.append({"url": url, "headers": headers, "json": json}) + return response if response is not None else _FakeResponse(_payload()) + + monkeypatch.setattr(sofya_module.requests, "post", fake_post) + return calls + + +def test_search_posts_the_query_with_a_bearer_key(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, outputs = _make_tool(api_key=" sofya-key ") + + tool._invoke(query="What is RAGFlow?") + + assert calls[0]["url"] == SEARCH_URL + assert calls[0]["headers"]["Authorization"] == "Bearer sofya-key" + assert calls[0]["json"]["query"] == "What is RAGFlow?" + assert outputs["formalized_content"] == "FORMALIZED" + + +def test_requests_identify_ragflow(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, _outputs = _make_tool() + + tool._invoke(query="q") + + assert calls[0]["headers"]["User-Agent"] == "RAGFlow sofya-integration/infiniflow-ragflow" + + +def test_page_content_is_preferred_over_the_snippet(monkeypatch): + _capture_post(monkeypatch) + tool, captured, outputs = _make_tool() + + tool._invoke(query="q") + + assert captured["references"] == [ + {"title": "A", "url": "https://example.com/a", "content": "Page text for A."}, + # B has no page content, so its snippet is used. + {"title": "B", "url": "https://example.com/b", "content": "Snippet for B."}, + ] + assert len(outputs["json"]) == 2 + + +def test_non_string_result_fields_do_not_raise(monkeypatch): + payload = {"results": [{"url": 42, "title": True, "content": 7, "description": None}]} + _capture_post(monkeypatch, _FakeResponse(payload)) + tool, captured, _outputs = _make_tool() + + tool._invoke(query="q") + + assert captured["references"] == [{"title": "True", "url": "42", "content": "7"}] + + +def test_whitespace_only_content_falls_back_to_the_snippet(monkeypatch): + payload = {"results": [{"url": "https://example.com/c", "title": "C", "content": " ", "description": " Snippet for C. "}]} + _capture_post(monkeypatch, _FakeResponse(payload)) + tool, captured, _outputs = _make_tool() + + tool._invoke(query="q") + + assert captured["references"] == [{"title": "C", "url": "https://example.com/c", "content": "Snippet for C."}] + + +def test_a_result_with_no_text_at_all_yields_no_content(monkeypatch): + payload = {"results": [{"url": "https://example.com/d", "title": "D", "content": " ", "description": " "}]} + _capture_post(monkeypatch, _FakeResponse(payload)) + tool, captured, _outputs = _make_tool() + + tool._invoke(query="q") + + # An empty content is dropped by _retrieve_chunks rather than stored blank. + assert captured["references"] == [{"title": "D", "url": "https://example.com/d", "content": ""}] + + +def test_a_results_field_that_is_not_a_list_yields_nothing(monkeypatch): + _capture_post(monkeypatch, _FakeResponse({"results": None})) + tool, captured, _outputs = _make_tool() + + tool._invoke(query="q") + + assert captured["references"] == [] + + +def test_the_node_top_n_is_used_when_the_caller_gives_no_count(monkeypatch): + calls = _capture_post(monkeypatch) + tool, captured, _outputs = _make_tool() + tool._param.top_n = 1 + + tool._invoke(query="q") + + assert calls[0]["json"]["max_results"] == 1 + assert [r["title"] for r in captured["references"]] == ["A"] + + +def test_a_caller_supplied_count_wins_over_the_node_top_n(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, _outputs = _make_tool() + tool._param.top_n = 10 + + tool._invoke(query="q", max_results=3) + + assert calls[0]["json"]["max_results"] == 3 + + +def test_the_result_count_is_clamped_to_the_supported_range(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, _outputs = _make_tool() + + tool._invoke(query="q", max_results=500) + assert calls[0]["json"]["max_results"] == 20 + + tool._invoke(query="q", max_results=0) + assert calls[1]["json"]["max_results"] == 1 + + # An unusable argument falls back to the node's Top N. + tool._invoke(query="q", max_results="lots") + assert calls[2]["json"]["max_results"] == tool._param.top_n + + +def test_an_unknown_search_depth_falls_back_to_the_default(monkeypatch, caplog): + calls = _capture_post(monkeypatch) + tool, _captured, _outputs = _make_tool() + tool._param.search_depth = "advanced" + + with caplog.at_level("WARNING"): + tool._invoke(query="q") + + assert calls[0]["json"]["search_depth"] == "basic" + assert "advanced" in caplog.text + + +def test_the_search_depth_is_forwarded_when_it_is_supported(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, _outputs = _make_tool() + tool._param.search_depth = "snippets" + + tool._invoke(query="q") + + assert calls[0]["json"]["search_depth"] == "snippets" + + +def test_freshness_is_forwarded_only_when_a_window_is_set(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, _outputs = _make_tool() + + tool._invoke(query="q", freshness="week") + assert calls[0]["json"]["freshness"] == "week" + + # Blank and `any` both mean no restriction. + tool._invoke(query="q", freshness="") + assert "freshness" not in calls[1]["json"] + + tool._invoke(query="q", freshness="any") + assert "freshness" not in calls[2]["json"] + + +def test_an_unsupported_freshness_is_rejected_before_the_request(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, _outputs = _make_tool() + + try: + tool._invoke(query="q", freshness="decade") + except ValueError as e: + assert "decade" in str(e) + assert calls == [] + return + raise AssertionError("expected an unsupported freshness to raise") + + +def test_an_unsupported_topic_is_rejected_before_the_request(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, _outputs = _make_tool() + + try: + tool._invoke(query="q", topic="sports") + except ValueError as e: + assert "sports" in str(e) + assert calls == [] + return + raise AssertionError("expected an unsupported topic to raise") + + +def test_the_topic_defaults_to_general(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, _outputs = _make_tool() + + tool._invoke(query="q") + assert calls[0]["json"]["topic"] == "general" + + tool._invoke(query="q", topic="news") + assert calls[1]["json"]["topic"] == "news" + + +def test_the_published_schema_restricts_the_enums(): + properties = SofyaSearchParam().get_meta()["function"]["parameters"]["properties"] + + assert properties["topic"]["enum"] == ["general", "news"] + assert properties["freshness"]["enum"] == ["any", "day", "week", "month", "year"] + + +def test_blank_query_short_circuits(monkeypatch): + calls = _capture_post(monkeypatch) + tool, _captured, outputs = _make_tool() + + assert tool._invoke(query="") == "" + assert calls == [] + assert outputs["formalized_content"] == "" + + +def test_failures_never_log_the_query_or_the_key(monkeypatch, caplog): + _capture_post(monkeypatch, _FakeResponse({}, status_code=402)) + tool, _captured, _outputs = _make_tool(api_key="sofya-secret") + + with caplog.at_level("ERROR"): + result = tool._invoke(query="my private query") + + assert "my private query" not in caplog.text + assert "sofya-secret" not in caplog.text + assert "my private query" not in str(result) + assert "HTTPError" in str(result) + + +def _capture_sleep(monkeypatch): + """Record the retry delays without patching the stdlib for other threads.""" + slept = [] + + class _Clock: + @staticmethod + def sleep(seconds): + slept.append(seconds) + + monkeypatch.setattr(sofya_module, "time", _Clock) + return slept + + +def test_a_failed_final_attempt_does_not_sleep(monkeypatch): + """The delay only buys something when another attempt follows it.""" + _capture_post(monkeypatch, _FakeResponse({}, status_code=402)) + slept = _capture_sleep(monkeypatch) + tool, _captured, _outputs = _make_tool() + tool._param.max_retries = 0 + tool._param.delay_after_error = 5 + + tool._invoke(query="q") + + assert slept == [] + + +def test_transient_failures_are_retried_with_a_delay(monkeypatch): + calls = _capture_post(monkeypatch, _FakeResponse({}, status_code=503)) + slept = _capture_sleep(monkeypatch) + tool, _captured, _outputs = _make_tool() + tool._param.max_retries = 2 + tool._param.delay_after_error = 5 + + tool._invoke(query="q") + + assert len(calls) == 3 + # Two gaps between three attempts, and nothing after the last one. + assert slept == [5, 5] + + +def test_non_transient_failures_are_not_retried(monkeypatch): + """A bad key or no credits fails the same way every time, so ask once.""" + calls = _capture_post(monkeypatch, _FakeResponse({}, status_code=402)) + slept = _capture_sleep(monkeypatch) + tool, _captured, _outputs = _make_tool() + tool._param.max_retries = 2 + tool._param.delay_after_error = 5 + + result = tool._invoke(query="q") + + assert len(calls) == 1 + assert slept == [] + assert "HTTPError" in str(result) + + +def test_network_errors_are_retried(monkeypatch): + slept = _capture_sleep(monkeypatch) + attempts = [] + + def failing_post(url, headers=None, json=None, timeout=None): + attempts.append(url) + raise sofya_module.requests.ConnectionError("connection reset") + + monkeypatch.setattr(sofya_module.requests, "post", failing_post) + tool, _captured, _outputs = _make_tool() + tool._param.max_retries = 1 + tool._param.delay_after_error = 2 + + result = tool._invoke(query="q") + + assert len(attempts) == 2 + assert slept == [2] + assert "ConnectionError" in str(result) + + +def test_param_check_requires_an_api_key(): + param = SofyaSearchParam() + + try: + param.check() + except Exception: + return + raise AssertionError("expected check() to reject a blank API key") + + +def test_param_check_rejects_an_unknown_search_depth(): + param = SofyaSearchParam() + param.api_key = "sofya-key" + param.search_depth = "advanced" + + try: + param.check() + except Exception: + return + raise AssertionError("expected check() to reject an unknown search depth") + + +def test_param_check_rejects_a_non_positive_top_n(): + param = SofyaSearchParam() + param.api_key = "sofya-key" + param.top_n = 0 + + try: + param.check() + except Exception: + return + raise AssertionError("expected check() to reject top_n=0") diff --git a/web/src/assets/svg/sofya.svg b/web/src/assets/svg/sofya.svg new file mode 100644 index 0000000000..400aa72672 --- /dev/null +++ b/web/src/assets/svg/sofya.svg @@ -0,0 +1,9 @@ + + Sofya + + + + + + + diff --git a/web/src/components/operator-icon.tsx b/web/src/components/operator-icon.tsx index 021d28d228..ff52e29325 100644 --- a/web/src/components/operator-icon.tsx +++ b/web/src/components/operator-icon.tsx @@ -73,6 +73,7 @@ export const SVGIconMap = { [Operator.SearXNG]: 'searxng', [Operator.KeenableSearch]: 'keenable', [Operator.YouComSearch]: 'youcom', + [Operator.SofyaSearch]: 'sofya', [Operator.TavilyExtract]: 'tavily', [Operator.TavilySearch]: 'tavily', [Operator.QueritContents]: 'querit', diff --git a/web/src/constants/agent.tsx b/web/src/constants/agent.tsx index 4adccf304f..fd81020f4b 100644 --- a/web/src/constants/agent.tsx +++ b/web/src/constants/agent.tsx @@ -119,6 +119,7 @@ export enum Operator { BGPT = 'BGPT', KeenableSearch = 'KeenableSearch', YouComSearch = 'YouComSearch', + SofyaSearch = 'SofyaSearch', DocGenerator = 'DocGenerator', Browser = 'Browser', Placeholder = 'Placeholder', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 86d665914f..da5341ce1a 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -2781,6 +2781,15 @@ Best for: Documents with flowing, contextually connected content — such as boo youComFreshnessMonth: 'Past month', youComFreshnessYear: 'Past year', youComApiKeyTip: 'Optional. Leave blank to use the keyless free tier.', + sofyaSearch: 'Sofya', + sofyaSearchDescription: + 'A web search component powered by Sofya. Results carry the content of each result page, or only its search snippet, depending on the search depth. An API Key is required.', + sofyaSearchDepth: 'Search depth', + sofyaSearchDepthTip: + 'Basic returns the content of each result page. Snippets returns the search snippets only, which is faster and costs less.', + sofyaSearchDepthBasic: 'Page content', + sofyaSearchDepthSnippets: 'Snippets only', + sofyaApiKeyTip: 'Required. Create an API Key at sofya.co.', docGenerator: 'Doc Generator', docGeneratorDescription: `Generate a file from Markdown content.`, browser: 'Browser', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index a78098d885..9ecb5daabb 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -2405,6 +2405,15 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取 Entities 和 R youComFreshnessMonth: '一个月内', youComFreshnessYear: '一年内', youComApiKeyTip: '可选。留空则使用免密钥的免费额度。', + sofyaSearch: 'Sofya', + sofyaSearchDescription: + '基于 Sofya 的网络搜索组件。根据搜索深度,结果附带结果页面的正文内容或仅附带搜索摘要。需要配置 API Key。', + sofyaSearchDepth: '搜索深度', + sofyaSearchDepthTip: + 'Basic 返回每条结果页面的正文内容;Snippets 只返回搜索摘要,速度更快、消耗更少。', + sofyaSearchDepthBasic: '页面正文', + sofyaSearchDepthSnippets: '仅摘要', + sofyaApiKeyTip: '必填。请在 sofya.co 申请 API Key。', docGenerator: '文档生成器', docGeneratorDescription: `从 Markdown 内容生成文件。`, browser: 'Browser', diff --git a/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx b/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx index 3e44bc01da..813d5e3078 100644 --- a/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx +++ b/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx @@ -127,6 +127,7 @@ export function AccordionOperators({ Operator.SearXNG, Operator.KeenableSearch, Operator.YouComSearch, + Operator.SofyaSearch, Operator.DocGenerator, Operator.Browser, ]} diff --git a/web/src/pages/agent/constant/index.tsx b/web/src/pages/agent/constant/index.tsx index 702edf8805..ec806fbb76 100644 --- a/web/src/pages/agent/constant/index.tsx +++ b/web/src/pages/agent/constant/index.tsx @@ -220,6 +220,28 @@ export const initialYouComValues = { }, }; +export enum SofyaSearchDepth { + Basic = 'basic', + Snippets = 'snippets', +} + +export const initialSofyaValues = { + api_key: '', + query: AgentGlobals.SysQuery, + search_depth: SofyaSearchDepth.Basic, + top_n: 10, + outputs: { + formalized_content: { + value: '', + type: 'string', + }, + json: { + value: [], + type: 'Array', + }, + }, +}; + export const initialKeenableValues = { api_key: '', query: AgentGlobals.SysQuery, @@ -775,6 +797,7 @@ export const RestrictedUpstreamMap = { [Operator.SearXNG]: [Operator.Begin, Operator.Retrieval], [Operator.KeenableSearch]: [Operator.Begin, Operator.Retrieval], [Operator.YouComSearch]: [Operator.Begin, Operator.Retrieval], + [Operator.SofyaSearch]: [Operator.Begin, Operator.Retrieval], [Operator.ExeSQL]: [Operator.Begin], [Operator.Switch]: [Operator.Begin], [Operator.WenCai]: [Operator.Begin], @@ -832,6 +855,7 @@ export const NodeMap = { [Operator.SearXNG]: 'ragNode', [Operator.KeenableSearch]: 'ragNode', [Operator.YouComSearch]: 'ragNode', + [Operator.SofyaSearch]: 'ragNode', [Operator.ExeSQL]: 'ragNode', [Operator.Switch]: 'switchNode', [Operator.WenCai]: 'ragNode', diff --git a/web/src/pages/agent/form-sheet/form-config-map.tsx b/web/src/pages/agent/form-sheet/form-config-map.tsx index d3072dd82a..eba7cd7496 100644 --- a/web/src/pages/agent/form-sheet/form-config-map.tsx +++ b/web/src/pages/agent/form-sheet/form-config-map.tsx @@ -21,6 +21,7 @@ import InvokeForm from '../form/invoke-form'; import IterationForm from '../form/iteration-form'; import IterationStartForm from '../form/iteration-start-from'; import KeenableForm from '../form/keenable-form'; +import SofyaForm from '../form/sofya-form'; import YouComForm from '../form/youcom-form'; import ListOperationsForm from '../form/list-operations-form'; import LoopForm from '../form/loop-form'; @@ -82,6 +83,9 @@ export const FormConfigMap = { [Operator.YouComSearch]: { component: YouComForm, }, + [Operator.SofyaSearch]: { + component: SofyaForm, + }, [Operator.Wikipedia]: { component: WikipediaForm, }, diff --git a/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx b/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx index d93a215f0b..3cc7f0655b 100644 --- a/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx +++ b/web/src/pages/agent/form/agent-form/tool-popover/tool-command.tsx @@ -31,6 +31,7 @@ const Menus = [ Operator.SearXNG, Operator.KeenableSearch, Operator.YouComSearch, + Operator.SofyaSearch, Operator.YahooFinance, Operator.PubMed, Operator.BGPT, diff --git a/web/src/pages/agent/form/sofya-form/index.tsx b/web/src/pages/agent/form/sofya-form/index.tsx new file mode 100644 index 0000000000..68090f0b96 --- /dev/null +++ b/web/src/pages/agent/form/sofya-form/index.tsx @@ -0,0 +1,108 @@ +import { FormContainer } from '@/components/form-container'; +import { TopNFormField } from '@/components/top-n-item'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { RAGFlowSelect } from '@/components/ui/select'; +import { useTranslate } from '@/hooks/common-hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { memo, useMemo } from 'react'; +import { useForm, useFormContext } from 'react-hook-form'; +import { z } from 'zod'; +import { SofyaSearchDepth, initialSofyaValues } from '../../constant'; +import { useFormValues } from '../../hooks/use-form-values'; +import { useWatchFormChange } from '../../hooks/use-watch-form-change'; +import { INextOperatorForm } from '../../interface'; +import { buildOutputList } from '../../utils/build-output-list'; +import { ApiKeyField } from '../components/api-key-field'; +import { FormWrapper } from '../components/form-wrapper'; +import { Output } from '../components/output'; +import { QueryVariable } from '../components/query-variable'; + +// Search depth is a user-facing word, so each option carries its own key rather +// than deriving an English label from the enum value. +const SofyaSearchDepthLabelKeys: Record = { + [SofyaSearchDepth.Basic]: 'sofyaSearchDepthBasic', + [SofyaSearchDepth.Snippets]: 'sofyaSearchDepthSnippets', +}; + +export const SofyaFormPartialSchema = { + api_key: z.string().optional(), + search_depth: z.string().optional(), + top_n: z.coerce.number(), +}; + +const FormSchema = z.object({ + query: z.string(), + ...SofyaFormPartialSchema, +}); + +export function SofyaWidgets() { + const { t } = useTranslate('flow'); + const form = useFormContext(); + + const searchDepthOptions = useMemo( + () => + Object.values(SofyaSearchDepth).map((x) => ({ + value: x, + label: t(SofyaSearchDepthLabelKeys[x]), + })), + [t], + ); + + return ( + <> + + ( + + + {t('sofyaSearchDepth')} + + + + + + + )} + /> + + + ); +} + +const SofyaOutputList = buildOutputList(initialSofyaValues.outputs); + +function SofyaForm({ node }: INextOperatorForm) { + const defaultValues = useFormValues(initialSofyaValues, node); + + const form = useForm>({ + defaultValues, + resolver: zodResolver(FormSchema), + }); + + useWatchFormChange(node?.id, form); + + return ( +
+ + + + + + +
+ +
+
+ ); +} + +export default memo(SofyaForm); diff --git a/web/src/pages/agent/form/tool-form/constant.tsx b/web/src/pages/agent/form/tool-form/constant.tsx index 8be86f58b2..936e14e0b9 100644 --- a/web/src/pages/agent/form/tool-form/constant.tsx +++ b/web/src/pages/agent/form/tool-form/constant.tsx @@ -9,6 +9,7 @@ import GithubForm from './github-form'; import GoogleForm from './google-form'; import GoogleScholarForm from './google-scholar-form'; import KeenableForm from './keenable-form'; +import SofyaForm from './sofya-form'; import YouComForm from './youcom-form'; import PubMedForm from './pubmed-form'; import QueritForm from './querit-form'; @@ -44,4 +45,5 @@ export const ToolFormConfigMap = { [Operator.SearXNG]: SearXNGForm, [Operator.KeenableSearch]: KeenableForm, [Operator.YouComSearch]: YouComForm, + [Operator.SofyaSearch]: SofyaForm, }; diff --git a/web/src/pages/agent/form/tool-form/sofya-form/index.tsx b/web/src/pages/agent/form/tool-form/sofya-form/index.tsx new file mode 100644 index 0000000000..27d80ddb8e --- /dev/null +++ b/web/src/pages/agent/form/tool-form/sofya-form/index.tsx @@ -0,0 +1,35 @@ +import { FormContainer } from '@/components/form-container'; +import { Form } from '@/components/ui/form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { memo } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { FormWrapper } from '../../components/form-wrapper'; +import { SofyaFormPartialSchema, SofyaWidgets } from '../../sofya-form'; +import { useValues } from '../use-values'; +import { useWatchFormChange } from '../use-watch-change'; + +function SofyaForm() { + const values = useValues(); + + const FormSchema = z.object(SofyaFormPartialSchema); + + const form = useForm>({ + defaultValues: values, + resolver: zodResolver(FormSchema), + }); + + useWatchFormChange(form); + + return ( +
+ + + + + +
+ ); +} + +export default memo(SofyaForm); diff --git a/web/src/pages/agent/hooks/use-add-node.ts b/web/src/pages/agent/hooks/use-add-node.ts index 96c80cebbd..d76f1408a8 100644 --- a/web/src/pages/agent/hooks/use-add-node.ts +++ b/web/src/pages/agent/hooks/use-add-node.ts @@ -32,6 +32,7 @@ import { initialIterationStartValues, initialIterationValues, initialKeenableValues, + initialSofyaValues, initialYouComValues, initialListOperationsValues, initialLoopValues, @@ -176,6 +177,7 @@ export const useInitializeOperatorParams = () => { [Operator.QueritSearch]: initialQueritValues, [Operator.KeenableSearch]: initialKeenableValues, [Operator.YouComSearch]: initialYouComValues, + [Operator.SofyaSearch]: initialSofyaValues, [Operator.UserFillUp]: initialUserFillUpValues, [Operator.StringTransform]: initialStringTransformValues, [Operator.TavilyExtract]: initialTavilyExtractValues, diff --git a/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts b/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts index 91fbd8ea08..e3145aa3bc 100644 --- a/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts +++ b/web/src/pages/agent/hooks/use-agent-tool-initial-values.ts @@ -70,6 +70,8 @@ export function useAgentToolInitialValues() { return pick(initialValues, 'api_key', 'mode', 'site', 'top_n'); case Operator.YouComSearch: return pick(initialValues, 'api_key', 'freshness', 'top_n'); + case Operator.SofyaSearch: + return pick(initialValues, 'api_key', 'search_depth', 'top_n'); default: return initialValues; diff --git a/web/src/pages/agent/log-sheet/tool-timeline-item.tsx b/web/src/pages/agent/log-sheet/tool-timeline-item.tsx index 234c0a6c73..ebb4a0ea55 100644 --- a/web/src/pages/agent/log-sheet/tool-timeline-item.tsx +++ b/web/src/pages/agent/log-sheet/tool-timeline-item.tsx @@ -33,6 +33,7 @@ type IToolIcon = | Operator.QueritSearch | Operator.KeenableSearch | Operator.YouComSearch + | Operator.SofyaSearch | Operator.Wikipedia | Operator.YahooFinance | Operator.WenCai diff --git a/web/src/pages/agent/utils/clear-sensitive-fields.test.ts b/web/src/pages/agent/utils/clear-sensitive-fields.test.ts index 67b7e1293a..0ccb4ae950 100644 --- a/web/src/pages/agent/utils/clear-sensitive-fields.test.ts +++ b/web/src/pages/agent/utils/clear-sensitive-fields.test.ts @@ -5,6 +5,7 @@ jest.mock('@/constants/agent', () => ({ Google: 'Google', KeenableSearch: 'KeenableSearch', YouComSearch: 'YouComSearch', + SofyaSearch: 'SofyaSearch', BGPT: 'Bing', QueritContents: 'QueritContents', QueritSearch: 'QueritSearch', @@ -146,6 +147,42 @@ describe('clearSensitiveFields', () => { expect(dsl.tools[0].params.api_key).toBe('ydc-tool-secret'); }); + it('clears a Sofya key from a canvas node and from a tool record', () => { + const dsl = { + graph: { + nodes: [ + { + data: { + label: Operator.SofyaSearch, + form: { + api_key: 'sofya-graph-secret', + search_depth: 'basic', + }, + }, + }, + ], + }, + tools: [ + { + component_name: Operator.SofyaSearch, + params: { + api_key: 'sofya-tool-secret', + top_n: 10, + }, + }, + ], + }; + + const sanitized = clearSensitiveFields(dsl); + + expect(sanitized.graph.nodes[0].data.form.api_key).toBe(''); + expect(sanitized.graph.nodes[0].data.form.search_depth).toBe('basic'); + expect(sanitized.tools[0].params.api_key).toBe(''); + expect(sanitized.tools[0].params.top_n).toBe(10); + expect(dsl.graph.nodes[0].data.form.api_key).toBe('sofya-graph-secret'); + expect(dsl.tools[0].params.api_key).toBe('sofya-tool-secret'); + }); + it('does not change standalone graph export behavior for other tools', () => { const dsl = { graph: { diff --git a/web/src/pages/agent/utils/clear-sensitive-fields.ts b/web/src/pages/agent/utils/clear-sensitive-fields.ts index 8fc8f10ec4..795090d6bb 100644 --- a/web/src/pages/agent/utils/clear-sensitive-fields.ts +++ b/web/src/pages/agent/utils/clear-sensitive-fields.ts @@ -7,6 +7,7 @@ const apiKeyOperators = [ Operator.Google, Operator.KeenableSearch, Operator.YouComSearch, + Operator.SofyaSearch, Operator.BGPT, Operator.QueritContents, Operator.QueritSearch, @@ -15,7 +16,10 @@ const apiKeyOperators = [ // Canvas nodes carry the operator under `data.label` and the key under // `data.form.api_key`, so a graph export has to be sanitized separately from the // agent-tool records above. -const nodeLabelApiKeyOperators: string[] = [Operator.YouComSearch]; +const nodeLabelApiKeyOperators: string[] = [ + Operator.YouComSearch, + Operator.SofyaSearch, +]; function isQueritOperator(value: unknown) { if (typeof value !== 'string') {