feat(agent): add Querit Contents tool (#18156)

### Summary

Add Querit Contents as a built-in page content tool for RAGFlow Agents
and Canvas workflows.

Querit Contents crawls one or more known URLs and returns their page
content and optional metadata. It complements the existing Querit Search
tool: Search discovers relevant pages, while Contents retrieves pages
already selected by an Agent or workflow.

This integration supports two usage modes:

- A standalone `QueritContents` node in Canvas workflows.
- An embedded content tool available to RAGFlow Agents.
This commit is contained in:
EthanZhang
2026-08-12 21:03:21 +08:00
committed by GitHub
parent c7db20b8d3
commit 1d8b9c7452
26 changed files with 1200 additions and 88 deletions

View File

@@ -74,6 +74,7 @@ export const SVGIconMap = {
[Operator.KeenableSearch]: 'keenable',
[Operator.TavilyExtract]: 'tavily',
[Operator.TavilySearch]: 'tavily',
[Operator.QueritContents]: 'querit',
[Operator.QueritSearch]: 'querit',
[Operator.Wikipedia]: 'wikipedia',
[Operator.YahooFinance]: 'yahoo-finance',
@@ -123,7 +124,7 @@ const OperatorIcon = ({ name, className }: IProps) => {
const svgIcon = SVGIconMap[name as keyof typeof SVGIconMap];
const LucideIcon = LucideIconMap[name as keyof typeof LucideIconMap];
if (name === Operator.QueritSearch) {
if (name === Operator.QueritContents || name === Operator.QueritSearch) {
return (
<img
src={queritLogo}

View File

@@ -111,6 +111,7 @@ export enum Operator {
Tool = 'Tool',
TavilySearch = 'TavilySearch',
TavilyExtract = 'TavilyExtract',
QueritContents = 'QueritContents',
QueritSearch = 'QueritSearch',
UserFillUp = 'UserFillUp',
StringTransform = 'StringTransform',

View File

@@ -2583,6 +2583,15 @@ Best for: Documents with flowing, contextually connected content — such as boo
queritLanguageInclude: 'Languages to include',
queritLanguageIncludeTip: 'Return results in the specified languages.',
queritListPlaceholder: 'Enter a value',
queritContentsUrls: 'URLs',
queritContentsUrlsTip:
'Enter between one and ten absolute HTTP or HTTPS URLs, separated by commas.',
queritContentsTimeout: 'Crawl timeout',
queritContentsTimeoutTip:
'Set the per-page crawl timeout from 1 to 60 seconds.',
queritContentsMetadata: 'Include page metadata',
queritContentsMetadataTip:
'Return page title, publication time, site name, and site icon when available.',
Days: 'Days',
comma: 'Comma',
semicolon: 'Semicolon',
@@ -3241,6 +3250,9 @@ This process aggregates variables from multiple branches into a single variable
queritSearch: 'Querit search',
queritSearchDescription:
'Search the web with Querit and return source-backed results for agents.',
queritContents: 'Querit contents',
queritContentsDescription:
'Crawl web pages with Querit and return their contents.',
tavilyExtract: 'Tavily extract',
tavilyExtractDescription: 'Tavily Extract',
log: 'Log',
@@ -3274,6 +3286,7 @@ This process aggregates variables from multiple branches into a single variable
textProcessing: 'Tidying up text',
tavilySearch: 'Searching the web',
queritSearch: 'Searching the web with Querit',
queritContents: 'Reading web pages with Querit',
tavilyExtract: 'Reading the page',
exeSQL: 'Querying database',
google: 'Searching the web',

View File

@@ -2218,6 +2218,15 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
queritLanguageInclude: '包含的语言',
queritLanguageIncludeTip: '返回使用指定语言的结果。',
queritListPlaceholder: '请输入内容',
queritContentsUrls: '网页地址',
queritContentsUrlsTip:
'请输入 1 到 10 个完整的 HTTP 或 HTTPS 地址,并使用英文逗号分隔。',
queritContentsTimeout: '抓取超时',
queritContentsTimeoutTip:
'设置每个页面的抓取超时时间,范围为 1 到 60 秒。',
queritContentsMetadata: '包含页面元数据',
queritContentsMetadataTip:
'在可用时返回页面标题、发布时间、站点名称和站点图标。',
days: '天数',
comma: '逗号',
semicolon: '分号',
@@ -2838,6 +2847,8 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
queritSearch: 'Querit 搜索',
queritSearchDescription:
'使用 Querit 搜索网络,并为智能体返回带来源的搜索结果。',
queritContents: 'Querit 网页内容',
queritContentsDescription: '使用 Querit 抓取网页并返回页面内容。',
tavilyExtract: 'Tavily 提取',
tavilyExtractDescription: 'Tavily 提取',
log: '日志',
@@ -2859,6 +2870,7 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
textProcessing: '整理文字',
tavilySearch: '正在网上搜索',
queritSearch: '正在使用 Querit 搜索网络',
queritContents: '正在使用 Querit 读取网页',
tavilyExtract: '读取网页内容',
exeSQL: '查询数据库',
google: '正在网上搜索',

View File

@@ -109,6 +109,7 @@ export function AccordionOperators({
operators={[
Operator.TavilySearch,
Operator.TavilyExtract,
Operator.QueritContents,
Operator.QueritSearch,
Operator.ExeSQL,
Operator.Google,

View File

@@ -592,6 +592,26 @@ export const initialQueritValues = {
},
};
export enum QueritContentFormat {
Text = 'text',
Markdown = 'markdown',
Html = 'html',
}
export const initialQueritContentsValues = {
api_key: '',
urls: '',
format: QueritContentFormat.Markdown,
crawl_timeout: 10,
extras_meta: false,
outputs: {
json: {
value: {},
type: 'object',
},
},
};
export enum TavilyExtractDepth {
Basic = 'basic',
Advanced = 'advanced',
@@ -742,6 +762,7 @@ export const RestrictedUpstreamMap = {
[Operator.WaitingDialogue]: [Operator.Begin],
[Operator.Agent]: [Operator.Begin],
[Operator.TavilySearch]: [Operator.Begin],
[Operator.QueritContents]: [Operator.Begin],
[Operator.QueritSearch]: [Operator.Begin],
[Operator.TavilyExtract]: [Operator.Begin],
[Operator.StringTransform]: [Operator.Begin],
@@ -798,6 +819,7 @@ export const NodeMap = {
[Operator.Agent]: 'agentNode',
[Operator.Tool]: 'toolNode',
[Operator.TavilySearch]: 'ragNode',
[Operator.QueritContents]: 'ragNode',
[Operator.QueritSearch]: 'ragNode',
[Operator.UserFillUp]: 'ragNode',
[Operator.StringTransform]: 'ragNode',

View File

@@ -26,6 +26,7 @@ import LoopForm from '../form/loop-form';
import MessageForm from '../form/message-form';
import ParserForm from '../form/parser-form';
import PubMedForm from '../form/pubmed-form';
import QueritContentsForm from '../form/querit-contents-form';
import QueritForm from '../form/querit-form';
import BGPTForm from '../form/bgpt-form';
import RetrievalForm from '../form/retrieval-form/next';
@@ -146,6 +147,9 @@ export const FormConfigMap = {
[Operator.TavilySearch]: {
component: TavilyForm,
},
[Operator.QueritContents]: {
component: QueritContentsForm,
},
[Operator.QueritSearch]: {
component: QueritForm,
},

View File

@@ -23,6 +23,7 @@ const Menus = [
list: [
Operator.TavilySearch,
Operator.TavilyExtract,
Operator.QueritContents,
Operator.QueritSearch,
Operator.Google,
// Operator.Bing,

View File

@@ -0,0 +1,138 @@
import { FormContainer } from '@/components/form-container';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { RAGFlowSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useTranslate } from '@/hooks/common-hooks';
import { buildOptions } from '@/utils/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {
QueritContentFormat,
initialQueritContentsValues,
} 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 { PromptEditor } from '../components/prompt-editor';
const outputList = buildOutputList(initialQueritContentsValues.outputs);
const FormSchema = z.object({
api_key: z.string(),
urls: z.string().min(1),
format: z.enum([
QueritContentFormat.Text,
QueritContentFormat.Markdown,
QueritContentFormat.Html,
]),
crawl_timeout: z.coerce.number().int().min(1).max(60),
extras_meta: z.boolean(),
});
function QueritContentsForm({ node }: INextOperatorForm) {
const { t } = useTranslate('flow');
const values = useFormValues(initialQueritContentsValues, node);
const form = useForm<z.infer<typeof FormSchema>>({
defaultValues: values,
resolver: zodResolver(FormSchema),
mode: 'onChange',
});
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<ApiKeyField />
<FormField
control={form.control}
name="urls"
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('queritContentsUrlsTip')}>
{t('queritContentsUrls')}
</FormLabel>
<FormControl>
<PromptEditor
{...field}
multiLine={false}
showToolbar={false}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="format"
render={({ field }) => (
<FormItem>
<FormLabel>{t('format')}</FormLabel>
<FormControl>
<RAGFlowSelect
{...field}
options={buildOptions(QueritContentFormat)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="crawl_timeout"
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('queritContentsTimeoutTip')}>
{t('queritContentsTimeout')}
</FormLabel>
<FormControl>
<Input type="number" min={1} max={60} step={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="extras_meta"
render={({ field }) => (
<FormItem className="flex items-center justify-between">
<FormLabel tooltip={t('queritContentsMetadataTip')}>
{t('queritContentsMetadata')}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</FormContainer>
</FormWrapper>
<div className="p-5">
<Output list={outputList} />
</div>
</Form>
);
}
export default memo(QueritContentsForm);

View File

@@ -14,7 +14,7 @@ import QueritForm from './querit-form';
import BGPTForm from './bgpt-form';
import RetrievalForm from './retrieval-form';
import SearXNGForm from './searxng-form';
import TavilyForm from './tavily-form';
import ApiKeyToolForm from './tavily-form';
import WenCaiForm from './wencai-form';
import WikipediaForm from './wikipedia-form';
import YahooFinanceForm from './yahoo-finance-form';
@@ -35,8 +35,9 @@ export const ToolFormConfigMap = {
[Operator.YahooFinance]: YahooFinanceForm,
[Operator.Crawler]: CrawlerForm,
[Operator.Email]: EmailForm,
[Operator.TavilySearch]: TavilyForm,
[Operator.TavilyExtract]: TavilyForm,
[Operator.TavilySearch]: ApiKeyToolForm,
[Operator.TavilyExtract]: ApiKeyToolForm,
[Operator.QueritContents]: ApiKeyToolForm,
[Operator.QueritSearch]: QueritForm,
[Operator.WenCai]: WenCaiForm,
[Operator.SearXNG]: SearXNGForm,

View File

@@ -38,6 +38,7 @@ import {
initialParserValues,
initialPubMedValues,
initialBGPTValues,
initialQueritContentsValues,
initialQueritValues,
initialRetrievalValues,
initialRewriteQuestionValues,
@@ -168,6 +169,7 @@ export const useInitializeOperatorParams = () => {
[Operator.Agent]: { ...initialAgentValues, llm_id: llmId },
[Operator.Tool]: {},
[Operator.TavilySearch]: initialTavilyValues,
[Operator.QueritContents]: initialQueritContentsValues,
[Operator.QueritSearch]: initialQueritValues,
[Operator.KeenableSearch]: initialKeenableValues,
[Operator.UserFillUp]: initialUserFillUpValues,

View File

@@ -23,6 +23,8 @@ export function useAgentToolInitialValues() {
};
case Operator.QueritSearch:
return getQueritAgentInitialValues(initialValues);
case Operator.QueritContents:
return { api_key: '' };
case Operator.ExeSQL:
return omit(initialValues, 'sql');
case Operator.Bing:

View File

@@ -1,5 +1,6 @@
jest.mock('@/constants/agent', () => ({
Operator: {
QueritContents: 'QueritContents',
QueritSearch: 'QueritSearch',
},
}));
@@ -15,6 +16,13 @@ describe('getToolOperatorName', () => {
},
);
it.each(['QueritContents', 'querit_contents'])(
'maps the Querit Contents timeline name %p to its operator',
(toolName) => {
expect(getToolOperatorName(toolName)).toBe(Operator.QueritContents);
},
);
it.each([undefined, null, ''])(
'returns an empty name for the missing value %p',
(toolName) => {

View File

@@ -9,6 +9,9 @@ export function getToolOperatorName(toolName?: string | null) {
if (normalizedName === Operator.QueritSearch.toLowerCase()) {
return Operator.QueritSearch;
}
if (normalizedName === Operator.QueritContents.toLowerCase()) {
return Operator.QueritContents;
}
return toolName
.split('_')

View File

@@ -29,6 +29,7 @@ type IToolIcon =
| Operator.BGPT
| Operator.TavilyExtract
| Operator.TavilySearch
| Operator.QueritContents
| Operator.QueritSearch
| Operator.KeenableSearch
| Operator.Wikipedia

View File

@@ -5,6 +5,7 @@ jest.mock('@/constants/agent', () => ({
Google: 'Google',
KeenableSearch: 'KeenableSearch',
BGPT: 'Bing',
QueritContents: 'QueritContents',
QueritSearch: 'QueritSearch',
},
}));
@@ -44,28 +45,31 @@ describe('clearSensitiveFields', () => {
expect(dsl.tools[0].params.api_key).toBe('querit-secret');
});
it.each(['querit', 'querit_search', 'queritsearch'])(
'clears a Querit API key for the %s registry alias',
(componentName) => {
const dsl = {
tools: [
{
component_name: componentName,
params: {
api_key: 'querit-secret',
count: 5,
},
it.each([
'querit',
'querit_contents',
'queritcontents',
'querit_search',
'queritsearch',
])('clears a Querit API key for the %s registry alias', (componentName) => {
const dsl = {
tools: [
{
component_name: componentName,
params: {
api_key: 'querit-secret',
count: 5,
},
],
};
},
],
};
const sanitized = clearSensitiveFields(dsl);
const sanitized = clearSensitiveFields(dsl);
expect(sanitized.tools[0].params.api_key).toBe('');
expect(sanitized.tools[0].params.count).toBe(5);
expect(dsl.tools[0].params.api_key).toBe('querit-secret');
},
);
expect(sanitized.tools[0].params.api_key).toBe('');
expect(sanitized.tools[0].params.count).toBe(5);
expect(dsl.tools[0].params.api_key).toBe('querit-secret');
});
it('clears a standalone Querit Canvas key from graph and components', () => {
const dsl = {

View File

@@ -7,6 +7,7 @@ const apiKeyOperators = [
Operator.Google,
Operator.KeenableSearch,
Operator.BGPT,
Operator.QueritContents,
Operator.QueritSearch,
];
@@ -15,7 +16,7 @@ function isQueritOperator(value: unknown) {
return false;
}
return ['querit', 'queritsearch'].includes(
return ['querit', 'queritcontents', 'queritsearch'].includes(
value.replace(/_/g, '').toLowerCase(),
);
}