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

603 lines
19 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useMemo, useRef, useState } from "react";
import { AutoComplete, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tabs, Tag, Tooltip, Typography, App } from 'antd';
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SaveOutlined,
SearchOutlined,
SyncOutlined,
WifiOutlined,
} from "@ant-design/icons";
import { useDict } from "../../hooks/useDict";
import {
AiModelDTO,
AiLocalProfileVO,
AiModelVO,
deleteAiModelByType,
getAiModelPage,
getRemoteModelList,
saveAiModel,
testLocalModelConnectivity,
updateAiModel,
} from "../../api/business/aimodel";
const { Option } = Select;
const { Title } = Typography;
type ModelType = "ASR" | "LLM";
const PROVIDER_BASE_URL_MAP: Record<string, string> = {
openai: "https://api.openai.com/v1",
deepseek: "https://api.deepseek.com",
aliyun: "https://dashscope.aliyuncs.com/compatible-mode/v1",
qwen: "https://dashscope.aliyuncs.com/compatible-mode/v1",
dashscope: "https://dashscope.aliyuncs.com/compatible-mode/v1",
moonshot: "https://api.moonshot.cn/v1",
kimi: "https://api.moonshot.cn/v1",
groq: "https://api.groq.com/openai/v1",
};
const AiModels: React.FC = () => {
const { message } = App.useApp();
const [form] = Form.useForm();
const { items: providers } = useDict("biz_ai_provider");
const [activeType, setActiveType] = useState<ModelType>("ASR");
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 [drawerVisible, setDrawerVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [submitLoading, setSubmitLoading] = useState(false);
const [fetchLoading, setFetchLoading] = useState(false);
const [connectivityLoading, setConnectivityLoading] = useState(false);
const [remoteModels, setRemoteModels] = useState<string[]>([]);
const [speakerModels, setSpeakerModels] = useState<string[]>([]);
const modelNameAutoFilledRef = useRef(false);
const localProfileLoadedRef = useRef(false);
const provider = Form.useWatch("provider", form);
const isLocalProvider = String(provider || "").toLowerCase() === "custom";
const isPlatformAdmin = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
if (!profileStr) {
return false;
}
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true;
}, []);
useEffect(() => {
void fetchData();
}, [current, size, searchName, activeType]);
useEffect(() => {
if (!drawerVisible || !provider) {
return;
}
const providerItem = providers.find((item) => item.itemValue === provider);
const providerLabel = providerItem?.itemLabel || provider;
const currentDisplayName = form.getFieldValue("modelName");
if (!editingId && (!currentDisplayName || modelNameAutoFilledRef.current)) {
form.setFieldValue("modelName", providerLabel);
modelNameAutoFilledRef.current = true;
}
const baseUrl = form.getFieldValue("baseUrl");
const providerKey = String(provider).toLowerCase();
const defaultBaseUrl = PROVIDER_BASE_URL_MAP[providerKey];
if (!baseUrl && defaultBaseUrl) {
form.setFieldValue("baseUrl", defaultBaseUrl);
}
}, [provider, drawerVisible, editingId, providers, form]);
const fetchData = async () => {
setLoading(true);
try {
const res = await getAiModelPage({
current,
size,
name: searchName || undefined,
type: activeType,
});
const pageData = (res as any)?.data?.data ?? (res as any);
setData(pageData?.records || []);
setTotal(pageData?.total || 0);
} finally {
setLoading(false);
}
};
const openDrawer = (record?: AiModelVO) => {
setRemoteModels([]);
setSpeakerModels([]);
modelNameAutoFilledRef.current = false;
localProfileLoadedRef.current = false;
if (record) {
setEditingId(record.id);
const speakerModel = record.mediaConfig?.speakerModel;
const svThreshold = record.mediaConfig?.svThreshold;
form.setFieldsValue({
...record,
modelType: record.modelType,
speakerModel,
svThreshold,
isDefaultChecked: record.isDefault === 1,
statusChecked: record.status === 1,
});
if (record.modelCode) {
setRemoteModels([record.modelCode]);
}
if (speakerModel) {
setSpeakerModels([String(speakerModel)]);
}
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({
modelType: activeType,
isDefaultChecked: false,
statusChecked: true,
temperature: 0.7,
topP: 0.9,
apiPath: "/v1/chat/completions",
svThreshold: 0.45,
});
}
setDrawerVisible(true);
};
useEffect(() => {
if (!drawerVisible || !isLocalProvider || !editingId || localProfileLoadedRef.current) {
return;
}
const values = form.getFieldsValue(["baseUrl", "apiKey"]);
if (!values.baseUrl || !values.apiKey) {
return;
}
localProfileLoadedRef.current = true;
void handleTestConnectivity();
}, [drawerVisible, isLocalProvider, editingId, form]);
const handleFetchRemote = async () => {
if (isLocalProvider) {
await handleTestConnectivity();
return;
}
const values = form.getFieldsValue(["provider", "baseUrl", "apiKey"]);
if (!values.provider || !values.baseUrl) {
message.warning("请先填写提供商和 Base URL");
return;
}
setFetchLoading(true);
try {
const res = await getRemoteModelList(values);
const rawModels = (res as any)?.data?.data ?? (Array.isArray(res) ? res : []);
const models = Array.isArray(rawModels) ? rawModels : [];
setRemoteModels(models);
message.success(`获取到 ${models.length} 个模型`);
} finally {
setFetchLoading(false);
}
};
const resolveLocalWsUrl = (baseUrl: string, wsEndpoint?: string) => {
if (!wsEndpoint) {
return undefined;
}
try {
const base = new URL(baseUrl);
const endpoint = wsEndpoint.startsWith("/") ? wsEndpoint : `/${wsEndpoint}`;
const protocol = base.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${base.host}${endpoint}`;
} catch {
return undefined;
}
};
const applyLocalProfile = (profile: AiLocalProfileVO, baseUrl: string) => {
const nextRemoteModels = Array.isArray(profile.asrModels) ? profile.asrModels : [];
const nextSpeakerModels = Array.isArray(profile.speakerModels) ? profile.speakerModels : [];
setRemoteModels(nextRemoteModels);
setSpeakerModels(nextSpeakerModels);
const nextValues: Record<string, unknown> = {};
if (profile.activeAsrModel) {
nextValues.modelCode = profile.activeAsrModel;
}
if (profile.activeSpeakerModel) {
nextValues.speakerModel = profile.activeSpeakerModel;
}
if (profile.svThreshold !== undefined) {
nextValues.svThreshold = profile.svThreshold;
}
const wsUrl = resolveLocalWsUrl(baseUrl, profile.wsEndpoint);
if (wsUrl) {
nextValues.wsUrl = wsUrl;
}
form.setFieldsValue(nextValues);
};
const handleSubmit = async () => {
const values = await form.validateFields();
const payload: AiModelDTO = {
id: editingId ?? undefined,
modelType: values.modelType,
modelName: values.modelName,
provider: values.provider,
baseUrl: values.baseUrl,
apiPath: values.apiPath,
apiKey: values.apiKey,
modelCode: values.modelCode,
wsUrl: values.wsUrl,
mediaConfig:
activeType === "ASR" && isLocalProvider
? {
speakerModel: values.speakerModel,
svThreshold: values.svThreshold,
}
: undefined,
temperature: values.temperature,
topP: values.topP,
isDefault: values.isDefaultChecked ? 1 : 0,
status: values.statusChecked ? 1 : 0,
remark: values.remark,
};
setSubmitLoading(true);
try {
if (editingId) {
await updateAiModel(payload);
message.success("更新成功");
} else {
await saveAiModel(payload);
message.success("新增成功");
}
setDrawerVisible(false);
void fetchData();
} finally {
setSubmitLoading(false);
}
};
const handleTestConnectivity = async () => {
const values = await form.validateFields(["provider", "baseUrl", "apiKey"]);
if (String(values.provider || "").toLowerCase() !== "custom") {
message.warning("仅本地模型支持连通性测试");
return;
}
setConnectivityLoading(true);
try {
const res = await testLocalModelConnectivity({
baseUrl: values.baseUrl,
apiKey: values.apiKey,
});
const profile = (res as any)?.data?.data ?? (res as any)?.data ?? (res as any);
applyLocalProfile(profile as AiLocalProfileVO, values.baseUrl);
message.success("本地模型连通性测试成功");
} finally {
setConnectivityLoading(false);
}
};
const handleDelete = async (record: AiModelVO) => {
await deleteAiModelByType(record.id, record.modelType);
message.success("删除成功");
void fetchData();
};
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: "provider",
key: "provider",
render: (value: string) => {
const item = providers.find((providerItem) => providerItem.itemValue === value);
return item ? <Tag>{item.itemLabel}</Tag> : value;
},
},
{ title: "模型名称(code)", dataIndex: "modelCode", key: "modelCode" },
{
title: "状态",
dataIndex: "status",
key: "status",
render: (status: number) =>
status === 1 ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: "操作",
key: "action",
render: (_: unknown, record: AiModelVO) => {
const canEdit = record.tenantId !== 0 || isPlatformAdmin;
return (
<Space>
{canEdit && (
<Button type="link" icon={<EditOutlined />} onClick={() => openDrawer(record)}>
</Button>
)}
{canEdit && (
<Popconfirm title="确定删除吗?" onConfirm={() => handleDelete(record)}>
<Button type="link" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
</Space>
);
},
},
];
return (
<div style={{ padding: 24 }}>
<Card
title="AI 模型配置"
extra={
<Space>
<Input
placeholder="搜索模型名称"
prefix={<SearchOutlined />}
allowClear
onPressEnter={(event) => setSearchName((event.target as HTMLInputElement).value)}
style={{ width: 220 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
</Button>
</Space>
}
>
<Tabs
activeKey={activeType}
onChange={(key) => {
setActiveType(key as ModelType);
setCurrent(1);
}}
items={[
{ key: "ASR", label: "ASR 模型" },
{ key: "LLM", label: "LLM 模型" },
]}
/>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current,
pageSize: size,
total,
onChange: (page, pageSize) => {
setCurrent(page);
setSize(pageSize);
},
}}
/>
</Card>
<Drawer
width={600}
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
title={<Title level={4} style={{ margin: 0 }}>{editingId ? "编辑模型" : "新增模型"}</Title>}
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" hidden>
<Input />
</Form.Item>
<Form.Item label="模型类型">
<Tag color={activeType === "ASR" ? "blue" : "purple"}>
{activeType === "ASR" ? "语音识别 (ASR)" : "总结模型 (LLM)"}
</Tag>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="modelName"
label="显示名称"
rules={[{ required: true, message: "请输入显示名称" }]}
>
<Input
onChange={() => {
modelNameAutoFilledRef.current = false;
}}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="provider"
label="提供商"
rules={[{ required: true, message: "请选择提供商" }]}
>
<Select allowClear placeholder="请选择">
{providers.map((item) => (
<Option key={item.itemValue} value={item.itemValue}>
{item.itemLabel}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请输入 Base URL" }]}>
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={isLocalProvider ? [{ required: true, message: "本地模型必须填写 API Key" }] : undefined}
>
<Input.Password />
</Form.Item>
{isLocalProvider && (
<Form.Item label="连通性测试">
<Button icon={<WifiOutlined />} loading={connectivityLoading} onClick={handleTestConnectivity}>
</Button>
</Form.Item>
)}
<Divider orientation="left" style={{ fontSize: 14, color: "#999" }}>
</Divider>
<Form.Item
label="模型名称"
required
tooltip="可从远程列表选择,也可手动输入;值将作为模型 code 传给后端"
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item
name="modelCode"
noStyle
rules={[{ required: true, message: "请输入或选择模型名称" }]}
>
<AutoComplete
allowClear
style={{ width: "calc(100% - 100px)" }}
placeholder="可选择或自定义输入模型名称"
onFocus={() => {
if (isLocalProvider && remoteModels.length === 0) {
void handleFetchRemote();
}
}}
options={remoteModels.map((model) => ({ value: model }))}
filterOption={(inputValue, option) =>
isLocalProvider ||
String(option?.value || "").toLowerCase().includes(inputValue.toLowerCase())
}
>
<Input />
</AutoComplete>
</Form.Item>
<Button icon={<SyncOutlined spin={fetchLoading} />} onClick={handleFetchRemote} style={{ width: 100 }}>
</Button>
</Space.Compact>
</Form.Item>
{activeType === "ASR" && (
<Form.Item name="wsUrl" label="WebSocket 地址">
<Input placeholder="wss://api.example.com/v1/ws" />
</Form.Item>
)}
{activeType === "ASR" && isLocalProvider && (
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="speakerModel"
label="声纹模型"
rules={[{ required: true, message: "请选择声纹模型" }]}
>
<Select
allowClear
placeholder="请先测试连接获取声纹模型"
options={speakerModels.map((model) => ({ label: model, value: model }))}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="svThreshold"
label="声纹阈值"
rules={[{ required: true, message: "请输入声纹阈值" }]}
>
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
)}
{activeType === "LLM" && (
<>
<Form.Item name="apiPath" label="API 路径" initialValue="/v1/chat/completions">
<Input />
</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="isDefaultChecked" label="设为默认" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="statusChecked" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default AiModels;