mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-01 13:33:48 +08:00
feat(agent): add Querit search tool (#17548)
This commit is contained in:
BIN
web/src/assets/querit.png
Normal file
BIN
web/src/assets/querit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
@@ -95,6 +95,7 @@ export enum Operator {
|
||||
Tool = 'Tool',
|
||||
TavilySearch = 'TavilySearch',
|
||||
TavilyExtract = 'TavilyExtract',
|
||||
QueritSearch = 'QueritSearch',
|
||||
UserFillUp = 'UserFillUp',
|
||||
StringTransform = 'StringTransform',
|
||||
SearXNG = 'SearXNG',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '正在网上搜索',
|
||||
|
||||
@@ -109,6 +109,7 @@ export function AccordionOperators({
|
||||
operators={[
|
||||
Operator.TavilySearch,
|
||||
Operator.TavilyExtract,
|
||||
Operator.QueritSearch,
|
||||
Operator.ExeSQL,
|
||||
Operator.Google,
|
||||
Operator.YahooFinance,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ const Menus = [
|
||||
list: [
|
||||
Operator.TavilySearch,
|
||||
Operator.TavilyExtract,
|
||||
Operator.QueritSearch,
|
||||
Operator.Google,
|
||||
// Operator.Bing,
|
||||
Operator.DuckDuckGo,
|
||||
|
||||
73
web/src/pages/agent/form/querit-form/dynamic-list.tsx
Normal file
73
web/src/pages/agent/form/querit-form/dynamic-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
web/src/pages/agent/form/querit-form/index.tsx
Normal file
136
web/src/pages/agent/form/querit-form/index.tsx
Normal 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);
|
||||
14
web/src/pages/agent/form/querit-form/use-values.ts
Normal file
14
web/src/pages/agent/form/querit-form/use-values.ts
Normal 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]);
|
||||
}
|
||||
114
web/src/pages/agent/form/querit-form/use-watch-change.test.tsx
Normal file
114
web/src/pages/agent/form/querit-form/use-watch-change.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
25
web/src/pages/agent/form/querit-form/use-watch-change.ts
Normal file
25
web/src/pages/agent/form/querit-form/use-watch-change.ts
Normal 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]);
|
||||
}
|
||||
224
web/src/pages/agent/form/querit-form/utils.test.ts
Normal file
224
web/src/pages/agent/form/querit-form/utils.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
72
web/src/pages/agent/form/querit-form/utils.ts
Normal file
72
web/src/pages/agent/form/querit-form/utils.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
41
web/src/pages/agent/form/tool-form/querit-form/index.tsx
Normal file
41
web/src/pages/agent/form/tool-form/querit-form/index.tsx
Normal 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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
66
web/src/pages/agent/form/tool-form/querit-form/utils.test.ts
Normal file
66
web/src/pages/agent/form/tool-form/querit-form/utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
22
web/src/pages/agent/form/tool-form/querit-form/utils.ts
Normal file
22
web/src/pages/agent/form/tool-form/querit-form/utils.ts
Normal 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);
|
||||
}
|
||||
53
web/src/pages/agent/hooks/clear-sensitive-fields.ts
Normal file
53
web/src/pages/agent/hooks/clear-sensitive-fields.ts
Normal 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: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
7
web/src/pages/agent/hooks/querit-agent-initial-values.ts
Normal file
7
web/src/pages/agent/hooks/querit-agent-initial-values.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { omit } from 'lodash';
|
||||
|
||||
export function getQueritAgentInitialValues(
|
||||
initialValues: Record<string, any>,
|
||||
) {
|
||||
return omit(initialValues, 'query', 'outputs');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
126
web/src/pages/agent/hooks/use-export-json.test.ts
Normal file
126
web/src/pages/agent/hooks/use-export-json.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
24
web/src/pages/agent/log-sheet/tool-name.test.ts
Normal file
24
web/src/pages/agent/log-sheet/tool-name.test.ts
Normal 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('');
|
||||
},
|
||||
);
|
||||
});
|
||||
17
web/src/pages/agent/log-sheet/tool-name.ts
Normal file
17
web/src/pages/agent/log-sheet/tool-name.ts
Normal 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('');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user