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

386 lines
17 KiB
TypeScript
Raw Normal View History

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 MeetingDTO } 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;
wsUrl: string;
mode: string;
useSpkId: number;
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 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",
});
} 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: MeetingDTO = {
...values,
meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: values.participants?.join(",") || "",
tags: values.tags?.join(",") || "",
useSpkId: values.useSpkId ? 1 : 0,
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",
wsUrl,
mode: values.mode || "2pass",
useSpkId: values.useSpkId ? 1 : 0,
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", useSpkId: 1 }} 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>
<Row gutter={16} align="middle">
<Col xs={24} md={8}>
<Form.Item name="mode" label="识别模式">
<Select>
<Option value="2pass">2pass</Option>
<Option value="online">online</Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} md={8}>
<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={resolveWsUrl(selectedAsrModel)} prefix={<LinkOutlined />} readOnly />
</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 }}>{resolveWsUrl(selectedAsrModel) || "-"}</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>
);
}