diff --git a/backend/meeting_room/urls.py b/backend/meeting_room/urls.py index e12d9b4c..a1ea5bee 100644 --- a/backend/meeting_room/urls.py +++ b/backend/meeting_room/urls.py @@ -5,6 +5,7 @@ from rest_framework.routers import DefaultRouter from meetings.views import MeetingViewSet, ParticipantViewSet from users.views import LoginView, RegisterView from instances.views import InstanceRegisterView, MeetingJoinView, InstanceListView, WebhookNotifyView +from meetings.minutes_api import MeetingRecordsView, MinutesUploadView, MeetingEndNotifyView router = DefaultRouter() router.register(r'meetings', MeetingViewSet, basename='meeting') @@ -19,6 +20,10 @@ urlpatterns = [ path("api/v1/instances/join-meeting/", MeetingJoinView.as_view()), path("api/v1/instances/", InstanceListView.as_view()), path("api/v1/instances/webhook-test/", WebhookNotifyView.as_view()), + # 会议纪要 API(主持龙虾专用) + path("api/v1/meetings//records/", MeetingRecordsView.as_view()), + path("api/v1/meetings//minutes/upload/", MinutesUploadView.as_view()), + path("api/v1/meetings//end-notify/", MeetingEndNotifyView.as_view()), re_path(r'^api/v1/meetings/(?P[^/.]+)/generate-minutes/$', MeetingViewSet.as_view({'get': 'minutes'}), name='meeting-minutes'), path("api/v1/", include(router.urls)), ] diff --git a/backend/meetings/migrations/0002_meeting_host_agent_id_meeting_host_instance_id_and_more.py b/backend/meetings/migrations/0002_meeting_host_agent_id_meeting_host_instance_id_and_more.py new file mode 100644 index 00000000..bc166a37 --- /dev/null +++ b/backend/meetings/migrations/0002_meeting_host_agent_id_meeting_host_instance_id_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.3 on 2026-04-04 04:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("meetings", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="meeting", + name="host_agent_id", + field=models.CharField( + blank=True, max_length=100, null=True, verbose_name="主持 Agent ID" + ), + ), + migrations.AddField( + model_name="meeting", + name="host_instance_id", + field=models.CharField( + blank=True, max_length=100, null=True, verbose_name="主持实例 ID" + ), + ), + migrations.AddField( + model_name="meeting", + name="minutes_generated", + field=models.BooleanField(default=False, verbose_name="纪要已生成"), + ), + migrations.AddField( + model_name="meeting", + name="minutes_uploaded_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="纪要上传时间" + ), + ), + ] diff --git a/backend/meetings/minutes_api.py b/backend/meetings/minutes_api.py new file mode 100644 index 00000000..57660832 --- /dev/null +++ b/backend/meetings/minutes_api.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +""" +会议纪要生成 API +供主持龙虾调用 +""" + +from rest_framework import serializers, status, views +from rest_framework.response import Response +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from .models import Meeting, Message, Participant +from .serializers import MeetingSerializer +from django.utils import timezone +import logging + +logger = logging.getLogger(__name__) + + +class MeetingRecordsSerializer(serializers.Serializer): + """获取会议记录(主持龙虾专用)""" + meeting_id = serializers.UUIDField() + agent_id = serializers.CharField() + + def validate(self, data): + # 验证是否是主持龙虾 + try: + meeting = Meeting.objects.get(id=data['meeting_id']) + if meeting.host_agent_id != data['agent_id']: + raise serializers.ValidationError('只有主持龙虾可以获取完整记录') + except Meeting.DoesNotExist: + raise serializers.ValidationError('会议不存在') + return data + + +class MeetingRecordsView(views.APIView): + """ + 获取会议记录(主持龙虾专用) + + GET /api/v1/meetings/{id}/records/?agent_id=xxx + """ + permission_classes = [AllowAny] + + def get(self, request, pk=None): + agent_id = request.query_params.get('agent_id') + + if not agent_id: + return Response({'error': '缺少 agent_id'}, status=status.HTTP_400_BAD_REQUEST) + + try: + meeting = Meeting.objects.get(id=pk) + + # 验证是否是主持龙虾 + if meeting.host_agent_id != agent_id: + return Response( + {'error': '只有主持龙虾可以获取完整记录'}, + status=status.HTTP_403_FORBIDDEN + ) + + # 获取所有消息 + messages = Message.objects.filter( + meeting=meeting + ).select_related('sender').order_by('created_at') + + # 获取参会者列表 + participants = Participant.objects.filter( + meeting=meeting + ).select_related('user') + + return Response({ + 'meeting': MeetingSerializer(meeting).data, + 'messages': [{ + 'id': m.id, + 'sender_name': m.sender.nickname, + 'sender_emoji': m.sender.agent_emoji, + 'content': m.content, + 'created_at': m.created_at.isoformat(), + 'requires_response': m.requires_response + } for m in messages], + 'participants': [{ + 'agent_id': p.agent_id, + 'agent_name': p.agent_name, + 'agent_emoji': p.agent_emoji, + 'is_host': p.is_host + } for p in participants] + }) + + except Meeting.DoesNotExist: + return Response({'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"获取会议记录失败:{e}") + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class MinutesUploadSerializer(serializers.Serializer): + """上传会议纪要""" + agent_id = serializers.CharField() + content = serializers.CharField() + format = serializers.CharField(default='markdown') + + +class MinutesUploadView(views.APIView): + """ + 上传会议纪要(主持龙虾专用) + + POST /api/v1/meetings/{id}/minutes/upload/ + { + "agent_id": "flying_hero", + "content": "# 会议纪要\n\n...", + "format": "markdown" + } + """ + permission_classes = [AllowAny] + + def post(self, request, pk=None): + serializer = MinutesUploadSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + try: + meeting = Meeting.objects.get(id=pk) + agent_id = serializer.validated_data['agent_id'] + + # 验证是否是主持龙虾 + if meeting.host_agent_id != agent_id: + return Response( + {'error': '只有主持龙虾可以上传纪要'}, + status=status.HTTP_403_FORBIDDEN + ) + + # 存储纪要(简化版:存为文本,生产环境应该存文件) + from meetings.models import MeetingMinutes + minutes, created = MeetingMinutes.objects.update_or_create( + meeting=meeting, + defaults={ + 'content': serializer.validated_data['content'], + 'generated_at': timezone.now() + } + ) + + # 更新会议状态 + meeting.minutes_generated = True + meeting.minutes_uploaded_at = timezone.now() + meeting.save() + + logger.info(f"✅ 会议纪要已上传:{meeting.id} by {agent_id}") + + return Response({ + 'status': 'success', + 'message': '会议纪要已上传', + 'minutes_id': str(minutes.id) + }) + + except Meeting.DoesNotExist: + return Response({'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"上传会议纪要失败:{e}") + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class MeetingEndNotifyView(views.APIView): + """ + 会议结束通知(触发主持龙虾生成纪要) + + POST /api/v1/meetings/{id}/end-notify/ + """ + permission_classes = [AllowAny] + + def post(self, request, pk=None): + try: + meeting = Meeting.objects.get(id=pk) + + if meeting.status != 'ended': + return Response( + {'error': '会议尚未结束'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 如果已有纪要,跳过 + if meeting.minutes_generated: + return Response({ + 'status': 'skipped', + 'message': '纪要已生成' + }) + + # 获取主持龙虾信息 + if not meeting.host_agent_id: + return Response({ + 'status': 'skipped', + 'message': '未指定主持龙虾' + }) + + # 通知主持龙虾(通过 Webhook) + from instances.webhook import push_message_to_instances + + payload = { + 'event': 'meeting_ended', + 'meeting_id': str(meeting.id), + 'topic': meeting.topic, + 'host_agent_id': meeting.host_agent_id, + 'message': '会议已结束,请生成会议纪要', + 'records_url': f'http://localhost:8000/api/v1/meetings/{meeting.id}/records/', + 'upload_url': f'http://localhost:8000/api/v1/meetings/{meeting.id}/minutes/upload/' + } + + # 推送给主持龙虾所在实例 + push_message_to_instances( + str(meeting.id), + payload, + target_agent_ids=[meeting.host_agent_id] + ) + + logger.info(f"📬 会议结束通知已发送:{meeting.id} -> {meeting.host_agent_id}") + + return Response({ + 'status': 'success', + 'message': f'已通知主持龙虾 {meeting.host_agent_id}' + }) + + except Meeting.DoesNotExist: + return Response({'error': '会议不存在'}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + logger.error(f"发送结束通知失败:{e}") + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/backend/meetings/models.py b/backend/meetings/models.py index 024dcea0..16ec2c4f 100644 --- a/backend/meetings/models.py +++ b/backend/meetings/models.py @@ -22,6 +22,12 @@ class Meeting(models.Model): started_at = models.DateTimeField(null=True, blank=True) ended_at = models.DateTimeField(null=True, blank=True) + # 主持龙虾(负责生成会议纪要) + host_agent_id = models.CharField(max_length=100, null=True, blank=True, verbose_name='主持 Agent ID') + host_instance_id = models.CharField(max_length=100, null=True, blank=True, verbose_name='主持实例 ID') + minutes_generated = models.BooleanField(default=False, verbose_name='纪要已生成') + minutes_uploaded_at = models.DateTimeField(null=True, blank=True, verbose_name='纪要上传时间') + class Meta: db_table = 'meetings' verbose_name = '会议室' diff --git a/backend/meetings/serializers.py b/backend/meetings/serializers.py index db1430ff..6369520b 100644 --- a/backend/meetings/serializers.py +++ b/backend/meetings/serializers.py @@ -21,7 +21,8 @@ class MeetingSerializer(serializers.ModelSerializer): model = Meeting fields = [ 'id', 'topic', 'host', 'host_name', 'status', 'invite_code', - 'created_at', 'started_at', 'ended_at', 'participant_count' + 'created_at', 'started_at', 'ended_at', 'participant_count', + 'host_agent_id', 'host_instance_id', 'minutes_generated', 'minutes_uploaded_at' ] read_only_fields = ['host', 'invite_code', 'status'] diff --git a/backend/meetings/views.py b/backend/meetings/views.py index 71da230a..c1a6254e 100644 --- a/backend/meetings/views.py +++ b/backend/meetings/views.py @@ -36,6 +36,14 @@ class MeetingViewSet(viewsets.ModelViewSet): 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, @@ -79,6 +87,21 @@ class MeetingViewSet(viewsets.ModelViewSet): 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']) diff --git a/backend/test_host_minutes.py b/backend/test_host_minutes.py new file mode 100644 index 00000000..f99d4437 --- /dev/null +++ b/backend/test_host_minutes.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +测试主持龙虾生成纪要完整流程 +""" + +import requests + +API_BASE = 'http://localhost:8000/api/v1' + +def test_host_agent_minutes(): + print("="*60) + print("🦞 测试主持龙虾生成纪要流程") + print("="*60) + + # 1. 登录 + res = requests.post(f'{API_BASE}/auth/login/', json={ + 'username': 'test', + 'password': 'test123' + }) + token = res.json()['token'] + headers = {'Authorization': f'Bearer {token}'} + print(f"✅ 登录成功") + + # 2. 注册实例(模拟 OpenClaw) + print("\n📝 注册实例...") + res = requests.post(f'{API_BASE}/instances/register/', json={ + 'instance_id': 'host-openclaw-001', + 'instance_name': '主持龙虾实例', + 'agent_ids': ['flying_hero'], + 'webhook_url': 'http://localhost:8888/meeting-notify' + }) + print(f"✅ 实例注册:{res.json()}") + + # 3. 创建会议(指定主持龙虾) + print("\n🏛️ 创建会议(指定主持龙虾)...") + res = requests.post(f'{API_BASE}/meetings/', json={ + 'topic': '主持龙虾测试会议', + 'host_agent_id': 'flying_hero', + 'host_instance_id': 'host-openclaw-001' + }, headers=headers) + meeting = res.json() + meeting_id = meeting['id'] + print(f"✅ 会议创建:{meeting_id}") + print(f" 主持龙虾:{meeting.get('host_agent_id')}") + + # 4. 发送消息(模拟会议讨论) + print("\n💬 发送会议消息...") + messages = [ + "大家好,开始今天的会议!", + "我来汇报一下 Q2 的进度。", + "这个项目需要更多资源支持。", + "好的,我会跟进这件事。", + "那我们下周再开会讨论细节。" + ] + for msg in messages: + requests.post(f'{API_BASE}/meetings/{meeting_id}/send_message/', json={ + 'content': msg + }, headers=headers) + print(f"✅ 发送 {len(messages)} 条消息") + + # 5. 结束会议(自动通知主持龙虾) + print("\n⏹️ 结束会议...") + res = requests.post(f'{API_BASE}/meetings/{meeting_id}/end/', headers=headers) + print(f"✅ 会议结束:{res.json()}") + + # 6. 主持龙虾获取会议记录 + print("\n📋 主持龙虾获取会议记录...") + res = requests.get(f'{API_BASE}/meetings/{meeting_id}/records/?agent_id=flying_hero') + if res.status_code == 200: + records = res.json() + print(f"✅ 获取成功") + print(f" 消息数:{len(records['messages'])}") + print(f" 参会者:{len(records['participants'])}") + else: + print(f"❌ 获取失败:{res.json()}") + return False + + # 7. 主持龙虾生成纪要(模拟 AI 生成) + print("\n🤖 主持龙虾生成纪要...") + minutes_content = f"""# 📋 会议纪要 + +**主题:** {meeting['topic']} +**时间:** {meeting['created_at']} +**主持:** 飞行侠 🦸 + +## 💬 讨论内容 + +会议共 {len(messages)} 条消息,主要讨论: +- Q2 进度汇报 +- 资源需求 +- 后续安排 + +## ✅ 决议事项 + +1. 跟进资源支持事宜 +2. 下周继续开会讨论细节 + +--- +*生成时间:现在* +""" + + # 8. 上传纪要 + print("\n📤 上传会议纪要...") + res = requests.post(f'{API_BASE}/meetings/{meeting_id}/minutes/upload/', json={ + 'agent_id': 'flying_hero', + 'content': minutes_content, + 'format': 'markdown' + }) + if res.status_code == 200: + print(f"✅ 纪要上传成功:{res.json()}") + else: + print(f"❌ 上传失败:{res.json()}") + return False + + # 9. 验证会议状态 + print("\n📊 验证会议状态...") + res = requests.get(f'{API_BASE}/meetings/{meeting_id}/', headers=headers) + meeting = res.json() + print(f" 状态:{meeting['status']}") + print(f" 纪要已生成:{meeting.get('minutes_generated', False)}") + print(f" 上传时间:{meeting.get('minutes_uploaded_at')}") + + # 10. 获取纪要(平台留存) + print("\n📥 下载会议纪要...") + res = requests.get(f'{API_BASE}/meetings/{meeting_id}/minutes/?output=markdown', headers=headers) + if res.status_code == 200: + minutes = res.json() + print(f"✅ 获取成功") + print(f" 内容预览:{minutes['markdown'][:100]}...") + else: + print(f"❌ 获取失败:{res.json()}") + return False + + print("\n" + "="*60) + print("✅ 主持龙虾生成纪要流程测试通过!") + print("="*60) + print("\n📊 流程总结:") + print("1. 用户创建会议 → 指定主持龙虾") + print("2. 会议进行 → 消息中央路由") + print("3. 会议结束 → 通知主持龙虾") + print("4. 主持龙虾 → 获取记录 + 生成纪要") + print("5. 上传纪要 → 平台留存供下载") + print("\n💡 算力分配:") + print("- 中央平台:消息路由 + 数据存储(轻量级)") + print("- 主持龙虾:生成纪要(消耗用户算力)") + return True + +if __name__ == '__main__': + test_host_agent_minutes()