feat(agent): add Querit search tool (#17548)

This commit is contained in:
EthanZhang
2026-07-30 09:36:16 +08:00
committed by GitHub
parent 03d887a975
commit 33e581a8b3
45 changed files with 3527 additions and 26 deletions

BIN
web/src/assets/querit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

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

View File

@@ -2491,6 +2491,26 @@ Best for: Documents with flowing, contextually connected content — such as boo
includeImageDescriptions: 'Include image descriptions',
includeDomains: 'Include domains',
ExcludeDomains: 'Exclude domains',
queritCount: 'Result count',
queritCountTip: 'Set the maximum number of search results to return.',
queritChunksPerDoc: 'Chunks per document',
queritChunksPerDocTip:
'Set the number of relevant text chunks returned for each result, from 1 to 3.',
queritSiteInclude: 'Sites to include',
queritSiteIncludeTip:
'Return results only from the specified domains or websites.',
queritSiteExclude: 'Sites to exclude',
queritSiteExcludeTip:
'Exclude results from the specified domains or websites.',
queritTimeRange: 'Time range',
queritTimeRangeTip:
'Use a relative range such as d7, w2, m1, or y1, or an absolute range such as 2026-01-01to2026-01-31.',
queritCountryInclude: 'Countries to include',
queritCountryIncludeTip:
'Return results associated with the specified countries.',
queritLanguageInclude: 'Languages to include',
queritLanguageIncludeTip: 'Return results in the specified languages.',
queritListPlaceholder: 'Enter a value',
Days: 'Days',
comma: 'Comma',
semicolon: 'Semicolon',
@@ -3147,6 +3167,9 @@ This process aggregates variables from multiple branches into a single variable
codeExec: 'Code',
tavilySearch: 'Tavily search',
tavilySearchDescription: 'Search results via Tavily service.',
queritSearch: 'Querit search',
queritSearchDescription:
'Search the web with Querit and return source-backed results for agents.',
tavilyExtract: 'Tavily extract',
tavilyExtractDescription: 'Tavily Extract',
log: 'Log',
@@ -3179,6 +3202,7 @@ This process aggregates variables from multiple branches into a single variable
code: 'Running a quick script',
textProcessing: 'Tidying up text',
tavilySearch: 'Searching the web',
queritSearch: 'Searching the web with Querit',
tavilyExtract: 'Reading the page',
exeSQL: 'Querying database',
google: 'Searching the web',

View File

@@ -2141,6 +2141,23 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
includeImageDescriptions: '包含图片描述',
includeDomains: '包含域名',
ExcludeDomains: '排除域名',
queritCount: '结果数量',
queritCountTip: '设置本次搜索最多返回的结果数量。',
queritChunksPerDoc: '每篇文档片段数',
queritChunksPerDocTip:
'设置每条结果返回的相关文本片段数量,可填写 1 至 3。',
queritSiteInclude: '包含的网站',
queritSiteIncludeTip: '仅返回来自指定域名或网站的结果。',
queritSiteExclude: '排除的网站',
queritSiteExcludeTip: '排除来自指定域名或网站的结果。',
queritTimeRange: '时间范围',
queritTimeRangeTip:
'可填写 d7、w2、m1、y1 等相对时间,或 2026-01-01to2026-01-31 形式的绝对日期范围。',
queritCountryInclude: '包含的国家',
queritCountryIncludeTip: '返回与指定国家相关的结果。',
queritLanguageInclude: '包含的语言',
queritLanguageIncludeTip: '返回使用指定语言的结果。',
queritListPlaceholder: '请输入内容',
days: '天数',
comma: '逗号',
semicolon: '分号',
@@ -2758,6 +2775,9 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
codeExec: '代码',
tavilySearch: 'Tavily 搜索',
tavilySearchDescription: '通过 Tavily 服务搜索结果',
queritSearch: 'Querit 搜索',
queritSearchDescription:
'使用 Querit 搜索网络,并为智能体返回带来源的搜索结果。',
tavilyExtract: 'Tavily 提取',
tavilyExtractDescription: 'Tavily 提取',
log: '日志',
@@ -2778,6 +2798,7 @@ NER使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系
code: '运行小段代码',
textProcessing: '整理文字',
tavilySearch: '正在网上搜索',
queritSearch: '正在使用 Querit 搜索网络',
tavilyExtract: '读取网页内容',
exeSQL: '查询数据库',
google: '正在网上搜索',

View File

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

View File

@@ -572,6 +572,28 @@ export const initialTavilyValues = {
},
};
export const initialQueritValues = {
api_key: '',
query: AgentGlobals.SysQuery,
count: 10,
chunks_per_doc: 3,
site_include: [],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [],
outputs: {
formalized_content: {
value: '',
type: 'string',
},
json: {
value: {},
type: 'object',
},
},
};
export enum TavilyExtractDepth {
Basic = 'basic',
Advanced = 'advanced',
@@ -722,6 +744,7 @@ export const RestrictedUpstreamMap = {
[Operator.WaitingDialogue]: [Operator.Begin],
[Operator.Agent]: [Operator.Begin],
[Operator.TavilySearch]: [Operator.Begin],
[Operator.QueritSearch]: [Operator.Begin],
[Operator.TavilyExtract]: [Operator.Begin],
[Operator.StringTransform]: [Operator.Begin],
[Operator.UserFillUp]: [Operator.Begin],
@@ -777,6 +800,7 @@ export const NodeMap = {
[Operator.Agent]: 'agentNode',
[Operator.Tool]: 'toolNode',
[Operator.TavilySearch]: 'ragNode',
[Operator.QueritSearch]: 'ragNode',
[Operator.UserFillUp]: 'ragNode',
[Operator.StringTransform]: 'ragNode',
[Operator.TavilyExtract]: '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 QueritForm from '../form/querit-form';
import BGPTForm from '../form/bgpt-form';
import RetrievalForm from '../form/retrieval-form/next';
import RewriteQuestionForm from '../form/rewrite-question-form';
@@ -145,6 +146,9 @@ export const FormConfigMap = {
[Operator.TavilySearch]: {
component: TavilyForm,
},
[Operator.QueritSearch]: {
component: QueritForm,
},
[Operator.UserFillUp]: {
component: UserFillUpForm,
},

View File

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

View File

@@ -0,0 +1,73 @@
import { BlockButton, Button } from '@/components/ui/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { X } from 'lucide-react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
type DynamicListProps = {
name:
| 'site_include'
| 'site_exclude'
| 'country_include'
| 'language_include';
label: string;
tooltip: string;
placeholder: string;
};
export function DynamicList({
name,
label,
tooltip,
placeholder,
}: DynamicListProps) {
const { t } = useTranslation();
const form = useFormContext();
const { fields, append, remove } = useFieldArray({
name,
control: form.control,
});
return (
<FormItem>
<FormLabel tooltip={tooltip}>{label}</FormLabel>
<div className="space-y-4">
{fields.map((item, index) => (
<div key={item.id} className="flex items-start gap-2">
<FormField
control={form.control}
name={`${name}.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input {...field} placeholder={placeholder} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`${t('common.remove')} ${label} ${index + 1}`}
onClick={() => remove(index)}
>
<X />
</Button>
</div>
))}
</div>
<BlockButton type="button" onClick={() => append({ value: '' })}>
{t('common.add')}
</BlockButton>
</FormItem>
);
}

View File

@@ -0,0 +1,136 @@
import { FormContainer } from '@/components/form-container';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useTranslate } from '@/hooks/common-hooks';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo } from 'react';
import { useForm, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { initialQueritValues } from '../../constant';
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';
import { DynamicList } from './dynamic-list';
import { QueritFormSchema } from './utils';
import { useValues } from './use-values';
import { useWatchFormChange } from './use-watch-change';
const outputList = buildOutputList(initialQueritValues.outputs);
export function QueritFormWidgets({ includeQuery = true }) {
const form = useFormContext();
const { t } = useTranslate('flow');
const listPlaceholder = t('queritListPlaceholder');
return (
<>
<ApiKeyField />
{includeQuery && <QueryVariable />}
<FormField
control={form.control}
name="count"
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('queritCountTip')}>
{t('queritCount')}
</FormLabel>
<FormControl>
<Input type="number" min={1} step={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="chunks_per_doc"
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('queritChunksPerDocTip')}>
{t('queritChunksPerDoc')}
</FormLabel>
<FormControl>
<Input type="number" min={1} max={3} step={1} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DynamicList
name="site_include"
label={t('queritSiteInclude')}
tooltip={t('queritSiteIncludeTip')}
placeholder={listPlaceholder}
/>
<DynamicList
name="site_exclude"
label={t('queritSiteExclude')}
tooltip={t('queritSiteExcludeTip')}
placeholder={listPlaceholder}
/>
<FormField
control={form.control}
name="time_range"
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('queritTimeRangeTip')}>
{t('queritTimeRange')}
</FormLabel>
<FormControl>
<Input placeholder="d7" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DynamicList
name="country_include"
label={t('queritCountryInclude')}
tooltip={t('queritCountryIncludeTip')}
placeholder={listPlaceholder}
/>
<DynamicList
name="language_include"
label={t('queritLanguageInclude')}
tooltip={t('queritLanguageIncludeTip')}
placeholder={listPlaceholder}
/>
</>
);
}
function QueritForm({ node }: INextOperatorForm) {
const values = useValues(node);
const form = useForm<z.infer<typeof QueritFormSchema>>({
defaultValues: values,
resolver: zodResolver(QueritFormSchema),
mode: 'onChange',
});
useWatchFormChange(node?.id, form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<QueritFormWidgets />
</FormContainer>
</FormWrapper>
<div className="p-5">
<Output list={outputList} />
</div>
</Form>
);
}
export default memo(QueritForm);

View File

@@ -0,0 +1,14 @@
import { RAGFlowNodeType } from '@/interfaces/database/agent';
import { isEmpty } from 'lodash';
import { useMemo } from 'react';
import { initialQueritValues } from '../../constant';
import { deserializeQueritFormValues } from './utils';
export function useValues(node?: RAGFlowNodeType) {
return useMemo(() => {
const formData = node?.data?.form;
const values = isEmpty(formData) ? initialQueritValues : formData;
return deserializeQueritFormValues(values);
}, [node?.data?.form]);
}

View File

@@ -0,0 +1,114 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { StrictMode } from 'react';
import { useForm } from 'react-hook-form';
import { useWatchFormChange } from './use-watch-change';
const mockUpdateNodeForm = jest.fn();
jest.mock('../../store', () => ({
__esModule: true,
default: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ updateNodeForm: mockUpdateNodeForm }),
}));
const validValues = {
api_key: '',
query: 'begin.query',
count: 10,
chunks_per_doc: 3,
site_include: [],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [],
};
function useQueritFormWatcher(defaultValues: Record<string, unknown>) {
const form = useForm({ defaultValues });
useWatchFormChange('querit:0', form);
return form;
}
describe('useWatchFormChange', () => {
beforeEach(() => {
mockUpdateNodeForm.mockClear();
});
it('skips the initial write and normalizes subsequent valid edits', async () => {
const { result } = renderHook(() =>
useQueritFormWatcher({
...validValues,
count: '5',
chunks_per_doc: '2',
site_include: [{ value: ' docs.example.com ' }],
}),
);
expect(mockUpdateNodeForm).not.toHaveBeenCalled();
act(() => {
result.current.setValue('count', '6');
});
await waitFor(() => {
expect(mockUpdateNodeForm).toHaveBeenCalledWith(
'querit:0',
expect.objectContaining({
count: 6,
chunks_per_doc: 2,
site_include: ['docs.example.com'],
}),
);
});
});
it('does not write an invalid initial form to the Canvas node', async () => {
renderHook(() =>
useQueritFormWatcher({
...validValues,
count: 0,
}),
);
await act(async () => {
await Promise.resolve();
});
expect(mockUpdateNodeForm).not.toHaveBeenCalled();
});
it('does not write a valid initial form in Strict Mode', async () => {
renderHook(() => useQueritFormWatcher(validValues), {
wrapper: StrictMode,
});
await act(async () => {
await Promise.resolve();
});
expect(mockUpdateNodeForm).not.toHaveBeenCalled();
});
it('does not overwrite the last valid state after an invalid edit', async () => {
const { result } = renderHook(() => useQueritFormWatcher(validValues));
act(() => {
result.current.setValue('count', 5);
});
await waitFor(() => {
expect(mockUpdateNodeForm).toHaveBeenCalled();
});
mockUpdateNodeForm.mockClear();
act(() => {
result.current.setValue('time_range', 'last week');
});
await act(async () => {
await Promise.resolve();
});
expect(mockUpdateNodeForm).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import useGraphStore from '../../store';
import { validateQueritFormValuesForPersistence } from './utils';
export function useWatchFormChange(id?: string, form?: UseFormReturn<any>) {
const values = useWatch({ control: form?.control });
const updateNodeForm = useGraphStore((state) => state.updateNodeForm);
const previousValues = useRef(values);
useEffect(() => {
if (Object.is(previousValues.current, values)) {
return;
}
previousValues.current = values;
if (id && form) {
const nextValues = validateQueritFormValuesForPersistence(
form.getValues(),
);
if (nextValues) {
updateNodeForm(id, nextValues);
}
}
}, [form, id, updateNodeForm, values]);
}

View File

@@ -0,0 +1,224 @@
import {
QueritFormSchema,
deserializeQueritFormValues,
serializeQueritFormValues,
validateQueritFormValuesForPersistence,
} from './utils';
describe('Querit form contract', () => {
const validValues = {
api_key: '',
query: 'begin.query',
count: 10,
chunks_per_doc: 3,
site_include: [],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [],
};
it.each(['d7', 'w2', 'm1', 'y1', '2026-01-01to2026-01-31', ''])(
'accepts the supported time range %p',
(timeRange) => {
expect(
QueritFormSchema.safeParse({
...validValues,
time_range: timeRange,
}).success,
).toBe(true);
},
);
it.each(['d0', '7d', '2026-01-01-2026-01-31', 'last week'])(
'rejects the unsupported time range %p',
(timeRange) => {
expect(
QueritFormSchema.safeParse({
...validValues,
time_range: timeRange,
}).success,
).toBe(false);
},
);
it.each([0, -1, 1.5])('rejects the invalid result count %p', (count) => {
expect(
QueritFormSchema.safeParse({
...validValues,
count,
}).success,
).toBe(false);
});
it.each([0, 1.5, 4])(
'rejects the invalid chunks per document value %p',
(chunksPerDoc) => {
expect(
QueritFormSchema.safeParse({
...validValues,
chunks_per_doc: chunksPerDoc,
}).success,
).toBe(false);
},
);
it.each(['', ' '])('rejects an empty dynamic list item %p', (listItem) => {
expect(
QueritFormSchema.safeParse({
...validValues,
site_include: [{ value: listItem }],
}).success,
).toBe(false);
});
it.each(['', ' '])('rejects an empty query %p', (query) => {
expect(
QueritFormSchema.safeParse({
...validValues,
query,
}).success,
).toBe(false);
});
});
describe('Querit form persistence', () => {
it('rehydrates persisted string arrays for dynamic form fields', () => {
expect(
deserializeQueritFormValues({
api_key: '',
query: 'begin.query',
count: 5,
chunks_per_doc: 2,
site_include: ['docs.example.com'],
site_exclude: ['spam.example.com'],
time_range: 'w1',
country_include: ['united states'],
language_include: ['en'],
}),
).toMatchObject({
site_include: [{ value: 'docs.example.com' }],
site_exclude: [{ value: 'spam.example.com' }],
country_include: [{ value: 'united states' }],
language_include: [{ value: 'en' }],
});
});
it('serializes dynamic form fields as string arrays', () => {
expect(
serializeQueritFormValues({
api_key: '',
query: 'begin.query',
count: 5,
chunks_per_doc: 2,
site_include: [{ value: 'docs.example.com' }],
site_exclude: [{ value: 'spam.example.com' }],
time_range: 'w1',
country_include: [{ value: 'united states' }],
language_include: [{ value: 'en' }],
}),
).toEqual({
api_key: '',
query: 'begin.query',
count: 5,
chunks_per_doc: 2,
site_include: ['docs.example.com'],
site_exclude: ['spam.example.com'],
time_range: 'w1',
country_include: ['united states'],
language_include: ['en'],
});
});
it('serializes valid numeric input values as numbers', () => {
expect(
serializeQueritFormValues({
api_key: '',
query: 'begin.query',
count: '5',
chunks_per_doc: '2',
site_include: [],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [],
}),
).toMatchObject({
count: 5,
chunks_per_doc: 2,
});
});
it('does not produce persisted values when the current form is invalid', () => {
expect(
validateQueritFormValuesForPersistence({
api_key: '',
query: 'begin.query',
count: 0,
chunks_per_doc: 3,
site_include: [],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [],
}),
).toBeUndefined();
});
it('produces normalized persisted values when the current form is valid', () => {
expect(
validateQueritFormValuesForPersistence({
api_key: '',
query: 'begin.query',
count: '5',
chunks_per_doc: '2',
site_include: [{ value: 'docs.example.com' }],
site_exclude: [],
time_range: 'w1',
country_include: [],
language_include: [{ value: 'en' }],
}),
).toMatchObject({
count: 5,
chunks_per_doc: 2,
site_include: ['docs.example.com'],
language_include: ['en'],
});
});
it('trims dynamic list values before persistence', () => {
expect(
validateQueritFormValuesForPersistence({
api_key: '',
query: 'begin.query',
count: 5,
chunks_per_doc: 2,
site_include: [{ value: ' docs.example.com ' }],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [{ value: ' en ' }],
}),
).toMatchObject({
site_include: ['docs.example.com'],
language_include: ['en'],
});
});
it('normalizes missing persisted list fields to empty arrays', () => {
expect(
deserializeQueritFormValues({
api_key: '',
query: 'begin.query',
count: 10,
chunks_per_doc: 3,
time_range: '',
}),
).toMatchObject({
site_include: [],
site_exclude: [],
country_include: [],
language_include: [],
});
});
});

View File

@@ -0,0 +1,72 @@
import { z } from 'zod';
export const QueritTimeRangePattern =
/^([dwmy][1-9][0-9]*|\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2})$/;
const dynamicStringListSchema = z.array(
z.object({
value: z.string().trim().min(1),
}),
);
export const QueritFormSchema = z.object({
api_key: z.string(),
query: z.string().trim().min(1),
count: z.coerce.number().int().min(1),
chunks_per_doc: z.coerce.number().int().min(1).max(3),
site_include: dynamicStringListSchema,
site_exclude: dynamicStringListSchema,
time_range: z
.string()
.refine((value) => value === '' || QueritTimeRangePattern.test(value)),
country_include: dynamicStringListSchema,
language_include: dynamicStringListSchema,
});
type QueritFormRecord = Record<string, any>;
function toObjectArray(values: unknown) {
return Array.isArray(values) ? values.map((value) => ({ value })) : [];
}
function toStringArray(values: unknown) {
return Array.isArray(values)
? values.map((item) => item?.value as string)
: [];
}
export function deserializeQueritFormValues(values: QueritFormRecord) {
return {
...values,
site_include: toObjectArray(values.site_include),
site_exclude: toObjectArray(values.site_exclude),
country_include: toObjectArray(values.country_include),
language_include: toObjectArray(values.language_include),
};
}
export function serializeQueritFormValues(values: QueritFormRecord) {
return {
...values,
count: Number(values.count),
chunks_per_doc: Number(values.chunks_per_doc),
site_include: toStringArray(values.site_include),
site_exclude: toStringArray(values.site_exclude),
country_include: toStringArray(values.country_include),
language_include: toStringArray(values.language_include),
};
}
export function validateQueritFormValuesForPersistence(
values: QueritFormRecord,
) {
const result = QueritFormSchema.safeParse(values);
if (!result.success) {
return undefined;
}
return serializeQueritFormValues({
...values,
...result.data,
});
}

View File

@@ -10,6 +10,7 @@ import GoogleForm from './google-form';
import GoogleScholarForm from './google-scholar-form';
import KeenableForm from './keenable-form';
import PubMedForm from './pubmed-form';
import QueritForm from './querit-form';
import BGPTForm from './bgpt-form';
import RetrievalForm from './retrieval-form';
import SearXNGForm from './searxng-form';
@@ -36,6 +37,7 @@ export const ToolFormConfigMap = {
[Operator.Email]: EmailForm,
[Operator.TavilySearch]: TavilyForm,
[Operator.TavilyExtract]: TavilyForm,
[Operator.QueritSearch]: QueritForm,
[Operator.WenCai]: WenCaiForm,
[Operator.SearXNG]: SearXNGForm,
[Operator.KeenableSearch]: KeenableForm,

View File

@@ -0,0 +1,41 @@
import { FormContainer } from '@/components/form-container';
import { Form } from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { memo, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { FormWrapper } from '../../components/form-wrapper';
import { QueritFormWidgets } from '../../querit-form';
import { useValues } from '../use-values';
import { useQueritAgentFormChange } from './use-watch-change';
import {
QueritAgentFormSchema,
deserializeQueritAgentFormValues,
} from './utils';
function QueritForm() {
const persistedValues = useValues();
const values = useMemo(
() => deserializeQueritAgentFormValues(persistedValues),
[persistedValues],
);
const form = useForm<z.infer<typeof QueritAgentFormSchema>>({
defaultValues: values,
resolver: zodResolver(QueritAgentFormSchema),
mode: 'onChange',
});
useQueritAgentFormChange(form);
return (
<Form {...form}>
<FormWrapper>
<FormContainer>
<QueritFormWidgets includeQuery={false} />
</FormContainer>
</FormWrapper>
</Form>
);
}
export default memo(QueritForm);

View File

@@ -0,0 +1,96 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { useForm } from 'react-hook-form';
import { useQueritAgentFormChange } from './use-watch-change';
const mockUpdateAgentToolById = jest.fn();
const agentNode = { id: 'agent:0' };
jest.mock('../../../store', () => ({
__esModule: true,
default: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
clickedToolId: 'querit:0',
clickedNodeId: 'agent:0',
findUpstreamNodeById: () => agentNode,
updateAgentToolById: mockUpdateAgentToolById,
}),
}));
const validValues = {
api_key: '',
count: 10,
chunks_per_doc: 3,
site_include: [],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [],
};
function useQueritAgentFormWatcher() {
const form = useForm<Record<string, any>>({ defaultValues: validValues });
useQueritAgentFormChange(form);
return form;
}
describe('useQueritAgentFormChange', () => {
beforeEach(() => {
mockUpdateAgentToolById.mockClear();
});
it('does not write untouched initial values to the Agent node', async () => {
renderHook(() => useQueritAgentFormWatcher());
await act(async () => {
await Promise.resolve();
});
expect(mockUpdateAgentToolById).not.toHaveBeenCalled();
});
it('writes normalized valid edits without a query', async () => {
const { result } = renderHook(() => useQueritAgentFormWatcher());
act(() => {
result.current.setValue(
'site_include',
[{ value: ' docs.example.com ' }],
{ shouldDirty: true },
);
result.current.setValue('count', '5', { shouldDirty: true });
});
await waitFor(() => {
expect(mockUpdateAgentToolById).toHaveBeenLastCalledWith(
agentNode,
'querit:0',
{
params: expect.objectContaining({
count: 5,
site_include: ['docs.example.com'],
}),
},
);
});
const persistedParams =
mockUpdateAgentToolById.mock.calls.at(-1)?.[2]?.params;
expect(persistedParams).not.toHaveProperty('query');
});
it('does not overwrite Agent tool params after an invalid edit', async () => {
const { result } = renderHook(() => useQueritAgentFormWatcher());
act(() => {
result.current.setValue('time_range', 'last week', {
shouldDirty: true,
});
});
await act(async () => {
await Promise.resolve();
});
expect(mockUpdateAgentToolById).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { UseFormReturn, useWatch } from 'react-hook-form';
import useGraphStore from '../../../store';
import { validateQueritAgentFormValuesForPersistence } from './utils';
export function useQueritAgentFormChange(form?: UseFormReturn<any>) {
const values = useWatch({ control: form?.control });
const clickedToolId = useGraphStore((state) => state.clickedToolId);
const clickedNodeId = useGraphStore((state) => state.clickedNodeId);
const findUpstreamNodeById = useGraphStore(
(state) => state.findUpstreamNodeById,
);
const updateAgentToolById = useGraphStore(
(state) => state.updateAgentToolById,
);
useEffect(() => {
const agentNode = findUpstreamNodeById(clickedNodeId);
if (!agentNode || !form?.formState.isDirty) {
return;
}
const nextValues = validateQueritAgentFormValuesForPersistence(
form.getValues(),
);
if (nextValues) {
updateAgentToolById(agentNode, clickedToolId, {
params: nextValues,
});
}
}, [
clickedNodeId,
clickedToolId,
findUpstreamNodeById,
form,
form?.formState.isDirty,
updateAgentToolById,
values,
]);
}

View File

@@ -0,0 +1,66 @@
import {
deserializeQueritAgentFormValues,
validateQueritAgentFormValuesForPersistence,
} from './utils';
describe('Querit Agent form persistence', () => {
it('rehydrates persisted string arrays for the embedded form', () => {
expect(
deserializeQueritAgentFormValues({
api_key: '',
count: 5,
chunks_per_doc: 2,
site_include: ['docs.example.com'],
site_exclude: ['spam.example.com'],
time_range: 'w1',
country_include: ['united states'],
language_include: ['en'],
}),
).toMatchObject({
site_include: [{ value: 'docs.example.com' }],
site_exclude: [{ value: 'spam.example.com' }],
country_include: [{ value: 'united states' }],
language_include: [{ value: 'en' }],
});
});
it('normalizes valid values without persisting a runtime query', () => {
expect(
validateQueritAgentFormValuesForPersistence({
api_key: 'secret',
query: 'must-not-be-persisted',
count: '5',
chunks_per_doc: '2',
site_include: [{ value: ' docs.example.com ' }],
site_exclude: [],
time_range: 'w1',
country_include: [{ value: ' united states ' }],
language_include: [{ value: ' en ' }],
}),
).toEqual({
api_key: 'secret',
count: 5,
chunks_per_doc: 2,
site_include: ['docs.example.com'],
site_exclude: [],
time_range: 'w1',
country_include: ['united states'],
language_include: ['en'],
});
});
it('does not produce persisted values when the embedded form is invalid', () => {
expect(
validateQueritAgentFormValuesForPersistence({
api_key: '',
count: 0,
chunks_per_doc: 3,
site_include: [],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [],
}),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,22 @@
import {
QueritFormSchema,
deserializeQueritFormValues,
serializeQueritFormValues,
} from '../../querit-form/utils';
export const QueritAgentFormSchema = QueritFormSchema.omit({ query: true });
export function deserializeQueritAgentFormValues(values: Record<string, any>) {
return deserializeQueritFormValues(values);
}
export function validateQueritAgentFormValuesForPersistence(
values: Record<string, any>,
) {
const result = QueritAgentFormSchema.safeParse(values);
if (!result.success) {
return undefined;
}
return serializeQueritFormValues(result.data);
}

View File

@@ -0,0 +1,53 @@
import { Operator } from '@/constants/agent';
import { cloneDeepWith, get, isPlainObject } from 'lodash';
const apiKeyOperators = [
Operator.TavilySearch,
Operator.TavilyExtract,
Operator.Google,
Operator.KeenableSearch,
Operator.BGPT,
Operator.QueritSearch,
];
function isQueritOperator(value: unknown) {
if (typeof value !== 'string') {
return false;
}
return ['querit', 'queritsearch'].includes(
value.replace(/_/g, '').toLowerCase(),
);
}
export function clearSensitiveFields<T>(obj: T): T {
return cloneDeepWith(obj, (value) => {
if (!isPlainObject(value)) {
return;
}
if (
(apiKeyOperators.includes(value.component_name) ||
isQueritOperator(value.component_name)) &&
get(value, 'params.api_key')
) {
return { ...value, params: { ...value.params, api_key: '' } };
}
if (
isQueritOperator(get(value, 'data.label')) &&
get(value, 'data.form.api_key')
) {
return {
...value,
data: {
...value.data,
form: {
...value.data.form,
api_key: '',
},
},
};
}
});
}

View File

@@ -0,0 +1,7 @@
import { omit } from 'lodash';
export function getQueritAgentInitialValues(
initialValues: Record<string, any>,
) {
return omit(initialValues, 'query', 'outputs');
}

View File

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

View File

@@ -0,0 +1,32 @@
import { getQueritAgentInitialValues } from './querit-agent-initial-values';
describe('getQueritAgentInitialValues', () => {
it('creates Querit Agent params without runtime query or outputs', () => {
expect(
getQueritAgentInitialValues({
api_key: '',
query: 'sys.query',
count: 10,
chunks_per_doc: 3,
site_include: [],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [],
outputs: {
formalized_content: { value: '', type: 'string' },
json: { value: {}, type: 'object' },
},
}),
).toEqual({
api_key: '',
count: 10,
chunks_per_doc: 3,
site_include: [],
site_exclude: [],
time_range: '',
country_include: [],
language_include: [],
});
});
});

View File

@@ -1,6 +1,7 @@
import { omit, pick } from 'lodash';
import { useCallback } from 'react';
import { Operator } from '../constant';
import { getQueritAgentInitialValues } from './querit-agent-initial-values';
import { useInitializeOperatorParams } from './use-add-node';
export function useAgentToolInitialValues() {
@@ -20,6 +21,8 @@ export function useAgentToolInitialValues() {
return {
api_key: '',
};
case Operator.QueritSearch:
return getQueritAgentInitialValues(initialValues);
case Operator.ExeSQL:
return omit(initialValues, 'sql');
case Operator.Bing:

View File

@@ -0,0 +1,126 @@
jest.mock('@/constants/agent', () => ({
Operator: {
TavilySearch: 'TavilySearch',
TavilyExtract: 'TavilyExtract',
Google: 'Google',
KeenableSearch: 'KeenableSearch',
BGPT: 'Bing',
QueritSearch: 'QueritSearch',
},
}));
import { Operator } from '@/constants/agent';
import { clearSensitiveFields } from './clear-sensitive-fields';
describe('clearSensitiveFields', () => {
it('clears a Querit API key without removing non-sensitive params', () => {
const dsl = {
tools: [
{
component_name: Operator.QueritSearch,
params: {
api_key: 'querit-secret',
count: 5,
chunks_per_doc: 2,
site_include: ['docs.example.com'],
},
},
],
};
expect(clearSensitiveFields(dsl)).toEqual({
tools: [
{
component_name: Operator.QueritSearch,
params: {
api_key: '',
count: 5,
chunks_per_doc: 2,
site_include: ['docs.example.com'],
},
},
],
});
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,
},
},
],
};
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');
},
);
it('clears a standalone Querit Canvas key from graph and components', () => {
const dsl = {
graph: {
nodes: [
{
data: {
label: Operator.QueritSearch,
form: {
api_key: 'graph-secret',
count: 5,
},
},
},
],
},
components: {
querit: {
obj: {
component_name: Operator.QueritSearch,
params: {
api_key: 'component-secret',
count: 5,
},
},
},
},
};
const sanitized = clearSensitiveFields(dsl);
expect(sanitized.graph.nodes[0].data.form.api_key).toBe('');
expect(sanitized.graph.nodes[0].data.form.count).toBe(5);
expect(sanitized.components.querit.obj.params.api_key).toBe('');
expect(sanitized.components.querit.obj.params.count).toBe(5);
expect(dsl.graph.nodes[0].data.form.api_key).toBe('graph-secret');
expect(dsl.components.querit.obj.params.api_key).toBe('component-secret');
});
it('does not change standalone graph export behavior for other tools', () => {
const dsl = {
graph: {
nodes: [
{
data: {
label: Operator.TavilySearch,
form: {
api_key: 'existing-tavily-key',
},
},
},
],
},
};
expect(clearSensitiveFields(dsl)).toEqual(dsl);
});
});

View File

@@ -1,30 +1,9 @@
import { Operator } from '@/constants/agent';
import { useFetchAgent } from '@/hooks/use-agent-request';
import { downloadJsonFile } from '@/utils/file-util';
import { cloneDeepWith, get, isPlainObject } from 'lodash';
import { useCallback } from 'react';
import useGraphStore from '../store';
import { exportDsl } from '../utils/dsl-bridge';
/**
* Recursively clear sensitive fields (api_key) from the DSL object
*/
const clearSensitiveFields = <T>(obj: T): T =>
cloneDeepWith(obj, (value) => {
if (
isPlainObject(value) &&
[
Operator.TavilySearch,
Operator.TavilyExtract,
Operator.Google,
Operator.KeenableSearch,
Operator.BGPT,
].includes(value.component_name) &&
get(value, 'params.api_key')
) {
return { ...value, params: { ...value.params, api_key: '' } };
}
});
import { clearSensitiveFields } from './clear-sensitive-fields';
export const useHandleExportJsonFile = () => {
const { data } = useFetchAgent();

View File

@@ -0,0 +1,24 @@
jest.mock('@/constants/agent', () => ({
Operator: {
QueritSearch: 'QueritSearch',
},
}));
import { Operator } from '@/constants/agent';
import { getToolOperatorName } from './tool-name';
describe('getToolOperatorName', () => {
it.each(['QueritSearch', 'querit_search'])(
'maps the Querit timeline name %p to its operator',
(toolName) => {
expect(getToolOperatorName(toolName)).toBe(Operator.QueritSearch);
},
);
it.each([undefined, null, ''])(
'returns an empty name for the missing value %p',
(toolName) => {
expect(getToolOperatorName(toolName)).toBe('');
},
);
});

View File

@@ -0,0 +1,17 @@
import { Operator } from '@/constants/agent';
export function getToolOperatorName(toolName?: string | null) {
if (!toolName) {
return '';
}
const normalizedName = toolName.replaceAll('_', '').toLowerCase();
if (normalizedName === Operator.QueritSearch.toLowerCase()) {
return Operator.QueritSearch;
}
return toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join('');
}

View File

@@ -16,6 +16,7 @@ import { isEmpty } from 'lodash';
import { Operator } from '../constant';
import { JsonViewer } from '../form/components/json-viewer';
import OperatorIcon, { SVGIconMap } from '../operator-icon';
import { getToolOperatorName } from './tool-name';
import { toLowerCaseStringAndDeleteChar, typeMap } from './workflow-timeline';
type IToolIcon =
| Operator.ArXiv
@@ -28,6 +29,7 @@ type IToolIcon =
| Operator.BGPT
| Operator.TavilyExtract
| Operator.TavilySearch
| Operator.QueritSearch
| Operator.KeenableSearch
| Operator.Wikipedia
| Operator.YahooFinance
@@ -73,7 +75,7 @@ const ToolTimelineItem = ({
return (
<>
{filteredTools?.map((tool, idx) => {
const toolName = capitalizeWords(tool.tool_name, '_').join('');
const toolName = getToolOperatorName(tool.tool_name);
return (
<TimelineItem

View File

@@ -51,6 +51,7 @@ export const typeMap = {
textProcessing: t('flow.logTimeline.textProcessing'),
tavilySearch: t('flow.logTimeline.tavilySearch'),
tavilyExtract: t('flow.logTimeline.tavilyExtract'),
queritSearch: t('flow.logTimeline.queritSearch'),
exeSQL: t('flow.logTimeline.exeSQL'),
google: t('flow.logTimeline.google'),
duckDuckGo: t('flow.logTimeline.google'),

View File

@@ -1,5 +1,6 @@
import { IconFontFill } from '@/components/icon-font';
import SvgIcon from '@/components/svg-icon';
import queritLogo from '@/assets/querit.png';
import { cn } from '@/lib/utils';
import {
Columns3Cog,
@@ -51,6 +52,7 @@ export const SVGIconMap = {
[Operator.KeenableSearch]: 'keenable',
[Operator.TavilyExtract]: 'tavily',
[Operator.TavilySearch]: 'tavily',
[Operator.QueritSearch]: 'querit',
[Operator.Wikipedia]: 'wikipedia',
[Operator.YahooFinance]: 'yahoo-finance',
[Operator.WenCai]: 'wencai',
@@ -93,6 +95,16 @@ 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) {
return (
<img
src={queritLogo}
alt=""
className={cn('size-5 object-contain', className)}
/>
);
}
if (name === Operator.Begin) {
return (
<div

View File

@@ -51,6 +51,7 @@ export const typeMap = {
textProcessing: t('flow.logTimeline.textProcessing'),
tavilySearch: t('flow.logTimeline.tavilySearch'),
tavilyExtract: t('flow.logTimeline.tavilyExtract'),
queritSearch: t('flow.logTimeline.queritSearch'),
exeSQL: t('flow.logTimeline.exeSQL'),
google: t('flow.logTimeline.google'),
duckDuckGo: t('flow.logTimeline.google'),