feat(agent): add Sofya search tool (#19323)

Add Sofya web search tool — returns full page content (not snippets). Modeled on You.com. Configurable depth (basic/snippets), top N, and per-call params.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jin Hai <haijin.chn@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-09-08 12:40:36 +08:00
1 parent 6c280f10a0
commit c44e171dbc
19 files changed
+953 -1

No files matched your search

+290
View File
@@ -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", "-_-!"))
@@ -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")
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="24" height="24" fill="none" role="img" aria-label="Sofya">
<title>Sofya</title>
<g transform="translate(0 -24)">
<path d="M 80 400 L 80 300 C 80 220, 140 160, 210 160 L 210 400 Z" fill="#b0b820"/>
<path d="M 210 160 L 242 190 L 242 385 L 210 400 Z" fill="#707818"/>
<path d="M 302 160 L 270 190 L 270 385 L 302 400 Z" fill="#707818"/>
<path d="M 432 400 L 432 300 C 432 220, 372 160, 302 160 L 302 400 Z" fill="#b0b820"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 525 B

+1
View File
@@ -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',
+1
View File
@@ -119,6 +119,7 @@ export enum Operator {
BGPT = 'BGPT',
KeenableSearch = 'KeenableSearch',
YouComSearch = 'YouComSearch',
SofyaSearch = 'SofyaSearch',
DocGenerator = 'DocGenerator',
Browser = 'Browser',
Placeholder = 'Placeholder',
+9
View File
@@ -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',
+9
View File
@@ -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',
@@ -127,6 +127,7 @@ export function AccordionOperators({
Operator.SearXNG,
Operator.KeenableSearch,
Operator.YouComSearch,
Operator.SofyaSearch,
Operator.DocGenerator,
Operator.Browser,
]}
+24
View File
@@ -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<Object>',
},
},
};
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',
@@ -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,
},
@@ -31,6 +31,7 @@ const Menus = [
Operator.SearXNG,
Operator.KeenableSearch,
Operator.YouComSearch,
Operator.SofyaSearch,
Operator.YahooFinance,
Operator.PubMed,
Operator.BGPT,
@@ -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, string> = {
[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 (
<>
<ApiKeyField placeholder={t('sofyaApiKeyTip')}></ApiKeyField>
<FormField
control={form.control}
name={'search_depth'}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('sofyaSearchDepthTip')}>
{t('sofyaSearchDepth')}
</FormLabel>
<FormControl>
<RAGFlowSelect {...field} options={searchDepthOptions} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<TopNFormField></TopNFormField>
</>
);
}
const SofyaOutputList = buildOutputList(initialSofyaValues.outputs);
function SofyaForm({ node }: INextOperatorForm) {
const defaultValues = useFormValues(initialSofyaValues, node);
const form = useForm<z.infer<typeof FormSchema>>({
defaultValues,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<QueryVariable></QueryVariable>
<SofyaWidgets></SofyaWidgets>
</FormContainer>
</FormWrapper>
<div className="p-5">
<Output list={SofyaOutputList}></Output>
</div>
</Form>
);
}
export default memo(SofyaForm);
@@ -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,
};
@@ -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<z.infer<typeof FormSchema>>({
defaultValues: values,
resolver: zodResolver(FormSchema),
});
useWatchFormChange(form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<SofyaWidgets></SofyaWidgets>
</FormContainer>
</FormWrapper>
</Form>
);
}
export default memo(SofyaForm);
@@ -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,
@@ -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;
@@ -33,6 +33,7 @@ type IToolIcon =
| Operator.QueritSearch
| Operator.KeenableSearch
| Operator.YouComSearch
| Operator.SofyaSearch
| Operator.Wikipedia
| Operator.YahooFinance
| Operator.WenCai
@@ -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: {
@@ -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') {