diff --git a/public/image/CircleAdd.svg b/public/image/CircleAdd.svg new file mode 100644 index 00000000..4ce80957 --- /dev/null +++ b/public/image/CircleAdd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 4c9f2c25..7b0bcf9e 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -44,6 +44,7 @@ "submit": "Save Settings", "provider": "Model Provider", "verify": "Verify Model", + "verifying": "Verifying...", "verifySuccess": "Model verification successful", "verifySuccessNoTool": "Model verification successful, but does not support Tool Calls", "verifyFailed": "Model verification failed", @@ -199,7 +200,25 @@ "modelSetting": "{{name}} Model Settings", "streamingModeTooltip": "Streaming Mode is when the model responds, it shows the content gradually, instead of showing it all at once. Turning off the button, in the Non-Streaming Mode, will wait for the model to generate the content completely and display it all at once, avoiding compatibility issues with tool calls in the model.", "streamingModeDescription": "Control how model responses are displayed", - "streamingModeAlert": "Reminder: {{name}} model does not support tool calls in Streaming mode. If you need to use tool functionality, please turn off this button and adjust to Non-Streaming Mode." + "streamingModeAlert": "Reminder: {{name}} model does not support tool calls in Streaming mode. If you need to use tool functionality, please turn off this button and adjust to Non-Streaming Mode.", + "customInput": "Custom Input", + "addCustomParameter": "Add Custom Parameter", + "parameterName": "Parameter Name", + "parameterType": "Parameter Type", + "parameterValue": "Parameter Value", + "parameterNameDescription": "Enter name, like temperature", + "parameterTypeDescription": "Select type", + "parameterValueDescription": "Please select the parameter type first and then enter the value", + "parameterNameDuplicate": "Parameter name already exists", + "parameterTypeInt": "Integer", + "parameterTypeFloat": "Float", + "parameterTypeString": "String", + "parameterTypeIntDescription": "Allowed range 0-1M", + "parameterTypeFloatDescription": "Allowed range 0.0-1.0", + "parameterTypeStringDescription": "Please enter a string, like medium", + "reasoningLevelDescription": "Set the depth of model thinking", + "reasoningLevelTooltip": "Low represents a quick response, focusing on conclusions and without additional details, with a direct and simple reasoning. Medium provides a response with sufficient logical explanation, covering key information. High offers a deep response with a structured and detailed analysis and explanation.", + "tokenBudgetDescription": "Set reasoning value ({{min}}~{{max}})" }, "system": { "title": "System Settings", diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index f8f75a6e..16581364 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -44,6 +44,7 @@ "submit": "Guardar Configuración", "provider": "Proveedor del Modelo", "verify": "Verificar Modelo", + "verifying": "Verificando modelo...", "verifySuccess": "Verificación del modelo exitosa", "verifySuccessNoTool": "Verificación exitosa, pero el modelo no admite llamadas de herramientas", "verifyFailed": "Verificación del modelo fallida", @@ -199,7 +200,25 @@ "modelSetting": "Configuración de {{name}}", "streamingModeTooltip": "Streaming Mode es cuando el modelo responde, muestra el contenido gradualmente, en lugar de mostrarlo de una vez. Desactivar el botón, en el modo Non-Streaming Mode, esperará a que el modelo genere el contenido completo y lo muestre de una vez, evitando problemas de compatibilidad con las llamadas a herramientas.", "streamingModeDescription": "Controla cómo se muestra la respuesta del modelo", - "streamingModeAlert": "Advertencia: {{name}} actualmente es un modelo en modo Streaming que no admite llamadas a herramientas. Si necesita usar la función de herramientas, desactive este botón y ajuste el modo a Non-Streaming Mode." + "streamingModeAlert": "Advertencia: {{name}} actualmente es un modelo en modo Streaming que no admite llamadas a herramientas. Si necesita usar la función de herramientas, desactive este botón y ajuste el modo a Non-Streaming Mode.", + "customInput": "Parámetro personalizado", + "addCustomParameter": "Agregar parámetro personalizado", + "parameterName": "Nombre del parámetro", + "parameterType": "Tipo de parámetro", + "parameterValue": "Valor del parámetro", + "parameterNameDescription": "Ingrese el nombre del parámetro, como temperature", + "parameterTypeDescription": "Seleccione el tipo", + "parameterValueDescription": "Por favor, seleccione el tipo de parámetro primero y luego ingrese el valor", + "parameterNameDuplicate": "El nombre del parámetro ya existe", + "parameterTypeInt": "Entero", + "parameterTypeFloat": "Flotante", + "parameterTypeString": "Cadena", + "parameterTypeIntDescription": "Rango permitido 0-1M", + "parameterTypeFloatDescription": "Rango permitido 0.0-1.0", + "parameterTypeStringDescription": "Ingrese una cadena, como medium", + "reasoningLevelDescription": "Establece la profundidad de la reflexión del modelo", + "reasoningLevelTooltip": "Low representa una respuesta rápida, enfocada en conclusiones y sin detalles adicionales, con un razonamiento directo y simple. Medium proporciona una respuesta con suficiente explicación lógica, cubriendo información clave. High ofrece una respuesta profunda con un razonamiento estructurado y detallado.", + "tokenBudgetDescription": "Establece el límite de tokens de razonamiento ({{min}}~{{max}})" }, "system": { "title": "Configuración del Sistema", diff --git a/public/locales/ja/translation.json b/public/locales/ja/translation.json index 70429e26..f1348ee6 100644 --- a/public/locales/ja/translation.json +++ b/public/locales/ja/translation.json @@ -44,6 +44,7 @@ "submit": "設定を保存", "provider": "モデルプロバイダー", "verify": "モデルを検証", + "verifying": "検証中...", "verifySuccess": "モデルの検証に成功しました", "verifySuccessNoTool": "モデルの検証に成功しましたが、ツール呼び出しはサポートしていません", "verifyFailed": "モデルの検証に失敗しました", diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index 93688317..74d4a28a 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -44,6 +44,7 @@ "submit": "保存设置", "provider": "模型提供者", "verify": "验证模型", + "verifying": "验证中...", "verifySuccess": "模型验证成功", "verifySuccessNoTool": "模型验证成功,但不支持Tool Calls", "verifyFailed": "模型验证失败", @@ -199,7 +200,25 @@ "modelSetting": "{{name}} 模型设置", "streamingModeTooltip": "Streaming Mode 是指 AI 回应时,即时逐步输出内容,而不是一次性完整回应。关闭按钮,在 Non-Streaming Mode 下,则是等待模型完全生成完毕后,回应一次性显示,可以避免工具调用在模型中的兼容性问题。", "streamingModeDescription": "控制模型回应的显示方式", - "streamingModeAlert": "提醒:{{name}}目前是Streaming mode模式不支持tool calls使用。如果您需要使用工具功能,请关闭此按钮,调整成Non-Streaming Mode模式。" + "streamingModeAlert": "提醒:{{name}}目前是Streaming mode模式不支持tool calls使用。如果您需要使用工具功能,请关闭此按钮,调整成Non-Streaming Mode模式。", + "customInput": "自定义参数", + "addCustomParameter": "添加自定义参数", + "parameterName": "参数名称", + "parameterType": "参数类型", + "parameterValue": "参数值", + "parameterNameDescription": "输入名称,如 temperature", + "parameterTypeDescription": "选择类型", + "parameterValueDescription": "请先选择参数类型,再填写数值", + "parameterNameDuplicate": "参数名称已存在", + "parameterTypeInt": "整数", + "parameterTypeFloat": "浮点数", + "parameterTypeString": "字符串", + "parameterTypeIntDescription": "允许的范圍0-1M", + "parameterTypeFloatDescription": "允许的范圍0.0-1.0", + "parameterTypeStringDescription": "请输入字符,例如:medium", + "reasoningLevelDescription": "设置模型思考的深度", + "reasoningLevelTooltip": "用来控制模型思考与推理的深度。Low代表快速回应,偏重结论、没有过多细节,推理简单直接;Medium代表回应适度说明逻辑、涵盖关键信息;High代表回应深度推理,条理清晰、有结构的分析与说明。", + "tokenBudgetDescription": "设置推理数值 ({{min}}~{{max}})" }, "system": { "title": "系统设置", diff --git a/public/locales/zh-TW/translation.json b/public/locales/zh-TW/translation.json index 981bb1ae..64b09f0c 100644 --- a/public/locales/zh-TW/translation.json +++ b/public/locales/zh-TW/translation.json @@ -44,6 +44,7 @@ "submit": "儲存設定", "provider": "模型提供者", "verify": "驗證模型", + "verifying": "驗證中...", "verifySuccess": "模型驗證成功", "verifySuccessNoTool": "模型驗證成功,但不支援Tool Calls", "verifyFailed": "模型驗證失敗", @@ -199,7 +200,25 @@ "modelSetting": "{{name}} 模型設定", "streamingModeTooltip": "Streaming Mode 是指 AI 回應時,即時逐步輸出內容,而不是一次性完整回應。關閉按鈕,在 Non-Streaming Mode 下,則是等待模型完全生成完畢後,回應一次性顯示,可以避免工具調用在模型中的兼容性問題。", "streamingModeDescription": "控制模型回應的顯示方式", - "streamingModeAlert": "提醒:{{name}} 目前是 Streaming mode 模式不支援 tool calls 使用。 如果您需要使用工具功能,請關閉此按鈕,調整成 Non-Streaming Mode 模式。" + "streamingModeAlert": "提醒:{{name}} 目前是 Streaming mode 模式不支援 tool calls 使用。 如果您需要使用工具功能,請關閉此按鈕,調整成 Non-Streaming Mode 模式。", + "customInput": "自訂參數", + "addCustomParameter": "添加自訂參數", + "parameterName": "參數名稱", + "parameterType": "參數類型", + "parameterValue": "參數值", + "parameterNameDescription": "輸入名稱,如 temperature", + "parameterTypeDescription": "選擇類型", + "parameterValueDescription": "請先選擇參數類型,再填寫數值", + "parameterNameDuplicate": "參數名稱重複", + "parameterTypeInt": "整數", + "parameterTypeFloat": "浮點數", + "parameterTypeString": "字符串", + "parameterTypeIntDescription": "允許的範圍0-1M", + "parameterTypeFloatDescription": "允許的範圍0.0-1.0", + "parameterTypeStringDescription": "請輸入字符,例如:medium", + "reasoningLevelDescription": "設置模型思考的深度", + "reasoningLevelTooltip": "用來控制模型思考與推理的深度。Low代表快速回應,偏重結論、沒有過多細節,推理簡單直接;Medium代表回應適度說明邏輯、涵蓋關鍵資訊;High代表回應深度推理,條理清晰、有結構的分析與說明。", + "tokenBudgetDescription": "設置推理數值 ({{min}}~{{max}})" }, "system": { "title": "系統設定", diff --git a/src/atoms/configState.ts b/src/atoms/configState.ts index 590e13ed..568a437e 100644 --- a/src/atoms/configState.ts +++ b/src/atoms/configState.ts @@ -29,8 +29,10 @@ export type ModelConfig = ProviderRequired & ModelParameter & { } export type InterfaceModelConfig = Omit & Partial & Partial & { - modelProvider: InterfaceProvider -} + modelProvider: InterfaceProvider; + checked?: boolean; + name?: string; + }; export type ModelConfigMap = Record export type InterfaceModelConfigMap = Record @@ -45,6 +47,9 @@ export type MultiModelConfig = ProviderRequired & ModelParameter & Partial({ diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 8001ab5e..8bbf6b3f 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -19,6 +19,7 @@ interface Props{ maxHeight?: number autoWidth?: boolean align?: "center" | "start" | "end" + leftSlotType?: 'col' | 'row' } /** DropdownMenu */ @@ -42,6 +43,7 @@ const Select = forwardRef(({ maxHeight, autoWidth, align = 'start', + leftSlotType = 'col', ...rest }, ref) => { const currentOption = options.find((option) => option.value === value) || null @@ -77,7 +79,7 @@ const Select = forwardRef(({ onSelect(item.value) }} > -
+
{item.label}
{item.info &&
{item.info}
diff --git a/src/helper/config.ts b/src/helper/config.ts index d78eb6e9..a2e36b23 100644 --- a/src/helper/config.ts +++ b/src/helper/config.ts @@ -1,19 +1,40 @@ import { InterfaceModelConfig, InterfaceModelConfigMap, ModelConfig, ModelConfigMap, MultiModelConfig } from "../atoms/configState" import { InterfaceProvider, ModelProvider } from "../atoms/interfaceState" -export const formatData = (data: InterfaceModelConfig|ModelConfig): MultiModelConfig => { - const { modelProvider, model, apiKey, baseURL, active, topP, temperature, ...extra } = convertConfigToInterfaceModel(data) +export const formatData = (data: InterfaceModelConfig | ModelConfig): MultiModelConfig => { + const { + modelProvider, + model, + apiKey, + baseURL, + active, + topP, + temperature, + configuration, + checked, + name, + ...otherParams + } = convertConfigToInterfaceModel(data) + + const baseParams = { topP: topP ?? 0, temperature: temperature ?? 0 } + + const allParams = { ...baseParams, ...otherParams } + return { - ...extra, name: modelProvider, apiKey, baseURL, active: active ?? false, checked: false, models: model ? [model] : [], - topP: topP ?? 0, - temperature: temperature ?? 0, model, + temperature: temperature ?? 0, + topP: topP ?? 0, + parameters: model + ? { + [model]: allParams, + } + : {}, } } @@ -28,31 +49,45 @@ export const extractData = (data: InterfaceModelConfigMap|ModelConfigMap) => { const _index = parseInt(index) if (!providerConfigList[_index]) { - const _value: InterfaceModelConfig|ModelConfig = {...value} + const _value: InterfaceModelConfig | ModelConfig = { ...value } _value.modelProvider = name as InterfaceProvider providerConfigList[_index] = { ...formatData(_value), } - } else if(value.model) { + } else if (value.model) { + const formatData_ = formatData(value) + const allParams = formatData_.parameters[value.model] || {} providerConfigList[_index].models.push(value.model) + providerConfigList[_index].parameters = { + ...(providerConfigList[_index].parameters || {}), + [value.model]: { + ...allParams, + }, + } } }) return providerConfigList } -export const compressData = (data: MultiModelConfig, index: number) => { +export const compressData = ( + data: MultiModelConfig, + index: number, + _parameter: Record, +) => { const compressedData: Record = {} - const { models, ...restData } = data + const { models, parameters, ...restData } = data const modelsToProcess = models.length === 0 ? [null] : models modelsToProcess.forEach((model, modelIndex) => { const formData = { ...restData, model: model, - modelProvider: data.name + modelProvider: data.name, + ...(model ? parameters[model] || {} : _parameter), } - const configuration = {...formData} as Partial> & Omit + const configuration = { ...formData } as Partial> & + Omit delete configuration.configuration compressedData[`${restData.name}-${index}-${modelIndex}`] = { ...formData, diff --git a/src/helper/modelParameterUtils.ts b/src/helper/modelParameterUtils.ts new file mode 100644 index 00000000..17e97c99 --- /dev/null +++ b/src/helper/modelParameterUtils.ts @@ -0,0 +1,150 @@ +import { InterfaceProvider } from '../atoms/interfaceState' + +export interface Parameter { + name: string + type: 'int' | 'float' | 'string' | 'boolean' | '' + value: string | number | boolean + isSpecific?: boolean // if true, the parameter is a special parameter, to avoid removing it when duplicate + isDuplicate?: boolean // if true, the parameter is a duplicate parameter +} + +/** + * Initializes model parameters, adding default special parameters if necessary. + */ +export function initializeAdvancedParameters( + modelName: string, + provider: InterfaceProvider, + existingParams: Record | undefined, +): Parameter[] { + const modelParams: Parameter[] = [] + + // load existing parameters + if (existingParams) { + Object.entries(existingParams).forEach(([key, value]) => { + if (!key) return + // temperature and topP are not default parameters handled here + if (['temperature', 'topP'].includes(key)) return + + // special parameters handling, transform to custom parameters structure list + if (key === 'thinking') { + const thinking = value as any + if (thinking.type === 'enabled' && thinking.budget_tokens !== undefined) { + modelParams.push({ + name: 'budget_tokens', + type: 'int', + value: thinking.budget_tokens, + isSpecific: true, + }) + } + return + } + // Handle disable_streaming specifically if it exists + if (key === 'disable_streaming') { + modelParams.push({ + name: 'disable_streaming', + type: 'boolean', + value: typeof value === 'boolean' ? value : false, // Ensure boolean + isSpecific: true, + }) + return + } + + const paramType = + typeof value === 'string' + ? 'string' + : typeof value === 'number' + ? Number.isInteger(value) + ? 'int' + : 'float' + : '' // Default or unknown type + if (paramType) { + modelParams.push({ + name: key, + type: paramType as 'int' | 'float' | 'string', + value: value as any, + isSpecific: ['reasoning_effort', 'budget_tokens', 'disable_streaming'].includes(key), + }) + } + }) + } + + // Add default reasoning_effort for o3-mini if needed + if ( + modelName.includes('o3-mini') && + provider === 'openai' && + !modelParams.some((p) => p.name === 'reasoning_effort') + ) { + modelParams.push({ name: 'reasoning_effort', type: 'string', value: 'low', isSpecific: true }) + } + + // Add default budget_tokens for claude-3-7 if needed + if ( + modelName.includes('claude-3-7') && // Assuming this is the correct check string + (provider === 'anthropic' || provider === 'bedrock') && + !modelParams.some((p) => p.name === 'budget_tokens') + ) { + modelParams.push({ name: 'budget_tokens', type: 'int', value: 1024, isSpecific: true }) + } + + // Ensure disable_streaming parameter exists + if (!modelParams.some((p) => p.name === 'disable_streaming')) { + modelParams.push({ name: 'disable_streaming', type: 'boolean', value: false, isSpecific: true }) + } + + return modelParams +} + +/** + * Formats the parameters array into a record suitable for saving, applying type conversions and specific transformations. + */ +export function formatParametersForSave(parameters: Parameter[]): Record { + const finalParameters: Record = {} + parameters.forEach((param) => { + if (!param.name || param.type === '' || param.value === '') return // Allow boolean false for disable_streaming + let value = param.value + let name = param.name + + switch (param.type) { + case 'int': + value = parseInt(String(value), 10) + if (isNaN(value)) value = 0 // Default to 0 if parsing fails + if (value < 0) value = 0 + if (value > 1000000) value = 1000000 + + if (param.name === 'budget_tokens') { + if (value < 1024) value = 1024 + if (value > 4096) value = 4096 + } + break + case 'float': + value = parseFloat(String(value)) + if (isNaN(value)) value = 0.0 // Default to 0.0 if parsing fails + if (value < 0) value = 0 + if (value > 1.0) value = 1.0 + break + case 'boolean': + // Already handled by Parameter type, ensure it's boolean + value = typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true' + break + case 'string': + default: + value = String(value) + break + } + + // Special handling for budget_tokens -> thinking object + if (param.name === 'budget_tokens') { + name = 'thinking' + const value_ = value as number + value = { + type: 'enabled', + budget_tokens: value_, + } as any + } + + // Assign value if not handled specifically above + finalParameters[name] = value + }) + + return finalParameters +} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index be9d5ec3..600413e1 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -304,6 +304,16 @@ $z-sidebar: 200; // slot .left-slot{ margin: 0; + + &.row{ + display: flex; + align-items: center; + gap: 6px; + + .info{ + margin-top: 0; + } + } } .right-slot{ line-height: 0; diff --git a/src/styles/components/Popup/_AdvancedSetting.scss b/src/styles/components/Popup/_AdvancedSetting.scss new file mode 100644 index 00000000..cd917624 --- /dev/null +++ b/src/styles/components/Popup/_AdvancedSetting.scss @@ -0,0 +1,373 @@ +@use '../../_variables' as *; + +.models-key-popup { + &.parameters { + width: 100%; + gap: 0; + } +} + +.model-parameters-popup { + width: 60vw; + height: 65vh; + + .verify-status-container { + font-size: 16px; + margin-top: 16px; + width: 100%; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 5px; + color: var(--text-pri-green); + + &.error { + color: var(--text-error); + } + } + + .confirm-btn { + &:disabled { + background-color: var(--bg-op-dark-mediumweak) !important; + + &:hover { + background-color: var(--bg-op-dark-mediumweak) !important; + } + } + } +} + +.model-custom-parameters { + flex: 1; + display: flex; + flex-direction: column; + + .parameters-list { + max-height: 100%; + width: 100%; + display: flex; + flex-direction: column; + gap: 15px; + margin-top: 5px; + + .left-slot{ + display: flex; + } + + .item { + width: 100%; + display: flex; + flex-direction: column; + gap: 15px; + align-items: flex-start; + padding: 15px; + border-radius: 8px; + border: 1px solid var(--border-weak); + position: relative; + + &:hover { + .btn-delete { + display: flex; + } + } + + .btn-delete { + display: none; + position: absolute; + top: 13px; + right: 13px; + align-items: center; + justify-content: center; + background: var(--bg-op-dark-ultraweak); + border-radius: 50%; + padding: 4px; + cursor: pointer; + + svg { + color: var(--stroke-dark-medium); + } + + &:hover { + background: var(--bg-op-dark-mediumweak); + } + } + + .name { + display: flex; + align-items: flex-start; + gap: 5px; + font-size: 14px; + color: var(--text-weak); + + label { + padding: 6px 0; + } + + input.error { + border-color: var(--text-error); + } + } + + .select-button { + background: var(--bg-input); + } + + .error-message { + color: var(--text-error); + font-size: 14px; + margin-top: 5px; + } + + .row { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 16px; + } + + .type, + .value { + display: flex; + align-items: center; + gap: 5px; + font-size: 14px; + color: var(--text-weak); + } + + .value { + flex: 1; + } + + label { + flex-shrink: 0; + display: block; + font-size: 14px; + margin-bottom: 5px; + color: var(--text-weak); + } + } + } +} + +.token-budget-slider { + width: 100%; + padding: 0 10px; + margin-top: 10px; + + input { + padding: 0 !important; + border: none !important; + + &:focus { + outline: none !important; + border-color: transparent !important; + box-shadow: none !important; + } + } + + .range-values { + display: flex; + justify-content: space-between; + font-size: 14px; + color: var(--text-weak); + margin-bottom: 8px; + } + + .slider { + -webkit-appearance: none; + width: 100%; + height: 6px; + border-radius: 5px; + background: var(--bg-op-dark-mediumweak); + outline: none; + cursor: pointer; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bg-pri-blue); + cursor: pointer; + border: 2px solid white; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); + } + + &::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bg-pri-blue); + cursor: pointer; + border: 2px solid white; + box-shadow: 0 0 2px rgba(0, 0, 0, 0.2); + } + } +} + +.reasoning-level-btn-group { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0 16px; + gap: 10px; + + .btn { + flex: 1; + padding: 10px 16px; + border-radius: 8px; + background: var(--bg); + border: 1px solid var(--stroke-dark-weak); + box-shadow: 0 3px 7px var(--shadow-btn-cancel); + + outline: none; + + &:hover, + &.active { + background: var(--bg-op-dark-ultraweak); + border: 1px solid transparent; + } + } +} + +.non-streaming { + .non-streaming-switch { + width: 100%; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 20px; + gap: 10px; + } + + .non-streaming-alert { + display: none; + align-items: flex-start; + gap: 8px; + padding: 12px; + background-color: var(--bg-alert); + border-radius: 6px; + color: var(--text-alert); + + svg { + flex-shrink: 0; + margin-top: 2px; + } + + .alert-content { + font-size: 14px; + line-height: 1.4; + } + + &.visible { + display: flex; + } + } +} + +.special-parameter { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 10px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-weak); + + &.start { + align-items: flex-start; + } + + .content { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 15px; + } + + .title { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; + font-size: 20px; + color: var(--text); + + .title-left { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + } + + .description { + font-size: 16px; + color: var(--text-weak); + } + + &.align-top { + padding: 15px 0; + } + + .parameter-label { + display: flex; + align-items: center; + gap: 8px; + + svg { + color: var(--stroke-dark-medium); + } + } + } + + .body { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + } +} + +.add-custom-parameter { + padding: 10px 0; + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 30px; + + .title { + font-weight: 500; + font-size: 20px; + color: var(--text); + } + + .btn { + padding: 14px 23px; + border-radius: 5px; + border-style: dashed; + border: 1px dashed var(--stroke-dark-medium); + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + font-size: 14px; + background-color: var(--bg-input); + white-space: nowrap; + outline: none; + + &:hover { + background-color: var(--bg-op-dark-ultraweak); + } + } +} + diff --git a/src/styles/components/_PopupConfirm.scss b/src/styles/components/_PopupConfirm.scss index e3e72afb..30ef73cc 100644 --- a/src/styles/components/_PopupConfirm.scss +++ b/src/styles/components/_PopupConfirm.scss @@ -60,9 +60,11 @@ display: flex; justify-content: center; align-items: center; + min-height: 0; } .popup-confirm-footer { + flex-shrink: 0; padding: 20px 16px; display: flex; justify-content: flex-end; diff --git a/src/styles/index.scss b/src/styles/index.scss index 2171c197..5781b744 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,5 +1,6 @@ @use "variables" as *; +@use "./components/Popup/AdvancedSetting"; @use "./components/Header"; @use "./components/ModelConfig"; @use "./components/HistorySidebar"; diff --git a/src/styles/overlay/_Model.scss b/src/styles/overlay/_Model.scss index e6b5e10a..5c17898b 100644 --- a/src/styles/overlay/_Model.scss +++ b/src/styles/overlay/_Model.scss @@ -426,6 +426,7 @@ flex-direction: column; gap: 12px; padding: 0 30px; + height: 100%; &.edit{ padding: 20px 86px 0 62px; @@ -438,10 +439,32 @@ } } + .header { + font-weight: 500; + font-size: 24px; + line-height: 100%; + display: flex; + align-items: center; + gap: 10px; + justify-content: flex-start; + color: var(--text); + padding: 20px 0 36px 0; + border-bottom: 1px solid var(--border-weak); + } + + .body{ + flex: 1; + display: flex; + flex-direction: column; + overflow: auto; + @include scrollbar; + } + .models-key-form-group { display: flex; flex-direction: column; gap: 6px; + height: 100%; .models-key-field-title { font-weight: 700; @@ -518,7 +541,7 @@ .model-popup { width: 900px; - height: 500px; + height: 65vh; .popup-confirm-content .loading-spinner { width: 50px; @@ -1018,90 +1041,3 @@ background: var(--bg-op-dark-ultraweak); } } - -.model-setting-popup { - .model-popup-content { - flex: 1; - padding: 0 20px; - - .model-setting-content { - height: 100%; - width: 100%; - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - gap: 24px; - - .model-popup-title { - font-size: 18px; - font-weight: 700; - margin-bottom: 8px; - } - - .model-setting-option { - width: 100%; - display: flex; - flex-direction: column; - gap: 6px; - font-size: 16px; - - .model-setting-option-header { - display: flex; - justify-content: space-between; - align-items: center; - - .label { - font-weight: 500; - font-size: 20px; - display: flex; - align-items: center; - gap: 5px; - } - - .parameter-label { - display: flex; - align-items: center; - gap: 8px; - - svg { - color: var(--stroke-dark-medium); - } - } - } - - .model-setting-option-description { - font-size: 14px; - color: var(--text-weak); - } - - .model-setting-alert { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 12px; - background-color: var(--bg-alert); - border-radius: 6px; - color: var(--text-alert); - visibility: hidden; - - svg { - flex-shrink: 0; - margin-top: 2px; - } - - .alert-content { - font-size: 14px; - line-height: 1.4; - } - - &.visible { - visibility: visible; - } - } - } - } - } -} - - diff --git a/src/views/Overlay/Model/ModelVerify.tsx b/src/views/Overlay/Model/ModelVerify.tsx index 5f884b86..e46796d7 100644 --- a/src/views/Overlay/Model/ModelVerify.tsx +++ b/src/views/Overlay/Model/ModelVerify.tsx @@ -7,7 +7,7 @@ export interface ModelVerifyDetail { detail?: Record } -export type ModelVerifyStatus = "verifying" | "abort" | "ignore" | "success" | "unSupportTool" | "unSupportModel" | "unVerified" +export type ModelVerifyStatus = "verifying" | "abort" | "ignore" | "success" | "unSupportTool" | "unSupportModel" | "unVerified" | "error" export const useModelVerify = () => { const localListOptions = localStorage.getItem("modelVerify") @@ -64,12 +64,17 @@ export const useModelVerify = () => { onUpdate?.(detail.current) }) .catch(error => { + const _detail = [...detail.current] + const _detailItem = _detail.find(item => item.name === _value.model)! if (error.name === 'AbortError') { - const _detail = [...detail.current] - _detail.find(item => item.name === _value.model)!.status = "abort" - detail.current = _detail - onUpdate?.(detail.current) + _detailItem.status = "abort" + } + else{ + _detailItem.status = "error" + _detailItem.detail = error.message } + detail.current = _detail + onUpdate?.(detail.current) }) .finally(() => { controllers.current.delete(controller) diff --git a/src/views/Overlay/Model/ModelsProvider.tsx b/src/views/Overlay/Model/ModelsProvider.tsx index 54c5c359..83b08f51 100644 --- a/src/views/Overlay/Model/ModelsProvider.tsx +++ b/src/views/Overlay/Model/ModelsProvider.tsx @@ -160,8 +160,7 @@ export default function ModelsProvider({ const _multiModelConfigList = await getMultiModelConfigList() const _parameter = await getParameter() _multiModelConfigList.forEach((multiModelConfig, index) => { - multiModelConfig = Object.assign(multiModelConfig, _parameter) - compressedData = Object.assign(compressedData, compressData(multiModelConfig, index)) + compressedData = Object.assign(compressedData, compressData(multiModelConfig, index, _parameter)) }) Object.entries(compressedData).forEach(([key, value]) => { if (value !== undefined) { diff --git a/src/views/Overlay/Model/Popup/AdvancedSetting.tsx b/src/views/Overlay/Model/Popup/AdvancedSetting.tsx index 38badde3..ab874782 100644 --- a/src/views/Overlay/Model/Popup/AdvancedSetting.tsx +++ b/src/views/Overlay/Model/Popup/AdvancedSetting.tsx @@ -1,119 +1,543 @@ -import clsx from "clsx"; -import { Dispatch, SetStateAction, useState } from "react"; -import { useTranslation } from "react-i18next"; -import InfoTooltip from "../../../../components/InfoTooltip"; -import PopupConfirm from "../../../../components/PopupConfirm"; -import Switch from "../../../../components/Switch"; - -const AdvancedSettingPopup = ({ - modelName, - isStreamingMode_, - onClose, -}: { - modelName: string; - isStreamingMode_: boolean; - onClose: () => void; -}) => { - const { t } = useTranslation(); - const [isStreamingMode, setIsStreamingMode] = - useState(isStreamingMode_); +import { useAtom } from 'jotai' +import { RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { InterfaceProvider } from '../../../../atoms/interfaceState' +import { showToastAtom } from '../../../../atoms/toastState' +import PopupConfirm from '../../../../components/PopupConfirm' +import Select from '../../../../components/Select' +import Tooltip from '../../../../components/Tooltip' +import WrappedInput from '../../../../components/WrappedInput' +import { compressData } from '../../../../helper/config' +import { + formatParametersForSave, + initializeAdvancedParameters, + Parameter, +} from '../../../../helper/modelParameterUtils' +import { useModelsProvider } from '../ModelsProvider' +import { ModelVerifyDetail, useModelVerify } from '../ModelVerify' +import NonStreamingParameter from './SpecialParameters/NonStreaming' +import ReasoningLevelParameter from './SpecialParameters/ReasoningLevel' +import TokenBudgetParameter from './SpecialParameters/TokenBudget' + +interface AdvancedSettingPopupProps { + modelName: string + onClose: () => void + onSave?: () => void +} + +const AdvancedSettingPopup = ({ modelName, onClose, onSave }: AdvancedSettingPopupProps) => { + const { t } = useTranslation() + const [, showToast] = useAtom(showToastAtom) + const { + parameter, + multiModelConfigList = [], + currentIndex, + setMultiModelConfigList, + } = useModelsProvider() + const { verify, abort } = useModelVerify() + + const [parameters, setParameters] = useState([]) + const [provider, setProvider] = useState('openai') + const isVerifying = useRef(false) + const [isVerifySuccess, setIsVerifySuccess] = useState(false) + const [verifyStatus, setVerifyStatus] = useState('') + const [verifyDetail, setVerifyDetail] = useState('') + const bodyRef = useRef(null) + const isAddParameter = useRef(false) + const prevParamsLength = useRef(0) + // load parameters of current model + useEffect(() => { + const currentModelProvider = multiModelConfigList[currentIndex] + if (!currentModelProvider) return + + const provider = currentModelProvider.name + const existingParams = currentModelProvider.parameters[modelName] + + // Use the utility function to initialize parameters + const initializedParams = initializeAdvancedParameters(modelName, provider, existingParams) + + setParameters(initializedParams) + setProvider(provider) + }, [parameter, multiModelConfigList, currentIndex, modelName]) // Added modelName dependency + + // integrate parameters config to current ModelConfig (not write just format) + const integrateParametersConfig = () => { + if (!multiModelConfigList || multiModelConfigList.length <= 0) { + return [] + } + + // Use the utility function to format parameters + const finalParameters = formatParametersForSave(parameters) + + const updatedModelConfigList = [...multiModelConfigList] + updatedModelConfigList[currentIndex] = { + ...updatedModelConfigList[currentIndex], + parameters: { + ...updatedModelConfigList[currentIndex].parameters, + [modelName]: finalParameters, + }, + } + return updatedModelConfigList + } + + const handleParameterTypeChange = (type: 'int' | 'float' | 'string', index?: number) => { + if (index == undefined || index < 0) return + const updatedParameters = [...parameters] + updatedParameters[index].type = type + setParameters(updatedParameters) + } + const handleParameterValueChange = (value: string | number | boolean, index?: number) => { + // Added boolean type + if (index == undefined || index < 0) return + const updatedParameters = [...parameters] + updatedParameters[index].value = value + setParameters(updatedParameters) + } - const handleStreamingModeChange = ( - e: React.ChangeEvent - ) => { - setIsStreamingMode(e.target.checked); - }; + const handleParameterNameChange = (value: string, index?: number) => { + if (index == undefined || index < 0) return + const updatedParameters = [...parameters] + updatedParameters[index].name = value + // Check for duplicates ignoring the current parameter being edited + const duplicateExists = parameters.some((p, i) => p.name === value && i !== index) + updatedParameters[index].isDuplicate = duplicateExists + // Also update duplicate status of other parameters with the same name + setParameters( + updatedParameters.map((p, i) => { + if (i !== index && p.name === value) { + return { ...p, isDuplicate: true } + } else if (p.name === value && !duplicateExists) { + // If the edited one is no longer a duplicate source, reset others + return { ...p, isDuplicate: false } + } + // Check if previously duplicated names are now unique + const wasDuplicate = parameters.filter((param) => param.name === p.name).length > 1 + const isNowUnique = updatedParameters.filter((param) => param.name === p.name).length <= 1 + if (wasDuplicate && isNowUnique) { + return { ...p, isDuplicate: false } + } + return p + }), + ) + } + + const handleAddParameter = () => { + isAddParameter.current = true + setParameters([...parameters, { name: '', type: '', value: '' }]) + } + useLayoutEffect(() => { + if (!isAddParameter.current) return + if (parameters.length > prevParamsLength.current && bodyRef.current) { + const parameterItems = bodyRef.current.querySelectorAll( + '.model-custom-parameters .parameters-list .item', + ) + if (parameterItems.length > 0) { + const lastItem = parameterItems[parameterItems.length - 1] + const nameInput = lastItem?.querySelector('.name input[type="text"]') as HTMLInputElement + nameInput && nameInput.focus() + lastItem?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + } + prevParamsLength.current = parameters.length + isAddParameter.current = false + }, [parameters.length]) + + const handleDeleteParameter = (index: number) => { + // careful, if the parameter is specific, don't delete it + if (parameters[index].isSpecific) { + // Maybe show a toast or message indicating it cannot be deleted? + console.warn(`Parameter "${parameters[index].name}" is specific and cannot be deleted.`) + return + } + const updatedParameters = [...parameters] + const deletedParamName = updatedParameters[index].name + updatedParameters.splice(index, 1) + + // After deleting, check if the name that was deleted still has duplicates + const remainingWithSameName = updatedParameters.filter((p) => p.name === deletedParamName) + if (remainingWithSameName.length === 1) { + // If only one remains, it's no longer a duplicate + const indexOfRemaining = updatedParameters.findIndex((p) => p.name === deletedParamName) + if (indexOfRemaining !== -1) { + updatedParameters[indexOfRemaining].isDuplicate = false + } + } + setParameters(updatedParameters) + } const handleClose = () => { - onClose(); - }; + onClose() + } + + const handleSave = async () => { + const integratedParametersConfig = integrateParametersConfig() + if (integratedParametersConfig.length <= 0) { + return + } + setMultiModelConfigList(integratedParametersConfig) + + if (onSave) { + onSave() + } + onClose() + } + + // verify current model setting if work + const onVerifyConfirm = async () => { + isVerifying.current = true + setVerifyStatus(t('setup.verifying')) + setVerifyDetail('') + + const integratedParametersConfig = integrateParametersConfig() + if (integratedParametersConfig.length <= 0) { + isVerifying.current = false + setVerifyStatus(t('setup.verifyFailed')) + setVerifyDetail('No model config to verify') + return + } + integratedParametersConfig[currentIndex].models = [modelName] + const compressedData = compressData( + integratedParametersConfig[currentIndex], + currentIndex, + parameter, + ) + + const _needVerifyList = compressedData + + // verify complete callback + const onComplete = async () => { + isVerifying.current = false + setIsVerifySuccess(true) + } + + // update status callback + const onUpdate = (detail: ModelVerifyDetail[]) => { + const _detail = detail.find((item) => item.name == modelName) + if (_detail) { + setVerifyStatus( + _detail.status === 'success' + ? t('setup.verifySuccess') + : _detail.status === 'error' + ? t('setup.verifyError') + : t('setup.verifying'), + ) + if (!_detail.detail?.['connectingSuccess']) { + setVerifyDetail(_detail.detail?.['connectingResult'] || '') + } else if (!_detail.detail?.['supportTools']) { + setVerifyDetail(_detail.detail?.['supportToolsResult'] || '') + } + } + } + + // abort verify callback + const onAbort = () => { + setIsVerifySuccess(false) + } + + verify(_needVerifyList, onComplete, onUpdate, onAbort) + } - const handleSave = () => { - onClose(); - }; + useEffect(() => { + if (bodyRef.current && (verifyDetail || verifyStatus)) { + bodyRef.current.scrollTo({ + top: bodyRef.current.scrollHeight, + // behavior: 'smooth' + }) + } + }, [verifyStatus, verifyDetail]) + + const handleCopiedError = async (text: string) => { + await navigator.clipboard.writeText(text) + showToast({ + message: t('toast.copiedToClipboard'), + type: 'success', + }) + } return ( p.isDuplicate)} + footerHint={} > -
-
-
- {t("models.modelSetting", { name: modelName })} -
+
+
+
{t('models.modelSetting', { name: modelName })}
+ +
+ {/* Streaming Mode Area */} + + + {/* Special Parameters Area */} + {SpecialParameters({ provider, modelName, parameters, setParameters })} + + {/* Custom Input Header */} +
+
+ +
+ +
+ + {/* Custom Input Parameters List */} +
+
+ {parameters.map((param, index) => { + if ( + param.name === 'reasoning_effort' || + param.name === 'budget_tokens' || + param.name === 'disable_streaming' + ) { + return null + } + return ( +
+
handleDeleteParameter(index)}> + + + + + + + +
+
+ +
+ handleParameterNameChange(e.target.value, index)} + /> + {param.isDuplicate && ( +
+ {t('models.parameterNameDuplicate')} +
+ )} +
+
+
+
+ + handleTokenBudgetChange(e)} + className="slider" + /> +
+ {min} + {max} +
+
+
+
+
+ ) +} + +export default TokenBudgetParameter diff --git a/src/views/Overlay/Model/Popup/index.tsx b/src/views/Overlay/Model/Popup/index.tsx index 8447a8cd..7e9d3a28 100644 --- a/src/views/Overlay/Model/Popup/index.tsx +++ b/src/views/Overlay/Model/Popup/index.tsx @@ -350,23 +350,39 @@ const ModelPopup = ({ const status = option.verifyStatus ?? "unVerified"; const menu = []; - // menu.push({ - // label: ( - //
- // - // - // - // - // - // - // {t("models.verifyMenu0")} - //
- // ), - // onClick: () => { - // setSelectedModel(option.name); - // setShowAdvancedSetting(true); - // }, - // }); + menu.push({ + label: ( +
+ + + + + + + {t('models.verifyMenu0')} +
+ ), + onClick: () => { + setSelectedModel(option.name); + setShowAdvancedSetting(true); + }, + }); // verify model if(status !== "success"){ @@ -491,8 +507,11 @@ const ModelPopup = ({ {showAdvancedSetting && ( { + setShowAdvancedSetting(false); + setSelectedModel(''); + }} + onSave={() => { setShowAdvancedSetting(false); setSelectedModel(""); }}