【悟凡】真正意义上的净土重生:只保留核心逻辑
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