Compare commits

...

7 Commits

9 changed files with 195 additions and 344 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-04-05 01:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("meetings", "0002_meeting_host_agent_id_meeting_host_instance_id_and_more"),
]
operations = [
migrations.AddField(
model_name="meeting",
name="expires_at",
field=models.DateTimeField(blank=True, null=True, verbose_name="过期时间"),
),
]

View File

@@ -21,6 +21,7 @@ class Meeting(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
ended_at = models.DateTimeField(null=True, blank=True)
expires_at = models.DateTimeField(null=True, blank=True, verbose_name="过期时间")
# 主持龙虾(负责生成会议纪要)
host_agent_id = models.CharField(max_length=100, null=True, blank=True, verbose_name='主持 Agent ID')

View File

@@ -2,11 +2,9 @@ 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 datetime import timedelta
from .models import Meeting, Participant, Message
from .serializers import MeetingSerializer, ParticipantSerializer, MessageSerializer
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
import uuid
@@ -21,7 +19,6 @@ class MeetingViewSet(viewsets.ModelViewSet):
permission_classes = [] # 临时开放所有权限
def get_queryset(self):
# 简单返回所有会议
return Meeting.objects.all().order_by('-created_at')
def create(self, request, *args, **kwargs):
@@ -29,19 +26,13 @@ class MeetingViewSet(viewsets.ModelViewSet):
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
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
# 设置 1 小时过期时间
meeting.expires_at = timezone.now() + timedelta(hours=1)
meeting.save()
# 创建主持人参会记录
@@ -53,102 +44,35 @@ class MeetingViewSet(viewsets.ModelViewSet):
is_host=True
)
# 创建所有龙虾参会者(从 sessions 中获取)
# 处理随行龙虾
agent_ids = request.data.get('agent_ids', [])
for agent_id in agent_ids:
# 避免重复创建
if Participant.objects.filter(meeting=meeting, agent_id=agent_id).exists():
continue
# 从用户绑定的龙虾中获取正确信息
agent_info = host.get_linked_agent(agent_id)
Participant.objects.create(
meeting=meeting,
agent_type='openclaw',
agent_id=agent_id,
agent_name=agent_info['agent_name'] if agent_info else agent_id,
agent_emoji=agent_info.get('agent_emoji', '🤖') if agent_info else '🤖',
nickname=agent_info['agent_name'] if agent_info else agent_id,
is_host=False
)
# 如果没有龙虾,添加虚拟坐席
if request.data.get('auto_add_virtual_agents', False):
Participant.objects.create(
meeting=meeting,
agent_type='openclaw',
agent_id='virtual_agent_1',
agent_name='虚拟助手 1 号',
agent_name='Agent',
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 号',
nickname=f'Agent {agent_id}',
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.expires_at and timezone.now() > meeting.expires_at:
# 过期会议,清空坐席
meeting.participants.all().delete()
return Response(
{'error': '会议已过期,坐席已清空'},
status=status.HTTP_400_BAD_REQUEST
)
if meeting.status == 'ended':
return Response(
{'error': '会议已结束'},
@@ -162,122 +86,75 @@ class MeetingViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
# 检查是否已加入
# 获取要加入的龙虾 ID 列表
agent_ids = request.data.get('agent_ids', [])
# 获取当前用户
user = User.objects.first() # 临时使用第一个用户
# 检查用户是否已加入
existing = Participant.objects.filter(
meeting=meeting,
user=request.user,
user=user,
left_at__isnull=True
).first()
if existing:
return Response(ParticipantSerializer(existing).data)
if not existing:
# 用户加入会议
Participant.objects.create(
meeting=meeting,
user=user,
agent_type='human',
nickname=user.username if user else '用户',
is_host=False
)
# 创建参会记录
# 随行龙虾加入会议
joined_agents = []
for agent_id in agent_ids:
# 检查是否已存在
exists = Participant.objects.filter(
meeting=meeting,
agent_id=agent_id,
left_at__isnull=True
).first()
if not exists:
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}"
agent_id=agent_id,
agent_name='Agent',
agent_emoji='🤖',
nickname=f'Agent {agent_id}',
is_host=False
)
joined_agents.append(ParticipantSerializer(participant).data)
# 返回所有参会者
participants = Participant.objects.filter(
meeting=meeting,
left_at__isnull=True
)
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})
return Response({
'message': '加入成功',
'participants': ParticipantSerializer(participants, many=True).data,
'joined_agents': joined_agents
})
@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,
@@ -301,170 +178,59 @@ class MeetingViewSet(viewsets.ModelViewSet):
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
# 如果是@消息,触发龙虾自动回复
if content.startswith('@') and message.requires_response:
self.auto_reply(message)
return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED)
def auto_reply(self, message):
"""龙虾自动回复"""
# 获取会议中所有龙虾
agents = Participant.objects.filter(
meeting=message.meeting,
agent_type='openclaw',
left_at__isnull=True
)
# 每个龙虾都有 30% 概率回复
import random
for agent in agents:
if random.random() < 0.3: # 30% 概率
replies = [
'收到!',
'明白了~',
'好的,我会处理',
'👌',
'嗯嗯',
'在的!',
]
import random
reply_content = random.choice(replies)
Message.objects.create(
meeting=message.meeting,
sender=agent,
content=reply_content,
is_broadcast=True,
in_reply_to=message
)
@action(detail=True, methods=['get'])
def inbox(self, request, pk=None):
"""Agent 查阅信箱(自动加入会议如果还没加入)"""
def messages(self, request, pk=None):
"""获取消息"""
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)
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({
'unread_count': messages.count(),
'messages': serializer.data,
'participant': ParticipantSerializer(participant).data
})
return Response({'messages': serializer.data})
@action(detail=True, methods=['post'])
def agent_reply(self, request, pk=None):
"""Agent 回复消息"""
@action(detail=True, methods=['get'])
def participants(self, request, pk=None):
"""获取参会者列表"""
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': '已离开会议'})
participants = meeting.participants.filter(left_at__isnull=True)
serializer = ParticipantSerializer(participants, many=True)
return Response(serializer.data)

