Merge branch 'alpha' into feature/claude-code

# Conflicts:
#	web/src/components/table/channels/modals/EditChannelModal.jsx
This commit is contained in:
Seefs
2025-07-31 21:19:43 +08:00
52 changed files with 1400 additions and 312 deletions

View File

@@ -43,6 +43,9 @@ import {
Tag,
Typography,
} from '@douyinfe/semi-ui';
import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
import ModelSelectModal from './ModelSelectModal';
import JSONEditor from '../../../common/JSONEditor';
import { CHANNEL_OPTIONS, CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT } from '../../../../constants';
import {
IconBolt,
@@ -70,7 +73,9 @@ const STATUS_CODE_MAPPING_EXAMPLE = {
};
const REGION_EXAMPLE = {
default: 'us-central1',
"default": 'global',
"gemini-1.5-pro-002": "europe-west2",
"gemini-1.5-flash-002": "europe-west2",
'claude-3-5-sonnet-20240620': 'europe-west1',
};
@@ -149,6 +154,8 @@ const EditChannelModal = (props) => {
const [authorizationCode, setAuthorizationCode] = useState('');
const [oauthParams, setOauthParams] = useState(null);
const [isExchangingCode, setIsExchangingCode] = useState(false);
const [modelModalVisible, setModelModalVisible] = useState(false);
const [fetchedModels, setFetchedModels] = useState([]);
const formApiRef = useRef(null);
const [vertexKeys, setVertexKeys] = useState([]);
const [vertexFileList, setVertexFileList] = useState([]);
@@ -404,7 +411,7 @@ const EditChannelModal = (props) => {
// return;
// }
setLoading(true);
const models = inputs['models'] || [];
const models = [];
let err = false;
if (isEdit) {
@@ -445,8 +452,9 @@ const EditChannelModal = (props) => {
}
if (!err) {
handleInputChange(name, Array.from(new Set(models)));
showSuccess(t('获取模型列表成功'));
const uniqueModels = Array.from(new Set(models));
setFetchedModels(uniqueModels);
setModelModalVisible(true);
} else {
showError(t('获取模型列表失败'));
}
@@ -1071,7 +1079,7 @@ const EditChannelModal = (props) => {
rules={[{ required: true, message: t('请选择渠道类型') }]}
optionList={channelOptionList}
style={{ width: '100%' }}
filter={modelSelectFilter}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
onSearch={(value) => setChannelSearchValue(value)}
@@ -1305,24 +1313,24 @@ const EditChannelModal = (props) => {
)}
{inputs.type === 41 && (
<Form.TextArea
<JSONEditor
field='other'
label={t('部署地区')}
placeholder={t(
'请输入部署地区例如us-central1\n支持使用模型映射格式\n{\n "default": "us-central1",\n "claude-3-5-sonnet-20240620": "europe-west1"\n}'
)}
autosize
value={inputs.other || ''}
onChange={(value) => handleInputChange('other', value)}
rules={[{ required: true, message: t('请填写部署地区') }]}
template={REGION_EXAMPLE}
templateLabel={t('填入模板')}
editorType="region"
formApi={formApiRef.current}
extraText={
<Text
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
>
{t('填入模板')}
<Text type="tertiary" size="small">
{t('设置默认地区和特定模型的专用地区')}
</Text>
}
showClear
/>
)}
@@ -1515,7 +1523,7 @@ const EditChannelModal = (props) => {
placeholder={t('请选择该渠道所支持的模型')}
rules={[{ required: true, message: t('请选择模型') }]}
multiple
filter={modelSelectFilter}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
optionList={modelOptions}
@@ -1578,24 +1586,24 @@ const EditChannelModal = (props) => {
showClear
/>
<Form.TextArea
<JSONEditor
field='model_mapping'
label={t('模型重定向')}
placeholder={
t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:') +
`\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`
}
autosize
value={inputs.model_mapping || ''}
onChange={(value) => handleInputChange('model_mapping', value)}
template={MODEL_MAPPING_EXAMPLE}
templateLabel={t('填入模板')}
editorType="keyValue"
formApi={formApiRef.current}
extraText={
<Text
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
>
{t('填入模板')}
<Text type="tertiary" size="small">
{t('键为请求中的模型名称,值为要替换的模型名称')}
</Text>
}
showClear
/>
</Card>
@@ -1685,7 +1693,7 @@ const EditChannelModal = (props) => {
showClear
/>
<Form.TextArea
<JSONEditor
field='status_code_mapping'
label={t('状态码复写')}
placeholder={
@@ -1693,17 +1701,17 @@ const EditChannelModal = (props) => {
'\n' +
JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2)
}
autosize
value={inputs.status_code_mapping || ''}
onChange={(value) => handleInputChange('status_code_mapping', value)}
template={STATUS_CODE_MAPPING_EXAMPLE}
templateLabel={t('填入模板')}
editorType="keyValue"
formApi={formApiRef.current}
extraText={
<Text
className="!text-semi-color-primary cursor-pointer"
onClick={() => handleInputChange('status_code_mapping', JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2))}
>
{t('填入模板')}
<Text type="tertiary" size="small">
{t('键为原状态码,值为要复写的状态码,仅影响本地判断')}
</Text>
}
showClear
/>
</Card>
@@ -1716,25 +1724,19 @@ const EditChannelModal = (props) => {
</Avatar>
<div>
<Text className="text-lg font-medium">{t('渠道额外设置')}</Text>
<div className="text-xs text-gray-600">
<Text
className="!text-semi-color-primary cursor-pointer"
onClick={() => window.open('https://github.com/QuantumNous/new-api/blob/main/docs/channel/other_setting.md')}
>
{t('设置说明')}
</Text>
</div>
</div>
</div>
<Form.Switch
field='force_format'
label={t('强制格式化')}
checkedText={t('')}
uncheckedText={t('')}
onChange={(value) => handleChannelSettingsChange('force_format', value)}
extraText={t('强制将响应格式化为 OpenAI 标准格式只适用于OpenAI渠道类型')}
/>
{inputs.type === 1 && (
<Form.Switch
field='force_format'
label={t('强制格式化')}
checkedText={t('')}
uncheckedText={t('关')}
onChange={(value) => handleChannelSettingsChange('force_format', value)}
extraText={t('强制将响应格式化为 OpenAI 标准格式只适用于OpenAI渠道类型')}
/>
)}
<Form.Switch
field='thinking_to_content'
@@ -1791,6 +1793,17 @@ const EditChannelModal = (props) => {
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
/>
</SideSheet>
<ModelSelectModal
visible={modelModalVisible}
models={fetchedModels}
selected={inputs.models}
onConfirm={(selectedModels) => {
handleInputChange('models', selectedModels);
showSuccess(t('模型列表已更新'));
setModelModalVisible(false);
}}
onCancel={() => setModelModalVisible(false)}
/>
{/* OAuth Authorization Modal */}
<Modal

View File

@@ -25,7 +25,7 @@ import {
showSuccess,
showWarning,
verifyJSON,
modelSelectFilter,
selectFilter,
} from '../../../../helpers';
import {
SideSheet,
@@ -395,7 +395,7 @@ const EditTagModal = (props) => {
label={t('模型')}
placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
multiple
filter={modelSelectFilter}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
optionList={modelOptions}

View File

@@ -0,0 +1,272 @@
import React, { useState, useEffect } from 'react';
import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
import { Modal, Checkbox, Spin, Input, Typography, Empty, Tabs, Collapse } from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark
} from '@douyinfe/semi-illustrations';
import { IconSearch } from '@douyinfe/semi-icons';
import { useTranslation } from 'react-i18next';
import { getModelCategories } from '../../../../helpers/render';
const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCancel }) => {
const { t } = useTranslation();
const [checkedList, setCheckedList] = useState(selected);
const [keyword, setKeyword] = useState('');
const [activeTab, setActiveTab] = useState('new');
const isMobile = useIsMobile();
const filteredModels = models.filter((m) => m.toLowerCase().includes(keyword.toLowerCase()));
// 分类模型:新获取的模型和已有模型
const newModels = filteredModels.filter(model => !selected.includes(model));
const existingModels = filteredModels.filter(model => selected.includes(model));
// 同步外部选中值
useEffect(() => {
if (visible) {
setCheckedList(selected);
}
}, [visible, selected]);
// 当模型列表变化时设置默认tab
useEffect(() => {
if (visible) {
// 默认显示新获取模型tab如果没有新模型则显示已有模型
const hasNewModels = newModels.length > 0;
setActiveTab(hasNewModels ? 'new' : 'existing');
}
}, [visible, newModels.length, selected]);
const handleOk = () => {
onConfirm && onConfirm(checkedList);
};
// 按厂商分类模型
const categorizeModels = (models) => {
const categories = getModelCategories(t);
const categorizedModels = {};
const uncategorizedModels = [];
models.forEach(model => {
let foundCategory = false;
for (const [key, category] of Object.entries(categories)) {
if (key !== 'all' && category.filter({ model_name: model })) {
if (!categorizedModels[key]) {
categorizedModels[key] = {
label: category.label,
icon: category.icon,
models: []
};
}
categorizedModels[key].models.push(model);
foundCategory = true;
break;
}
}
if (!foundCategory) {
uncategorizedModels.push(model);
}
});
// 如果有未分类模型,添加到"其他"分类
if (uncategorizedModels.length > 0) {
categorizedModels['other'] = {
label: t('其他'),
icon: null,
models: uncategorizedModels
};
}
return categorizedModels;
};
const newModelsByCategory = categorizeModels(newModels);
const existingModelsByCategory = categorizeModels(existingModels);
// Tab列表配置
const tabList = [
...(newModels.length > 0 ? [{
tab: `${t('新获取的模型')} (${newModels.length})`,
itemKey: 'new'
}] : []),
...(existingModels.length > 0 ? [{
tab: `${t('已有的模型')} (${existingModels.length})`,
itemKey: 'existing'
}] : [])
];
// 处理分类全选/取消全选
const handleCategorySelectAll = (categoryModels, isChecked) => {
let newCheckedList = [...checkedList];
if (isChecked) {
// 全选:添加该分类下所有未选中的模型
categoryModels.forEach(model => {
if (!newCheckedList.includes(model)) {
newCheckedList.push(model);
}
});
} else {
// 取消全选:移除该分类下所有已选中的模型
newCheckedList = newCheckedList.filter(model => !categoryModels.includes(model));
}
setCheckedList(newCheckedList);
};
// 检查分类是否全选
const isCategoryAllSelected = (categoryModels) => {
return categoryModels.length > 0 && categoryModels.every(model => checkedList.includes(model));
};
// 检查分类是否部分选中
const isCategoryIndeterminate = (categoryModels) => {
const selectedCount = categoryModels.filter(model => checkedList.includes(model)).length;
return selectedCount > 0 && selectedCount < categoryModels.length;
};
const renderModelsByCategory = (modelsByCategory, categoryKeyPrefix) => {
const categoryEntries = Object.entries(modelsByCategory);
if (categoryEntries.length === 0) return null;
// 生成所有面板的key确保都展开
const allActiveKeys = categoryEntries.map((_, index) => `${categoryKeyPrefix}_${index}`);
return (
<Collapse activeKey={allActiveKeys}>
{categoryEntries.map(([key, categoryData], index) => (
<Collapse.Panel
key={`${categoryKeyPrefix}_${index}`}
itemKey={`${categoryKeyPrefix}_${index}`}
header={`${categoryData.label} (${categoryData.models.length})`}
extra={
<Checkbox
checked={isCategoryAllSelected(categoryData.models)}
indeterminate={isCategoryIndeterminate(categoryData.models)}
onChange={(e) => {
e.stopPropagation(); // 防止触发面板折叠
handleCategorySelectAll(categoryData.models, e.target.checked);
}}
onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
/>
}
>
<div className="flex items-center gap-2 mb-3">
{categoryData.icon}
<Typography.Text type="secondary" size="small">
{t('已选择 {{selected}} / {{total}}', {
selected: categoryData.models.filter(model => checkedList.includes(model)).length,
total: categoryData.models.length
})}
</Typography.Text>
</div>
<div className="grid grid-cols-2 gap-x-4">
{categoryData.models.map((model) => (
<Checkbox key={model} value={model} className="my-1">
{model}
</Checkbox>
))}
</div>
</Collapse.Panel>
))}
</Collapse>
);
};
return (
<Modal
header={
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4">
<Typography.Title heading={5} className="m-0">
{t('选择模型')}
</Typography.Title>
<div className="flex-shrink-0">
<Tabs
type="slash"
size="small"
tabList={tabList}
activeKey={activeTab}
onChange={(key) => setActiveTab(key)}
/>
</div>
</div>
}
visible={visible}
onOk={handleOk}
onCancel={onCancel}
okText={t('确定')}
cancelText={t('取消')}
size={isMobile ? 'full-width' : 'large'}
closeOnEsc
maskClosable
centered
>
<Input
prefix={<IconSearch size={14} />}
placeholder={t('搜索模型')}
value={keyword}
onChange={(v) => setKeyword(v)}
showClear
/>
<Spin spinning={!models || models.length === 0}>
<div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
{filteredModels.length === 0 ? (
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
description={t('暂无匹配模型')}
style={{ padding: 30 }}
/>
) : (
<Checkbox.Group value={checkedList} onChange={(vals) => setCheckedList(vals)}>
{activeTab === 'new' && newModels.length > 0 && (
<div>
{renderModelsByCategory(newModelsByCategory, 'new')}
</div>
)}
{activeTab === 'existing' && existingModels.length > 0 && (
<div>
{renderModelsByCategory(existingModelsByCategory, 'existing')}
</div>
)}
</Checkbox.Group>
)}
</div>
</Spin>
<Typography.Text type="secondary" size="small" className="block text-right mt-4">
<div className="flex items-center justify-end gap-2">
{(() => {
const currentModels = activeTab === 'new' ? newModels : existingModels;
const currentSelected = currentModels.filter(model => checkedList.includes(model)).length;
const isAllSelected = currentModels.length > 0 && currentSelected === currentModels.length;
const isIndeterminate = currentSelected > 0 && currentSelected < currentModels.length;
return (
<>
<span>
{t('已选择 {{selected}} / {{total}}', {
selected: currentSelected,
total: currentModels.length
})}
</span>
<Checkbox
checked={isAllSelected}
indeterminate={isIndeterminate}
onChange={(e) => {
handleCategorySelectAll(currentModels, e.target.checked);
}}
/>
</>
);
})()}
</div>
</Typography.Text>
</Modal>
);
};
export default ModelSelectModal;

View File

@@ -26,7 +26,7 @@ import {
renderGroupOption,
renderQuotaWithPrompt,
getModelCategories,
modelSelectFilter,
selectFilter,
} from '../../../../helpers';
import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
import {
@@ -514,7 +514,7 @@ const EditTokenModal = (props) => {
multiple
optionList={models}
extraText={t('非必要,不建议启用模型限制')}
filter={modelSelectFilter}
filter={selectFilter}
autoClearSearchValue={false}
searchPosition='dropdown'
showClear