386 lines
17 KiB
TypeScript
386 lines
17 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 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>
|
|||
|
|
);
|
|||
|
|
}
|