1.前言

        在现代企业协作环境中,高效的会议管理是提升团队生产力的关键。本文将深入解析一个完整的会议管理系统,涵盖从会议创建到总结生成的完整生命周期。该系统构建一个基于AI技术的智能会议系统,实现会议全流程的智能化管理,包括智能预约、实时转录、AI总结、智能分析等功能,为企业提供高效、智能的会议解决方案。

2. 会议核心功能架构

        当前项目启用采用前后端分离的现代化架构设计,系统实现了从会议预约、创建会议,实时音频沟通到会后自动摘要的全流程智能化管理。其核心作用在于使用腾讯云语音识别接口进行语音识别,随后将识别的文本利用AI技术自动完成语音转写、内容摘要和会议分析,显著减轻人工负担,确保信息完整可追溯。同时,系统后期打算支持多端接入和实时协作,打破地域限制,助力远程团队高效沟通。整体设计注重用户体验与业务价值,旨在成为推动企业数字化协作和决策效率的关键平台。

项目的结构如图所示:

后端:                                                                        前端:


2.1 整体架构

系统架构图如图所示:

  1. 前后端分离架构(Django + Vue3)
  2. 会议管理作为独立模块
  3. 集成腾讯云语音识别和浏览器原生语音识别
  4. 使用Coze AI进行会议内容优化和总结生成


2.2 数据模型设计

  1. Meeting 模型:存储会议基本信息
  2. MeetingTranscript 模型:存储会议转写记录
  3. OptimizedTranscript 模型:存储优化后的会议记录
  4. MeetingSummary 模型:存储AI生成的会议总结

模型的相关代码如下:

from django.db import models
from user.models import SysUser
import uuid
from datetime import datetime, timedelta
import json
from django.utils import timezoneclass Meeting(models.Model):"""会议模型"""STATUS_CHOICES = [('scheduled', '已安排'),('active', '进行中'),('ended', '已结束'),('cancelled', '已取消'),]id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)title = models.CharField(max_length=200, verbose_name='会议主题')  # meeting_topicdescription = models.TextField(blank=True, verbose_name='会议描述')host = models.ForeignKey(SysUser, on_delete=models.CASCADE, verbose_name='主持人')  # organizer_idmeeting_id = models.CharField(max_length=20, unique=True, verbose_name='会议号')password = models.CharField(max_length=20, blank=True, verbose_name='会议密码')scheduled_time = models.DateTimeField(verbose_name='计划开始时间')  # start_timeduration = models.IntegerField(default=60, verbose_name='计划时长(分钟)')scheduled_end_time = models.DateTimeField(null=True, blank=True, verbose_name='计划结束时间')  # end_timeactual_start_time = models.DateTimeField(null=True, blank=True, verbose_name='实际开始时间')actual_end_time = models.DateTimeField(null=True, blank=True, verbose_name='实际结束时间')status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='scheduled', verbose_name='会议状态')# 会议设置enable_recording = models.BooleanField(default=True, verbose_name='启用录音')enable_transcription = models.BooleanField(default=True, verbose_name='启用语音转文字')enable_ai_summary = models.BooleanField(default=True, verbose_name='启用AI总结')created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')class Meta:verbose_name = '会议'verbose_name_plural = '会议'ordering = ['-created_at']def __str__(self):return f'{self.title} ({self.meeting_id})'def get_join_url(self):"""生成加入会议的URL"""return f'/meeting/join/{self.meeting_id}'def is_active(self):"""判断会议是否正在进行"""return self.status == 'active'def can_join(self):"""判断是否可以加入会议"""return self.status in ['scheduled', 'active']def can_start(self, user):"""判断用户是否可以开始会议"""# 检查是否是主持人if self.host != user:return False, '只有主持人可以开始会议'# 检查是否已经有会议正在进行active_meetings = Meeting.objects.filter(host=user, status='active')if active_meetings.exists():return False, '您已有会议正在进行,请先结束当前会议'# 检查会议是否在允许的时间范围内开始now = timezone.now()scheduled_start_time = self.scheduled_timetime_diff = (scheduled_start_time - now).total_seconds()# 如果会议已经开始超过5分钟,则不允许开始if time_diff < -300:  # -300秒 = -5分钟return False, '会议已过开始时间5分钟以上,无法开始会议'# 如果会议开始时间还未到5分钟内,则不允许开始if time_diff > 300:  # 300秒 = 5分钟scheduled_time_str = scheduled_start_time.strftime("%Y-%m-%d %H:%M")return False, f'会议只能在计划开始时间({scheduled_time_str})前5分钟内开始'return True, '可以开始会议'class MeetingTranscript(models.Model):"""会议转写模型"""meeting = models.ForeignKey(Meeting, on_delete=models.CASCADE, related_name='transcripts')speaker_name = models.CharField(max_length=100, verbose_name='说话人')text = models.TextField(verbose_name='转写文字')confidence = models.FloatField(default=0.0, verbose_name='置信度')start_time = models.DateTimeField(verbose_name='开始时间')end_time = models.DateTimeField(verbose_name='结束时间')duration = models.FloatField(verbose_name='时长(秒)')# 音频文件信息audio_file = models.FileField(upload_to='meeting_audio/', null=True, blank=True, verbose_name='音频文件')audio_format = models.CharField(max_length=10, default='wav', verbose_name='音频格式')created_at = models.DateTimeField(auto_now_add=True)class Meta:verbose_name = '会议转写'verbose_name_plural = '会议转写'ordering = ['start_time']def __str__(self):return f'{self.speaker_name}: {self.text[:50]}...'class MeetingSummary(models.Model):"""会议总结模型"""meeting = models.OneToOneField(Meeting, on_delete=models.CASCADE, related_name='summary')# AI生成的总结summary_text = models.TextField(verbose_name='AI总结')key_points = models.JSONField(default=list, verbose_name='关键要点')action_items = models.JSONField(default=list, verbose_name='行动项')# 统计信息total_words = models.IntegerField(default=0, verbose_name='总字数')total_duration = models.IntegerField(default=0, verbose_name='总时长(秒)')# 生成信息generated_by = models.CharField(max_length=50, default='coze', verbose_name='生成工具')generated_at = models.DateTimeField(auto_now_add=True, verbose_name='生成时间')# 总结文件的存储路径summary_file_path = models.CharField(max_length=500, blank=True, null=True, verbose_name='总结文件路径')class Meta:verbose_name = '会议总结'verbose_name_plural = '会议总结'def __str__(self):return f'{self.meeting.title} - 总结'def get_summary_data(self):"""获取格式化的总结数据"""import re# 清理总结内容,移除多余的标记clean_summary = self.summary_textif clean_summary:# 移除Markdown标题标记(包括多级标题)clean_summary = re.sub(r'^#{1,6}\s*', '', clean_summary, flags=re.MULTILINE)# 移除粗体标记clean_summary = re.sub(r'\*\*(.*?)\*\*', r'\1', clean_summary)# 移除斜体标记clean_summary = re.sub(r'\*(.*?)\*', r'\1', clean_summary)# 移除多余的星号clean_summary = re.sub(r'\*+', '', clean_summary)# 移除行内代码标记clean_summary = re.sub(r'`+', '', clean_summary)# 将关键要点转换为列表格式,并清理内容clean_key_points = []if self.key_points:for point in self.key_points:if isinstance(point, str):# 清理关键要点中的Markdown标记clean_point = re.sub(r'^#{1,6}\s*', '', point)clean_point = re.sub(r'\*\*(.*?)\*\*', r'\1', clean_point)clean_point = re.sub(r'\*(.*?)\*', r'\1', clean_point)clean_point = re.sub(r'\*+', '', clean_point)clean_point = re.sub(r'`+', '', clean_point)clean_key_points.append(clean_point.strip())else:clean_key_points.append(point)# 处理summary_text可能包含JSON的情况try:# 尝试解析summary_text为JSONimport jsonsummary_data = json.loads(self.summary_text)# 如果是JSON格式且包含message字段,则使用该消息if 'message' in summary_data:clean_summary = summary_data['message']# 清理JSON中的内容clean_summary = re.sub(r'^#{1,6}\s*', '', clean_summary, flags=re.MULTILINE)clean_summary = re.sub(r'\*\*(.*?)\*\*', r'\1', clean_summary)clean_summary = re.sub(r'\*(.*?)\*', r'\1', clean_summary)clean_summary = re.sub(r'\*+', '', clean_summary)clean_summary = re.sub(r'`+', '', clean_summary)except:# 如果不是JSON格式,直接使用原文本并清理if self.summary_text:clean_summary = self.summary_textclean_summary = re.sub(r'^#{1,6}\s*', '', clean_summary, flags=re.MULTILINE)clean_summary = re.sub(r'\*\*(.*?)\*\*', r'\1', clean_summary)clean_summary = re.sub(r'\*(.*?)\*', r'\1', clean_summary)clean_summary = re.sub(r'\*+', '', clean_summary)clean_summary = re.sub(r'`+', '', clean_summary)return {'meeting_info': {'title': self.meeting.title,'description': self.meeting.description if self.meeting.description else '暂无','date': self.meeting.actual_start_time.strftime('%Y-%m-%d %H:%M:%S') if self.meeting.actual_start_time else '','duration': self.total_duration,},'summary': clean_summary.strip() if clean_summary else '',  # 使用清理后的总结'key_points': clean_key_points,  # 使用清理后的关键要点'key_points_markdown': "",  # 不再使用Markdown格式的关键要点'action_items': self.action_items,}class OptimizedTranscript(models.Model):"""优化后的转写记录模型"""meeting = models.ForeignKey(Meeting, on_delete=models.CASCADE, related_name='optimized_transcripts')speaker_name = models.CharField(max_length=100, verbose_name='说话人')# 原始和优化后的文字original_text = models.TextField(verbose_name='原始转写文字', blank=True)optimized_text = models.TextField(verbose_name='优化后文字')confidence = models.FloatField(default=0.0, verbose_name='置信度')# 时间信息start_time = models.DateTimeField(verbose_name='开始时间')processing_time = models.FloatField(default=0.0, verbose_name='处理时长(秒)')# Coze工作流信息workflow_id = models.CharField(max_length=100, blank=True, verbose_name='工作流ID')optimization_level = models.CharField(max_length=20, default='high', verbose_name='优化级别')created_at = models.DateTimeField(auto_now_add=True)class Meta:verbose_name = '优化转写记录'verbose_name_plural = '优化转写记录'ordering = ['start_time']def __str__(self):return f'{self.speaker_name}: {self.optimized_text[:50]}...'

2.3功能模块设计

2.3.1. 会议管理模块

这是系统的核心模块,负责会议的完整生命周期管理:

  • 会议创建(快速开始和预定)

  • 会议状态控制(开始、结束、取消)

  • 会议信息维护

  • 会议列表展示与搜索

会议首页如图所示:

预定会议:

2.3.2. 语音转写模块

实现会议过程中的语音实时转写功能:

  • 音频采集与处理

  • 语音识别转换为文字

  • 实时转写结果显示

  • 转写记录保存

会议室如图所示,点击开始转写就会识别语音:

2.3.3. AI优化模块

利用Coze AI服务提升转写质量:

  • 语音文本语法优化

  • 会议内容智能总结

  • 关键要点提取

  • 行动项识别

2.3.4. 会议总结模块

自动生成并管理会议总结:

  • 会议总结生成

  • 总结内容展示

  • 关键要点列表

  • 行动项跟踪

例子;

2.3.5. 文件导出模块

提供会议总结的导出功能:

  • TXT文本格式导出

  • DOCX文档格式导出

  • 导出内容格式化处理

使用docx文件下载需要安装:python-docx模块

 3.技术栈选择

  • 前端 : Vue3 + TypeScript + Element Plus
  • 后端 : Django REST Framework + Python3.11
  • AI服务 : 集成Coze工作流进行语音优化和会议总结
  • 存储 : MYSQL + 文件存储+文件下载

        当前会议使用了两个coze工作流,(coze官网:扣子)工作流程是接收输入的文本后,调用大模型对其尽心进行整理:

        一个是对文本进行优化的工作流,主要功能是为了将口语化表达精准转换为书面语,能够有效识别并处理文本中的各类语气词、填充词以及重复词语和语义冗余部分。

        另一个是文本总结的工作流,作用是能够精准捕捉会议中的关键信息,有效过滤各类语气词、口头禅以及无意义的表述,将会议内容转化为规范、正式且专业的中文会议报告。

4.功能分析

4.1会议管理模块

        目前在项目的会议创建(快速开始和预定)功能阶段,我还使用了时间冲突的功能来对会议的安排进行一个管理,作用是可以防止用户创建或开始一个与现有会议时间重叠的会议,确保每个主持人在同一时间只能主持一个会议。快速开始会议功能会检查未来5分钟内是否有计划中的会议,在开始会议时,系统会检查用户是否已经有正在进行的会议。

        在会议状态控制(开始、结束、取消)中,结合响应式的布局,对会议的状态进行一个管理

        会议列表展示与搜索,使用分页以及大小写不区分的模糊搜索。会议列表代码如下:

