Feat: Display both "naive" and "agentic" configurations on the chat settings page. (#21285)

This commit is contained in:
balibabu authored and GitHub committed 2026-10-21 16:25:12 +08:00
1 parent b84cadea97
commit fbb37f509f
20 files changed
+757 -87

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
// Stub for @/routes: the real module calls createBrowserRouter at module
// scope, which constructs a fetch Request — a global jsdom does not provide —
// so any import chain reaching it (e.g. llm-setting-items → llm hooks →
// navigate-hooks) crashes the test worker before a test runs. Tests only ever
// consume the Routes path constants; keep this object in sync with the enum
// in src/routes.tsx (values copied 1:1, template literals resolved).
const Routes = {
Root: '/',
Login: '/login-next',
Logout: '/logout',
Home: '/home',
Datasets: '/datasets',
DatasetBase: '/dataset',
Files: '/files',
Dataset: '/dataset/files',
Agent: '/agent',
AgentTemplates: '/agent-templates',
Agents: '/agents',
Pipeline: '/pipeline',
Explore: '/explore',
AgentExplore: '/agent/:id/explore',
Memories: '/memories',
Memory: '/memory',
MemoryMessage: '/memory-message',
MemorySetting: '/memory-setting',
AgentList: '/agent-list',
Searches: '/searches',
Search: '/search',
SearchShare: '/search/share',
Chats: '/chats',
Chat: '/chat',
Skills: '/files/skills',
ProfileSetting: '/profile-setting',
Profile: '/profile',
Api: '/api',
Mcp: '/mcp',
Team: '/team',
Plan: '/plan',
Model: '/model',
Prompt: '/prompt',
CompilationTemplatesEditNext: '/compilation-templates/edit-next',
DataSource: '/data-source',
DataSourceDetailPage: '/data-source-detail-page',
ChatChannel: '/chat-channel',
ProfileMcp: '/profile-setting/mcp',
ProfileTeam: '/profile-setting/team',
ProfilePlan: '/profile-setting/plan',
ProfileModel: '/profile-setting/model',
ProfilePrompt: '/profile-setting/prompt',
ProfileProfile: '/profile-setting/profile',
DatasetTesting: '/retrieval',
Chunk: '/chunk',
ChunkResult: '/chunk/chunk',
Parsed: '/parsed',
ParsedResult: '/chunk/parsed',
Result: '/result',
ResultView: '/chunk/result',
KnowledgeGraph: '/knowledge-graph',
Compilation: '/compilation',
AgentLogPage: '/agent-log-page',
AgentShare: '/agent/share',
ChatShare: '/chats/share',
ChatWidget: '/chats/widget',
UserSetting: '/user-setting',
DataSetOverview: '/logs',
DataSetSetting: '/configuration',
DataflowResult: '/dataflow-result',
Admin: '/admin',
AdminServices: '/admin/services',
AdminUserManagement: '/admin/users',
AdminSandboxSettings: '/admin/sandbox-settings',
AdminWhitelist: '/admin/whitelist',
AdminRoles: '/admin/roles',
AdminMonitoring: '/admin/monitoring',
};
module.exports = {
__esModule: true,
Routes,
};
+3
View File
@@ -15,6 +15,9 @@ const config: Config = {
// Drags the app shell (routes/react-router) into jsdom; see __mocks__
'^@/components/layout-recognize-form-field$':
'<rootDir>/__mocks__/layout-recognize-form-field.js',
// The app shell itself: createBrowserRouter runs at module scope and
// needs the fetch Request global, which jsdom does not provide.
'^@/routes$': '<rootDir>/__mocks__/routes.js',
'^@/(.*)$': '<rootDir>/src/$1',
'^human-id$': '<rootDir>/__mocks__/human-id.js',
},
+11 -6
View File
@@ -40,7 +40,11 @@ import * as React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import storage from '@/utils/authorization-util';
import {
setThinkingLevel,
ThinkingMode,
useThinkingLevel,
} from '@/hooks/use-thinking-level';
import { SelectWithSearch } from '../originui/select-with-search';
import { AudioButton } from '../ui/audio-button';
@@ -97,9 +101,7 @@ export function NextMessageInput({
null,
);
const [enableThinking, setEnableThinking] = useState(() =>
storage.getThinkingLevel(),
);
const enableThinking = useThinkingLevel();
const [enableInternet, setEnableInternet] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -165,8 +167,11 @@ export function NextMessageInput({
];
const handleThinkingChange = useCallback((value: string) => {
setEnableThinking(value);
storage.setThinkingLevel(value);
setThinkingLevel(
value === ThinkingMode.Agentic
? ThinkingMode.Agentic
: ThinkingMode.Naive,
);
}, []);
const handleInternetToggle = useCallback(() => {
+24
View File
@@ -20,6 +20,7 @@ import message from '@/components/ui/message';
import { ChatSearchParams } from '@/constants/chat';
import { ListDeletionKey } from '@/constants/list-deletion';
import {
IAgenticRagSettings,
IClientConversation,
IConversation,
IDialog,
@@ -69,6 +70,12 @@ export const enum ChatApiAction {
CreateSharedConversation = 'createSharedConversation',
}
export const AgenticRagKeys = {
all: ['agenticRag'] as const,
config: (chatId?: string) =>
[...AgenticRagKeys.all, 'config', chatId] as const,
};
export const useGetChatSearchParams = () => {
const [currentQueryParameters] = useSearchParams();
@@ -224,6 +231,7 @@ export const useUpdateChat = () => {
queryKey: [ChatApiAction.FetchChatList],
});
queryClient.invalidateQueries({ queryKey: [ChatApiAction.FetchChat] });
queryClient.invalidateQueries({ queryKey: AgenticRagKeys.all });
message.success(t('message.modified'));
}
return data?.code;
@@ -260,6 +268,7 @@ export const usePatchChat = () => {
queryKey: [ChatApiAction.FetchChatList],
});
queryClient.invalidateQueries({ queryKey: [ChatApiAction.FetchChat] });
queryClient.invalidateQueries({ queryKey: AgenticRagKeys.all });
message.success(t('message.modified'));
}
return data?.code;
@@ -291,6 +300,21 @@ export const useFetchChat = () => {
return { data, loading, refetch };
};
export const useFetchAgenticRagConfig = (chatId?: string) => {
const { data, isFetching: loading } = useQuery<IAgenticRagSettings>({
queryKey: AgenticRagKeys.config(chatId),
enabled: !!chatId,
gcTime: 0,
refetchOnWindowFocus: false,
queryFn: async () => {
const { data } = await chatService.getAgenticRagConfig(chatId);
return data?.data;
},
});
return { data, loading };
};
//#region Session
export const useFetchSessionList = () => {
+48
View File
@@ -0,0 +1,48 @@
/*
* Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import storage from '@/utils/authorization-util';
import { useSyncExternalStore } from 'react';
// Values mirror the strings persisted under the `thinkingLevel` localStorage
// key; they double as the reasoning level sent with each message ('0' naive,
// '5' agentic).
export const ThinkingMode = { Naive: '0', Agentic: '5' } as const;
export type ThinkingMode = (typeof ThinkingMode)[keyof typeof ThinkingMode];
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
// Persists the level, then notifies same-tab subscribers (e.g. the chat
// settings panel) so a Type change in the message input re-renders them.
export function setThinkingLevel(mode: ThinkingMode) {
storage.setThinkingLevel(mode);
listeners.forEach((notify) => notify());
}
export function useThinkingLevel(): ThinkingMode {
// getThinkingLevel already sanitizes unknown persisted values to Naive.
return useSyncExternalStore(
subscribe,
storage.getThinkingLevel,
) as ThinkingMode;
}
+25
View File
@@ -8,6 +8,30 @@ export interface IDocumentDownloadInfo {
size?: number;
}
export interface AgenticRagOverride {
content?: string;
tools?: string[];
}
export interface AgenticRagTemplate {
id: string;
name: string;
description: string;
temperature?: number | null;
tools: string[];
content: string;
}
// Response of GET /api/v1/chats/:chat_id/agentic-rag
export interface IAgenticRagSettings {
base_template: string;
defaults: AgenticRagTemplate;
override: AgenticRagOverride | null;
effective: AgenticRagTemplate;
available_tools: Array<{ name: string; description: string }>;
using_defaults: boolean;
}
export interface PromptConfig {
empty_response: string;
parameters: Parameter[];
@@ -20,6 +44,7 @@ export interface PromptConfig {
use_kg: boolean;
reasoning?: boolean;
cross_languages?: Array<string>;
agentic_rag?: AgenticRagOverride | null;
brave_api_key?: string;
exa_api_key?: string;
firecrawl_api_key?: string;
+7
View File
@@ -1183,6 +1183,13 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
thinkingLevelNone: 'Naive',
thinkingLevelAgentic: 'Agentic',
thinkingLevelAgenticDescription: 'Self-directed corpus exploration',
naiveSettings: 'Naive settings',
agenticSettings: 'Agentic settings',
agenticTemplate: 'Agentic template',
agenticTemplateTip:
'The system template the agentic RAG agent runs with. Editing it overrides the shipped default for this chat; reset to restore it.',
agenticTemplateTooLong: 'Template must be at most 32768 characters',
resetToDefault: 'Reset to default',
thinkingTip:
'Only controls thinking mode for official Qwen, Kimi, and GLM model providers. System default disables Qwen thinking to avoid long-running tasks.',
quote: 'Show citations',
+7
View File
@@ -1076,6 +1076,13 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取 Entities 和 R
thinkingLevelNone: 'Naive',
thinkingLevelAgentic: 'Agentic',
thinkingLevelAgenticDescription: '自主探索语料库',
naiveSettings: 'Naive 设置',
agenticSettings: 'Agentic 设置',
agenticTemplate: 'Agentic 模板',
agenticTemplateTip:
'Agentic RAG 运行时使用的系统模板。编辑后为当前助手覆盖默认模板,可一键重置恢复。',
agenticTemplateTooLong: '模板长度不能超过 32768 个字符',
resetToDefault: '重置为默认',
thinkingTip:
'仅控制官方模型提供商中的 Qwen、Kimi 和 GLM 模型思考模式。系统默认会关闭 Qwen 思考,以避免任务长时间运行。',
quote: '显示引文',
@@ -0,0 +1,61 @@
import { resolveAgenticRagSubmitValue } from './agentic-rag-submit';
describe('resolveAgenticRagSubmitValue', () => {
const DefaultContent = 'shipped default template';
it('returns null when there is no value', () => {
expect(resolveAgenticRagSubmitValue(undefined, DefaultContent)).toBeNull();
expect(resolveAgenticRagSubmitValue(null, DefaultContent)).toBeNull();
});
it('returns null for blank content, which the backend rejects', () => {
expect(
resolveAgenticRagSubmitValue({ content: ' ' }, DefaultContent),
).toBeNull();
});
it('returns null when content matches the server default', () => {
expect(
resolveAgenticRagSubmitValue({ content: DefaultContent }, DefaultContent),
).toBeNull();
});
it('preserves a tools override when content matches the server default', () => {
expect(
resolveAgenticRagSubmitValue(
{ content: DefaultContent, tools: ['grep_chunks', 'list_chunks'] },
DefaultContent,
),
).toEqual({ tools: ['grep_chunks', 'list_chunks'] });
});
it('keeps the content when the default is unknown (query never ran)', () => {
expect(
resolveAgenticRagSubmitValue({ content: DefaultContent }, undefined),
).toEqual({ content: DefaultContent });
});
it('returns custom content', () => {
expect(
resolveAgenticRagSubmitValue({ content: 'custom' }, DefaultContent),
).toEqual({ content: 'custom' });
});
it('preserves a tools override on the reset path', () => {
expect(
resolveAgenticRagSubmitValue(
{ content: '', tools: ['grep_chunks', 'list_chunks'] },
DefaultContent,
),
).toEqual({ tools: ['grep_chunks', 'list_chunks'] });
});
it('preserves a tools override together with custom content', () => {
expect(
resolveAgenticRagSubmitValue(
{ content: 'custom', tools: ['list_chunks'] },
DefaultContent,
),
).toEqual({ content: 'custom', tools: ['list_chunks'] });
});
});
@@ -0,0 +1,25 @@
import type { AgenticRagOverride } from '@/interfaces/database/chat';
/**
* Maps the form's agentic_rag value to the PUT payload. The backend treats
* `agentic_rag: null` as "reset to the shipped template", so blank content and
* content identical to the server default both collapse to null — this keeps
* chats on the shipped default following server-side template updates instead
* of freezing a redundant copy. A stored tools override is preserved on every
* path: the full-replace PUT must not silently drop it.
*/
export function resolveAgenticRagSubmitValue(
value: AgenticRagOverride | null | undefined,
defaultContent: string | undefined,
): AgenticRagOverride | null {
const content = value?.content;
const tools = value?.tools?.length ? value.tools : undefined;
if (!content?.trim()) {
return tools ? { tools } : null;
}
if (defaultContent !== undefined && content === defaultContent) {
return tools ? { tools } : null;
}
return tools ? { content, tools } : { content };
}
@@ -0,0 +1,80 @@
import { Form } from '@/components/ui/form';
import { TooltipProvider } from '@/components/ui/tooltip';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useForm } from 'react-hook-form';
import { AgenticTemplateFormField } from './agentic-template-form-field';
const DefaultTemplate = 'default template';
const FieldName = 'prompt_config.agentic_rag.content';
const Config = {
defaults: { content: DefaultTemplate },
override: null,
};
jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
function Harness({
defaultValues,
}: {
defaultValues: Record<string, unknown>;
}) {
const form = useForm({ defaultValues, mode: 'onChange' });
return (
<TooltipProvider>
<Form {...form}>
<AgenticTemplateFormField name={FieldName} config={Config} />
</Form>
</TooltipProvider>
);
}
describe('AgenticTemplateFormField', () => {
it('fills the shipped template only when the field has no value', async () => {
render(<Harness defaultValues={{ prompt_config: {} }} />);
await waitFor(() =>
expect(screen.getByRole('textbox')).toHaveValue(DefaultTemplate),
);
});
it('keeps an explicitly blank value instead of autofilling it', () => {
render(
<Harness
defaultValues={{
prompt_config: { agentic_rag: { content: '' } },
}}
/>,
);
expect(screen.getByRole('textbox')).toHaveValue('');
});
it('resets custom content to the shipped template', () => {
render(
<Harness
defaultValues={{
prompt_config: { agentic_rag: { content: 'custom template' } },
}}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: 'chat.resetToDefault' }),
);
expect(screen.getByRole('textbox')).toHaveValue(DefaultTemplate);
});
it('keeps the field empty after the autofilled template is cleared', async () => {
render(<Harness defaultValues={{ prompt_config: {} }} />);
const textarea = screen.getByRole('textbox');
await waitFor(() => expect(textarea).toHaveValue(DefaultTemplate));
fireEvent.change(textarea, { target: { value: '' } });
await waitFor(() => expect(textarea).toHaveValue(''));
});
});
@@ -0,0 +1,84 @@
'use client';
import { Button } from '@/components/ui/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Textarea } from '@/components/ui/textarea';
import { getDirAttribute } from '@/utils/text-direction';
import { useEffect } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
interface AgenticTemplateFormFieldProps {
name: string;
config?: {
defaults: { content: string };
override?: { content?: string } | null;
};
}
export function AgenticTemplateFormField({
name,
config,
}: AgenticTemplateFormFieldProps) {
const { t } = useTranslation();
const { control, setValue } = useFormContext();
const value = useWatch({ control, name });
const defaultContent = config?.defaults.content;
// The template arrives after the form's own reset; fill it only while the
// field has no value. An empty string is an explicit edit, and the submit
// path maps it back to the shipped template.
useEffect(() => {
if (!config || value != null) return;
setValue(name, config.override?.content ?? config.defaults.content);
}, [config, value, name, setValue]);
const handleReset = () => {
if (defaultContent === undefined) return;
setValue(name, defaultContent, { shouldDirty: true, shouldValidate: true });
};
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel tooltip={t('chat.agenticTemplateTip')}>
{t('chat.agenticTemplate')}
</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleReset}
disabled={
defaultContent === undefined || value === defaultContent
}
>
{t('chat.resetToDefault')}
</Button>
</div>
<FormControl>
<Textarea
{...field}
value={field.value ?? ''}
rows={10}
className="overflow-y-auto"
dir={getDirAttribute(value || '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
@@ -17,25 +17,39 @@ import {
} from '@/components/ui/form';
import { Textarea } from '@/components/ui/textarea';
import { WebSearchFormField } from '@/components/web-search-form-field';
import { useFetchAgenticRagConfig } from '@/hooks/use-chat-request';
import { prefixName } from '@/utils/form';
import { getDirAttribute } from '@/utils/text-direction';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { AgenticTemplateFormField } from './agentic-template-form-field';
import { DynamicVariableForm } from './dynamic-variable';
interface ChatPromptEngineProps {
prefix?: string;
collapseOpen?: boolean;
onCollapseOpenChange?: (open: boolean) => void;
advancedSettingOpen?: boolean;
onAdvancedSettingOpenChange?: (open: boolean) => void;
naiveSettingOpen?: boolean;
onNaiveSettingOpenChange?: (open: boolean) => void;
agenticSettingOpen?: boolean;
onAgenticSettingOpenChange?: (open: boolean) => void;
}
export function ChatPromptEngine({
prefix = '',
collapseOpen,
onCollapseOpenChange,
advancedSettingOpen,
onAdvancedSettingOpenChange,
naiveSettingOpen,
onNaiveSettingOpenChange,
agenticSettingOpen,
onAgenticSettingOpenChange,
}: ChatPromptEngineProps) {
const { t } = useTranslation();
const form = useFormContext();
const { id } = useParams();
// Keep the default config available even when the advanced panel is collapsed.
const { data: agenticConfig } = useFetchAgenticRagConfig(id);
const systemPromptValue = form.watch(
prefixName(prefix, 'prompt_config.system'),
);
@@ -43,42 +57,19 @@ export function ChatPromptEngine({
const emptyResponseValue = form.watch(
prefixName(prefix, 'prompt_config.empty_response'),
);
return (
<Collapse
title={t('flow.advancedSettings')}
open={collapseOpen}
onOpenChange={onCollapseOpenChange}
open={advancedSettingOpen}
onOpenChange={onAdvancedSettingOpenChange}
>
<div className="space-y-8">
<FormField
control={form.control}
name={prefixName(prefix, 'prompt_config.empty_response')}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('chat.emptyResponseTip')}>
{t('chat.emptyResponse')}
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t('chat.emptyResponsePlaceholder')}
dir={getDirAttribute(emptyResponseValue || '')}
></Textarea>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SwitchFormField
name={prefixName(prefix, 'prompt_config.quote')}
label={t('chat.quote')}
tooltip={t('chat.quoteTip')}
></SwitchFormField>
<SwitchFormField
name={prefixName(prefix, 'prompt_config.keyword')}
label={t('chat.keyword')}
tooltip={t('chat.keywordTip')}
></SwitchFormField>
<SwitchFormField
name={prefixName(prefix, 'prompt_config.tts')}
label={t('chat.tts')}
@@ -86,50 +77,94 @@ export function ChatPromptEngine({
></SwitchFormField>
<WebSearchFormField prefix={prefix} />
<MetadataFilter></MetadataFilter>
<FormField
control={form.control}
name={prefixName(prefix, 'prompt_config.system')}
render={({ field }) => (
<FormItem>
<FormLabel>{t('chat.system')}</FormLabel>
<FormControl>
<Textarea
{...field}
rows={8}
placeholder={t('chat.systemPlaceholder')}
className="overflow-y-auto"
dir={getDirAttribute(systemPromptValue || '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SimilaritySliderFormField
isTooltipShown
similarityName={prefixName(prefix, 'similarity_threshold')}
similarityWeightName={prefixName(
prefix,
'keywords_similarity_weight',
)}
></SimilaritySliderFormField>
<RerankCandidatesCountFormField
name={prefixName(prefix, 'rerank_candidates_count')}
></RerankCandidatesCountFormField>
<TopNFormField name={prefixName(prefix, 'top_n')}></TopNFormField>
<RerankFormFields prefix={prefix}></RerankFormFields>
<CrossLanguageFormField
name={prefixName(prefix, 'prompt_config.cross_languages')}
></CrossLanguageFormField>
<SwitchFormField
name={prefixName(prefix, 'prompt_config.refine_multiturn')}
label={t('chat.multiTurn')}
tooltip={t('chat.multiTurnTip')}
></SwitchFormField>
<DynamicVariableForm
name={prefixName(prefix, 'prompt_config.parameters')}
></DynamicVariableForm>
<Collapse
title={t('chat.naiveSettings')}
open={naiveSettingOpen}
onOpenChange={onNaiveSettingOpenChange}
>
<div className="space-y-8">
<FormField
control={form.control}
name={prefixName(prefix, 'prompt_config.empty_response')}
render={({ field }) => (
<FormItem>
<FormLabel tooltip={t('chat.emptyResponseTip')}>
{t('chat.emptyResponse')}
</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t('chat.emptyResponsePlaceholder')}
dir={getDirAttribute(emptyResponseValue || '')}
></Textarea>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SwitchFormField
name={prefixName(prefix, 'prompt_config.keyword')}
label={t('chat.keyword')}
tooltip={t('chat.keywordTip')}
></SwitchFormField>
<FormField
control={form.control}
name={prefixName(prefix, 'prompt_config.system')}
render={({ field }) => (
<FormItem>
<FormLabel>{t('chat.system')}</FormLabel>
<FormControl>
<Textarea
{...field}
rows={8}
placeholder={t('chat.systemPlaceholder')}
className="overflow-y-auto"
dir={getDirAttribute(systemPromptValue || '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<SimilaritySliderFormField
isTooltipShown
similarityName={prefixName(prefix, 'similarity_threshold')}
similarityWeightName={prefixName(
prefix,
'keywords_similarity_weight',
)}
></SimilaritySliderFormField>
<RerankCandidatesCountFormField
name={prefixName(prefix, 'rerank_candidates_count')}
></RerankCandidatesCountFormField>
<TopNFormField name={prefixName(prefix, 'top_n')}></TopNFormField>
<RerankFormFields prefix={prefix}></RerankFormFields>
<CrossLanguageFormField
name={prefixName(prefix, 'prompt_config.cross_languages')}
></CrossLanguageFormField>
<SwitchFormField
name={prefixName(prefix, 'prompt_config.refine_multiturn')}
label={t('chat.multiTurn')}
tooltip={t('chat.multiTurnTip')}
></SwitchFormField>
<DynamicVariableForm
name={prefixName(prefix, 'prompt_config.parameters')}
></DynamicVariableForm>
</div>
</Collapse>
<Collapse
title={t('chat.agenticSettings')}
open={agenticSettingOpen}
onOpenChange={onAgenticSettingOpenChange}
>
<AgenticTemplateFormField
name={prefixName(prefix, 'prompt_config.agentic_rag.content')}
config={agenticConfig}
/>
</Collapse>
</div>
</Collapse>
);
@@ -0,0 +1,110 @@
import { WebSearchProvider } from '@/constants/chat';
import { buildChatSettingFormSchema } from './use-chat-setting-schema';
const t = (key: string) => key;
const BaseValues = {
name: 'assistant',
icon: '',
dataset_ids: [] as string[],
prompt_config: {
quote: true,
keyword: false,
tts: false,
refine_multiturn: true,
system: 'Answer using {knowledge}',
parameters: [] as Array<{ key: string; optional: boolean }>,
},
rerank_candidates_count: 64,
similarity_threshold: 0.2,
keywords_similarity_weight: 0.8,
llm_setting: {},
meta_data_filter: { method: 'disabled', manual: [] },
};
function parseSettings(
promptConfig: Record<string, unknown> = {},
overrides: Record<string, unknown> = {},
) {
const schema = buildChatSettingFormSchema(t);
return schema.safeParse({
...BaseValues,
...overrides,
prompt_config: { ...BaseValues.prompt_config, ...promptConfig },
});
}
describe('buildChatSettingFormSchema', () => {
it('rejects a blank system prompt', () => {
const result = parseSettings({ system: '' });
expect(result.success).toBe(false);
expect(result.error?.issues).toContainEqual(
expect.objectContaining({
path: ['prompt_config', 'system'],
message: 'systemMessage',
}),
);
});
it('flags a system prompt missing {knowledge} when datasets are selected', () => {
const result = parseSettings(
{ system: 'answer the question' },
{ dataset_ids: ['kb1'] },
);
expect(result.success).toBe(false);
expect(result.error?.issues).toContainEqual(
expect.objectContaining({
path: ['prompt_config', 'system'],
message: 'knowledgePlaceholderMessage',
}),
);
});
it('rejects blank variable keys', () => {
const parameters = [{ key: '', optional: true }];
const result = parseSettings({ parameters });
expect(result.success).toBe(false);
expect(result.error?.issues).toContainEqual(
expect.objectContaining({
path: ['prompt_config', 'parameters', 0, 'key'],
message: 'variableKeyMessage',
}),
);
});
it('requires the web search API key', () => {
const result = parseSettings({
web_search_provider: WebSearchProvider.Tavily,
});
expect(result.success).toBe(false);
expect(result.error?.issues).toContainEqual(
expect.objectContaining({
path: ['prompt_config', 'tavily_api_key'],
message: 'webSearchApiKeyRequired',
}),
);
});
it('keeps agentic_rag through parsing instead of stripping it', () => {
const agenticRag = {
content: 'custom template',
tools: ['grep_chunks', 'list_chunks'],
};
const result = parseSettings({ agentic_rag: agenticRag });
expect(result.success).toBe(true);
expect(result.data?.prompt_config.agentic_rag).toEqual(agenticRag);
});
it('rejects agentic templates longer than 32768 characters', () => {
const result = parseSettings({
agentic_rag: { content: 'x'.repeat(32769) },
});
expect(result.success).toBe(false);
expect(result.error?.issues).toContainEqual(
expect.objectContaining({
path: ['prompt_config', 'agentic_rag', 'content'],
message: 'agenticTemplateTooLong',
}),
);
});
});
@@ -3,18 +3,24 @@ import { Form } from '@/components/ui/form';
import { ScrollArea } from '@/components/ui/scroll-area';
import { DatasetMetadata } from '@/constants/chat';
import { useSetModalState } from '@/hooks/common-hooks';
import { useFetchChat, useUpdateChat } from '@/hooks/use-chat-request';
import {
AgenticRagKeys,
useFetchChat,
useUpdateChat,
} from '@/hooks/use-chat-request';
import { useFindLlmByUuid } from '@/hooks/use-llm-request';
import {
useRevalidateStaleDatasetIds,
useStaleDatasetFormSchema,
} from '@/hooks/use-stale-dataset-validation';
import { IAgenticRagSettings } from '@/interfaces/database/chat';
import { cn } from '@/lib/utils';
import {
removeUselessFieldsFromValues,
setLLMSettingEnabledValues,
} from '@/utils/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQueryClient } from '@tanstack/react-query';
import { isEmpty, omit } from 'lodash';
import { LucidePanelRightClose, LucideSettings } from 'lucide-react';
import { useEffect } from 'react';
@@ -24,6 +30,7 @@ import { useParams } from 'react-router';
import { z } from 'zod';
import ChatBasicSetting from './chat-basic-settings';
import { ChatPromptEngine } from './chat-prompt-engine';
import { resolveAgenticRagSubmitValue } from './agentic-rag-submit';
import { SavingButton } from './saving-button';
import { useChatSettingSchema } from './use-chat-setting-schema';
import { useRevealSubmitErrors } from './use-reveal-submit-errors';
@@ -41,6 +48,7 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
);
const { updateChat, loading } = useUpdateChat();
const findLlmByUuid = useFindLlmByUuid();
const queryClient = useQueryClient();
const { id } = useParams();
const { t } = useTranslation();
@@ -54,6 +62,10 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
onModelSettingOpenChange,
advancedSettingOpen,
onAdvancedSettingOpenChange,
naiveSettingOpen,
onNaiveSettingOpenChange,
agenticSettingOpen,
onAgenticSettingOpenChange,
} = useRevealSubmitErrors();
type FormSchemaType = z.infer<typeof formSchema>;
@@ -107,6 +119,19 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
};
}
// Keep the override in sync with the shipped default on every save while
// preserving a custom content or complete tools override.
const agenticRag = resolveAgenticRagSubmitValue(
values.prompt_config?.agentic_rag,
queryClient.getQueryData<IAgenticRagSettings>(AgenticRagKeys.config(id))
?.defaults?.content,
);
nextValues.prompt_config = {
...nextValues.prompt_config,
agentic_rag: agenticRag,
};
updateChat({
chatId: id!,
params: {
@@ -207,8 +232,12 @@ export function ChatSettings({ hasSingleChatBox }: ChatSettingsProps) {
onCollapseOpenChange={onModelSettingOpenChange}
></ChatBasicSetting>
<ChatPromptEngine
collapseOpen={advancedSettingOpen}
onCollapseOpenChange={onAdvancedSettingOpenChange}
advancedSettingOpen={advancedSettingOpen}
onAdvancedSettingOpenChange={onAdvancedSettingOpenChange}
naiveSettingOpen={naiveSettingOpen}
onNaiveSettingOpenChange={onNaiveSettingOpenChange}
agenticSettingOpen={agenticSettingOpen}
onAgenticSettingOpenChange={onAgenticSettingOpenChange}
></ChatPromptEngine>
</section>
</ScrollArea>
@@ -12,13 +12,15 @@ import {
import { topnSchema } from '@/components/top-n-item';
import { WebSearchProvider } from '@/constants/chat';
import { useTranslate } from '@/hooks/common-hooks';
import { useMemo } from 'react';
import { z, ZodIssueCode } from 'zod';
import { missingWebSearchApiKeyField } from '../web-search-api-key';
import { chatPromptKbIssues } from './validate-chat-prompt';
export function useChatSettingSchema() {
const { t } = useTranslate('chat');
// Pure builder so the schema can be unit-tested without rendering a form.
// All settings remain in one schema because both naive and agentic fields are
// available in the settings form at the same time.
export function buildChatSettingFormSchema(t: (key: string) => string) {
const promptConfigSchema = z.object({
quote: z.boolean(),
keyword: z.boolean(),
@@ -62,6 +64,19 @@ export function useChatSettingSchema() {
.or(z.literal('')),
reasoning: z.boolean().optional(),
cross_languages: z.array(z.string()).optional(),
// The per-chat agentic template override lives here; without this
// declaration zod strips the key at parse time and the full-replace PUT
// would silently delete a saved override on every settings save.
agentic_rag: z
.object({
content: z
.string()
.max(32768, { message: t('agenticTemplateTooLong') })
.optional(),
tools: z.array(z.string()).optional(),
})
.nullable()
.optional(),
});
const formSchema = z
@@ -108,3 +123,12 @@ export function useChatSettingSchema() {
return formSchema;
}
export function useChatSettingSchema() {
const { t } = useTranslate('chat');
return useMemo(
() => buildChatSettingFormSchema((key: string) => t(key)),
[t],
);
}
@@ -17,12 +17,16 @@ function Harness() {
handleInvalidSubmit,
modelSettingOpen,
advancedSettingOpen,
naiveSettingOpen,
agenticSettingOpen,
} = useRevealSubmitErrors();
return (
<form ref={formContainerRef}>
<span data-testid="model-open">{String(modelSettingOpen)}</span>
<span data-testid="advanced-open">{String(advancedSettingOpen)}</span>
<span data-testid="naive-open">{String(naiveSettingOpen)}</span>
<span data-testid="agentic-open">{String(agenticSettingOpen)}</span>
<button type="button" onClick={handleInvalidSubmit}>
save
</button>
@@ -32,20 +36,24 @@ function Harness() {
}
describe('useRevealSubmitErrors', () => {
it('keeps both sections collapsed initially', () => {
it('keeps all sections collapsed initially', () => {
render(<Harness />);
expect(screen.getByTestId('model-open')).toHaveTextContent('false');
expect(screen.getByTestId('advanced-open')).toHaveTextContent('false');
expect(screen.getByTestId('naive-open')).toHaveTextContent('false');
expect(screen.getByTestId('agentic-open')).toHaveTextContent('false');
});
it('expands both sections and scrolls to the first error on invalid submit', () => {
it('expands all sections and scrolls to the first error on invalid submit', () => {
render(<Harness />);
fireEvent.click(screen.getByText('save'));
expect(screen.getByTestId('model-open')).toHaveTextContent('true');
expect(screen.getByTestId('advanced-open')).toHaveTextContent('true');
expect(screen.getByTestId('naive-open')).toHaveTextContent('true');
expect(screen.getByTestId('agentic-open')).toHaveTextContent('true');
expect(scrollIntoViewMock).toHaveBeenCalledTimes(1);
expect(scrollIntoViewMock).toHaveBeenCalledWith({
behavior: 'smooth',
@@ -14,12 +14,16 @@ const ErrorIndicatorSelector =
export function useRevealSubmitErrors() {
const [modelSettingOpen, setModelSettingOpen] = useState(false);
const [advancedSettingOpen, setAdvancedSettingOpen] = useState(false);
const [naiveSettingOpen, setNaiveSettingOpen] = useState(false);
const [agenticSettingOpen, setAgenticSettingOpen] = useState(false);
const [invalidSubmitSignal, setInvalidSubmitSignal] = useState(0);
const formContainerRef = useRef<HTMLFormElement>(null);
const handleInvalidSubmit = useCallback(() => {
setModelSettingOpen(true);
setAdvancedSettingOpen(true);
setNaiveSettingOpen(true);
setAgenticSettingOpen(true);
setInvalidSubmitSignal((signal) => signal + 1);
}, []);
@@ -44,5 +48,9 @@ export function useRevealSubmitErrors() {
onModelSettingOpenChange: setModelSettingOpen,
advancedSettingOpen,
onAdvancedSettingOpenChange: setAdvancedSettingOpen,
naiveSettingOpen,
onNaiveSettingOpenChange: setNaiveSettingOpen,
agenticSettingOpen,
onAgenticSettingOpenChange: setAgenticSettingOpen,
};
}
+5
View File
@@ -21,6 +21,7 @@ const {
createChat,
listChats,
getChat,
getAgenticRagConfig,
updateChat,
patchChat,
deleteChat,
@@ -52,6 +53,10 @@ const methods = {
url: getChat,
method: 'get',
},
getAgenticRagConfig: {
url: getAgenticRagConfig,
method: 'get',
},
updateChat: {
url: updateChat,
method: 'put',
+2
View File
@@ -296,6 +296,8 @@ export default {
createChat: `${restAPIv1}/chats`,
listChats: `${restAPIv1}/chats`,
getChat: (chatId: string) => `${restAPIv1}/chats/${chatId}`,
getAgenticRagConfig: (chatId: string) =>
`${restAPIv1}/chats/${chatId}/agentic-rag`,
updateChat: (chatId: string) => `${restAPIv1}/chats/${chatId}`,
patchChat: (chatId: string) => `${restAPIv1}/chats/${chatId}`,
deleteChat: (chatId: string) => `${restAPIv1}/chats/${chatId}`,