| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | import React, { useState, useEffect, useRef, useMemo } from 'react';
|
| | import JSONEditor from '../../../common/ui/JSONEditor';
|
| | import {
|
| | SideSheet,
|
| | Form,
|
| | Button,
|
| | Space,
|
| | Spin,
|
| | Typography,
|
| | Card,
|
| | Tag,
|
| | Avatar,
|
| | Col,
|
| | Row,
|
| | Tooltip,
|
| | } from '@douyinfe/semi-ui';
|
| | import { Save, X, FileText } from 'lucide-react';
|
| | import { IconInfoCircle, 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;
|
| |
|
| |
|
| | 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) {
|
| |
|
| | }
|
| | };
|
| |
|
| |
|
| | 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) {
|
| |
|
| | }
|
| | };
|
| |
|
| | 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) {
|
| |
|
| | if (data.tags) {
|
| | data.tags = data.tags.split(',').filter(Boolean);
|
| | } else {
|
| | data.tags = [];
|
| | }
|
| |
|
| | if (!data.endpoints) {
|
| | data.endpoints = '';
|
| | }
|
| |
|
| | 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}>
|
| | <JSONEditor
|
| | field='endpoints'
|
| | label={
|
| | <span className='inline-flex items-center gap-2'>
|
| | <span>{t('端点映射')}</span>
|
| | <Tooltip
|
| | position='top'
|
| | content={t(
|
| | '提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。',
|
| | )}
|
| | >
|
| | <IconInfoCircle
|
| | size='small'
|
| | className='text-gray-400 cursor-help'
|
| | />
|
| | </Tooltip>
|
| | </span>
|
| | }
|
| | 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;
|
| |
|