imeeting/frontend/src/pages/business/AiModels.tsx

344 lines
13 KiB
TypeScript
Raw Normal View History

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');
const watchedModelType = Form.useWatch('modelType', form);
const provider = Form.useWatch('provider', form);
// 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>
{!(watchedModelType === 'ASR' && provider === 'Custom') && (
<Form.Item name="apiKey" label="API Key / Secret" tooltip="密钥将加密存储,仅在更新时需重新输入">
<Input.Password placeholder="输入您的 API 密钥" />
</Form.Item>
)}
<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;