From 4c313ced5ef59de2533cb424fdd37fb970c283fc Mon Sep 17 00:00:00 2001 From: sxjeru Date: Thu, 23 Oct 2025 11:25:49 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style:=20Allow=20removal=20of=20?= =?UTF-8?q?`top=5Fp`=20and=20similar=20request=20parameters=20(#9498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 --- packages/const/src/layoutTokens.ts | 2 +- .../src/models/__tests__/session.test.ts | 108 ++++++ packages/database/src/models/session.ts | 42 ++- packages/model-bank/src/aiModels/groq.ts | 17 - packages/model-bank/src/aiModels/novita.ts | 62 +--- .../model-bank/src/aiModels/siliconcloud.ts | 187 ++++++++--- .../AgentSetting/AgentModal/index.tsx | 297 +++++++++++++++-- .../ChatInput/ActionBar/Params/Controls.tsx | 315 +++++++++++++++--- .../ModelParamsControl/FrequencyPenalty.tsx | 11 +- .../ModelParamsControl/PresencePenalty.tsx | 11 +- .../ModelParamsControl/Temperature.tsx | 13 +- src/features/ModelParamsControl/TopP.tsx | 11 +- src/services/chat/index.ts | 6 + 13 files changed, 858 insertions(+), 224 deletions(-) diff --git a/packages/const/src/layoutTokens.ts b/packages/const/src/layoutTokens.ts index e7cfa0d1ef..827f342003 100644 --- a/packages/const/src/layoutTokens.ts +++ b/packages/const/src/layoutTokens.ts @@ -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 }; diff --git a/packages/database/src/models/__tests__/session.test.ts b/packages/database/src/models/__tests__/session.test.ts index bf6df4bdda..2a20720dfc 100644 --- a/packages/database/src/models/__tests__/session.test.ts +++ b/packages/database/src/models/__tests__/session.test.ts @@ -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'; diff --git a/packages/database/src/models/session.ts b/packages/database/src/models/session.ts index 1cff2d7bb7..78918e2504 100644 --- a/packages/database/src/models/session.ts +++ b/packages/database/src/models/session.ts @@ -435,7 +435,47 @@ export class SessionModel { ); } - const mergedValue = merge(session.agent, data); + // 先处理参数字段:undefined 表示删除,null 表示禁用标记 + const existingParams = session.agent.params ?? {}; + const updatedParams: Record = { ...existingParams }; + + if (data.params) { + const incomingParams = data.params as Record; + 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; + 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) diff --git a/packages/model-bank/src/aiModels/groq.ts b/packages/model-bank/src/aiModels/groq.ts index f73dabd74c..cfb2ef1198 100644 --- a/packages/model-bank/src/aiModels/groq.ts +++ b/packages/model-bank/src/aiModels/groq.ts @@ -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, diff --git a/packages/model-bank/src/aiModels/novita.ts b/packages/model-bank/src/aiModels/novita.ts index 2ff119b692..51c7939df6 100644 --- a/packages/model-bank/src/aiModels/novita.ts +++ b/packages/model-bank/src/aiModels/novita.ts @@ -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]; diff --git a/packages/model-bank/src/aiModels/siliconcloud.ts b/packages/model-bank/src/aiModels/siliconcloud.ts index 3a9c7ae11d..d477d6b347 100644 --- a/packages/model-bank/src/aiModels/siliconcloud.ts +++ b/packages/model-bank/src/aiModels/siliconcloud.ts @@ -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 Attention,DSA)机制以提升长上下文训练与推理效率,针对工具调用、长文档理解与多步推理进行了专项优化。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 Attention,DSA)机制以提升长上下文训练与推理效率,针对工具调用、长文档理解与多步推理进行了专项优化。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, diff --git a/src/features/AgentSetting/AgentModal/index.tsx b/src/features/AgentSetting/AgentModal/index.tsx index a4a23f4f51..0327579b45 100644 --- a/src/features/AgentSetting/AgentModal/index.tsx +++ b/src/features/AgentSetting/AgentModal/index.tsx @@ -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( + ({ value, onChange, disabled, checked, onToggle, styles, min, max, step }) => { + return ( +
+ { + e.stopPropagation(); + onToggle(e.target.checked); + }} + /> +
+ +
+
+ ); + }, +); + +const PARAM_NAME_MAP: Record = { + frequency_penalty: ['params', 'frequency_penalty'], + presence_penalty: ['params', 'presence_penalty'], + temperature: ['params', 'temperature'], + top_p: ['params', 'top_p'], +}; + +const PARAM_DEFAULTS: Record = { + 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>({ + 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 = { + 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: ( + handleToggle(key, checked)} + step={meta.slider.step} + styles={styles} + /> + ), + desc: t(meta.descKey as any), + label: ( + + {t(meta.labelKey as any)} + + + ), + 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: , - desc: t('settingModel.temperature.desc'), - label: t('settingModel.temperature.title'), - name: ['params', 'temperature'], - tag: 'temperature', - }, - { - children: , - desc: t('settingModel.topP.desc'), - label: t('settingModel.topP.title'), - name: ['params', 'top_p'], - tag: 'top_p', - }, - { - children: , - desc: t('settingModel.presencePenalty.desc'), - label: t('settingModel.presencePenalty.title'), - name: ['params', 'presence_penalty'], - tag: 'presence_penalty', - }, - { - children: , - desc: t('settingModel.frequencyPenalty.desc'), - label: t('settingModel.frequencyPenalty.title'), - name: ['params', 'frequency_penalty'], - tag: 'frequency_penalty', - }, + ...paramItems, { children: , label: t('settingModel.enableMaxTokens.title'), @@ -77,7 +283,15 @@ const AgentModal = memo(() => { valuePropName: 'checked', }, { - children: , + children: ( + + ), 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).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'} diff --git a/src/features/ChatInput/ActionBar/Params/Controls.tsx b/src/features/ChatInput/ActionBar/Params/Controls.tsx index 9b4d726a82..8d065408c8 100644 --- a/src/features/ChatInput/ActionBar/Params/Controls.tsx +++ b/src/features/ChatInput/ActionBar/Params/Controls.tsx @@ -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; + checked: boolean; + disabled: boolean; + onChange?: (value: number) => void; + onToggle: (checked: boolean) => void; + styles: any; + value?: number; +} + +const ParamControlWrapper = memo( + ({ Component, value, onChange, disabled, checked, onToggle, styles }) => { + return ( +
+ { + e.stopPropagation(); + onToggle(e.target.checked); + }} + /> +
+ +
+
+ ); + }, +); + +const PARAM_NAME_MAP: Record = { + frequency_penalty: ['params', 'frequency_penalty'], + presence_penalty: ['params', 'presence_penalty'], + temperature: ['params', 'temperature'], + top_p: ['params', 'top_p'], +}; + +const PARAM_DEFAULTS: Record = { + 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; + descKey: ParamDescKey; + labelKey: ParamLabelKey; + tag: string; + } +>; + const Controls = memo(({ 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: , + const { frequency_penalty, presence_penalty, temperature, top_p } = config.params ?? {}; + + const lastValuesRef = useRef>({ + 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 = { + 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; + const currentParams: Record = { ...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: ( + handleToggle(key, checked)} + styles={styles} + /> + ), label: ( - - {t('settingModel.temperature.title')} - + + {t(meta.labelKey)} + ), - name: ['params', 'temperature'], - tag: 'temperature', - }, - { - children: , - label: ( - - {t('settingModel.topP.title')} - - - ), - name: ['params', 'top_p'], - tag: 'top_p', - }, - { - children: , - label: ( - - {t('settingModel.presencePenalty.title')} - - - ), - name: ['params', 'presence_penalty'], - tag: 'presence_penalty', - }, - { - children: , - label: ( - - {t('settingModel.frequencyPenalty.title')} - - - ), - name: ['params', 'frequency_penalty'], - tag: 'frequency_penalty', - }, - ]; + name: PARAM_NAME_MAP[key], + tag: meta.tag, + } satisfies FormItemProps; + }); return (
({ ...item, desc: {tag} })) + ? baseItems + : baseItems.map(({ tag, ...item }) => ({ + ...item, + desc: {tag}, + })) } itemsType={'flat'} - onValuesChange={debounce(async (values) => { - setUpdating(true); - await updateAgentConfig(values); - setUpdating(false); - }, 500)} + onValuesChange={handleValuesChange} styles={{ group: { background: 'transparent', diff --git a/src/features/ModelParamsControl/FrequencyPenalty.tsx b/src/features/ModelParamsControl/FrequencyPenalty.tsx index 149ae2f332..6deff61293 100644 --- a/src/features/ModelParamsControl/FrequencyPenalty.tsx +++ b/src/features/ModelParamsControl/FrequencyPenalty.tsx @@ -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(({ value, onChange }) => { +const FrequencyPenalty = memo(({ value, onChange, disabled }) => { const theme = useTheme(); return ( - + @@ -29,9 +33,10 @@ const FrequencyPenalty = memo(({ value, onChange }) => { onChange={onChange} size={'small'} step={0.1} + style={{ height: 42 }} styles={{ input: { - maxWidth: 64, + maxWidth: 43, }, }} value={value} diff --git a/src/features/ModelParamsControl/PresencePenalty.tsx b/src/features/ModelParamsControl/PresencePenalty.tsx index c30a50170c..891f3a33ee 100644 --- a/src/features/ModelParamsControl/PresencePenalty.tsx +++ b/src/features/ModelParamsControl/PresencePenalty.tsx @@ -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(({ value, onChange }) => { +const PresencePenalty = memo(({ value, onChange, disabled }) => { const theme = useTheme(); return ( - + @@ -27,9 +31,10 @@ const PresencePenalty = memo(({ value, onChange }) => { onChange={onChange} size={'small'} step={0.1} + style={{ height: 42 }} styles={{ input: { - maxWidth: 64, + maxWidth: 43, }, }} value={value} diff --git a/src/features/ModelParamsControl/Temperature.tsx b/src/features/ModelParamsControl/Temperature.tsx index 1f885e8ba0..67c1ab0819 100644 --- a/src/features/ModelParamsControl/Temperature.tsx +++ b/src/features/ModelParamsControl/Temperature.tsx @@ -41,16 +41,19 @@ const Warning = memo(() => { }); interface TemperatureProps { + disabled?: boolean; onChange?: (value: number) => void; value?: number; } -const Temperature = memo(({ value, onChange }) => { +const Temperature = memo(({ value, onChange, disabled }) => { const theme = useTheme(); return ( - + , 1:
, @@ -60,15 +63,15 @@ const Temperature = memo(({ value, onChange }) => { onChange={onChange} size={'small'} step={0.1} - style={{ height: 48 }} + style={{ height: 42 }} styles={{ input: { - maxWidth: 64, + maxWidth: 43, }, }} value={value} /> - + {!disabled && } ); }); diff --git a/src/features/ModelParamsControl/TopP.tsx b/src/features/ModelParamsControl/TopP.tsx index 7707b1a3af..6d25caf101 100644 --- a/src/features/ModelParamsControl/TopP.tsx +++ b/src/features/ModelParamsControl/TopP.tsx @@ -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(({ value, onChange }) => { +const TopP = memo(({ value, onChange, disabled }) => { const theme = useTheme(); return ( - + (({ value, onChange }) => { onChange={onChange} size={'small'} step={0.1} + style={{ height: 42 }} styles={{ input: { - maxWidth: 64, + maxWidth: 43, }, }} value={value} diff --git a/src/services/chat/index.ts b/src/services/chat/index.ts index b4e88167f4..23bbf50c1e 100644 --- a/src/services/chat/index.ts +++ b/src/services/chat/index.ts @@ -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); /**