mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-12 06:35:44 +08:00
Add Keenable web search tool to the agent (#16233)
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.
This commit is contained in:
5
web/src/assets/svg/keenable.svg
Normal file
5
web/src/assets/svg/keenable.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="5" fill="#3B5BFE"/>
|
||||
<circle cx="10.5" cy="10.5" r="4.25" stroke="#fff" stroke-width="1.8"/>
|
||||
<line x1="13.6" y1="13.6" x2="18" y2="18" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -108,6 +108,7 @@ export enum Operator {
|
||||
UserFillUp = 'UserFillUp',
|
||||
StringTransform = 'StringTransform',
|
||||
SearXNG = 'SearXNG',
|
||||
KeenableSearch = 'KeenableSearch',
|
||||
DocGenerator = 'DocGenerator',
|
||||
Browser = 'Browser',
|
||||
Placeholder = 'Placeholder',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -122,6 +122,7 @@ export function AccordionOperators({
|
||||
Operator.Invoke,
|
||||
Operator.WenCai,
|
||||
Operator.SearXNG,
|
||||
Operator.KeenableSearch,
|
||||
Operator.DocGenerator,
|
||||
Operator.Browser,
|
||||
]}
|
||||
|
||||
@@ -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<Object>',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ const Menus = [
|
||||
Operator.DuckDuckGo,
|
||||
Operator.Wikipedia,
|
||||
Operator.SearXNG,
|
||||
Operator.KeenableSearch,
|
||||
Operator.YahooFinance,
|
||||
Operator.PubMed,
|
||||
Operator.GoogleScholar,
|
||||
|
||||
114
web/src/pages/agent/form/keenable-form/index.tsx
Normal file
114
web/src/pages/agent/form/keenable-form/index.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<ApiKeyField placeholder={t('keenableApiKeyTip')}></ApiKeyField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'mode'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('keenableMode')}</FormLabel>
|
||||
<FormControl>
|
||||
<RAGFlowSelect {...field} options={modeOptions} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={'site'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('keenableSite')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="example.com"></Input>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<TopNFormField></TopNFormField>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const outputList = buildOutputList(initialKeenableValues.outputs);
|
||||
|
||||
function KeenableForm({ node }: INextOperatorForm) {
|
||||
const defaultValues = useFormValues(initialKeenableValues, node);
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(FormSchema),
|
||||
});
|
||||
|
||||
useWatchFormChange(node?.id, form);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormWrapper>
|
||||
<FormContainer>
|
||||
<QueryVariable></QueryVariable>
|
||||
<KeenableWidgets></KeenableWidgets>
|
||||
</FormContainer>
|
||||
</FormWrapper>
|
||||
<div className="p-5">
|
||||
<Output list={outputList}></Output>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(KeenableForm);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
38
web/src/pages/agent/form/tool-form/keenable-form/index.tsx
Normal file
38
web/src/pages/agent/form/tool-form/keenable-form/index.tsx
Normal file
@@ -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<z.infer<typeof FormSchema>>({
|
||||
defaultValues: values,
|
||||
resolver: zodResolver(FormSchema),
|
||||
});
|
||||
|
||||
useWatchFormChange(form);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<FormWrapper>
|
||||
<FormContainer>
|
||||
<KeenableWidgets></KeenableWidgets>
|
||||
</FormContainer>
|
||||
</FormWrapper>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(KeenableForm);
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,9 +13,12 @@ const clearSensitiveFields = <T>(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: '' } };
|
||||
|
||||
@@ -27,6 +27,7 @@ type IToolIcon =
|
||||
| Operator.PubMed
|
||||
| Operator.TavilyExtract
|
||||
| Operator.TavilySearch
|
||||
| Operator.KeenableSearch
|
||||
| Operator.Wikipedia
|
||||
| Operator.YahooFinance
|
||||
| Operator.WenCai
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user