2026-04-08 06:34:59 +00:00
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
|
|
|
|
import { Drawer, Form, Input, Select, DatePicker, Switch, Upload, Progress, Space, Avatar, Row, Col, Radio, Typography, Tooltip, App, Tag, Button, Collapse } from 'antd';
|
|
|
|
|
|
import { UserOutlined, CloudUploadOutlined, AudioOutlined, QuestionCircleOutlined, CheckOutlined, LinkOutlined, SettingOutlined } from '@ant-design/icons';
|
|
|
|
|
|
import dayjs from 'dayjs';
|
|
|
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
|
|
|
|
|
|
|
|
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
|
|
|
|
|
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
|
|
|
|
|
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
|
|
|
|
|
|
import { listUsers } from '../../api';
|
|
|
|
|
|
import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting';
|
|
|
|
|
|
import { SysUser } from '../../types';
|
|
|
|
|
|
|
|
|
|
|
|
const { Option } = Select;
|
|
|
|
|
|
const { Dragger } = Upload;
|
|
|
|
|
|
const { Text, Title } = Typography;
|
|
|
|
|
|
|
|
|
|
|
|
export type MeetingCreateType = 'upload' | 'realtime';
|
|
|
|
|
|
|
|
|
|
|
|
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 resolveWsUrl(model?: AiModelVO | null) {
|
|
|
|
|
|
if (model?.wsUrl) return model.wsUrl;
|
|
|
|
|
|
if (model?.baseUrl) return model.baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildRealtimeProxyPreviewUrl() {
|
|
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
|
|
|
|
return `${protocol}://${window.location.host}/ws/meeting/realtime`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const [asrModels, setAsrModels] = useState<AiModelVO[]>([]);
|
|
|
|
|
|
const [llmModels, setLlmModels] = useState<AiModelVO[]>([]);
|
|
|
|
|
|
const [prompts, setPrompts] = useState<PromptTemplateVO[]>([]);
|
|
|
|
|
|
const [hotwordList, setHotwordList] = useState<HotWordVO[]>([]);
|
|
|
|
|
|
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 watchedPromptId = Form.useWatch("promptId", form);
|
|
|
|
|
|
const watchedSummaryModelId = Form.useWatch("summaryModelId", 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]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (open) {
|
|
|
|
|
|
setType(initialType);
|
|
|
|
|
|
loadInitialData();
|
|
|
|
|
|
setAudioUrl('');
|
|
|
|
|
|
setUploadProgress(0);
|
|
|
|
|
|
setFileList([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [open, initialType]);
|
|
|
|
|
|
|
|
|
|
|
|
const loadInitialData = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [asrRes, llmRes, promptRes, hotwordRes, users, defaultAsr, defaultLlm] = await Promise.all([
|
|
|
|
|
|
getAiModelPage({ current: 1, size: 100, type: 'ASR' }),
|
|
|
|
|
|
getAiModelPage({ current: 1, size: 100, type: 'LLM' }),
|
|
|
|
|
|
getPromptPage({ current: 1, size: 100 }),
|
|
|
|
|
|
getHotWordPage({ current: 1, size: 1000 }),
|
|
|
|
|
|
listUsers(),
|
|
|
|
|
|
getAiModelDefault("ASR"),
|
|
|
|
|
|
getAiModelDefault("LLM")
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const activeAsrModels = asrRes.data.data.records.filter((m: AiModelVO) => m.status === 1);
|
|
|
|
|
|
const activeLlmModels = llmRes.data.data.records.filter((m: AiModelVO) => m.status === 1);
|
|
|
|
|
|
const activePrompts = promptRes.data.data.records.filter((p: PromptTemplateVO) => p.status === 1);
|
|
|
|
|
|
const activeHotwords = hotwordRes.data.data.records.filter((h: HotWordVO) => h.status === 1);
|
|
|
|
|
|
|
|
|
|
|
|
setAsrModels(activeAsrModels);
|
|
|
|
|
|
setLlmModels(activeLlmModels);
|
|
|
|
|
|
setPrompts(activePrompts);
|
|
|
|
|
|
setHotwordList(activeHotwords);
|
|
|
|
|
|
setUserList(users || []);
|
|
|
|
|
|
|
|
|
|
|
|
form.setFieldsValue({
|
|
|
|
|
|
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
|
|
|
|
|
|
meetingTime: dayjs(),
|
|
|
|
|
|
asrModelId: defaultAsr.data.data?.id,
|
|
|
|
|
|
summaryModelId: defaultLlm.data.data?.id,
|
|
|
|
|
|
promptId: activePrompts.length > 0 ? activePrompts[0].id : undefined,
|
|
|
|
|
|
useSpkId: 1,
|
|
|
|
|
|
enableTextRefine: false,
|
|
|
|
|
|
mode: "2pass",
|
|
|
|
|
|
language: "auto",
|
|
|
|
|
|
enablePunctuation: true,
|
|
|
|
|
|
enableItn: true,
|
|
|
|
|
|
saveAudio: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
message.error("加载配置失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Sync title when type changes
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) return;
|
|
|
|
|
|
const currentTitle = form.getFieldValue('title');
|
|
|
|
|
|
if (currentTitle && (currentTitle.startsWith('文件会议') || currentTitle.startsWith('实时会议'))) {
|
|
|
|
|
|
form.setFieldsValue({
|
|
|
|
|
|
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [type, form, open]);
|
|
|
|
|
|
|
|
|
|
|
|
const customUpload = async (options: any) => {
|
|
|
|
|
|
const { file, onSuccess: uploadSuccess, onError } = options;
|
|
|
|
|
|
setUploadProgress(0);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const interval = setInterval(() => setUploadProgress(prev => (prev < 95 ? prev + 5 : prev)), 300);
|
|
|
|
|
|
const res = await uploadAudio(file);
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
setUploadProgress(100);
|
|
|
|
|
|
setAudioUrl(res.data.data);
|
|
|
|
|
|
uploadSuccess(res.data.data);
|
|
|
|
|
|
message.success('录音上传成功');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
onError(err);
|
|
|
|
|
|
message.error('文件上传失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleOk = async () => {
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { hostUserId, ...meetingValues } = values;
|
|
|
|
|
|
|
|
|
|
|
|
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(',')
|
|
|
|
|
|
});
|
|
|
|
|
|
message.success('会议发起成功');
|
|
|
|
|
|
onSuccess();
|
|
|
|
|
|
onCancel();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const selectedHotwords = (values.hotWords?.length
|
|
|
|
|
|
? hotwordList.filter((item) => values.hotWords.includes(item.word))
|
|
|
|
|
|
: hotwordList
|
|
|
|
|
|
).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(",") || "",
|
|
|
|
|
|
mode: meetingValues.mode || "2pass",
|
|
|
|
|
|
language: meetingValues.language || "auto",
|
|
|
|
|
|
useSpkId: meetingValues.useSpkId ? 1 : 0,
|
|
|
|
|
|
enablePunctuation: meetingValues.enablePunctuation !== false,
|
|
|
|
|
|
enableItn: meetingValues.enableItn !== false,
|
|
|
|
|
|
enableTextRefine: !!meetingValues.enableTextRefine,
|
|
|
|
|
|
saveAudio: !!meetingValues.saveAudio,
|
|
|
|
|
|
hotWords: meetingValues.hotWords,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 ? 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 (err) {
|
|
|
|
|
|
message.error(type === 'upload' ? '创建会议失败' : '创建实时会议失败');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Drawer
|
|
|
|
|
|
title={null}
|
|
|
|
|
|
open={open}
|
|
|
|
|
|
onClose={onCancel}
|
|
|
|
|
|
width={960}
|
2026-04-08 08:16:21 +00:00
|
|
|
|
forceRender
|
|
|
|
|
|
destroyOnClose={false}
|
2026-04-08 06:34:59 +00:00
|
|
|
|
placement="right"
|
|
|
|
|
|
closable={false}
|
|
|
|
|
|
footer={
|
|
|
|
|
|
<div style={{ textAlign: 'right', padding: '16px 32px' }}>
|
|
|
|
|
|
<Space size={16}>
|
|
|
|
|
|
<Button onClick={onCancel} size="large" style={{ borderRadius: 8, minWidth: 120 }}>取消</Button>
|
|
|
|
|
|
<Button type="primary" onClick={handleOk} loading={submitting} size="large" style={{ borderRadius: 8, minWidth: 140, fontWeight: 500 }}>
|
|
|
|
|
|
{type === 'upload' ? (audioUrl ? '开始分析' : '创建并上传') : '创建并进入识别'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
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)' }
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ background: 'var(--app-bg-surface)', padding: '24px 32px', borderBottom: '1px solid var(--app-border-color)' }}>
|
|
|
|
|
|
<Row justify="space-between" align="middle">
|
|
|
|
|
|
<Col>
|
|
|
|
|
|
<Space size={16}>
|
|
|
|
|
|
<div style={{ width: 48, height: 48, borderRadius: 12, background: 'var(--app-bg-surface-strong)', color: 'var(--app-text-main)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, border: '1px solid var(--app-border-color)' }}>
|
|
|
|
|
|
{type === 'upload' ? <CloudUploadOutlined /> : <AudioOutlined />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Title level={4} style={{ margin: 0, fontWeight: 600 }}>{type === 'upload' ? '上传录音分析' : '创建实时会议'}</Title>
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: 13 }}>{type === 'upload' ? '上传已有录音文件进行转写和总结' : '创建会议实时进行语音转写和内容分析'}</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col>
|
|
|
|
|
|
<Radio.Group value={type} onChange={e => setType(e.target.value)} optionType="button" buttonStyle="solid" size="large">
|
|
|
|
|
|
<Radio.Button value="upload" style={{ padding: '0 24px' }}><CloudUploadOutlined style={{ marginRight: 6 }} /> 上传录音</Radio.Button>
|
|
|
|
|
|
<Radio.Button value="realtime" style={{ padding: '0 24px' }}><AudioOutlined style={{ marginRight: 6 }} /> 实时识别</Radio.Button>
|
|
|
|
|
|
</Radio.Group>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ padding: '32px 40px', flex: 1, overflowY: 'auto', background: 'var(--app-bg-layout)' }}>
|
|
|
|
|
|
<Form form={form} layout="vertical" disabled={loading}>
|
|
|
|
|
|
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
|
|
|
|
|
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
|
|
|
|
|
|
<Title level={5} style={{ margin: 0 }}>基础信息</Title>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Row gutter={32}>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="title" label="会议标题" rules={[{ required: true }]}>
|
|
|
|
|
|
<Input placeholder="输入会议标题" size="large" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]}>
|
|
|
|
|
|
<DatePicker showTime style={{ width: '100%' }} size="large" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
|
|
<Row gutter={32}>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="participants" label="参会人员">
|
|
|
|
|
|
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
|
|
|
|
|
|
{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 span={12}>
|
|
|
|
|
|
<Form.Item name="hostUserId" label="会议主持人">
|
|
|
|
|
|
<Select allowClear placeholder="不选择则默认为创建人" showSearch optionFilterProp="children" size="large">
|
|
|
|
|
|
{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={32}>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="tags" label="会议标签">
|
|
|
|
|
|
<Select mode="tags" placeholder="输入标签" size="large" />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} />
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
|
|
|
|
|
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
|
|
|
|
|
|
<Title level={5} style={{ margin: 0 }}>AI 模型与分析配置</Title>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Row gutter={32}>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="asrModelId" label="语音识别 (ASR)" rules={[{ required: true }]}>
|
|
|
|
|
|
<Select placeholder="选择 ASR 模型" size="large">{asrModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={12}>
|
|
|
|
|
|
<Form.Item name="summaryModelId" label="内容总结 (LLM)" rules={[{ required: true }]}>
|
|
|
|
|
|
<Select placeholder="选择总结模型" size="large">{llmModels.map(m => (<Option key={m.id} value={m.id}>{m.modelName}</Option>))}</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
|
|
<Form.Item name="promptId" label="总结模板" rules={[{ required: true }]}>
|
|
|
|
|
|
{prompts.length > 15 ? (
|
|
|
|
|
|
<Select placeholder="请选择模板" showSearch optionFilterProp="children" size="large">
|
|
|
|
|
|
{prompts.map(p => <Option key={p.id} value={p.id}>{p.templateName}</Option>)}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ padding: '2px' }}>
|
|
|
|
|
|
<Row gutter={[12, 12]}>
|
|
|
|
|
|
{prompts.map(p => {
|
|
|
|
|
|
const isSelected = watchedPromptId === p.id;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Col span={8} key={p.id}>
|
|
|
|
|
|
<div onClick={() => form.setFieldsValue({ promptId: p.id })} style={{ padding: '12px 16px', borderRadius: 8, border: `1px solid ${isSelected ? '#1890ff' : 'var(--app-border-color)'}`, background: isSelected ? '#e6f7ff' : 'var(--app-bg-surface)', cursor: 'pointer', position: 'relative', transition: 'all 0.2s', display: 'flex', alignItems: 'center', height: '100%' }}>
|
|
|
|
|
|
<div style={{ fontSize: '14px', color: isSelected ? '#1890ff' : 'var(--app-text-main)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSelected ? 500 : 400 }}>{p.templateName}</div>
|
|
|
|
|
|
{isSelected && <div style={{ position: 'absolute', top: -1, right: -1, width: 20, height: 20, background: '#1890ff', borderRadius: '0 8px 0 8px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><CheckOutlined style={{ color: '#fff', fontSize: 12 }} /></div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
|
|
<Row gutter={32}>
|
|
|
|
|
|
<Col span={24}>
|
|
|
|
|
|
<Form.Item name="hotWords" label={<span>热词增强 <Tooltip title="不选择时将带上系统当前启用的热词"><QuestionCircleOutlined /></Tooltip></span>}>
|
|
|
|
|
|
<Select mode="multiple" allowClear placeholder="请选择需要增强识别的热词" size="large" style={{ width: '100%' }}>
|
|
|
|
|
|
{hotwordList.map((item) => (
|
|
|
|
|
|
<Option key={item.word} value={item.word}>{item.word}</Option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
|
|
<Collapse
|
|
|
|
|
|
ghost
|
|
|
|
|
|
expandIconPosition="end"
|
|
|
|
|
|
style={{ marginBottom: 24, background: 'var(--app-bg-layout)', borderRadius: 8 }}
|
|
|
|
|
|
items={[
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'advanced',
|
|
|
|
|
|
label: (
|
|
|
|
|
|
<span style={{ fontWeight: 500, color: 'var(--app-text-secondary)' }}>
|
|
|
|
|
|
<SettingOutlined style={{ marginRight: 8 }} />
|
|
|
|
|
|
高级设置
|
|
|
|
|
|
</span>
|
|
|
|
|
|
),
|
|
|
|
|
|
children: (
|
|
|
|
|
|
<div style={{ paddingTop: 8 }}>
|
|
|
|
|
|
<Row gutter={32}>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Form.Item name="useSpkId" label={<span>声纹区分 <Tooltip title="开启后将尝试区分不同发言人"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked" getValueProps={(v) => ({ checked: v === 1 || v === true })} normalize={(v) => (v ? 1 : 0)}>
|
|
|
|
|
|
<Switch />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Form.Item name="enableTextRefine" label={<span>文本修正 <Tooltip title="开启后将尝试对识别文本进行修正"><QuestionCircleOutlined /></Tooltip></span>} valuePropName="checked">
|
|
|
|
|
|
<Switch />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
{type === 'realtime' && (
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Form.Item name="mode" label="识别模式">
|
|
|
|
|
|
<Select size="large">
|
|
|
|
|
|
<Option value="2pass">2pass (流式+离线修正)</Option>
|
|
|
|
|
|
<Option value="online">online (纯流式)</Option>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{type === 'realtime' && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Form.Item name="language" hidden><Input /></Form.Item>
|
|
|
|
|
|
<Form.Item name="enablePunctuation" hidden valuePropName="checked"><Switch /></Form.Item>
|
|
|
|
|
|
<Form.Item name="enableItn" hidden valuePropName="checked"><Switch /></Form.Item>
|
|
|
|
|
|
<Form.Item name="saveAudio" hidden valuePropName="checked"><Switch /></Form.Item>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{type === 'upload' && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ margin: '32px 0', borderTop: '1px solid var(--app-border-color)' }} />
|
|
|
|
|
|
<div style={{ marginBottom: 24, display: 'flex', alignItems: 'center' }}>
|
|
|
|
|
|
<div style={{ width: 4, height: 16, background: '#1890ff', borderRadius: 2, marginRight: 8 }} />
|
|
|
|
|
|
<Title level={5} style={{ margin: 0 }}>上传录音文件</Title>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Dragger
|
|
|
|
|
|
accept=".mp3,.wav,.m4a"
|
|
|
|
|
|
fileList={fileList}
|
|
|
|
|
|
customRequest={customUpload}
|
|
|
|
|
|
onChange={info => setFileList(info.fileList.slice(-1))}
|
|
|
|
|
|
maxCount={1}
|
|
|
|
|
|
style={{ borderRadius: 12, padding: '32px 0', background: 'var(--app-bg-surface)', border: '1px dashed var(--app-border-color)' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: '#1890ff' }} /></p>
|
|
|
|
|
|
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: 'var(--app-text-main)' }}>点击或拖拽录音文件到此处</p>
|
|
|
|
|
|
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: 'var(--app-text-secondary)' }}>支持高质量 .mp3, .wav, .m4a 格式音频</p>
|
|
|
|
|
|
{uploadProgress > 0 && uploadProgress < 100 && (
|
|
|
|
|
|
<div style={{ width: '60%', margin: '32px auto 0' }}>
|
|
|
|
|
|
<Progress percent={uploadProgress} size="small" />
|
|
|
|
|
|
<div style={{ fontSize: 13, color: '#1890ff', marginTop: 8 }}>文件传输中,请稍候...</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{audioUrl && (
|
|
|
|
|
|
<Tag color="processing" style={{ marginTop: 24, padding: '6px 16px', fontSize: 14, borderRadius: 6, maxWidth: '90%', display: 'inline-flex', alignItems: 'center' }}>
|
|
|
|
|
|
<span style={{ flexShrink: 0 }}>就绪:</span>
|
|
|
|
|
|
<span style={{ marginLeft: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
|
|
|
|
{audioUrl.split('/').pop()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Dragger>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|