import React, { useEffect, useMemo, useState } from "react"; import { App, Avatar, Button, Col, Collapse, DatePicker, Drawer, Form, Input, Progress, Radio, Row, Select, Skeleton, Space, Switch, Tag, Tooltip, Typography, Upload, } from "antd"; import { AudioOutlined, CheckOutlined, CloudUploadOutlined, QuestionCircleOutlined, SettingOutlined, UserOutlined, } from "@ant-design/icons"; import dayjs from "dayjs"; import { useNavigate } from "react-router-dom"; import { listUsers } from "../../api"; import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel"; import { getHotWordPage, type HotWordVO } from "../../api/business/hotword"; import { getHotWordGroupOptions, type HotWordGroupVO } from "../../api/business/hotwordGroup"; import { createMeeting, createRealtimeMeeting, getMeetingCreateConfig, type CreateRealtimeMeetingCommand, type MeetingCreateConfig, type SummaryDetailLevel, uploadAudio, } from "../../api/business/meeting"; import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt"; import type { SysUser } from "../../types"; import "./MeetingCreateDrawer.css"; const { Dragger } = Upload; const { Option } = Select; const { Text, Title } = Typography; export type MeetingCreateType = "upload" | "realtime"; const DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB = 1024; const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = { offlineEnabled: true, realtimeEnabled: true, offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB, }; interface MeetingCreateDrawerProps { open: boolean; initialType?: MeetingCreateType; onCancel: () => void; onSuccess: () => void; } type RealtimeMeetingSessionDraft = { meetingId: number; meetingTitle: string; asrModelName: string; summaryModelName: string; asrModelId: number; mode: string; language: string; useSpkId: number; enablePunctuation: boolean; enableItn: boolean; enableTextRefine: boolean; saveAudio: boolean; hotwords: Array<{ hotword: string; weight: number }>; }; function resolveAvailableCreateTypes(config: MeetingCreateConfig): MeetingCreateType[] { const types: MeetingCreateType[] = []; if (config.offlineEnabled) { types.push("upload"); } if (config.realtimeEnabled) { types.push("realtime"); } return types; } function resolveAvailableCreateType(initialType: MeetingCreateType, config: MeetingCreateConfig): MeetingCreateType { const availableTypes = resolveAvailableCreateTypes(config); if (availableTypes.includes(initialType)) { return initialType; } return availableTypes[0] || initialType; } function resolveWsUrl(model?: AiModelVO | null) { if (model?.wsUrl) return model.wsUrl; if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://"); return ""; } function getSessionKey(meetingId: number) { return `realtimeMeetingSession:${meetingId}`; } export const MeetingCreateDrawer: React.FC = ({ open, initialType = "upload", onCancel, onSuccess, }) => { const { message } = App.useApp(); const navigate = useNavigate(); const [form] = Form.useForm(); const [type, setType] = useState(initialType); const [loading, setLoading] = useState(false); const [configLoaded, setConfigLoaded] = useState(false); const [submitting, setSubmitting] = useState(false); const [createConfig, setCreateConfig] = useState({ offlineEnabled: false, realtimeEnabled: false, offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB, }); const [asrModels, setAsrModels] = useState([]); const [llmModels, setLlmModels] = useState([]); const [prompts, setPrompts] = useState([]); const [hotwordList, setHotwordList] = useState([]); const [hotWordGroups, setHotWordGroups] = useState([]); const [userList, setUserList] = useState([]); const [audioUrl, setAudioUrl] = useState(""); const [uploadProgress, setUploadProgress] = useState(0); const [fileList, setFileList] = useState([]); const watchedAsrModelId = Form.useWatch("asrModelId", form); const watchedSummaryModelId = Form.useWatch("summaryModelId", form); const watchedPromptId = Form.useWatch("promptId", form); const watchedHotWordGroupId = Form.useWatch("hotWordGroupId", form); const selectedAsrModel = useMemo( () => asrModels.find((item) => item.id === watchedAsrModelId) || null, [asrModels, watchedAsrModelId] ); const selectedSummaryModel = useMemo( () => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId] ); const selectedPrompt = useMemo( () => prompts.find((item) => item.id === watchedPromptId) || null, [prompts, watchedPromptId] ); const availableTypes = useMemo(() => resolveAvailableCreateTypes(createConfig), [createConfig]); const offlineAudioMaxSizeBytes = useMemo( () => (createConfig.offlineAudioMaxSizeMb || DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB) * 1024 * 1024, [createConfig] ); useEffect(() => { if (!open) { return; } setAudioUrl(""); setUploadProgress(0); setFileList([]); void loadInitialData(); }, [open, initialType]); const loadInitialData = async () => { setLoading(true); try { const [asrRes, llmRes, promptRes, hotwordRes, hotWordGroupRes, users, defaultAsr, defaultLlm, createConfigRes] = await Promise.all([ getAiModelPage({current: 1, size: 100, type: "ASR", tenantEnabledOnly: true}), getAiModelPage({current: 1, size: 100, type: "LLM", tenantEnabledOnly: true}), getPromptPage({ current: 1, size: 100 }), getHotWordPage({ current: 1, size: 1000 }), getHotWordGroupOptions(), listUsers(), getAiModelDefault("ASR"), getAiModelDefault("LLM"), getMeetingCreateConfig(), ]); const nextConfig = createConfigRes.data.data || DEFAULT_CREATE_CONFIG; const nextType = resolveAvailableCreateType(initialType, nextConfig); const promptRecords = promptRes.data?.data?.records || []; const asrRecords = asrRes.data?.data?.records || []; const llmRecords = llmRes.data?.data?.records || []; const hotwordRecords = hotwordRes.data?.data?.records || []; const activeLlmModels = llmRecords.filter((item: AiModelVO) => item.status === 1); const activePrompts = promptRecords.filter((item: PromptTemplateVO) => item.status === 1); setCreateConfig(nextConfig); setConfigLoaded(true); setType(nextType); setAsrModels(asrRecords.filter((item: AiModelVO) => item.status === 1)); setLlmModels(activeLlmModels); setPrompts(activePrompts); setHotwordList(hotwordRecords.filter((item: HotWordVO) => item.status === 1)); setHotWordGroups((hotWordGroupRes.data.data || []).filter((item: HotWordGroupVO) => item.status === 1)); setUserList(users || []); const defaultPrompt = activePrompts.find((item: PromptTemplateVO) => item.isDefault && item.defaultAvailable) || activePrompts[0]; const defaultSummaryModel = activeLlmModels.find((item: AiModelVO) => item.id === defaultLlm.data.data?.id) || activeLlmModels.find((item: AiModelVO) => item.isDefault === 1) || activeLlmModels[0]; form.setFieldsValue({ title: nextType === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, meetingTime: dayjs(), asrModelId: defaultAsr.data.data?.id, summaryModelId: defaultSummaryModel?.id, promptId: defaultPrompt?.id, hotWordGroupId: defaultPrompt?.hotWordGroupId ?? 0, summaryDetailLevel: "STANDARD", useSpkId: 1, enableTextRefine: true, mode: "2pass", language: "auto", enablePunctuation: true, enableItn: true, saveAudio: false, }); } catch { message.error("加载配置失败"); } finally { setLoading(false); } }; useEffect(() => { if (!open) { return; } const currentTitle = form.getFieldValue("title"); if (typeof currentTitle !== "string") { return; } if (currentTitle.startsWith("文件会议") || currentTitle.startsWith("实时会议")) { form.setFieldsValue({ title: type === "upload" ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, }); } }, [form, open, type]); const beforeAudioUpload = (file: File) => { if (file.size > offlineAudioMaxSizeBytes) { message.error(`录音文件大小不能超过 ${createConfig.offlineAudioMaxSizeMb}MB`); setUploadProgress(0); return Upload.LIST_IGNORE; } return true; }; const customUpload = async (options: any) => { const { file, onSuccess: uploadSuccess, onError, onProgress } = options; setUploadProgress(0); try { const res = await uploadAudio(file, (progressEvent) => { if (progressEvent.total) { const percent = Math.min(99, Math.round((progressEvent.loaded * 100) / progressEvent.total)); setUploadProgress(percent); onProgress({ percent }); } }); setUploadProgress(100); onProgress({ percent: 100 }); setAudioUrl(res.data.data); uploadSuccess(res.data.data); message.success("录音上传成功"); } catch (err) { onError(err); message.error("文件上传失败"); } }; const handleOk = async () => { if (availableTypes.length === 0) { message.error("当前系统参数已关闭全部会议创建入口"); return; } if (!availableTypes.includes(type)) { const fallbackType = availableTypes[0]; if (fallbackType) { setType(fallbackType); } message.warning("当前入口已关闭,已切换到可用创建方式"); return; } if (type === "upload" && !audioUrl) { message.error("请先上传录音文件"); return; } const values = await form.validateFields(); if (type === "realtime") { const wsUrl = resolveWsUrl(selectedAsrModel); if (!wsUrl) { message.error("当前 ASR 模型未配置 WebSocket 地址"); return; } } if (!values.promptId) { message.error("总结模板为空"); return; } setSubmitting(true); try { const { hostUserId, ...meetingValues } = values; const selectedHotWords = meetingValues.hotWordGroupId == null || meetingValues.hotWordGroupId === 0 ? undefined : hotwordList .filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId) .map((item) => item.word) .filter((word) => !!word?.trim()); if (type === "upload") { await createMeeting({ ...meetingValues, ...(hostUserId != null ? { hostUserId } : {}), meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"), audioUrl, participants: meetingValues.participants?.join(","), tags: meetingValues.tags?.join(","), summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel, hotWords: selectedHotWords, }); message.success("会议发起成功"); onSuccess(); onCancel(); return; } const selectedHotwords = hotwordList .filter((item) => item.hotWordGroupId === meetingValues.hotWordGroupId && meetingValues.hotWordGroupId !== 0) .map((item) => ({ hotword: item.word, weight: Number(item.weight || 2) / 10, })); const payload: CreateRealtimeMeetingCommand = { ...meetingValues, ...(hostUserId != null ? { hostUserId } : {}), meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"), participants: meetingValues.participants?.join(",") || "", tags: meetingValues.tags?.join(",") || "", summaryDetailLevel: meetingValues.summaryDetailLevel as SummaryDetailLevel, mode: meetingValues.mode || "2pass", language: meetingValues.language || "auto", useSpkId: meetingValues.useSpkId == null ? 1 : (meetingValues.useSpkId ? 1 : 0), enablePunctuation: meetingValues.enablePunctuation !== false, enableItn: meetingValues.enableItn !== false, enableTextRefine: !!meetingValues.enableTextRefine, saveAudio: !!meetingValues.saveAudio, hotWords: selectedHotWords, }; const res = await createRealtimeMeeting(payload); const createdMeeting = res.data.data; const sessionDraft: RealtimeMeetingSessionDraft = { meetingId: createdMeeting.id, meetingTitle: createdMeeting.title, asrModelName: selectedAsrModel?.modelName || "ASR", summaryModelName: selectedSummaryModel?.modelName || "LLM", asrModelId: selectedAsrModel?.id || values.asrModelId, mode: values.mode || "2pass", language: values.language || "auto", useSpkId: values.useSpkId == null ? 1 : (values.useSpkId ? 1 : 0), enablePunctuation: values.enablePunctuation !== false, enableItn: values.enableItn !== false, enableTextRefine: !!values.enableTextRefine, saveAudio: !!values.saveAudio, hotwords: selectedHotwords, }; sessionStorage.setItem(getSessionKey(createdMeeting.id), JSON.stringify(sessionDraft)); message.success("会议已创建,即将进入实时识别"); onSuccess(); onCancel(); navigate(`/meeting-live-session/${createdMeeting.id}`); } catch { message.error(type === "upload" ? "创建会议失败" : "创建实时会议失败"); } finally { setSubmitting(false); } }; return ( ) : null } styles={{ header: { display: "none" }, body: { padding: 0, display: "flex", flexDirection: "column", background: "var(--app-bg-layout)" }, footer: { padding: 0, borderTop: "1px solid var(--app-border-color)", background: "var(--app-bg-surface)", minHeight: configLoaded ? 72 : 0 } }} > {!configLoaded ? (
) : ( <>
{type === "upload" ? : }
{type === "upload" ? "上传录音发起分析" : "创建实时会议"} {type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}
setType(e.target.value)} optionType="button" buttonStyle="solid" className="meeting-create-drawer__type-switch"> {createConfig.offlineEnabled && ( 上传录音 )} {createConfig.realtimeEnabled && ( 实时会议 )}
基础信息
{asrModels.map(m => ())} {prompts.length > 15 ? ( ) : (
{prompts.map(p => { const isSelected = watchedPromptId === p.id; return (
form.setFieldsValue({ promptId: p.id })} className={isSelected ? "meeting-create-template-card is-selected" : "meeting-create-template-card"} >
{p.templateName}
{isSelected &&
}
); })}
)}