2026-03-02 11:59:47 +00:00
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
|
|
|
|
import { Table, Card, Button, Input, Space, Drawer, Form, Select, Tag, message, Popconfirm, Typography, Divider, Tooltip, Row, Col, InputNumber, Switch, Radio } from 'antd';
|
|
|
|
|
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SyncOutlined, SearchOutlined, SafetyCertificateOutlined, SaveOutlined, ApiOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
|
|
|
|
|
import { useDict } from '../../hooks/useDict';
|
|
|
|
|
|
import {
|
|
|
|
|
|
getAiModelPage,
|
|
|
|
|
|
saveAiModel,
|
|
|
|
|
|
updateAiModel,
|
|
|
|
|
|
deleteAiModel,
|
|
|
|
|
|
getRemoteModelList,
|
|
|
|
|
|
AiModelVO,
|
|
|
|
|
|
AiModelDTO
|
|
|
|
|
|
} from '../../api/business/aimodel';
|
|
|
|
|
|
|
|
|
|
|
|
const { Option } = Select;
|
|
|
|
|
|
const { Text, Title } = Typography;
|
|
|
|
|
|
|
|
|
|
|
|
const AiModels: React.FC = () => {
|
|
|
|
|
|
const [form] = Form.useForm();
|
|
|
|
|
|
const { items: providers } = useDict('biz_ai_provider');
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [data, setData] = useState<AiModelVO[]>([]);
|
|
|
|
|
|
const [total, setTotal] = useState(0);
|
|
|
|
|
|
const [current, setCurrent] = useState(1);
|
|
|
|
|
|
const [size, setSize] = useState(10);
|
|
|
|
|
|
const [searchName, setSearchName] = useState('');
|
|
|
|
|
|
const [searchType, setSearchType] = useState<string | undefined>(undefined);
|
|
|
|
|
|
|
|
|
|
|
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
|
|
|
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
|
|
|
|
const [submitLoading, setSubmitLoading] = useState(false);
|
|
|
|
|
|
const [fetchLoading, setFetchLoading] = useState(false);
|
|
|
|
|
|
const [remoteModels, setRemoteModels] = useState<string[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
const [modelType, setModelType] = useState<'ASR' | 'LLM'>('ASR');
|
2026-03-04 07:19:40 +00:00
|
|
|
|
const watchedModelType = Form.useWatch('modelType', form);
|
|
|
|
|
|
const provider = Form.useWatch('provider', form);
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
// Check if current user is platform admin
|
|
|
|
|
|
const isPlatformAdmin = React.useMemo(() => {
|
|
|
|
|
|
const profileStr = sessionStorage.getItem("userProfile");
|
|
|
|
|
|
if (profileStr) {
|
|
|
|
|
|
const profile = JSON.parse(profileStr);
|
|
|
|
|
|
return profile.isPlatformAdmin === true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchData();
|
|
|
|
|
|
}, [current, size, searchName, searchType]);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchData = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getAiModelPage({ current, size, name: searchName, type: searchType });
|
|
|
|
|
|
if (res.data && res.data.data && res.data.data.records) {
|
|
|
|
|
|
setData(res.data.data.records);
|
|
|
|
|
|
setTotal(res.data.data.total);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOpenDrawer = (record?: AiModelVO) => {
|
|
|
|
|
|
setRemoteModels([]);
|
|
|
|
|
|
if (record) {
|
|
|
|
|
|
setEditingId(record.id);
|
|
|
|
|
|
setModelType(record.modelType);
|
|
|
|
|
|
form.setFieldsValue(record);
|
|
|
|
|
|
if (record.modelCode) setRemoteModels([record.modelCode]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setEditingId(null);
|
|
|
|
|
|
setModelType('ASR');
|
|
|
|
|
|
form.resetFields();
|
|
|
|
|
|
form.setFieldsValue({ status: 1, isDefault: 0, temperature: 0.7, topP: 0.9, modelType: 'ASR' });
|
|
|
|
|
|
}
|
|
|
|
|
|
setDrawerVisible(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleFetchRemote = async () => {
|
|
|
|
|
|
const vals = form.getFieldsValue(['provider', 'baseUrl', 'apiKey']);
|
|
|
|
|
|
if (!vals.provider || !vals.baseUrl) {
|
|
|
|
|
|
message.warning('请先填写提供商和基础地址');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setFetchLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await getRemoteModelList(vals);
|
|
|
|
|
|
// res.data 是后端的 ApiResponse, res.data.data 才是模型字符串数组
|
|
|
|
|
|
if (res.data && Array.isArray(res.data.data)) {
|
|
|
|
|
|
setRemoteModels(res.data.data);
|
|
|
|
|
|
message.success(`成功获取 ${res.data.data.length} 个模型`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setRemoteModels([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setFetchLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const values = await form.validateFields();
|
|
|
|
|
|
setSubmitLoading(true);
|
|
|
|
|
|
|
|
|
|
|
|
if (editingId) {
|
|
|
|
|
|
await updateAiModel({ ...values, id: editingId });
|
|
|
|
|
|
message.success('更新成功');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await saveAiModel(values);
|
|
|
|
|
|
message.success('添加成功');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setDrawerVisible(false);
|
|
|
|
|
|
fetchData();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error(err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const columns = [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '模型名称',
|
|
|
|
|
|
dataIndex: 'modelName',
|
|
|
|
|
|
key: 'modelName',
|
|
|
|
|
|
render: (text: string, record: AiModelVO) => (
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
{text}
|
|
|
|
|
|
{record.isDefault === 1 && <Tag color="gold">默认</Tag>}
|
|
|
|
|
|
{record.tenantId === 0 && <Tooltip title="系统预置"><SafetyCertificateOutlined style={{ color: '#52c41a' }} /></Tooltip>}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '类型',
|
|
|
|
|
|
dataIndex: 'modelType',
|
|
|
|
|
|
key: 'modelType',
|
|
|
|
|
|
render: (type: string) => <Tag color={type === 'ASR' ? 'blue' : 'purple'}>{type === 'ASR' ? '语音识别' : '会议总结'}</Tag>
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '提供商',
|
|
|
|
|
|
dataIndex: 'provider',
|
|
|
|
|
|
key: 'provider',
|
|
|
|
|
|
render: (val: string) => {
|
|
|
|
|
|
const item = providers.find(i => i.itemValue === val);
|
|
|
|
|
|
return item ? <Tag>{item.itemLabel}</Tag> : val;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '模型代码',
|
|
|
|
|
|
dataIndex: 'modelCode',
|
|
|
|
|
|
key: 'modelCode',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '状态',
|
|
|
|
|
|
dataIndex: 'status',
|
|
|
|
|
|
key: 'status',
|
|
|
|
|
|
render: (status: number) => status === 1 ? <Tag color="green">启用</Tag> : <Tag color="default">禁用</Tag>
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '操作',
|
|
|
|
|
|
key: 'action',
|
|
|
|
|
|
render: (_: any, record: AiModelVO) => {
|
|
|
|
|
|
const canEdit = record.tenantId !== 0 || isPlatformAdmin;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Space size="middle">
|
|
|
|
|
|
{canEdit && <Button type="link" icon={<EditOutlined />} onClick={() => handleOpenDrawer(record)}>编辑</Button>}
|
|
|
|
|
|
{canEdit && (
|
|
|
|
|
|
<Popconfirm title="确定删除吗?" onConfirm={() => deleteAiModel(record.id).then(() => fetchData())}>
|
|
|
|
|
|
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
|
|
|
|
|
</Popconfirm>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: '24px' }}>
|
|
|
|
|
|
<Card title="AI 模型配置" extra={
|
|
|
|
|
|
<Space wrap>
|
|
|
|
|
|
<Radio.Group value={searchType} onChange={e => setSearchType(e.target.value)} buttonStyle="solid">
|
|
|
|
|
|
<Radio.Button value={undefined}>全部</Radio.Button>
|
|
|
|
|
|
<Radio.Button value="ASR">语音识别</Radio.Button>
|
|
|
|
|
|
<Radio.Button value="LLM">会议总结</Radio.Button>
|
|
|
|
|
|
</Radio.Group>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
placeholder="搜索模型名称"
|
|
|
|
|
|
prefix={<SearchOutlined />}
|
|
|
|
|
|
allowClear
|
|
|
|
|
|
onPressEnter={(e) => setSearchName((e.target as any).value)}
|
|
|
|
|
|
style={{ width: 180 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleOpenDrawer()}>配置模型</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
}>
|
|
|
|
|
|
<Table columns={columns} dataSource={data} rowKey="id" loading={loading}
|
|
|
|
|
|
pagination={{ current, pageSize: size, total, onChange: (p, s) => { setCurrent(p); setSize(s); }}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Drawer
|
|
|
|
|
|
title={<Title level={4} style={{ margin: 0 }}>{editingId ? '编辑模型配置' : '添加模型配置'}</Title>}
|
|
|
|
|
|
width={600}
|
|
|
|
|
|
onClose={() => setDrawerVisible(false)}
|
|
|
|
|
|
open={drawerVisible}
|
|
|
|
|
|
extra={
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
<Button onClick={() => setDrawerVisible(false)}>取消</Button>
|
|
|
|
|
|
<Button type="primary" icon={<SaveOutlined />} loading={submitLoading} onClick={handleSubmit}>保存配置</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Form form={form} layout="vertical">
|
|
|
|
|
|
<Form.Item name="modelType" label="模型用途" rules={[{ required: true }]}>
|
|
|
|
|
|
<Radio.Group onChange={e => setModelType(e.target.value)} disabled={!!editingId}>
|
|
|
|
|
|
<Radio.Button value="ASR">语音识别 (ASR)</Radio.Button>
|
|
|
|
|
|
<Radio.Button value="LLM">会议总结 (LLM)</Radio.Button>
|
|
|
|
|
|
</Radio.Group>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
|
|
<Row gutter={16}>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="modelName" label="配置显示名称" rules={[{ required: true, message: '请输入显示名称' }]}>
|
|
|
|
|
|
<Input placeholder="如: 阿里云语音-高速版" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
|
|
|
|
|
|
<Select placeholder="选择厂商" allowClear>
|
|
|
|
|
|
{providers.map(item => (
|
|
|
|
|
|
<Option key={item.itemValue} value={item.itemValue}>{item.itemLabel}</Option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Item name="baseUrl" label="API 基础地址 (Base URL)" rules={[{ required: true }]}>
|
|
|
|
|
|
<Input placeholder="https://api.example.com/v1" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
2026-03-04 07:19:40 +00:00
|
|
|
|
{!(watchedModelType === 'ASR' && provider === 'Custom') && (
|
|
|
|
|
|
<Form.Item name="apiKey" label="API Key / Secret" tooltip="密钥将加密存储,仅在更新时需重新输入">
|
|
|
|
|
|
<Input.Password placeholder="输入您的 API 密钥" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
)}
|
2026-03-02 11:59:47 +00:00
|
|
|
|
|
|
|
|
|
|
<Divider orientation="left" style={{ fontSize: '14px', color: '#999' }}>业务参数</Divider>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Item label="模型编码 (Model Code)" required>
|
|
|
|
|
|
<Space.Compact style={{ width: '100%' }}>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="modelCode"
|
|
|
|
|
|
noStyle
|
|
|
|
|
|
rules={[{ required: true, message: '请输入或选择模型编码' }]}
|
|
|
|
|
|
getValueFromEvent={(value) => {
|
|
|
|
|
|
// 如果是数组(tags模式返回数组),取最后一个值作为最终模型编码
|
|
|
|
|
|
return Array.isArray(value) ? value[value.length - 1] : value;
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
placeholder="选择建议或直接输入 (回车确认)"
|
|
|
|
|
|
mode="tags"
|
|
|
|
|
|
maxCount={1}
|
|
|
|
|
|
showSearch
|
|
|
|
|
|
allowClear
|
|
|
|
|
|
style={{ width: 'calc(100% - 100px)' }}
|
|
|
|
|
|
filterOption={(input, option) =>
|
|
|
|
|
|
(option?.children as unknown as string).toLowerCase().includes(input.toLowerCase())
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{remoteModels.map(m => <Option key={m} value={m}>{m}</Option>)}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Button icon={<SyncOutlined spin={fetchLoading} />} onClick={handleFetchRemote} style={{ width: 100 }}>刷新列表</Button>
|
|
|
|
|
|
</Space.Compact>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
|
|
{modelType === 'ASR' && (
|
|
|
|
|
|
<Form.Item name="wsUrl" label="WebSocket 地址" tooltip="留空则根据 Base URL 自动推断 (http->ws)">
|
|
|
|
|
|
<Input placeholder="wss://api.example.com/v1/ws" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{modelType === 'LLM' && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Form.Item name="apiPath" label="API 路径" initialValue="/chat/completions">
|
|
|
|
|
|
<Input placeholder="/chat/completions" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Row gutter={16}>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="temperature" label="Temperature (随机性)">
|
|
|
|
|
|
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="topP" label="Top P (核采样)">
|
|
|
|
|
|
<InputNumber min={0} max={1} step={0.1} style={{ width: '100%' }} />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Row gutter={16}>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Form.Item name="isDefault" label="设为默认" valuePropName="checked">
|
|
|
|
|
|
<Switch checkedChildren="是" unCheckedChildren="否"
|
|
|
|
|
|
onChange={checked => form.setFieldsValue({ isDefault: checked ? 1 : 0 })}
|
|
|
|
|
|
checked={form.getFieldValue('isDefault') === 1}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Form.Item name="status" label="启用状态" valuePropName="checked">
|
|
|
|
|
|
<Switch checkedChildren="开" unCheckedChildren="关"
|
|
|
|
|
|
onChange={checked => form.setFieldsValue({ status: checked ? 1 : 0 })}
|
|
|
|
|
|
checked={form.getFieldValue('status') === 1}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Item name="remark" label="备注说明">
|
|
|
|
|
|
<Input.TextArea rows={2} />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default AiModels;
|