From 10d02e54a8c3c317b2e02bccdec0ffb53fd3fb15 Mon Sep 17 00:00:00 2001 From: Ilya Bogin Date: Thu, 25 Jun 2026 07:12:28 +0300 Subject: [PATCH] Add Keenable web search tool to the agent (#16233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Keenable as a web search tool in the agent, alongside the existing Tavily/DuckDuckGo/SearXNG/Google tools. The main difference from the other search tools is that it doesn't require an API key. By default it uses Keenable's keyless public endpoint, so it works out of the box. Providing a key (in the tool config) switches to the authenticated endpoint and lifts the rate limits. ### Changes - Backend: `agent/tools/keenable.py` — `KeenableSearch`, follows the Tavily/DuckDuckGo tool shape (results go through `_retrieve_chunks`). Auto-registered by `agent/tools/__init__.py`. - Frontend: wired into the agent builder — operator + icon, config form (optional API key, search mode, site filter, top N), the search tool menu, and the existing api_key export sanitizer. ### Config - API key: optional. Blank = keyless free tier; set it to lift limits / enable `realtime` mode. - `site`: restrict to a single domain. - `mode`: `pro` (default) or `realtime`. ### Notes `KEENABLE_API_URL` can override the API base (HTTPS enforced; defaults to `https://api.keenable.ai`). The tool only sends the query (no URL fetch), so there's no SSRF surface. Verified the frontend with `vite build` and the backend search path against the public endpoint. --- agent/tools/keenable.py | 183 ++++++++++++++++++ web/src/assets/svg/keenable.svg | 5 + web/src/constants/agent.tsx | 1 + web/src/locales/en.ts | 6 + .../node/dropdown/accordion-operators.tsx | 1 + web/src/pages/agent/constant/index.tsx | 25 +++ .../agent/form-sheet/form-config-map.tsx | 4 + .../agent-form/tool-popover/tool-command.tsx | 1 + .../pages/agent/form/keenable-form/index.tsx | 114 +++++++++++ .../pages/agent/form/tool-form/constant.tsx | 2 + .../form/tool-form/keenable-form/index.tsx | 38 ++++ web/src/pages/agent/hooks/use-add-node.ts | 2 + .../hooks/use-agent-tool-initial-values.ts | 2 + web/src/pages/agent/hooks/use-export-json.ts | 9 +- .../agent/log-sheet/tool-timeline-item.tsx | 1 + web/src/pages/agent/operator-icon.tsx | 1 + 16 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 agent/tools/keenable.py create mode 100644 web/src/assets/svg/keenable.svg create mode 100644 web/src/pages/agent/form/keenable-form/index.tsx create mode 100644 web/src/pages/agent/form/tool-form/keenable-form/index.tsx diff --git a/agent/tools/keenable.py b/agent/tools/keenable.py new file mode 100644 index 0000000000..3797336d0e --- /dev/null +++ b/agent/tools/keenable.py @@ -0,0 +1,183 @@ +# +# Copyright 2025 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 +from urllib.parse import urlsplit + +import requests + +from agent.tools.base import ToolBase, ToolMeta, ToolParamBase +from common.connection_utils import timeout + + +def _base_url() -> str: + """Resolve the Keenable API base URL from ``KEENABLE_API_URL`` (HTTPS enforced).""" + base = (os.environ.get("KEENABLE_API_URL") or "https://api.keenable.ai").rstrip("/") + parsed = urlsplit(base) + if parsed.hostname: + if parsed.scheme == "https": + return base + # Permit plain http only against a loopback host (local dev). + if parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}: + return base + raise ValueError(f"KEENABLE_API_URL must be an https:// URL with a host, got {base!r}") + + +def _request(method: str, public_path: str, keyed_path: str, api_key: str, *, params=None, json=None, timeout_s: int = 30): + """Call the keyed endpoint with X-API-Key when a key is set, else the keyless public one.""" + api_key = (api_key or "").strip() + headers = { + "User-Agent": "keenable-ragflow", + # Attribution header the Keenable backend segments traffic by. + "X-Keenable-Title": "RAGFlow", + } + if api_key: + path = keyed_path + headers["X-API-Key"] = api_key + else: + path = public_path + resp = requests.request(method, f"{_base_url()}{path}", headers=headers, params=params, json=json, timeout=timeout_s) + resp.raise_for_status() + return resp.json() + + +class KeenableSearchParam(ToolParamBase): + """ + Define the Keenable search component parameters. + """ + + def __init__(self): + self.meta: ToolMeta = { + "name": "keenable_search", + "description": """ +Keenable is a web search API built for AI agents. It returns fresh, relevant web +results for a query and works without an API key by default (keyless free tier). +When searching: + - Use a focused query of the most important terms (and synonyms). + - Optionally restrict to a single site/domain. + """, + "parameters": { + "query": { + "type": "string", + "description": "The search keywords to execute with Keenable. The keywords should be the most important words/terms(includes synonyms) from the original request.", + "default": "{sys.query}", + "required": True, + }, + "site": { + "type": "string", + "description": "default:''. Restrict results to a single domain, e.g. 'techcrunch.com'.", + "default": "", + "required": False, + }, + }, + } + super().__init__() + # A key is optional: blank uses the keyless public endpoint (free tier); + # setting one lifts rate limits and enables the 'realtime' mode. + self.api_key = "" + # "pro" (default, deeper) or "realtime" (low latency; requires a key). + self.mode = "pro" + self.top_n = 10 + + def check(self): + self.check_valid_value(self.mode, "Keenable search mode should be in 'pro/realtime'", ["pro", "realtime"]) + self.check_positive_integer(self.top_n, "Top N") + # 'realtime' is not available on the keyless public endpoint, so reject + # the invalid combination at config time instead of failing at runtime. + if self.mode == "realtime" and not (self.api_key or "").strip(): + raise ValueError("Keenable 'realtime' mode requires an API key") + + def get_input_form(self) -> dict[str, dict]: + return { + "query": { + "name": "Query", + "type": "line", + }, + "site": { + "name": "Site", + "type": "line", + }, + } + + +class KeenableSearch(ToolBase, ABC): + component_name = "KeenableSearch" + + @timeout(int(os.environ.get("COMPONENT_EXEC_TIMEOUT", 12))) + def _invoke(self, **kwargs): + if self.check_if_canceled("KeenableSearch processing"): + return + + if not kwargs.get("query"): + self.set_output("formalized_content", "") + return "" + + payload = {"query": kwargs["query"], "mode": self._param.mode} + if kwargs.get("site"): + payload["site"] = kwargs["site"] + + logging.info(f"KeenableSearch: starting search (mode={self._param.mode}, keyed={bool((self._param.api_key or '').strip())})") + last_e = None + for _ in range(self._param.max_retries + 1): + if self.check_if_canceled("KeenableSearch processing"): + logging.info("KeenableSearch: cancelled before request") + return + + try: + data = _request("POST", "/v1/search/public", "/v1/search", self._param.api_key, json=payload) + if self.check_if_canceled("KeenableSearch processing"): + logging.info("KeenableSearch: cancelled after request") + return + + results = (data.get("results") or [])[: self._param.top_n] + self._retrieve_chunks( + results, + get_title=lambda r: r.get("title"), + get_url=lambda r: r.get("url"), + get_content=lambda r: r.get("description"), + ) + self.set_output("json", results) + logging.info(f"KeenableSearch: returned {len(results)} results") + return self.output("formalized_content") + except ValueError as e: + # Config/local errors (e.g. invalid KEENABLE_API_URL) won't be + # fixed by retrying, so fail fast instead of sleeping. + if self.check_if_canceled("KeenableSearch processing"): + return + last_e = e + logging.exception(f"Keenable config error: {e}") + break + except Exception as e: + if self.check_if_canceled("KeenableSearch processing"): + return + + last_e = e + logging.exception(f"Keenable error: {e}") + time.sleep(self._param.delay_after_error) + + if last_e: + self.set_output("_ERROR", str(last_e)) + return f"Keenable error: {last_e}" + + 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/web/src/assets/svg/keenable.svg b/web/src/assets/svg/keenable.svg new file mode 100644 index 0000000000..4d24ebf0ad --- /dev/null +++ b/web/src/assets/svg/keenable.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/constants/agent.tsx b/web/src/constants/agent.tsx index 62370fa15f..6e08a34370 100644 --- a/web/src/constants/agent.tsx +++ b/web/src/constants/agent.tsx @@ -108,6 +108,7 @@ export enum Operator { UserFillUp = 'UserFillUp', StringTransform = 'StringTransform', SearXNG = 'SearXNG', + KeenableSearch = 'KeenableSearch', DocGenerator = 'DocGenerator', Browser = 'Browser', Placeholder = 'Placeholder', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index ace0a5f5a4..01517b9ed8 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -2133,6 +2133,12 @@ Best for: Documents with flowing, contextually connected content — such as boo searXNG: 'SearXNG', searXNGDescription: 'A component that searches via your provided SearXNG instance URL. Specify TopN and the instance URL.', + keenableSearch: 'Keenable', + keenableSearchDescription: + 'A web search component powered by Keenable, a search API built for AI agents. Works without an API key by default (keyless free tier); add a key to lift rate limits.', + keenableMode: 'Search mode', + keenableSite: 'Site', + keenableApiKeyTip: 'Optional. Leave blank to use the keyless free tier.', docGenerator: 'Doc Generator', docGeneratorDescription: `Generate a file from Markdown content.`, 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 0efdee71aa..b0f93781d7 100644 --- a/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx +++ b/web/src/pages/agent/canvas/node/dropdown/accordion-operators.tsx @@ -122,6 +122,7 @@ export function AccordionOperators({ Operator.Invoke, Operator.WenCai, Operator.SearXNG, + Operator.KeenableSearch, Operator.DocGenerator, Operator.Browser, ]} diff --git a/web/src/pages/agent/constant/index.tsx b/web/src/pages/agent/constant/index.tsx index 8f1146efce..06eba860c3 100644 --- a/web/src/pages/agent/constant/index.tsx +++ b/web/src/pages/agent/constant/index.tsx @@ -192,6 +192,29 @@ export const initialSearXNGValues = { }, }; +export enum KeenableMode { + Pro = 'pro', + Realtime = 'realtime', +} + +export const initialKeenableValues = { + api_key: '', + query: AgentGlobals.SysQuery, + mode: KeenableMode.Pro, + site: '', + top_n: 10, + outputs: { + formalized_content: { + value: '', + type: 'string', + }, + json: { + value: [], + type: 'Array', + }, + }, +}; + export const initialWikipediaValues = { top_n: 10, language: 'en', @@ -665,6 +688,7 @@ export const RestrictedUpstreamMap = { [Operator.GoogleScholar]: [Operator.Begin, Operator.Retrieval], [Operator.GitHub]: [Operator.Begin, Operator.Retrieval], [Operator.SearXNG]: [Operator.Begin, Operator.Retrieval], + [Operator.KeenableSearch]: [Operator.Begin, Operator.Retrieval], [Operator.ExeSQL]: [Operator.Begin], [Operator.Switch]: [Operator.Begin], [Operator.WenCai]: [Operator.Begin], @@ -716,6 +740,7 @@ export const NodeMap = { [Operator.GoogleScholar]: 'ragNode', [Operator.GitHub]: 'ragNode', [Operator.SearXNG]: 'ragNode', + [Operator.KeenableSearch]: '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 fbfd4948a5..ebc16dd937 100644 --- a/web/src/pages/agent/form-sheet/form-config-map.tsx +++ b/web/src/pages/agent/form-sheet/form-config-map.tsx @@ -19,6 +19,7 @@ import GoogleScholarForm from '../form/google-scholar-form'; 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 ListOperationsForm from '../form/list-operations-form'; import LoopForm from '../form/loop-form'; import MessageForm from '../form/message-form'; @@ -70,6 +71,9 @@ export const FormConfigMap = { [Operator.DuckDuckGo]: { component: DuckDuckGoForm, }, + [Operator.KeenableSearch]: { + component: KeenableForm, + }, [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 d3cd5009a5..0acc877e8f 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 @@ -28,6 +28,7 @@ const Menus = [ Operator.DuckDuckGo, Operator.Wikipedia, Operator.SearXNG, + Operator.KeenableSearch, Operator.YahooFinance, Operator.PubMed, Operator.GoogleScholar, diff --git a/web/src/pages/agent/form/keenable-form/index.tsx b/web/src/pages/agent/form/keenable-form/index.tsx new file mode 100644 index 0000000000..4ba3fcdd61 --- /dev/null +++ b/web/src/pages/agent/form/keenable-form/index.tsx @@ -0,0 +1,114 @@ +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 { Input } from '@/components/ui/input'; +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 { KeenableMode, initialKeenableValues } 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'; + +export const KeenableFormPartialSchema = { + api_key: z.string().optional(), + mode: z.string(), + site: z.string().optional(), + top_n: z.coerce.number(), +}; + +const FormSchema = z.object({ + query: z.string(), + ...KeenableFormPartialSchema, +}); + +export function KeenableWidgets() { + const { t } = useTranslate('flow'); + const form = useFormContext(); + + const modeOptions = useMemo( + () => + Object.values(KeenableMode).map((x) => ({ + value: x, + label: x.charAt(0).toUpperCase() + x.slice(1), + })), + [], + ); + + return ( + <> + + ( + + {t('keenableMode')} + + + + + + )} + /> + ( + + {t('keenableSite')} + + + + + + )} + /> + + + ); +} + +const outputList = buildOutputList(initialKeenableValues.outputs); + +function KeenableForm({ node }: INextOperatorForm) { + const defaultValues = useFormValues(initialKeenableValues, node); + + const form = useForm>({ + defaultValues, + resolver: zodResolver(FormSchema), + }); + + useWatchFormChange(node?.id, form); + + return ( +
+ + + + + + +
+ +
+
+ ); +} + +export default memo(KeenableForm); diff --git a/web/src/pages/agent/form/tool-form/constant.tsx b/web/src/pages/agent/form/tool-form/constant.tsx index 4f93ddb50d..6391a4550d 100644 --- a/web/src/pages/agent/form/tool-form/constant.tsx +++ b/web/src/pages/agent/form/tool-form/constant.tsx @@ -8,6 +8,7 @@ import ExeSQLForm from './exesql-form'; import GithubForm from './github-form'; import GoogleForm from './google-form'; import GoogleScholarForm from './google-scholar-form'; +import KeenableForm from './keenable-form'; import PubMedForm from './pubmed-form'; import RetrievalForm from './retrieval-form'; import SearXNGForm from './searxng-form'; @@ -35,4 +36,5 @@ export const ToolFormConfigMap = { [Operator.TavilyExtract]: TavilyForm, [Operator.WenCai]: WenCaiForm, [Operator.SearXNG]: SearXNGForm, + [Operator.KeenableSearch]: KeenableForm, }; diff --git a/web/src/pages/agent/form/tool-form/keenable-form/index.tsx b/web/src/pages/agent/form/tool-form/keenable-form/index.tsx new file mode 100644 index 0000000000..ac54c626a8 --- /dev/null +++ b/web/src/pages/agent/form/tool-form/keenable-form/index.tsx @@ -0,0 +1,38 @@ +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 { + KeenableFormPartialSchema, + KeenableWidgets, +} from '../../keenable-form'; +import { useValues } from '../use-values'; +import { useWatchFormChange } from '../use-watch-change'; + +function KeenableForm() { + const values = useValues(); + + const FormSchema = z.object(KeenableFormPartialSchema); + + const form = useForm>({ + defaultValues: values, + resolver: zodResolver(FormSchema), + }); + + useWatchFormChange(form); + + return ( +
+ + + + + +
+ ); +} + +export default memo(KeenableForm); diff --git a/web/src/pages/agent/hooks/use-add-node.ts b/web/src/pages/agent/hooks/use-add-node.ts index 65f9310826..e89ea07e57 100644 --- a/web/src/pages/agent/hooks/use-add-node.ts +++ b/web/src/pages/agent/hooks/use-add-node.ts @@ -29,6 +29,7 @@ import { initialInvokeValues, initialIterationStartValues, initialIterationValues, + initialKeenableValues, initialListOperationsValues, initialLoopValues, initialMessageValues, @@ -163,6 +164,7 @@ export const useInitializeOperatorParams = () => { [Operator.Agent]: { ...initialAgentValues, llm_id: llmId }, [Operator.Tool]: {}, [Operator.TavilySearch]: initialTavilyValues, + [Operator.KeenableSearch]: initialKeenableValues, [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 b3bbe76db6..f0a7230ecf 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 @@ -59,6 +59,8 @@ export function useAgentToolInitialValues() { return {}; case Operator.SearXNG: return pick(initialValues, 'searxng_url', 'top_n'); + case Operator.KeenableSearch: + return pick(initialValues, 'api_key', 'mode', 'site', 'top_n'); default: return initialValues; diff --git a/web/src/pages/agent/hooks/use-export-json.ts b/web/src/pages/agent/hooks/use-export-json.ts index cb340fd2ee..e8a2e4038d 100644 --- a/web/src/pages/agent/hooks/use-export-json.ts +++ b/web/src/pages/agent/hooks/use-export-json.ts @@ -13,9 +13,12 @@ const clearSensitiveFields = (obj: T): T => cloneDeepWith(obj, (value) => { if ( isPlainObject(value) && - [Operator.TavilySearch, Operator.TavilyExtract, Operator.Google].includes( - value.component_name, - ) && + [ + Operator.TavilySearch, + Operator.TavilyExtract, + Operator.Google, + Operator.KeenableSearch, + ].includes(value.component_name) && get(value, 'params.api_key') ) { return { ...value, params: { ...value.params, api_key: '' } }; 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 ea7a0786e4..509738ed80 100644 --- a/web/src/pages/agent/log-sheet/tool-timeline-item.tsx +++ b/web/src/pages/agent/log-sheet/tool-timeline-item.tsx @@ -27,6 +27,7 @@ type IToolIcon = | Operator.PubMed | Operator.TavilyExtract | Operator.TavilySearch + | Operator.KeenableSearch | Operator.Wikipedia | Operator.YahooFinance | Operator.WenCai diff --git a/web/src/pages/agent/operator-icon.tsx b/web/src/pages/agent/operator-icon.tsx index 524fb4ffae..57936c8f3d 100644 --- a/web/src/pages/agent/operator-icon.tsx +++ b/web/src/pages/agent/operator-icon.tsx @@ -46,6 +46,7 @@ export const SVGIconMap = { [Operator.GoogleScholar]: 'google-scholar', [Operator.PubMed]: 'pubmed', [Operator.SearXNG]: 'searxng', + [Operator.KeenableSearch]: 'keenable', [Operator.TavilyExtract]: 'tavily', [Operator.TavilySearch]: 'tavily', [Operator.Wikipedia]: 'wikipedia',