538 lines
22 KiB
TypeScript
538 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|