💄 style: Allow removal of top_p and similar request parameters (#9498)

*  feat: 增强参数管理,支持删除 undefined 和 null 值的参数

*  feat: 将模型参数中的 null 值转换为 undefined,以防止向 API 发送 null 值

*  feat: 更新参数处理逻辑,使用 null 表示禁用标记,优化前端与后端的参数同步

*  feat: 更新 Novita 和 SiliconCloud 模型,添加新模型并优化定价信息

* 🔧 refactor: remove deprecated model and update ID format in AI models

* 🔧 feat: update pricing for novita models and add new Ling 1T model to siliconcloud

* 🔧 feat: remove deprecated ERNIE model and add DeepSeek V3.2 Exp models to siliconcloud

---------

Co-authored-by: Arvin Xu <arvinx@foxmail.com>
This commit is contained in:
sxjeru
2025-10-23 11:25:49 +08:00
committed by GitHub
parent b6f1fc4a14
commit 4c313ced5e
13 changed files with 858 additions and 224 deletions

View File

@@ -17,7 +17,7 @@ export const MARKET_SIDEBAR_WIDTH = 400;
export const FOLDER_WIDTH = 270;
export const MAX_WIDTH = 1024;
export const FORM_STYLE: FormProps = {
itemMinWidth: 'max(30%,240px)',
itemMinWidth: 'max(34%, 240px)',
style: { maxWidth: MAX_WIDTH, width: '100%' },
};
export const MOBILE_HEADER_ICON_SIZE: ActionIconProps['size'] = { blockSize: 36, size: 22 };

View File

@@ -1,4 +1,5 @@
import { and, eq, inArray } from 'drizzle-orm';
import { LLMParams } from 'model-bank';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_AGENT_CONFIG } from '@/const/settings';
@@ -994,6 +995,113 @@ describe('SessionModel', () => {
expect(result).toBeUndefined();
});
it('should properly delete params when value is undefined', async () => {
// Create test session with agent having params
const sessionId = 'test-session-delete-params';
const agentId = 'test-agent-delete-params';
await serverDB.transaction(async (trx) => {
await trx.insert(sessions).values({
id: sessionId,
userId,
type: 'agent',
});
await trx.insert(agents).values({
id: agentId,
userId,
model: 'gpt-3.5-turbo',
title: 'Test Agent',
params: {
temperature: 0.7,
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
},
});
await trx.insert(agentsToSessions).values({
sessionId,
agentId,
userId,
});
});
// Update config with temperature set to undefined (delete it)
await sessionModel.updateConfig(sessionId, {
params: {
temperature: undefined,
},
});
// Verify temperature was deleted while other params remain
const updatedAgent = await serverDB
.select()
.from(agents)
.where(and(eq(agents.id, agentId), eq(agents.userId, userId)));
expect(updatedAgent[0].params).toMatchObject({
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
});
expect(updatedAgent[0].params).not.toHaveProperty('temperature');
});
it('should mark params as null when value is null', async () => {
// Create test session with agent having params
const sessionId = 'test-session-delete-params-null';
const agentId = 'test-agent-delete-params-null';
await serverDB.transaction(async (trx) => {
await trx.insert(sessions).values({
id: sessionId,
userId,
type: 'agent',
});
await trx.insert(agents).values({
id: agentId,
userId,
model: 'gpt-3.5-turbo',
title: 'Test Agent',
params: {
temperature: 0.7,
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
},
});
await trx.insert(agentsToSessions).values({
sessionId,
agentId,
userId,
});
});
// Update config with temperature set to null (mark it as disabled)
await sessionModel.updateConfig(sessionId, {
params: {
temperature: null,
} as any,
});
// Verify temperature is marked as null while other params remain untouched
const updatedAgent = await serverDB
.select()
.from(agents)
.where(and(eq(agents.id, agentId), eq(agents.userId, userId)));
expect(updatedAgent[0].params).toMatchObject({
top_p: 1,
presence_penalty: 0,
frequency_penalty: 0,
temperature: null,
});
expect((updatedAgent[0].params as LLMParams)?.temperature).toBeNull();
});
it('should throw error if session has no associated agent', async () => {
// Create session without agent
const sessionId = 'session-no-agent';

View File

@@ -435,7 +435,47 @@ export class SessionModel {
);
}
const mergedValue = merge(session.agent, data);
// 先处理参数字段undefined 表示删除null 表示禁用标记
const existingParams = session.agent.params ?? {};
const updatedParams: Record<string, any> = { ...existingParams };
if (data.params) {
const incomingParams = data.params as Record<string, any>;
Object.keys(incomingParams).forEach((key) => {
const incomingValue = incomingParams[key];
// undefined 代表显式删除该字段
if (incomingValue === undefined) {
delete updatedParams[key];
return;
}
// 其余值(包括 null都直接覆盖null 表示在前端禁用该参数
updatedParams[key] = incomingValue;
});
}
// 构建要合并的数据,排除 params单独处理
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { params: _params, ...restData } = data;
const mergedValue = merge(session.agent, restData);
// 应用处理后的参数
mergedValue.params = Object.keys(updatedParams).length > 0 ? updatedParams : undefined;
// 最终清理:确保没有 undefined 或 null 值进入数据库
if (mergedValue.params) {
const params = mergedValue.params as Record<string, any>;
Object.keys(params).forEach((key) => {
if (params[key] === undefined) {
delete params[key];
}
});
if (Object.keys(params).length === 0) {
mergedValue.params = undefined;
}
}
return this.db
.update(agents)
.set(mergedValue)

View File

@@ -130,23 +130,6 @@ const groqChatModels: AIChatModelCard[] = [
},
type: 'chat',
},
{
abilities: {
functionCall: true,
reasoning: true,
},
contextWindowTokens: 131_072,
displayName: 'DeepSeek R1 Distill Llama 70B',
id: 'deepseek-r1-distill-llama-70b',
maxOutput: 131_072,
pricing: {
units: [
{ name: 'textInput', rate: 0.75, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 0.99, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
},
{
abilities: {
functionCall: true,

View File

@@ -311,21 +311,6 @@ const novitaChatModels: AIChatModelCard[] = [
},
type: 'chat',
},
{
abilities: {
functionCall: true,
},
contextWindowTokens: 120_000,
displayName: 'ERNIE 4.5 0.3B',
id: 'baidu/ernie-4.5-0.3b',
pricing: {
units: [
{ name: 'textInput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
},
{
abilities: {
functionCall: true,
@@ -432,8 +417,8 @@ const novitaChatModels: AIChatModelCard[] = [
id: 'qwen/qwen3-4b-fp8',
pricing: {
units: [
{ name: 'textInput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 0, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textInput', rate: 0.03, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 0.03, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
@@ -634,19 +619,6 @@ const novitaChatModels: AIChatModelCard[] = [
},
type: 'chat',
},
{
contextWindowTokens: 32_768,
description: 'Mistral 7B Instruct 是一款兼有速度优化和长上下文支持的高性能行业标准模型。',
displayName: 'Mistral 7B Instruct',
id: 'mistralai/mistral-7b-instruct',
pricing: {
units: [
{ name: 'textInput', rate: 0.029, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 0.059, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
},
{
contextWindowTokens: 65_535,
description: 'WizardLM-2 8x22B 是微软AI最先进的Wizard模型显示出极其竞争力的表现。',
@@ -834,21 +806,6 @@ const novitaChatModels: AIChatModelCard[] = [
},
type: 'chat',
},
{
abilities: {
reasoning: true,
},
contextWindowTokens: 32_000,
displayName: 'Deepseek R1 Distill Llama 8B',
id: 'deepseek/deepseek-r1-distill-llama-8b',
pricing: {
units: [
{ name: 'textInput', rate: 0.04, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 0.04, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
},
{
abilities: {
functionCall: true,
@@ -988,21 +945,6 @@ const novitaChatModels: AIChatModelCard[] = [
},
type: 'chat',
},
{
abilities: {
functionCall: true,
},
contextWindowTokens: 32_000,
displayName: 'GLM 4 32B 0414',
id: 'thudm/glm-4-32b-0414',
pricing: {
units: [
{ name: 'textInput', rate: 0.55, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 1.66, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
},
];
export const allModels = [...novitaChatModels];

View File

@@ -2,6 +2,25 @@ import { AIChatModelCard, AIImageModelCard } from '../types/aiModel';
// https://siliconflow.cn/zh-cn/models
const siliconcloudChatModels: AIChatModelCard[] = [
{
abilities: {
functionCall: true,
},
contextWindowTokens: 131_072,
description:
'Ling-1T 是 "灵 2.0" 系列的首款旗舰级 non-thinking 模型,拥有 1 万亿总参数和每 token 约 500 亿个活动参数。基于灵 2.0 架构构建Ling-1T 旨在突破高效推理和可扩展认知的极限。Ling-1T-base 在超过 20 万亿个高质量、推理密集的 token 上进行训练,针对大型知识密集型任务与长文档理解进行了优化,具备出色的工具调用和上下文记忆能力。',
displayName: 'Ling 1T',
id: 'inclusionAI/Ling-1T',
pricing: {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 4, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 16, strategy: 'fixed', unit: 'millionTokens' },
],
},
releasedAt: '2025-10-09',
type: 'chat',
},
{
abilities: {
functionCall: true,
@@ -106,6 +125,130 @@ const siliconcloudChatModels: AIChatModelCard[] = [
releasedAt: '2025-09-10',
type: 'chat',
},
{
abilities: {
functionCall: true,
vision: true,
},
contextWindowTokens: 256_000,
description:
'Qwen3-VL-30B-A3B-Instruct 是 Qwen3-VL 系列的指令微调版本,具有强大的视觉-语言理解与生成能力,原生支持 256K 上下文长度,适合多模态对话与图像条件生成任务。',
displayName: 'Qwen3 VL 30B A3B Instruct',
id: 'Qwen/Qwen3-VL-30B-A3B-Instruct',
pricing: {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 0.7, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 2.8, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
},
{
abilities: {
functionCall: true,
reasoning: true,
vision: true,
},
contextWindowTokens: 256_000,
description:
'Qwen3-VL-30B-A3B-Thinking 是 Qwen3-VL 的推理增强版本Thinking在多模态推理、图像到代码和复杂视觉理解任务上进行了优化支持 256K 上下文并具备更强的链式思考能力。',
displayName: 'Qwen3 VL 30B A3B Thinking',
id: 'Qwen/Qwen3-VL-30B-A3B-Thinking',
pricing: {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 0.7, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 2.8, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
},
{
abilities: {
functionCall: true,
vision: true,
},
contextWindowTokens: 256_000,
description:
'Qwen3-VL-235B-A22B-Instruct 是 Qwen3-VL 系列的大型指令微调模型基于混合专家MoE架构拥有卓越的多模态理解与生成能力原生支持 256K 上下文,适用于高并发生产级多模态服务。',
displayName: 'Qwen3 VL 235B A22B Instruct',
id: 'Qwen/Qwen3-VL-235B-A22B-Instruct',
pricing: {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 2.5, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 10, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
},
{
abilities: {
functionCall: true,
reasoning: true,
vision: true,
},
contextWindowTokens: 256_000,
description:
'Qwen3-VL-235B-A22B-Thinking 是 Qwen3-VL 系列中的旗舰思考版本,针对复杂多模态推理、长上下文推理与智能体交互进行了专项优化,适合需要深度思考与视觉推理的企业级场景。',
displayName: 'Qwen3 VL 235B A22B Thinking',
id: 'Qwen/Qwen3-VL-235B-A22B-Thinking',
pricing: {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 2.5, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 10, strategy: 'fixed', unit: 'millionTokens' },
],
},
type: 'chat',
},
{
abilities: {
functionCall: true,
reasoning: true,
},
contextWindowTokens: 163_840,
description:
'DeepSeek-V3.2-Exp 是 DeepSeek 发布的实验性 V3.2 版本,作为迈向下一代架构的中间探索。它在 V3.1-Terminus 的基础上引入了 DeepSeek 稀疏注意力DeepSeek Sparse AttentionDSA机制以提升长上下文训练与推理效率针对工具调用、长文档理解与多步推理进行了专项优化。V3.2-Exp 为研究与产品化之间的桥梁,适合希望在高上下文预算场景中探索更高推理效率的用户。',
displayName: 'DeepSeek V3.2 Exp',
id: 'deepseek-ai/DeepSeek-V3.2-Exp',
pricing: {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
],
},
releasedAt: '2025-09-29',
settings: {
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
},
type: 'chat',
},
{
abilities: {
functionCall: true,
reasoning: true,
},
contextWindowTokens: 163_840,
description:
'DeepSeek-V3.2-Exp 是 DeepSeek 发布的实验性 V3.2 版本,作为迈向下一代架构的中间探索。它在 V3.1-Terminus 的基础上引入了 DeepSeek 稀疏注意力DeepSeek Sparse AttentionDSA机制以提升长上下文训练与推理效率针对工具调用、长文档理解与多步推理进行了专项优化。V3.2-Exp 为研究与产品化之间的桥梁,适合希望在高上下文预算场景中探索更高推理效率的用户。',
displayName: 'DeepSeek V3.2 Exp (Pro)',
id: 'Pro/deepseek-ai/DeepSeek-V3.2-Exp',
pricing: {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
],
},
releasedAt: '2025-09-29',
settings: {
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
},
type: 'chat',
},
{
abilities: {
functionCall: true,
@@ -151,50 +294,6 @@ const siliconcloudChatModels: AIChatModelCard[] = [
},
type: 'chat',
},
{
abilities: {
functionCall: true,
reasoning: true,
},
contextWindowTokens: 163_840,
description:
'DeepSeek-V3.1 是由深度求索DeepSeek AI发布的混合模式大语言模型它在前代模型的基础上进行了多方面的重要升级。该模型的一大创新是集成了“思考模式”Thinking Mode和“非思考模式”Non-thinking Mode于一体用户可以通过调整聊天模板灵活切换以适应不同的任务需求。通过专门的训练后优化V3.1 在工具调用和 Agent 任务方面的性能得到了显著增强,能够更好地支持外部搜索工具和执行多步复杂任务。该模型基于 DeepSeek-V3.1-Base 进行后训练通过两阶段长文本扩展方法大幅增加了训练数据量使其在处理长文档和长篇代码方面表现更佳。作为一个开源模型DeepSeek-V3.1 在编码、数学和推理等多个基准测试中展现了与顶尖闭源模型相媲美的能力同时凭借其混合专家MoE架构在保持巨大模型容量的同时有效降低了推理成本。',
displayName: 'DeepSeek V3.1',
id: 'deepseek-ai/DeepSeek-V3.1',
pricing: {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 4, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 12, strategy: 'fixed', unit: 'millionTokens' },
],
},
settings: {
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
},
type: 'chat',
},
{
abilities: {
functionCall: true,
reasoning: true,
},
contextWindowTokens: 163_840,
description:
'DeepSeek-V3.1 是由深度求索DeepSeek AI发布的混合模式大语言模型它在前代模型的基础上进行了多方面的重要升级。该模型的一大创新是集成了“思考模式”Thinking Mode和“非思考模式”Non-thinking Mode于一体用户可以通过调整聊天模板灵活切换以适应不同的任务需求。通过专门的训练后优化V3.1 在工具调用和 Agent 任务方面的性能得到了显著增强,能够更好地支持外部搜索工具和执行多步复杂任务。该模型基于 DeepSeek-V3.1-Base 进行后训练通过两阶段长文本扩展方法大幅增加了训练数据量使其在处理长文档和长篇代码方面表现更佳。作为一个开源模型DeepSeek-V3.1 在编码、数学和推理等多个基准测试中展现了与顶尖闭源模型相媲美的能力同时凭借其混合专家MoE架构在保持巨大模型容量的同时有效降低了推理成本。',
displayName: 'DeepSeek V3.1 (Pro)',
id: 'Pro/deepseek-ai/DeepSeek-V3.1',
pricing: {
currency: 'CNY',
units: [
{ name: 'textInput', rate: 4, strategy: 'fixed', unit: 'millionTokens' },
{ name: 'textOutput', rate: 12, strategy: 'fixed', unit: 'millionTokens' },
],
},
settings: {
extendParams: ['enableReasoning', 'reasoningBudgetToken'],
},
type: 'chat',
},
{
abilities: {
functionCall: true,

View File

@@ -1,26 +1,259 @@
'use client';
import { Form, type FormGroupItemType, Select, SliderWithInput } from '@lobehub/ui';
import { Form as AntdForm, Switch } from 'antd';
import {
Form,
type FormGroupItemType,
type FormItemProps,
Select,
SliderWithInput,
} from '@lobehub/ui';
import { Form as AntdForm, Checkbox, Switch } from 'antd';
import { createStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { memo } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import InfoTooltip from '@/components/InfoTooltip';
import { FORM_STYLE } from '@/const/layoutTokens';
import ModelSelect from '@/features/ModelSelect';
import { useProviderName } from '@/hooks/useProviderName';
import { selectors, useStore } from '../store';
type ParamKey = 'temperature' | 'top_p' | 'presence_penalty' | 'frequency_penalty';
const useStyles = createStyles(({ css, token }) => ({
checkbox: css`
.ant-checkbox-inner {
border-radius: 4px;
}
&:hover .ant-checkbox-inner {
border-color: ${token.colorPrimary};
}
`,
label: css`
user-select: none;
`,
sliderWrapper: css`
display: flex;
gap: 16px;
align-items: center;
width: 100%;
`,
}));
// Wrapper component for slider with checkbox
interface SliderWithCheckboxProps {
checked: boolean;
disabled: boolean;
max: number;
min: number;
onChange?: (value: number) => void;
onToggle: (checked: boolean) => void;
step: number;
styles: any;
value?: number;
}
const SliderWithCheckbox = memo<SliderWithCheckboxProps>(
({ value, onChange, disabled, checked, onToggle, styles, min, max, step }) => {
return (
<div className={styles.sliderWrapper}>
<Checkbox
checked={checked}
className={styles.checkbox}
onChange={(e) => {
e.stopPropagation();
onToggle(e.target.checked);
}}
/>
<div style={{ flex: 1 }}>
<SliderWithInput
disabled={disabled}
max={max}
min={min}
onChange={onChange}
step={step}
value={value}
/>
</div>
</div>
);
},
);
const PARAM_NAME_MAP: Record<ParamKey, (string | number)[]> = {
frequency_penalty: ['params', 'frequency_penalty'],
presence_penalty: ['params', 'presence_penalty'],
temperature: ['params', 'temperature'],
top_p: ['params', 'top_p'],
};
const PARAM_DEFAULTS: Record<ParamKey, number> = {
frequency_penalty: 0,
presence_penalty: 0,
temperature: 0.7,
top_p: 1,
};
const PARAM_CONFIG = {
frequency_penalty: {
descKey: 'settingModel.frequencyPenalty.desc',
labelKey: 'settingModel.frequencyPenalty.title',
slider: { max: 2, min: -2, step: 0.1 },
tag: 'frequency_penalty',
},
presence_penalty: {
descKey: 'settingModel.presencePenalty.desc',
labelKey: 'settingModel.presencePenalty.title',
slider: { max: 2, min: -2, step: 0.1 },
tag: 'presence_penalty',
},
temperature: {
descKey: 'settingModel.temperature.desc',
labelKey: 'settingModel.temperature.title',
slider: { max: 2, min: 0, step: 0.1 },
tag: 'temperature',
},
top_p: {
descKey: 'settingModel.topP.desc',
labelKey: 'settingModel.topP.title',
slider: { max: 1, min: 0, step: 0.1 },
tag: 'top_p',
},
} satisfies Record<
ParamKey,
{
descKey: string;
labelKey: string;
slider: { max: number; min: number; step: number };
tag: string;
}
>;
const AgentModal = memo(() => {
const { t } = useTranslation('setting');
const [form] = Form.useForm();
const config = useStore(selectors.currentAgentConfig, isEqual);
const { styles } = useStyles();
const enableMaxTokens = AntdForm.useWatch(['chatConfig', 'enableMaxTokens'], form);
const enableReasoningEffort = AntdForm.useWatch(['chatConfig', 'enableReasoningEffort'], form);
const config = useStore(selectors.currentAgentConfig, isEqual);
const updateConfig = useStore((s) => s.setAgentConfig);
const providerName = useProviderName(useStore((s) => s.config.provider) as string);
const provider = useStore((s) => s.config.provider);
const providerName = useProviderName(provider as string);
const { temperature, top_p, presence_penalty, frequency_penalty } = config.params ?? {};
const lastValuesRef = useRef<Record<ParamKey, number | undefined>>({
frequency_penalty,
presence_penalty,
temperature,
top_p,
});
useEffect(() => {
form.setFieldsValue({
...config,
_modalConfig: {
model: config.model,
provider: config.provider,
},
});
if (typeof temperature === 'number') lastValuesRef.current.temperature = temperature;
if (typeof top_p === 'number') lastValuesRef.current.top_p = top_p;
if (typeof presence_penalty === 'number') {
lastValuesRef.current.presence_penalty = presence_penalty;
}
if (typeof frequency_penalty === 'number') {
lastValuesRef.current.frequency_penalty = frequency_penalty;
}
}, [config, form, temperature, top_p, presence_penalty, frequency_penalty]);
const temperatureValue = AntdForm.useWatch(PARAM_NAME_MAP.temperature, form);
const topPValue = AntdForm.useWatch(PARAM_NAME_MAP.top_p, form);
const presencePenaltyValue = AntdForm.useWatch(PARAM_NAME_MAP.presence_penalty, form);
const frequencyPenaltyValue = AntdForm.useWatch(PARAM_NAME_MAP.frequency_penalty, form);
useEffect(() => {
if (typeof temperatureValue === 'number') lastValuesRef.current.temperature = temperatureValue;
}, [temperatureValue]);
useEffect(() => {
if (typeof topPValue === 'number') lastValuesRef.current.top_p = topPValue;
}, [topPValue]);
useEffect(() => {
if (typeof presencePenaltyValue === 'number') {
lastValuesRef.current.presence_penalty = presencePenaltyValue;
}
}, [presencePenaltyValue]);
useEffect(() => {
if (typeof frequencyPenaltyValue === 'number') {
lastValuesRef.current.frequency_penalty = frequencyPenaltyValue;
}
}, [frequencyPenaltyValue]);
const enabledMap: Record<ParamKey, boolean> = {
frequency_penalty: typeof frequencyPenaltyValue === 'number',
presence_penalty: typeof presencePenaltyValue === 'number',
temperature: typeof temperatureValue === 'number',
top_p: typeof topPValue === 'number',
};
const handleToggle = useCallback(
(key: ParamKey, enabled: boolean) => {
const namePath = PARAM_NAME_MAP[key];
if (!enabled) {
const currentValue = form.getFieldValue(namePath);
if (typeof currentValue === 'number') {
lastValuesRef.current[key] = currentValue;
}
form.setFieldValue(namePath, undefined);
return;
}
const fallback = lastValuesRef.current[key];
const nextValue = typeof fallback === 'number' ? fallback : PARAM_DEFAULTS[key];
lastValuesRef.current[key] = nextValue;
form.setFieldValue(namePath, nextValue);
},
[form],
);
const paramItems: FormItemProps[] = (Object.keys(PARAM_CONFIG) as ParamKey[]).map((key) => {
const meta = PARAM_CONFIG[key];
const enabled = enabledMap[key];
return {
children: (
<SliderWithCheckbox
checked={enabled}
disabled={!enabled}
max={meta.slider.max}
min={meta.slider.min}
onToggle={(checked) => handleToggle(key, checked)}
step={meta.slider.step}
styles={styles}
/>
),
desc: t(meta.descKey as any),
label: (
<Flexbox align={'center'} className={styles.label} gap={8} horizontal>
{t(meta.labelKey as any)}
<InfoTooltip title={t(meta.descKey as any)} />
</Flexbox>
),
name: PARAM_NAME_MAP[key],
tag: meta.tag,
} satisfies FormItemProps;
});
const model: FormGroupItemType = {
children: [
@@ -40,34 +273,7 @@ const AgentModal = memo(() => {
name: ['chatConfig', 'enableStreaming'],
valuePropName: 'checked',
},
{
children: <SliderWithInput max={2} min={0} step={0.1} />,
desc: t('settingModel.temperature.desc'),
label: t('settingModel.temperature.title'),
name: ['params', 'temperature'],
tag: 'temperature',
},
{
children: <SliderWithInput max={1} min={0} step={0.1} />,
desc: t('settingModel.topP.desc'),
label: t('settingModel.topP.title'),
name: ['params', 'top_p'],
tag: 'top_p',
},
{
children: <SliderWithInput max={2} min={-2} step={0.1} />,
desc: t('settingModel.presencePenalty.desc'),
label: t('settingModel.presencePenalty.title'),
name: ['params', 'presence_penalty'],
tag: 'presence_penalty',
},
{
children: <SliderWithInput max={2} min={-2} step={0.1} />,
desc: t('settingModel.frequencyPenalty.desc'),
label: t('settingModel.frequencyPenalty.title'),
name: ['params', 'frequency_penalty'],
tag: 'frequency_penalty',
},
...paramItems,
{
children: <Switch />,
label: t('settingModel.enableMaxTokens.title'),
@@ -77,7 +283,15 @@ const AgentModal = memo(() => {
valuePropName: 'checked',
},
{
children: <SliderWithInput max={32_000} min={0} step={100} unlimitedInput={true} />,
children: (
<SliderWithInput
disabled={!enableMaxTokens}
max={32_000}
min={0}
step={100}
unlimitedInput
/>
),
desc: t('settingModel.maxTokens.desc'),
divider: false,
hidden: !enableMaxTokens,
@@ -137,10 +351,23 @@ const AgentModal = memo(() => {
items={[model]}
itemsType={'group'}
onFinish={({ _modalConfig, ...rest }) => {
// 清理 params 中的 undefined 和 null 值,确保禁用的参数被正确移除
const cleanedRest = { ...rest };
if (cleanedRest.params) {
const cleanedParams = { ...cleanedRest.params };
(Object.keys(cleanedParams) as Array<keyof typeof cleanedParams>).forEach((key) => {
// 使用 null 作为禁用标记JSON 可以序列化 null而 undefined 会被忽略)
if (cleanedParams[key] === undefined) {
cleanedParams[key] = null as any;
}
});
cleanedRest.params = cleanedParams as any;
}
updateConfig({
model: _modalConfig?.model,
provider: _modalConfig?.provider,
...rest,
...cleanedRest,
});
}}
variant={'borderless'}

View File

@@ -1,7 +1,10 @@
import { Form, type FormItemProps, Tag } from '@lobehub/ui';
import { Form as AntdForm, Checkbox } from 'antd';
import { createStyles } from 'antd-style';
import isEqual from 'fast-deep-equal';
import { debounce } from 'lodash-es';
import { memo } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import type { ComponentType } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
@@ -20,75 +23,283 @@ interface ControlsProps {
setUpdating: (updating: boolean) => void;
updating: boolean;
}
type ParamKey = 'temperature' | 'top_p' | 'presence_penalty' | 'frequency_penalty';
type ParamLabelKey =
| 'settingModel.temperature.title'
| 'settingModel.topP.title'
| 'settingModel.presencePenalty.title'
| 'settingModel.frequencyPenalty.title';
type ParamDescKey =
| 'settingModel.temperature.desc'
| 'settingModel.topP.desc'
| 'settingModel.presencePenalty.desc'
| 'settingModel.frequencyPenalty.desc';
const useStyles = createStyles(({ css, token }) => ({
checkbox: css`
.ant-checkbox-inner {
border-radius: 4px;
}
&:hover .ant-checkbox-inner {
border-color: ${token.colorPrimary};
}
`,
label: css`
user-select: none;
`,
sliderWrapper: css`
display: flex;
gap: 16px;
align-items: center;
width: 100%;
`,
}));
// Wrapper component to handle checkbox + slider
interface ParamControlWrapperProps {
Component: ComponentType<any>;
checked: boolean;
disabled: boolean;
onChange?: (value: number) => void;
onToggle: (checked: boolean) => void;
styles: any;
value?: number;
}
const ParamControlWrapper = memo<ParamControlWrapperProps>(
({ Component, value, onChange, disabled, checked, onToggle, styles }) => {
return (
<div className={styles.sliderWrapper}>
<Checkbox
checked={checked}
className={styles.checkbox}
onChange={(e) => {
e.stopPropagation();
onToggle(e.target.checked);
}}
/>
<div style={{ flex: 1 }}>
<Component disabled={disabled} onChange={onChange} value={value} />
</div>
</div>
);
},
);
const PARAM_NAME_MAP: Record<ParamKey, (string | number)[]> = {
frequency_penalty: ['params', 'frequency_penalty'],
presence_penalty: ['params', 'presence_penalty'],
temperature: ['params', 'temperature'],
top_p: ['params', 'top_p'],
};
const PARAM_DEFAULTS: Record<ParamKey, number> = {
frequency_penalty: 0,
presence_penalty: 0,
temperature: 0.7,
top_p: 1,
};
const PARAM_CONFIG = {
frequency_penalty: {
Component: FrequencyPenalty,
descKey: 'settingModel.frequencyPenalty.desc',
labelKey: 'settingModel.frequencyPenalty.title',
tag: 'frequency_penalty',
},
presence_penalty: {
Component: PresencePenalty,
descKey: 'settingModel.presencePenalty.desc',
labelKey: 'settingModel.presencePenalty.title',
tag: 'presence_penalty',
},
temperature: {
Component: Temperature,
descKey: 'settingModel.temperature.desc',
labelKey: 'settingModel.temperature.title',
tag: 'temperature',
},
top_p: {
Component: TopP,
descKey: 'settingModel.topP.desc',
labelKey: 'settingModel.topP.title',
tag: 'top_p',
},
} satisfies Record<
ParamKey,
{
Component: ComponentType<any>;
descKey: ParamDescKey;
labelKey: ParamLabelKey;
tag: string;
}
>;
const Controls = memo<ControlsProps>(({ setUpdating }) => {
const { t } = useTranslation('setting');
const mobile = useServerConfigStore((s) => s.isMobile);
const updateAgentConfig = useAgentStore((s) => s.updateAgentConfig);
const { styles } = useStyles();
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
const [form] = Form.useForm();
const items: FormItemProps[] = [
{
children: <Temperature />,
const { frequency_penalty, presence_penalty, temperature, top_p } = config.params ?? {};
const lastValuesRef = useRef<Record<ParamKey, number | undefined>>({
frequency_penalty,
presence_penalty,
temperature,
top_p,
});
useEffect(() => {
form.setFieldsValue(config);
if (typeof temperature === 'number') lastValuesRef.current.temperature = temperature;
if (typeof top_p === 'number') lastValuesRef.current.top_p = top_p;
if (typeof presence_penalty === 'number') {
lastValuesRef.current.presence_penalty = presence_penalty;
}
if (typeof frequency_penalty === 'number') {
lastValuesRef.current.frequency_penalty = frequency_penalty;
}
}, [config, form, frequency_penalty, presence_penalty, temperature, top_p]);
const temperatureValue = AntdForm.useWatch(PARAM_NAME_MAP.temperature, form);
const topPValue = AntdForm.useWatch(PARAM_NAME_MAP.top_p, form);
const presencePenaltyValue = AntdForm.useWatch(PARAM_NAME_MAP.presence_penalty, form);
const frequencyPenaltyValue = AntdForm.useWatch(PARAM_NAME_MAP.frequency_penalty, form);
useEffect(() => {
if (typeof temperatureValue === 'number') lastValuesRef.current.temperature = temperatureValue;
}, [temperatureValue]);
useEffect(() => {
if (typeof topPValue === 'number') lastValuesRef.current.top_p = topPValue;
}, [topPValue]);
useEffect(() => {
if (typeof presencePenaltyValue === 'number') {
lastValuesRef.current.presence_penalty = presencePenaltyValue;
}
}, [presencePenaltyValue]);
useEffect(() => {
if (typeof frequencyPenaltyValue === 'number') {
lastValuesRef.current.frequency_penalty = frequencyPenaltyValue;
}
}, [frequencyPenaltyValue]);
const enabledMap: Record<ParamKey, boolean> = {
frequency_penalty: typeof frequencyPenaltyValue === 'number',
presence_penalty: typeof presencePenaltyValue === 'number',
temperature: typeof temperatureValue === 'number',
top_p: typeof topPValue === 'number',
};
const handleToggle = useCallback(
async (key: ParamKey, enabled: boolean) => {
const namePath = PARAM_NAME_MAP[key];
let newValue: number | undefined;
if (!enabled) {
const currentValue = form.getFieldValue(namePath);
if (typeof currentValue === 'number') {
lastValuesRef.current[key] = currentValue;
}
newValue = undefined;
form.setFieldValue(namePath, undefined);
} else {
const fallback = lastValuesRef.current[key];
const nextValue = typeof fallback === 'number' ? fallback : PARAM_DEFAULTS[key];
lastValuesRef.current[key] = nextValue;
newValue = nextValue;
form.setFieldValue(namePath, nextValue);
}
// 立即保存变更 - 手动构造配置对象确保使用最新值
setUpdating(true);
const currentValues = form.getFieldsValue();
const prevParams = (currentValues.params ?? {}) as Record<ParamKey, number | undefined>;
const currentParams: Record<ParamKey, number | undefined> = { ...prevParams };
if (newValue === undefined) {
// 显式删除该属性,而不是设置为 undefined
// 这样可以确保 Form 表单状态同步
delete currentParams[key];
// 使用 null 作为禁用标记(数据库会保留 null前端据此判断复选框状态
currentParams[key] = null as any;
} else {
currentParams[key] = newValue;
}
const updatedConfig = {
...currentValues,
params: currentParams,
};
await updateAgentConfig(updatedConfig);
setUpdating(false);
},
[form, setUpdating, updateAgentConfig],
);
// 使用 useMemo 确保防抖函数只创建一次
const handleValuesChange = useCallback(
debounce(async (values) => {
setUpdating(true);
await updateAgentConfig(values);
setUpdating(false);
}, 500),
[updateAgentConfig, setUpdating],
);
const baseItems: FormItemProps[] = (Object.keys(PARAM_CONFIG) as ParamKey[]).map((key) => {
const meta = PARAM_CONFIG[key];
const Component = meta.Component;
const enabled = enabledMap[key];
return {
children: (
<ParamControlWrapper
Component={Component}
checked={enabled}
disabled={!enabled}
onToggle={(checked) => handleToggle(key, checked)}
styles={styles}
/>
),
label: (
<Flexbox align={'center'} gap={8} horizontal justify={'space-between'}>
{t('settingModel.temperature.title')}
<InfoTooltip title={t('settingModel.temperature.desc')} />
<Flexbox align={'center'} className={styles.label} gap={8} horizontal>
{t(meta.labelKey)}
<InfoTooltip title={t(meta.descKey)} />
</Flexbox>
),
name: ['params', 'temperature'],
tag: 'temperature',
},
{
children: <TopP />,
label: (
<Flexbox gap={8} horizontal>
{t('settingModel.topP.title')}
<InfoTooltip title={t('settingModel.topP.desc')} />
</Flexbox>
),
name: ['params', 'top_p'],
tag: 'top_p',
},
{
children: <PresencePenalty />,
label: (
<Flexbox gap={8} horizontal>
{t('settingModel.presencePenalty.title')}
<InfoTooltip title={t('settingModel.presencePenalty.desc')} />
</Flexbox>
),
name: ['params', 'presence_penalty'],
tag: 'presence_penalty',
},
{
children: <FrequencyPenalty />,
label: (
<Flexbox gap={8} horizontal>
{t('settingModel.frequencyPenalty.title')}
<InfoTooltip title={t('settingModel.frequencyPenalty.desc')} />
</Flexbox>
),
name: ['params', 'frequency_penalty'],
tag: 'frequency_penalty',
},
];
name: PARAM_NAME_MAP[key],
tag: meta.tag,
} satisfies FormItemProps;
});
return (
<Form
form={form}
initialValues={config}
itemMinWidth={200}
itemMinWidth={220}
items={
mobile
? items
: items.map(({ tag, ...item }) => ({ ...item, desc: <Tag size={'small'}>{tag}</Tag> }))
? baseItems
: baseItems.map(({ tag, ...item }) => ({
...item,
desc: <Tag size={'small'}>{tag}</Tag>,
}))
}
itemsType={'flat'}
onValuesChange={debounce(async (values) => {
setUpdating(true);
await updateAgentConfig(values);
setUpdating(false);
}, 500)}
onValuesChange={handleValuesChange}
styles={{
group: {
background: 'transparent',

View File

@@ -5,16 +5,20 @@ import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
interface FrequencyPenaltyProps {
disabled?: boolean;
onChange?: (value: number) => void;
value?: number;
}
const FrequencyPenalty = memo<FrequencyPenaltyProps>(({ value, onChange }) => {
const FrequencyPenalty = memo<FrequencyPenaltyProps>(({ value, onChange, disabled }) => {
const theme = useTheme();
return (
<Flexbox style={{ paddingInlineStart: 8 }}>
<Flexbox style={{ width: '100%' }}>
<SliderWithInput
changeOnWheel
controls={false}
disabled={disabled}
marks={{
'-2': (
<Icon icon={FileIcon} size={'small'} style={{ color: theme.colorTextQuaternary }} />
@@ -29,9 +33,10 @@ const FrequencyPenalty = memo<FrequencyPenaltyProps>(({ value, onChange }) => {
onChange={onChange}
size={'small'}
step={0.1}
style={{ height: 42 }}
styles={{
input: {
maxWidth: 64,
maxWidth: 43,
},
}}
value={value}

View File

@@ -5,16 +5,20 @@ import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
interface PresencePenaltyProps {
disabled?: boolean;
onChange?: (value: number) => void;
value?: number;
}
const PresencePenalty = memo<PresencePenaltyProps>(({ value, onChange }) => {
const PresencePenalty = memo<PresencePenaltyProps>(({ value, onChange, disabled }) => {
const theme = useTheme();
return (
<Flexbox style={{ paddingInlineStart: 8 }}>
<Flexbox style={{ width: '100%' }}>
<SliderWithInput
changeOnWheel
controls={false}
disabled={disabled}
marks={{
'-2': (
<Icon icon={RepeatIcon} size={'small'} style={{ color: theme.colorTextQuaternary }} />
@@ -27,9 +31,10 @@ const PresencePenalty = memo<PresencePenaltyProps>(({ value, onChange }) => {
onChange={onChange}
size={'small'}
step={0.1}
style={{ height: 42 }}
styles={{
input: {
maxWidth: 64,
maxWidth: 43,
},
}}
value={value}

View File

@@ -41,16 +41,19 @@ const Warning = memo(() => {
});
interface TemperatureProps {
disabled?: boolean;
onChange?: (value: number) => void;
value?: number;
}
const Temperature = memo<TemperatureProps>(({ value, onChange }) => {
const Temperature = memo<TemperatureProps>(({ value, onChange, disabled }) => {
const theme = useTheme();
return (
<Flexbox gap={4} style={{ paddingInlineStart: 8 }}>
<Flexbox gap={4} style={{ width: '100%' }}>
<SliderWithInput
changeOnWheel
controls={false}
disabled={disabled}
marks={{
0: <Icon icon={Sparkle} size={'small'} style={{ color: theme.colorTextQuaternary }} />,
1: <div />,
@@ -60,15 +63,15 @@ const Temperature = memo<TemperatureProps>(({ value, onChange }) => {
onChange={onChange}
size={'small'}
step={0.1}
style={{ height: 48 }}
style={{ height: 42 }}
styles={{
input: {
maxWidth: 64,
maxWidth: 43,
},
}}
value={value}
/>
<Warning />
{!disabled && <Warning />}
</Flexbox>
);
});

View File

@@ -5,16 +5,20 @@ import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
interface TopPProps {
disabled?: boolean;
onChange?: (value: number) => void;
value?: number;
}
const TopP = memo<TopPProps>(({ value, onChange }) => {
const TopP = memo<TopPProps>(({ value, onChange, disabled }) => {
const theme = useTheme();
return (
<Flexbox style={{ paddingInlineStart: 8 }}>
<Flexbox style={{ width: '100%' }}>
<SliderWithInput
changeOnWheel
controls={false}
disabled={disabled}
marks={{
0: (
<Icon
@@ -31,9 +35,10 @@ const TopP = memo<TopPProps>(({ value, onChange }) => {
onChange={onChange}
size={'small'}
step={0.1}
style={{ height: 42 }}
styles={{
input: {
maxWidth: 64,
maxWidth: 43,
},
}}
value={value}

View File

@@ -274,6 +274,12 @@ class ChatService {
{ ...res, apiMode, model },
);
// Convert null to undefined for model params to prevent sending null values to API
if (payload.temperature === null) payload.temperature = undefined;
if (payload.top_p === null) payload.top_p = undefined;
if (payload.presence_penalty === null) payload.presence_penalty = undefined;
if (payload.frequency_penalty === null) payload.frequency_penalty = undefined;
const sdkType = resolveRuntimeProvider(provider);
/**