Compare commits

..

2 Commits

Author SHA1 Message Date
f74babe5f5 🔧 修复:进入会议时自动加入
变更:
- joinMeeting 先获取会议信息
- 传递 invite_code 到后端
- 加入后刷新参会者列表
2026-04-04 21:10:18 +08:00
5cb47e9b3e 功能:进入历史会议时自动加入
变更:
- 进入会议室页面时自动调用 join API
- 当前登录用户会自动加入会议坐席
- 如果已加入则忽略错误
2026-04-04 21:01:17 +08:00
14 changed files with 375 additions and 206 deletions

View File

@@ -1,18 +0,0 @@
# 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,7 +21,6 @@ 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,9 +2,11 @@ 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 datetime import timedelta
from .models import Meeting, Participant, Message
from .serializers import MeetingSerializer, ParticipantSerializer, MessageSerializer
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
@@ -19,6 +21,7 @@ class MeetingViewSet(viewsets.ModelViewSet):
permission_classes = [] # 临时开放所有权限
def get_queryset(self):
# 简单返回所有会议
return Meeting.objects.all().order_by('-created_at')
def create(self, request, *args, **kwargs):
@@ -26,14 +29,20 @@ class MeetingViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
# 使用第一个用户作为 host
# 临时:使用第一个用户作为 host
from django.contrib.auth import get_user_model
User = get_user_model()
host = User.objects.first()
meeting = serializer.save(host=host)
# 设置 1 小时过期时间
meeting.expires_at = timezone.now() + timedelta(hours=1)
meeting.save()
# 指定主持龙虾(第一只,负责生成纪要)
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(
@@ -44,34 +53,101 @@ 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',
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_emoji='🤖',
nickname=f'Agent {agent_id}',
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 join(self, request, pk=None):
"""加入会议(支持批量加入)"""
def start(self, request, pk=None):
"""开始会议"""
meeting = self.get_object()
# 临时:不检查主持人权限(开发环境)
# if meeting.host != request.user:
# return Response(
# {'error': '只有主持人可以开始会议'},
# status=status.HTTP_403_FORBIDDEN
# )
# 检查会议是否过期
if meeting.expires_at and timezone.now() > meeting.expires_at:
# 过期会议,清空坐席
meeting.participants.all().delete()
return Response(
{'error': '会议已过期,坐席已清空'},
status=status.HTTP_400_BAD_REQUEST
)
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(
@@ -86,75 +162,122 @@ 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=user,
user=request.user,
left_at__isnull=True
).first()
if not existing:
# 用户加入会议
Participant.objects.create(
meeting=meeting,
user=user,
agent_type='human',
nickname=user.username if user else '用户',
is_host=False
)
if existing:
return Response(ParticipantSerializer(existing).data)
# 随行龙虾加入会议
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,
agent_type='openclaw',
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(
# 创建参会记录
participant = Participant.objects.create(
meeting=meeting,
left_at__isnull=True
user=request.user,
agent_type='human',
nickname=request.user.username
)
return Response({
'message': '加入成功',
'participants': ParticipantSerializer(participants, many=True).data,
'joined_agents': joined_agents
})
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,
@@ -178,59 +301,170 @@ class MeetingViewSet(viewsets.ModelViewSet):
requires_response=request.data.get('requires_response', False)
)
# 如果是@消息,触发龙虾自动回复
if content.startswith('@') and message.requires_response:
self.auto_reply(message)
# 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)
def auto_reply(self, message):
"""龙虾自动回复"""
# 获取会议中所有龙虾
agents = Participant.objects.filter(
meeting=message.meeting,
agent_type='openclaw',
@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
)
# 每个龙虾都有 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 messages(self, request, pk=None):
"""获取消息"""
meeting = self.get_object()
last_id = request.query_params.get('last_id', 0)
# 标记为已读
participant.read_messages.add(*messages)
messages = meeting.messages.filter(id__gt=last_id).select_related('sender')
serializer = MessageSerializer(messages, many=True)
return Response({'messages': serializer.data})
return Response({
'unread_count': messages.count(),
'messages': serializer.data,
'participant': ParticipantSerializer(participant).data
})
@action(detail=True, methods=['get'])
def participants(self, request, pk=None):
"""获取参会者列表"""
@action(detail=True, methods=['post'])
def agent_reply(self, request, pk=None):
"""Agent 回复消息"""
meeting = self.get_object()
participants = meeting.participants.filter(left_at__isnull=True)
serializer = ParticipantSerializer(participants, many=True)
return Response(serializer.data)
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': '已离开会议'})

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":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",[],[]]
[{"/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":20129,"mtime":1775308176625,"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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -5306,9 +5306,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001784",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz",
"integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==",
"version": "1.0.30001785",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz",
"integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==",
"funding": [
{
"type": "opencollective",
@@ -16000,9 +16000,9 @@
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"license": "Apache-2.0",
"peer": true,
"bin": {
@@ -16010,7 +16010,7 @@
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/unbox-primitive": {

View File

@@ -242,6 +242,7 @@ function MeetingRoom() {
fetchMeeting();
fetchParticipants();
fetchMessages();
joinMeeting(); // 自动加入会议
const interval = setInterval(fetchMessages, 1000);
return () => clearInterval(interval);
}, [id]);
@@ -267,6 +268,25 @@ function MeetingRoom() {
} catch (error) { console.error(error); }
};
const joinMeeting = async () => {
try {
// 先获取会议信息
if (!meeting) {
const res = await axios.get(`${API_BASE}/meetings/${id}/`);
setMeeting(res.data);
}
// 尝试加入会议(如果还没加入)
await axios.post(`${API_BASE}/meetings/${id}/join/`, {
invite_code: meeting?.invite_code
});
// 刷新参会者列表
fetchParticipants();
} catch (error) {
// 可能已经加入了,忽略错误
console.log('加入会议:', error?.response?.data?.error || '已加入');
}
};
const sendMessage = async (e) => {
e.preventDefault();
if (!content.trim()) return;

View File

@@ -1,66 +0,0 @@
#!/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 ""