imeeting/frontend/src/components/business/MeetingCreateDrawer.tsx

677 lines
26 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, 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<MeetingCreateDrawerProps> = ({
open,
initialType = "upload",
onCancel,
onSuccess,
}) => {
const { message } = App.useApp();
const navigate = useNavigate();
const [form] = Form.useForm();
const [type, setType] = useState<MeetingCreateType>(initialType);
const [loading, setLoading] = useState(false);
const [configLoaded, setConfigLoaded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>({
offlineEnabled: false,
realtimeEnabled: false,
offlineAudioMaxSizeMb: DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB,
});
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
const [hotWordGroups, setHotWordGroups] = useState<HotWordGroupVO[]>([]);
const [userList, setUserList] = useState<SysUser[]>([]);
const [audioUrl, setAudioUrl] = useState("");
const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]);
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 (
<Drawer
rootClassName="meeting-create-drawer-root"
className="meeting-create-drawer"
title={null}
open={open}
onClose={onCancel}
width="min(960px, calc(100vw - 48px))"
forceRender
destroyOnHidden={false}
placement="right"
closable={false}
footer={
configLoaded ? (
<div className="meeting-create-drawer__footer">
<Space size={12}>
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={handleOk} loading={submitting}>
{type === "upload" ? (audioUrl ? "完成并分析" : "上传并分析") : "创建并进入识别"}
</Button>
</Space>
</div>
) : 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 ? (
<div className="meeting-create-drawer__skeleton">
<Skeleton active paragraph={{ rows: 12 }} />
</div>
) : (
<>
<div className="meeting-create-drawer__header">
<Row justify="space-between" align="middle" gutter={[16, 12]}>
<Col flex="auto">
<Space size={12}>
<div className="meeting-create-drawer__title-icon">
{type === "upload" ? <CloudUploadOutlined /> : <AudioOutlined />}
</div>
<div>
<Title level={4} className="meeting-create-drawer__title">{type === "upload" ? "上传录音发起分析" : "创建实时会议"}</Title>
<Text type="secondary" className="meeting-create-drawer__subtitle">{type === "upload" ? "上传已有音频文件并由 AI 进行转写与总结分析" : "实时采集语音并进行流式转写与实时纪要生成"}</Text>
</div>
</Space>
</Col>
<Col flex="none">
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" className="meeting-create-drawer__type-switch">
{createConfig.offlineEnabled && (
<Radio.Button value="upload"><CloudUploadOutlined /> </Radio.Button>
)}
{createConfig.realtimeEnabled && (
<Radio.Button value="realtime"><AudioOutlined /> </Radio.Button>
)}
</Radio.Group>
</Col>
</Row>
</div>
<div className="meeting-create-drawer__body">
<Form form={form} layout="vertical" disabled={loading} className="meeting-create-form">
<div className="meeting-create-section-title">
<span className="meeting-create-section-title__bar" />
<Title level={5}></Title>
</div>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
<Input placeholder="输入会议标题" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item name="participants" label="参会人员">
<Select mode="multiple" placeholder="选择参会人员" showSearch optionFilterProp="children">
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="hostUserId" label="会议主持人">
<Select allowClear placeholder="默认为创建人" showSearch optionFilterProp="children">
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={24}>
<Col span={24}>
<Form.Item name="tags" label="会议标签">
<Select mode="tags" placeholder="输入标签" />
</Form.Item>
</Col>
</Row>
<div className="meeting-create-section-divider" />
<div className="meeting-create-section-title">
<span className="meeting-create-section-title__bar" />
<Title level={5}></Title>
</div>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item name="asrModelId" label="语音识别模型 (ASR)" rules={[{ required: true }]}>
<Select placeholder="选择 ASR 模型">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="summaryModelId" label="总结模型 (LLM)" rules={[{ required: true }]}>
<Select placeholder="选择总结模型">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
</Form.Item>
</Col>
</Row>
<Form.Item
name="promptId"
label="总结模板"
>
{prompts.length > 15 ? (
<Select allowClear placeholder="请选择总结模板" showSearch optionFilterProp="children">
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
</Select>
) : (
<div className="meeting-create-template-grid">
<Row gutter={[12, 12]}>
{prompts.map(p => {
const isSelected = watchedPromptId === p.id;
return (
<Col xs={24} sm={12} md={8} key={p.id}>
<div
onClick={() => form.setFieldsValue({ promptId: p.id })}
className={isSelected ? "meeting-create-template-card is-selected" : "meeting-create-template-card"}
>
<div className="meeting-create-template-card__name">{p.templateName}</div>
{isSelected && <div className="meeting-create-template-card__check"><CheckOutlined /></div>}
</div>
</Col>
);
})}
</Row>
</div>
)}
</Form.Item>
<Row gutter={24}>
<Col xs={24} md={12}>
<Form.Item name="hotWordGroupId" label="热词组" tooltip={selectedPrompt?.hotWordGroupName ? `默认跟随模板:${selectedPrompt.hotWordGroupName}` : "模板未绑定热词组时可手动选择"} extra={watchedHotWordGroupId != null ? "创建会议时会优先使用这里选中的热词组" : undefined}>
<Select placeholder={selectedPrompt?.hotWordGroupId ? "默认已带出模板热词组,可按需修改" : "请选择热词组"} options={[{ label: "不使用热词组", value: 0 }, ...hotWordGroups.map((item) => ({ label: `${item.groupName} (${item.hotWordCount}/200)`, value: item.id }))]} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="summaryDetailLevel" label="总结详细程度" rules={[{ required: true }]}>
<Radio.Group className="meeting-create-radio-line">
<Radio value="DETAILED"></Radio>
<Radio value="STANDARD"></Radio>
<Radio value="BRIEF"></Radio>
</Radio.Group>
</Form.Item>
</Col>
</Row>
<Collapse
ghost
expandIconPosition="end"
className="meeting-create-advanced"
items={[
{
key: "advanced",
forceRender: true,
label: (
<div className="meeting-create-advanced__label">
<div className="meeting-create-advanced__title">
<div className="meeting-create-advanced__icon">
<SettingOutlined />
</div>
</div>
</div>
),
children: (
<div className="meeting-create-advanced__body">
<Row gutter={24}>
<Col xs={24} md={8}>
<Form.Item name="useSpkId" label={<span> <Tooltip title="自动识别录音中的不同发言者并进行角色分离"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: !!v })} normalize={(v) => (v ? 1 : 0)}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="enableTextRefine" label={<span> <Tooltip title="自动识别并修正语音转写中的口语词、语气助词等"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
<Switch />
</Form.Item>
</Col>
{type === "realtime" && (
<>
<Col xs={24} md={8}>
<Form.Item name="saveAudio" label="保存录音" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="mode" label="识别模式">
<Radio.Group>
<Radio value="2pass">2pass + 线</Radio>
<Radio value="online">online</Radio>
</Radio.Group>
</Form.Item>
</Col>
</>
)}
</Row>
<Form.Item name="userPrompt" label="用户提示词" extra="提供补充信息以指导 AI 在生成会议总结时更侧重于特定方面或遵循特定风格">
<Input.TextArea placeholder="请输入额外说明,例如:请重点关注会议中的待办事项、以摘要形式输出、或者采用严谨的公文风格等" autoSize={{ minRows: 3, maxRows: 6 }} showCount maxLength={1000} />
</Form.Item>
</div>
),
}
]}
/>
{type === "upload" && (
<>
<div className="meeting-create-section-divider" />
<div className="meeting-create-section-title">
<span className="meeting-create-section-title__bar" />
<Title level={5}></Title>
</div>
<Dragger
accept=".mp3,.wav,.m4a"
beforeUpload={beforeAudioUpload}
fileList={fileList}
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
maxCount={1}
className="meeting-create-upload"
>
<div>
<p className="ant-upload-drag-icon"><CloudUploadOutlined /></p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"> mp3wavm4a {createConfig.offlineAudioMaxSizeMb}MB</p>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="meeting-create-upload__progress">
<Progress percent={uploadProgress} size="small" />
<div>...</div>
</div>
)}
{audioUrl && (
<Tag color="processing" className="meeting-create-upload__file">
<span></span>
<span>{audioUrl.split("/").pop()}</span>
</Tag>
)}
</div>
</Dragger>
</>
)}
</Form>
</div>
</>
)}
</Drawer>
);
};