1.前言

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

2. 会议核心功能架构

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

项目的结构如图所示:

后端:                                                                        前端:


2.1 整体架构

系统架构图如图所示:

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


2.2 数据模型设计

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

模型的相关代码如下:

from django.db import models
from user.models import SysUser
import uuid
from datetime import datetime, timedelta
import json
from django.utils import 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. 语音转写模块

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

  1. 音频采集与处理

  2. 语音识别转换为文字

  3. 实时转写结果显示

  4. 转写记录保存

首先由于我设计了双重保险机制,先使用浏览器自带的语音识别进行识别语音,如果浏览器自带的不能使用,就调用后端的腾讯云语音接口,当前项目,我在前端设计了原生浏览器采集的语音的参数,同时对接收到的音发送给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.总结

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

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

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

       未来与展望:

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

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