初始化导入 new-api 源码
This commit is contained in:
554
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
554
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import {
|
||||
Banner,
|
||||
SideSheet,
|
||||
Form,
|
||||
Button,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Col,
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Save, X, FileText } from 'lucide-react';
|
||||
import { IconAlertTriangle, IconLink } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
// Example endpoint template for quick fill
|
||||
const ENDPOINT_TEMPLATE = {
|
||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||
'openai-response': { path: '/v1/responses', method: 'POST' },
|
||||
'openai-response-compact': { path: '/v1/responses/compact', method: 'POST' },
|
||||
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
|
||||
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
|
||||
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||
};
|
||||
|
||||
const nameRuleOptions = [
|
||||
{ label: '精确名称匹配', value: 0 },
|
||||
{ label: '前缀名称匹配', value: 1 },
|
||||
{ label: '包含名称匹配', value: 2 },
|
||||
{ label: '后缀名称匹配', value: 3 },
|
||||
];
|
||||
|
||||
const EditModelModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = props.editingModel && props.editingModel.id !== undefined;
|
||||
const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]);
|
||||
|
||||
// 供应商列表
|
||||
const [vendors, setVendors] = useState([]);
|
||||
|
||||
// 预填组(标签、端点)
|
||||
const [tagGroups, setTagGroups] = useState([]);
|
||||
const [endpointGroups, setEndpointGroups] = useState([]);
|
||||
|
||||
// 获取供应商列表
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商
|
||||
if (res.data.success) {
|
||||
const items = res.data.data.items || res.data.data || [];
|
||||
setVendors(Array.isArray(items) ? items : []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// 获取预填组(标签、端点)
|
||||
const fetchPrefillGroups = async () => {
|
||||
try {
|
||||
const [tagRes, endpointRes] = await Promise.all([
|
||||
API.get('/api/prefill_group?type=tag'),
|
||||
API.get('/api/prefill_group?type=endpoint'),
|
||||
]);
|
||||
if (tagRes?.data?.success) {
|
||||
setTagGroups(tagRes.data.data || []);
|
||||
}
|
||||
if (endpointRes?.data?.success) {
|
||||
setEndpointGroups(endpointRes.data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
fetchVendors();
|
||||
fetchPrefillGroups();
|
||||
}
|
||||
}, [props.visiable]);
|
||||
|
||||
const getInitValues = () => ({
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
description: '',
|
||||
icon: '',
|
||||
tags: [],
|
||||
vendor_id: undefined,
|
||||
vendor: '',
|
||||
vendor_icon: '',
|
||||
endpoints: '',
|
||||
name_rule: props.editingModel?.model_name ? 0 : undefined, // 通过未配置模型过来的固定为精确匹配
|
||||
status: true,
|
||||
sync_official: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const loadModel = async () => {
|
||||
if (!isEdit || !props.editingModel.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/models/${props.editingModel.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 处理tags
|
||||
if (data.tags) {
|
||||
data.tags = data.tags.split(',').filter(Boolean);
|
||||
} else {
|
||||
data.tags = [];
|
||||
}
|
||||
// endpoints 保持原始 JSON 字符串,若为空设为空串
|
||||
if (!data.endpoints) {
|
||||
data.endpoints = '';
|
||||
}
|
||||
// 处理status/sync_official,将数字转为布尔值
|
||||
data.status = data.status === 1;
|
||||
data.sync_official = (data.sync_official ?? 1) === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载模型信息失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
if (!isEdit) {
|
||||
formApiRef.current.setValues({
|
||||
...getInitValues(),
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [props.editingModel?.id, props.editingModel?.model_name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
if (isEdit) {
|
||||
loadModel();
|
||||
} else {
|
||||
formApiRef.current?.setValues({
|
||||
...getInitValues(),
|
||||
model_name: props.editingModel?.model_name || '',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [props.visiable, props.editingModel?.id, props.editingModel?.model_name]);
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||
endpoints: values.endpoints || '',
|
||||
status: values.status ? 1 : 0,
|
||||
sync_official: values.sync_official ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
submitData.id = props.editingModel.id;
|
||||
const res = await API.put('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型创建成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement={placement}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新模型信息') : t('创建新的模型')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<Save size={16} />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<X size={16} />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={isEdit ? 'edit' : 'new'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<FileText size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('设置模型的基本信息')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='model_name'
|
||||
label={t('模型名称')}
|
||||
placeholder={t('请输入模型名称,如:gpt-4')}
|
||||
rules={[{ required: true, message: t('请输入模型名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='name_rule'
|
||||
label={t('名称匹配类型')}
|
||||
placeholder={t('请选择名称匹配类型')}
|
||||
optionList={nameRuleOptions.map((o) => ({
|
||||
label: t(o.label),
|
||||
value: o.value,
|
||||
}))}
|
||||
rules={[
|
||||
{ required: true, message: t('请选择名称匹配类型') },
|
||||
]}
|
||||
extraText={t(
|
||||
'根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含',
|
||||
)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='icon'
|
||||
label={t('模型图标')}
|
||||
placeholder={t('请输入图标名称')}
|
||||
extraText={
|
||||
<span>
|
||||
{t(
|
||||
"图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={'platform'}、OpenRouter.Avatar.shape={'square'},查询所有可用图标请 ",
|
||||
)}
|
||||
<Typography.Text
|
||||
link={{
|
||||
href: 'https://icons.lobehub.com/components/lobe-hub',
|
||||
target: '_blank',
|
||||
}}
|
||||
icon={<IconLink />}
|
||||
underline
|
||||
>
|
||||
{t('请点击我')}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入模型描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TagInput
|
||||
field='tags'
|
||||
label={t('标签')}
|
||||
placeholder={t('输入标签或使用","分隔多个标签')}
|
||||
addOnBlur
|
||||
showClear
|
||||
onChange={(newTags) => {
|
||||
if (!formApiRef.current) return;
|
||||
const normalize = (tags) => {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return [
|
||||
...new Set(
|
||||
tags.flatMap((tag) =>
|
||||
tag
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
),
|
||||
];
|
||||
};
|
||||
const normalized = normalize(newTags);
|
||||
formApiRef.current.setValue('tags', normalized);
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
{...(tagGroups.length > 0 && {
|
||||
extraText: (
|
||||
<Space wrap>
|
||||
{tagGroups.map((group) => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
if (formApiRef.current) {
|
||||
const currentTags =
|
||||
formApiRef.current.getValue('tags') || [];
|
||||
const newTags = [
|
||||
...currentTags,
|
||||
...(group.items || []),
|
||||
];
|
||||
const uniqueTags = [...new Set(newTags)];
|
||||
formApiRef.current.setValue(
|
||||
'tags',
|
||||
uniqueTags,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='vendor_id'
|
||||
label={t('供应商')}
|
||||
placeholder={t('选择模型供应商')}
|
||||
optionList={vendors.map((v) => ({
|
||||
label: v.name,
|
||||
value: v.id,
|
||||
}))}
|
||||
filter
|
||||
showClear
|
||||
onChange={(value) => {
|
||||
const vendorInfo = vendors.find((v) => v.id === value);
|
||||
if (vendorInfo && formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vendor',
|
||||
vendorInfo.name,
|
||||
);
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Banner
|
||||
type='warning'
|
||||
closeIcon={null}
|
||||
icon={
|
||||
<IconAlertTriangle
|
||||
size='large'
|
||||
style={{ color: 'var(--semi-color-warning)' }}
|
||||
/>
|
||||
}
|
||||
description={t(
|
||||
'提示:此处配置仅用于控制「模型广场」对用户的展示效果,不会影响模型的实际调用与路由。若需配置真实调用行为,请前往「渠道管理」进行设置。',
|
||||
)}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<JSONEditor
|
||||
field='endpoints'
|
||||
label={t('在模型广场向用户展示的端点')}
|
||||
placeholder={
|
||||
'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'
|
||||
}
|
||||
value={values.endpoints}
|
||||
onChange={(val) =>
|
||||
formApiRef.current?.setValue('endpoints', val)
|
||||
}
|
||||
formApi={formApiRef.current}
|
||||
editorType='object'
|
||||
template={ENDPOINT_TEMPLATE}
|
||||
templateLabel={t('填入模板')}
|
||||
extraText={t('留空则使用默认端点;支持 {path, method}')}
|
||||
extraFooter={
|
||||
endpointGroups.length > 0 && (
|
||||
<Space wrap>
|
||||
{endpointGroups.map((group) => (
|
||||
<Button
|
||||
key={group.id}
|
||||
size='small'
|
||||
type='primary'
|
||||
onClick={() => {
|
||||
try {
|
||||
const current =
|
||||
formApiRef.current?.getValue(
|
||||
'endpoints',
|
||||
) || '';
|
||||
let base = {};
|
||||
if (current && current.trim())
|
||||
base = JSON.parse(current);
|
||||
const groupObj =
|
||||
typeof group.items === 'string'
|
||||
? JSON.parse(group.items || '{}')
|
||||
: group.items || {};
|
||||
const merged = { ...base, ...groupObj };
|
||||
formApiRef.current?.setValue(
|
||||
'endpoints',
|
||||
JSON.stringify(merged, null, 2),
|
||||
);
|
||||
} catch (e) {
|
||||
try {
|
||||
const groupObj =
|
||||
typeof group.items === 'string'
|
||||
? JSON.parse(group.items || '{}')
|
||||
: group.items || {};
|
||||
formApiRef.current?.setValue(
|
||||
'endpoints',
|
||||
JSON.stringify(groupObj, null, 2),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{group.name}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='sync_official'
|
||||
label={t('参与官方同步')}
|
||||
extraText={t(
|
||||
'关闭后,此模型将不会被“同步官方”自动覆盖或创建',
|
||||
)}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='status'
|
||||
label={t('状态')}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModelModal;
|
||||
276
web/src/components/table/models/modals/EditPrefillGroupModal.jsx
Normal file
276
web/src/components/table/models/modals/EditPrefillGroupModal.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||
import {
|
||||
SideSheet,
|
||||
Button,
|
||||
Form,
|
||||
Typography,
|
||||
Space,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Avatar,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconLayers, IconSave, IconClose } from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
// Example endpoint template for quick fill
|
||||
const ENDPOINT_TEMPLATE = {
|
||||
openai: { path: '/v1/chat/completions', method: 'POST' },
|
||||
'openai-response': { path: '/v1/responses', method: 'POST' },
|
||||
'openai-response-compact': { path: '/v1/responses/compact', method: 'POST' },
|
||||
anthropic: { path: '/v1/messages', method: 'POST' },
|
||||
gemini: { path: '/v1beta/models/{model}:generateContent', method: 'POST' },
|
||||
'jina-rerank': { path: '/v1/rerank', method: 'POST' },
|
||||
'image-generation': { path: '/v1/images/generations', method: 'POST' },
|
||||
};
|
||||
|
||||
const EditPrefillGroupModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
editingGroup,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formRef = useRef(null);
|
||||
const isEdit = editingGroup && editingGroup.id !== undefined;
|
||||
|
||||
const [selectedType, setSelectedType] = useState(editingGroup?.type || 'tag');
|
||||
|
||||
// 当外部传入的编辑组类型变化时同步 selectedType
|
||||
useEffect(() => {
|
||||
setSelectedType(editingGroup?.type || 'tag');
|
||||
}, [editingGroup?.type]);
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('模型组'), value: 'model' },
|
||||
{ label: t('标签组'), value: 'tag' },
|
||||
{ label: t('端点组'), value: 'endpoint' },
|
||||
{ label: t('订阅分组'), value: 'subscription_group' },
|
||||
];
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
};
|
||||
if (values.type === 'endpoint') {
|
||||
submitData.items = values.items || '';
|
||||
} else {
|
||||
submitData.items = Array.isArray(values.items) ? values.items : [];
|
||||
}
|
||||
|
||||
if (editingGroup.id) {
|
||||
submitData.id = editingGroup.id;
|
||||
const res = await API.put('/api/prefill_group', submitData);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
onSuccess();
|
||||
} else {
|
||||
showError(res.data.message || t('更新失败'));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/prefill_group', submitData);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('创建成功'));
|
||||
onSuccess();
|
||||
} else {
|
||||
showError(res.data.message || t('创建失败'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement='left'
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新预填组') : t('创建新的预填组')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
width={isMobile ? '100%' : 600}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={onClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
getFormApi={(api) => (formRef.current = api)}
|
||||
initValues={{
|
||||
name: editingGroup?.name || '',
|
||||
type: editingGroup?.type || 'tag',
|
||||
description: editingGroup?.description || '',
|
||||
items: (() => {
|
||||
try {
|
||||
if (editingGroup?.type === 'endpoint') {
|
||||
// 保持原始字符串
|
||||
return typeof editingGroup?.items === 'string'
|
||||
? editingGroup.items
|
||||
: JSON.stringify(editingGroup.items || {}, null, 2);
|
||||
}
|
||||
return Array.isArray(editingGroup?.items)
|
||||
? editingGroup.items
|
||||
: [];
|
||||
} catch {
|
||||
return editingGroup?.type === 'endpoint' ? '' : [];
|
||||
}
|
||||
})(),
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconLayers size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('设置预填组的基本信息')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('组名')}
|
||||
placeholder={t('请输入组名')}
|
||||
rules={[{ required: true, message: t('请输入组名') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='type'
|
||||
label={t('类型')}
|
||||
placeholder={t('选择组类型')}
|
||||
optionList={typeOptions}
|
||||
rules={[{ required: true, message: t('请选择组类型') }]}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(val) => setSelectedType(val)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入组描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
{selectedType === 'endpoint' ? (
|
||||
<JSONEditor
|
||||
field='items'
|
||||
label={t('端点映射')}
|
||||
value={
|
||||
formRef.current?.getValue('items') ??
|
||||
(typeof editingGroup?.items === 'string'
|
||||
? editingGroup.items
|
||||
: JSON.stringify(editingGroup.items || {}, null, 2))
|
||||
}
|
||||
onChange={(val) =>
|
||||
formRef.current?.setValue('items', val)
|
||||
}
|
||||
editorType='object'
|
||||
placeholder={
|
||||
'{\n "openai": {"path": "/v1/chat/completions", "method": "POST"}\n}'
|
||||
}
|
||||
template={ENDPOINT_TEMPLATE}
|
||||
templateLabel={t('填入模板')}
|
||||
extraText={t('键为端点类型,值为路径和方法对象')}
|
||||
/>
|
||||
) : (
|
||||
<Form.TagInput
|
||||
field='items'
|
||||
label={t('项目')}
|
||||
placeholder={t('输入项目名称,按回车添加')}
|
||||
addOnBlur
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPrefillGroupModal;
|
||||
186
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
186
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Modal, Form, Col, Row } from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { IconLink } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEdit = editingVendor && editingVendor.id !== undefined;
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
status: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
handleClose();
|
||||
formApiRef.current?.reset();
|
||||
};
|
||||
|
||||
const loadVendor = async () => {
|
||||
if (!isEdit || !editingVendor.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/vendors/${editingVendor.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 将数字状态转为布尔值
|
||||
data.status = data.status === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载供应商信息失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (isEdit) {
|
||||
loadVendor();
|
||||
} else {
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [visible, editingVendor?.id]);
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 转换 status 为数字
|
||||
const submitData = {
|
||||
...values,
|
||||
status: values.status ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
submitData.id = editingVendor.id;
|
||||
const res = await API.put('/api/vendors/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('供应商更新成功!'));
|
||||
refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/vendors/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('供应商创建成功!'));
|
||||
refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? t('编辑供应商') : t('新增供应商')}
|
||||
visible={visible}
|
||||
onOk={() => formApiRef.current?.submitForm()}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={loading}
|
||||
size={isMobile ? 'full-width' : 'small'}
|
||||
>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='name'
|
||||
label={t('供应商名称')}
|
||||
placeholder={t('请输入供应商名称,如:OpenAI')}
|
||||
rules={[{ required: true, message: t('请输入供应商名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入供应商描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='icon'
|
||||
label={t('供应商图标')}
|
||||
placeholder={t('请输入图标名称')}
|
||||
extraText={
|
||||
<span>
|
||||
{t(
|
||||
"图标使用@lobehub/icons库,如:OpenAI、Claude.Color,支持链式参数:OpenAI.Avatar.type={'platform'}、OpenRouter.Avatar.shape={'square'},查询所有可用图标请 ",
|
||||
)}
|
||||
<Typography.Text
|
||||
link={{
|
||||
href: 'https://icons.lobehub.com/components/lobe-hub',
|
||||
target: '_blank',
|
||||
}}
|
||||
icon={<IconLink />}
|
||||
underline
|
||||
>
|
||||
{t('请点击我')}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch field='status' label={t('状态')} initValue={true} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditVendorModal;
|
||||
198
web/src/components/table/models/modals/MissingModelsModal.jsx
Normal file
198
web/src/components/table/models/modals/MissingModelsModal.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Spin,
|
||||
Button,
|
||||
Typography,
|
||||
Empty,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { API, showError } from '../../../../helpers';
|
||||
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const MissingModelsModal = ({ visible, onClose, onConfigureModel, t }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [missingModels, setMissingModels] = useState([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const fetchMissing = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/models/missing');
|
||||
if (res.data.success) {
|
||||
setMissingModels(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (_) {
|
||||
showError(t('获取未配置模型失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchMissing();
|
||||
setSearchKeyword('');
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
setMissingModels([]);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 过滤和分页逻辑
|
||||
const filteredModels = missingModels.filter((model) =>
|
||||
model.toLowerCase().includes(searchKeyword.toLowerCase()),
|
||||
);
|
||||
|
||||
const dataSource = (() => {
|
||||
const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filteredModels.slice(start, end).map((model) => ({
|
||||
model,
|
||||
key: model,
|
||||
}));
|
||||
})();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model',
|
||||
render: (text) => (
|
||||
<div className='flex items-center'>
|
||||
<Typography.Text strong>{text}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 120,
|
||||
render: (text, record) => (
|
||||
<Button
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={() => onConfigureModel(record.model)}
|
||||
>
|
||||
{t('配置')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Typography.Text
|
||||
strong
|
||||
className='!text-[var(--semi-color-text-0)] !text-base'
|
||||
>
|
||||
{t('未配置的模型列表')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type='tertiary' size='small'>
|
||||
{t('共')} {missingModels.length} {t('个未配置模型')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
size={isMobile ? 'full-width' : 'medium'}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{missingModels.length === 0 && !loading ? (
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无缺失模型')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
) : (
|
||||
<div className='missing-models-content'>
|
||||
{/* 搜索框 */}
|
||||
<div className='flex items-center justify-end gap-2 w-full mb-4'>
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={searchKeyword}
|
||||
onChange={(v) => {
|
||||
setSearchKeyword(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className='!w-full'
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
{filteredModels.length > 0 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredModels.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 100, height: 100 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark
|
||||
style={{ width: 100, height: 100 }}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
searchKeyword ? t('未找到匹配的模型') : t('暂无缺失模型')
|
||||
}
|
||||
style={{ padding: 20 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MissingModelsModal;
|
||||
@@ -0,0 +1,309 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
SideSheet,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Avatar,
|
||||
Spin,
|
||||
Empty,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconLayers } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
stringToColor,
|
||||
} from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import CardTable from '../../../common/ui/CardTable';
|
||||
import EditPrefillGroupModal from './EditPrefillGroupModal';
|
||||
import {
|
||||
renderLimitedItems,
|
||||
renderDescription,
|
||||
} from '../../../common/ui/RenderUtils';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const PrefillGroupManagement = ({ visible, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState({ id: undefined });
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t('模型组'), value: 'model' },
|
||||
{ label: t('标签组'), value: 'tag' },
|
||||
{ label: t('端点组'), value: 'endpoint' },
|
||||
{ label: t('订阅分组'), value: 'subscription_group' },
|
||||
];
|
||||
|
||||
// 加载组列表
|
||||
const loadGroups = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/prefill_group');
|
||||
if (res.data.success) {
|
||||
setGroups(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data.message || t('获取组列表失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('获取组列表失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// 删除组
|
||||
const deleteGroup = async (id) => {
|
||||
try {
|
||||
const res = await API.delete(`/api/prefill_group/${id}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('删除成功'));
|
||||
loadGroups();
|
||||
} else {
|
||||
showError(res.data.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑组
|
||||
const handleEdit = (group = {}) => {
|
||||
setEditingGroup(group);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
// 关闭编辑
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setTimeout(() => {
|
||||
setEditingGroup({ id: undefined });
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 编辑成功回调
|
||||
const handleEditSuccess = () => {
|
||||
closeEdit();
|
||||
loadGroups();
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: t('组名'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
<Text strong>{text}</Text>
|
||||
<Tag color='white' shape='circle' size='small'>
|
||||
{typeOptions.find((opt) => opt.value === record.type)?.label ||
|
||||
record.type}
|
||||
</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
render: (text) => renderDescription(text, 150),
|
||||
},
|
||||
{
|
||||
title: t('项目内容'),
|
||||
dataIndex: 'items',
|
||||
key: 'items',
|
||||
render: (items, record) => {
|
||||
try {
|
||||
if (record.type === 'endpoint') {
|
||||
const obj =
|
||||
typeof items === 'string'
|
||||
? JSON.parse(items || '{}')
|
||||
: items || {};
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0)
|
||||
return <Text type='tertiary'>{t('暂无项目')}</Text>;
|
||||
return renderLimitedItems({
|
||||
items: keys,
|
||||
renderItem: (key, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size='small'
|
||||
shape='circle'
|
||||
color={stringToColor(key)}
|
||||
>
|
||||
{key}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
}
|
||||
const itemsArray =
|
||||
typeof items === 'string' ? JSON.parse(items) : items;
|
||||
if (!Array.isArray(itemsArray) || itemsArray.length === 0) {
|
||||
return <Text type='tertiary'>{t('暂无项目')}</Text>;
|
||||
}
|
||||
return renderLimitedItems({
|
||||
items: itemsArray,
|
||||
renderItem: (item, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size='small'
|
||||
shape='circle'
|
||||
color={stringToColor(item)}
|
||||
>
|
||||
{item}
|
||||
</Tag>
|
||||
),
|
||||
maxDisplay: 3,
|
||||
});
|
||||
} catch {
|
||||
return <Text type='tertiary'>{t('数据格式错误')}</Text>;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size='small' onClick={() => handleEdit(record)}>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('确定删除此组?')}
|
||||
onConfirm={() => deleteGroup(record.id)}
|
||||
>
|
||||
<Button size='small' type='danger'>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
loadGroups();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement='left'
|
||||
title={
|
||||
<Space>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('管理')}
|
||||
</Tag>
|
||||
<Title heading={4} className='m-0'>
|
||||
{t('预填组管理')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
width={isMobile ? '100%' : 800}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
closeIcon={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<div className='p-2'>
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconLayers size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('组列表')}</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('管理模型、标签、端点等预填组')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end mb-4'>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
size='small'
|
||||
icon={<IconPlus />}
|
||||
onClick={() => handleEdit()}
|
||||
>
|
||||
{t('新建组')}
|
||||
</Button>
|
||||
</div>
|
||||
{groups.length > 0 ? (
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={groups}
|
||||
rowKey='id'
|
||||
hidePagination={true}
|
||||
size='small'
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark
|
||||
style={{ width: 150, height: 150 }}
|
||||
/>
|
||||
}
|
||||
description={t('暂无预填组')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
|
||||
{/* 编辑组件 */}
|
||||
<EditPrefillGroupModal
|
||||
visible={showEdit}
|
||||
onClose={closeEdit}
|
||||
editingGroup={editingGroup}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrefillGroupManagement;
|
||||
135
web/src/components/table/models/modals/SyncWizardModal.jsx
Normal file
135
web/src/components/table/models/modals/SyncWizardModal.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, RadioGroup, Radio, Steps, Button } from '@douyinfe/semi-ui';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const SyncWizardModal = ({ visible, onClose, onConfirm, loading, t }) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const [option, setOption] = useState('official');
|
||||
const [locale, setLocale] = useState('zh-CN');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setStep(0);
|
||||
setOption('official');
|
||||
setLocale('zh-CN');
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('同步向导')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
{step === 1 && (
|
||||
<Button onClick={() => setStep(0)}>{t('上一步')}</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>{t('取消')}</Button>
|
||||
{step === 0 && (
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setStep(1)}
|
||||
disabled={option !== 'official'}
|
||||
>
|
||||
{t('下一步')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
await onConfirm?.({ option, locale });
|
||||
}}
|
||||
>
|
||||
{t('开始同步')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width={isMobile ? '100%' : 'small'}
|
||||
>
|
||||
<div className='mb-3'>
|
||||
<Steps type='basic' current={step} size='small'>
|
||||
<Steps.Step title={t('选择方式')} description={t('选择同步来源')} />
|
||||
<Steps.Step title={t('选择语言')} description={t('选择同步语言')} />
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<RadioGroup
|
||||
value={option}
|
||||
onChange={(e) => setOption(e?.target?.value ?? e)}
|
||||
type='card'
|
||||
direction='horizontal'
|
||||
aria-label='同步方式选择'
|
||||
name='sync-mode-selection'
|
||||
>
|
||||
<Radio value='official' extra={t('从官方模型库同步')}>
|
||||
{t('官方模型同步')}
|
||||
</Radio>
|
||||
<Radio value='config' extra={t('从配置文件同步')} disabled>
|
||||
{t('配置文件同步')}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className='mt-2'>
|
||||
<div className='mb-2 text-[var(--semi-color-text-2)]'>
|
||||
{t('请选择同步语言')}
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<RadioGroup
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e?.target?.value ?? e)}
|
||||
type='card'
|
||||
direction='horizontal'
|
||||
aria-label='语言选择'
|
||||
name='sync-locale-selection'
|
||||
>
|
||||
<Radio value='en' extra='English'>
|
||||
en
|
||||
</Radio>
|
||||
<Radio value='zh-CN' extra='简体中文'>
|
||||
zh-CN
|
||||
</Radio>
|
||||
<Radio value='zh-TW' extra='繁體中文'>
|
||||
zh-TW
|
||||
</Radio>
|
||||
<Radio value='ja' extra='日本語'>
|
||||
ja
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncWizardModal;
|
||||
324
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
324
web/src/components/table/models/modals/UpstreamConflictModal.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Empty,
|
||||
Tag,
|
||||
Popover,
|
||||
Input,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { MousePointerClick } from 'lucide-react';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const FIELD_LABELS = {
|
||||
description: '描述',
|
||||
icon: '图标',
|
||||
tags: '标签',
|
||||
vendor: '供应商',
|
||||
name_rule: '命名规则',
|
||||
status: '状态',
|
||||
};
|
||||
const FIELD_KEYS = Object.keys(FIELD_LABELS);
|
||||
|
||||
const UpstreamConflictModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
conflicts = [],
|
||||
onSubmit,
|
||||
t,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [selections, setSelections] = useState({});
|
||||
const isMobile = useIsMobile();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
const formatValue = (v) => {
|
||||
if (v === null || v === undefined) return '-';
|
||||
if (typeof v === 'string') return v || '-';
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch (_) {
|
||||
return String(v);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const init = {};
|
||||
conflicts.forEach((item) => {
|
||||
init[item.model_name] = new Set();
|
||||
});
|
||||
setSelections(init);
|
||||
setCurrentPage(1);
|
||||
setSearchKeyword('');
|
||||
} else {
|
||||
setSelections({});
|
||||
}
|
||||
}, [visible, conflicts]);
|
||||
|
||||
const toggleField = useCallback((modelName, field, checked) => {
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
const set = new Set(next[modelName] || []);
|
||||
if (checked) set.add(field);
|
||||
else set.delete(field);
|
||||
next[modelName] = set;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 构造数据源与过滤后的数据源
|
||||
const dataSource = useMemo(
|
||||
() =>
|
||||
(conflicts || []).map((c) => ({
|
||||
key: c.model_name,
|
||||
model_name: c.model_name,
|
||||
fields: c.fields || [],
|
||||
})),
|
||||
[conflicts],
|
||||
);
|
||||
|
||||
const filteredDataSource = useMemo(() => {
|
||||
const kw = (searchKeyword || '').toLowerCase();
|
||||
if (!kw) return dataSource;
|
||||
return dataSource.filter((item) =>
|
||||
(item.model_name || '').toLowerCase().includes(kw),
|
||||
);
|
||||
}, [dataSource, searchKeyword]);
|
||||
|
||||
// 列头工具:当前过滤范围内可操作的行集合/勾选状态/批量设置
|
||||
const getPresentRowsForField = useCallback(
|
||||
(fieldKey) =>
|
||||
(filteredDataSource || []).filter((row) =>
|
||||
(row.fields || []).some((f) => f.field === fieldKey),
|
||||
),
|
||||
[filteredDataSource],
|
||||
);
|
||||
|
||||
const getHeaderState = useCallback(
|
||||
(fieldKey) => {
|
||||
const presentRows = getPresentRowsForField(fieldKey);
|
||||
const selectedCount = presentRows.filter((row) =>
|
||||
selections[row.model_name]?.has(fieldKey),
|
||||
).length;
|
||||
const allCount = presentRows.length;
|
||||
return {
|
||||
headerChecked: allCount > 0 && selectedCount === allCount,
|
||||
headerIndeterminate: selectedCount > 0 && selectedCount < allCount,
|
||||
hasAny: allCount > 0,
|
||||
};
|
||||
},
|
||||
[getPresentRowsForField, selections],
|
||||
);
|
||||
|
||||
const applyHeaderChange = useCallback(
|
||||
(fieldKey, checked) => {
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
getPresentRowsForField(fieldKey).forEach((row) => {
|
||||
const set = new Set(next[row.model_name] || []);
|
||||
if (checked) set.add(fieldKey);
|
||||
else set.delete(fieldKey);
|
||||
next[row.model_name] = set;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[getPresentRowsForField],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const base = [
|
||||
{
|
||||
title: t('模型'),
|
||||
dataIndex: 'model_name',
|
||||
fixed: 'left',
|
||||
render: (text) => <Text strong>{text}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const cols = FIELD_KEYS.map((fieldKey) => {
|
||||
const rawLabel = FIELD_LABELS[fieldKey] || fieldKey;
|
||||
const label = t(rawLabel);
|
||||
|
||||
const { headerChecked, headerIndeterminate, hasAny } =
|
||||
getHeaderState(fieldKey);
|
||||
if (!hasAny) return null;
|
||||
const onHeaderChange = (e) =>
|
||||
applyHeaderChange(fieldKey, e?.target?.checked);
|
||||
|
||||
return {
|
||||
title: (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
indeterminate={headerIndeterminate}
|
||||
onChange={onHeaderChange}
|
||||
/>
|
||||
<Text>{label}</Text>
|
||||
</div>
|
||||
),
|
||||
dataIndex: fieldKey,
|
||||
render: (_, record) => {
|
||||
const f = (record.fields || []).find((x) => x.field === fieldKey);
|
||||
if (!f) return <Text type='tertiary'>-</Text>;
|
||||
const checked = selections[record.model_name]?.has(fieldKey) || false;
|
||||
return (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(e) =>
|
||||
toggleField(record.model_name, fieldKey, e?.target?.checked)
|
||||
}
|
||||
>
|
||||
<Popover
|
||||
trigger='hover'
|
||||
position='top'
|
||||
content={
|
||||
<div className='p-2 max-w-[520px]'>
|
||||
<div className='mb-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('本地')}
|
||||
</Text>
|
||||
<pre className='whitespace-pre-wrap m-0'>
|
||||
{formatValue(f.local)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('官方')}
|
||||
</Text>
|
||||
<pre className='whitespace-pre-wrap m-0'>
|
||||
{formatValue(f.upstream)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag
|
||||
color='white'
|
||||
size='small'
|
||||
prefixIcon={<MousePointerClick size={14} />}
|
||||
>
|
||||
{t('点击查看差异')}
|
||||
</Tag>
|
||||
</Popover>
|
||||
</Checkbox>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [...base, ...cols.filter(Boolean)];
|
||||
}, [
|
||||
t,
|
||||
selections,
|
||||
filteredDataSource,
|
||||
getHeaderState,
|
||||
applyHeaderChange,
|
||||
toggleField,
|
||||
]);
|
||||
|
||||
const pagedDataSource = useMemo(() => {
|
||||
const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
|
||||
const end = start + MODEL_TABLE_PAGE_SIZE;
|
||||
return filteredDataSource.slice(start, end);
|
||||
}, [filteredDataSource, currentPage]);
|
||||
|
||||
const handleOk = async () => {
|
||||
const payload = Object.entries(selections)
|
||||
.map(([modelName, set]) => ({
|
||||
model_name: modelName,
|
||||
fields: Array.from(set || []),
|
||||
}))
|
||||
.filter((x) => x.fields.length > 0);
|
||||
|
||||
const ok = await onSubmit?.(payload);
|
||||
if (ok) onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('选择要覆盖的冲突项')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText={t('应用覆盖')}
|
||||
cancelText={t('取消')}
|
||||
width={isMobile ? '100%' : 1000}
|
||||
>
|
||||
{dataSource.length === 0 ? (
|
||||
<Empty description={t('无冲突项')} className='p-6' />
|
||||
) : (
|
||||
<>
|
||||
<div className='mb-3 text-[var(--semi-color-text-2)]'>
|
||||
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
|
||||
</div>
|
||||
{/* 搜索框 */}
|
||||
<div className='flex items-center justify-end gap-2 w-full mb-4'>
|
||||
<Input
|
||||
placeholder={t('搜索模型...')}
|
||||
value={searchKeyword}
|
||||
onChange={(v) => {
|
||||
setSearchKeyword(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className='!w-full'
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
/>
|
||||
</div>
|
||||
{filteredDataSource.length > 0 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pagedDataSource}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: MODEL_TABLE_PAGE_SIZE,
|
||||
total: filteredDataSource.length,
|
||||
showSizeChanger: false,
|
||||
onPageChange: (page) => setCurrentPage(page),
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
description={
|
||||
searchKeyword ? t('未找到匹配的模型') : t('无冲突项')
|
||||
}
|
||||
className='p-6'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpstreamConflictModal;
|
||||
Reference in New Issue
Block a user