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 = { 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("ASR"); const [loading, setLoading] = useState(false); const [data, setData] = useState([]); 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(null); const [submitLoading, setSubmitLoading] = useState(false); const [fetchLoading, setFetchLoading] = useState(false); const [connectivityLoading, setConnectivityLoading] = useState(false); const [remoteModels, setRemoteModels] = useState([]); const [createConfig, setCreateConfig] = useState(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 = {}; 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) => ( {text} {record.isDefault === 1 && 系统默认} {record.tenantDefault === 1 && 租户默认} {record.tenantId === 0 && ( )} {record.scope && ( {record.scope === "PLATFORM" ? "平台级" : "租户级"} )} ), }, { title: "提供商", dataIndex: "provider", key: "provider", render: (value: string) => { const item = providers.find((providerItem) => providerItem.itemValue === value); return item ? {item.itemLabel} : 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 ( void handlePlatformStatusToggle(record, checked)} /> ); } return ( 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 ( {canSetDefault && ( )} {canEdit && ( )} {canEdit && ( handleDelete(record)}> )} ); }, }, ]; const leftActions = ( {activeType === "ASR" && ( )} ); return ( { setActiveType(key as ModelType); setCurrent(1); }} items={[ { key: "ASR", label: "ASR 模型" }, { key: "LLM", label: "LLM 模型" }, ]} size="middle" type="card" /> } > } className="ai-models-search" onSearch={(value) => { setCurrent(1); setSearchName(value.trim()); }} /> } footer={ { setCurrent(page); setSize(pageSize); }} /> } > setDrawerVisible(false)} title={{editingId ? "编辑模型" : "新增模型"}} forceRender extra={ } >
{activeType === "ASR" ? "语音识别 (ASR)" : "大语言模型 (LLM)"}
{ modelNameAutoFilledRef.current = false; }}/> {!isTencentProvider && ( <> )} {(activeType === "LLM" || isLocalProvider) && ( )} 模型参数 {activeType === "ASR" && isLocalProvider && ( )} {activeType === "ASR" && isTencentProvider && ( )} {activeType === "LLM" && ( <> )} { if (checked) { form.setFieldValue("statusChecked", true); } }} /> ); }; export default AiModels;