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 568 additions and 255 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":12200,"mtime":1775353673604,"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

@@ -14,17 +14,21 @@ axios.interceptors.request.use(config => {
function LoginPage() {
const [username, setUsername] = useState('test');
const [password, setPassword] = useState('test123');
const [mode, setMode] = useState('solo');
const [agents, setAgents] = useState([]);
const [selectedAgents, setSelectedAgents] = useState([]);
const navigate = useNavigate();
// 扫描本机龙虾
useEffect(() => {
scanAgents();
}, []);
if (username) {
scanAgents();
}
}, [username]);
const scanAgents = async () => {
try {
// 传递 username 参数,获取绑定的龙虾信息
const res = await axios.get(`${API_BASE}/user/scan-local-agents/?username=${username}`);
setAgents(res.data.agents || []);
} catch (error) {
@@ -35,13 +39,24 @@ function LoginPage() {
const handleLogin = async (e) => {
e.preventDefault();
try {
const res = await axios.post(`${API_BASE}/auth/login/`, { username, password });
const payload = {
username,
password,
mode
};
if (mode !== 'solo' && selectedAgents.length > 0) {
payload.agent_ids = selectedAgents;
}
const res = await axios.post(`${API_BASE}/auth/login/`, payload);
localStorage.setItem('token', res.data.token);
localStorage.setItem('user', JSON.stringify(res.data.user));
localStorage.setItem('selectedAgents', JSON.stringify(selectedAgents));
localStorage.setItem('sessions', JSON.stringify(res.data.sessions));
localStorage.setItem('mode', res.data.mode);
navigate('/meetings');
} catch (error) {
alert('登录失败:' + (error.response?.data?.detail || error.message));
alert('登录失败:' + (error.response?.data?.detail || error.response?.data?.error || error.message));
}
};
@@ -61,22 +76,71 @@ function LoginPage() {
<input type="text" placeholder="用户名" value={username} onChange={e => setUsername(e.target.value)} style={styles.input} required />
<input type="password" placeholder="密码" value={password} onChange={e => setPassword(e.target.value)} style={styles.input} required />
<div style={{margin: '15px 0', width: '100%'}}>
<label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>🦐 选择随行龙虾</label>
{agents.map(agent => (
<label key={agent.agent_id} style={{display: 'block', marginBottom: '8px', cursor: 'pointer'}}>
<input
type="checkbox"
checked={selectedAgents.includes(agent.agent_id)}
onChange={() => toggleAgent(agent.agent_id)}
style={{marginRight: '8px'}}
/>
{agent.agent_emoji || '🤖'} {agent.agent_name} ({agent.instance_name})
</label>
))}
{/* 出战模式选择 */}
<div style={{margin: '15px 0'}}>
<label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>🎯 出战模式</label>
<label style={{display: 'block', marginBottom: '12px', cursor: 'pointer', padding: '10px', background: mode === 'solo' ? '#e7f3ff' : 'white', borderRadius: '8px', border: '1px solid #2196f3'}}>
<input
type="radio"
name="mode"
value="solo"
checked={mode === 'solo'}
onChange={e => setMode(e.target.value)}
/>
{' '}🥷 <strong>单枪匹马</strong>
</label>
<label style={{display: 'block', marginBottom: '12px', cursor: 'pointer', padding: '10px', background: mode === 'team' ? '#e7f3ff' : 'white', borderRadius: '8px', border: '1px solid #2196f3'}}>
<input
type="radio"
name="mode"
value="team"
checked={mode === 'team'}
onChange={e => setMode(e.target.value)}
/>
{' '}🛡 <strong>组队团战</strong> + N
</label>
<label style={{display: 'block', marginBottom: '12px', cursor: 'pointer', padding: '10px', background: mode === 'agent_only' ? '#e7f3ff' : 'white', borderRadius: '8px', border: '1px solid #2196f3'}}>
<input
type="radio"
name="mode"
value="agent_only"
checked={mode === 'agent_only'}
onChange={e => setMode(e.target.value)}
/>
{' '} <strong>独当一面</strong>
</label>
</div>
<button type="submit" style={styles.btn}>登录</button>
{/* 龙虾选择(组队或独当一面) */}
{mode !== 'solo' && (
<div style={{margin: '15px 0', padding: '15px', background: '#f9f9f9', borderRadius: '8px'}}>
<label style={{display: 'block', marginBottom: '10px', fontWeight: '600'}}>🦐 选择龙虾队友</label>
{agents.length === 0 ? (
<p style={{color: '#999', fontSize: '14px'}}>未找到可用龙虾</p>
) : (
agents.map(a => (
<label key={a.agent_id} style={{display: 'flex', alignItems: 'center', marginBottom: '8px', cursor: 'pointer'}}>
<input
type="checkbox"
checked={selectedAgents.includes(a.agent_id)}
onChange={() => toggleAgent(a.agent_id)}
style={{marginRight: '10px'}}
/>
<span style={{fontSize: '16px', marginRight: '8px'}}>{a.agent_emoji || '🤖'}</span>
<span>{a.agent_id}</span>
<span style={{color: '#999', fontSize: '12px', marginLeft: '8px'}}>({a.instance_name})</span>
</label>
))
)}
{selectedAgents.length > 0 && (
<p style={{marginTop: '10px', color: '#2196f3', fontWeight: '600'}}>
已选 {selectedAgents.length} 只龙虾队友 🦸
</p>
)}
</div>
)}
<button type="submit" style={styles.btn}>🚀 登录出征</button>
</form>
</div>
</div>
@@ -87,6 +151,7 @@ function LoginPage() {
function MeetingList() {
const [meetings, setMeetings] = useState([]);
const [topic, setTopic] = useState('');
const [autoAddAgents, setAutoAddAgents] = useState(true);
const navigate = useNavigate();
const token = localStorage.getItem('token');
@@ -105,10 +170,15 @@ function MeetingList() {
const createMeeting = async (e) => {
e.preventDefault();
try {
const selectedAgents = JSON.parse(localStorage.getItem('selectedAgents') || '[]');
// 获取当前登录的龙虾
const sessions = JSON.parse(localStorage.getItem('sessions') || '[]');
const agentIds = sessions.filter(s => s.session_type === 'agent').map(s => s.agent_id);
const res = await axios.post(`${API_BASE}/meetings/`, {
topic,
agent_ids: selectedAgents
auto_add_virtual_agents: agentIds.length === 0, // 只有没有龙虾时才添加虚拟的
host_agent_id: agentIds.length > 0 ? agentIds[0] : null, // 第一只作为主持龙虾
agent_ids: agentIds // 传递所有龙虾
});
navigate(`/meeting/${res.data.id}`);
} catch (error) {
@@ -128,8 +198,19 @@ function MeetingList() {
<h2>创建会议</h2>
<form onSubmit={createMeeting} style={styles.form}>
<input type="text" placeholder="会议主题" value={topic} onChange={e => setTopic(e.target.value)} style={styles.input} required />
<label style={{display: 'flex', alignItems: 'center', gap: '5px', whiteSpace: 'nowrap'}}>
<input
type="checkbox"
checked={autoAddAgents}
onChange={e => setAutoAddAgents(e.target.checked)}
/>
添加虚拟坐席
</label>
<button type="submit" style={styles.btn}>创建</button>
</form>
<p style={{fontSize: '12px', color: '#666', marginTop: '10px'}}>
💡 勾选"添加虚拟坐席"会自动创建 2 个虚拟龙虾参会者方便测试 @ 功能
</p>
</div>
<div style={styles.list}>
{meetings.map(m => (
@@ -153,11 +234,15 @@ function MeetingRoom() {
const [content, setContent] = useState('');
const [participants, setParticipants] = useState([]);
const [meeting, setMeeting] = useState(null);
const [hoveredSeat, setHoveredSeat] = useState(null);
const token = localStorage.getItem('token');
useEffect(() => {
if (!token) return;
fetchMeeting();
fetchParticipants();
fetchMessages();
joinMeeting(); // 自动加入会议
const interval = setInterval(fetchMessages, 1000);
return () => clearInterval(interval);
}, [id]);
@@ -166,27 +251,9 @@ function MeetingRoom() {
try {
const res = await axios.get(`${API_BASE}/meetings/${id}/`);
setMeeting(res.data);
// 获取会议信息后,自动加入会议
if (res.data.invite_code) {
joinMeeting(res.data.invite_code);
}
} catch (error) { console.error(error); }
};
const joinMeeting = async (inviteCode) => {
try {
const selectedAgents = JSON.parse(localStorage.getItem('selectedAgents') || '[]');
await axios.post(`${API_BASE}/meetings/${id}/join/`, {
invite_code: inviteCode,
agent_ids: selectedAgents
});
// 加入后刷新参会者列表
fetchParticipants();
} catch (error) {
console.log('加入会议:', error?.response?.data?.error || '已加入');
}
};
const fetchParticipants = async () => {
try {
const res = await axios.get(`${API_BASE}/meetings/${id}/participants/`);
@@ -201,16 +268,30 @@ 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;
try {
// 检查是否是@消息
const requiresResponse = content.startsWith('@');
await axios.post(`${API_BASE}/meetings/${id}/send_message/`, {
content,
requires_response: requiresResponse
});
await axios.post(`${API_BASE}/meetings/${id}/send_message/`, { content });
setContent('');
fetchMessages();
} catch (error) {
@@ -218,13 +299,80 @@ function MeetingRoom() {
}
};
const mentionAgent = async (targetAgentId, agentName) => {
const target = targetAgentId || prompt('@哪个 Agent输入 agent_id:');
if (!target || !content.trim()) return;
const name = agentName || target;
try {
await axios.post(`${API_BASE}/meetings/${id}/mention_agent/`, {
target_agent_id: target, content,
sender_name: localStorage.getItem('user') ? JSON.parse(localStorage.getItem('user')).username : 'User'
});
setContent('');
fetchMessages();
alert(`✅ 已 @${name}`);
} catch (error) {
alert('发送失败:' + (error.response?.data?.error || error.message));
}
};
const startMeeting = async () => {
try {
await axios.post(`${API_BASE}/meetings/${id}/start/`);
fetchMeeting();
alert('✅ 会议已开始');
} catch (error) {
alert('开始失败:' + (error.response?.data?.error || error.message));
}
};
const endMeeting = async () => {
if (!confirm('确定结束会议?')) return;
try {
await axios.post(`${API_BASE}/meetings/${id}/end/`);
fetchMeeting();
alert('✅ 会议已结束');
} catch (error) {
alert('结束失败:' + (error.response?.data?.error || error.message));
}
};
const generateMinutes = async () => {
try {
const res = await axios.get(`${API_BASE}/meetings/${id}/minutes/?output=markdown`);
const blob = new Blob([res.data.markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `meeting-${id.slice(0, 8)}.md`;
a.click();
URL.revokeObjectURL(url);
alert('✅ 纪要已导出');
} catch (error) {
alert('导出失败:' + error.message);
}
};
return (
<div style={styles.container}>
<div style={styles.header}>
<Link to="/meetings" style={styles.link}> 返回</Link>
<h1>{meeting?.topic || '会议室'}</h1>
{meeting && <span style={styles.badge}>{meeting.status}</span>}
</div>
{meeting && (
<div style={styles.infoCard}>
<p><strong>ID:</strong> {meeting.id}</p>
<p><strong>邀请码:</strong> {meeting.invite_code}</p>
<div style={styles.btnGroup}>
<button onClick={startMeeting} style={styles.btnGreen}> 开始</button>
<button onClick={endMeeting} style={styles.btnRed}> 结束</button>
<button onClick={generateMinutes} style={styles.btnBlue}>📋 纪要</button>
</div>
</div>
)}
{/* 座位图 */}
<div style={styles.card}>
<h2>🪑 座位图 <span style={styles.badge}>{participants.length}</span></h2>
@@ -232,8 +380,16 @@ function MeetingRoom() {
{participants.map(p => (
<div
key={p.id}
style={styles.seat}
title={p.nickname}
style={{...styles.seat, ...(hoveredSeat === p.id ? styles.seatHover : {})}}
onClick={() => {
if (p.agent_id) {
setContent(`@${p.nickname} `);
document.querySelector('input[placeholder="输入消息..."]')?.focus();
}
}}
onMouseEnter={() => setHoveredSeat(p.id)}
onMouseLeave={() => setHoveredSeat(null)}
title={p.agent_id ? '点击 @ 此人' : ''}
>
<div style={styles.seatEmoji}>{p.agent_emoji || '👤'}</div>
<div style={styles.seatName}>{p.nickname}</div>
@@ -259,8 +415,9 @@ function MeetingRoom() {
))}
</div>
<form onSubmit={sendMessage} style={styles.form}>
<input type="text" placeholder="输入消息... (使用 @ 来呼叫龙虾)" value={content} onChange={e => setContent(e.target.value)} style={styles.input} />
<input type="text" placeholder="输入消息..." value={content} onChange={e => setContent(e.target.value)} style={styles.input} />
<button type="submit" style={styles.btn}>发送</button>
<button type="button" onClick={mentionAgent} style={styles.btnPink}>📍 @Agent</button>
</form>
</div>
</div>
@@ -287,17 +444,24 @@ const styles = {
container: { maxWidth: '900px', margin: '0 auto', padding: '20px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
header: { display: 'flex', alignItems: 'center', gap: '15px', marginBottom: '20px' },
card: { background: 'white', borderRadius: '12px', padding: '20px', marginBottom: '20px', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' },
infoCard: { background: '#e7f3ff', border: '1px solid #2196f3', borderRadius: '12px', padding: '15px', marginBottom: '20px' },
title: { margin: '0 0 20px', color: '#1a365d', textAlign: 'center' },
form: { display: 'flex', gap: '10px' },
input: { flex: 1, padding: '12px', border: '2px solid #e2e8f0', borderRadius: '8px', fontSize: '14px' },
btn: { padding: '12px 20px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: '600' },
btnGreen: { padding: '8px 16px', background: 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', marginRight: '8px' },
btnRed: { padding: '8px 16px', background: 'linear-gradient(135deg, #eb3349 0%, #f45c43 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', marginRight: '8px' },
btnBlue: { padding: '8px 16px', background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' },
btnPink: { padding: '8px 16px', background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' },
smallBtn: { padding: '8px 16px', background: '#edf2f7', border: 'none', borderRadius: '6px', cursor: 'pointer' },
list: { display: 'flex', flexDirection: 'column', gap: '15px' },
item: { background: 'white', borderRadius: '12px', padding: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' },
link: { color: '#4299e1', textDecoration: 'none', fontSize: '16px' },
badge: { background: '#667eea', color: 'white', padding: '4px 10px', borderRadius: '20px', fontSize: '12px', fontWeight: '600' },
btnGroup: { display: 'flex', marginTop: '10px' },
seats: { display: 'flex', flexWrap: 'wrap', gap: '15px', justifyContent: 'center' },
seat: { background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', padding: '15px', borderRadius: '50%', width: '90px', height: '90px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', cursor: 'default' },
seat: { background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', padding: '15px', borderRadius: '50%', width: '90px', height: '90px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', cursor: 'pointer', transition: 'transform 0.2s', ':hover': { transform: 'scale(1.1)' } },
seatHover: { transform: 'scale(1.05)' },
seatEmoji: { fontSize: '28px', marginBottom: '5px' },
seatName: { fontSize: '12px', fontWeight: '600' },
hostBadge: { fontSize: '10px', opacity: '0.8' },

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 ""