View File

@@ -1 +1 @@
[{"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/index.js":"1","/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/App.js":"2"},{"size":232,"mtime":1775265162529,"results":"3","hashOfConfig":"4"},{"size":19499,"mtime":1775294803463,"results":"5","hashOfConfig":"4"},{"filePath":"6","messages":"7","suppressedMessages":"8","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1sir4jg",{"filePath":"9","messages":"10","suppressedMessages":"11","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/index.js",[],[],"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/App.js",[],[]]
[{"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/index.js":"1","/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/App.js":"2"},{"size":232,"mtime":1775265162529,"results":"3","hashOfConfig":"4"},{"size":19499,"mtime":1775343974253,"results":"5","hashOfConfig":"4"},{"filePath":"6","messages":"7","suppressedMessages":"8","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"1sir4jg",{"filePath":"9","messages":"10","suppressedMessages":"11","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/index.js",[],[],"/home/node/.openclaw/workspace/flying-hero/projects/meeting-room/frontend/src/App.js",[],[]]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

66
start-dev.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/bash
# 龙虾议事厅 - 开发环境快速启动脚本
echo "🏛️ 启动龙虾议事厅开发环境..."
# 检查并停止旧服务
echo "🧹 清理旧服务..."
# 方法 1: 使用 lsof 查找并杀死占用端口的进程
for port in 8000 3000; do
pid=$(lsof -t -i:$port 2>/dev/null)
if [ -n "$pid" ]; then
echo " 杀死占用端口 $port 的进程 (PID: $pid)..."
kill -9 $pid 2>/dev/null
fi
done
# 方法 2: 使用 pkill 清理
pkill -9 -f "manage.py runserver" 2>/dev/null
pkill -9 -f "npm start" 2>/dev/null
pkill -9 -f "node.*react-scripts" 2>/dev/null
sleep 2
# 启动后端
echo "📦 启动后端..."
cd backend
nohup python3 manage.py runserver 0.0.0.0:8000 > /tmp/meeting-backend.log 2>&1 &
BACKEND_PID=$!
sleep 2
if ps -p $BACKEND_PID > /dev/null; then
echo "✅ 后端已启动 (PID: $BACKEND_PID)"
else
echo "❌ 后端启动失败,请检查日志:/tmp/meeting-backend.log"
exit 1
fi
# 启动前端
echo "📦 启动前端..."
cd ../frontend
nohup npm start > /tmp/meeting-frontend.log 2>&1 &
FRONTEND_PID=$!
sleep 3
if ps -p $FRONTEND_PID > /dev/null; then
echo "✅ 前端已启动 (PID: $FRONTEND_PID)"
else
echo "❌ 前端启动失败,请检查日志:/tmp/meeting-frontend.log"
exit 1
fi
echo ""
echo "=========================================="
echo "✅ 开发环境启动完成!"
echo "=========================================="
echo ""
echo "📌 访问地址:"
echo " 前端http://localhost:3000/"
echo " 后端 API: http://localhost:8000/api/v1/"
echo ""
echo "📌 日志文件:"
echo " 后端:/tmp/meeting-backend.log"
echo " 前端:/tmp/meeting-frontend.log"
echo ""
echo "📌 停止服务:"
echo " kill $BACKEND_PID $FRONTEND_PID"
echo ""