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 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. 語音轉寫模塊
會議轉寫模塊主要分為四個階段:
-
音頻采集與處理
-
語音識別轉換為文字
-
實時轉寫結果顯示
-
轉寫記錄保存
首先由于我設計了雙重保險機制,先使用瀏覽器自帶的語音識別進行識別語音,如果瀏覽器自帶的不能使用,就調用后端的騰訊云語音接口,當前項目,我在前端設計了原生瀏覽器采集的語音的參數,同時對接收到的音發送給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.總結
????????該會議管理系統通過完整的功能設計和實現,為用戶提供了從會議創建到總結生成的全流程解決方案。系統的核心亮點包括:
- 完整的會議生命周期管理:支持會議的創建、開始、進行、結束和總結等完整流程
- 先進的語音處理技術:集成多種語音識別服務和AI優化功能
- 智能會議總結:基于AI技術自動生成會議總結、關鍵要點和行動項
- 良好的用戶體驗:提供直觀的操作界面和實時反饋機制
- 靈活的擴展性:模塊化設計便于功能擴展和集成
????????該系統不僅滿足了基本的會議管理需求,還通過AI技術提升了會議效率和價值,為現代企業協作提供了有力支持。????????
? ?????未來與展望:
- 集成更多AI服務商(OpenAI、百度等)
- 支持視頻會議和屏幕共享
- 移動端應用開發
- 企業級SSO集成