🎨 飞行侠完善 P1 功能:座位图 + @Agent + 会议纪要

新增功能:
- 座位可视化 - 圆形头像展示参会者
- @Agent 功能 - 定向消息给特定 Agent
- 会议纪要生成 - Web 界面一键生成
- 参会者列表 API

文件变更:
- meetings/views.py: mention_agent() 新接口
- templates/meeting_room.html:
  - 座位图 UI(圆形头像)
  - 生成纪要按钮
  - @Agent 按钮
- test_mention.py: @Agent 测试脚本

测试结果:
 完整功能测试 (7 项)
 会议纪要测试 (JSON + Markdown)
 @Agent 功能测试
This commit is contained in:
2026-04-04 11:43:41 +08:00
parent 53c3ac487a
commit d403583fb8
3 changed files with 298 additions and 2 deletions

View File

@@ -125,6 +125,70 @@ class MeetingViewSet(viewsets.ModelViewSet):
serializer = ParticipantSerializer(participants, many=True) serializer = ParticipantSerializer(participants, many=True)
return Response(serializer.data) 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']) @action(detail=True, methods=['get'])
def messages(self, request, pk=None): def messages(self, request, pk=None):
"""获取消息(人类轮询)""" """获取消息(人类轮询)"""

View File

@@ -217,18 +217,38 @@
<input type="text" id="agentEmoji" placeholder="🦸" value="🦸"> <input type="text" id="agentEmoji" placeholder="🦸" value="🦸">
</div> </div>
<button class="btn" onclick="checkInbox()" style="width: 100%; margin-bottom: 10px;">📬 查阅信箱</button> <button class="btn" onclick="checkInbox()" style="width: 100%; margin-bottom: 10px;">📬 查阅信箱</button>
<button class="btn" onclick="agentReply()" style="width: 100%;">💬 回复消息</button> <button class="btn" onclick="agentReply()" style="width: 100%; margin-bottom: 10px;">💬 回复消息</button>
<button class="btn" onclick="mentionAgent()" style="width: 100%; background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">📍 @Agent</button>
</div> </div>
</div> </div>
</div> </div>
<!-- 座位图 -->
<div style="margin-top: 20px;">
<h3 style="margin-bottom: 15px;">🪑 座位图 <span id="participantCount" class="badge">0</span></h3>
<div class="messages" id="seatMap" style="min-height: 100px;">
<p style="text-align: center; color: #999; padding: 20px;">暂无参会者</p>
</div>
</div>
<!-- 消息列表 --> <!-- 消息列表 -->
<div style="margin-top: 20px;"> <div style="margin-top: 20px;">
<h3 style="margin-bottom: 15px;">💬 消息列表 <span id="messageCount" class="badge">0</span></h3> <h3 style="margin-bottom: 15px;">💬 消息列表 <span id="messageCount" class="badge">0</span></h3>
<div class="messages" id="messageList"> <div class="messages" id="messageList">
<p style="text-align: center; color: #999; padding: 40px;">暂无消息,开始聊天吧!</p> <p style="text-align: center; color: #999; padding: 40px;">暂无消息,开始聊天吧!</p>
</div> </div>
<button class="btn" onclick="loadMessages()" style="margin-top: 15px;">🔄 刷新消息</button> <div style="margin-top: 15px; display: flex; gap: 10px;">
<button class="btn" onclick="loadMessages()">🔄 刷新</button>
<button class="btn" onclick="loadParticipants()" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">👥 刷新座位</button>
<button class="btn" onclick="generateMinutes()" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">📋 生成纪要</button>
</div>
</div>
<!-- 会议纪要显示区 -->
<div id="minutesDisplay" style="margin-top: 20px; display: none;">
<h3 style="margin-bottom: 15px;">📋 会议纪要</h3>
<div class="messages" id="minutesContent" style="white-space: pre-wrap; font-family: monospace;"></div>
<button class="btn" onclick="document.getElementById('minutesDisplay').style.display='none'" style="margin-top: 15px;">❌ 关闭</button>
</div> </div>
</div> </div>
</div> </div>
@@ -499,6 +519,115 @@
} }
} }
async function loadParticipants() {
if (!currentMeetingId) {
showStatus('❌ 请先创建或加入会议', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/participants/`);
const participants = await res.json();
document.getElementById('participantCount').textContent = participants.length;
if (participants.length === 0) {
document.getElementById('seatMap').innerHTML =
'<p style="text-align: center; color: #999; padding: 20px;">暂无参会者</p>';
} else {
// 圆桌座位布局
document.getElementById('seatMap').innerHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 15px; justify-content: center; padding: 20px;">
${participants.map(p => `
<div style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 50%;
width: 100px;
height: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 12px;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
">
<div style="font-size: 24px; margin-bottom: 5px;">${p.agent_emoji || '👤'}</div>
<div style="font-weight: 600;">${p.nickname || '参会者'}</div>
${p.is_host ? '<div style="font-size: 10px; opacity: 0.8;">👑 主持</div>' : ''}
</div>
`).join('')}
</div>
`;
}
} catch (e) {
showStatus(`❌ 加载座位失败:${e.message}`, 'error');
}
}
async function generateMinutes() {
if (!currentMeetingId) {
showStatus('❌ 请先创建或加入会议', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/${currentMeetingId}/minutes/?output=markdown`);
const data = await res.json();
if (res.ok && data.markdown) {
document.getElementById('minutesContent').textContent = data.markdown;
document.getElementById('minutesDisplay').style.display = 'block';
showStatus('✅ 会议纪要已生成!', 'success');
} else {
showStatus(`❌ 生成失败:${data.error || '未知错误'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
async function mentionAgent() {
const meetingId = currentMeetingId || prompt('请输入会议 ID:');
const targetAgentId = prompt('@哪个 Agent输入 agent_id:');
const content = document.getElementById('messageContent').value;
const senderAgentId = document.getElementById('agentId').value || 'human';
const senderName = document.getElementById('agentName').value || '用户';
const senderEmoji = document.getElementById('agentEmoji').value || '👤';
if (!meetingId || !targetAgentId || !content) {
showStatus('❌ 请填写完整信息', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/meetings/${meetingId}/mention_agent/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target_agent_id: targetAgentId,
content: content,
sender_agent_id: senderAgentId,
sender_name: senderName,
sender_emoji: senderEmoji
})
});
const data = await res.json();
if (res.ok) {
document.getElementById('messageContent').value = '';
showStatus(`✅ 已 @${targetAgentId}`, 'success');
loadMessages();
} else {
showStatus(`${data.error || '发送失败'}`, 'error');
}
} catch (e) {
showStatus(`❌ 请求失败:${e.message}`, 'error');
}
}
// 自动登录(如果记得凭证) // 自动登录(如果记得凭证)
document.getElementById('username').value = 'test'; document.getElementById('username').value = 'test';
document.getElementById('password').value = 'test123'; document.getElementById('password').value = 'test123';

103
backend/test_mention.py Normal file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""
测试 @Agent 功能
"""
import requests
API_BASE = 'http://localhost:8000/api/v1'
def test_mention_agent():
print("="*60)
print("📍 测试 @Agent 功能")
print("="*60)
# 登录
res = requests.post(f'{API_BASE}/auth/login/', json={
'username': 'test',
'password': 'test123'
})
token = res.json()['token']
headers = {'Authorization': f'Bearer {token}'}
print(f"✅ 登录成功")
# 创建会议
res = requests.post(f'{API_BASE}/meetings/', json={
'topic': '@Agent 测试会议'
}, headers=headers)
meeting_id = res.json()['id']
print(f"✅ 创建会议:{meeting_id}")
# Agent A 加入(查信箱自动加入)
res = requests.get(
f'{API_BASE}/meetings/{meeting_id}/inbox/',
params={'agent_id': 'agent_a', 'agent_name': '助手 A', 'agent_emoji': '🤖'}
)
print(f"✅ Agent A 加入会议")
# Agent B 加入
res = requests.get(
f'{API_BASE}/meetings/{meeting_id}/inbox/',
params={'agent_id': 'agent_b', 'agent_name': '助手 B', 'agent_emoji': '🦊'}
)
print(f"✅ Agent B 加入会议")
# 获取参会者列表
res = requests.get(f'{API_BASE}/meetings/{meeting_id}/participants/')
participants = res.json()
print(f"✅ 参会者列表:{len(participants)}")
for p in participants:
print(f" - {p['nickname']} (ID: {p['agent_id']})")
# @Agent A
res = requests.post(f'{API_BASE}/meetings/{meeting_id}/mention_agent/', json={
'target_agent_id': 'agent_a',
'content': '请汇报一下进度',
'sender_agent_id': 'human_user',
'sender_name': '北极星',
'sender_emoji': ''
})
if res.status_code == 201:
msg = res.json()
print(f"✅ @Agent A 成功:{msg['content']}")
else:
print(f"❌ @Agent 失败:{res.text}")
return False
# Agent A 查信箱(应该收到 @ 消息)
res = requests.get(
f'{API_BASE}/meetings/{meeting_id}/inbox/',
params={'agent_id': 'agent_a', 'agent_name': '助手 A', 'agent_emoji': '🤖'}
)
inbox = res.json()
print(f"✅ Agent A 信箱:{inbox['unread_count']} 条未读")
# Agent A 回复
res = requests.post(f'{API_BASE}/meetings/{meeting_id}/agent_reply/', json={
'agent_id': 'agent_a',
'agent_name': '助手 A',
'agent_emoji': '🤖',
'content': '收到!进度正常,已完成 80%',
'in_reply_to': msg['id']
})
if res.status_code == 201:
print(f"✅ Agent A 回复成功")
else:
print(f"❌ Agent A 回复失败:{res.text}")
return False
# 获取全部消息
res = requests.get(f'{API_BASE}/meetings/{meeting_id}/messages/?last_id=0')
messages = res.json()['messages']
print(f"\n💬 全部消息 ({len(messages)} 条):")
for m in messages:
reply_info = f" (回复 #{m['in_reply_to']})" if m.get('in_reply_to') else ""
print(f" {m['sender_emoji']} {m['sender_name']}: {m['content']}{reply_info}")
print("\n" + "="*60)
print("✅ @Agent 功能测试通过!")
print("="*60)
return True
if __name__ == '__main__':
test_mention_agent()