Files
meeting-room/backend/meetings/views.py
flying-hero 845817a028 🤖 添加虚拟坐席功能
后端:
- 创建会议时自动添加虚拟龙虾参会者
- 如果指定了 host_agent_id,添加该龙虾
- 否则添加 2 个虚拟助手(🤖🦊)

前端:
- 创建会议时可选"添加虚拟坐席"
- 默认勾选,方便测试 @ 功能
- 提示文字说明用途

使用场景:
- 用户创建会议 → 自动有虚拟龙虾
- 点击虚拟龙虾座位 → @ 该龙虾
- 测试 @ 功能无需真实龙虾在线
2026-04-04 13:04:26 +08:00

464 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from rest_framework import viewsets, status, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from django.utils import timezone
from .models import Meeting, Participant, Message, MeetingMinutes
from .serializers import (
MeetingSerializer, ParticipantSerializer,
MessageSerializer, InboxSerializer
)
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
import uuid
User = get_user_model()
class MeetingViewSet(viewsets.ModelViewSet):
"""会议室视图集"""
queryset = Meeting.objects.all()
serializer_class = MeetingSerializer
permission_classes = [] # 临时开放所有权限
def get_queryset(self):
# 简单返回所有会议
return Meeting.objects.all().order_by('-created_at')
def create(self, request, *args, **kwargs):
"""创建会议"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# 临时:使用第一个用户作为 host
from django.contrib.auth import get_user_model
User = get_user_model()
host = User.objects.first()
meeting = serializer.save(host=host)
# 指定主持龙虾(可选)
host_agent_id = request.data.get('host_agent_id')
host_instance_id = request.data.get('host_instance_id')
if host_agent_id:
meeting.host_agent_id = host_agent_id
meeting.host_instance_id = host_instance_id
meeting.save()
# 创建主持人参会记录
Participant.objects.create(
meeting=meeting,
user=host,
agent_type='human',
nickname=host.username,
is_host=True
)
# 创建虚拟龙虾参会者(如果指定了 host_agent_id
if host_agent_id:
Participant.objects.create(
meeting=meeting,
agent_type='openclaw',
agent_id=host_agent_id,
agent_name='飞行侠',
agent_emoji='🦸',
nickname='飞行侠',
is_host=False
)
# 如果没有指定 host_agent_id创建两个虚拟龙虾
if not host_agent_id and request.data.get('auto_add_virtual_agents', True):
Participant.objects.create(
meeting=meeting,
agent_type='openclaw',
agent_id='virtual_agent_1',
agent_name='虚拟助手 1 号',
agent_emoji='🤖',
nickname='虚拟助手 1 号',
is_host=False
)
Participant.objects.create(
meeting=meeting,
agent_type='openclaw',
agent_id='virtual_agent_2',
agent_name='虚拟助手 2 号',
agent_emoji='🦊',
nickname='虚拟助手 2 号',
is_host=False
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'])
def start(self, request, pk=None):
"""开始会议"""
meeting = self.get_object()
# 临时:不检查主持人权限(开发环境)
# if meeting.host != request.user:
# return Response(
# {'error': '只有主持人可以开始会议'},
# status=status.HTTP_403_FORBIDDEN
# )
meeting.status = 'active'
meeting.started_at = timezone.now()
meeting.save()
return Response({'status': '会议已开始'})
@action(detail=True, methods=['post'])
def end(self, request, pk=None):
"""结束会议"""
meeting = self.get_object()
# 临时:不检查主持人权限(开发环境)
# if meeting.host != request.user:
# return Response(
# {'error': '只有主持人可以结束会议'},
# status=status.HTTP_403_FORBIDDEN
# )
meeting.status = 'ended'
meeting.ended_at = timezone.now()
meeting.save()
# 触发通知主持龙虾生成纪要
try:
from .minutes_api import MeetingEndNotifyView
from django.test import RequestFactory
factory = RequestFactory()
notify_request = factory.post(f'/api/v1/meetings/{meeting.id}/end-notify/')
response = MeetingEndNotifyView.as_view()(notify_request, pk=str(meeting.id))
if response.status_code == 200:
pass # 通知成功
except Exception as e:
# 通知失败不影响会议结束
pass
return Response({'status': '会议已结束'})
@action(detail=True, methods=['post'])
def join(self, request, pk=None):
"""加入会议"""
meeting = self.get_object()
if meeting.status == 'ended':
return Response(
{'error': '会议已结束'},
status=status.HTTP_400_BAD_REQUEST
)
invite_code = request.data.get('invite_code')
if invite_code != meeting.invite_code:
return Response(
{'error': '邀请码错误'},
status=status.HTTP_400_BAD_REQUEST
)
# 检查是否已加入
existing = Participant.objects.filter(
meeting=meeting,
user=request.user,
left_at__isnull=True
).first()
if existing:
return Response(ParticipantSerializer(existing).data)
# 创建参会记录
participant = Participant.objects.create(
meeting=meeting,
user=request.user,
agent_type='human',
nickname=request.user.username
)
return Response(ParticipantSerializer(participant).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'])
def participants(self, request, pk=None):
"""获取参会者列表"""
meeting = self.get_object()
participants = meeting.participants.filter(left_at__isnull=True)
serializer = ParticipantSerializer(participants, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def mention_agent(self, request, pk=None):
"""@Agent 功能 - 发送消息给特定 Agent"""
meeting = self.get_object()
target_agent_id = request.data.get('target_agent_id')
content = request.data.get('content')
sender_agent_id = request.data.get('sender_agent_id')
sender_name = request.data.get('sender_name', 'User')
sender_emoji = request.data.get('sender_emoji', '👤')
if not target_agent_id or not content:
return Response(
{'error': '缺少 target_agent_id 或 content'},
status=status.HTTP_400_BAD_REQUEST
)
# 找到目标 Agent
target_participant = Participant.objects.filter(
meeting=meeting,
agent_id=target_agent_id,
left_at__isnull=True
).first()
if not target_participant:
return Response(
{'error': f'未找到 Agent: {target_agent_id}'},
status=status.HTTP_404_NOT_FOUND
)
# 获取或创建发送者
sender_participant = Participant.objects.filter(
meeting=meeting,
agent_id=sender_agent_id,
left_at__isnull=True
).first()
if not sender_participant and sender_agent_id:
sender_participant = Participant.objects.create(
meeting=meeting,
agent_type='openclaw',
agent_id=sender_agent_id,
agent_name=sender_name,
agent_emoji=sender_emoji,
nickname=f"{sender_emoji} {sender_name}"
)
if not sender_participant:
# 人类发送的,用主持人
sender_participant = Participant.objects.filter(
meeting=meeting,
is_host=True
).first()
# 创建消息,标记为需要回复
message = Message.objects.create(
meeting=meeting,
sender=sender_participant,
content=f"@{target_participant.nickname} {content}",
is_broadcast=False, # 只发给目标 Agent
requires_response=True
)
return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'])
def messages(self, request, pk=None):
"""获取消息(人类轮询)"""
meeting = self.get_object()
last_id = request.query_params.get('last_id', 0)
messages = meeting.messages.filter(id__gt=last_id).select_related('sender')
serializer = MessageSerializer(messages, many=True)
return Response({'messages': serializer.data})
@action(detail=True, methods=['post'])
def send_message(self, request, pk=None):
"""发送消息"""
meeting = self.get_object()
# 获取或创建参会者(临时:使用第一个参会者或创建)
participant = Participant.objects.filter(
meeting=meeting,
left_at__isnull=True
).first()
if not participant:
# 创建默认参会者
host = meeting.host
participant = Participant.objects.create(
meeting=meeting,
user=host,
agent_type='human',
nickname=host.username
)
content = request.data.get('content')
if not content:
return Response(
{'error': '消息内容不能为空'},
status=status.HTTP_400_BAD_REQUEST
)
message = Message.objects.create(
meeting=meeting,
sender=participant,
content=content,
is_broadcast=request.data.get('is_broadcast', True),
requires_response=request.data.get('requires_response', False)
)
# Webhook 推送通知
try:
from instances.webhook import push_message_to_instances
from meetings.serializers import MessageSerializer
message_data = MessageSerializer(message).data
target_agents = None
# 如果不是广播,只推送给特定 Agent
if not message.is_broadcast:
# 从@消息中提取目标 Agent
if content.startswith('@'):
# 简单解析 @Agent
pass
push_message_to_instances(str(meeting.id), message_data, target_agents)
except Exception as e:
# Webhook 失败不影响消息发送
pass
return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'])
def inbox(self, request, pk=None):
"""Agent 查阅信箱(自动加入会议如果还没加入)"""
meeting = self.get_object()
agent_id = request.query_params.get('agent_id')
agent_name = request.query_params.get('agent_name', 'Agent')
agent_emoji = request.query_params.get('agent_emoji', '🤖')
if not agent_id:
return Response(
{'error': '缺少 agent_id 参数'},
status=status.HTTP_400_BAD_REQUEST
)
# 找到或创建这个 Agent 的参会记录
participant = Participant.objects.filter(
meeting=meeting,
agent_id=agent_id,
left_at__isnull=True
).first()
if not participant:
# Agent 首次访问,自动加入会议
participant = Participant.objects.create(
meeting=meeting,
agent_type='openclaw',
agent_id=agent_id,
agent_name=agent_name,
agent_emoji=agent_emoji,
nickname=f"{agent_emoji} {agent_name}"
)
# 获取发给这个 Agent 的消息(未读)
messages = Message.objects.filter(
meeting=meeting
).exclude(
read_by=participant
)
# 标记为已读
participant.read_messages.add(*messages)
serializer = MessageSerializer(messages, many=True)
return Response({
'unread_count': messages.count(),
'messages': serializer.data,
'participant': ParticipantSerializer(participant).data
})
@action(detail=True, methods=['post'])
def agent_reply(self, request, pk=None):
"""Agent 回复消息"""
meeting = self.get_object()
agent_id = request.data.get('agent_id')
agent_name = request.data.get('agent_name', 'Agent')
agent_emoji = request.data.get('agent_emoji', '🤖')
content = request.data.get('content')
in_reply_to = request.data.get('in_reply_to')
if not agent_id:
return Response(
{'error': '缺少 agent_id 参数'},
status=status.HTTP_400_BAD_REQUEST
)
if not content:
return Response(
{'error': '消息内容不能为空'},
status=status.HTTP_400_BAD_REQUEST
)
# 找到或创建 Agent 参会记录
participant = Participant.objects.filter(
meeting=meeting,
agent_id=agent_id,
left_at__isnull=True
).first()
if not participant:
participant = Participant.objects.create(
meeting=meeting,
agent_type='openclaw',
agent_id=agent_id,
agent_name=agent_name,
agent_emoji=agent_emoji,
nickname=f"{agent_emoji} {agent_name}"
)
# 创建回复消息
message = Message.objects.create(
meeting=meeting,
sender=participant,
content=content,
is_broadcast=request.data.get('is_broadcast', True),
requires_response=request.data.get('requires_response', False),
in_reply_to_id=in_reply_to
)
return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['get'], url_path='minutes')
def minutes(self, request, pk=None):
"""生成会议纪要"""
meeting = self.get_object()
try:
from .utils import generate_meeting_minutes, export_minutes_to_markdown
minutes = generate_meeting_minutes(str(meeting.id))
output_format = request.query_params.get('output', 'json')
if output_format == 'markdown':
md_content = export_minutes_to_markdown(minutes)
return Response({'markdown': md_content})
else:
return Response(minutes)
except Exception as e:
return Response(
{'error': f'生成纪要失败:{str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class ParticipantViewSet(viewsets.ModelViewSet):
"""参会者视图集"""
queryset = Participant.objects.all()
serializer_class = ParticipantSerializer
permission_classes = [permissions.IsAuthenticated]
@action(detail=True, methods=['post'])
def leave(self, request, pk=None):
"""离开会议"""
participant = self.get_object()
if participant.user != request.user:
return Response(
{'error': '无权操作'},
status=status.HTTP_403_FORBIDDEN
)
participant.left_at = timezone.now()
participant.save()
return Response({'status': '已离开会议'})