Files
cherry-studio/src/renderer/src/components/ModelList/ModelEditContent.tsx
T
SuYao 65257eb3d5 Fix/qwen-mt (#8527)
* feat(ModelList): add support for 'supported_text_delta' in model configuration

- Introduced a new boolean property 'supported_text_delta' to the model configuration, allowing models to indicate support for text delta outputs.
- Updated the AddModelPopup and ModelEditContent components to handle this new property, including UI elements for user interaction.
- Enhanced migration logic to set default values for existing models based on compatibility checks.
- Added corresponding translations for the new property in the i18n files.

* feat(OpenAIApiClient): enhance support for Qwen MT model and system message handling

- Added support for Qwen MT model in OpenAIApiClient, including translation options based on target language.
- Updated system message handling to accommodate models that do not support system messages.
- Introduced utility functions to identify Qwen MT models and their compatibility with text delta outputs.
- Enhanced TextChunkMiddleware to handle text accumulation based on model capabilities.
- Updated model configuration to include Qwen MT in the list of excluded models for function calling.

* feat(i18n): add translations for 'supported_text_delta' in multiple languages

- Introduced new translation entries for the 'supported_text_delta' property in English, Japanese, Russian, and Traditional Chinese localization files.
- Updated the corresponding labels and tooltips to enhance user experience across different languages.

* refactor(ModelEditContent): reposition 'supported_text_delta' input for improved UI layout

- Moved the 'supported_text_delta' Form.Item from its previous location to enhance the user interface and maintain consistency in the model editing experience.
- Ensured that the switch input for 'supported_text_delta' is now displayed in a more logical order within the form.

* fix(TextChunkMiddleware): update condition for supported_text_delta check

- Changed the condition in TextChunkMiddleware to explicitly check for 'supported_text_delta' being false, improving clarity in the logic.
- Updated test cases to reflect the new structure of model configurations, ensuring 'supported_text_delta' is consistently set to true for relevant models.

* feat(migrate): add support for 'supported_text_delta' in assistant models

- Updated migration logic to set 'supported_text_delta' for both default and specific models within assistants.
- Implemented checks to ensure compatibility with text delta outputs, enhancing model configuration consistency.

* feat(ModelList): add 'supported_text_delta' to model addition logic

- Enhanced the model addition process in EditModelsPopup, NewApiAddModelPopup, and NewApiBatchAddModelPopup to include the 'supported_text_delta' property.
- Ensured consistency across components by setting 'supported_text_delta' to true when adding models, improving compatibility with text delta outputs.

* feat(migrate): streamline model text delta support in migration logic

- Refactored migration logic to utilize a new helper function for updating the 'supported_text_delta' property across various model types, enhancing code clarity and reducing redundancy.
- Ensured that all relevant models, including those in assistants and LLM providers, are correctly configured for text delta compatibility during migration.

* feat(OpenAIApiClient): integrate language mapping for Qwen MT model translations

- Updated the OpenAIApiClient to utilize a new utility function for mapping target languages to Qwen MT model names, enhancing translation accuracy.
- Refactored migration logic to ensure default tool use mode is set for assistants lacking this configuration, improving user experience.
- Added a new utility function for language mapping in the utils module, supporting better integration with translation features.

* feat(ModelList): update model addition logic to determine 'supported_text_delta'

- Integrated a new utility function to assess model compatibility with text delta outputs during the model addition process in AddModelPopup.
- Simplified the logic for setting the 'supported_text_delta' property, enhancing clarity and ensuring accurate model configuration.

* feat(ModelList): unify model addition logic for 'supported_text_delta'

- Refactored model addition across AddModelPopup, EditModelsPopup, NewApiAddModelPopup, and NewApiBatchAddModelPopup to consistently determine 'supported_text_delta' using a utility function.
- Simplified the logic for setting 'supported_text_delta', enhancing clarity and ensuring accurate model configuration across components.
2025-07-26 17:33:46 +08:00

416 lines
17 KiB
TypeScript

import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { endpointTypeOptions } from '@renderer/config/endpointTypes'
import {
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isRerankModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { useDynamicLabelWidth } from '@renderer/hooks/useDynamicLabelWidth'
import { Model, ModelCapability, ModelType, Provider } from '@renderer/types'
import { getDefaultGroupName, getDifference, getUnion } from '@renderer/utils'
import { Button, Checkbox, Divider, Flex, Form, Input, InputNumber, message, Modal, Select, Switch } from 'antd'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ModelEditContentProps {
provider: Provider
model: Model
onUpdateModel: (model: Model) => void
open: boolean
onClose: () => void
}
const symbols = ['$', '¥', '€', '£']
const ModelEditContent: FC<ModelEditContentProps> = ({ provider, model, onUpdateModel, open, onClose }) => {
const [form] = Form.useForm()
const { t } = useTranslation()
const [showMoreSettings, setShowMoreSettings] = useState(false)
const [currencySymbol, setCurrencySymbol] = useState(model.pricing?.currencySymbol || '$')
const [isCustomCurrency, setIsCustomCurrency] = useState(!symbols.includes(model.pricing?.currencySymbol || '$'))
const [modelCapabilities, setModelCapabilities] = useState(model.capabilities || [])
const [supportedTextDelta, setSupportedTextDelta] = useState(model.supported_text_delta)
const labelWidth = useDynamicLabelWidth([t('settings.models.add.endpoint_type.label')])
const onFinish = (values: any) => {
const finalCurrencySymbol = isCustomCurrency ? values.customCurrencySymbol : values.currencySymbol
const updatedModel: Model = {
...model,
id: values.id || model.id,
name: values.name || model.name,
group: values.group || model.group,
endpoint_type: provider.id === 'new-api' ? values.endpointType : model.endpoint_type,
capabilities: modelCapabilities,
supported_text_delta: supportedTextDelta,
pricing: {
input_per_million_tokens: Number(values.input_per_million_tokens) || 0,
output_per_million_tokens: Number(values.output_per_million_tokens) || 0,
currencySymbol: finalCurrencySymbol || '$'
}
}
onUpdateModel(updatedModel)
setShowMoreSettings(false)
onClose()
}
const handleClose = () => {
setShowMoreSettings(false)
setModelCapabilities(model.capabilities || [])
onClose()
}
const currencyOptions = [
...symbols.map((symbol) => ({ label: symbol, value: symbol })),
{ label: t('models.price.custom'), value: 'custom' }
]
return (
<Modal
title={t('models.edit')}
open={open}
onCancel={handleClose}
footer={null}
transitionName="animation-move-down"
centered
afterOpenChange={(visible) => {
if (visible) {
form.getFieldInstance('id')?.focus()
} else {
setShowMoreSettings(false)
}
}}>
<Form
form={form}
labelCol={{ flex: provider.id === 'new-api' ? labelWidth : '110px' }}
labelAlign="left"
colon={false}
style={{ marginTop: 15 }}
initialValues={{
id: model.id,
name: model.name,
group: model.group,
endpointType: model.endpoint_type,
input_per_million_tokens: model.pricing?.input_per_million_tokens ?? 0,
output_per_million_tokens: model.pricing?.output_per_million_tokens ?? 0,
currencySymbol: symbols.includes(model.pricing?.currencySymbol || '$')
? model.pricing?.currencySymbol || '$'
: 'custom',
customCurrencySymbol: symbols.includes(model.pricing?.currencySymbol || '$')
? ''
: model.pricing?.currencySymbol || ''
}}
onFinish={onFinish}>
<Form.Item
name="id"
label={t('settings.models.add.model_id.label')}
tooltip={t('settings.models.add.model_id.tooltip')}
rules={[{ required: true }]}>
<Flex justify="space-between" gap={5}>
<Input
placeholder={t('settings.models.add.model_id.placeholder')}
spellCheck={false}
maxLength={200}
disabled={true}
value={model.id}
onChange={(e) => {
const value = e.target.value
form.setFieldValue('name', value)
form.setFieldValue('group', getDefaultGroupName(value))
}}
/>
<Button
onClick={() => {
//copy model id
const val = form.getFieldValue('name')
navigator.clipboard.writeText((val.id || model.id) as string)
message.success(t('message.copied'))
}}>
<CopyIcon /> {t('chat.topics.copy.title')}
</Button>
</Flex>
</Form.Item>
<Form.Item
name="name"
label={t('settings.models.add.model_name.label')}
tooltip={t('settings.models.add.model_name.tooltip')}>
<Input placeholder={t('settings.models.add.model_name.placeholder')} spellCheck={false} />
</Form.Item>
<Form.Item
name="group"
label={t('settings.models.add.group_name.label')}
tooltip={t('settings.models.add.group_name.tooltip')}>
<Input placeholder={t('settings.models.add.group_name.placeholder')} spellCheck={false} />
</Form.Item>
{provider.id === 'new-api' && (
<Form.Item
name="endpointType"
label={t('settings.models.add.endpoint_type.label')}
tooltip={t('settings.models.add.endpoint_type.tooltip')}
rules={[{ required: true, message: t('settings.models.add.endpoint_type.required') }]}>
<Select placeholder={t('settings.models.add.endpoint_type.placeholder')}>
{endpointTypeOptions.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>
{t(opt.label)}
</Select.Option>
))}
</Select>
</Form.Item>
)}
<Form.Item style={{ marginBottom: 8, textAlign: 'center' }}>
<Flex justify="space-between" align="center" style={{ position: 'relative' }}>
<Button
color="default"
variant="filled"
icon={showMoreSettings ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
iconPosition="end"
onClick={() => setShowMoreSettings(!showMoreSettings)}
style={{ color: 'var(--color-text-3)' }}>
{t('settings.moresetting.label')}
</Button>
<Button type="primary" htmlType="submit" size="middle">
{t('common.save')}
</Button>
</Flex>
</Form.Item>
{showMoreSettings && (
<div style={{ marginBottom: 8 }}>
<Divider style={{ margin: '16px 0 16px 0' }} />
<TypeTitle>{t('models.type.select')}:</TypeTitle>
{(() => {
const defaultTypes = [
...(isVisionModel(model) ? ['vision'] : []),
...(isReasoningModel(model) ? ['reasoning'] : []),
...(isFunctionCallingModel(model) ? ['function_calling'] : []),
...(isWebSearchModel(model) ? ['web_search'] : []),
...(isEmbeddingModel(model) ? ['embedding'] : []),
...(isRerankModel(model) ? ['rerank'] : [])
]
// 合并现有选择和默认类型用于前端展示
const selectedTypes = getUnion(
modelCapabilities?.filter((t) => t.isUserSelected).map((t) => t.type) || [],
getDifference(
defaultTypes,
modelCapabilities?.filter((t) => t.isUserSelected === false).map((t) => t.type) || []
)
)
const isDisabled = selectedTypes.includes('rerank') || selectedTypes.includes('embedding')
const isRerankDisabled = selectedTypes.includes('embedding')
const isEmbeddingDisabled = selectedTypes.includes('rerank')
const showTypeConfirmModal = (newCapability: ModelCapability) => {
const onUpdateType = selectedTypes?.find((t) => t === newCapability.type)
window.modal.confirm({
title: t('settings.moresetting.warn'),
content: t('settings.moresetting.check.warn'),
okText: t('settings.moresetting.check.confirm'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true },
cancelButtonProps: { type: 'primary' },
onOk: () => {
if (onUpdateType) {
const updatedTypes = selectedTypes?.map((t) => {
if (t === newCapability.type) {
return { type: t, isUserSelected: true }
}
if (
(onUpdateType !== t && onUpdateType === 'rerank') ||
(onUpdateType === 'embedding' && onUpdateType !== t)
) {
return { type: t, isUserSelected: false }
}
return { type: t }
})
setModelCapabilities(updatedTypes as ModelCapability[])
} else {
const updatedTypes = selectedTypes?.map((t) => {
if (
(newCapability.type !== t && newCapability.type === 'rerank') ||
(newCapability.type === 'embedding' && newCapability.type !== t)
) {
return { type: t, isUserSelected: false }
}
return { type: t }
})
setModelCapabilities([...(updatedTypes as ModelCapability[]), newCapability])
}
},
onCancel: () => {},
centered: true
})
}
const handleTypeChange = (types: string[]) => {
const diff = types.length > selectedTypes.length
if (diff) {
const newCapability = getDifference(types, selectedTypes) // checkbox的特性,确保了newCapability只有一个元素
showTypeConfirmModal({
type: newCapability[0] as ModelType,
isUserSelected: true
})
} else {
const disabledTypes = getDifference(selectedTypes, types)
const onUpdateType = modelCapabilities?.find((t) => t.type === disabledTypes[0])
if (onUpdateType) {
const updatedTypes = modelCapabilities?.map((t) => {
if (t.type === disabledTypes[0]) {
return { ...t, isUserSelected: false }
}
if (
(onUpdateType !== t && onUpdateType.type === 'rerank') ||
(onUpdateType.type === 'embedding' && onUpdateType !== t && t.isUserSelected === false)
) {
return { ...t, isUserSelected: true }
}
return t
})
setModelCapabilities(updatedTypes || [])
} else {
const updatedTypes = modelCapabilities?.map((t) => {
if (
(disabledTypes[0] === 'rerank' && t.type !== 'rerank') ||
(disabledTypes[0] === 'embedding' && t.type !== 'embedding' && t.isUserSelected === false)
) {
return { ...t, isUserSelected: true }
}
return t
})
setModelCapabilities([
...(updatedTypes ?? []),
{ type: disabledTypes[0] as ModelType, isUserSelected: false }
])
}
}
}
const handleResetTypes = () => {
setModelCapabilities([])
}
return (
<div>
<Flex justify="space-between" align="center" style={{ marginBottom: 8 }}>
<Checkbox.Group
value={selectedTypes}
onChange={handleTypeChange}
options={[
{
label: t('models.type.vision'),
value: 'vision',
disabled: isDisabled
},
{
label: t('models.type.websearch'),
value: 'web_search',
disabled: isDisabled
},
{
label: t('models.type.rerank'),
value: 'rerank',
disabled: isRerankDisabled
},
{
label: t('models.type.embedding'),
value: 'embedding',
disabled: isEmbeddingDisabled
},
{
label: t('models.type.reasoning'),
value: 'reasoning',
disabled: isDisabled
},
{
label: t('models.type.function_calling'),
value: 'function_calling',
disabled: isDisabled
}
]}
/>
<Button size="small" onClick={handleResetTypes}>
{t('common.reset')}
</Button>
</Flex>
</div>
)
})()}
<Form.Item
name="supported_text_delta"
label={t('settings.models.add.supported_text_delta.label')}
tooltip={t('settings.models.add.supported_text_delta.tooltip')}>
<Switch checked={supportedTextDelta} onChange={(checked) => setSupportedTextDelta(checked)} />
</Form.Item>
<TypeTitle>{t('models.price.price')}</TypeTitle>
<Form.Item name="currencySymbol" label={t('models.price.currency')} style={{ marginBottom: 10 }}>
<Select
style={{ width: '100px' }}
options={currencyOptions}
onChange={(value) => {
if (value === 'custom') {
setIsCustomCurrency(true)
setCurrencySymbol(form.getFieldValue('customCurrencySymbol') || '')
} else {
setIsCustomCurrency(false)
setCurrencySymbol(value)
}
}}
dropdownMatchSelectWidth={false}
/>
</Form.Item>
{isCustomCurrency && (
<Form.Item
name="customCurrencySymbol"
label={t('models.price.custom_currency')}
style={{ marginBottom: 10 }}
rules={[{ required: isCustomCurrency }]}>
<Input
style={{ width: '100px' }}
placeholder={t('models.price.custom_currency_placeholder')}
maxLength={5}
onChange={(e) => setCurrencySymbol(e.target.value)}
/>
</Form.Item>
)}
<Form.Item label={t('models.price.input')} name="input_per_million_tokens">
<InputNumber
placeholder="0.00"
min={0}
step={0.01}
precision={2}
style={{ width: '240px' }}
addonAfter={`${currencySymbol} / ${t('models.price.million_tokens')}`}
/>
</Form.Item>
<Form.Item label={t('models.price.output')} name="output_per_million_tokens">
<InputNumber
placeholder="0.00"
min={0}
step={0.01}
precision={2}
style={{ width: '240px' }}
addonAfter={`${currencySymbol} / ${t('models.price.million_tokens')}`}
/>
</Form.Item>
</div>
)}
</Form>
</Modal>
)
}
const TypeTitle = styled.div`
margin: 12px 0;
font-size: 14px;
font-weight: 600;
`
export default ModelEditContent