腾讯云语音接口实现会议系统
自动生成并管理会议总结:会议总结生成总结内容展示关键要点列表行动项跟踪例子;该会议管理系统通过完整的功能设计和实现,为用户提供了从会议创建到总结生成的全流程解决方案。完整的会议生命周期管理:支持会议的创建、开始、进行、结束和总结等完整流程先进的语音处理技术:集成多种语音识别服务和AI优化功能智能会议总结:基于AI技术自动生成会议总结、关键要点和行动项良好的用户体验:提供直观的操作界面和实时反馈机制
1.前言
在现代企业协作环境中,高效的会议管理是提升团队生产力的关键。本文将深入解析一个完整的会议管理系统,涵盖从会议创建到总结生成的完整生命周期。该系统构建一个基于AI技术的智能会议系统,实现会议全流程的智能化管理,包括智能预约、实时转录、AI总结、智能分析等功能,为企业提供高效、智能的会议解决方案。
2. 会议核心功能架构
当前项目启用采用前后端分离的现代化架构设计,系统实现了从会议预约、创建会议,实时音频沟通到会后自动摘要的全流程智能化管理。其核心作用在于使用腾讯云语音识别接口进行语音识别,随后将识别的文本利用AI技术自动完成语音转写、内容摘要和会议分析,显著减轻人工负担,确保信息完整可追溯。同时,系统后期打算支持多端接入和实时协作,打破地域限制,助力远程团队高效沟通。整体设计注重用户体验与业务价值,旨在成为推动企业数字化协作和决策效率的关键平台。
项目的结构如图所示:
后端: 前端:
2.1 整体架构
系统架构图如图所示:
- 前后端分离架构(Django + Vue3)
- 会议管理作为独立模块
- 集成腾讯云语音识别和浏览器原生语音识别
- 使用Coze AI进行会议内容优化和总结生成
2.2 数据模型设计
- Meeting 模型:存储会议基本信息
- MeetingTranscript 模型:存储会议转写记录
- OptimizedTranscript 模型:存储优化后的会议记录
- 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 timezone
class 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_topic
description = models.TextField(blank=True, verbose_name='会议描述')
host = models.ForeignKey(SysUser, on_delete=models.CASCADE, verbose_name='主持人') # organizer_id
meeting_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_time
duration = models.IntegerField(default=60, verbose_name='计划时长(分钟)')
scheduled_end_time = models.DateTimeField(null=True, blank=True, verbose_name='计划结束时间') # end_time
actual_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_time
time_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_text
if 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为JSON
import json
summary_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_text
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)
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-input
v-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-pagination
v-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.value
if (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 = true
try {
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 = 1
loadMeetings()
}
const handleSizeChange = (newSize: number) => {
pageSize.value = newSize
currentPage.value = 1
loadMeetings()
}
const handleCurrentChange = (newPage: number) => {
currentPage.value = newPage
loadMeetings()
}
const handleRowClick = (row: Meeting) => {
viewDetails(row)
}
const joinMeeting = (meetingId: string) => {
router.push(`/home/meeting/room/${meetingId}`)
}
// 快速开始会议
const quickStartMeeting = async () => {
quickStartLoading.value = true
try {
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 = meeting
showDetailDialog.value = true
}
const handleCommand = async (command: string, meeting: Meeting) => {
switch (command) {
case 'generate':
await generateSummary(meeting)
break
case 'summary':
await viewSummary(meeting)
break
case 'download':
await downloadSummary(meeting)
break
case 'delete':
await deleteMeeting(meeting)
break
case '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 = false
loadMeetings()
// 不在这里显示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 re
from 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 logging
from .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_service
logger = logging.getLogger(__name__)
class MeetingViewSet(viewsets.ModelViewSet):
"""会议视图集"""
serializer_class = MeetingSerializer
permission_classes = [IsAuthenticated]
lookup_field = 'meeting_id' # 使用meeting_id作为查找字段
def get_queryset(self):
"""只返回用户作为主持人的会议"""
user = self.request.user
return 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_field
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
try:
obj = Meeting.objects.get(**filter_kwargs)
# 检查用户是否有权限访问此会议
user = self.request.user
if obj.host != user:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied('您没有权限访问此会议')
return obj
except Meeting.DoesNotExist:
from rest_framework.exceptions import NotFound
raise 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_id
title = 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. 语音转写模块
会议转写模块主要分为四个阶段:
-
音频采集与处理
-
语音识别转换为文字
-
实时转写结果显示
-
转写记录保存
首先由于我设计了双重保险机制,先使用浏览器自带的语音识别进行识别语音,如果浏览器自带的不能使用,就调用后端的腾讯云语音接口,当前项目,我在前端设计了原生浏览器采集的语音的参数,同时对接收到的音发送给coze工作流进行处理。
浏览器原生语音识别设计代码如下:
export interface AudioConfig {
sampleRate?: number
channelCount?: number
autoGainControl?: boolean
noiseSuppression?: boolean
echoCancellation?: boolean
}
export class AudioManager {
private stream: MediaStream | null = null
private audioContext: AudioContext | null = null
private mediaRecorder: MediaRecorder | null = null
private isRecording = false
private chunks: Blob[] = []
private eventListeners: { [key: string]: Function[] } = {}
private recordingInterval: number | null = null
private 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) return
this.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 = true
this.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 = false
return
}
this.isRecording = false
this.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 = 256
source.connect(analyser)
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
analyser.getByteFrequencyData(dataArray)
let sum = 0
for (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: ArrayBuffer
if (audioData instanceof Blob) {
arrayBuffer = await audioData.arrayBuffer()
} else {
arrayBuffer = audioData
}
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer)
const source = this.audioContext.createBufferSource()
source.buffer = audioBuffer
source.connect(this.audioContext.destination)
source.start()
this.emit('audioPlayed')
} catch (error) {
console.error('播放音频失败:', error)
this.emit('error', error)
throw error
}
}
// 转换音频为Base64
async 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 = reject
reader.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 = null
this.isRecording = false
this.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 = null
private isListening = false
private eventListeners: { [key: string]: Function[] } = {}
private restartTimeout: number | null = null
constructor() {
// 检查浏览器支持
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (SpeechRecognition) {
this.recognition = new SpeechRecognition()
this.setupRecognition()
} else {
console.warn('浏览器不支持语音识别')
}
}
private setupRecognition() {
if (!this.recognition) return
this.recognition.continuous = true
this.recognition.interimResults = true
this.recognition.lang = 'zh-CN'
this.recognition.onstart = () => {
this.isListening = true
this.emit('started')
console.log('语音识别已开始')
}
this.recognition.onend = () => {
this.isListening = false
this.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].transcript
const confidence = event.results[i][0].confidence
if (event.results[i].isFinal) {
finalTranscript += transcript
this.emit('finalResult', { text: transcript, confidence })
} else {
interimTranscript += transcript
this.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 = false
return
}
this.recognition.stop()
this.isListening = false
}
// 中止识别
abort(): void {
// 清除重启定时器
if (this.restartTimeout) {
clearTimeout(this.restartTimeout)
this.restartTimeout = null
}
if (!this.recognition) {
// 即使没有识别器,也要确保状态正确
this.isListening = false
return
}
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 settings
logger = logging.getLogger(__name__)
class SpeechRecognitionService:
def __init__(self, service_type='mock'):
self.service_type = service_type
def 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 settings
secret_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 credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.asr.v20190614 import asr_client, models
import json
cred = credential.Credential(secret_id, secret_key)
httpProfile = HttpProfile()
httpProfile.endpoint = "asr.tencentcloudapi.com"
httpProfile.reqTimeout = 30
clientProfile = ClientProfile()
clientProfile.httpProfile = httpProfile
client = 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_URL
logger = 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 = None
for msg in reversed(messages):
if msg.get('role') == 'assistant':
assistant_message = msg
break
if 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 messages
def 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 = None
for msg in reversed(messages):
if msg.get('role') == 'assistant':
assistant_message = msg
break
if 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 re
if 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_service
coze_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_service
coze_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: string
text: string
timestamp: number
confidence?: 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 = true
ElMessage.info('已切换到后端语音识别服务')
}
}
})
}
const toggleTranscription = async () => {
transcriptionLoading.value = true
try {
if (isTranscribing.value) {
// 停止转写
speechRecognition.value?.stop()
audioManager.value?.stopRecording()
isTranscribing.value = false
ElMessage.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 = true
ElMessage.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 = true
try {
// 如果是主持人,先结束会议
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-item
v-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 string
const 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 = true
try {
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 HttpResponse
import re
meeting = 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.summary
except 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 json
try:
# 尝试解析summary_text为JSON
summary_data = json.loads(summary.summary_text)
# 如果是JSON格式且包含message字段,则使用该消息
if 'message' in summary_data:
summary_content = summary_data['message']
else:
summary_content = summary.summary_text
except 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 point
basic_content += f"\n• {clean_point}"
# 添加签名
basic_content += "\n\n签名:"
# 只支持txt和docx格式,移除md格式
if file_format == 'docx':
# Word格式处理
try:
from docx import Document
from io import BytesIO
document = 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 response
except 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 response
else:
# 默认返回文本格式
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 response
except 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.data
try:
# 使用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.总结
该会议管理系统通过完整的功能设计和实现,为用户提供了从会议创建到总结生成的全流程解决方案。系统的核心亮点包括:
- 完整的会议生命周期管理:支持会议的创建、开始、进行、结束和总结等完整流程
- 先进的语音处理技术:集成多种语音识别服务和AI优化功能
- 智能会议总结:基于AI技术自动生成会议总结、关键要点和行动项
- 良好的用户体验:提供直观的操作界面和实时反馈机制
- 灵活的扩展性:模块化设计便于功能扩展和集成
该系统不仅满足了基本的会议管理需求,还通过AI技术提升了会议效率和价值,为现代企业协作提供了有力支持。
未来与展望:
- 集成更多AI服务商(OpenAI、百度等)
- 支持视频会议和屏幕共享
- 移动端应用开发
- 企业级SSO集成
更多推荐
所有评论(0)