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

538 lines
22 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 { useEffect, useMemo, useState } from "react";
import {
Alert,
Avatar,
Button,
Card,
Col,
DatePicker,
Form,
Input,
Row,
Select,
Space,
Spin,
Statistic,
Switch,
Tag,
Tooltip,
Typography,
message,
} from "antd";
import {
AudioOutlined,
CheckCircleOutlined,
LinkOutlined,
MessageOutlined,
QuestionCircleOutlined,
RocketOutlined,
TeamOutlined,
UserOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
import PageHeader from "../../components/shared/PageHeader";
import { listUsers } from "../../api";
import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel";
import { getHotWordPage, type HotWordVO } from "../../api/business/hotword";
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
import { createRealtimeMeeting, type CreateRealtimeMeetingCommand } from "../../api/business/meeting";
import type { SysUser } from "../../types";
const { Title, Text } = Typography;
const { Option } = Select;
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 default function RealtimeAsr() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
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 watchedAsrModelId = Form.useWatch("asrModelId", form);
const watchedSummaryModelId = Form.useWatch("summaryModelId", form);
const watchedHotWords = Form.useWatch("hotWords", form) || [];
const watchedParticipants = Form.useWatch("participants", form) || [];
const watchedUseSpkId = Form.useWatch("useSpkId", 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 selectedHotwordCount = watchedHotWords.length > 0 ? watchedHotWords.length : hotwordList.length;
useEffect(() => {
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((item) => item.status === 1);
const activeLlmModels = llmRes.data.data.records.filter((item) => item.status === 1);
const activePrompts = promptRes.data.data.records.filter((item) => item.status === 1);
const activeHotwords = hotwordRes.data.data.records.filter((item) => item.status === 1);
setAsrModels(activeAsrModels);
setLlmModels(activeLlmModels);
setPrompts(activePrompts);
setHotwordList(activeHotwords);
setUserList(users || []);
form.setFieldsValue({
title: `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
meetingTime: dayjs(),
asrModelId: defaultAsr.data.data?.id,
summaryModelId: defaultLlm.data.data?.id,
promptId: activePrompts[0]?.id,
useSpkId: 1,
mode: "2pass",
language: "auto",
enablePunctuation: true,
enableItn: true,
enableTextRefine: false,
saveAudio: false,
});
} catch {
message.error("加载实时会议配置失败");
} finally {
setLoading(false);
}
};
void loadInitialData();
}, [form]);
const handleCreate = async () => {
const values = await form.validateFields();
const wsUrl = resolveWsUrl(selectedAsrModel);
if (!wsUrl) {
message.error("当前 ASR 模型没有配置 WebSocket 地址");
return;
}
setSubmitting(true);
try {
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 = {
...values,
meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: values.participants?.join(",") || "",
tags: values.tags?.join(",") || "",
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: values.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("会议已创建,进入实时识别");
navigate(`/meeting-live-session/${createdMeeting.id}`);
} catch {
message.error("创建实时会议失败");
} finally {
setSubmitting(false);
}
};
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<PageHeader title="实时识别会议" subtitle="先完成配置,再进入会中识别页面,减少会中页面干扰。" />
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
{loading ? (
<Card bordered={false} style={{ borderRadius: 18 }}>
<div style={{ textAlign: "center", padding: "88px 0" }}>
<Spin />
</div>
</Card>
) : (
<Row gutter={16} style={{ height: "100%" }}>
<Col xs={24} xl={17} style={{ height: "100%" }}>
<Card
bordered={false}
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
>
<div
style={{
marginBottom: 12,
padding: 14,
borderRadius: 16,
background: "linear-gradient(135deg, #f8fbff 0%, #eef6ff 58%, #ffffff 100%)",
border: "1px solid #dbeafe",
flexShrink: 0,
}}
>
<Space direction="vertical" size={6}>
<Space size={10}>
<div
style={{
width: 42,
height: 42,
borderRadius: 12,
background: "#1677ff",
color: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<AudioOutlined />
</div>
<div>
<Title level={4} style={{ margin: 0 }}>
</Title>
<Text type="secondary"></Text>
</div>
</Space>
<Space wrap size={[8, 8]}>
<Tag color="blue"></Tag>
<Tag color="cyan"></Tag>
<Tag color="gold"></Tag>
</Space>
</Space>
</div>
<Form
form={form}
layout="vertical"
initialValues={{
mode: "2pass",
language: "auto",
useSpkId: 1,
enablePunctuation: true,
enableItn: true,
enableTextRefine: false,
saveAudio: false,
}}
style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}
>
<div style={{ flex: 1, minHeight: 0 }}>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="title" label="会议标题" rules={[{ required: true, message: "请输入会议标题" }]}>
<Input placeholder="例如:产品例会实时记录" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="meetingTime"
label="会议时间"
rules={[{ required: true, message: "请选择会议时间" }]}
>
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="participants" label="参会人员">
<Select mode="multiple" showSearch optionFilterProp="children" placeholder="选择参会人员">
{userList.map((user) => (
<Option key={user.userId} value={user.userId}>
<Space>
<Avatar size="small" icon={<UserOutlined />} />
{user.displayName || user.username}
</Space>
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="tags" label="会议标签">
<Select mode="tags" placeholder="输入标签后回车" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="asrModelId"
label="识别模型 (ASR)"
rules={[{ required: true, message: "请选择 ASR 模型" }]}
>
<Select placeholder="选择实时识别模型">
{asrModels.map((model) => (
<Option key={model.id} value={model.id}>
{model.modelName}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="summaryModelId"
label="总结模型 (LLM)"
rules={[{ required: true, message: "请选择总结模型" }]}
>
<Select placeholder="选择总结模型">
{llmModels.map((model) => (
<Option key={model.id} value={model.id}>
{model.modelName}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item
name="promptId"
label="总结模板"
rules={[{ required: true, message: "请选择总结模板" }]}
>
<Select placeholder="选择总结模板">
{prompts.map((prompt) => (
<Option key={prompt.id} value={prompt.id}>
{prompt.templateName}
</Option>
))}
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="hotWords"
label={
<span>
{" "}
<Tooltip title="不选择时将带上系统当前启用的热词">
<QuestionCircleOutlined />
</Tooltip>
</span>
}
>
<Select mode="multiple" allowClear placeholder="可选热词">
{hotwordList.map((item) => (
<Option key={item.word} value={item.word}>
{item.word}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<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>
<Row gutter={16} align="middle">
<Col xs={24} md={12}>
<Form.Item name="mode" label="识别模式">
<Select>
<Option value="2pass">2pass</Option>
<Option value="online">online</Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="useSpkId"
label={
<span>
<Tooltip title="开启后会尝试区分不同发言人">
<QuestionCircleOutlined />
</Tooltip>
</span>
}
valuePropName="checked"
getValueProps={(value) => ({ checked: value === 1 || value === true })}
normalize={(value) => (value ? 1 : 0)}
>
<Switch />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item label="WebSocket 地址">
<Input value={buildRealtimeProxyPreviewUrl()} prefix={<LinkOutlined />} readOnly />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item name="enableTextRefine" label="文本修正" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
</div>
</Form>
</Card>
</Col>
<Col xs={24} xl={7} style={{ height: "100%" }}>
<Card
bordered={false}
style={{ height: "100%", borderRadius: 18, boxShadow: "0 8px 22px rgba(15,23,42,0.05)" }}
bodyStyle={{ height: "100%", padding: 16, display: "flex", flexDirection: "column" }}
>
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Col span={12}>
<Statistic title="参会人数" value={watchedParticipants.length} prefix={<TeamOutlined />} />
</Col>
<Col span={12}>
<Statistic title="热词数量" value={selectedHotwordCount} prefix={<MessageOutlined />} />
</Col>
<Col span={12}>
<Statistic
title="说话人区分"
value={watchedUseSpkId ? "开启" : "关闭"}
prefix={<CheckCircleOutlined />}
/>
</Col>
<Col span={12}>
<Statistic
title="识别链路"
value={selectedAsrModel ? "已就绪" : "待配置"}
prefix={<AudioOutlined />}
/>
</Col>
</Row>
<Space direction="vertical" size={12} style={{ width: "100%", flex: 1, minHeight: 0 }}>
<div>
<Text strong style={{ fontSize: 15 }}>
</Text>
</div>
<div style={{ padding: 14, borderRadius: 14, background: "#fafcff", border: "1px solid #edf2ff" }}>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Text type="secondary">ASR</Text>
<Text strong>{selectedAsrModel?.modelName || "-"}</Text>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Text type="secondary">LLM</Text>
<Text strong>{selectedSummaryModel?.modelName || "-"}</Text>
</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Text type="secondary">WebSocket</Text>
<Text ellipsis style={{ maxWidth: 220 }}>
{buildRealtimeProxyPreviewUrl()}
</Text>
</div>
</Space>
</div>
<Alert
type="info"
showIcon
message="异常关闭保护"
description="会中页持续写库,并在关闭时自动兜底结束。"
/>
<div style={{ marginTop: "auto", padding: 12, borderRadius: 14, background: "#f6ffed", border: "1px solid #b7eb8f" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 10 }}>
<Text type="secondary"></Text>
<Space>
<Button onClick={() => navigate("/meetings")}></Button>
<Button type="primary" icon={<RocketOutlined />} loading={submitting} onClick={() => void handleCreate()}>
</Button>
</Space>
</div>
</div>
</Space>
</Card>
</Col>
</Row>
)}
</div>
</div>
);
}