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

819 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useEffect, useMemo, useRef, useState } from "react";
import {
App,
AutoComplete,
Button,
Col,
Divider,
Drawer,
Form,
Input,
InputNumber,
Popconfirm,
Row,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
Tooltip,
Typography,
} from "antd";
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
SafetyCertificateOutlined,
SaveOutlined,
SearchOutlined,
SyncOutlined,
WifiOutlined,
} from "@ant-design/icons";
import PageContainer from "@/components/shared/PageContainer";
import DataListPanel from "@/components/shared/DataListPanel";
import SectionCard from "@/components/shared/SectionCard";
import AppPagination from "../../components/shared/AppPagination";
import { useDict } from "../../hooks/useDict";
import {
AiLocalProfileVO,
AiModelDTO,
AiModelVO,
deleteAiModelByType,
getAiModelPage,
getRemoteModelList,
saveAiModel,
setTenantDefaultModel,
syncCurrentAsrSpeakers,
tenantDisableModel,
tenantEnableModel,
testLlmModelConnectivity,
testLocalModelConnectivity,
updateAiModel,
updatePlatformModelStatus,
} from "../../api/business/aimodel";
import {getMeetingCreateConfig, type MeetingCreateConfig} from "../../api/business/meeting";
import "./AiModels.css";
const { Option } = Select;
const { Title } = Typography;
type ModelType = "ASR" | "LLM";
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
offlineEnabled: true,
realtimeEnabled: false,
offlineAudioMaxSizeMb: 1024,
};
const PROVIDER_BASE_URL_MAP: Record<string, string> = {
openai: "https://api.openai.com",
deepseek: "https://api.deepseek.com",
aliyun: "https://dashscope.aliyuncs.com/compatible-mode",
qwen: "https://dashscope.aliyuncs.com/compatible-mode",
dashscope: "https://dashscope.aliyuncs.com/compatible-mode",
moonshot: "https://api.moonshot.cn",
kimi: "https://api.moonshot.cn",
groq: "https://api.groq.com/openai",
};
const DEFAULT_LLM_TEST_MESSAGE = "请只返回固定成功结果,用于 LLM 连通性测试。";
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 [createConfig, setCreateConfig] = useState<MeetingCreateConfig>(DEFAULT_CREATE_CONFIG);
const modelNameAutoFilledRef = useRef(false);
const localProfileLoadedRef = useRef(false);
const provider = Form.useWatch("provider", form);
const isDefaultChecked = Form.useWatch("isDefaultChecked", form);
const isLocalProvider = String(provider || "").toLowerCase() === "local";
const isTencentProvider = String(provider || "").toLowerCase() === "tencent";
const isPlatformAdmin = useMemo(() => {
const profileStr = sessionStorage.getItem("userProfile");
if (!profileStr) {
return false;
}
try {
const profile = JSON.parse(profileStr);
return profile.isPlatformAdmin === true;
} catch {
return false;
}
}, []);
useEffect(() => {
void fetchData();
}, [current, size, searchName, activeType]);
useEffect(() => {
getMeetingCreateConfig()
.then((res) => {
const config = (res as any)?.data?.data ?? (res as any);
setCreateConfig({
...DEFAULT_CREATE_CONFIG,
...(config || {}),
});
})
.catch(() => {
setCreateConfig(DEFAULT_CREATE_CONFIG);
});
}, []);
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);
}
}, [drawerVisible, editingId, form, provider, providers]);
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, editingId, form, isLocalProvider]);
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([]);
modelNameAutoFilledRef.current = false;
localProfileLoadedRef.current = false;
if (record) {
setEditingId(record.id);
form.setFieldsValue({
...record,
modelType: record.modelType,
speakerModel: record.mediaConfig?.speakerModel,
svThreshold: record.mediaConfig?.svThreshold,
tencentAppId: record.mediaConfig?.tencentAppId,
tencentSecretId: record.mediaConfig?.tencentSecretId,
tencentSecretKey: record.mediaConfig?.tencentSecretKey,
tencentOfflineModelCode: record.mediaConfig?.tencentOfflineModelCode || record.modelCode,
tencentRealtimeModelCode: record.mediaConfig?.tencentRealtimeModelCode || record.modelCode,
isDefaultChecked: record.isDefault === 1,
statusChecked: record.status === 1,
});
if (record.modelCode) {
setRemoteModels([record.modelCode]);
}
} else {
setEditingId(null);
form.resetFields();
form.setFieldsValue({
modelType: activeType,
isDefaultChecked: false,
statusChecked: true,
sortOrder: 0,
temperature: 0.2,
topP: 0.9,
apiPath: "/v1/chat/completions",
svThreshold: 0.45,
});
}
setDrawerVisible(true);
};
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 : [];
setRemoteModels(nextRemoteModels);
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();
if (values.isDefaultChecked && !values.statusChecked) {
message.warning("默认模型必须保持启用状态");
return;
}
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: activeType === "ASR" && isTencentProvider ? values.tencentOfflineModelCode : values.modelCode,
wsUrl: activeType === "ASR" ? values.wsUrl : undefined,
mediaConfig:
activeType === "ASR" && isLocalProvider
? {
speakerModel: values.speakerModel,
svThreshold: values.svThreshold,
}
: activeType === "ASR" && isTencentProvider
? {
tencentAppId: values.tencentAppId,
tencentSecretId: values.tencentSecretId,
tencentSecretKey: values.tencentSecretKey,
tencentOfflineModelCode: values.tencentOfflineModelCode,
tencentRealtimeModelCode: values.tencentRealtimeModelCode,
}
: undefined,
temperature: values.temperature,
topP: values.topP,
isDefault: values.isDefaultChecked ? 1 : 0,
status: values.statusChecked ? 1 : 0,
sortOrder: values.sortOrder ?? 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 () => {
if (activeType === "LLM") {
const values = await form.validateFields(["provider", "baseUrl", "apiPath", "modelCode"]);
const extraValues = form.getFieldsValue(["apiKey", "temperature", "topP"]);
setConnectivityLoading(true);
try {
await testLlmModelConnectivity({
provider: values.provider,
baseUrl: values.baseUrl,
apiPath: values.apiPath,
apiKey: extraValues.apiKey,
modelCode: values.modelCode,
temperature: extraValues.temperature,
topP: extraValues.topP,
testMessage: DEFAULT_LLM_TEST_MESSAGE,
});
message.success("LLM 连通性测试成功");
} finally {
setConnectivityLoading(false);
}
return;
}
const values = await form.validateFields(["provider", "baseUrl"]);
if (String(values.provider || "").toLowerCase() !== "local") {
message.warning("只有本地 ASR 支持该连通性测试");
return;
}
const { apiKey } = form.getFieldsValue(["apiKey"]);
setConnectivityLoading(true);
try {
const res = await testLocalModelConnectivity({
baseUrl: values.baseUrl,
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 handleTenantToggle = async (record: AiModelVO, checked: boolean) => {
if (checked) {
await tenantEnableModel(record.id, activeType);
message.success(activeType === "ASR" ? "已切换当前 ASR" : "已启用当前 LLM");
} else {
await tenantDisableModel(record.id, activeType);
message.success(activeType === "ASR" ? "已关闭当前 ASR" : "已关闭当前 LLM");
}
await fetchData();
};
const handlePlatformStatusToggle = async (record: AiModelVO, checked: boolean) => {
await updatePlatformModelStatus(record.id, activeType, checked ? 1 : 0);
message.success(checked ? `平台级 ${activeType} 已启用` : `平台级 ${activeType} 已禁用`);
await fetchData();
};
const handleSyncCurrentAsr = async () => {
await syncCurrentAsrSpeakers();
message.success("已提交后台同步任务");
};
const handleSetTenantDefault = async (record: AiModelVO) => {
await setTenantDefaultModel(record.id, "LLM");
message.success("已设置为默认 LLM");
await fetchData();
};
const resolvedTableColumns = [
{
title: "模型名称",
dataIndex: "modelName",
key: "modelName",
render: (text: string, record: AiModelVO) => (
<Space>
{text}
{record.isDefault === 1 && <Tag color="gold"></Tag>}
{record.tenantDefault === 1 && <Tag color="blue"></Tag>}
{record.tenantId === 0 && (
<Tooltip title="平台透传模型">
<SafetyCertificateOutlined style={{ color: "#52c41a" }} />
</Tooltip>
)}
{record.scope && (
<Tag bordered={false} color={record.scope === "PLATFORM" ? "geekblue" : "default"}>
{record.scope === "PLATFORM" ? "平台级" : "租户级"}
</Tag>
)}
</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: "模型编码",
dataIndex: "modelCode",
key: "modelCode",
},
{
title: "排序",
dataIndex: "sortOrder",
key: "sortOrder",
render: (value: number | undefined) => value ?? 0,
},
{
title: "状态",
dataIndex: "status",
key: "status",
render: (status: number, record: AiModelVO) => {
if (isPlatformAdmin && record.scope === "PLATFORM") {
return (
<Switch
checked={status === 1}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(checked) => void handlePlatformStatusToggle(record, checked)}
/>
);
}
return (
<Switch
checked={record.tenantEnabled === 1}
checkedChildren={activeType === "ASR" ? "当前生效" : "已启用"}
unCheckedChildren={activeType === "ASR" ? "未启用" : "已关闭"}
disabled={status !== 1}
onChange={(checked) => void handleTenantToggle(record, checked)}
/>
);
},
},
{
title: "操作",
key: "action",
render: (_: unknown, record: AiModelVO) => {
const canEdit = record.canEditConfig ?? (record.tenantId !== 0 || isPlatformAdmin);
const canSetDefault = activeType === "LLM" && record.tenantEnabled === 1;
return (
<Space>
{canSetDefault && (
<Button type="link" onClick={() => void handleSetTenantDefault(record)}>
{record.tenantDefault === 1 ? "默认 LLM" : "设为默认"}
</Button>
)}
{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>
);
},
},
];
const leftActions = (
<Space wrap>
<Button type="primary" icon={<PlusOutlined/>} onClick={() => openDrawer()}>
</Button>
{activeType === "ASR" && (
<Button icon={<SyncOutlined/>} onClick={() => void handleSyncCurrentAsr()}>
ASR
</Button>
)}
</Space>
);
return (
<PageContainer title={null} className="ai-models-page">
<SectionCard
title="AI 模型配置"
description="管理 ASR 语音识别和 LLM 大语言模型。"
tabs={
<Tabs
activeKey={activeType}
onChange={(key) => {
setActiveType(key as ModelType);
setCurrent(1);
}}
items={[
{ key: "ASR", label: "ASR 模型" },
{ key: "LLM", label: "LLM 模型" },
]}
size="middle"
type="card"
/>
}
>
<DataListPanel
className="ai-models-data-panel"
leftActions={leftActions}
rightActions={
<Input.Search
allowClear
placeholder="搜索模型名称"
prefix={<SearchOutlined />}
className="ai-models-search"
onSearch={(value) => {
setCurrent(1);
setSearchName(value.trim());
}}
/>
}
footer={
<AppPagination
current={current}
pageSize={size}
total={total}
onChange={(page, pageSize) => {
setCurrent(page);
setSize(pageSize);
}}
/>
}
>
<Table
rowKey="id"
columns={resolvedTableColumns}
dataSource={data}
loading={loading}
scroll={{ x: "max-content", y: "100%" }}
pagination={false}
/>
</DataListPanel>
</SectionCard>
<Drawer
width={600}
open={drawerVisible}
onClose={() => setDrawerVisible(false)}
title={<Title level={4} style={{ margin: 0 }}>{editingId ? "编辑模型" : "新增模型"}</Title>}
forceRender
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>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="sortOrder" label="排序值">
<InputNumber min={0} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
{!isTencentProvider && (
<>
<Form.Item name="baseUrl" label="Base URL" rules={[{required: true, message: "请输入 Base URL"}]}>
<Input placeholder="https://api.example.com"/>
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password/>
</Form.Item>
</>
)}
{(activeType === "LLM" || 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={activeType === "LLM"}
hidden={activeType === "ASR" && isTencentProvider}
tooltip="可从远程列表选择,也可手动输入;该值会作为模型编码传给后端"
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item
name="modelCode"
noStyle
rules={activeType === "LLM" ? [{required: true, message: "请输入或选择模型编码"}] : []}
>
<AutoComplete
style={{ width: "calc(100% - 100px)" }}
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 allowClear placeholder="可选择或手动输入模型编码"/>
</AutoComplete>
</Form.Item>
{!isTencentProvider && (
<Button icon={<SyncOutlined spin={fetchLoading}/>} onClick={handleFetchRemote} style={{width: 100}}>
</Button>
)}
</Space.Compact>
</Form.Item>
<Form.Item name="wsUrl" label="WebSocket 地址"
hidden={!(activeType === "ASR" && createConfig.realtimeEnabled)}>
<Input placeholder="wss://api.example.com/v1/ws" />
</Form.Item>
{activeType === "ASR" && isLocalProvider && (
<Row gutter={16} hidden>
<Col span={12}>
<Form.Item name="svThreshold" label="声纹阈值">
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
)}
{activeType === "ASR" && isTencentProvider && (
<Row gutter={16}>
<Col span={12}>
<Form.Item name="tencentAppId" label="App ID" rules={[{required: true, message: "请输入 App ID"}]}>
<Input/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="tencentSecretId" label="Secret ID"
rules={[{required: true, message: "请输入 Secret ID"}]}>
<Input/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="tencentSecretKey" label="Secret Key"
rules={[{required: true, message: "请输入 Secret Key"}]}>
<Input.Password/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="tencentOfflineModelCode" label="离线识别模型"
rules={[{required: true, message: "请输入离线识别模型"}]}>
<Input placeholder="例如16k_zh"/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="tencentRealtimeModelCode" label="实时识别模型"
rules={[{required: true, message: "请输入实时识别模型"}]}>
<Input placeholder="例如16k_zh_realtime"/>
</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="否"
onChange={(checked) => {
if (checked) {
form.setFieldValue("statusChecked", true);
}
}}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="statusChecked" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" disabled={Boolean(isDefaultChecked)} />
</Form.Item>
</Col>
</Row>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Drawer>
</PageContainer>
);
};
export default AiModels;