🎨 飞行侠完善 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:
@@ -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):
|
||||||
"""获取消息(人类轮询)"""
|
"""获取消息(人类轮询)"""
|
||||||
|
|||||||
@@ -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
103
backend/test_mention.py
Normal 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()
|
||||||
Reference in New Issue
Block a user