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