<template><div class="meeting-list-container"><div class="header"><h1>我的会议</h1><el-space><el-button type="primary" size="large" @click="quickStartMeeting" :loading="quickStartLoading"><el-icon><VideoCamera /></el-icon>快速开始会议</el-button><el-button type="success" @click="showCreateDialog = true"><el-icon><Plus /></el-icon>预定会议</el-button></el-space></div><!-- 搜索和筛选 --><div class="filters"><el-row :gutter="16"><el-col :span="8"><el-inputv-model="searchKeyword"placeholder="搜索会议标题或ID"clearable@input="handleSearch"><template #prefix><el-icon><Search /></el-icon></template></el-input></el-col><el-col :span="6"><el-select v-model="statusFilter" placeholder="会议状态" clearable @change="handleSearch"><el-option label="全部" value="" /><el-option label="进行中" value="active" /><el-option label="已结束" value="ended" /><el-option label="计划中" value="scheduled" /></el-select></el-col><el-col :span="4"><el-button type="primary" @click="loadMeetings" :loading="loading"><el-icon><Refresh /></el-icon>刷新</el-button></el-col></el-row></div><!-- 会议列表 --><div class="meeting-list"><el-table v-loading="loading" :data="filteredMeetings" style="width: 100%"@row-click="handleRowClick"><el-table-column prop="title" label="会议标题" min-width="200"><template #default="{ row }"><div class="meeting-title"><h4>{{ row.title }}</h4><p class="meeting-id">ID: {{ row.meeting_id }}</p></div></template></el-table-column><el-table-column prop="status" label="状态" width="100"><template #default="{ row }"><el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag></template></el-table-column><el-table-column prop="scheduled_time" label="时间" width="180"><template #default="{ row }">{{ formatDateTime(row.scheduled_time) }}</template></el-table-column><el-table-column prop="duration" label="时长" width="100"><template #default="{ row }">{{ row.duration }}分钟</template></el-table-column><el-table-column label="操作" width="250" fixed="right"><template #default="{ row }"><el-space><el-button v-if="row.status === 'scheduled'" type="primary" size="small"@click.stop="startMeeting(row)">开始会议</el-button><el-button v-if="row.status === 'active'" type="success" size="small"@click.stop="joinMeeting(row.meeting_id)">进入会议</el-button><el-button type="info" size="small"@click.stop="viewDetails(row)">详情</el-button><el-dropdown @command="(command: string) => handleCommand(command, row)" trigger="click"><el-button size="small"  @click.stop><el-icon><MoreFilled /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="row.status === 'ended'" command="generate">生成总结</el-dropdown-item><el-dropdown-item v-if="row.status === 'ended'" command="summary">查看总结</el-dropdown-item><el-dropdown-item v-if="row.status === 'ended'" command="download">下载总结</el-dropdown-item><el-dropdown-item command="delete" divided>删除会议</el-dropdown-item><el-dropdown-item v-if="row.status === 'active'" command="end">结束会议</el-dropdown-item></el-dropdown-menu></template></el-dropdown></el-space></template></el-table-column></el-table><!-- 分页 --><div class="pagination"><el-paginationv-model:current-page="currentPage"v-model:page-size="pageSize":page-sizes="[10, 20, 50, 100]":total="total"layout="total, sizes, prev, pager, next, jumper"@size-change="handleSizeChange"@current-change="handleCurrentChange"/></div></div><!-- 创建会议对话框 --><el-dialog v-model="showCreateDialog" title="预定会议" width="500px"><CreateMeetingForm @created="handleMeetingCreated" @cancel="showCreateDialog = false" /></el-dialog><!-- 会议详情对话框 --><el-dialog v-model="showDetailDialog" title="会议详情" width="600px"><MeetingDetail v-if="selectedMeeting" :meeting="selectedMeeting" @updated="handleMeetingUpdated"/></el-dialog></div>
</template><script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { Plus, Search, VideoCamera, Refresh, MoreFilled } from '@element-plus/icons-vue'
import { meetingApi, type Meeting } from '@/api/meeting'
import CreateMeetingForm from '@/components/meeting/CreateMeetingForm.vue'
import MeetingDetail from '@/components/meeting/MeetingDetail.vue'const router = useRouter()// 响应式数据
const loading = ref(false)
const quickStartLoading = ref(false)
const meetings = ref<Meeting[]>([])
const searchKeyword = ref('')
const statusFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const showCreateDialog = ref(false)
const showDetailDialog = ref(false)
const selectedMeeting = ref<Meeting | null>(null)// 计算属性
const filteredMeetings = computed(() => {let filtered = meetings.valueif (searchKeyword.value) {filtered = filtered.filter(meeting => meeting.title.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||meeting.meeting_id.includes(searchKeyword.value))}if (statusFilter.value) {filtered = filtered.filter(meeting => meeting.status === statusFilter.value)}return filtered
})// 方法
const loadMeetings = async () => {loading.value = truetry {const response = await meetingApi.getMeetingList({page: currentPage.value,page_size: pageSize.value})if (response.success) {meetings.value = response.meetings || []total.value = response.total || 0} else {ElMessage.error(response.error || '加载会议列表失败')}} catch (error) {console.error('加载会议列表失败:', error)ElMessage.error('网络错误,请重试')} finally {loading.value = false}
}const handleSearch = () => {currentPage.value = 1loadMeetings()
}const handleSizeChange = (newSize: number) => {pageSize.value = newSizecurrentPage.value = 1loadMeetings()
}const handleCurrentChange = (newPage: number) => {currentPage.value = newPageloadMeetings()
}const handleRowClick = (row: Meeting) => {viewDetails(row)
}const joinMeeting = (meetingId: string) => {router.push(`/home/meeting/room/${meetingId}`)
}// 快速开始会议
const quickStartMeeting = async () => {quickStartLoading.value = truetry {const response = await meetingApi.quickStartMeeting({title: `快速会议 - ${new Date().toLocaleString('zh-CN')}`,duration: 60})if (response.success) {ElMessage.success('会议已开始')// 直接进入会议室router.push(`/home/meeting/room/${response.meeting.meeting_id}`)} else {// 处理错误情况let errorMessage = '开始会议失败'if (response.error) {if (typeof response.error === 'object' && response.error.scheduled_time) {errorMessage = response.error.scheduled_time} else if (typeof response.error === 'string') {errorMessage = response.error}}ElMessage({message: errorMessage,type: 'error',duration: 6000,showClose: true,dangerouslyUseHTMLString: true})}} catch (error: any) {console.error('快速开始会议失败:', error)let errorMessage = '网络错误,请重试'if (error.response?.data?.error) {errorMessage = error.response.data.error}ElMessage({message: errorMessage,type: 'error',duration: 6000,showClose: true,dangerouslyUseHTMLString: true})} finally {quickStartLoading.value = false}
}// 开始预定的会议
const startMeeting = async (meeting: Meeting) => {try {// 检查是否已经有会议正在进行const activeMeeting = meetings.value.find(m => m.status === 'active');if (activeMeeting) {ElMessage.error('您已有会议正在进行,请先结束当前会议');return;}// 检查会议是否在允许的时间范围内开始const now = new Date();const scheduledTime = new Date(meeting.scheduled_time);const timeDiff = (scheduledTime.getTime() - now.getTime()) / 1000; // 转换为秒// 如果会议已经开始超过5分钟,则不允许开始if (timeDiff < -300) { // -300秒 = -5分钟ElMessage.error('会议已过开始时间5分钟以上,无法开始会议');return;}// 如果会议开始时间还未到5分钟内,则提示用户if (timeDiff > 300) { // 300秒 = 5分钟const scheduledTimeStr = scheduledTime.toLocaleString('zh-CN');await ElMessageBox.confirm(`会议只能在计划开始时间(${scheduledTimeStr})前5分钟内开始,现在还不能开始会议。`,'提示',{confirmButtonText: '确定',showCancelButton: false,type: 'warning'});return;}const response = await meetingApi.startMeeting(meeting.meeting_id)if (response.success) {ElMessage.success('会议已开始')// 直接进入会议室router.push(`/home/meeting/room/${meeting.meeting_id}`)// 刷新列表loadMeetings()} else {ElMessage.error(response.error || '开始会议失败')}} catch (error: any) {console.error('开始会议失败:', error)// 提供更具体的错误信息if (error.response?.status === 400 && error.response.data?.error) {ElMessage.error(error.response.data.error)} else {ElMessage.error('网络错误,请重试')}}
}const viewDetails = (meeting: Meeting) => {selectedMeeting.value = meetingshowDetailDialog.value = true
}const handleCommand = async (command: string, meeting: Meeting) => {switch (command) {case 'generate':await generateSummary(meeting)breakcase 'summary':await viewSummary(meeting)breakcase 'download':await downloadSummary(meeting)breakcase 'delete':await deleteMeeting(meeting)breakcase 'end':await endMeeting(meeting)break}
}// 生成会议总结
const generateSummary = async (meeting: Meeting) => {try {// 显示加载提示const loadingInstance = ElLoading.service({lock: true,text: '正在生成会议总结...',spinner: 'el-icon-loading',background: 'rgba(0, 0, 0, 0.7)'})try {const response = await meetingApi.generateMeetingSummary(meeting.meeting_id)loadingInstance.close()if (response.success) {ElMessage.success('会议总结生成成功')} else {// 提供更具体的错误信息let errorMessage = response.error || '生成会议总结失败'// 特别处理没有转写记录的情况if (errorMessage.includes('无法生成会议总结') || errorMessage.includes('没有语音转写内容')) {errorMessage = '无法生成会议总结:会议中没有语音转写内容。请确保在会议期间启用了语音转写功能并有发言内容。'} else if (errorMessage.includes('会议状态')) {errorMessage = `会议状态不正确:${errorMessage}`}ElMessage.error(errorMessage)}} catch (error: any) {loadingInstance.close()console.error('生成会议总结失败:', error)// 提供更具体的错误信息let errorMessage = '生成会议总结失败'if (error.message) {errorMessage = error.message} else if (error.response?.data?.error) {errorMessage = error.response.data.error// 特别处理没有转写记录的情况if (errorMessage.includes('无法生成会议总结') || errorMessage.includes('没有语音转写内容')) {errorMessage = '无法生成会议总结:会议中没有语音转写内容。请确保在会议期间启用了语音转写功能并有发言内容。'} else if (errorMessage.includes('会议状态')) {errorMessage = `会议状态不正确:${errorMessage}`}} else if (error.response?.status === 400) {errorMessage = '请求参数错误:会议可能没有转写记录或状态不正确'} else if (error.response?.status === 403) {errorMessage = '您没有权限生成此会议的总结'} else if (error.response?.status === 404) {errorMessage = '会议不存在'} else if (error.response?.status === 408) {errorMessage = '请求超时,请稍后重试'} else if (error.response?.status === 429) {errorMessage = '请求频率超限,请稍后重试'} else if (error.response?.status === 500) {errorMessage = '服务器内部错误,请稍后重试'} else if (error.response?.status === 503) {errorMessage = '服务暂时不可用,请稍后重试'}ElMessage.error(errorMessage)}} catch (error) {console.error('生成会议总结失败:', error)ElMessage.error('生成会议总结失败')}
}// 查看会议总结
const viewSummary = async (meeting: Meeting) => {try {// 显示加载提示const loadingInstance = ElLoading.service({lock: true,text: '正在获取会议总结...',spinner: 'el-icon-loading',background: 'rgba(0, 0, 0, 0.7)'})try {const response = await meetingApi.getMeetingSummary(meeting.meeting_id)loadingInstance.close()if (response.success) {// 处理summary可能包含JSON的情况let summaryContent = response.summary.summary || '暂无总结内容';try {const summaryData = JSON.parse(summaryContent);if (summaryData.message) {summaryContent = summaryData.message;}} catch (e) {// 如果不是JSON格式,保持原样}// 确保内容正确显示,清理Markdown标记const cleanSummaryContent = summaryContent.replace(/^#{1,6}\s*/gm, '')  // 移除标题标记.replace(/\*\*(.*?)\*\*/g, '$1')  // 移除粗体标记.replace(/\*(.*?)\*/g, '$1')  // 移除斜体标记.replace(/\*+/g, '')  // 移除多余的星号.replace(/`+/g, '');  // 移除行内代码标记// 格式化关键要点,清理Markdown标记const keyPointsList = (response.summary.key_points || []).map((point: string) => {const cleanPoint = typeof point === 'string'? point.replace(/^#{1,6}\s*/gm, '')  // 移除标题标记.replace(/\*\*(.*?)\*\*/g, '$1')  // 移除粗体标记.replace(/\*(.*?)\*/g, '$1')  // 移除斜体标记.replace(/\*+/g, '')  // 移除多余的星号.replace(/`+/g, '')  // 移除行内代码标记: point;return `<li>${cleanPoint}</li>`;}).join('');// 显示会议总结对话框,使用清晰的HTML格式ElMessageBox.alert(`<div style="text-align: left; line-height: 1.6;"><h2>会议总结</h2><h3>会议信息</h3><p><strong>会议主题:</strong>${response.summary.meeting_info?.title || 'N/A'}</p><p><strong>会议描述:</strong>${response.summary.meeting_info?.description || '暂无'}</p><p><strong>会议时间:</strong>${response.summary.meeting_info?.date || 'N/A'}</p><p><strong>会议时长:</strong>${response.summary.meeting_info?.duration || 0}秒</p><h3>总结内容</h3><p style="white-space: pre-wrap;">${cleanSummaryContent}</p><h3>关键要点</h3>${keyPointsList ? `<ul>${keyPointsList}</ul>` : '<p>暂无关键要点</p>'}<h2>签名:</h2></div>`,'会议总结',{dangerouslyUseHTMLString: true,confirmButtonText: '确定',customClass: 'summary-dialog'})} else {ElMessage.warning('该会议尚未生成总结')}} catch (error: any) {loadingInstance.close()console.error('获取会议总结失败:', error)// 提供更具体的错误信息let errorMessage = '获取总结失败'if (error.message) {errorMessage = error.message} else if (error.response?.status === 404) {errorMessage = '该会议尚未生成总结,请先生成会议总结'} else if (error.response?.data?.error) {errorMessage = error.response.data.error}ElMessage.error(errorMessage)}} catch (error) {console.error('查看会议总结失败:', error)ElMessage.error('查看会议总结失败')}
}// 下载会议总结
const downloadSummary = async (meeting: Meeting) => {try {// 使用confirm并添加HTML内容来显示选择框const result = await ElMessageBox.confirm('<div style="text-align:center;"><p>请选择下载格式:</p>' +'<select id="download-format" style="width:100%;padding:8px;margin-top:10px;border:1px solid #dcdfe6;border-radius:4px;">' +'<option value="txt">文本文件 (.txt)</option>' +'<option value="docx">Word文档 (.docx)</option>' +'</select></div>','下载会议总结',{confirmButtonText: '下载',cancelButtonText: '取消',dangerouslyUseHTMLString: true,beforeClose: (action, instance, done) => {if (action === 'confirm') {const select = document.getElementById('download-format') as HTMLSelectElement;const format = select?.value as 'txt' | 'docx';// 立即执行下载meetingApi.downloadMeetingSummary(meeting.meeting_id, format).then(() => {ElMessage.success('下载开始')done() // 关闭对话框}).catch((error) => {console.error('下载会议总结失败:', error)ElMessage.error('下载失败,请先生成会议总结')done() // 关闭对话框})} else {done() // 取消时关闭对话框}}}).catch(() => {}) // 忽略取消操作的错误} catch (error) {if (error !== 'cancel') {console.error('下载会议总结失败:', error)ElMessage.error('下载失败,请生成成会议总结')}}
}// 结束会议
const endMeeting = async (meeting: Meeting) => {try {await ElMessageBox.confirm('确定要结束这个会议吗?', '确认', {confirmButtonText: '结束',cancelButtonText: '取消',type: 'warning'})const response = await meetingApi.endMeeting(meeting.meeting_id)if (response.success) {ElMessage.success('会议已结束')loadMeetings()} else {ElMessage.error(response.error || '结束会议失败')}} catch (error) {if (error !== 'cancel') {console.error('结束会议失败:', error)ElMessage.error('结束会议失败')}}
}// 删除会议
const deleteMeeting = async (meeting: Meeting) => {try {await ElMessageBox.confirm(`确定要删除会议"${meeting.title}"吗?删除后将无法恢复。`, '确认删除', {confirmButtonText: '删除',cancelButtonText: '取消',type: 'warning'})const response = await meetingApi.deleteMeeting(meeting.meeting_id)if (response.success) {ElMessage.success('会议删除成功')loadMeetings()} else {ElMessage.error(response.error || '删除会议失败')}} catch (error) {if (error !== 'cancel') {console.error('删除会议失败:', error)ElMessage.error('删除会议失败')}}
}const getStatusType = (status: string) => {const types: Record<string, string> = {'scheduled': 'info','active': 'success','ended': 'warning','cancelled': 'danger'}return types[status] || 'info'
}const getStatusText = (status: string) => {const texts: Record<string, string> = {'scheduled': '计划中','active': '进行中','ended': '已结束','cancelled': '已取消'}return texts[status] || status
}const formatDateTime = (dateString: string) => {if (!dateString) return ''return new Date(dateString).toLocaleString('zh-CN')
}const handleMeetingCreated = (meeting: Meeting) => {showCreateDialog.value = falseloadMeetings()// 不在这里显示ElMessage,因为CreateMeetingForm中已经处理了
}const handleMeetingUpdated = (meeting: Meeting) => {// 更新本地数据const index = meetings.value.findIndex(m => m.id === meeting.id)if (index > -1) {meetings.value[index] = meeting}selectedMeeting.value = meeting
}// 生命周期
onMounted(() => {loadMeetings()
})
</script><style scoped>
.meeting-list-container {padding: 20px;height: 100%;display: flex;flex-direction: column;
}.header {display: flex;justify-content: space-between;align-items: center;margin-bottom: 20px;
}.header h1 {margin: 0;color: #303133;
}.filters {margin-bottom: 20px;
}.meeting-list {flex: 1;display: flex;flex-direction: column;
}.meeting-title h4 {margin: 0 0 5px 0;color: #303133;font-size: 14px;
}.meeting-id {margin: 0;color: #909399;font-size: 12px;
}.pagination {margin-top: 20px;display: flex;justify-content: center;
}
</style><style>
/* 添加全局样式用于Markdown内容显示 */
.summary-markdown-content {text-align: left;line-height: 1.6;
}.summary-markdown-content h2 {font-size: 24px;font-weight: bold;margin: 20px 0 15px 0;color: #303133;border-bottom: 2px solid #409eff;padding-bottom: 10px;
}.summary-markdown-content h3 {font-size: 18px;font-weight: bold;margin: 15px 0 10px 0;color: #606266;
}.summary-markdown-content h4 {font-size: 16px;font-weight: bold;margin: 10px 0 5px 0;color: #909399;
}.summary-markdown-content ul {padding-left: 20px;margin: 10px 0;
}.summary-markdown-content li {margin: 5px 0;
}.summary-markdown-content p {margin: 10px 0;
}.summary-markdown-content strong {font-weight: bold;
}
</style>

代码如下:

import traceback
import refrom rest_framework import status, viewsets, permissions, serializers
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
from django.conf import settings
from user.models import SysUser
from django.utils import timezone
from django.db.models import Q
from datetime import datetime, timedelta
import loggingfrom .models import Meeting, MeetingTranscript, MeetingSummary, OptimizedTranscript
from .serializers import (MeetingSerializer, MeetingTranscriptSerializer,MeetingSummarySerializer, CreateMeetingSerializer
)
from .speech_service import get_speech_recognition_service
from .coze_service import get_coze_servicelogger = logging.getLogger(__name__)class MeetingViewSet(viewsets.ModelViewSet):"""会议视图集"""serializer_class = MeetingSerializerpermission_classes = [IsAuthenticated]lookup_field = 'meeting_id'  # 使用meeting_id作为查找字段def get_queryset(self):"""只返回用户作为主持人的会议"""user = self.request.userreturn Meeting.objects.filter(host=user).order_by('-created_at')def get_object(self):"""获取会议对象,支持meeting_id查找"""lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_fieldfilter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}try:obj = Meeting.objects.get(**filter_kwargs)# 检查用户是否有权限访问此会议user = self.request.userif obj.host != user:from rest_framework.exceptions import PermissionDeniedraise PermissionDenied('您没有权限访问此会议')return objexcept Meeting.DoesNotExist:from rest_framework.exceptions import NotFoundraise NotFound('会议不存在')def perform_create(self, serializer):"""创建会议时自动设置主持人"""serializer.save(host=self.request.user)def destroy(self, request, *args, **kwargs):"""删除会议"""instance = self.get_object()meeting_id = instance.meeting_idtitle = instance.title# 检查用户是否有权限删除此会议if instance.host != request.user:return Response({'success': False,'error': '您没有权限删除此会议'}, status=status.HTTP_403_FORBIDDEN)# 执行删除操作self.perform_destroy(instance)return Response({'success': True,'message': f'会议"{title}"(ID: {meeting_id})已成功删除'})@action(detail=False, methods=['post'])def create_meeting(self, request):"""创建会议的专用接口"""try:logger.info(f"开始创建会议,用户: {request.user.username}")logger.info(f"请求数据: {request.data}")serializer = CreateMeetingSerializer(data=request.data)if serializer.is_valid():logger.info("序列化器验证通过,开始保存会议")meeting = serializer.save(host=request.user)logger.info(f"会议创建成功,ID: {meeting.id}, meeting_id: {meeting.meeting_id}")response_serializer = MeetingSerializer(meeting)return Response({'success': True,'meeting': response_serializer.data,'message': '会议创建成功'})else:logger.error(f"序列化器验证失败: {serializer.errors}")return Response({'success': False,'error': serializer.errors}, status=status.HTTP_400_BAD_REQUEST)except Exception as e:logger.error(f"创建会议失败: {str(e)}")logger.error(f"错误详情: {traceback.format_exc()}")return Response({'success': False,'error': f'创建会议失败: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)# 在 views.py 文件中的 start_meeting 方法中添加冲突检查@action(detail=True, methods=['post'])def start_meeting(self, request, meeting_id=None):"""开始会议(仅主持人)"""meeting = self.get_object()if meeting.host != request.user:return Response({'success': False,'error': '只有主持人可以开始会议'}, status=status.HTTP_403_FORBIDDEN)# 检查是否在允许的时间范围内开始now = timezone.now()time_diff = (meeting.scheduled_time - now).total_seconds()# 如果会议已经开始超过5分钟,则不允许开始if time_diff < -300:  # -300秒 = -5分钟return Response({'success': False,'error': '会议已过开始时间5分钟以上,无法开始会议'}, status=status.HTTP_400_BAD_REQUEST)# 如果会议开始时间还未到5分钟内,则提示用户if time_diff > 300:  # 300秒 = 5分钟scheduled_time_str = meeting.scheduled_time.strftime("%Y-%m-%d %H:%M")return Response({'success': False,'error': f'会议只能在计划开始时间({scheduled_time_str})前5分钟内开始,现在还不能开始会议。'}, status=status.HTTP_400_BAD_REQUEST)# 检查是否有正在进行的快速会议active_meetings = Meeting.objects.filter(host=request.user,status='active').exclude(meeting_id=meeting.meeting_id)if active_meetings.exists():return Response({'success': False,'error': '您已有会议正在进行,请先结束当前会议'}, status=status.HTTP_400_BAD_REQUEST)meeting.status = 'active'meeting.actual_start_time = timezone.now()meeting.save()return Response({'success': True,'message': '会议已开始','meeting': MeetingSerializer(meeting).data})@action(detail=False, methods=['post'])def quick_start_meeting(self, request):"""快速开始会议"""try:logger.info(f"开始快速创建会议,用户: {request.user.username}")# 默认会议参数title = request.data.get('title', f"{request.user.username}的会议")duration = request.data.get('duration', 60)  # 默认60分钟# 使用当前时间作为开始时间scheduled_time = timezone.now()scheduled_end_time = scheduled_time + timedelta(minutes=duration)# 检查时间冲突 - 检查未来5分钟内是否有计划中的会议conflict_check_time = scheduled_time + timedelta(minutes=5)conflicting_meetings = Meeting.objects.filter(scheduled_time__lt=scheduled_end_time,scheduled_end_time__gt=conflict_check_time,status__in=['scheduled', 'active']  # 只检查计划中和进行中的会议).exclude(status='ended').exclude(status='cancelled').exclude(status__in=['scheduled', 'active'],actual_end_time__isnull=False  # 排除状态为scheduled/active但实际已结束的会议)if conflicting_meetings.exists():conflict_meeting = conflicting_meetings.first()# 检查是否在预约会议开始前5分钟内if conflict_meeting.status == 'scheduled' and conflict_meeting.scheduled_time <= scheduled_end_time:time_diff = (conflict_meeting.scheduled_time - scheduled_time).total_seconds()if 0 <= time_diff <= 300:  # 5分钟内conflict_time = conflict_meeting.scheduled_time.strftime("%Y-%m-%d %H:%M")raise serializers.ValidationError({'计划时间': f'未来5分钟内有预约会议 "{conflict_meeting.title}" ({conflict_time}) 即将开始,无法创建快速会议。'})meeting_data = {'title': title,'description': request.data.get('description', ''),'scheduled_time': scheduled_time,'duration': duration,'enable_transcription': True,'enable_ai_summary': True,}# 使用CreateMeetingSerializer进行完整的时间冲突检测serializer = CreateMeetingSerializer(data=meeting_data)if serializer.is_valid():# 创建会议meeting = serializer.save(host=request.user)logger.info(f"会议创建成功,ID: {meeting.id}, meeting_id: {meeting.meeting_id}")# 立即开始会议meeting.status = 'active'meeting.actual_start_time = timezone.now()meeting.save()response_serializer = MeetingSerializer(meeting)return Response({'success': True,'meeting': response_serializer.data,'message': '会议已创建并开始'})else:# 返回时间冲突错误,与预约会议保持一致logger.error(f"快速开始会议验证失败: {serializer.errors}")return Response({'success': False,'error': serializer.errors}, status=status.HTTP_400_BAD_REQUEST)except Exception as e:logger.error(f"快速开始会议失败: {str(e)}")logger.error(f"错误详情: {traceback.format_exc()}")return Response({'success': False,'error': f'快速开始会议失败: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)@action(detail=True, methods=['post'])def end_meeting(self, request, meeting_id=None):"""结束会议(仅主持人)"""meeting = self.get_object()if meeting.host != request.user:return Response({'success': False,'error': '只有主持人可以结束会议'}, status=status.HTTP_403_FORBIDDEN)meeting.status = 'ended'meeting.actual_end_time = timezone.now()meeting.save()return Response({'success': True,'message': '会议已结束'})@action(detail=True, methods=['get'])def transcripts(self, request, meeting_id=None):"""获取会议转写记录"""meeting = self.get_object()transcripts = meeting.transcripts.all().order_by('start_time')serializer = MeetingTranscriptSerializer(transcripts, many=True)return Response({'success': True,'transcripts': serializer.data})

4.2. 语音转写模块

会议转写模块主要分为四个阶段:

  1. 音频采集与处理

  2. 语音识别转换为文字

  3. 实时转写结果显示

  4. 转写记录保存

首先由于我设计了双重保险机制,先使用浏览器自带的语音识别进行识别语音,如果浏览器自带的不能使用,就调用后端的腾讯云语音接口,当前项目,我在前端设计了原生浏览器采集的语音的参数,同时对接收到的音发送给coze工作流进行处理。

浏览器原生语音识别设计代码如下:

export interface AudioConfig {sampleRate?: numberchannelCount?: numberautoGainControl?: booleannoiseSuppression?: booleanechoCancellation?: boolean
}export class AudioManager {private stream: MediaStream | null = nullprivate audioContext: AudioContext | null = nullprivate mediaRecorder: MediaRecorder | null = nullprivate isRecording = falseprivate chunks: Blob[] = []private eventListeners: { [key: string]: Function[] } = {}private recordingInterval: number | null = nullprivate audioTracks: MediaStreamTrack[] = [] // 保存音频轨道private config: AudioConfig = {sampleRate: 44100,channelCount: 1,autoGainControl: true,noiseSuppression: true,echoCancellation: true}constructor(config?: Partial<AudioConfig>) {if (config) {this.config = { ...this.config, ...config }}}// 初始化音频async initialize(): Promise<void> {try {// 获取用户媒体权限this.stream = await navigator.mediaDevices.getUserMedia({audio: {sampleRate: this.config.sampleRate,channelCount: this.config.channelCount,autoGainControl: this.config.autoGainControl,noiseSuppression: this.config.noiseSuppression,echoCancellation: this.config.echoCancellation}})// 保存音频轨道以便控制this.audioTracks = this.stream.getAudioTracks()// 创建音频上下文this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()// 创建媒体录制器this.mediaRecorder = new MediaRecorder(this.stream, {mimeType: 'audio/webm;codecs=opus'})this.setupMediaRecorderEvents()this.emit('initialized')console.log('音频管理器初始化成功')} catch (error) {console.error('音频初始化失败:', error)this.emit('error', error)throw error}}private setupMediaRecorderEvents() {if (!this.mediaRecorder) returnthis.mediaRecorder.ondataavailable = (event) => {if (event.data.size > 0) {this.chunks.push(event.data)this.emit('dataAvailable', event.data)}}this.mediaRecorder.onstop = () => {// 发送最后的音频数据if (this.chunks.length > 0) {const blob = new Blob(this.chunks, { type: 'audio/webm' })this.emit('dataAvailable', blob)}this.chunks = []this.emit('recordingStopped', null)}this.mediaRecorder.onstart = () => {this.emit('recordingStarted')}}// 开始录音startRecording(timeslice?: number): void {if (!this.mediaRecorder) {throw new Error('媒体录制器未初始化')}if (this.isRecording) {console.warn('录音已在进行中')return}this.isRecording = truethis.chunks = []// 清除之前的定时器if (this.recordingInterval) {clearInterval(this.recordingInterval)this.recordingInterval = null}if (timeslice && timeslice > 0) {this.mediaRecorder.start(timeslice)} else {this.mediaRecorder.start()}}// 停止录音stopRecording(): void {if (!this.mediaRecorder || !this.isRecording) {console.warn('没有正在进行的录音')// 确保状态正确this.isRecording = falsereturn}this.isRecording = falsethis.mediaRecorder.stop()// 清除定时器if (this.recordingInterval) {clearInterval(this.recordingInterval)this.recordingInterval = null}}// 暂停录音pauseRecording(): void {if (!this.mediaRecorder || !this.isRecording) {console.warn('没有正在进行的录音')return}this.mediaRecorder.pause()this.emit('recordingPaused')}// 恢复录音resumeRecording(): void {if (!this.mediaRecorder) {console.warn('媒体录制器未初始化')return}this.mediaRecorder.resume()this.emit('recordingResumed')}// 获取音频级别(用于可视化)getAudioLevel(): number {if (!this.audioContext || !this.stream) {return 0}try {const source = this.audioContext.createMediaStreamSource(this.stream)const analyser = this.audioContext.createAnalyser()analyser.fftSize = 256source.connect(analyser)const bufferLength = analyser.frequencyBinCountconst dataArray = new Uint8Array(bufferLength)analyser.getByteFrequencyData(dataArray)let sum = 0for (let i = 0; i < bufferLength; i++) {sum += dataArray[i]}return sum / bufferLength / 255 // 归一化到0-1} catch (error) {console.error('获取音频级别失败:', error)return 0}}// 播放音频async playAudio(audioData: Blob | ArrayBuffer): Promise<void> {if (!this.audioContext) {throw new Error('音频上下文未初始化')}try {let arrayBuffer: ArrayBufferif (audioData instanceof Blob) {arrayBuffer = await audioData.arrayBuffer()} else {arrayBuffer = audioData}const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)const source = this.audioContext.createBufferSource()source.buffer = audioBuffersource.connect(this.audioContext.destination)source.start()this.emit('audioPlayed')} catch (error) {console.error('播放音频失败:', error)this.emit('error', error)throw error}}// 转换音频为Base64async blobToBase64(blob: Blob): Promise<string> {return new Promise((resolve, reject) => {const reader = new FileReader()reader.onload = () => {if (typeof reader.result === 'string') {resolve(reader.result.split(',')[1]) // 移除data:audio/webm;base64,前缀} else {reject(new Error('读取文件失败'))}}reader.onerror = rejectreader.readAsDataURL(blob)})}// 清理资源cleanup(): void {// 停止录音if (this.mediaRecorder && this.isRecording) {this.stopRecording()}// 清除定时器if (this.recordingInterval) {clearInterval(this.recordingInterval)this.recordingInterval = null}if (this.stream) {this.stream.getTracks().forEach(track => track.stop())this.stream = null}if (this.audioContext) {this.audioContext.close()this.audioContext = null}this.mediaRecorder = nullthis.isRecording = falsethis.chunks = []this.eventListeners = {}console.log('音频管理器已清理')}// 事件监听器管理on(event: string, callback: Function) {if (!this.eventListeners[event]) {this.eventListeners[event] = []}this.eventListeners[event].push(callback)}off(event: string, callback: Function) {if (this.eventListeners[event]) {const index = this.eventListeners[event].indexOf(callback)if (index > -1) {this.eventListeners[event].splice(index, 1)}}}private emit(event: string, data?: any) {if (this.eventListeners[event]) {this.eventListeners[event].forEach(callback => {try {callback(data)} catch (error) {console.error('事件回调执行错误:', error)}})}}// 获取状态get isInitialized(): boolean {return this.stream !== null && this.audioContext !== null}get recordingState(): boolean {return this.isRecording}get hasPermission(): boolean {return this.stream !== null}
}// 语音识别管理器
export class SpeechRecognitionManager {private recognition: any = nullprivate isListening = falseprivate eventListeners: { [key: string]: Function[] } = {}private restartTimeout: number | null = nullconstructor() {// 检查浏览器支持const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognitionif (SpeechRecognition) {this.recognition = new SpeechRecognition()this.setupRecognition()} else {console.warn('浏览器不支持语音识别')}}private setupRecognition() {if (!this.recognition) returnthis.recognition.continuous = truethis.recognition.interimResults = truethis.recognition.lang = 'zh-CN'this.recognition.onstart = () => {this.isListening = truethis.emit('started')console.log('语音识别已开始')}this.recognition.onend = () => {this.isListening = falsethis.emit('ended')console.log('语音识别已结束')// 如果仍在转写状态,自动重启识别if (this.shouldRestart()) {this.restart()}}this.recognition.onresult = (event: any) => {let finalTranscript = ''let interimTranscript = ''for (let i = event.resultIndex; i < event.results.length; i++) {const transcript = event.results[i][0].transcriptconst confidence = event.results[i][0].confidenceif (event.results[i].isFinal) {finalTranscript += transcriptthis.emit('finalResult', { text: transcript, confidence })} else {interimTranscript += transcriptthis.emit('interimResult', { text: transcript, confidence })}}this.emit('result', { final: finalTranscript, interim: interimTranscript })}this.recognition.onerror = (event: any) => {console.error('语音识别错误:', event.error)// 添加详细的错误信息记录console.error('语音识别错误详情:', {error: event.error,message: event.message,type: event.type})this.emit('error', event.error)// 根据错误类型决定是否重启if (this.shouldRestartOnError(event.error)) {this.restart()}}}private shouldRestart(): boolean {// 检查是否应该重启识别(仍在转写状态但不是静音状态)return false // 默认不自动重启}private shouldRestartOnError(error: string): boolean {// 根据错误类型决定是否重启const restartableErrors = ['no-speech', 'audio-capture']return restartableErrors.includes(error)}private restart() {// 清除之前的重启定时器if (this.restartTimeout) {clearTimeout(this.restartTimeout)this.restartTimeout = null}// 延迟重启以避免过于频繁的重启this.restartTimeout = window.setTimeout(() => {if (this.isListening === false) {this.start()}}, 1000)}// 开始识别start(): void {if (!this.recognition) {throw new Error('语音识别不可用')}if (this.isListening) {console.warn('语音识别已在进行中')return}this.recognition.start()}// 停止识别stop(): void {// 清除重启定时器if (this.restartTimeout) {clearTimeout(this.restartTimeout)this.restartTimeout = null}if (!this.recognition || !this.isListening) {console.warn('没有正在进行的语音识别')// 即使没有在监听,也要确保状态正确this.isListening = falsereturn}this.recognition.stop()this.isListening = false}// 中止识别abort(): void {// 清除重启定时器if (this.restartTimeout) {clearTimeout(this.restartTimeout)this.restartTimeout = null}if (!this.recognition) {// 即使没有识别器,也要确保状态正确this.isListening = falsereturn}this.recognition.abort()this.isListening = false}// 事件监听器管理on(event: string, callback: Function) {if (!this.eventListeners[event]) {this.eventListeners[event] = []}this.eventListeners[event].push(callback)}off(event: string, callback: Function) {if (this.eventListeners[event]) {const index = this.eventListeners[event].indexOf(callback)if (index > -1) {this.eventListeners[event].splice(index, 1)}}}private emit(event: string, data?: any) {if (this.eventListeners[event]) {this.eventListeners[event].forEach(callback => {try {callback(data)} catch (error) {console.error('事件回调执行错误:', error)}})}}// 获取状态get isSupported(): boolean {return this.recognition !== null}get listening(): boolean {return this.isListening}
}

        腾讯云语音识别(Automatic Speech Recognition,ASR)是将语音转成文字的 PaaS 产品,能够为企业提供极具性价比的语音识别服务。被微信、王者荣耀、腾讯视频等大量内部业务使用,外部亦服务于呼叫中心录音转写、会议实时转写、语音输入法、数字人、互动直播、课堂内容分析等多个业务场景,产品具备丰富的行业落地经验。腾讯云官网:腾讯云 产业智变·云启未来 - 腾讯

启用腾讯云语音识别接口代码如下;

import logging
from django.conf import settingslogger = logging.getLogger(__name__)class SpeechRecognitionService:def __init__(self, service_type='mock'):self.service_type = service_typedef recognize_audio(self, audio_file):if self.service_type == 'tencent':return self._tencent_recognition(audio_file)else:return self._mock_recognition(audio_file)def _mock_recognition(self, audio_file):return {'success': True,'text': f"这是一段模拟的语音识别结果,音频文件大小: {len(audio_file.read())} 字节",'confidence': 0.95}def _tencent_recognition(self, audio_file):try:from django.conf import settingssecret_id = getattr(settings, 'TENCENT_SECRET_ID', '')secret_key = getattr(settings, 'TENCENT_SECRET_KEY', '')if not secret_id or not secret_key:return {'success': False,'error': '腾讯云配置缺失,请检查TENCENT_SECRET_ID和TENCENT_SECRET_KEY设置'}from tencentcloud.common import credentialfrom tencentcloud.common.profile.client_profile import ClientProfilefrom tencentcloud.common.profile.http_profile import HttpProfilefrom tencentcloud.asr.v20190614 import asr_client, modelsimport jsoncred = credential.Credential(secret_id, secret_key)httpProfile = HttpProfile()httpProfile.endpoint = "asr.tencentcloudapi.com"httpProfile.reqTimeout = 30clientProfile = ClientProfile()clientProfile.httpProfile = httpProfileclient = asr_client.AsrClient(cred, "ap-beijing", clientProfile)audio_data = audio_file.read()req = models.SentenceRecognitionRequest()params = {"ProjectId": 0,"SubServiceType": 2,"EngSerViceType": "16k_zh","SourceType": 1,"VoiceFormat": "webm","UsrAudioKey": "meeting-audio","Data": audio_data,"DataLen": len(audio_data)}req.from_json_string(json.dumps(params))resp = client.SentenceRecognition(req)return {'success': True,'text': resp.Result,'confidence': resp.Confidence / 100.0}except ImportError:return {'success': False,'error': '腾讯云SDK未安装,请运行: pip install tencentcloud-sdk-python'}except Exception as e:logger.error(f"腾讯云语音识别失败: {str(e)}")error_msg = str(e)if 'NetworkError' in error_msg or 'timeout' in error_msg.lower() or '连接' in error_msg:return {'success': False,'error': '网络连接问题,请检查网络后重试'}elif 'AuthFailure' in error_msg:return {'success': False,'error': '腾讯云认证失败,请检查密钥配置'}elif 'LimitExceeded' in error_msg:return {'success': False,'error': '腾讯云API调用频率超限,请稍后重试'}elif 'InvalidParameter' in error_msg:return {'success': False,'error': '音频参数无效,请检查音频格式'}elif 'ResourceNotFound' in error_msg:return {'success': False,'error': '腾讯云资源未找到,请检查配置'}elif 'FailedOperation' in error_msg:return {'success': False,'error': '腾讯云服务操作失败,请稍后重试'}else:return {'success': False,'error': f'腾讯云语音识别失败: {str(e)}'}def get_speech_recognition_service():service_type = getattr(settings, 'SPEECH_RECOGNITION_SERVICE', 'mock')return SpeechRecognitionService(service_type)

语音识别转换为文字功能

当使用腾讯云语音接口时,上传给腾讯云语音识别的是音频文件,识别后的文本传递给coze工作流,随后coze根据提示词进行优化和总结,相关代码如下:

调用coze工作流:

import requests
import json
import logging
from django.conf import settings
import time
from typing import List
from cozepy import Coze, TokenAuth, Stream, WorkflowEvent, WorkflowEventType, COZE_CN_BASE_URLlogger = logging.getLogger(__name__)class CozeWorkflowService:def __init__(self):self.api_base_url = getattr(settings, 'COZE_API_BASE_URL', 'https://api.coze.com')self.api_token = getattr(settings, 'COZE_API_TOKEN', '')self.workflow_id = getattr(settings, 'COZE_WORKFLOW_ID', '')self.agent_id = getattr(settings, 'COZE_AGENT_ID', '')self.coze = Coze(auth=TokenAuth(token=self.api_token), base_url=COZE_CN_BASE_URL)def optimize_speech_text(self, audio_file):try:audio_file.seek(0)audio_content = audio_file.read()headers = {'Authorization': f'Bearer {self.api_token}'}url = f"{self.api_base_url}/open_api/v1/chat"data = {'bot_id': self.agent_id,'stream': False,'auto_save_history': True,}additional_messages = [{'role': 'user','content': '请处理这段语音','content_type': 'text'}]files = {'file': (getattr(audio_file, 'name', 'audio.webm'), audio_content, 'audio/webm'),'additional_messages': (None, json.dumps(additional_messages), 'application/json')}response = requests.post(url, files=files, headers=headers, timeout=60)if response.status_code == 200:result = response.json()if result.get('code') == 0:messages = result.get('messages', [])assistant_message = Nonefor msg in reversed(messages):if msg.get('role') == 'assistant':assistant_message = msgbreakif assistant_message:content = assistant_message.get('content', '')try:parsed_content = json.loads(content)return {'success': True,'original_text': parsed_content.get('original_text', ''),'optimized_text': parsed_content.get('optimized_text', content),'confidence': parsed_content.get('confidence', 0.9),'processing_time': parsed_content.get('processing_time', 0)}except json.JSONDecodeError:return {'success': True,'original_text': '','optimized_text': content,'confidence': 0.9,'processing_time': 0}else:return {'success': False,'error': '未找到智能体回复'}else:logger.error(f"Coze智能体执行失败: {result.get('msg')}")return {'success': False,'error': result.get('msg', '智能体执行失败')}elif response.status_code == 429:logger.error("Coze API调用频率超限")return {'success': False,'error': 'API调用频率超限,请稍后重试'}elif response.status_code == 500:logger.error("Coze API服务器内部错误")return {'success': False,'error': 'Coze服务内部错误,请稍后重试'}elif response.status_code == 503:logger.error("Coze API服务暂时不可用")return {'success': False,'error': 'Coze服务暂时不可用,请稍后重试'}else:logger.error(f"Coze API调用失败: {response.status_code} - {response.text}")return {'success': False,'error': f'API调用失败: {response.status_code}'}except Exception as e:logger.error(f"Coze智能体调用异常: {str(e)}")return {'success': False,'error': f'服务异常: {str(e)}'}def _run_workflow(self, text: str) -> List[str]:"""运行工作流并处理事件流Args:text (str): 输入到工作流的文本参数Returns:List[str]: 包含工作流执行过程中产生的所有消息的列表,包括普通消息、错误信息等"""messages: List[str] = []def handle_stream(stream: Stream[WorkflowEvent]):# 遍历事件流并根据事件类型进行相应处理for event in stream:if event.event == WorkflowEventType.MESSAGE:# 处理普通消息事件,将消息内容添加到消息列表中messages.append(event.message.content)elif event.event == WorkflowEventType.ERROR:# 处理错误事件,将错误信息格式化后添加到消息列表中messages.append(f"[ERROR] {event.error}")elif event.event == WorkflowEventType.INTERRUPT:# 处理中断事件,恢复工作流执行并递归处理新的事件流handle_stream(self.coze.workflows.runs.resume(workflow_id=self.workflow_id,event_id=event.interrupt.interrupt_data.event_id,resume_data="continue",interrupt_type=event.interrupt.interrupt_data.type,))# 启动工作流并获取事件流,然后调用处理函数处理事件handle_stream(self.coze.workflows.runs.stream(workflow_id=self.workflow_id,parameters={"input": text}))return messagesdef generate_meeting_summary(self, meeting_texts, meeting_description=""):try:# 添加会议描述到会议内容中description_text = f"会议描述:{meeting_description if meeting_description else '暂无'}\n\n"full_content = description_text + '\n\n'.join([f"[{item.get('timestamp', '')}] {item.get('speaker', '')}: {item.get('text', '')}"for item in meeting_texts])logger.info(f"准备生成会议总结,内容长度: {len(full_content)} 字符")if self.workflow_id:try:logger.info(f"使用工作流生成会议总结,工作流ID: {self.workflow_id}")workflow_messages = self._run_workflow(full_content)valid_messages = [msg for msg in workflow_messages if not msg.startswith('[ERROR]')]if valid_messages:summary_content = '\n'.join(valid_messages)logger.info(f"工作流生成总结成功,总结长度: {len(summary_content)} 字符")# 移除可能的Markdown标记clean_summary = self._remove_markdown_formatting(summary_content)return {'success': True,'summary': clean_summary,'key_points': [],'action_items': [],'meeting_duration': '','word_count': len(clean_summary)}else:logger.warning("工作流未返回有效消息,检查错误信息")error_messages = [msg for msg in workflow_messages if msg.startswith('[ERROR]')]if error_messages:error_msg = error_messages[0]if "permission" in error_msg.lower() or "权限" in error_msg:logger.warning("检测到权限问题,直接回退到智能体方式")return self._generate_summary_with_agent(full_content)logger.warning("工作流执行未返回有效结果,回退到智能体方式")return self._generate_summary_with_agent(full_content)except Exception as e:logger.error(f"工作流执行失败: {str(e)}")return self._generate_summary_with_agent(full_content)else:logger.info("使用智能体生成会议总结")return self._generate_summary_with_agent(full_content)except Exception as e:logger.error(f"会议总结生成异常: {str(e)}")return {'success': False,'error': f'总结生成异常: {str(e)}'}def _generate_summary_with_agent(self, full_content):try:headers = {'Authorization': f'Bearer {self.api_token}','Content-Type': 'application/json'}url = f"{self.api_base_url}/open_api/v2/chat"data = {'bot_id': self.agent_id,'stream': False,'auto_save_history': True,'additional_messages': [{'role': 'user','content': f'请为以下会议内容生成总结,包括关键要点和行动项。请以纯文本格式返回,不要使用Markdown或其他格式标记:\n\n{full_content}','content_type': 'text'}]}response = requests.post(url, json=data, headers=headers, timeout=120)if response.status_code == 200:result = response.json()if result.get('code') == 0:messages = result.get('messages', [])assistant_message = Nonefor msg in reversed(messages):if msg.get('role') == 'assistant':assistant_message = msgbreakif assistant_message:content = assistant_message.get('content', '')try:parsed_content = json.loads(content)# 确保返回纯文本格式的总结summary_text = parsed_content.get('summary', content)# 移除可能的Markdown标记summary_text = self._remove_markdown_formatting(summary_text)key_points = parsed_content.get('key_points', [])# 确保关键要点也是纯文本格式key_points = [self._remove_markdown_formatting(point) for point in key_points]return {'success': True,'summary': summary_text,'key_points': key_points,'action_items': parsed_content.get('action_items', []),'meeting_duration': parsed_content.get('meeting_duration', ''),'word_count': parsed_content.get('word_count', len(summary_text))}except json.JSONDecodeError:# 移除可能的Markdown标记clean_content = self._remove_markdown_formatting(content)return {'success': True,'summary': clean_content,'key_points': [],'action_items': [],'meeting_duration': '','word_count': len(clean_content)}else:return {'success': False,'error': '未找到智能体回复'}else:error_msg = result.get('msg', '总结生成失败')logger.error(f"会议总结生成失败: {error_msg}")if "server issues" in error_msg.lower():return {'success': False,'error': 'Coze服务暂时不可用,请稍后重试或联系技术支持'}elif "token" in error_msg.lower():return {'success': False,'error': 'API令牌无效,请检查配置'}elif "bot_id" in error_msg.lower():return {'success': False,'error': '智能体ID无效,请检查配置'}else:return {'success': False,'error': error_msg}elif response.status_code == 429:logger.error("会议总结API调用频率超限")return {'success': False,'error': 'API调用频率超限,请稍后重试'}elif response.status_code == 500:logger.error("会议总结API服务器内部错误")return {'success': False,'error': 'Coze服务内部错误,请稍后重试或联系技术支持'}elif response.status_code == 503:logger.error("会议总结API服务暂时不可用")return {'success': False,'error': 'Coze服务暂时不可用,请稍后重试'}else:logger.error(f"总结API调用失败: {response.status_code} - {response.text}")return {'success': False,'error': f'总结生成失败: {response.status_code} - {response.text[:100]}'}except Exception as e:logger.error(f"会议总结生成异常: {str(e)}")return {'success': False,'error': f'总结生成异常: {str(e)}'}def _remove_markdown_formatting(self, text):"""移除文本中的Markdown格式标记"""import reif not isinstance(text, str):return str(text)# 移除Markdown标题标记text = re.sub(r'^#+\s*', '', text, flags=re.MULTILINE)# 移除粗体标记text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)text = re.sub(r'__(.*?)__', r'\1', text)# 移除斜体标记text = re.sub(r'\*(.*?)\*', r'\1', text)text = re.sub(r'_(.*?)_', r'\1', text)# 移除代码块标记text = re.sub(r'`([^`]+)`', r'\1', text)# 移除链接标记,保留链接文本text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)# 移除多余的星号text = re.sub(r'\*+', '', text)# 移除多余的空白行text = re.sub(r'\n\s*\n', '\n\n', text)return text.strip()def get_coze_service():return CozeWorkflowService()

使用coze工作流进行交互的相关代码:

@api_view(['POST'])
@permission_classes([IsAuthenticated])
def speech_to_text(request):"""语音转文字接口"""try:audio_file = request.FILES.get('audio')if not audio_file:return Response({'success': False,'error': '没有提供音频文件'}, status=status.HTTP_400_BAD_REQUEST)# 使用语音识别服务speech_service = get_speech_recognition_service()result = speech_service.recognize_audio(audio_file)if result.get('success'):return Response({'success': True,'text': result.get('text'),'confidence': result.get('confidence', 0.0),'message': '语音识别成功'})else:return Response({'success': False,'error': result.get('error', '语音识别失败')}, status=status.HTTP_400_BAD_REQUEST)except Exception as e:logger.error(f'语音转文字失败: {str(e)}')return Response({'success': False,'error': '语音识别失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)@api_view(['POST'])
@permission_classes([IsAuthenticated])
def coze_optimize_speech(request):"""使用Coze工作流进行语音优化"""try:audio_file = request.FILES.get('audio')meeting_id = request.data.get('meeting_id')if not audio_file:return Response({'success': False,'error': '没有提供音频文件'}, status=status.HTTP_400_BAD_REQUEST)if not meeting_id:return Response({'success': False,'error': '没有提供会议 ID'}, status=status.HTTP_400_BAD_REQUEST)# 检查会议和用户权限try:meeting = Meeting.objects.get(meeting_id=meeting_id)if meeting.host != request.user:return Response({'success': False,'error': '您不在此会议中'}, status=status.HTTP_403_FORBIDDEN)except Meeting.DoesNotExist:return Response({'success': False,'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND)# 重置文件指针audio_file.seek(0)# 调用Coze智能体服务from .coze_service import get_coze_servicecoze_service = get_coze_service()result = coze_service.optimize_speech_text(audio_file)if result.get('success'):# 保存优化后的转写记录optimized_transcript = OptimizedTranscript.objects.create(meeting=meeting,speaker_name="内容",original_text=result.get('original_text', ''),optimized_text=result.get('optimized_text', ''),confidence=result.get('confidence', 0.0),start_time=timezone.now(),processing_time=result.get('processing_time', 0.0),workflow_id=getattr(settings, 'COZE_WORKFLOW_ID', ''),optimization_level='high')return Response({'success': True,'original_text': result.get('original_text'),'optimized_text': result.get('optimized_text'),'confidence': result.get('confidence'),'processing_time': result.get('processing_time'),'transcript_id': optimized_transcript.id,'message': '语音优化成功'})else:# 根据错误类型返回适当的HTTP状态码error_message = result.get('error', 'Coze智能体处理失败')if '超时' in error_message:return Response({'success': False,'error': error_message}, status=status.HTTP_408_REQUEST_TIMEOUT)elif '频率超限' in error_message:return Response({'success': False,'error': error_message}, status=status.HTTP_429_TOO_MANY_REQUESTS)elif '服务暂时不可用' in error_message:return Response({'success': False,'error': error_message}, status=status.HTTP_503_SERVICE_UNAVAILABLE)else:return Response({'success': False,'error': error_message}, status=status.HTTP_400_BAD_REQUEST)except Exception as e:logger.error(f'Coze语音优化失败: {str(e)}')logger.error(f'错误详情: {traceback.format_exc()}')return Response({'success': False,'error': f'语音优化失败: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)@api_view(['POST'])
@permission_classes([IsAuthenticated])
def generate_meeting_summary(request, meeting_id):"""生成会议总结"""try:# 检查会议和权限try:meeting = Meeting.objects.get(meeting_id=meeting_id)if meeting.host != request.user:return Response({'success': False,'error': '只有主持人可以生成会议总结'}, status=status.HTTP_403_FORBIDDEN)except Meeting.DoesNotExist:return Response({'success': False,'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND)# 检查会议是否已结束if meeting.status != 'ended':return Response({'success': False,'error': '只有已结束的会议才能生成总结。当前会议状态为:' + dict(Meeting.STATUS_CHOICES).get(meeting.status, meeting.status)}, status=status.HTTP_400_BAD_REQUEST)# 获取所有优化后的转写记录optimized_transcripts = meeting.optimized_transcripts.all().order_by('start_time')# 如果没有优化后的转写记录,尝试使用普通转写记录if not optimized_transcripts.exists():# 获取普通转写记录regular_transcripts = meeting.transcripts.all().order_by('start_time')if not regular_transcripts.exists():# 提供更详细的错误信息,包括会议信息return Response({'success': False,'error': f'无法生成会议总结:会议中没有语音转写内容。请确保在会议期间启用了语音转写功能并有发言内容。会议ID: {meeting_id}, 会议主题: {meeting.title}'}, status=status.HTTP_400_BAD_REQUEST)# 将普通转写记录转换为优化后的格式meeting_texts = []for transcript in regular_transcripts:meeting_texts.append({'timestamp': transcript.start_time.strftime('%H:%M:%S') if transcript.start_time else '','speaker': transcript.speaker_name,'text': transcript.text})else:# 准备优化后的会议文字数据meeting_texts = []for transcript in optimized_transcripts:meeting_texts.append({'timestamp': transcript.start_time.strftime('%H:%M:%S') if transcript.start_time else '','speaker': transcript.speaker_name,'text': transcript.optimized_text or transcript.original_text})# 检查是否有会议内容if not meeting_texts:return Response({'success': False,'error': f'没有找到有效的会议记录,无法生成总结。请确保会议中有语音转写内容。会议ID: {meeting_id}, 会议主题: {meeting.title}'}, status=status.HTTP_400_BAD_REQUEST)# 记录调试信息logger.info(f"准备生成会议总结,会议ID: {meeting_id}, 记录数量: {len(meeting_texts)}")logger.info(f"会议内容预览: {meeting_texts[:3] if len(meeting_texts) > 3 else meeting_texts}")# 调用Coze智能体生成总结,包含会议描述from .coze_service import get_coze_servicecoze_service = get_coze_service()summary_result = coze_service.generate_meeting_summary(meeting_texts, meeting.description)logger.info(f"Coze服务返回结果: {summary_result}")if summary_result.get('success'):# 保存或更新会议总结summary, created = MeetingSummary.objects.update_or_create(meeting=meeting,defaults={'summary_text': summary_result.get('summary', ''),'key_points': summary_result.get('key_points', []),'action_items': summary_result.get('action_items', []),'total_words': summary_result.get('word_count', 0),'total_duration': (meeting.actual_end_time - meeting.actual_start_time).total_seconds() if meeting.actual_end_time and meeting.actual_start_time else 0,'generated_by': 'coze_agent'})return Response({'success': True,'summary': summary.get_summary_data(),'message': '会议总结生成成功'})else:error_msg = summary_result.get('error', '总结生成失败')logger.error(f"会议总结生成失败: {error_msg}")# 根据错误类型返回适当的HTTP状态码if '超时' in error_msg:return Response({'success': False,'error': error_msg}, status=status.HTTP_408_REQUEST_TIMEOUT)elif '频率超限' in error_msg:return Response({'success': False,'error': error_msg}, status=status.HTTP_429_TOO_MANY_REQUESTS)elif '服务暂时不可用' in error_msg:return Response({'success': False,'error': error_msg}, status=status.HTTP_503_SERVICE_UNAVAILABLE)elif '没有语音转写内容' in error_msg or '没有有效的会议记录' in error_msg:# 提供更友好的中文提示return Response({'success': False,'error': f'无法生成会议总结:{error_msg}。请确保在会议期间启用了语音转写功能并有发言内容。'}, status=status.HTTP_400_BAD_REQUEST)else:return Response({'success': False,'error': error_msg}, status=status.HTTP_400_BAD_REQUEST)except Exception as e:logger.error(f'生成会议总结失败: {str(e)}')logger.error(f'错误详情: {traceback.format_exc()}')return Response({'success': False,'error': f'生成总结失败: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

3.前端的响应式布局实现实时转写结果显示

前端代码如下:

会议室相关代码:

<template><div class="meeting-room"><div class="meeting-header"><div class="meeting-info"><h2>{{ meeting?.title || '会议室' }}</h2><p>会议ID: {{ meetingId }}</p></div><div class="meeting-controls"><el-button type="danger" @click="leaveMeeting":loading="leaving">离开会议</el-button></div></div><div class="meeting-content"><!-- 音频控制区域 --><div class="audio-controls"><el-button :type="isTranscribing ? 'success' : 'info'"@click="toggleTranscription":loading="transcriptionLoading"><el-icon><component :is="isTranscribing ? 'ChatLineSquare' : 'ChatDotSquare'" /></el-icon>{{ isTranscribing ? '停止转写' : '开始转写' }}</el-button></div><!-- 转写显示区域 --><div class="transcription-area" v-if="showTranscription"><h3>实时转写</h3><div class="transcription-content" ref="transcriptionRef"><div v-for="transcript in transcripts" :key="transcript.timestamp"class="transcript-item"><span class="speaker">内容:</span><span class="text">{{ transcript.text }}</span><span class="time">{{ formatTime(transcript.timestamp) }}</span></div><div v-if="currentTranscript" class="transcript-item interim"><span class="speaker">内容:</span><span class="text">{{ currentTranscript }}</span><span class="time">实时</span></div></div></div></div></div>
</template><script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
import { Microphone, Mute,ChatLineSquare,ChatDotSquare,CircleCheckFilled 
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
import { meetingApi, type Meeting } from '@/api/meeting'
import { AudioManager, SpeechRecognitionManager } from '@/utils/audio'const route = useRoute()
const router = useRouter()
const userStore = useUserStore()// 响应式数据
const meetingId = ref(route.params.meetingId as string)
const meeting = ref<Meeting | null>(null)
const leaving = ref(false)// 音频相关
const audioManager = ref<AudioManager | null>(null)
const speechRecognition = ref<SpeechRecognitionManager | null>(null)
const isTranscribing = ref(false)
const transcriptionLoading = ref(false)// 转写相关
const showTranscription = ref(true)
const transcripts = ref<Array<{speaker: stringtext: stringtimestamp: numberconfidence?: number
}>>([])
const currentTranscript = ref('')
const transcriptionRef = ref<HTMLElement>()const currentUser = userStore.user// 处理音频数据的函数
const handleAudioData = async (data: Blob) => {try {// 只有当音频数据不为空时才发送到Coze服务if (data.size > 0 && meetingId.value) {const response = await meetingApi.cozeOptimizeSpeech(data, meetingId.value)if (response.success && response.optimized_text) {addOptimizedTranscript(currentUser?.username || '我', response.original_text,response.optimized_text, response.confidence)} else {// 即使Coze服务没有返回优化文本,也显示原始转写内容if (response.original_text) {addTranscript(currentUser?.username || '我', response.original_text, response.confidence)} else if (response.error) {ElMessage.error(response.error || '语音优化失败')}}}} catch (error: any) {console.error('Coze语音优化失败:', error)// 根据错误类型提供不同的提示if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {ElMessage.error('语音处理超时,请检查网络连接后重试')} else if (error.response?.status === 408) {ElMessage.error('语音处理超时,请稍后重试')} else if (error.response?.status === 503) {ElMessage.error('服务暂时不可用,请稍后重试')} else if (error.response?.status === 500) {ElMessage.error('服务器内部错误,请稍后重试')} else if (error.response?.status === 400) {ElMessage.error('请求参数错误,请检查后重试')} else {ElMessage.error('语音优化失败,请检查网络连接后重试')}}
}// 处理从语音识别服务发送的音频数据
const handleAudioDataFromSpeechRecognition = async (data: Blob) => {try {// 只有当音频数据不为空时才发送到Coze服务if (data.size > 0 && meetingId.value) {const response = await meetingApi.cozeOptimizeSpeech(data, meetingId.value)if (response.success && response.optimized_text) {addOptimizedTranscript(currentUser?.username || '我', response.original_text,response.optimized_text, response.confidence)} else {// 即使Coze服务没有返回优化文本,也显示原始转写内容if (response.original_text) {addTranscript(currentUser?.username || '我', response.original_text, response.confidence)} else if (response.error) {ElMessage.error(response.error || '语音优化失败')}}}} catch (error: any) {console.error('Coze语音优化失败:', error)// 根据错误类型提供不同的提示if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {ElMessage.error('语音处理超时,请检查网络连接后重试')} else if (error.response?.status === 408) {ElMessage.error('语音处理超时,请稍后重试')} else if (error.response?.status === 503) {ElMessage.error('服务暂时不可用,请稍后重试')} else if (error.response?.status === 500) {ElMessage.error('服务器内部错误,请稍后重试')} else if (error.response?.status === 400) {ElMessage.error('请求参数错误,请检查后重试')} else {ElMessage.error('语音优化失败,请检查网络连接后重试')}}
}// 方法
const initializeMeeting = async () => {try {// 获取会议信息const response = await meetingApi.getMeetingById(meetingId.value)if (response.success) {meeting.value = response.meeting} else {ElMessage.error('获取会议信息失败')router.push('/home/meeting')return}// 初始化音频管理器await initializeAudio()// 初始化语音识别initializeSpeechRecognition()} catch (error) {console.error('初始化会议失败:', error)ElMessage.error('初始化会议失败')}
}const initializeAudio = async () => {try {audioManager.value = new AudioManager()audioManager.value.on('initialized', () => {console.log('音频管理器初始化成功')// 确保音频轨道启用if (audioManager.value) {const tracks = audioManager.value['audioTracks']if (tracks) {tracks.forEach(track => {track.enabled = true})}}})audioManager.value.on('error', (error: any) => {console.error('音频错误:', error)ElMessage.error('音频初始化失败,请检查麦克风权限')})audioManager.value.on('dataAvailable', (data: Blob) => {// 处理音频数据if (typeof handleAudioData === 'function') {handleAudioData(data)} else {console.warn('handleAudioData函数未定义')}})await audioManager.value.initialize()} catch (error) {console.error('音频初始化失败:', error)ElMessage.error('无法访问麦克风,请检查权限设置')}
}const initializeSpeechRecognition = () => {speechRecognition.value = new SpeechRecognitionManager()if (!speechRecognition.value.isSupported) {console.warn('浏览器不支持语音识别,将使用后端语音识别服务')return}speechRecognition.value.on('interimResult', (data: any) => {currentTranscript.value = data.text})speechRecognition.value.on('finalResult', (data: any) => {addTranscript(currentUser?.username || '我', data.text, data.confidence)currentTranscript.value = ''// 保存到服务器saveTranscript(data.text, data.confidence)})speechRecognition.value.on('error', (error: string) => {console.error('语音识别错误:', error)// 根据错误类型提供不同的提示let errorMessage = '语音识别出错,请稍后重试'if (error === 'network') {errorMessage = '网络连接不稳定,请检查网络后重试'} else if (error === 'not-allowed') {errorMessage = '麦克风权限被拒绝,请允许麦克风访问'} else if (error === 'no-speech') {errorMessage = '未检测到语音,请稍后重试'} else if (error === 'aborted') {errorMessage = '语音识别被中断,请重新开始'} else if (error === 'audio-capture') {errorMessage = '音频捕获失败,请检查麦克风设备'} else if (error === 'not-supported') {errorMessage = '浏览器不支持语音识别功能'} else if (error === 'service-not-allowed') {errorMessage = '语音识别服务被拒绝,请检查浏览器设置'} else if (error === 'bad-grammar') {errorMessage = '语音识别语法错误,请稍后重试'} else if (error === 'language-not-supported') {errorMessage = '不支持当前语言,请切换语言后重试'}// 显示错误消息ElMessage.error(errorMessage)// 如果是网络错误,尝试切换到后端语音识别if (error === 'network') {console.log('切换到后端语音识别服务')// 停止当前的语音识别speechRecognition.value?.stop()isTranscribing.value = false// 启动后端语音识别if (!audioManager.value?.recordingState) {// 先移除之前的事件监听器(如果有的话)audioManager.value?.off('dataAvailable', handleAudioDataFromSpeechRecognition)// 添加新的事件监听器用于处理录音数据audioManager.value?.on('dataAvailable', handleAudioDataFromSpeechRecognition)// 使用统一的5秒间隔时间audioManager.value?.startRecording(5000) // 每5秒发送一次音频isTranscribing.value = trueElMessage.info('已切换到后端语音识别服务')}}})
}const toggleTranscription = async () => {transcriptionLoading.value = truetry {if (isTranscribing.value) {// 停止转写speechRecognition.value?.stop()audioManager.value?.stopRecording()isTranscribing.value = falseElMessage.success('转写已停止')} else {// 开始转写if (speechRecognition.value?.isSupported) {// 使用浏览器自带语音识别speechRecognition.value.start()} else {// 使用后端语音识别服务,开始录音// 先移除之前的事件监听器(如果有的话)audioManager.value?.off('dataAvailable', handleAudioDataFromSpeechRecognition)// 添加新的事件监听器用于处理录音数据audioManager.value?.on('dataAvailable', handleAudioDataFromSpeechRecognition)// 确保音频轨道启用if (audioManager.value) {const tracks = audioManager.value['audioTracks']if (tracks) {tracks.forEach(track => {track.enabled = true})}}// 增加录音间隔时间到5秒,以捕获更完整的语音内容audioManager.value?.startRecording(5000) // 每5秒发送一次音频}isTranscribing.value = trueElMessage.success('转写已开始')}} catch (error: any) {console.error('切换转写状态失败:', error)ElMessage.error('操作失败,请稍后重试')} finally {transcriptionLoading.value = false}
}const addTranscript = (speaker: string, text: string, confidence?: number) => {transcripts.value.push({speaker,text,timestamp: Date.now(),confidence})// 自动滚动到底部nextTick(() => {if (transcriptionRef.value) {transcriptionRef.value.scrollTop = transcriptionRef.value.scrollHeight}})
}// 新增:处理Coze优化后的转写
const addOptimizedTranscript = (speaker: string, originalText: string, optimizedText: string, confidence?: number) => {transcripts.value.push({speaker,text: optimizedText, // 显示优化后的文字timestamp: Date.now(),confidence,})// 自动滚动到底部nextTick(() => {if (transcriptionRef.value) {transcriptionRef.value.scrollTop = transcriptionRef.value.scrollHeight}})
}const saveTranscript = async (text: string, confidence: number) => {try {await meetingApi.saveTranscript({meeting_id: meetingId.value,text,confidence})} catch (error: any) {console.error('保存转写记录失败:', error)// 添加更详细的错误处理if (error.response?.status === 401) {ElMessage.error('认证已过期,请重新登录')// 清除本地存储的令牌并重定向到登录页面localStorage.removeItem('token')window.location.href = '/login'} else if (error.response?.status === 403) {ElMessage.error('您没有权限保存转写记录')} else if (error.response?.status === 404) {ElMessage.error('会议不存在')} else {ElMessage.error('保存转写记录失败,请稍后重试')}}
}const leaveMeeting = async () => {try {await ElMessageBox.confirm('确定要离开会议吗?', '确认离开', {confirmButtonText: '离开',cancelButtonText: '取消',type: 'warning'})leaving.value = truetry {// 如果是主持人,先结束会议if (meeting.value?.host === userStore.user?.id) {await meetingApi.endMeeting(meetingId.value)ElMessage.success('会议已结束')// 提示是否生成总结try {await ElMessageBox.confirm('是否现在生成会议总结?', '生成总结', {confirmButtonText: '生成总结',cancelButtonText: '稍后再说',type: 'info'// 增加自定义类名以便样式调整})// 显示生成总结的加载提示const loading = ElLoading.service({lock: true,text: '正在生成会议总结...',spinner: 'el-icon-loading',background: 'rgba(0, 0, 0, 0.7)'})try {// 生成总结const summaryResponse = await meetingApi.generateMeetingSummary(meetingId.value)loading.close()if (summaryResponse.success) {ElMessage.success('会议总结生成成功')// 提示下载try {await ElMessageBox.confirm('是否下载会议总结?', '下载总结', {confirmButtonText: '下载',cancelButtonText: '不下载',type: 'info'})await meetingApi.downloadMeetingSummary(meetingId.value)} catch (downloadError) {// 用户取消下载ElMessage.info('您可以稍后在会议列表中查看和下载总结')}} else {ElMessage.error('总结生成失败: ' + (summaryResponse.error || '未知错误'))}} catch (summaryError: any) {loading.close()console.error('生成会议总结失败:', summaryError)// 提供更具体的错误信息let errorMessage = '生成总结失败'if (summaryError.message) {errorMessage = summaryError.message} else if (summaryError.response?.data?.error) {errorMessage = summaryError.response.data.error} else if (summaryError.response?.status === 400) {// 特别处理400错误,提供更友好的中文提示errorMessage = summaryError.response.data?.error || '请求参数错误'} else if (summaryError.response?.status === 403) {errorMessage = '没有权限生成会议总结'} else if (summaryError.response?.status === 404) {errorMessage = '会议不存在'} else if (summaryError.response?.status === 408) {errorMessage = '请求超时,请稍后重试'} else if (summaryError.response?.status === 429) {errorMessage = '请求频率超限,请稍后重试'} else if (summaryError.response?.status === 503) {errorMessage = '服务暂时不可用,请稍后重试'}ElMessage.error('总结生成失败: ' + errorMessage)}} catch (summaryError: any) {// 用户取消生成总结或生成失败if (summaryError.message && summaryError.message !== 'cancel') {ElMessage.info(summaryError.message)}}} else {// 非主持人直接离开页面ElMessage.success('已离开会议')}} catch (error: any) {console.error('操作失败:', error)// 提供更具体的错误信息if (error.response?.status === 400) {ElMessage.error('操作失败: ' + (error.response.data?.error || '请求参数错误'))} else if (error.response?.status === 403) {ElMessage.error('操作失败: 没有权限执行此操作')} else if (error.response?.status === 404) {ElMessage.error('操作失败: 会议不存在')} else {ElMessage.error('操作失败: ' + (error.message || '未知错误'))}}// 清理资源cleanup()// 返回会议列表router.push('/home/meeting')} catch (error: any) {if (error !== 'cancel') {console.error('离开会议失败:', error)ElMessage.error('离开会议失败: ' + (error.message || '未知错误'))}} finally {leaving.value = false}
}const formatTime = (timestamp: number) => {return new Date(timestamp).toLocaleTimeString('zh-CN')
}const cleanup = () => {// 停止转写if (isTranscribing.value) {speechRecognition.value?.stop()audioManager.value?.stopRecording()isTranscribing.value = false}// 清理音频管理器audioManager.value?.cleanup()// 停止语音识别speechRecognition.value?.abort()// 移除事件监听器audioManager.value?.off('dataAvailable', handleAudioData)audioManager.value?.off('dataAvailable', handleAudioDataFromSpeechRecognition)// 清除可能存在的定时器if (typeof window !== 'undefined') {// 清除所有相关的定时器const intervalId = (window as any).audioProcessingInterval;if (intervalId) {clearInterval(intervalId);delete (window as any).audioProcessingInterval;}}
}// 生命周期
onMounted(() => {initializeMeeting()
})onUnmounted(() => {cleanup()
})// 监听页面关闭事件
window.addEventListener('beforeunload', cleanup)
</script><style scoped>
.meeting-room {height: 100vh;display: flex;flex-direction: column;background: #f5f5f5;
}.meeting-header {background: white;padding: 15px 20px;display: flex;justify-content: space-between;align-items: center;border-bottom: 1px solid #e4e7ed;
}.meeting-info h2 {margin: 0 0 5px 0;color: #303133;
}.meeting-info p {margin: 0;color: #909399;font-size: 14px;
}.meeting-content {flex: 1;display: flex;flex-direction: column;padding: 20px;gap: 20px;
}.audio-controls {display: flex;justify-content: center;gap: 10px;
}.audio-controls .el-button {min-width: 120px;
}.transcription-area {flex: 1;background: white;border-radius: 4px;padding: 20px;box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}.transcription-area h3 {margin-top: 0;color: #303133;border-bottom: 1px solid #e4e7ed;padding-bottom: 10px;
}.transcription-content {height: calc(100% - 40px);overflow-y: auto;padding: 10px 0;
}.transcript-item {margin-bottom: 10px;padding: 8px 12px;border-radius: 4px;background-color: #f5f7fa;
}.transcript-item.interim {background-color: #e6f7ff;border: 1px solid #91d5ff;
}.transcript-item .speaker {font-weight: bold;color: #1890ff;margin-right: 8px;
}.transcript-item .text {color: #303133;
}.transcript-item .time {float: right;color: #909399;font-size: 12px;
}
</style>

会议转写详情相关代码:

<template><div class="meeting-transcripts-container"><div class="header"><el-page-header @back="goBack"><template #content><span class="header-title">会议转写记录</span></template></el-page-header></div><div class="content" v-loading="loading"><div class="meeting-info" v-if="meeting"><h2>{{ meeting.title }}</h2><p>会议ID: {{ meeting.meeting_id }}</p><p>会议时间: {{ formatDateTime(meeting.scheduled_time) }}</p></div><div class="transcripts-list"><el-timeline v-if="transcripts.length > 0"><el-timeline-itemv-for="transcript in transcripts":key="transcript.id":timestamp="formatTime(transcript.start_time)"placement="top"><el-card><h4>内容</h4><p>{{ transcript.text }}</p>
<!--              <div class="transcript-info">-->
<!--                <el-tag type="info" size="small">置信度: {{ (transcript.confidence * 100).toFixed(1) }}%</el-tag>-->
<!--                <el-tag type="info" size="small">时长: {{ transcript.duration }}秒</el-tag>-->
<!--              </div>--></el-card></el-timeline-item></el-timeline><el-empty v-else description="暂无转写记录" /></div></div></div>
</template><script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { meetingApi, type Meeting, type MeetingTranscript } from '@/api/meeting'const route = useRoute()
const router = useRouter()const loading = ref(false)
const meeting = ref<Meeting | null>(null)
const transcripts = ref<MeetingTranscript[]>([])const meetingId = route.params.meetingId as stringconst goBack = () => {router.back()
}const formatDateTime = (dateString: string) => {if (!dateString) return ''return new Date(dateString).toLocaleString('zh-CN')
}const formatTime = (dateString: string) => {if (!dateString) return ''return new Date(dateString).toLocaleTimeString('zh-CN')
}const loadMeetingInfo = async () => {try {const response = await meetingApi.getMeetingById(meetingId)if (response.success) {meeting.value = response.meeting} else {ElMessage.error(response.error || '获取会议信息失败')}} catch (error) {console.error('获取会议信息失败:', error)ElMessage.error('获取会议信息失败')}
}const loadTranscripts = async () => {loading.value = truetry {const response = await meetingApi.getMeetingTranscripts(meetingId)if (response.success) {transcripts.value = response.transcripts || []} else {ElMessage.error(response.error || '获取转写记录失败')}} catch (error) {console.error('获取转写记录失败:', error)ElMessage.error('获取转写记录失败')} finally {loading.value = false}
}const loadData = async () => {await Promise.all([loadMeetingInfo(), loadTranscripts()])
}onMounted(() => {loadData()
})
</script><style scoped>
.meeting-transcripts-container {padding: 20px;height: 100%;display: flex;flex-direction: column;
}.header {margin-bottom: 20px;
}.header-title {font-size: 18px;font-weight: 500;
}.content {flex: 1;overflow-y: auto;
}.meeting-info {margin-bottom: 30px;padding: 20px;background-color: #f5f7fa;border-radius: 4px;
}.meeting-info h2 {margin: 0 0 10px 0;color: #303133;
}.meeting-info p {margin: 5px 0;color: #606266;
}.transcripts-list {margin-top: 20px;
}.transcript-info {margin-top: 10px;display: flex;gap: 10px;
}
</style>

4.3文件导出模块

在对会议的总结存储时,我将其存储到数据库里面,然后查看总结的形式可以选择txt文档形式和word格式,下载也是一样。

提供会议总结的导出功能:

  • TXT文本格式导出

  • DOCX文档格式导出

  • 导出内容格式化处理

相关代码如下;

@api_view(['GET'])
@permission_classes([IsAuthenticated])
def download_meeting_summary(request, meeting_id):"""下载会议总结"""try:from django.http import HttpResponseimport remeeting = Meeting.objects.get(meeting_id=meeting_id)# 检查用户权限if meeting.host != request.user:return Response({'success': False,'error': '您没有权限下载此会议总结'}, status=status.HTTP_403_FORBIDDEN)try:summary = meeting.summaryexcept MeetingSummary.DoesNotExist:return Response({'success': False,'error': '该会议尚未生成总结'}, status=status.HTTP_404_NOT_FOUND)# 获取请求的文件格式参数file_format = request.GET.get('format', 'txt')  # 默认为txt格式# 限制只支持txt和docx格式,移除md格式if file_format not in ['txt', 'docx']:file_format = 'txt'  # 默认为txt格式# 创建文本格式的总结,与前端显示格式保持一致# 处理summary_text可能包含JSON的情况import jsontry:# 尝试解析summary_text为JSONsummary_data = json.loads(summary.summary_text)# 如果是JSON格式且包含message字段,则使用该消息if 'message' in summary_data:summary_content = summary_data['message']else:summary_content = summary.summary_textexcept json.JSONDecodeError:# 如果不是JSON格式,直接使用原文本summary_content = summary.summary_text# 清理Markdown标记,确保下载的内容没有##号等标记def clean_markdown(text):if not text:return ""# 移除Markdown标题标记(包括多级标题)text = re.sub(r'^#{1,6}\s*', '', text, flags=re.MULTILINE)# 移除粗体标记text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)# 移除斜体标记text = re.sub(r'\*(.*?)\*', r'\1', text)# 移除多余的星号text = re.sub(r'\*+', '', text)# 移除行内代码标记text = re.sub(r'`+', '', text)# 移除引用标记text = re.sub(r'^>\s*', '', text, flags=re.MULTILINE)# 移除分隔线text = re.sub(r'^[-*]{3,}\s*$', '', text, flags=re.MULTILINE)return text.strip()# 清理总结内容clean_summary_content = clean_markdown(summary_content)# 准备基础内容basic_content = f"""会议总结会议信息:
会议主题:{meeting.title}
会议描述:{meeting.description if meeting.description else '暂无'}
会议时间:{meeting.actual_start_time.strftime('%Y-%m-%d %H:%M:%S') if meeting.actual_start_time else '未开始'}
会议时长:{summary.total_duration}秒总结内容
{clean_summary_content}关键要点:"""# 添加关键要点,使用列表格式,并清理Markdown标记for point in summary.key_points:clean_point = clean_markdown(point) if isinstance(point, str) else pointbasic_content += f"\n• {clean_point}"# 添加签名basic_content += "\n\n签名:"# 只支持txt和docx格式,移除md格式if file_format == 'docx':# Word格式处理try:from docx import Documentfrom io import BytesIOdocument = Document()document.add_heading('会议总结', 0)# 添加内容到Word文档lines = basic_content.split('\n')for line in lines:if line.strip():if line.startswith('会议信息:') or line.startswith('总结内容') or line.startswith('关键要点:') or line.startswith('签名:'):document.add_heading(line.strip(), level=1)elif line.startswith('会议主题:') or line.startswith('会议描述:') or line.startswith('会议时间:') or line.startswith('会议时长:'):document.add_paragraph(line.strip(), style='Intense Quote')elif line.startswith('•'):document.add_paragraph(line.strip(), style='List Bullet')else:document.add_paragraph(line.strip())buffer = BytesIO()document.save(buffer)docx_data = buffer.getvalue()buffer.close()response = HttpResponse(docx_data,content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document')response['Content-Disposition'] = f'attachment; filename="{meeting.title}_总结_{timezone.now().strftime("%Y%m%d")}.docx"'return responseexcept ImportError:# 如果没有安装python-docx,则返回txt格式response = HttpResponse(basic_content.encode('utf-8'), content_type='text/plain; charset=utf-8')response['Content-Disposition'] = f'attachment; filename="{meeting.title}_总结_{timezone.now().strftime("%Y%m%d")}.txt"'return responseelse:# 默认返回文本格式content = basic_content# 根据格式返回相应的内容response = HttpResponse(content.encode('utf-8'), content_type='text/plain; charset=utf-8')response['Content-Disposition'] = f'attachment; filename="{meeting.title}_总结_{timezone.now().strftime("%Y%m%d")}.txt"'return responseexcept Meeting.DoesNotExist:return Response({'success': False,'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND)except Exception as e:logger.error(f'下载会议总结失败: {str(e)}')return Response({'success': False,'error': '下载失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)@api_view(['POST'])
@permission_classes([IsAuthenticated])
def save_transcript(request):"""保存会议转写记录"""data = request.datatry:# 使用meeting_id而不是id来查找会议meeting = Meeting.objects.get(meeting_id=data.get('meeting_id'))# 检查用户是否是会议主持人if meeting.host != request.user:return Response({'success': False,'error': '您不是此会议的主持人'}, status=status.HTTP_403_FORBIDDEN)transcript = MeetingTranscript.objects.create(meeting=meeting,speaker_name=request.user.username,text=data.get('text'),confidence=data.get('confidence', 0.0),start_time=timezone.now(),end_time=timezone.now(),duration=data.get('duration', 0))serializer = MeetingTranscriptSerializer(transcript)return Response({'success': True,'transcript': serializer.data})except Meeting.DoesNotExist:return Response({'success': False,'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND)except Exception as e:logger.error(f'保存转写记录失败: {str(e)}')logger.error(f'错误详情: {traceback.format_exc()}')return Response({'success': False,'error': '保存失败'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

5.总结

        该会议管理系统通过完整的功能设计和实现,为用户提供了从会议创建到总结生成的全流程解决方案。系统的核心亮点包括:

  1. 完整的会议生命周期管理:支持会议的创建、开始、进行、结束和总结等完整流程
  2. 先进的语音处理技术:集成多种语音识别服务和AI优化功能
  3. 智能会议总结:基于AI技术自动生成会议总结、关键要点和行动项
  4. 良好的用户体验:提供直观的操作界面和实时反馈机制
  5. 灵活的扩展性:模块化设计便于功能扩展和集成

        该系统不仅满足了基本的会议管理需求,还通过AI技术提升了会议效率和价值,为现代企业协作提供了有力支持。        

       未来与展望:

  • 集成更多AI服务商(OpenAI、百度等)
  • 支持视频会议和屏幕共享
  • 移动端应用开发
  • 企业级SSO集成

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/diannao/98286.shtml
繁体地址,请注明出处:http://hk.pswp.cn/diannao/98286.shtml
英文地址,请注明出处:http://en.pswp.cn/diannao/98286.shtml

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【LeetCode 每日一题】1277. 统计全为 1 的正方形子矩阵

Problem: 1277. 统计全为 1 的正方形子矩阵 文章目录整体思路完整代码时空复杂度时间复杂度&#xff1a;O(m * n)空间复杂度&#xff1a;O(m * n)整体思路 这段代码旨在解决一个经典的二维矩阵问题&#xff1a;统计全为 1 的正方形子矩阵个数 (Count Square Submatrices with …

【论文阅读】MedResearcher-R1: 基于知识引导轨迹合成框架的专家级医学深度研究员

论文链接&#xff1a;https://arxiv.org/pdf/2508.14880 【导读】当通用大模型还在“背题库”时&#xff0c;蚂蚁集团联合哈工大推出的 MedResearcher-R1 已把“临床查房”搬进训练场&#xff01;这篇 2025 年 9 月发布的论文&#xff0c;首次让开源 32B 模型在医学深度研究基准…

基于大语言模型的事件响应优化方案探索

程序员的技术管理推荐阅读 当愿望遇上能力鸿沟&#xff1a;一位技术管理者眼中的团队激励思考 从“激励”到“保健”&#xff1a;80后与90后程序员&#xff0c;到底想要什么&#xff1f; 从“激励”到“保健”&#xff1a;80后与90后程序员&#xff0c;到底想要什么&#xff1f…

数字化浪潮下,传统加工厂如何智能化转型?

在制造业向高端化、服务化升级的今天&#xff0c;传统加工厂正面临前所未有的挑战。订单碎片化、人力成本攀升、设备OEE&#xff08;综合效率&#xff09;长期低于50%、质量波动难以追溯……这些痛点不仅压缩着企业利润空间&#xff0c;更让其在应对市场需求变化时显得迟缓。当…

谓语动词选择指南

文章目录谓语动词的重要性谓语动词类别一. 助动词1. be&#xff08;am, is, are, was, were, been, being&#xff09;表示 存在、状态、身份、特征。2. have&#xff08;have, has, had&#xff09;表示 拥有、经历 或 完成时态的助动词。3. do&#xff08;do, does, did&…

代码随想录学习摘抄day7(二叉树11-21)

一个朴实无华的目录题型226.翻转二叉树思路&#xff1a;把每一个节点的左右孩子交换一下101. 对称二叉树思路&#xff1a;使用队列来比较两个树&#xff08;根节点的左右子树&#xff09;是否相互翻转222.完全二叉树的节点个数思路&#xff1a;本题直接就是求有多少个节点&…

Python+DRVT 从外部调用 Revit:批量创建楼板

今天继续批量创建常用的基础元素&#xff1a;楼板。这次以简单的轮廓为矩形的楼板为例。让我们来看一看如何让Revit自动干活&#xff1a; from typing import List import math # drvt_pybind 支持多会话、多文档&#xff0c;先从简单的单会话、单文档开始 # MyContext是在Pyt…

猿辅导数据分析面试题及参考答案

给定用户成绩表,编写SQL查询排名靠前的用户(例如前10名),并说明rank()和dense_rank()的区别。 要查询成绩表中排名靠前的用户(如前10名),需先明确排名依据(通常为成绩降序),再通过排序和限制结果行数实现。假设用户成绩表名为user_scores,包含user_id(用户ID)和s…

在树莓派集群上部署 Distributed Llama (Qwen 3 14B) 详细指南

项目地址&#xff1a;https://github.com/b4rtaz/distributed-llama 本文档将指导您如何使用一个树莓派5作为Root节点和三个树莓派4作为Worker节点&#xff0c;共同搭建一个4节点的分布式LLM推理集群&#xff0c;并运行10.9GB的Qwen 3 14B模型。 中间要用到github和huggingface…

C++ 容器——unordered_xxx

自 C11 开始&#xff0c;STL 引入了基于 hash table 的 unordered_set、unordered_map 等容器&#xff0c;正如其名它们是无序容器。一定数量&#xff08;据说有测试数据是10000000&#xff09;元素时无序容器的性能要比对应的有序容器优。一、容器数据结构unordered_set、unor…

分布式常见面试题整理

一、分布式理论&#xff1a; CAP理论 分布式系统最多同时满足一致性&#xff08;C&#xff09;、可用性&#xff08;A&#xff09;、分区容错性&#xff08;P&#xff09;中的两个&#xff0c;无法三者兼得。 BASE理论 对CAP中一致性和可用性的权衡&#xff0c;强调基本可用&a…

Python基础入门常用198英语单词详解

最近&#xff0c;我总结了一份Python学习者入门常用单词表&#xff0c;列出了Python学习中常见的198个高频单词&#xff0c;供初学者学习使用。 这些单词都比较简单&#xff0c;非常易于理解&#xff0c;在掌握好单词的基础上&#xff0c;再去学Python可以达到事半功倍的效果。…

EP-SPY 網路追蹤規避實驗:山脈通聯測試

EP-SPY V3.0 https://github.com/MartinxMax/ep-spy 基於 GI6E 編碼的無線電通信工具&#xff0c;用於保護您的隱私。 https://github.com/MartinxMax/gi6e 編寫了偽協議以防止內容被解密無法通過網絡追蹤&#xff0c;抵抗官方監控無線音頻廣播&#xff0c;用於隱蔽信息傳輸…

苹果 FoundationModels 秘典侠客行:隐私为先的端侧 AI 江湖

引子 话说侠客岛之上&#xff0c;有一对年轻侠侣 ——「青锋剑客」凌云与「素心仙子」苏凝&#xff0c;二人自幼习武&#xff0c;尤擅拆解各路奇功秘籍。 近日听闻苹果谷&#xff08;Apple&#xff09;于 WWDC 2025 武林大会之上&#xff0c;亮出一门全新绝学「FoundationMod…

华为基于IPD的产品质量计划模板

目录 模板:产品质量计划模板....................................... 1 1. 介绍...................................................................... 5 1.1. 范围和目的.................................................... 5 1.2. 参考资料..…

事务管理的选择:为何 @Transactional 并非万能,TransactionTemplate 更值得信赖

在 Spring 生态的后端开发中&#xff0c;事务管理是保障数据一致性的核心环节。开发者常常会使用 Transactional 注解快速开启事务&#xff0c;一行代码似乎就能解决问题。但随着业务复杂度提升&#xff0c;这种“简单”的背后往往隐藏着难以察觉的隐患。本文将深入剖析 Spring…

CodePerfAI体验:AI代码性能分析工具如何高效排查性能瓶颈、优化SQL执行耗时?

前阵子帮同事排查用户下单接口的性能问题时&#xff0c;我算是真切感受到 “找性能瓶颈比写代码还磨人”—— 接口偶尔会突然卡到 3 秒以上&#xff0c;查日志只看到 “SQL 执行耗时过长”&#xff0c;但具体是哪个查询慢、为什么慢&#xff0c;翻了半天监控也没头绪&#xff0…

《sklearn机器学习——绘制分数以评估模型》验证曲线、学习曲线

估计器的偏差、方差和噪声 每一个估计器都有其优势和劣势。它的泛化误差可以分解为偏差、方差和噪声。估计器的偏差是不同训练集的平均误差。估计器的方差表示对不同训练集&#xff0c;模型的敏感度。噪声是数据的特质。 在下图中&#xff0c;可以看见一个函数 f(x)cos⁡32πxf…

2025年AI PPT必修课-汇报中AI相关内容的“陷阱”与“亮点”

《2025年AI PPT必修课-汇报中AI相关内容的“陷阱”与“亮点”》 (适用于方案汇报、战略PPT、标书/投资人演示)一、内容类坑&#xff08;战略/趋势层面&#xff09;❌ Pitfall (不要写)✅ Correct Expression (推荐写法)Why (原因)还在强调 Caffe / Theano / TF1.x / LSTM采用 P…

Java数据结构 - 顺序表模拟实现与使用

目录1.顺序表的基本介绍2.顺序表的模拟实现2.1 常见的功能2.2 基本框架2.3 方法的实现2.3.1 add方法2.3.2 size方法2.3.3 display方法2.3.4 add&#xff08;int pos&#xff0c;E data)方法2.3.5 remove方法2.3.6 get方法2.3.7 contain方法2.3.8 indexOf方法2.3.9 set方法2.3.1…