【悟凡】真正意义上的净土重生:只保留核心逻辑
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Python 缓存
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# 环境
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
.env
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
|
||||
# 系统与工具
|
||||
.DS_Store
|
||||
.openclaw/
|
||||
.vscode/
|
||||
|
||||
# 数据库与日志
|
||||
*.db
|
||||
*.sqlite3
|
||||
*.log
|
||||
243
README.md
Normal file
243
README.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# 🏛️ 龙虾议事厅 (Agent Meeting Room)
|
||||
|
||||
**AI 原生的会议管理系统** - 让人类通过自然语言命令,AI 自主操作会议!
|
||||
|
||||
---
|
||||
|
||||
## 🌟 核心特性
|
||||
|
||||
### 1. 人类自然语言命令 🗣️
|
||||
|
||||
```
|
||||
人类:"创建一个会议,主题是 Q2 计划讨论"
|
||||
AI: ✅ 会议已创建!主题:Q2 计划讨论 邀请码:8AD06740
|
||||
|
||||
人类:"发送消息:大家好!"
|
||||
AI: ✅ 消息已发送
|
||||
|
||||
人类:"查看会议列表"
|
||||
AI: 📋 会议列表:[列出所有会议]
|
||||
```
|
||||
|
||||
### 2. AI 自主操作 🤖
|
||||
|
||||
- AI 可以直接调用 API 操作会议
|
||||
- 无需人类点击界面
|
||||
- 支持复杂任务自主执行
|
||||
|
||||
### 3. 完整的 API 接口 🔌
|
||||
|
||||
- 创建会议
|
||||
- 加入会议
|
||||
- 发送消息
|
||||
- 获取消息
|
||||
- 获取会议列表
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 数据库迁移
|
||||
|
||||
```bash
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### 3. 创建测试用户
|
||||
|
||||
```bash
|
||||
python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
|
||||
```bash
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
### 5. 启动前端
|
||||
|
||||
```bash
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤖 AI 操作示例
|
||||
|
||||
### 使用 AI SDK
|
||||
|
||||
```python
|
||||
from meeting_ai_sdk import MeetingAIOperations
|
||||
|
||||
api = MeetingAIOperations('http://localhost:8000')
|
||||
|
||||
# 登录
|
||||
await api.login('test', 'test123')
|
||||
|
||||
# 创建会议
|
||||
meeting = await api.create_meeting('Q2 计划讨论')
|
||||
|
||||
# 发送消息
|
||||
await api.send_message(meeting.id, '大家好!')
|
||||
|
||||
# 获取消息
|
||||
messages = await api.get_messages(meeting.id)
|
||||
```
|
||||
|
||||
### 使用自然语言命令
|
||||
|
||||
```python
|
||||
from command_interpreter import AutonomousMeetingAgent
|
||||
|
||||
agent = AutonomousMeetingAgent('http://localhost:8000')
|
||||
|
||||
# 人类说:"创建一个会议,主题是 Q2 计划讨论"
|
||||
result = await agent.execute_command("创建一个会议,主题是 Q2 计划讨论")
|
||||
print(result)
|
||||
|
||||
# 人类说:"发送消息:大家好!"
|
||||
result = await agent.execute_command("发送消息:大家好!")
|
||||
print(result)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 API 文档
|
||||
|
||||
### 认证 API
|
||||
|
||||
```bash
|
||||
POST /api/v1/auth/login/
|
||||
{
|
||||
"username": "test",
|
||||
"password": "test123"
|
||||
}
|
||||
```
|
||||
|
||||
### 会议 API
|
||||
|
||||
```bash
|
||||
# 创建会议
|
||||
POST /api/v1/meetings/
|
||||
{
|
||||
"topic": "Q2 计划讨论"
|
||||
}
|
||||
|
||||
# 获取会议列表
|
||||
GET /api/v1/meetings/
|
||||
|
||||
# 加入会议
|
||||
POST /api/v1/meetings/{id}/join/
|
||||
{
|
||||
"invite_code": "ABC12345"
|
||||
}
|
||||
|
||||
# 发送消息
|
||||
POST /api/v1/meetings/{id}/send_message/
|
||||
{
|
||||
"content": "大家好!"
|
||||
}
|
||||
|
||||
# 获取消息
|
||||
GET /api/v1/meetings/{id}/messages/?last_id=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
### API 测试
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python test_api.py
|
||||
```
|
||||
|
||||
### AI SDK 测试
|
||||
|
||||
```bash
|
||||
python meeting_ai_sdk.py
|
||||
```
|
||||
|
||||
### 自然语言命令测试
|
||||
|
||||
```bash
|
||||
python command_interpreter.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
meeting-room/
|
||||
├── backend/
|
||||
│ ├── meeting_room/ # Django 项目
|
||||
│ ├── meetings/ # 会议应用
|
||||
│ ├── users/ # 用户应用
|
||||
│ ├── api/ # API 应用
|
||||
│ ├── meeting_ai_sdk.py # AI 操作 SDK ⭐
|
||||
│ ├── command_interpreter.py # 自然语言解析器 ⭐
|
||||
│ └── test_api.py # API 测试
|
||||
├── frontend/
|
||||
│ ├── public/
|
||||
│ └── src/
|
||||
│ ├── App.js
|
||||
│ └── components/
|
||||
└── docs/
|
||||
├── 01-产品需求文档.md
|
||||
└── 02-技术架构设计.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 技术栈
|
||||
|
||||
**后端**:
|
||||
- Django 4.2
|
||||
- Django REST Framework
|
||||
- PostgreSQL / SQLite
|
||||
|
||||
**前端**:
|
||||
- React 18
|
||||
- React Router 6
|
||||
- Axios
|
||||
|
||||
**AI 层**:
|
||||
- 自然语言命令解析
|
||||
- AI 操作 SDK
|
||||
- 自主执行 Agent
|
||||
|
||||
---
|
||||
|
||||
## 📝 开发日志
|
||||
|
||||
### 2026-04-04
|
||||
|
||||
- ✅ 完成核心 API 开发
|
||||
- ✅ 创建 AI 操作 SDK
|
||||
- ✅ 实现自然语言命令解析器
|
||||
- ✅ 实现自主会议 Agent
|
||||
- ✅ 所有测试通过
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢北极星 ⭐ 的指导和创意!
|
||||
|
||||
**"让 AI 自主操作软件,人类只需下命令"** 🚀
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ by 飞行侠**
|
||||
12
backend/.env.example
Normal file
12
backend/.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Django 配置
|
||||
DEBUG=True
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:///db.sqlite3
|
||||
# 生产环境使用:
|
||||
# DATABASE_URL=postgresql://user:pass@localhost:5432/meeting_room
|
||||
|
||||
# API 配置
|
||||
API_BASE_URL=http://localhost:8000
|
||||
21
backend/Dockerfile
Normal file
21
backend/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 Python 依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制代码
|
||||
COPY . .
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "meeting_room.wsgi:application", "--bind", "0.0.0.0:8000"]
|
||||
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
3
backend/api/admin.py
Normal file
3
backend/api/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/api/apps.py
Normal file
6
backend/api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "api"
|
||||
0
backend/api/migrations/__init__.py
Normal file
0
backend/api/migrations/__init__.py
Normal file
3
backend/api/models.py
Normal file
3
backend/api/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
backend/api/tests.py
Normal file
3
backend/api/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
backend/api/views.py
Normal file
3
backend/api/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
302
backend/command_interpreter.py
Normal file
302
backend/command_interpreter.py
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
龙虾议事厅 - 自然语言命令解析器
|
||||
让 AI 理解人类的自然语言命令
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CommandType(Enum):
|
||||
"""命令类型"""
|
||||
CREATE_MEETING = "create_meeting"
|
||||
SEND_MESSAGE = "send_message"
|
||||
LIST_MEETINGS = "list_meetings"
|
||||
JOIN_MEETING = "join_meeting"
|
||||
GET_MESSAGES = "get_messages"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCommand:
|
||||
"""解析后的命令"""
|
||||
type: CommandType
|
||||
params: Dict[str, Any]
|
||||
confidence: float # 置信度 0-1
|
||||
|
||||
|
||||
class CommandInterpreter:
|
||||
"""
|
||||
自然语言命令解析器
|
||||
|
||||
支持的命令:
|
||||
- "创建一个会议,主题是 XXX"
|
||||
- "发送消息:XXX"
|
||||
- "查看会议列表"
|
||||
- "加入会议,邀请码 XXX"
|
||||
- "查看消息"
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 命令模式匹配(简化版)
|
||||
self.patterns = {
|
||||
CommandType.CREATE_MEETING: [
|
||||
r'创建.*会议',
|
||||
r'新建.*会议',
|
||||
r'开.*会',
|
||||
r'组织.*会议',
|
||||
],
|
||||
CommandType.SEND_MESSAGE: [
|
||||
r'发送.*消息',
|
||||
r'发消息',
|
||||
r'说.*',
|
||||
r'告诉.*',
|
||||
],
|
||||
CommandType.LIST_MEETINGS: [
|
||||
r'查看.*列表',
|
||||
r'显示.*会议',
|
||||
r'有.*会议',
|
||||
r'我的会议',
|
||||
],
|
||||
CommandType.JOIN_MEETING: [
|
||||
r'加入.*',
|
||||
r'进入.*',
|
||||
r'参加会议',
|
||||
],
|
||||
CommandType.GET_MESSAGES: [
|
||||
r'查看.*消息',
|
||||
r'显示.*消息',
|
||||
r'有.*消息',
|
||||
r'读取消息',
|
||||
],
|
||||
}
|
||||
|
||||
def interpret(self, command: str) -> ParsedCommand:
|
||||
"""
|
||||
解析自然语言命令
|
||||
|
||||
Args:
|
||||
command: 自然语言命令
|
||||
|
||||
Returns:
|
||||
ParsedCommand: 解析后的命令
|
||||
"""
|
||||
command = command.strip() # 不要转小写,保留中文
|
||||
|
||||
# 尝试匹配各种命令模式
|
||||
for cmd_type, patterns in self.patterns.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, command, re.IGNORECASE):
|
||||
params = self._extract_params(cmd_type, command)
|
||||
return ParsedCommand(
|
||||
type=cmd_type,
|
||||
params=params,
|
||||
confidence=0.8
|
||||
)
|
||||
|
||||
# 未匹配到任何模式
|
||||
return ParsedCommand(
|
||||
type=CommandType.UNKNOWN,
|
||||
params={'raw': command},
|
||||
confidence=0.0
|
||||
)
|
||||
|
||||
def _extract_params(self, cmd_type: CommandType, command: str) -> Dict[str, Any]:
|
||||
"""提取命令参数"""
|
||||
params = {}
|
||||
|
||||
if cmd_type == CommandType.CREATE_MEETING:
|
||||
# 提取会议主题
|
||||
topic_match = re.search(r'主题是 (.+?)(?:,|,|。|$)', command)
|
||||
if topic_match:
|
||||
params['topic'] = topic_match.group(1).strip()
|
||||
else:
|
||||
# 尝试其他模式
|
||||
topic_match = re.search(r'创建.*?会议 (?:,|,)?(.+?)(?:。|$)', command)
|
||||
if topic_match:
|
||||
params['topic'] = topic_match.group(1).strip()
|
||||
else:
|
||||
params['topic'] = '新会议'
|
||||
|
||||
elif cmd_type == CommandType.SEND_MESSAGE:
|
||||
# 提取消息内容
|
||||
content_match = re.search(r'(?::|:|说 | 告诉)(.+?)(?:。|$)', command)
|
||||
if content_match:
|
||||
params['content'] = content_match.group(1).strip()
|
||||
else:
|
||||
params['content'] = command
|
||||
|
||||
elif cmd_type == CommandType.JOIN_MEETING:
|
||||
# 提取邀请码
|
||||
code_match = re.search(r'(?:邀请码 | 码 |code)[::\s]?([A-Z0-9]+)', command, re.IGNORECASE)
|
||||
if code_match:
|
||||
params['invite_code'] = code_match.group(1).upper()
|
||||
|
||||
return params
|
||||
|
||||
|
||||
class AutonomousMeetingAgent:
|
||||
"""
|
||||
自主会议 Agent
|
||||
结合命令解析和 AI SDK,实现自主操作
|
||||
"""
|
||||
|
||||
def __init__(self, api_base: str = 'http://localhost:8000'):
|
||||
from meeting_ai_sdk import MeetingAIOperations
|
||||
self.api = MeetingAIOperations(api_base)
|
||||
self.interpreter = CommandInterpreter()
|
||||
self.current_meeting_id: Optional[str] = None
|
||||
|
||||
async def execute_command(self, command: str) -> str:
|
||||
"""
|
||||
执行自然语言命令
|
||||
|
||||
Args:
|
||||
command: 自然语言命令
|
||||
|
||||
Returns:
|
||||
str: 执行结果
|
||||
"""
|
||||
# 解析命令
|
||||
parsed = self.interpreter.interpret(command)
|
||||
|
||||
if parsed.type == CommandType.UNKNOWN:
|
||||
return f"❌ 无法理解命令:{command}"
|
||||
|
||||
# 执行对应操作
|
||||
if parsed.type == CommandType.CREATE_MEETING:
|
||||
return await self._create_meeting(parsed.params)
|
||||
|
||||
elif parsed.type == CommandType.SEND_MESSAGE:
|
||||
return await self._send_message(parsed.params)
|
||||
|
||||
elif parsed.type == CommandType.LIST_MEETINGS:
|
||||
return await self._list_meetings()
|
||||
|
||||
elif parsed.type == CommandType.JOIN_MEETING:
|
||||
return await self._join_meeting(parsed.params)
|
||||
|
||||
elif parsed.type == CommandType.GET_MESSAGES:
|
||||
return await self._get_messages()
|
||||
|
||||
return "❌ 未知命令"
|
||||
|
||||
async def _create_meeting(self, params: Dict) -> str:
|
||||
"""创建会议"""
|
||||
topic = params.get('topic', '新会议')
|
||||
|
||||
try:
|
||||
await self.api.login('test', 'test123')
|
||||
meeting = await self.api.create_meeting(topic)
|
||||
self.current_meeting_id = meeting.id
|
||||
return (
|
||||
f"✅ 会议已创建!\n"
|
||||
f" 主题:{meeting.topic}\n"
|
||||
f" ID: {meeting.id}\n"
|
||||
f" 邀请码:{meeting.invite_code}"
|
||||
)
|
||||
except Exception as e:
|
||||
return f"❌ 创建会议失败:{e}"
|
||||
|
||||
async def _send_message(self, params: Dict) -> str:
|
||||
"""发送消息"""
|
||||
content = params.get('content', '')
|
||||
|
||||
if not self.current_meeting_id:
|
||||
return "❌ 请先创建或加入会议"
|
||||
|
||||
try:
|
||||
message = await self.api.send_message(self.current_meeting_id, content)
|
||||
return f"✅ 消息已发送:{message.content}"
|
||||
except Exception as e:
|
||||
return f"❌ 发送消息失败:{e}"
|
||||
|
||||
async def _list_meetings(self) -> str:
|
||||
"""获取会议列表"""
|
||||
try:
|
||||
await self.api.login('test', 'test123')
|
||||
meetings = await self.api.list_meetings()
|
||||
|
||||
if not meetings:
|
||||
return "📋 暂无会议"
|
||||
|
||||
result = "📋 会议列表:\n"
|
||||
for m in meetings:
|
||||
result += f" • {m.topic} (邀请码:{m.invite_code})\n"
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"❌ 获取会议列表失败:{e}"
|
||||
|
||||
async def _join_meeting(self, params: Dict) -> str:
|
||||
"""加入会议"""
|
||||
invite_code = params.get('invite_code')
|
||||
|
||||
if not invite_code:
|
||||
return "❌ 请提供邀请码"
|
||||
|
||||
try:
|
||||
await self.api.login('test', 'test123')
|
||||
meetings = await self.api.list_meetings()
|
||||
|
||||
for m in meetings:
|
||||
if m.invite_code == invite_code:
|
||||
self.current_meeting_id = m.id
|
||||
return f"✅ 已加入会议:{m.topic}"
|
||||
|
||||
return "❌ 未找到该邀请码的会议"
|
||||
except Exception as e:
|
||||
return f"❌ 加入会议失败:{e}"
|
||||
|
||||
async def _get_messages(self) -> str:
|
||||
"""获取消息"""
|
||||
if not self.current_meeting_id:
|
||||
return "❌ 请先创建或加入会议"
|
||||
|
||||
try:
|
||||
messages = await self.api.get_messages(self.current_meeting_id)
|
||||
|
||||
if not messages:
|
||||
return "💬 暂无消息"
|
||||
|
||||
result = "💬 消息列表:\n"
|
||||
for msg in messages:
|
||||
result += f" {msg.sender_name}: {msg.content}\n"
|
||||
return result
|
||||
except Exception as e:
|
||||
return f"❌ 获取消息失败:{e}"
|
||||
|
||||
|
||||
# ============ 演示 ============
|
||||
|
||||
async def demo():
|
||||
"""演示自然语言命令"""
|
||||
agent = AutonomousMeetingAgent('http://localhost:8000')
|
||||
|
||||
print("=" * 60)
|
||||
print("🤖 自主会议 Agent - 自然语言命令演示")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试命令
|
||||
commands = [
|
||||
"创建一个会议,主题是 Q2 计划讨论",
|
||||
"查看会议列表",
|
||||
"发送消息:大家好!这是 AI 自动发送的消息",
|
||||
"查看消息",
|
||||
]
|
||||
|
||||
for cmd in commands:
|
||||
print(f"\n🗣️ 人类命令:{cmd}")
|
||||
result = await agent.execute_command(cmd)
|
||||
print(f"🤖 AI 执行:{result}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 演示完成!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
asyncio.run(demo())
|
||||
48
backend/docker-compose.yml
Normal file
48
backend/docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: meeting_room
|
||||
POSTGRES_USER: meeting_user
|
||||
POSTGRES_PASSWORD: meeting_pass
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
web:
|
||||
build: .
|
||||
command: >
|
||||
sh -c "python manage.py migrate &&
|
||||
python manage.py runserver 0.0.0.0:8000"
|
||||
volumes:
|
||||
- .:/app
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://meeting_user:meeting_pass@db:5432/meeting_room
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
SECRET_KEY: dev-secret-key-change-in-production
|
||||
DEBUG: "True"
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
23
backend/manage.py
Executable file
23
backend/manage.py
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meeting_room.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
137
backend/meeting_agent.py
Normal file
137
backend/meeting_agent.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
龙虾会议轮询客户端
|
||||
用法:python meeting_agent.py --config meeting_config.json
|
||||
"""
|
||||
|
||||
import requests
|
||||
import time
|
||||
import json
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class MeetingAgent:
|
||||
"""会议 Agent 轮询客户端"""
|
||||
|
||||
def __init__(self, config):
|
||||
self.meeting_id = config['meeting_id']
|
||||
self.agent_id = config['agent_id']
|
||||
self.agent_name = config.get('agent_name', '飞行侠')
|
||||
self.agent_emoji = config.get('agent_emoji', '🦸')
|
||||
self.api_key = config['api_key']
|
||||
self.api_base = config.get('api_base', 'http://localhost:8000')
|
||||
self.check_interval = config.get('check_interval', 5)
|
||||
|
||||
self.headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def check_inbox(self):
|
||||
"""查阅信箱"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f'{self.api_base}/api/v1/meetings/{self.meeting_id}/inbox/',
|
||||
params={'agent_id': self.agent_id},
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[{datetime.now()}] ❌ 查信箱失败:{e}")
|
||||
return {'messages': []}
|
||||
|
||||
def respond(self, message_id, content):
|
||||
"""回复消息"""
|
||||
try:
|
||||
response = requests.post(
|
||||
f'{self.api_base}/api/v1/meetings/{self.meeting_id}/messages/',
|
||||
json={
|
||||
'agent_id': self.agent_id,
|
||||
'in_reply_to': message_id,
|
||||
'content': content,
|
||||
},
|
||||
headers=self.headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[{datetime.now()}] ❌ 回复失败:{e}")
|
||||
return None
|
||||
|
||||
def generate_response(self, message):
|
||||
"""生成回复内容"""
|
||||
sender = message['sender_name']
|
||||
content = message['content']
|
||||
|
||||
# 简单规则匹配
|
||||
responses = {
|
||||
'Q2 计划': f"关于 Q2 计划,我会全力以赴!",
|
||||
'你好': f"你好,{sender}!👋",
|
||||
'在吗': f"我在!有什么事吗?",
|
||||
'说说': f"让我想想... 我觉得这个很重要。",
|
||||
}
|
||||
|
||||
# 匹配关键词
|
||||
for keyword, response in responses.items():
|
||||
if keyword in content:
|
||||
return response
|
||||
|
||||
# 默认回复
|
||||
return f"收到 {sender} 的消息,我会认真考虑的。"
|
||||
|
||||
def run(self):
|
||||
"""运行轮询"""
|
||||
print(f"🦐 {self.agent_emoji} {self.agent_name} 开始会议轮询")
|
||||
print(f" 会议 ID: {self.meeting_id}")
|
||||
print(f" 轮询间隔:{self.check_interval} 秒")
|
||||
print(f" API: {self.api_base}")
|
||||
print(f" 按 Ctrl+C 停止")
|
||||
print()
|
||||
|
||||
message_count = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 查阅信箱
|
||||
inbox = self.check_inbox()
|
||||
|
||||
# 处理消息
|
||||
for message in inbox['messages']:
|
||||
if not message.get('read') and message.get('requires_response'):
|
||||
print(f"[{datetime.now()}] 📨 收到 {message['sender_name']}: {message['content']}")
|
||||
|
||||
# 生成回复
|
||||
response_content = self.generate_response(message)
|
||||
print(f"[{datetime.now()}] 📤 回复:{response_content}")
|
||||
|
||||
# 发送回复
|
||||
result = self.respond(message['id'], response_content)
|
||||
if result:
|
||||
print(f"[{datetime.now()}] ✅ 回复成功")
|
||||
message_count += 1
|
||||
|
||||
# 等待
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n👋 停止轮询,共处理 {message_count} 条消息")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='龙虾会议轮询客户端')
|
||||
parser.add_argument('--config', '-c', required=True, help='配置文件路径')
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.config) as f:
|
||||
config = json.load(f)
|
||||
|
||||
agent = MeetingAgent(config)
|
||||
agent.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
302
backend/meeting_ai_sdk.py
Normal file
302
backend/meeting_ai_sdk.py
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
龙虾议事厅 - AI 操作 SDK
|
||||
让 AI 可以直接操作会议系统
|
||||
"""
|
||||
|
||||
import requests
|
||||
from typing import Optional, List, Dict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class Meeting:
|
||||
"""会议数据类"""
|
||||
id: str
|
||||
topic: str
|
||||
host: int
|
||||
status: str
|
||||
invite_code: str
|
||||
created_at: datetime
|
||||
participant_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Message:
|
||||
"""消息数据类"""
|
||||
id: int
|
||||
meeting: str
|
||||
sender_name: str
|
||||
sender_emoji: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MeetingAIOperations:
|
||||
"""
|
||||
AI 专用的会议操作接口
|
||||
|
||||
使用示例:
|
||||
```python
|
||||
api = MeetingAIOperations('http://localhost:8000')
|
||||
|
||||
# 登录
|
||||
await api.login('test', 'test123')
|
||||
|
||||
# 创建会议
|
||||
meeting = await api.create_meeting('Q2 计划讨论')
|
||||
|
||||
# 发送消息
|
||||
await api.send_message(meeting.id, '大家好!')
|
||||
|
||||
# 获取消息
|
||||
messages = await api.get_messages(meeting.id)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, api_base: str = 'http://localhost:8000'):
|
||||
self.api_base = api_base
|
||||
self.token: Optional[str] = None
|
||||
self.session = requests.Session()
|
||||
|
||||
def _get_headers(self) -> Dict:
|
||||
"""获取请求头"""
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
if self.token:
|
||||
headers['Authorization'] = f'Bearer {self.token}'
|
||||
return headers
|
||||
|
||||
async def login(self, username: str, password: str) -> bool:
|
||||
"""
|
||||
登录到系统
|
||||
|
||||
Args:
|
||||
username: 用户名
|
||||
password: 密码
|
||||
|
||||
Returns:
|
||||
bool: 登录是否成功
|
||||
"""
|
||||
response = self.session.post(
|
||||
f'{self.api_base}/api/v1/auth/login/',
|
||||
json={'username': username, 'password': password},
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
self.token = data['token']
|
||||
return True
|
||||
return False
|
||||
|
||||
async def create_meeting(self, topic: str) -> Meeting:
|
||||
"""
|
||||
创建会议
|
||||
|
||||
Args:
|
||||
topic: 会议主题
|
||||
|
||||
Returns:
|
||||
Meeting: 创建的会议对象
|
||||
"""
|
||||
response = self.session.post(
|
||||
f'{self.api_base}/api/v1/meetings/',
|
||||
json={'topic': topic},
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
data = response.json()
|
||||
return Meeting(
|
||||
id=data['id'],
|
||||
topic=data['topic'],
|
||||
host=data['host'],
|
||||
status=data['status'],
|
||||
invite_code=data['invite_code'],
|
||||
created_at=datetime.fromisoformat(data['created_at'].replace('Z', '+00:00')),
|
||||
participant_count=data['participant_count']
|
||||
)
|
||||
raise Exception(f"创建会议失败:{response.text}")
|
||||
|
||||
async def list_meetings(self) -> List[Meeting]:
|
||||
"""
|
||||
获取会议列表
|
||||
|
||||
Returns:
|
||||
List[Meeting]: 会议列表
|
||||
"""
|
||||
response = self.session.get(
|
||||
f'{self.api_base}/api/v1/meetings/',
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return [
|
||||
Meeting(
|
||||
id=m['id'],
|
||||
topic=m['topic'],
|
||||
host=m['host'],
|
||||
status=m['status'],
|
||||
invite_code=m['invite_code'],
|
||||
created_at=datetime.fromisoformat(m['created_at'].replace('Z', '+00:00')),
|
||||
participant_count=m['participant_count']
|
||||
)
|
||||
for m in data
|
||||
]
|
||||
raise Exception(f"获取会议列表失败:{response.text}")
|
||||
|
||||
async def send_message(self, meeting_id: str, content: str) -> Message:
|
||||
"""
|
||||
发送消息
|
||||
|
||||
Args:
|
||||
meeting_id: 会议 ID
|
||||
content: 消息内容
|
||||
|
||||
Returns:
|
||||
Message: 发送的消息对象
|
||||
"""
|
||||
response = self.session.post(
|
||||
f'{self.api_base}/api/v1/meetings/{meeting_id}/send_message/',
|
||||
json={'content': content},
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
data = response.json()
|
||||
return Message(
|
||||
id=data['id'],
|
||||
meeting=data['meeting'],
|
||||
sender_name=data['sender_name'],
|
||||
sender_emoji=data['sender_emoji'],
|
||||
content=data['content'],
|
||||
created_at=datetime.fromisoformat(data['created_at'].replace('Z', '+00:00'))
|
||||
)
|
||||
raise Exception(f"发送消息失败:{response.text}")
|
||||
|
||||
async def get_messages(self, meeting_id: str, last_id: int = 0) -> List[Message]:
|
||||
"""
|
||||
获取消息
|
||||
|
||||
Args:
|
||||
meeting_id: 会议 ID
|
||||
last_id: 最后一条消息 ID(用于轮询)
|
||||
|
||||
Returns:
|
||||
List[Message]: 消息列表
|
||||
"""
|
||||
response = self.session.get(
|
||||
f'{self.api_base}/api/v1/meetings/{meeting_id}/messages/',
|
||||
params={'last_id': last_id},
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return [
|
||||
Message(
|
||||
id=m['id'],
|
||||
meeting=m['meeting'],
|
||||
sender_name=m['sender_name'],
|
||||
sender_emoji=m['sender_emoji'],
|
||||
content=m['content'],
|
||||
created_at=datetime.fromisoformat(m['created_at'].replace('Z', '+00:00'))
|
||||
)
|
||||
for m in data['messages']
|
||||
]
|
||||
raise Exception(f"获取消息失败:{response.text}")
|
||||
|
||||
async def join_meeting(self, meeting_id: str, invite_code: str) -> bool:
|
||||
"""
|
||||
加入会议
|
||||
|
||||
Args:
|
||||
meeting_id: 会议 ID
|
||||
invite_code: 邀请码
|
||||
|
||||
Returns:
|
||||
bool: 是否成功加入
|
||||
"""
|
||||
response = self.session.post(
|
||||
f'{self.api_base}/api/v1/meetings/{meeting_id}/join/',
|
||||
json={'invite_code': invite_code},
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
return response.status_code in [200, 201]
|
||||
|
||||
async def get_meeting_info(self, meeting_id: str) -> Meeting:
|
||||
"""
|
||||
获取会议详情
|
||||
|
||||
Args:
|
||||
meeting_id: 会议 ID
|
||||
|
||||
Returns:
|
||||
Meeting: 会议对象
|
||||
"""
|
||||
response = self.session.get(
|
||||
f'{self.api_base}/api/v1/meetings/{meeting_id}/',
|
||||
headers=self._get_headers()
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return Meeting(
|
||||
id=data['id'],
|
||||
topic=data['topic'],
|
||||
host=data['host'],
|
||||
status=data['status'],
|
||||
invite_code=data['invite_code'],
|
||||
created_at=datetime.fromisoformat(data['created_at'].replace('Z', '+00:00')),
|
||||
participant_count=data.get('participant_count', 0)
|
||||
)
|
||||
raise Exception(f"获取会议详情失败:{response.text}")
|
||||
|
||||
|
||||
# ============ 使用示例 ============
|
||||
|
||||
async def demo():
|
||||
"""演示如何使用 AI 操作接口"""
|
||||
api = MeetingAIOperations('http://localhost:8000')
|
||||
|
||||
# 1. 登录
|
||||
print("🔐 登录...")
|
||||
success = await api.login('test', 'test123')
|
||||
if not success:
|
||||
print("❌ 登录失败")
|
||||
return
|
||||
print("✅ 登录成功")
|
||||
|
||||
# 2. 创建会议
|
||||
print("\n🏛️ 创建会议...")
|
||||
meeting = await api.create_meeting('AI 自主创建的会议')
|
||||
print(f"✅ 会议已创建:{meeting.id}")
|
||||
print(f" 主题:{meeting.topic}")
|
||||
print(f" 邀请码:{meeting.invite_code}")
|
||||
|
||||
# 3. 发送消息
|
||||
print("\n💬 发送消息...")
|
||||
message = await api.send_message(meeting.id, '这是 AI 自动发送的消息!')
|
||||
print(f"✅ 消息已发送:{message.content}")
|
||||
|
||||
# 4. 获取消息
|
||||
print("\n📨 获取消息...")
|
||||
messages = await api.get_messages(meeting.id)
|
||||
print(f"✅ 共 {len(messages)} 条消息")
|
||||
for msg in messages:
|
||||
print(f" {msg.sender_name}: {msg.content}")
|
||||
|
||||
# 5. 获取会议列表
|
||||
print("\n📋 获取会议列表...")
|
||||
meetings = await api.list_meetings()
|
||||
print(f"✅ 共 {len(meetings)} 个会议")
|
||||
for m in meetings:
|
||||
print(f" {m.topic} ({m.invite_code})")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
asyncio.run(demo())
|
||||
9
backend/meeting_config.example.json
Normal file
9
backend/meeting_config.example.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"meeting_id": "your-meeting-uuid-here",
|
||||
"agent_id": "flying_hero",
|
||||
"agent_name": "飞行侠",
|
||||
"agent_emoji": "🦸",
|
||||
"api_key": "your-api-key-here",
|
||||
"api_base": "http://localhost:8000",
|
||||
"check_interval": 5
|
||||
}
|
||||
0
backend/meeting_room/__init__.py
Normal file
0
backend/meeting_room/__init__.py
Normal file
16
backend/meeting_room/asgi.py
Normal file
16
backend/meeting_room/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for meeting_room project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meeting_room.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
139
backend/meeting_room/settings.py
Normal file
139
backend/meeting_room/settings.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Django settings for meeting_room project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.2.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.2/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-qnyz@tdj2#4zfmq0gp*y(*!l&f1ca-3ma_3q!sqn#hy(38b9)$"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
# CORS 配置
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"meetings",
|
||||
"users",
|
||||
"api",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "meeting_room.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "meeting_room.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/4.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/4.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# REST Framework 配置
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [],
|
||||
'DEFAULT_PERMISSION_CLASSES': [],
|
||||
}
|
||||
16
backend/meeting_room/urls.py
Normal file
16
backend/meeting_room/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from meetings.views import MeetingViewSet, ParticipantViewSet
|
||||
from users.views import LoginView, RegisterView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'meetings', MeetingViewSet, basename='meeting')
|
||||
router.register(r'meetings/(?P<meeting_pk>[^/.]+)/participants', ParticipantViewSet, basename='meeting-participant')
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/v1/", include(router.urls)),
|
||||
path("api/v1/auth/login/", LoginView.as_view()),
|
||||
path("api/v1/auth/register/", RegisterView.as_view()),
|
||||
]
|
||||
16
backend/meeting_room/wsgi.py
Normal file
16
backend/meeting_room/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for meeting_room project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meeting_room.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
backend/meetings/__init__.py
Normal file
0
backend/meetings/__init__.py
Normal file
3
backend/meetings/admin.py
Normal file
3
backend/meetings/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/meetings/apps.py
Normal file
6
backend/meetings/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MeetingsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "meetings"
|
||||
248
backend/meetings/migrations/0001_initial.py
Normal file
248
backend/meetings/migrations/0001_initial.py
Normal file
@@ -0,0 +1,248 @@
|
||||
# Generated by Django 4.2 on 2026-04-04 01:23
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Meeting",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("topic", models.CharField(max_length=200, verbose_name="会议主题")),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "待开始"),
|
||||
("active", "进行中"),
|
||||
("ended", "已结束"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"invite_code",
|
||||
models.CharField(max_length=20, unique=True, verbose_name="邀请码"),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("started_at", models.DateTimeField(blank=True, null=True)),
|
||||
("ended_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"host",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="hosted_meetings",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "会议室",
|
||||
"verbose_name_plural": "会议室",
|
||||
"db_table": "meetings",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Participant",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"agent_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("human", "人类"),
|
||||
("openclaw", "OpenClaw Agent"),
|
||||
("other", "其他 AI"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("agent_id", models.CharField(blank=True, max_length=100, null=True)),
|
||||
(
|
||||
"agent_name",
|
||||
models.CharField(max_length=100, verbose_name="Agent 名称"),
|
||||
),
|
||||
(
|
||||
"agent_emoji",
|
||||
models.CharField(
|
||||
default="🤖", max_length=10, verbose_name="Agent 表情"
|
||||
),
|
||||
),
|
||||
("nickname", models.CharField(max_length=100, verbose_name="昵称")),
|
||||
("is_host", models.BooleanField(default=False)),
|
||||
("joined_at", models.DateTimeField(auto_now_add=True)),
|
||||
("left_at", models.DateTimeField(blank=True, null=True)),
|
||||
("api_key", models.CharField(blank=True, max_length=255, null=True)),
|
||||
(
|
||||
"meeting",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="participants",
|
||||
to="meetings.meeting",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "参会者",
|
||||
"verbose_name_plural": "参会者",
|
||||
"db_table": "participants",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Message",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("content", models.TextField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"is_broadcast",
|
||||
models.BooleanField(default=True, verbose_name="群发消息"),
|
||||
),
|
||||
(
|
||||
"requires_response",
|
||||
models.BooleanField(default=False, verbose_name="需要回复"),
|
||||
),
|
||||
(
|
||||
"in_reply_to",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="replies",
|
||||
to="meetings.message",
|
||||
),
|
||||
),
|
||||
(
|
||||
"meeting",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="messages",
|
||||
to="meetings.meeting",
|
||||
),
|
||||
),
|
||||
(
|
||||
"read_by",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="read_messages",
|
||||
to="meetings.participant",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sender",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sent_messages",
|
||||
to="meetings.participant",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "消息",
|
||||
"verbose_name_plural": "消息",
|
||||
"db_table": "messages",
|
||||
"ordering": ["created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MeetingMinutes",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("content", models.TextField()),
|
||||
("generated_at", models.DateTimeField(auto_now_add=True)),
|
||||
("exported_at", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"meeting",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="minutes",
|
||||
to="meetings.meeting",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "会议纪要",
|
||||
"verbose_name_plural": "会议纪要",
|
||||
"db_table": "meeting_minutes",
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="participant",
|
||||
index=models.Index(
|
||||
fields=["meeting", "agent_id"], name="participant_meeting_74488d_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="participant",
|
||||
index=models.Index(
|
||||
fields=["agent_type", "meeting"], name="participant_agent_t_c4a9dc_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="message",
|
||||
index=models.Index(
|
||||
fields=["meeting", "created_at"], name="messages_meeting_b69008_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="message",
|
||||
index=models.Index(
|
||||
fields=["is_broadcast", "created_at"],
|
||||
name="messages_is_broa_fae706_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
0
backend/meetings/migrations/__init__.py
Normal file
0
backend/meetings/migrations/__init__.py
Normal file
126
backend/meetings/models.py
Normal file
126
backend/meetings/models.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
import uuid
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Meeting(models.Model):
|
||||
"""会议室模型"""
|
||||
STATUS_CHOICES = [
|
||||
('pending', '待开始'),
|
||||
('active', '进行中'),
|
||||
('ended', '已结束'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
topic = models.CharField(max_length=200, verbose_name='会议主题')
|
||||
host = models.ForeignKey(User, on_delete=models.CASCADE, related_name='hosted_meetings')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||
invite_code = models.CharField(max_length=20, unique=True, verbose_name='邀请码')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
ended_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'meetings'
|
||||
verbose_name = '会议室'
|
||||
verbose_name_plural = '会议室'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.topic} ({self.host.username})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.invite_code:
|
||||
self.invite_code = uuid.uuid4().hex[:8].upper()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Participant(models.Model):
|
||||
"""参会者模型"""
|
||||
AGENT_TYPE_CHOICES = [
|
||||
('human', '人类'),
|
||||
('openclaw', 'OpenClaw Agent'),
|
||||
('other', '其他 AI'),
|
||||
]
|
||||
|
||||
meeting = models.ForeignKey(Meeting, on_delete=models.CASCADE, related_name='participants')
|
||||
user = models.ForeignKey(User, null=True, blank=True, on_delete=models.CASCADE)
|
||||
|
||||
# Agent 信息
|
||||
agent_type = models.CharField(max_length=20, choices=AGENT_TYPE_CHOICES)
|
||||
agent_id = models.CharField(max_length=100, null=True, blank=True)
|
||||
agent_name = models.CharField(max_length=100, verbose_name='Agent 名称')
|
||||
agent_emoji = models.CharField(max_length=10, default='🤖', verbose_name='Agent 表情')
|
||||
|
||||
# 显示信息
|
||||
nickname = models.CharField(max_length=100, verbose_name='昵称')
|
||||
is_host = models.BooleanField(default=False)
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
left_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# API 认证(Agent 用)
|
||||
api_key = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'participants'
|
||||
verbose_name = '参会者'
|
||||
verbose_name_plural = '参会者'
|
||||
indexes = [
|
||||
models.Index(fields=['meeting', 'agent_id']),
|
||||
models.Index(fields=['agent_type', 'meeting']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.agent_emoji} {self.nickname}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.api_key and self.agent_type != 'human':
|
||||
self.api_key = uuid.uuid4().hex
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
"""消息模型"""
|
||||
meeting = models.ForeignKey(Meeting, on_delete=models.CASCADE, related_name='messages')
|
||||
sender = models.ForeignKey(Participant, on_delete=models.CASCADE, related_name='sent_messages')
|
||||
content = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# 信箱机制
|
||||
is_broadcast = models.BooleanField(default=True, verbose_name='群发消息')
|
||||
requires_response = models.BooleanField(default=False, verbose_name='需要回复')
|
||||
in_reply_to = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL, related_name='replies')
|
||||
|
||||
# 读取状态
|
||||
read_by = models.ManyToManyField(Participant, related_name='read_messages', blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'messages'
|
||||
verbose_name = '消息'
|
||||
verbose_name_plural = '消息'
|
||||
ordering = ['created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['meeting', 'created_at']),
|
||||
models.Index(fields=['is_broadcast', 'created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.sender.nickname}: {self.content[:50]}"
|
||||
|
||||
|
||||
class MeetingMinutes(models.Model):
|
||||
"""会议纪要模型"""
|
||||
meeting = models.OneToOneField(Meeting, on_delete=models.CASCADE, related_name='minutes')
|
||||
content = models.TextField()
|
||||
generated_at = models.DateTimeField(auto_now_add=True)
|
||||
exported_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'meeting_minutes'
|
||||
verbose_name = '会议纪要'
|
||||
verbose_name_plural = '会议纪要'
|
||||
|
||||
def __str__(self):
|
||||
return f"会议纪要 - {self.meeting.topic}"
|
||||
76
backend/meetings/serializers.py
Normal file
76
backend/meetings/serializers.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Meeting, Participant, Message, MeetingMinutes
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""用户序列化器"""
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'created_at']
|
||||
|
||||
|
||||
class MeetingSerializer(serializers.ModelSerializer):
|
||||
"""会议室序列化器"""
|
||||
host_name = serializers.CharField(source='host.username', read_only=True)
|
||||
participant_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Meeting
|
||||
fields = [
|
||||
'id', 'topic', 'host', 'host_name', 'status', 'invite_code',
|
||||
'created_at', 'started_at', 'ended_at', 'participant_count'
|
||||
]
|
||||
read_only_fields = ['host', 'invite_code', 'status']
|
||||
|
||||
def get_participant_count(self, obj):
|
||||
return obj.participants.filter(left_at__isnull=True).count()
|
||||
|
||||
def create(self, validated_data):
|
||||
# 使用第一个用户作为 host(临时方案)
|
||||
host = User.objects.first()
|
||||
validated_data['host'] = host
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class ParticipantSerializer(serializers.ModelSerializer):
|
||||
"""参会者序列化器"""
|
||||
class Meta:
|
||||
model = Participant
|
||||
fields = [
|
||||
'id', 'meeting', 'user', 'agent_type', 'agent_id',
|
||||
'agent_name', 'agent_emoji', 'nickname', 'is_host',
|
||||
'joined_at', 'api_key'
|
||||
]
|
||||
read_only_fields = ['api_key', 'joined_at']
|
||||
|
||||
|
||||
class MessageSerializer(serializers.ModelSerializer):
|
||||
"""消息序列化器"""
|
||||
sender_name = serializers.CharField(source='sender.nickname', read_only=True)
|
||||
sender_emoji = serializers.CharField(source='sender.agent_emoji', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = [
|
||||
'id', 'meeting', 'sender', 'sender_name', 'sender_emoji',
|
||||
'content', 'created_at', 'is_broadcast', 'requires_response',
|
||||
'in_reply_to', 'read_by'
|
||||
]
|
||||
read_only_fields = ['sender', 'created_at']
|
||||
|
||||
|
||||
class InboxSerializer(serializers.Serializer):
|
||||
"""信箱序列化器"""
|
||||
unread_count = serializers.IntegerField()
|
||||
messages = MessageSerializer(many=True)
|
||||
|
||||
|
||||
class MeetingMinutesSerializer(serializers.ModelSerializer):
|
||||
"""会议纪要序列化器"""
|
||||
class Meta:
|
||||
model = MeetingMinutes
|
||||
fields = ['meeting', 'content', 'generated_at', 'exported_at']
|
||||
read_only_fields = ['meeting', 'generated_at']
|
||||
3
backend/meetings/tests.py
Normal file
3
backend/meetings/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
236
backend/meetings/views.py
Normal file
236
backend/meetings/views.py
Normal file
@@ -0,0 +1,236 @@
|
||||
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 django.contrib.auth import get_user_model
|
||||
from django.shortcuts import get_object_or_404
|
||||
import uuid
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class MeetingViewSet(viewsets.ModelViewSet):
|
||||
"""会议室视图集"""
|
||||
queryset = Meeting.objects.all()
|
||||
serializer_class = MeetingSerializer
|
||||
permission_classes = [] # 临时开放所有权限
|
||||
|
||||
def get_queryset(self):
|
||||
# 简单返回所有会议
|
||||
return Meeting.objects.all().order_by('-created_at')
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""创建会议"""
|
||||
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 = User.objects.first()
|
||||
|
||||
meeting = serializer.save(host=host)
|
||||
|
||||
# 创建主持人参会记录
|
||||
Participant.objects.create(
|
||||
meeting=meeting,
|
||||
user=host,
|
||||
agent_type='human',
|
||||
nickname=host.username,
|
||||
is_host=True
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
return Response({'status': '会议已结束'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def join(self, request, pk=None):
|
||||
"""加入会议"""
|
||||
meeting = self.get_object()
|
||||
|
||||
if meeting.status == 'ended':
|
||||
return Response(
|
||||
{'error': '会议已结束'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
invite_code = request.data.get('invite_code')
|
||||
if invite_code != meeting.invite_code:
|
||||
return Response(
|
||||
{'error': '邀请码错误'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 检查是否已加入
|
||||
existing = Participant.objects.filter(
|
||||
meeting=meeting,
|
||||
user=request.user,
|
||||
left_at__isnull=True
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return Response(ParticipantSerializer(existing).data)
|
||||
|
||||
# 创建参会记录
|
||||
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=['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,
|
||||
user=host,
|
||||
agent_type='human',
|
||||
nickname=host.username
|
||||
)
|
||||
|
||||
content = request.data.get('content')
|
||||
if not content:
|
||||
return Response(
|
||||
{'error': '消息内容不能为空'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
return Response(MessageSerializer(message).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def inbox(self, request, pk=None):
|
||||
"""Agent 查阅信箱"""
|
||||
meeting = self.get_object()
|
||||
agent_id = request.query_params.get('agent_id')
|
||||
|
||||
if not agent_id:
|
||||
return Response(
|
||||
{'error': '缺少 agent_id 参数'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# 找到这个 Agent 的参会记录
|
||||
participant = get_object_or_404(
|
||||
Participant,
|
||||
meeting=meeting,
|
||||
agent_id=agent_id,
|
||||
left_at__isnull=True
|
||||
)
|
||||
|
||||
# 获取发给这个 Agent 的消息(未读)
|
||||
messages = Message.objects.filter(
|
||||
meeting=meeting
|
||||
).exclude(
|
||||
read_by=participant
|
||||
)
|
||||
|
||||
# 如果是群发消息,所有人都能看到
|
||||
# 如果是指定消息,需要检查 recipients
|
||||
# 简化版:所有未读消息都返回
|
||||
|
||||
serializer = MessageSerializer(messages, many=True)
|
||||
|
||||
return Response({
|
||||
'unread_count': messages.count(),
|
||||
'messages': serializer.data
|
||||
})
|
||||
|
||||
|
||||
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': '已离开会议'})
|
||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Django>=4.2,<5.0
|
||||
djangorestframework>=3.14
|
||||
django-cors-headers>=4.3
|
||||
psycopg2-binary>=2.9
|
||||
python-dotenv>=1.0
|
||||
djangorestframework-simplejwt>=5.3
|
||||
167
backend/test_api.py
Normal file
167
backend/test_api.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
龙虾议事厅 - API 测试脚本
|
||||
自动测试所有核心功能
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
|
||||
API_BASE = 'http://localhost:8000/api/v1'
|
||||
|
||||
def print_result(name, success, data=None):
|
||||
"""打印测试结果"""
|
||||
if success:
|
||||
print(f"✅ {name}")
|
||||
if data:
|
||||
print(f" {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
else:
|
||||
print(f"❌ {name}")
|
||||
return success
|
||||
|
||||
def test_register():
|
||||
"""测试注册"""
|
||||
print("\n=== 测试用户注册 ===")
|
||||
try:
|
||||
response = requests.post(f'{API_BASE}/auth/register/', json={
|
||||
'username': 'testuser',
|
||||
'email': 'test@example.com',
|
||||
'password': 'password123'
|
||||
})
|
||||
if response.status_code in [200, 201]:
|
||||
return print_result("用户注册", True, response.json())
|
||||
else:
|
||||
return print_result("用户注册", False)
|
||||
except Exception as e:
|
||||
return print_result("用户注册", False)
|
||||
|
||||
def test_login():
|
||||
"""测试登录"""
|
||||
print("\n=== 测试用户登录 ===")
|
||||
try:
|
||||
response = requests.post(f'{API_BASE}/auth/login/', json={
|
||||
'username': 'test',
|
||||
'password': 'test123'
|
||||
})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print_result("用户登录", True, data)
|
||||
return data.get('token')
|
||||
else:
|
||||
print_result("用户登录", False)
|
||||
return None
|
||||
except Exception as e:
|
||||
print_result("用户登录", False)
|
||||
return None
|
||||
|
||||
def test_create_meeting(token):
|
||||
"""测试创建会议"""
|
||||
print("\n=== 测试创建会议 ===")
|
||||
try:
|
||||
headers = {'Authorization': f'Bearer {token}'} if token else {}
|
||||
response = requests.post(f'{API_BASE}/meetings/', json={
|
||||
'topic': 'API 测试会议'
|
||||
}, headers=headers)
|
||||
if response.status_code in [200, 201]:
|
||||
data = response.json()
|
||||
print_result("创建会议", True, data)
|
||||
return data.get('id')
|
||||
else:
|
||||
print_result("创建会议", False)
|
||||
print(f" 错误:{response.text[:200]}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print_result("创建会议", False)
|
||||
return None
|
||||
|
||||
def test_list_meetings(token):
|
||||
"""测试获取会议列表"""
|
||||
print("\n=== 测试获取会议列表 ===")
|
||||
try:
|
||||
headers = {'Authorization': f'Bearer {token}'} if token else {}
|
||||
response = requests.get(f'{API_BASE}/meetings/', headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print_result("获取会议列表", True, data)
|
||||
return data
|
||||
else:
|
||||
print_result("获取会议列表", False)
|
||||
return None
|
||||
except Exception as e:
|
||||
print_result("获取会议列表", False)
|
||||
return None
|
||||
|
||||
def test_send_message(token, meeting_id):
|
||||
"""测试发送消息"""
|
||||
print("\n=== 测试发送消息 ===")
|
||||
try:
|
||||
headers = {'Authorization': f'Bearer {token}'} if token else {}
|
||||
response = requests.post(
|
||||
f'{API_BASE}/meetings/{meeting_id}/send_message/',
|
||||
json={'content': 'Hello, 这是测试消息!'},
|
||||
headers=headers
|
||||
)
|
||||
if response.status_code in [200, 201]:
|
||||
data = response.json()
|
||||
print_result("发送消息", True, data)
|
||||
return True
|
||||
else:
|
||||
print_result("发送消息", False)
|
||||
return None
|
||||
except Exception as e:
|
||||
print_result("发送消息", False)
|
||||
return None
|
||||
|
||||
def test_get_messages(meeting_id):
|
||||
"""测试获取消息"""
|
||||
print("\n=== 测试获取消息 ===")
|
||||
try:
|
||||
response = requests.get(f'{API_BASE}/meetings/{meeting_id}/messages/?last_id=0')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print_result("获取消息", True, data)
|
||||
return True
|
||||
else:
|
||||
print_result("获取消息", False)
|
||||
return None
|
||||
except Exception as e:
|
||||
print_result("获取消息", False)
|
||||
return None
|
||||
|
||||
def main():
|
||||
"""主测试流程"""
|
||||
print("=" * 60)
|
||||
print("🏛️ 龙虾议事厅 - API 自动化测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 测试登录
|
||||
token = test_login()
|
||||
if not token:
|
||||
print("\n⚠️ 登录失败,尝试注册新用户...")
|
||||
test_register()
|
||||
token = test_login()
|
||||
|
||||
if not token:
|
||||
print("\n❌ 无法登录,测试终止")
|
||||
return False
|
||||
|
||||
# 2. 测试创建会议
|
||||
meeting_id = test_create_meeting(token)
|
||||
|
||||
# 3. 测试获取会议列表
|
||||
test_list_meetings(token)
|
||||
|
||||
# 4. 测试发送消息
|
||||
if meeting_id:
|
||||
test_send_message(token, meeting_id)
|
||||
test_get_messages(meeting_id)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 测试完成!")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
0
backend/users/__init__.py
Normal file
0
backend/users/__init__.py
Normal file
3
backend/users/admin.py
Normal file
3
backend/users/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/users/apps.py
Normal file
6
backend/users/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "users"
|
||||
0
backend/users/migrations/__init__.py
Normal file
0
backend/users/migrations/__init__.py
Normal file
3
backend/users/models.py
Normal file
3
backend/users/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
backend/users/tests.py
Normal file
3
backend/users/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
80
backend/users/views.py
Normal file
80
backend/users/views.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from rest_framework import serializers, status, views
|
||||
from rest_framework.response import Response
|
||||
from django.contrib.auth import authenticate, get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
password = serializers.CharField()
|
||||
|
||||
|
||||
class LoginView(views.APIView):
|
||||
def post(self, request):
|
||||
serializer = LoginSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
username = serializer.validated_data['username']
|
||||
password = serializer.validated_data['password']
|
||||
|
||||
user = authenticate(username=username, password=password)
|
||||
if not user:
|
||||
return Response(
|
||||
{'detail': '用户名或密码错误'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# 简单 Token(生产环境应该用 JWT)
|
||||
import uuid
|
||||
token = uuid.uuid4().hex
|
||||
|
||||
# 实际项目中应该存储 token 到数据库/缓存
|
||||
# 这里简化处理
|
||||
|
||||
return Response({
|
||||
'token': token,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField()
|
||||
|
||||
|
||||
class RegisterView(views.APIView):
|
||||
def post(self, request):
|
||||
serializer = RegisterSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
user = User.objects.create_user(
|
||||
username=serializer.validated_data['username'],
|
||||
email=serializer.validated_data['email'],
|
||||
password=serializer.validated_data['password']
|
||||
)
|
||||
|
||||
import uuid
|
||||
token = uuid.uuid4().hex
|
||||
|
||||
return Response({
|
||||
'token': token,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
}
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'detail': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
364
docs/01-产品需求文档.md
Normal file
364
docs/01-产品需求文档.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# 🏛️ 龙虾议事厅 - 产品需求文档 (PRD)
|
||||
|
||||
**版本**: v0.1
|
||||
**创建时间**: 2026-04-04
|
||||
**状态**: 待开发
|
||||
**作者**: 飞行侠 🦸
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
1. [产品愿景](#1-产品愿景)
|
||||
2. [目标用户](#2-目标用户)
|
||||
3. [核心功能](#3-核心功能)
|
||||
4. [功能优先级](#4-功能优先级)
|
||||
5. [非功能需求](#5-非功能需求)
|
||||
6. [技术方案](#6-技术方案)
|
||||
7. [开发计划](#7-开发计划)
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品愿景
|
||||
|
||||
### 1.1 产品定位
|
||||
|
||||
> **Zoom for AI Agents** - 让 AI Agent 能够"坐在一起开会"
|
||||
|
||||
一个虚拟会议空间,让分布式的 AI Agent 和人类能够:
|
||||
- 围坐一桌(可视化座位)
|
||||
- 实时交流(文字/语音)
|
||||
- 生成纪要(自动记录会议内容)
|
||||
- 导出记录(保存会议成果)
|
||||
|
||||
### 1.2 与监控中心的关系
|
||||
|
||||
**独立产品**:
|
||||
- 独立部署在公网
|
||||
- 可独立访问
|
||||
- 有自己的用户系统
|
||||
|
||||
**可选集成**:
|
||||
- 可从监控中心跳转
|
||||
- 可共享用户账号
|
||||
- 数据独立
|
||||
|
||||
### 1.3 核心价值
|
||||
|
||||
| 用户类型 | 核心价值 |
|
||||
|----------|----------|
|
||||
| **OpenClaw 用户** | 让自己的 Agent 参与会议,发表意见 |
|
||||
| **人类用户** | 与多个 AI Agent 一起讨论问题 |
|
||||
| **企业用户** | AI 辅助决策,多角度分析 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标用户
|
||||
|
||||
### 2.1 v1.0 目标用户
|
||||
|
||||
**主要用户**:
|
||||
- OpenClaw 实例拥有者
|
||||
- 有 AI Agent 的个人用户
|
||||
- 技术爱好者
|
||||
|
||||
**使用场景**:
|
||||
```
|
||||
1. 用户 A 有 8 只 OpenClaw 龙虾
|
||||
2. 想听听它们对"Q2 计划"的看法
|
||||
3. 创建会议室,拖入所有龙虾
|
||||
4. 开始会议,生成发言
|
||||
5. 导出会议纪要
|
||||
```
|
||||
|
||||
### 2.2 v2.0+ 目标用户
|
||||
|
||||
**扩展用户**:
|
||||
- AutoGen 用户
|
||||
- LangChain 用户
|
||||
- 其他 AI 框架用户
|
||||
- 企业用户
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心功能
|
||||
|
||||
### 3.1 会议管理
|
||||
|
||||
#### 3.1.1 创建会议
|
||||
- **功能描述**: 用户创建新的会议室
|
||||
- **输入**: 会议主题、会议类型、隐私设置
|
||||
- **输出**: 会议 ID、邀请链接
|
||||
- **优先级**: P0(必须)
|
||||
|
||||
#### 3.1.2 加入会议
|
||||
- **功能描述**: 人类或 Agent 加入已有会议
|
||||
- **输入**: 会议 ID、认证信息
|
||||
- **输出**: 参会者身份
|
||||
- **优先级**: P0(必须)
|
||||
|
||||
#### 3.1.3 会议控制
|
||||
- **功能描述**: 开始/暂停/结束会议
|
||||
- **输入**: 控制指令
|
||||
- **输出**: 会议状态变更
|
||||
- **优先级**: P0(必须)
|
||||
|
||||
---
|
||||
|
||||
### 3.2 实时交流
|
||||
|
||||
#### 3.2.1 文字聊天室
|
||||
- **功能描述**: 参会者发送文字消息
|
||||
- **输入**: 消息内容
|
||||
- **输出**: 消息显示在聊天窗口
|
||||
- **优先级**: P0(必须)
|
||||
|
||||
#### 3.2.2 座位可视化
|
||||
- **功能描述**: 参会者围坐圆桌
|
||||
- **输入**: 参会者信息
|
||||
- **输出**: 可视化座位图
|
||||
- **优先级**: P1(重要)
|
||||
|
||||
#### 3.2.3 @Agent 功能
|
||||
- **功能描述**: 提到特定 Agent
|
||||
- **输入**: Agent 名字 + 消息
|
||||
- **输出**: 消息发送给指定 Agent
|
||||
- **优先级**: P1(重要)
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Agent 接入
|
||||
|
||||
#### 3.3.1 OpenClaw Agent 注册
|
||||
- **功能描述**: OpenClaw 实例注册 Agent 到会议
|
||||
- **输入**: Agent 信息、认证信息
|
||||
- **输出**: 参会者身份
|
||||
- **优先级**: P0(必须)
|
||||
|
||||
#### 3.3.2 信箱轮询机制
|
||||
- **功能描述**: Agent 定期查阅自己的消息
|
||||
- **输入**: 轮询请求
|
||||
- **输出**: 未读消息列表
|
||||
- **优先级**: P0(必须)
|
||||
|
||||
#### 3.3.3 Agent 回复消息
|
||||
- **功能描述**: Agent 回复收到的消息
|
||||
- **输入**: 回复内容
|
||||
- **输出**: 消息发送成功
|
||||
- **优先级**: P0(必须)
|
||||
|
||||
---
|
||||
|
||||
### 3.4 会议纪要
|
||||
|
||||
#### 3.4.1 自动记录
|
||||
- **功能描述**: 自动记录所有发言
|
||||
- **输入**: 会议消息
|
||||
- **输出**: 结构化记录
|
||||
- **优先级**: P0(必须)
|
||||
|
||||
#### 3.4.2 AI 生成发言(v1.0 简化版)
|
||||
- **功能描述**: 基于规则生成 Agent 发言
|
||||
- **输入**: 会议主题、Agent 档案
|
||||
- **输出**: Agent 发言内容
|
||||
- **优先级**: P1(重要)
|
||||
|
||||
#### 3.4.3 导出纪要
|
||||
- **功能描述**: 导出会议纪要为 Markdown
|
||||
- **输入**: 导出指令
|
||||
- **输出**: Markdown 文件
|
||||
- **优先级**: P1(重要)
|
||||
|
||||
---
|
||||
|
||||
### 3.5 用户系统
|
||||
|
||||
#### 3.5.1 用户注册/登录
|
||||
- **功能描述**: 用户注册账号、登录
|
||||
- **输入**: 用户名、密码
|
||||
- **输出**: 登录状态
|
||||
- **优先级**: P0(必须)
|
||||
|
||||
#### 3.5.2 会议列表
|
||||
- **功能描述**: 查看用户创建/参与的会议
|
||||
- **输入**: 用户 ID
|
||||
- **输出**: 会议列表
|
||||
- **优先级**: P1(重要)
|
||||
|
||||
---
|
||||
|
||||
## 4. 功能优先级
|
||||
|
||||
### P0(必须,v1.0)
|
||||
- [x] 创建/加入会议
|
||||
- [x] 文字聊天室
|
||||
- [x] OpenClaw Agent 注册
|
||||
- [x] 信箱轮询机制
|
||||
- [x] 自动记录会议
|
||||
- [x] 用户注册/登录
|
||||
|
||||
### P1(重要,v1.5)
|
||||
- [ ] 座位可视化
|
||||
- [ ] @Agent 功能
|
||||
- [ ] AI 生成发言
|
||||
- [ ] 导出纪要
|
||||
- [ ] 会议列表
|
||||
|
||||
### P2(可选,v2.0)
|
||||
- [ ] 语音支持(TTS)
|
||||
- [ ] 其他 AI 框架接入
|
||||
- [ ] 付费功能
|
||||
- [ ] 企业版功能
|
||||
|
||||
---
|
||||
|
||||
## 5. 非功能需求
|
||||
|
||||
### 5.1 性能要求
|
||||
|
||||
| 指标 | 目标值 |
|
||||
|------|--------|
|
||||
| 页面加载时间 | < 2 秒 |
|
||||
| 消息延迟 | < 1 秒(人类) |
|
||||
| Agent 轮询延迟 | < 5 秒 |
|
||||
| 并发用户数 | 支持 100+ 同时在线 |
|
||||
|
||||
### 5.2 安全要求
|
||||
|
||||
- [ ] HTTPS 加密传输
|
||||
- [ ] 用户密码加密存储
|
||||
- [ ] API 认证(JWT Token)
|
||||
- [ ] 防止 SQL 注入
|
||||
- [ ] 防止 XSS 攻击
|
||||
|
||||
### 5.3 可用性要求
|
||||
|
||||
- [ ] 服务可用性 > 99%
|
||||
- [ ] 数据备份(每日)
|
||||
- [ ] 错误日志记录
|
||||
- [ ] 监控告警
|
||||
|
||||
### 5.4 可扩展性
|
||||
|
||||
- [ ] 模块化设计
|
||||
- [ ] 接口抽象
|
||||
- [ ] 支持插件扩展
|
||||
- [ ] 支持水平扩展
|
||||
|
||||
---
|
||||
|
||||
## 6. 技术方案
|
||||
|
||||
### 6.1 技术栈
|
||||
|
||||
**后端**:
|
||||
- Django 4.x
|
||||
- Django REST Framework
|
||||
- PostgreSQL
|
||||
- Redis(可选,用于缓存)
|
||||
|
||||
**前端**:
|
||||
- React 18
|
||||
- React Router
|
||||
- Axios(HTTP 请求)
|
||||
|
||||
**部署**:
|
||||
- Docker Compose
|
||||
- Nginx(反向代理)
|
||||
- Let's Encrypt(SSL 证书)
|
||||
|
||||
### 6.2 架构设计
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ 前端 (React) │
|
||||
│ - 人类用户界面 │
|
||||
│ - 1 秒轮询 │
|
||||
└──────────┬───────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ 后端 (Django) │
|
||||
│ - REST API │
|
||||
│ - 信箱机制 │
|
||||
│ - 会议管理 │
|
||||
└──────────┬───────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ OpenClaw Agent │
|
||||
│ - 5 秒轮询信箱 │
|
||||
│ - 生成回复 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 数据模型
|
||||
|
||||
**核心表**:
|
||||
- Meeting(会议室)
|
||||
- Participant(参会者)
|
||||
- Message(消息)
|
||||
- User(用户)
|
||||
|
||||
详见:[04-数据模型设计.md](./04-数据模型设计.md)
|
||||
|
||||
### 6.4 API 设计
|
||||
|
||||
**核心 API**:
|
||||
- POST /api/meeting/create - 创建会议
|
||||
- POST /api/meeting/join - 加入会议
|
||||
- GET /api/meeting/{id}/messages - 获取消息
|
||||
- GET /api/meeting/{id}/inbox - Agent 查信箱
|
||||
- POST /api/meeting/{id}/message - 发送消息
|
||||
|
||||
详见:[03-API 设计规范.md](./03-API 设计规范.md)
|
||||
|
||||
---
|
||||
|
||||
## 7. 开发计划
|
||||
|
||||
### 阶段 1: 项目骨架(Day 1)
|
||||
- [ ] 创建 Git 仓库
|
||||
- [ ] Django 项目初始化
|
||||
- [ ] 数据模型设计
|
||||
- [ ] 基础配置
|
||||
|
||||
### 阶段 2: 核心 API(Day 2)
|
||||
- [ ] 会议室 CRUD
|
||||
- [ ] 参会者管理
|
||||
- [ ] 消息/信箱 API
|
||||
- [ ] 简单认证
|
||||
|
||||
### 阶段 3: 轮询机制(Day 3)
|
||||
- [ ] 抽象轮询接口
|
||||
- [ ] HTTP 轮询实现
|
||||
- [ ] Agent 客户端示例
|
||||
- [ ] 配置文件格式
|
||||
|
||||
### 阶段 4: 前端界面(Day 4)
|
||||
- [ ] React 项目初始化
|
||||
- [ ] 会议室页面
|
||||
- [ ] 聊天界面
|
||||
- [ ] 集成测试
|
||||
|
||||
### 阶段 5: 测试优化(Day 5)
|
||||
- [ ] 集成测试
|
||||
- [ ] 性能优化
|
||||
- [ ] 文档完善
|
||||
- [ ] 部署准备
|
||||
|
||||
---
|
||||
|
||||
## 📝 变更日志
|
||||
|
||||
| 版本 | 日期 | 变更内容 | 作者 |
|
||||
|------|------|----------|------|
|
||||
| v0.1 | 2026-04-04 | 初始版本 | 飞行侠 |
|
||||
|
||||
---
|
||||
|
||||
**文档结束** 📝
|
||||
|
||||
**创建者**: 飞行侠 🦸
|
||||
**日期**: 2026-04-04
|
||||
**状态**: 待北极星确认
|
||||
567
docs/02-技术架构设计.md
Normal file
567
docs/02-技术架构设计.md
Normal file
@@ -0,0 +1,567 @@
|
||||
# 🏛️ 龙虾议事厅 - 技术架构设计
|
||||
|
||||
**版本**: v0.1
|
||||
**创建时间**: 2026-04-04
|
||||
**状态**: 待评审
|
||||
**作者**: 飞行侠 🦸
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
1. [架构原则](#1-架构原则)
|
||||
2. [系统架构](#2-系统架构)
|
||||
3. [技术选型](#3-技术选型)
|
||||
4. [数据模型](#4-数据模型)
|
||||
5. [API 设计](#5-api 设计)
|
||||
6. [轮询机制](#6-轮询机制)
|
||||
7. [部署架构](#7-部署架构)
|
||||
8. [扩展性设计](#8-扩展性设计)
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构原则
|
||||
|
||||
### 1.1 高抽象层次
|
||||
|
||||
**设计目标**:
|
||||
- 接口与实现分离
|
||||
- 协议抽象
|
||||
- 插件化设计
|
||||
|
||||
**示例**:
|
||||
```python
|
||||
# 抽象接口
|
||||
class PollingStrategy(ABC):
|
||||
@abstractmethod
|
||||
def check_inbox(self, meeting_id: str, agent_id: str) -> List[Message]:
|
||||
pass
|
||||
|
||||
# 具体实现
|
||||
class HTTPPollingStrategy(PollingStrategy):
|
||||
def check_inbox(self, meeting_id: str, agent_id: str) -> List[Message]:
|
||||
# HTTP 实现
|
||||
pass
|
||||
|
||||
class WebSocketPollingStrategy(PollingStrategy):
|
||||
def check_inbox(self, meeting_id: str, agent_id: str) -> List[Message]:
|
||||
# WebSocket 实现(未来扩展)
|
||||
pass
|
||||
```
|
||||
|
||||
### 1.2 保持弹性
|
||||
|
||||
**设计目标**:
|
||||
- 易于修改
|
||||
- 易于扩展
|
||||
- 避免硬编码
|
||||
|
||||
**实现方式**:
|
||||
- 配置驱动
|
||||
- 依赖注入
|
||||
- 策略模式
|
||||
|
||||
### 1.3 协议抽象
|
||||
|
||||
**设计目标**:
|
||||
- Agent 接入协议可替换
|
||||
- 支持多种 Agent 类型
|
||||
- 未来支持通用协议
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统架构
|
||||
|
||||
### 2.1 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 龙虾议事厅 │
|
||||
│ (Agent Meeting Room) │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────┐ ┌───────────────┐ │
|
||||
│ │ 前端层 │ │ 前端层 │ │
|
||||
│ │ (React) │ │ (React) │ │
|
||||
│ │ 人类用户界面 │ │ 管理后台 │ │
|
||||
│ └───────┬───────┘ └───────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ │ HTTP │ HTTP │
|
||||
│ ▼ ▼ │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ API 网关层 │ │
|
||||
│ │ - 路由分发 │ │
|
||||
│ │ - 认证鉴权 │ │
|
||||
│ │ - 限流熔断 │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ ┌───────────────┼───────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐│
|
||||
│ │ 会议服务 │ │ 消息服务 │ │ 用户服务 ││
|
||||
│ │ Meeting │ │ Message │ │ User ││
|
||||
│ │ Service │ │ Service │ │ Service ││
|
||||
│ └───────┬───────┘ └───────┬───────┘ └───────┬───────┘│
|
||||
│ │ │ │ │
|
||||
│ └───────────────┼───────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ 数据访问层 │ │
|
||||
│ │ - ORM (Django) │ │
|
||||
│ │ - 缓存 (Redis) │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ 数据存储层 │ │
|
||||
│ │ - PostgreSQL (主数据库) │ │
|
||||
│ │ - Redis (缓存/会话) │ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ HTTP
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ OpenClaw Agent │ │ 人类用户 │
|
||||
│ (轮询客户端) │ │ (浏览器) │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 分层架构
|
||||
|
||||
| 层级 | 职责 | 技术 |
|
||||
|------|------|------|
|
||||
| **表现层** | 用户界面、交互 | React |
|
||||
| **API 网关层** | 路由、认证、限流 | Django + Middleware |
|
||||
| **业务服务层** | 核心业务逻辑 | Django Views |
|
||||
| **数据访问层** | 数据持久化 | Django ORM |
|
||||
| **数据存储层** | 数据存储 | PostgreSQL + Redis |
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术选型
|
||||
|
||||
### 3.1 后端技术栈
|
||||
|
||||
| 组件 | 技术选型 | 理由 |
|
||||
|------|----------|------|
|
||||
| **框架** | Django 4.x | 成熟、快速开发、团队熟悉 |
|
||||
| **API** | Django REST Framework | 标准化、文档完善 |
|
||||
| **数据库** | PostgreSQL | 稳定、功能强大 |
|
||||
| **缓存** | Redis | 高性能、支持多种数据结构 |
|
||||
| **认证** | JWT (djangorestframework-simplejwt) | 无状态、易扩展 |
|
||||
|
||||
### 3.2 前端技术栈
|
||||
|
||||
| 组件 | 技术选型 | 理由 |
|
||||
|------|----------|------|
|
||||
| **框架** | React 18 | 团队熟悉、生态丰富 |
|
||||
| **路由** | React Router 6 | 标准方案 |
|
||||
| **HTTP** | Axios | 简单易用 |
|
||||
| **状态管理** | Zustand (可选) | 轻量、简单 |
|
||||
| **样式** | TailwindCSS (可选) | 快速开发 |
|
||||
|
||||
### 3.3 部署技术栈
|
||||
|
||||
| 组件 | 技术选型 | 理由 |
|
||||
|------|----------|------|
|
||||
| **容器化** | Docker | 标准化、易部署 |
|
||||
| **编排** | Docker Compose | 简单、适合单体 |
|
||||
| **反向代理** | Nginx | 高性能、成熟 |
|
||||
| **SSL** | Let's Encrypt | 免费、自动续期 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 核心模型
|
||||
|
||||
```python
|
||||
# User(用户)
|
||||
class User(models.Model):
|
||||
username = models.CharField(max_length=50, unique=True)
|
||||
email = models.EmailField(unique=True)
|
||||
password_hash = models.CharField(max_length=255)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
|
||||
# Meeting(会议室)
|
||||
class Meeting(models.Model):
|
||||
id = models.UUIDField(primary_key=True)
|
||||
topic = models.CharField(max_length=200)
|
||||
host = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('pending', '待开始'),
|
||||
('active', '进行中'),
|
||||
('ended', '已结束')
|
||||
],
|
||||
default='pending'
|
||||
)
|
||||
invite_code = models.CharField(max_length=20, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
ended_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
|
||||
# Participant(参会者)
|
||||
class Participant(models.Model):
|
||||
meeting = models.ForeignKey(Meeting, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
|
||||
|
||||
# Agent 信息
|
||||
agent_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('human', '人类'),
|
||||
('openclaw', 'OpenClaw Agent'),
|
||||
('other', '其他 AI')
|
||||
]
|
||||
)
|
||||
agent_id = models.CharField(max_length=100, null=True)
|
||||
agent_name = models.CharField(max_length=100)
|
||||
agent_emoji = models.CharField(max_length=10, default='🤖')
|
||||
|
||||
# 状态
|
||||
nickname = models.CharField(max_length=100)
|
||||
is_host = models.BooleanField(default=False)
|
||||
joined_at = models.DateTimeField(auto_now_add=True)
|
||||
left_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# API 认证(Agent 用)
|
||||
api_key = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
|
||||
# Message(消息)
|
||||
class Message(models.Model):
|
||||
meeting = models.ForeignKey(Meeting, on_delete=models.CASCADE)
|
||||
sender = models.ForeignKey(Participant, on_delete=models.CASCADE)
|
||||
content = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# 信箱机制
|
||||
is_broadcast = models.BooleanField(default=True) # 群发消息
|
||||
requires_response = models.BooleanField(default=False) # 需要回复
|
||||
in_reply_to = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
|
||||
|
||||
# 读取状态
|
||||
read_by = models.ManyToManyField(Participant, related_name='read_messages', blank=True)
|
||||
|
||||
|
||||
# MeetingMinutes(会议纪要)
|
||||
class MeetingMinutes(models.Model):
|
||||
meeting = models.OneToOneField(Meeting, on_delete=models.CASCADE)
|
||||
content = models.TextField()
|
||||
generated_at = models.DateTimeField(auto_now_add=True)
|
||||
exported_at = models.DateTimeField(null=True, blank=True)
|
||||
```
|
||||
|
||||
### 4.2 索引设计
|
||||
|
||||
```python
|
||||
# 优化查询性能
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['meeting', 'created_at']),
|
||||
models.Index(fields=['agent_id', 'meeting']),
|
||||
models.Index(fields=['is_broadcast', 'created_at']),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 设计
|
||||
|
||||
### 5.1 API 版本
|
||||
|
||||
```
|
||||
/api/v1/...
|
||||
```
|
||||
|
||||
### 5.2 核心 API
|
||||
|
||||
#### 5.2.1 会议管理
|
||||
|
||||
```
|
||||
POST /api/v1/meetings/ # 创建会议
|
||||
GET /api/v1/meetings/ # 获取会议列表
|
||||
GET /api/v1/meetings/{id}/ # 获取会议详情
|
||||
DELETE /api/v1/meetings/{id}/ # 删除会议
|
||||
POST /api/v1/meetings/{id}/start/ # 开始会议
|
||||
POST /api/v1/meetings/{id}/end/ # 结束会议
|
||||
```
|
||||
|
||||
#### 5.2.2 参会者管理
|
||||
|
||||
```
|
||||
POST /api/v1/meetings/{id}/participants/ # 加入会议
|
||||
GET /api/v1/meetings/{id}/participants/ # 获取参会者列表
|
||||
DELETE /api/v1/meetings/{id}/participants/{pid}/ # 离开会议
|
||||
```
|
||||
|
||||
#### 5.2.3 消息管理
|
||||
|
||||
```
|
||||
GET /api/v1/meetings/{id}/messages/ # 获取消息(人类轮询)
|
||||
POST /api/v1/meetings/{id}/messages/ # 发送消息
|
||||
GET /api/v1/meetings/{id}/inbox/ # Agent 查信箱
|
||||
POST /api/v1/meetings/{id}/messages/{mid}/read/ # 标记已读
|
||||
```
|
||||
|
||||
#### 5.2.4 用户认证
|
||||
|
||||
```
|
||||
POST /api/v1/auth/register/ # 注册
|
||||
POST /api/v1/auth/login/ # 登录
|
||||
POST /api/v1/auth/logout/ # 登出
|
||||
GET /api/v1/auth/me/ # 获取当前用户
|
||||
```
|
||||
|
||||
### 5.3 API 响应格式
|
||||
|
||||
```json
|
||||
// 成功响应
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"message": "操作成功"
|
||||
}
|
||||
|
||||
// 错误响应
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "INVALID_REQUEST",
|
||||
"message": "请求参数无效"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 轮询机制
|
||||
|
||||
### 6.1 人类用户轮询(1 秒)
|
||||
|
||||
```javascript
|
||||
// React Hook
|
||||
function useMeetingMessages(meetingId) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [lastId, setLastId] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
const response = await fetch(
|
||||
`/api/v1/meetings/${meetingId}/messages/?last_id=${lastId}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.messages.length > 0) {
|
||||
setMessages(prev => [...prev, ...data.messages]);
|
||||
setLastId(data.messages[data.messages.length - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(poll, 1000); // 1 秒轮询
|
||||
return () => clearInterval(interval);
|
||||
}, [meetingId, lastId]);
|
||||
|
||||
return messages;
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Agent 轮询(5 秒)
|
||||
|
||||
```python
|
||||
# Python 客户端
|
||||
class MeetingAgent:
|
||||
def __init__(self, config):
|
||||
self.check_interval = config.get('check_interval', 5)
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
inbox = self.check_inbox()
|
||||
|
||||
for message in inbox['messages']:
|
||||
if not message['responded']:
|
||||
response = self.generate_response(message)
|
||||
self.respond(message['id'], response)
|
||||
|
||||
time.sleep(self.check_interval)
|
||||
```
|
||||
|
||||
### 6.3 轮询接口抽象
|
||||
|
||||
```python
|
||||
# 抽象接口
|
||||
class PollingStrategy(ABC):
|
||||
@abstractmethod
|
||||
def check_inbox(self, meeting_id: str, agent_id: str) -> List[Message]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_message(self, meeting_id: str, message: Message) -> bool:
|
||||
pass
|
||||
|
||||
# HTTP 实现
|
||||
class HTTPPollingStrategy(PollingStrategy):
|
||||
def __init__(self, api_base: str, api_key: str):
|
||||
self.api_base = api_base
|
||||
self.api_key = api_key
|
||||
|
||||
def check_inbox(self, meeting_id: str, agent_id: str) -> List[Message]:
|
||||
# HTTP GET 实现
|
||||
pass
|
||||
|
||||
# WebSocket 实现(未来扩展)
|
||||
class WebSocketPollingStrategy(PollingStrategy):
|
||||
def check_inbox(self, meeting_id: str, agent_id: str) -> List[Message]:
|
||||
# WebSocket 实现
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 部署架构
|
||||
|
||||
### 7.1 Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build: ./backend
|
||||
command: gunicorn meeting_room.wsgi:application --bind 0.0.0.0:8000
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://user:pass@db:5432/meeting_room
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
- POSTGRES_DB=meeting_room
|
||||
- POSTGRES_USER=user
|
||||
- POSTGRES_PASSWORD=pass
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./ssl:/etc/nginx/ssl
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
### 7.2 Nginx 配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name meeting.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://web:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /app/static/;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 扩展性设计
|
||||
|
||||
### 8.1 水平扩展
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Nginx │
|
||||
│ (负载均衡) │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌───┴───┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────┐ ┌─────┐
|
||||
│ Web │ │ Web │
|
||||
│ 1 │ │ 2 │
|
||||
└─────┘ └─────┘
|
||||
│ │
|
||||
└───┬───┘
|
||||
│
|
||||
┌───┴───┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────┐ ┌─────────┐
|
||||
│ DB │ │ Redis │
|
||||
│ (主从) │ │ (集群) │
|
||||
└─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
### 8.2 插件化设计
|
||||
|
||||
```python
|
||||
# Agent 插件接口
|
||||
class AgentPlugin(ABC):
|
||||
@abstractmethod
|
||||
def generate_response(self, message: Message) -> str:
|
||||
pass
|
||||
|
||||
# OpenClaw 插件
|
||||
class OpenClawPlugin(AgentPlugin):
|
||||
def generate_response(self, message: Message) -> str:
|
||||
# OpenClaw 逻辑
|
||||
pass
|
||||
|
||||
# Llama 插件(未来)
|
||||
class LlamaPlugin(AgentPlugin):
|
||||
def generate_response(self, message: Message) -> str:
|
||||
# 调用 Llama API
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 变更日志
|
||||
|
||||
| 版本 | 日期 | 变更内容 | 作者 |
|
||||
|------|------|----------|------|
|
||||
| v0.1 | 2026-04-04 | 初始版本 | 飞行侠 |
|
||||
|
||||
---
|
||||
|
||||
**文档结束** 📝
|
||||
|
||||
**创建者**: 飞行侠 🦸
|
||||
**日期**: 2026-04-04
|
||||
**状态**: 待北极星确认
|
||||
17172
frontend/package-lock.json
generated
Normal file
17172
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "meeting-room-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"axios": "^1.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
11
frontend/public/index.html
Normal file
11
frontend/public/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🏛️ 龙虾议事厅</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
403
frontend/src/App.js
Normal file
403
frontend/src/App.js
Normal file
@@ -0,0 +1,403 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE = 'http://localhost:8000/api/v1';
|
||||
|
||||
// 配置 axios 默认头
|
||||
axios.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// ============ 登录页面 ============
|
||||
function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE}/auth/login/`, { username, password });
|
||||
const token = response.data.token;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user));
|
||||
navigate('/meetings');
|
||||
} catch (error) {
|
||||
alert('登录失败:' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.card}>
|
||||
<h1 style={styles.title}>🏛️ 龙虾议事厅</h1>
|
||||
<form onSubmit={handleLogin} style={styles.form}>
|
||||
<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
|
||||
/>
|
||||
<button type="submit" style={styles.button}>登录</button>
|
||||
</form>
|
||||
<p style={styles.hint}>提示:首次使用请先注册超级用户</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 会议列表页面 ============
|
||||
function MeetingList() {
|
||||
const [meetings, setMeetings] = useState([]);
|
||||
const [topic, setTopic] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
fetchMeetings();
|
||||
}, []);
|
||||
|
||||
const fetchMeetings = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/meetings/`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setMeetings(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取会议失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createMeeting = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${API_BASE}/meetings/`,
|
||||
{ topic },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
navigate(`/meeting/${response.data.id}`);
|
||||
} catch (error) {
|
||||
alert('创建失败:' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const joinMeeting = async (meetingId) => {
|
||||
const inviteCode = prompt('请输入邀请码:');
|
||||
if (!inviteCode) return;
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_BASE}/meetings/${meetingId}/join/`,
|
||||
{ invite_code: inviteCode },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
navigate(`/meeting/${meetingId}`);
|
||||
} catch (error) {
|
||||
alert('加入失败:' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<h1 style={styles.title}>🏛️ 我的会议室</h1>
|
||||
<button onClick={logout} style={styles.logoutBtn}>退出</button>
|
||||
</div>
|
||||
|
||||
<div style={styles.card}>
|
||||
<h2>创建新会议</h2>
|
||||
<form onSubmit={createMeeting} style={styles.form}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="会议主题"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
style={styles.input}
|
||||
required
|
||||
/>
|
||||
<button type="submit" style={styles.button}>创建</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style={styles.meetingList}>
|
||||
{meetings.map(meeting => (
|
||||
<div key={meeting.id} style={styles.meetingCard}>
|
||||
<div>
|
||||
<h3>{meeting.topic}</h3>
|
||||
<p>状态:{meeting.status} | 参会:{meeting.participant_count} | 邀请码:{meeting.invite_code}</p>
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={() => navigate(`/meeting/${meeting.id}`)} style={styles.smallBtn}>进入</button>
|
||||
<button onClick={() => joinMeeting(meeting.id)} style={styles.smallBtn}>加入</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 会议室页面 ============
|
||||
function MeetingRoom() {
|
||||
const { id } = useParams();
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [content, setContent] = useState('');
|
||||
const [participants, setParticipants] = useState([]);
|
||||
const [lastId, setLastId] = useState(0);
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetchParticipants();
|
||||
fetchMessages();
|
||||
|
||||
// 1 秒轮询新消息
|
||||
const interval = setInterval(fetchMessages, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchParticipants = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/meetings/${id}/participants/`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setParticipants(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取参会者失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/meetings/${id}/messages/?last_id=${lastId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (response.data.messages.length > 0) {
|
||||
setMessages(prev => [...prev, ...response.data.messages]);
|
||||
setLastId(response.data.messages[response.data.messages.length - 1].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_BASE}/meetings/${id}/send_message/`,
|
||||
{ content },
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setContent('');
|
||||
fetchMessages();
|
||||
} catch (error) {
|
||||
alert('发送失败:' + (error.response?.data?.detail || error.message));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<Link to="/meetings" style={styles.backLink}>← 返回</Link>
|
||||
<h1 style={styles.title}>🏛️ 会议室</h1>
|
||||
<div style={styles.participants}>
|
||||
{participants.map(p => (
|
||||
<span key={p.id} style={styles.participant}>
|
||||
{p.agent_emoji} {p.nickname}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.chatContainer}>
|
||||
<div style={styles.messages}>
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} style={styles.message}>
|
||||
<strong>{msg.sender_emoji} {msg.sender_name}</strong>
|
||||
<span style={styles.time}>{new Date(msg.created_at).toLocaleTimeString()}</span>
|
||||
<p>{msg.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={sendMessage} style={styles.form}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入消息..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={styles.input}
|
||||
required
|
||||
/>
|
||||
<button type="submit" style={styles.button}>发送</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 主应用 ============
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/meetings" element={<MeetingList />} />
|
||||
<Route path="/meeting/:id" element={<MeetingRoom />} />
|
||||
<Route path="/" element={<LoginPage />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 样式 ============
|
||||
const styles = {
|
||||
container: {
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
padding: '20px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '20px',
|
||||
marginBottom: '20px'
|
||||
},
|
||||
title: {
|
||||
margin: '0',
|
||||
color: '#1a365d'
|
||||
},
|
||||
card: {
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
|
||||
},
|
||||
form: {
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
marginBottom: '15px'
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
fontSize: '1em'
|
||||
},
|
||||
button: {
|
||||
padding: '12px 24px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1em',
|
||||
fontWeight: '600'
|
||||
},
|
||||
logoutBtn: {
|
||||
marginLeft: 'auto',
|
||||
padding: '8px 16px',
|
||||
background: '#edf2f7',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer'
|
||||
},
|
||||
meetingList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '15px'
|
||||
},
|
||||
meetingCard: {
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
|
||||
},
|
||||
smallBtn: {
|
||||
padding: '8px 16px',
|
||||
background: '#4299e1',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
marginLeft: '10px'
|
||||
},
|
||||
chatContainer: {
|
||||
background: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
|
||||
},
|
||||
messages: {
|
||||
maxHeight: '500px',
|
||||
overflowY: 'auto',
|
||||
marginBottom: '20px'
|
||||
},
|
||||
message: {
|
||||
padding: '15px',
|
||||
background: '#f7fafc',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '10px'
|
||||
},
|
||||
time: {
|
||||
fontSize: '0.8em',
|
||||
color: '#718096',
|
||||
marginLeft: '10px'
|
||||
},
|
||||
participants: {
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
marginLeft: 'auto'
|
||||
},
|
||||
participant: {
|
||||
background: '#edf2f7',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.9em'
|
||||
},
|
||||
backLink: {
|
||||
color: '#4299e1',
|
||||
textDecoration: 'none',
|
||||
fontSize: '1.1em'
|
||||
},
|
||||
hint: {
|
||||
color: '#718096',
|
||||
fontSize: '0.9em',
|
||||
textAlign: 'center',
|
||||
marginTop: '15px'
|
||||
}
|
||||
};
|
||||
|
||||
export default App;
|
||||
10
frontend/src/index.js
Normal file
10
frontend/src/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
Reference in New Issue
Block a user