Compare commits

...

25 Commits

Author SHA1 Message Date
小白
7d747fd33a docs: 新增问题记录文档
- 新增 docs/ISSUES.md 问题记录文档
- 记录问题 #001:新建机器人报 Failed to fetch 错误
- 包含问题现象、原因分析、解决方案
- 提供问题记录模板
2026-03-15 13:31:07 +08:00
小白
11f4347864 docs: 更新版本号和更新日志 (v0.9.8)
- 更新版本号至 v0.9.8
- 添加 v0.9.6 新建机器人功能更新日志
- 添加 v0.9.7 绑定/解绑 Agent 功能更新日志
- 添加 v0.9.8 Markdown 渲染和代码高亮功能更新日志
2026-03-15 12:21:24 +08:00
dcf8494db7 feat: 功能3 - Markdown 渲染和代码高亮 (v0.9.8)
- 添加 MarkdownMessage.vue 组件
- 集成 markdown-it 库
- 集成 highlight.js 代码高亮
- 支持标题/列表/代码块/表格等
- ChatWindow 使用 Markdown 渲染
- 添加 .gitignore 忽略 node_modules
2026-03-15 12:13:06 +08:00
021ce8b50b feat: 功能2 - 绑定/解绑 Agent (v0.9.7)
- 添加 BotSettingsModal.vue 机器人设置模态框
- 显示在线 Agent 列表
- 实现 Agent 绑定功能
- 实现 Agent 解绑功能
- HomeView 添加设置按钮(hover 显示)
- Store 添加 bindBotAgent 方法
- 显示机器人绑定状态
2026-03-15 11:59:04 +08:00
15b001bab5 feat: 功能1 - 新建机器人 (v0.9.6)
- 添加 CreateBotModal.vue 新建机器人模态框
- 实现12个预设头像选择
- 实现名称/显示名称/描述表单
- HomeView 添加「新建机器人」按钮
- Store 添加 createBot 方法
- Store 添加 fetchAgents 方法
- 表单验证和错误提示
2026-03-15 11:54:36 +08:00
小白
cb2bb5ec35 docs: 完善智队机器人技术方案 (v0.9.1)
- 完善新建智队机器人技术方案
- 完善机器人绑定 Agent 流程
- 添加创建聊天会话流程说明
- 添加完整使用流程说明
- 添加前端新建机器人功能说明
2026-03-15 11:44:12 +08:00
小白
c5aaee66b5 docs: 更新智队机器人功能开发进度 (v0.9.0)
- 更新版本号至 v0.9.0
- 标记智队机器人功能为已完成
- 更新开发进度表格
- 总进度提升至 90%
2026-03-15 11:11:07 +08:00
e651f21324 feat: Step 6 - 前端聊天界面 (v0.9.5)
- 创建 Vue.js 3 前端项目 (frontend/)
- 实现核心功能:
  - 登录页面 (LoginView)
  - 首页 - 机器人和会话列表 (HomeView)
  - 聊天页面 (ChatView)
  - 聊天侧边栏 (ChatSidebar)
  - 聊天窗口 (ChatWindow)
  - 机器人选择器 (BotSelector)
- 集成功能:
  - Socket.io WebSocket 连接
  - Pinia 状态管理
  - Axios API 客户端
  - JWT 认证
- 更新版本号到 0.9.5
2026-03-15 10:57:50 +08:00
04132c298a feat: Step 5 - 聊天 API (v0.9.4)
- 创建 chat.py 路由文件
- 实现 7 个 REST API 端点:
  - GET /api/chat/sessions - 获取聊天会话列表
  - POST /api/chat/sessions - 创建聊天会话
  - GET /api/chat/sessions/:id - 获取会话详情
  - GET /api/chat/sessions/:id/messages - 获取消息历史
  - POST /api/chat/sessions/:id/messages - 发送消息
  - PUT /api/chat/sessions/:id/read - 标记已读
  - DELETE /api/chat/sessions/:id - 关闭会话
- 支持分页、过滤、Bot 信息关联
- 注册蓝图到应用
- 更新版本号到 0.9.4
2026-03-15 10:43:10 +08:00
b74ec0b73d feat: Step 4 - 聊天 WebSocket 事件 (v0.9.3)
- 创建 chat_handlers.py 聊天事件处理器
- 实现 6 个 C→S 事件:
  - chat.send.create - 创建聊天会话
  - chat.send.join - 加入会话
  - chat.send.leave - 离开会话
  - chat.send.message - 发送消息
  - chat.send.typing - 正在输入
  - chat.send.read - 消息已读
- 实现 7 个 S→C 事件:
  - chat.created - 会话已创建
  - chat.joined - 已加入会话
  - chat.left - 已离开会话
  - chat.message - 收到消息
  - chat.typing - 对方正在输入
  - chat.read - 消息已读确认
  - chat.closed - 会话被关闭
- 创建 ChatConnectionManager 管理连接
- 注册聊天事件处理器
- 更新版本号到 0.9.3
2026-03-15 10:34:40 +08:00
608e53ed2f feat: Step 3 - Bot API 路由 (v0.9.2)
- 创建 bots.py 路由文件
- 实现 9 个 REST API 端点:
  - GET /api/bots - 获取列表
  - POST /api/bots - 创建
  - GET /api/bots/:id - 详情
  - PUT /api/bots/:id - 更新
  - DELETE /api/bots/:id - 删除
  - POST /api/bots/:id/bind - 绑定 Agent
  - POST /api/bots/:id/unbind - 解绑 Agent
  - GET /api/bots/:id/status - 状态
  - POST /api/bots/:id/heartbeat - 心跳
  - POST /api/bots/:id/token - 重新生成 Token
  - GET /api/bots/:id/stats - 统计
- 实现 JWT 权限检查
- 实现 X-Bot-Token 认证
- 注册蓝图到应用
- 更新版本号到 0.9.2
2026-03-15 10:28:57 +08:00
1ba9f78bd8 feat: Step 2 - Bot 服务层 (v0.9.1)
- 创建 BotService 服务类
- 实现 CRUD 操作
- 实现 Agent 绑定/解绑
- 实现权限检查 (check_permission)
- 实现 Token 生成/验证/重新生成
- 实现状态同步 (sync_agent_status)
- 实现统计信息获取
- 更新 services/__init__.py 导出
2026-03-15 10:23:41 +08:00
8673eaf655 feat: Step 1 - 添加 Bot 数据模型 (v0.9.0)
- 创建 Bot 模型:id/name/display_name/avatar/description
- 添加 owner_id/agent_id/token_hash/is_system 字段
- 添加 status/capabilities/config 字段
- 修改 Session 模型:添加 bot_id 关联
- 修改 Message 模型:添加 sender_name 和 bot_id
- 创建数据库迁移脚本

技术方案实现:
- Bot 一对一绑定 Agent
- owner_id 区分所有权
- is_system 支持系统级 Bot
- capabilities 存储能力标签
2026-03-15 10:22:05 +08:00
44582a8199 fix: 修复智队机器人技术方案问题 (v0.8.1)
- Bot模型: 添加owner_id/token_hash/is_system/capabilities
- 路由逻辑: 删除gateway_id,只保留agent_id一对一绑定
- WebSocket事件: 区分chat.send.*和chat.*
- Connection模型: 移除bot类型
- Message模型: 分离sender_type和sender_name
- 添加Bot Config详细配置结构
- 添加Bot权限控制和Token认证机制
- 明确前端架构为Vue.js 3 SPA
2026-03-15 10:08:27 +08:00
小白
066ccf0d28 docs: 新增智队机器人功能详细技术方案 (v0.8.0)
- 新增智队机器人功能模块设计
- 新增 Bot 数据模型设计
- 新增机器人管理 API 设计
- 新增聊天界面设计
- 新增聊天 WebSocket 事件设计
- 更新已完成工作列表
- 更新待完成工作列表
- 更新项目版本至 v0.8.0
2026-03-15 09:47:31 +08:00
92dede8793 test: 测试签名验证修复 2026-03-15 09:10:40 +08:00
0386da3905 test: 测试签名验证 2026-03-15 09:09:06 +08:00
9d6a489a31 test: 测试自动部署(禁用签名验证) 2026-03-15 09:04:00 +08:00
70a3643b77 test: 测试 webhook 签名 2026-03-15 09:02:54 +08:00
42ac87f79c test: 测试自动部署修复 2026-03-15 09:02:16 +08:00
645d226f0d test: 测试自动部署修复 2026-03-15 09:02:16 +08:00
cb0a496f7d Merge branch 'main' of http://1.14.58.157:3000/yunxiafei/pit-router 2026-03-15 08:53:07 +08:00
8c912b8808 v0.7.2: 修复登录页面版本号和主题初始化 2026-03-15 08:52:57 +08:00
a09afc1c4c test: 测试 Gitea 修复 2026-03-15 08:50:37 +08:00
4b707a49f4 test: 测试 webhook 最终修复 2026-03-15 08:41:55 +08:00
34 changed files with 5434 additions and 167 deletions

13
.gitignore vendored
View File

@@ -1,11 +1,2 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
*.db
*.sqlite
.env
venv/
*.pyc
__pycache__/
venv/
frontend/node_modules/
frontend/package-lock.json

919
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@ def create_app(config_name='default'):
def index():
return {
'service': '智队中枢',
'version': '0.6.1',
'version': '0.9.4',
'status': 'running',
'endpoints': {
'health': '/health',
@@ -93,6 +93,8 @@ def _register_blueprints(app):
from app.routes.gateways import gateways_bp
from app.routes.messages import messages_bp
from app.routes.stats import stats_bp
from app.routes.bots import bots_bp
from app.routes.chat import chat_bp # Step 5: Chat API
from app.web.routes import web_bp
from app.web.api import web_api_bp
@@ -102,6 +104,8 @@ def _register_blueprints(app):
app.register_blueprint(gateways_bp, url_prefix='/api/gateways')
app.register_blueprint(messages_bp, url_prefix='/api/messages')
app.register_blueprint(stats_bp, url_prefix='/api/stats')
app.register_blueprint(bots_bp, url_prefix='/api/bots')
app.register_blueprint(chat_bp, url_prefix='/api/chat') # Step 5: Chat API
app.register_blueprint(web_bp, url_prefix='/web')
app.register_blueprint(web_api_bp)

View File

@@ -129,12 +129,57 @@ class Agent(db.Model):
}
class Bot(db.Model):
"""机器人模型 - Agent 的用户侧展示配置"""
__tablename__ = 'bots'
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
name: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
display_name: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
avatar: Mapped[Optional[str]] = mapped_column(String(256), nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey('users.id'), nullable=False)
agent_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey('agents.id'), nullable=True)
token_hash: Mapped[Optional[str]] = mapped_column(String(256), nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(20), default='offline')
capabilities: Mapped[Optional[str]] = mapped_column(JSON, nullable=True)
config: Mapped[Optional[str]] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
last_active_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# 关联
owner = relationship('User', foreign_keys=[owner_id])
agent = relationship('Agent', foreign_keys=[agent_id])
def __repr__(self):
return f'<Bot {self.name}>'
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'display_name': self.display_name,
'avatar': self.avatar,
'description': self.description,
'owner_id': self.owner_id,
'agent_id': self.agent_id,
'is_system': self.is_system,
'status': self.status,
'capabilities': self.capabilities,
'config': self.config,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_active_at': self.last_active_at.isoformat() if self.last_active_at else None,
}
class Session(db.Model):
"""会话模型"""
__tablename__ = 'sessions'
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
user_id: Mapped[str] = mapped_column(String(36), ForeignKey('users.id'), nullable=False)
bot_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey('bots.id'), nullable=True)
primary_agent_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey('agents.id'), nullable=True)
participating_agent_ids: Mapped[Optional[str]] = mapped_column(JSON, nullable=True)
user_socket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
@@ -149,6 +194,7 @@ class Session(db.Model):
# 关联
user = relationship('User', back_populates='sessions')
bot = relationship('Bot', foreign_keys=[bot_id])
agent = relationship('Agent', back_populates='sessions')
messages = relationship('Message', back_populates='session', cascade='all, delete-orphan')
@@ -159,6 +205,7 @@ class Session(db.Model):
return {
'id': self.id,
'user_id': self.user_id,
'bot_id': self.bot_id,
'primary_agent_id': self.primary_agent_id,
'participating_agent_ids': self.participating_agent_ids,
'user_socket_id': self.user_socket_id,
@@ -181,6 +228,8 @@ class Message(db.Model):
session_id: Mapped[str] = mapped_column(String(36), ForeignKey('sessions.id'), nullable=False)
sender_type: Mapped[str] = mapped_column(String(20), nullable=False)
sender_id: Mapped[str] = mapped_column(String(36), nullable=False)
sender_name: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
bot_id: Mapped[Optional[str]] = mapped_column(String(36), ForeignKey('bots.id'), nullable=True)
message_type: Mapped[str] = mapped_column(String(20), default='text')
content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
content_type: Mapped[str] = mapped_column(String(20), default='markdown')
@@ -193,6 +242,7 @@ class Message(db.Model):
# 关联
session = relationship('Session', back_populates='messages')
bot = relationship('Bot', foreign_keys=[bot_id])
def __repr__(self):
return f'<Message {self.id}>'
@@ -203,6 +253,8 @@ class Message(db.Model):
'session_id': self.session_id,
'sender_type': self.sender_type,
'sender_id': self.sender_id,
'sender_name': self.sender_name,
'bot_id': self.bot_id,
'message_type': self.message_type,
'content': self.content,
'content_type': self.content_type,

362
app/routes/bots.py Normal file
View File

@@ -0,0 +1,362 @@
"""
Bot 路由 - 机器人管理 API
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime
from app.models import User, Agent
from app.services.bot_service import BotService
bots_bp = Blueprint('bots', __name__)
@bots_bp.route('/', methods=['GET'])
@jwt_required()
def get_bots():
"""
获取机器人列表
Query params:
- owner_only: bool - 只返回自己的 Bot
- include_system: bool - 包含系统 Bot
"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
owner_only = request.args.get('owner_only', 'false').lower() == 'true'
if owner_only:
bots = BotService.get_bots_by_owner(user_id)
else:
bots = BotService.get_available_bots(user)
return jsonify({
'bots': [bot.to_dict() for bot in bots],
'count': len(bots)
}), 200
@bots_bp.route('/', methods=['POST'])
@jwt_required()
def create_bot():
"""
创建机器人
Request body:
- name: str - 机器人名称(必填,唯一)
- display_name: str - 显示名称
- avatar: str - 头像 URL
- description: str - 描述
- is_system: bool - 是否为系统级 Bot仅 admin
- agent_id: str - 绑定的 Agent ID
- capabilities: list - 能力标签
- config: dict - 配置
"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
data = request.get_json()
# 验证必填字段
if not data or 'name' not in data:
return jsonify({'error': 'name is required'}), 400
name = data['name'].strip()
# 检查名称是否已存在
if BotService.get_bot_by_name(name):
return jsonify({'error': 'Bot name already exists'}), 400
# 检查 is_system 权限
is_system = data.get('is_system', False)
if is_system and user.role != 'admin':
return jsonify({'error': 'Only admin can create system bots'}), 403
# 检查 agent_id 是否有效
agent_id = data.get('agent_id')
if agent_id:
agent = Agent.query.get(agent_id)
if not agent:
return jsonify({'error': 'Agent not found'}), 404
# 创建 Bot
bot, token = BotService.create_bot(
name=name,
owner_id=user_id,
display_name=data.get('display_name'),
avatar=data.get('avatar'),
description=data.get('description'),
is_system=is_system,
agent_id=agent_id,
capabilities=data.get('capabilities'),
config=data.get('config')
)
return jsonify({
'bot': bot.to_dict(),
'token': token # 只在创建时返回一次
}), 201
@bots_bp.route('/<bot_id>', methods=['GET'])
@jwt_required()
def get_bot(bot_id):
"""获取机器人详情"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# 权限检查
if not BotService.check_permission(user, bot, 'view'):
return jsonify({'error': 'Permission denied'}), 403
return jsonify({'bot': bot.to_dict()}), 200
@bots_bp.route('/<bot_id>', methods=['PUT'])
@jwt_required()
def update_bot(bot_id):
"""
更新机器人配置
Request body:
- display_name: str
- avatar: str
- description: str
- capabilities: list
- config: dict
"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# 权限检查
if not BotService.check_permission(user, bot, 'edit'):
return jsonify({'error': 'Permission denied'}), 403
data = request.get_json() or {}
bot = BotService.update_bot(
bot=bot,
display_name=data.get('display_name'),
avatar=data.get('avatar'),
description=data.get('description'),
capabilities=data.get('capabilities'),
config=data.get('config')
)
return jsonify({'bot': bot.to_dict()}), 200
@bots_bp.route('/<bot_id>', methods=['DELETE'])
@jwt_required()
def delete_bot(bot_id):
"""删除机器人"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# 权限检查
if not BotService.check_permission(user, bot, 'delete'):
return jsonify({'error': 'Permission denied'}), 403
# 不允许删除系统 Bot除非是 admin
if bot.is_system and user.role != 'admin':
return jsonify({'error': 'Cannot delete system bot'}), 403
success = BotService.delete_bot(bot)
if success:
return jsonify({'message': 'Bot deleted'}), 200
else:
return jsonify({'error': 'Failed to delete bot'}), 500
@bots_bp.route('/<bot_id>/bind', methods=['POST'])
@jwt_required()
def bind_agent(bot_id):
"""
绑定 Agent
Request body:
- agent_id: str - Agent ID
"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# 权限检查
if not BotService.check_permission(user, bot, 'bind'):
return jsonify({'error': 'Permission denied'}), 403
data = request.get_json()
if not data or 'agent_id' not in data:
return jsonify({'error': 'agent_id is required'}), 400
agent_id = data['agent_id']
agent = Agent.query.get(agent_id)
if not agent:
return jsonify({'error': 'Agent not found'}), 404
bot = BotService.bind_agent(bot, agent_id)
return jsonify({
'bot': bot.to_dict(),
'message': f'Bot bound to agent {agent.name}'
}), 200
@bots_bp.route('/<bot_id>/unbind', methods=['POST'])
@jwt_required()
def unbind_agent(bot_id):
"""解绑 Agent"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# 权限检查
if not BotService.check_permission(user, bot, 'bind'):
return jsonify({'error': 'Permission denied'}), 403
bot = BotService.bind_agent(bot, None)
return jsonify({
'bot': bot.to_dict(),
'message': 'Bot unbound from agent'
}), 200
@bots_bp.route('/<bot_id>/status', methods=['GET'])
@jwt_required()
def get_bot_status(bot_id):
"""获取机器人状态"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# 权限检查
if not BotService.check_permission(user, bot, 'view'):
return jsonify({'error': 'Permission denied'}), 403
return jsonify({
'bot_id': bot.id,
'name': bot.name,
'status': bot.status,
'agent_id': bot.agent_id,
'last_active_at': bot.last_active_at.isoformat() if bot.last_active_at else None
}), 200
@bots_bp.route('/<bot_id>/heartbeat', methods=['POST'])
def bot_heartbeat(bot_id):
"""
机器人心跳上报
用于 Bot Token 认证(非 JWT
Headers: X-Bot-Token: <token>
"""
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# Token 认证
token = request.headers.get('X-Bot-Token')
if not token or not BotService.verify_token(bot, token):
return jsonify({'error': 'Invalid bot token'}), 401
# 更新状态
BotService.update_status(bot, 'online')
return jsonify({'status': 'ok'}), 200
@bots_bp.route('/<bot_id>/token', methods=['POST'])
@jwt_required()
def regenerate_token(bot_id):
"""重新生成 Bot Token"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# 权限检查
if not BotService.check_permission(user, bot, 'edit'):
return jsonify({'error': 'Permission denied'}), 403
token = BotService.regenerate_token(bot)
return jsonify({
'message': 'Token regenerated',
'token': token
}), 200
@bots_bp.route('/<bot_id>/stats', methods=['GET'])
@jwt_required()
def get_bot_stats(bot_id):
"""获取机器人统计信息"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# 权限检查
if not BotService.check_permission(user, bot, 'view'):
return jsonify({'error': 'Permission denied'}), 403
stats = BotService.get_bot_stats(bot)
return jsonify(stats), 200

382
app/routes/chat.py Normal file
View File

@@ -0,0 +1,382 @@
"""
聊天路由 - 聊天会话管理 API
提供 REST API 用于管理聊天会话(补充 WebSocket 功能)
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime
from app.models import User, Session, Message, Bot, Agent
from app.extensions import db
from app.services.bot_service import BotService
chat_bp = Blueprint('chat', __name__)
@chat_bp.route('/sessions', methods=['GET'])
@jwt_required()
def get_chat_sessions():
"""
获取聊天会话列表
Query params:
- status: str - 会话状态过滤active/paused/closed
- bot_id: str - 按 Bot 过滤
- limit: int - 返回数量限制(默认 20
- offset: int - 分页偏移(默认 0
"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
# 获取查询参数
status = request.args.get('status')
bot_id = request.args.get('bot_id')
limit = min(int(request.args.get('limit', 20)), 100) # 最大 100
offset = int(request.args.get('offset', 0))
# 构建查询
query = Session.query.filter_by(user_id=user_id)
# 只查询聊天会话(有 bot_id 的)
query = query.filter(Session.bot_id.isnot(None))
if status:
query = query.filter_by(status=status)
if bot_id:
query = query.filter_by(bot_id=bot_id)
# 按最后活跃时间倒序
query = query.order_by(Session.last_active_at.desc())
# 分页
total = query.count()
sessions = query.offset(offset).limit(limit).all()
# 构建返回数据
result = []
for session in sessions:
session_data = session.to_dict()
# 添加 Bot 信息
if session.bot_id:
bot = BotService.get_bot_by_id(session.bot_id)
if bot:
session_data['bot'] = {
'id': bot.id,
'name': bot.name,
'display_name': bot.display_name,
'avatar': bot.avatar,
'status': bot.status
}
# 添加最后一条消息预览
last_message = Message.query.filter_by(session_id=session.id)\
.order_by(Message.created_at.desc())\
.first()
if last_message:
session_data['last_message'] = {
'id': last_message.id,
'content': last_message.content[:100] if last_message.content else '',
'sender_type': last_message.sender_type,
'created_at': last_message.created_at.isoformat()
}
result.append(session_data)
return jsonify({
'sessions': result,
'total': total,
'limit': limit,
'offset': offset
}), 200
@chat_bp.route('/sessions', methods=['POST'])
@jwt_required()
def create_chat_session():
"""
创建聊天会话HTTP 方式WebSocket 的替代方案)
Request body:
- bot_id: str - 机器人 ID必填
- title: str - 会话标题(可选)
"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
data = request.get_json()
if not data or 'bot_id' not in data:
return jsonify({'error': 'bot_id is required'}), 400
bot_id = data['bot_id']
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return jsonify({'error': 'Bot not found'}), 404
# 权限检查
if not BotService.check_permission(user, bot, 'use'):
return jsonify({'error': 'Permission denied'}), 403
# 检查 Agent
if not bot.agent_id:
return jsonify({'error': 'Bot has no agent bound'}), 400
agent = Agent.query.get(bot.agent_id)
if not agent:
return jsonify({'error': 'Agent not found'}), 404
# 创建会话
title = data.get('title', f'Chat with {bot.display_name or bot.name}')
session = Session(
user_id=user_id,
bot_id=bot.id,
primary_agent_id=agent.id,
title=title,
channel_type='pit-bot',
status='active',
created_at=datetime.utcnow(),
last_active_at=datetime.utcnow()
)
db.session.add(session)
db.session.commit()
return jsonify({
'session': {
**session.to_dict(),
'bot': bot.to_dict()
},
'message': 'Chat session created'
}), 201
@chat_bp.route('/sessions/<session_id>', methods=['GET'])
@jwt_required()
def get_chat_session(session_id):
"""获取聊天会话详情"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
session = Session.query.get(session_id)
if not session:
return jsonify({'error': 'Session not found'}), 404
# 权限检查
if session.user_id != user_id:
return jsonify({'error': 'Permission denied'}), 403
# 验证是聊天会话
if not session.bot_id:
return jsonify({'error': 'Not a chat session'}), 400
session_data = session.to_dict()
# 添加 Bot 信息
bot = BotService.get_bot_by_id(session.bot_id)
if bot:
session_data['bot'] = bot.to_dict()
return jsonify({'session': session_data}), 200
@chat_bp.route('/sessions/<session_id>/messages', methods=['GET'])
@jwt_required()
def get_chat_messages(session_id):
"""
获取聊天消息历史
Query params:
- limit: int - 返回数量限制(默认 50最大 100
- before: str - 获取此 ID 之前的消息(分页)
"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
session = Session.query.get(session_id)
if not session:
return jsonify({'error': 'Session not found'}), 404
# 权限检查
if session.user_id != user_id:
return jsonify({'error': 'Permission denied'}), 403
# 获取查询参数
limit = min(int(request.args.get('limit', 50)), 100)
before_id = request.args.get('before')
# 构建查询
query = Message.query.filter_by(session_id=session_id)
if before_id:
before_msg = Message.query.get(before_id)
if before_msg:
query = query.filter(Message.created_at < before_msg.created_at)
# 按时间倒序,取最近的消息
messages = query.order_by(Message.created_at.desc()).limit(limit).all()
# 翻转顺序(从旧到新)
messages = list(reversed(messages))
return jsonify({
'messages': [m.to_dict() for m in messages],
'count': len(messages),
'has_more': len(messages) == limit
}), 200
@chat_bp.route('/sessions/<session_id>/messages', methods=['POST'])
@jwt_required()
def send_chat_message(session_id):
"""
发送消息HTTP 方式WebSocket 的替代方案)
Request body:
- content: str - 消息内容(必填)
- reply_to: str - 回复的消息 ID可选
- content_type: str - 内容类型(默认 markdown
"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
session = Session.query.get(session_id)
if not session:
return jsonify({'error': 'Session not found'}), 404
# 权限检查
if session.user_id != user_id:
return jsonify({'error': 'Permission denied'}), 403
# 检查会话状态
if session.status == 'closed':
return jsonify({'error': 'Session is closed'}), 400
data = request.get_json()
if not data or 'content' not in data:
return jsonify({'error': 'content is required'}), 400
content = data['content'].strip()
if not content:
return jsonify({'error': 'content cannot be empty'}), 400
# 获取 Bot 信息用于 sender_name
bot = None
sender_name = user.nickname or user.username
if session.bot_id:
bot = BotService.get_bot_by_id(session.bot_id)
# 这里不需要修改 sender_name因为这是用户发送的消息
# 创建消息
message = Message(
session_id=session_id,
sender_type='user',
sender_id=user_id,
sender_name=sender_name,
bot_id=session.bot_id,
message_type='text',
content=content,
content_type=data.get('content_type', 'markdown'),
reply_to=data.get('reply_to'),
status='sent',
ack_status='pending',
created_at=datetime.utcnow()
)
db.session.add(message)
# 更新会话
session.message_count += 1
session.last_active_at = datetime.utcnow()
session.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
'message': message.to_dict(),
'session_id': session_id
}), 201
@chat_bp.route('/sessions/<session_id>/read', methods=['PUT'])
@jwt_required()
def mark_session_read(session_id):
"""标记会话消息已读"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
session = Session.query.get(session_id)
if not session:
return jsonify({'error': 'Session not found'}), 404
# 权限检查
if session.user_id != user_id:
return jsonify({'error': 'Permission denied'}), 403
# 将所有未读消息标记为已读
messages = Message.query.filter_by(
session_id=session_id,
status='delivered'
).all()
for msg in messages:
msg.status = 'read'
# 重置会话未读数
session.unread_count = 0
db.session.commit()
return jsonify({
'session_id': session_id,
'marked_read': len(messages)
}), 200
@chat_bp.route('/sessions/<session_id>', methods=['DELETE'])
@jwt_required()
def close_chat_session(session_id):
"""关闭聊天会话"""
user_id = get_jwt_identity()
user = User.query.get(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
session = Session.query.get(session_id)
if not session:
return jsonify({'error': 'Session not found'}), 404
# 权限检查
if session.user_id != user_id:
return jsonify({'error': 'Permission denied'}), 403
# 关闭会话
session.status = 'closed'
session.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({
'session_id': session_id,
'status': 'closed',
'message': 'Chat session closed'
}), 200

View File

@@ -6,6 +6,7 @@ from .message_queue import MessageQueue
from .session_service import SessionService
from .message_service import MessageService
from .agent_service import AgentService
from .bot_service import BotService
__all__ = [
'AgentScheduler',
@@ -13,4 +14,5 @@ __all__ = [
'SessionService',
'MessageService',
'AgentService',
'BotService',
]

277
app/services/bot_service.py Normal file
View File

@@ -0,0 +1,277 @@
"""
Bot 服务层
实现 Bot 的 CRUD、权限控制、Token 管理、Agent 绑定等功能
"""
import secrets
import hashlib
from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from app.extensions import db
from app.models import Bot, User, Agent
class BotService:
"""Bot 服务类"""
@staticmethod
def generate_token() -> str:
"""生成 Bot API Token"""
return f"bot_{secrets.token_urlsafe(32)}"
@staticmethod
def hash_token(token: str) -> str:
"""Hash Token 用于存储"""
return hashlib.sha256(token.encode()).hexdigest()
@staticmethod
def verify_token(bot: Bot, token: str) -> bool:
"""验证 Token"""
if not bot.token_hash:
return False
return bot.token_hash == BotService.hash_token(token)
@staticmethod
def check_permission(user: User, bot: Bot, action: str) -> bool:
"""
检查用户对 Bot 的权限
Args:
user: 当前用户
bot: Bot 对象
action: 操作类型 ('view', 'use', 'edit', 'delete', 'bind')
Returns:
是否有权限
"""
# 管理员拥有所有权限
if user.role == 'admin':
return True
# 查看/使用权限
if action in ['view', 'use']:
# 系统级 Bot 所有用户可用
if bot.is_system:
return True
# 自己的 Bot
return bot.owner_id == user.id
# 编辑/删除/绑定权限
if action in ['edit', 'delete', 'bind']:
# 只有所有者可以编辑/删除/绑定
return bot.owner_id == user.id
return False
@staticmethod
def create_bot(
name: str,
owner_id: str,
display_name: Optional[str] = None,
avatar: Optional[str] = None,
description: Optional[str] = None,
is_system: bool = False,
agent_id: Optional[str] = None,
capabilities: Optional[List[str]] = None,
config: Optional[Dict[str, Any]] = None
) -> Tuple[Bot, str]:
"""
创建 Bot
Returns:
(Bot 对象, 明文 Token)
"""
# 生成 Token
token = BotService.generate_token()
token_hash = BotService.hash_token(token)
bot = Bot(
name=name,
display_name=display_name or name,
avatar=avatar,
description=description,
owner_id=owner_id,
agent_id=agent_id,
token_hash=token_hash,
is_system=is_system,
capabilities=capabilities or ['chat'],
config=config or BotService.get_default_config(),
status='offline',
created_at=datetime.utcnow()
)
db.session.add(bot)
db.session.commit()
return bot, token
@staticmethod
def get_default_config() -> Dict[str, Any]:
"""获取默认 Bot 配置"""
return {
"model": "gpt-4o",
"temperature": 0.7,
"max_tokens": 4096,
"system_prompt": "",
"rate_limit": {
"requests_per_minute": 60,
"tokens_per_day": 100000
},
"features": {
"streaming": True,
"markdown": True,
"code_highlight": True,
"file_upload": False
},
"context": {
"max_history": 20,
"summary_threshold": 10
}
}
@staticmethod
def get_bot_by_id(bot_id: str) -> Optional[Bot]:
"""根据 ID 获取 Bot"""
return Bot.query.get(bot_id)
@staticmethod
def get_bot_by_name(name: str) -> Optional[Bot]:
"""根据名称获取 Bot"""
return Bot.query.filter_by(name=name).first()
@staticmethod
def get_bots_by_owner(owner_id: str) -> List[Bot]:
"""获取用户的所有 Bot"""
return Bot.query.filter_by(owner_id=owner_id).order_by(Bot.created_at.desc()).all()
@staticmethod
def get_system_bots() -> List[Bot]:
"""获取所有系统级 Bot"""
return Bot.query.filter_by(is_system=True).order_by(Bot.created_at.desc()).all()
@staticmethod
def get_available_bots(user: User) -> List[Bot]:
"""
获取用户可用的所有 Bot
- 自己的 Bot
- 系统级 Bot
"""
if user.role == 'admin':
return Bot.query.order_by(Bot.created_at.desc()).all()
return Bot.query.filter(
(Bot.owner_id == user.id) | (Bot.is_system == True)
).order_by(Bot.created_at.desc()).all()
@staticmethod
def update_bot(
bot: Bot,
display_name: Optional[str] = None,
avatar: Optional[str] = None,
description: Optional[str] = None,
capabilities: Optional[List[str]] = None,
config: Optional[Dict[str, Any]] = None
) -> Bot:
"""更新 Bot 信息"""
if display_name is not None:
bot.display_name = display_name
if avatar is not None:
bot.avatar = avatar
if description is not None:
bot.description = description
if capabilities is not None:
bot.capabilities = capabilities
if config is not None:
bot.config = {**bot.config, **config} if bot.config else config
db.session.commit()
return bot
@staticmethod
def bind_agent(bot: Bot, agent_id: Optional[str]) -> Bot:
"""
绑定/解绑 Agent
Args:
bot: Bot 对象
agent_id: Agent IDNone 表示解绑
"""
bot.agent_id = agent_id
# 更新状态
if agent_id:
agent = Agent.query.get(agent_id)
if agent and agent.status == 'online':
bot.status = 'online'
else:
bot.status = 'offline'
else:
bot.status = 'offline'
db.session.commit()
return bot
@staticmethod
def regenerate_token(bot: Bot) -> str:
"""重新生成 Token"""
token = BotService.generate_token()
bot.token_hash = BotService.hash_token(token)
db.session.commit()
return token
@staticmethod
def delete_bot(bot: Bot) -> bool:
"""删除 Bot"""
try:
db.session.delete(bot)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
return False
@staticmethod
def update_status(bot: Bot, status: str) -> Bot:
"""更新 Bot 状态"""
bot.status = status
bot.last_active_at = datetime.utcnow()
db.session.commit()
return bot
@staticmethod
def sync_agent_status(bot: Bot) -> Bot:
"""
同步 Agent 状态到 Bot
当 Agent 状态变化时调用
"""
if bot.agent_id:
agent = Agent.query.get(bot.agent_id)
if agent:
bot.status = agent.status
else:
bot.status = 'offline'
else:
bot.status = 'offline'
bot.last_active_at = datetime.utcnow()
db.session.commit()
return bot
@staticmethod
def get_bot_stats(bot: Bot) -> Dict[str, Any]:
"""获取 Bot 统计信息"""
from app.models import Session, Message
session_count = Session.query.filter_by(bot_id=bot.id).count()
message_count = Message.query.filter_by(bot_id=bot.id).count()
return {
'bot_id': bot.id,
'name': bot.name,
'status': bot.status,
'session_count': session_count,
'message_count': message_count,
'is_system': bot.is_system,
'created_at': bot.created_at.isoformat() if bot.created_at else None,
'last_active_at': bot.last_active_at.isoformat() if bot.last_active_at else None,
}

View File

@@ -0,0 +1,391 @@
"""
聊天 WebSocket 事件处理器
实现智队机器人的聊天功能
"""
from flask_socketio import emit, join_room, leave_room, rooms
from flask import request
from datetime import datetime
from typing import Optional, Dict, Any
from app.extensions import db, redis_client
from app.models import User, Session, Message, Bot, Agent, Connection
from app.services.bot_service import BotService
from app.services.session_service import SessionService
class ChatConnectionManager:
"""聊天连接管理器"""
def __init__(self):
# socket_id -> {user_id, current_session_id}
self.connections: Dict[str, Dict[str, Any]] = {}
def add_connection(self, socket_id: str, user_id: str):
"""添加连接"""
self.connections[socket_id] = {
'user_id': user_id,
'current_session_id': None,
'connected_at': datetime.utcnow()
}
def set_current_session(self, socket_id: str, session_id: Optional[str]):
"""设置当前会话"""
if socket_id in self.connections:
self.connections[socket_id]['current_session_id'] = session_id
def get_user_id(self, socket_id: str) -> Optional[str]:
"""获取用户 ID"""
return self.connections.get(socket_id, {}).get('user_id')
def get_current_session(self, socket_id: str) -> Optional[str]:
"""获取当前会话 ID"""
return self.connections.get(socket_id, {}).get('current_session_id')
def remove_connection(self, socket_id: str):
"""移除连接"""
if socket_id in self.connections:
del self.connections[socket_id]
# 全局聊天连接管理器
chat_manager = ChatConnectionManager()
def get_user_from_socket(socket_id: str) -> Optional[User]:
"""从 socket_id 获取用户"""
user_id = chat_manager.get_user_id(socket_id)
if user_id:
return User.query.get(user_id)
return None
def emit_chat_error(message: str, code: str = 'CHAT_ERROR', session_id: Optional[str] = None):
"""发送聊天错误"""
error_data = {
'code': code,
'message': message
}
if session_id:
error_data['session_id'] = session_id
emit('chat_error', error_data)
def register_chat_handlers(socketio):
"""注册聊天事件处理器"""
# ==================== 创建会话 ====================
@socketio.on('chat.send.create')
def handle_chat_create(data):
"""
创建聊天会话
参数:
- bot_id: str - 机器人 ID必填
- title: str - 会话标题(可选)
"""
sid = request.sid
user = get_user_from_socket(sid)
if not user:
return emit_chat_error('User not authenticated', 'AUTH_REQUIRED')
bot_id = data.get('bot_id')
if not bot_id:
return emit_chat_error('bot_id is required', 'MISSING_BOT_ID')
# 获取 Bot
bot = BotService.get_bot_by_id(bot_id)
if not bot:
return emit_chat_error('Bot not found', 'BOT_NOT_FOUND')
# 检查权限
if not BotService.check_permission(user, bot, 'use'):
return emit_chat_error('Permission denied', 'PERMISSION_DENIED')
# 检查 Bot 是否绑定了 Agent
if not bot.agent_id:
return emit_chat_error('Bot has no agent bound', 'NO_AGENT_BOUND')
# 检查 Agent 是否在线
agent = Agent.query.get(bot.agent_id)
if not agent or agent.status != 'online':
return emit_chat_error('Agent is offline', 'AGENT_OFFLINE')
# 创建会话
title = data.get('title', f'Chat with {bot.display_name or bot.name}')
session = Session(
user_id=user.id,
bot_id=bot.id,
primary_agent_id=agent.id,
title=title,
channel_type='pit-bot',
status='active',
created_at=datetime.utcnow(),
last_active_at=datetime.utcnow()
)
db.session.add(session)
db.session.commit()
# 加入房间
join_room(session.id)
chat_manager.set_current_session(sid, session.id)
# 返回创建结果
emit('chat.created', {
'session_id': session.id,
'bot': bot.to_dict(),
'agent': {
'id': agent.id,
'name': agent.name,
'display_name': agent.display_name
},
'title': title,
'created_at': session.created_at.isoformat()
})
# ==================== 加入会话 ====================
@socketio.on('chat.send.join')
def handle_chat_join(data):
"""
加入会话
参数:
- session_id: str - 会话 ID
"""
sid = request.sid
user = get_user_from_socket(sid)
if not user:
return emit_chat_error('User not authenticated', 'AUTH_REQUIRED')
session_id = data.get('session_id')
if not session_id:
return emit_chat_error('session_id is required', 'MISSING_SESSION_ID')
# 获取会话
session = Session.query.get(session_id)
if not session:
return emit_chat_error('Session not found', 'SESSION_NOT_FOUND', session_id)
# 检查权限
if session.user_id != user.id:
return emit_chat_error('Permission denied', 'PERMISSION_DENIED', session_id)
# 获取 Bot 信息
bot = None
if session.bot_id:
bot = BotService.get_bot_by_id(session.bot_id)
# 获取历史消息
messages = Message.query.filter_by(session_id=session_id)\
.order_by(Message.created_at.desc())\
.limit(50)\
.all()
# 加入房间
join_room(session_id)
chat_manager.set_current_session(sid, session_id)
# 返回加入结果
emit('chat.joined', {
'session_id': session_id,
'bot': bot.to_dict() if bot else None,
'messages': [m.to_dict() for m in reversed(messages)],
'message_count': len(messages)
})
# ==================== 离开会话 ====================
@socketio.on('chat.send.leave')
def handle_chat_leave(data):
"""
离开会话
参数:
- session_id: str - 会话 ID
"""
sid = request.sid
session_id = data.get('session_id')
if not session_id:
return emit_chat_error('session_id is required', 'MISSING_SESSION_ID')
# 离开房间
leave_room(session_id)
chat_manager.set_current_session(sid, None)
emit('chat.left', {'session_id': session_id})
# ==================== 发送消息 ====================
@socketio.on('chat.send.message')
def handle_chat_message(data):
"""
发送消息
参数:
- session_id: str - 会话 ID
- content: str - 消息内容
- reply_to: str - 回复的消息 ID可选
"""
sid = request.sid
user = get_user_from_socket(sid)
if not user:
return emit_chat_error('User not authenticated', 'AUTH_REQUIRED')
session_id = data.get('session_id')
content = data.get('content')
reply_to = data.get('reply_to')
if not session_id:
return emit_chat_error('session_id is required', 'MISSING_SESSION_ID')
if not content or not content.strip():
return emit_chat_error('content is required', 'MISSING_CONTENT', session_id)
# 获取会话
session = Session.query.get(session_id)
if not session:
return emit_chat_error('Session not found', 'SESSION_NOT_FOUND', session_id)
# 检查权限
if session.user_id != user.id:
return emit_chat_error('Permission denied', 'PERMISSION_DENIED', session_id)
# 获取 Bot 信息
bot = None
sender_name = user.nickname or user.username
if session.bot_id:
bot = BotService.get_bot_by_id(session.bot_id)
# 创建用户消息
message = Message(
session_id=session_id,
sender_type='user',
sender_id=user.id,
sender_name=sender_name,
bot_id=session.bot_id,
message_type='text',
content=content.strip(),
content_type='markdown',
reply_to=reply_to,
status='sent',
ack_status='pending',
created_at=datetime.utcnow()
)
db.session.add(message)
# 更新会话
session.message_count += 1
session.last_active_at = datetime.utcnow()
session.updated_at = datetime.utcnow()
db.session.commit()
# 广播消息到房间(用户端)
emit('chat.message', {
'message_id': message.id,
'session_id': session_id,
'sender_type': 'user',
'sender_id': user.id,
'sender_name': sender_name,
'bot_id': session.bot_id,
'content': content.strip(),
'content_type': 'markdown',
'reply_to': reply_to,
'timestamp': message.created_at.isoformat()
}, room=session_id)
# TODO: 转发消息给 Agent通过 PIT Channel 协议)
# 这里需要实现将消息转发给绑定的 Agent
# 使用 session.primary_agent_id 获取 Agent
# 然后通过 Agent 的 socket_id 发送消息
# ==================== 正在输入 ====================
@socketio.on('chat.send.typing')
def handle_chat_typing(data):
"""
正在输入状态
参数:
- session_id: str - 会话 ID
- is_typing: bool - 是否正在输入
"""
sid = request.sid
user = get_user_from_socket(sid)
if not user:
return
session_id = data.get('session_id')
is_typing = data.get('is_typing', False)
if not session_id:
return
# 广播输入状态到房间(除了发送者)
emit('chat.typing', {
'session_id': session_id,
'user_id': user.id,
'user_name': user.nickname or user.username,
'is_typing': is_typing
}, room=session_id, include_self=False)
# ==================== 消息已读 ====================
@socketio.on('chat.send.read')
def handle_chat_read(data):
"""
标记消息已读
参数:
- session_id: str - 会话 ID
- message_ids: list - 消息 ID 列表
"""
sid = request.sid
user = get_user_from_socket(sid)
if not user:
return emit_chat_error('User not authenticated', 'AUTH_REQUIRED')
session_id = data.get('session_id')
message_ids = data.get('message_ids', [])
if not session_id:
return emit_chat_error('session_id is required', 'MISSING_SESSION_ID')
# 更新消息状态
for msg_id in message_ids:
message = Message.query.get(msg_id)
if message and message.session_id == session_id:
message.status = 'read'
# 更新会话未读数
session = Session.query.get(session_id)
if session:
session.unread_count = 0
db.session.commit()
# 返回已读确认
emit('chat.read', {
'session_id': session_id,
'message_ids': message_ids
})
# ==================== 关闭会话 ====================
def close_chat_session(session_id: str, reason: str = 'closed'):
"""关闭聊天会话(内部方法)"""
session = Session.query.get(session_id)
if session:
session.status = 'closed'
session.updated_at = datetime.utcnow()
db.session.commit()
# 通知房间内的所有用户
emit('chat.closed', {
'session_id': session_id,
'reason': reason
}, room=session_id)
# 导出注册函数
__all__ = ['register_chat_handlers', 'chat_manager']

View File

@@ -20,6 +20,7 @@ connection_manager = ConnectionManager()
def register_handlers(socketio):
"""注册 Socket.IO 事件处理器"""
# ==================== 连接事件 ====================
@socketio.on('connect')
def handle_connect():
"""客户端连接"""
@@ -38,10 +39,13 @@ def register_handlers(socketio):
if sid in connection_manager.socket_sessions:
del connection_manager.socket_sessions[sid]
# ==================== 认证事件 ====================
@socketio.on('auth')
def handle_auth(data):
"""处理认证"""
from flask import request
from app.models import User
sid = request.sid
token = data.get('token')
@@ -71,11 +75,13 @@ def register_handlers(socketio):
except Exception as e:
emit('auth_error', {'code': 'INVALID_TOKEN', 'message': str(e)})
# ==================== 心跳事件 ====================
@socketio.on('ping')
def handle_ping(data):
"""心跳响应"""
emit('pong', {'timestamp': datetime.utcnow().timestamp()})
# ==================== 会话事件 ====================
@socketio.on('session.create')
def handle_session_create(data):
"""创建会话"""
@@ -124,6 +130,7 @@ def register_handlers(socketio):
'participants': [session.user_id, session.primary_agent_id]
})
# ==================== 消息事件 ====================
@socketio.on('message.send')
def handle_message_send(data):
"""发送消息"""
@@ -180,3 +187,7 @@ def register_handlers(socketio):
db.session.commit()
emit('message.acked', {'message_id': message_id, 'status': status})
# ==================== 聊天事件 (Step 4) ====================
from app.socketio.chat_handlers import register_chat_handlers
register_chat_handlers(socketio)

View File

@@ -69,16 +69,19 @@
</div>
<div class="mt-8 text-center text-sm text-gray-400 dark:text-gray-500">
智队中枢 v0.7.0
智队中枢 v0.7.2
</div>
</div>
<script>
// 主题切换
const savedTheme = localStorage.getItem('theme') || 'light';
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark');
}
// 立即应用主题 - 防止闪烁
(function() {
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
document.documentElement.setAttribute('data-theme', theme);
})();
// 登录处理
document.getElementById('login-form').addEventListener('submit', async (e) => {

166
docs/ISSUES.md Normal file
View File

@@ -0,0 +1,166 @@
# 问题记录文档
> 记录智队中枢项目开发过程中发现的问题、原因分析和解决方案
---
## 问题 #001新建机器人报 "Failed to fetch" 错误
### 问题现象
**发现时间**2026-03-15
**发现版本**v0.9.8
**问题描述**
用户在智队中枢 Web 页面点击「新建机器人」按钮,填写表单提交后,前端报错:
```
Failed to fetch
```
**影响范围**
- 新建机器人功能
- 所有 `/api/bots` 相关请求
---
### 问题原因
**根本原因**Flask 路由尾部斜杠不一致导致 301 重定向
**详细分析**
| 组件 | 路径 | 说明 |
|------|------|------|
| Flask 蓝图注册 | `url_prefix='/api/bots'` | 无尾部斜杠 |
| Flask 路由定义 | `@bots_bp.route('/')` | 有前导斜杠 |
| 实际完整路径 | `/api/bots/` | **有尾部斜杠** |
| 前端请求路径 | `fetch('/api/bots')` | **无尾部斜杠** |
**错误流程**
```
1. 前端发送 POST /api/bots无尾部斜杠
2. Flask 返回 301 重定向 → /api/bots/(有尾部斜杠)
3. POST 请求被重定向时:
- 某些浏览器会将 POST 改为 GET
- fetch API 无法正确处理重定向
4. 前端报错 "Failed to fetch"
```
**验证结果**
```bash
# 不带尾部斜杠 → 301 重定向
curl -X POST http://localhost:9000/api/bots
# 返回: Redirecting to /api/bots/
# 带尾部斜杠 → 正常响应
curl -X POST http://localhost:9000/api/bots/
# 返回: {"msg": "Not enough segments"} # 正常的 JWT 错误
```
---
### 解决方案
**方案一:前端修改(推荐)**
修改 `frontend/src/components/chat/CreateBotModal.vue`
```typescript
// 修改前
const response = await fetch('/api/bots', {
// 修改后
const response = await fetch('/api/bots/', {
```
**方案二:后端修改**
修改 `app/routes/bots.py`
```python
# 修改前
@bots_bp.route('/', methods=['POST'])
# 修改后
@bots_bp.route('', methods=['POST'])
```
**方案三Flask 配置(全局生效)**
`app/__init__.py` 中添加:
```python
app.url_map.strict_slashes = False
```
---
### 解决状态
| 状态 | 日期 |
|------|------|
| ⏳ 待修复 | - |
---
## 问题记录模板
```markdown
## 问题 #XXX[问题标题]
### 问题现象
**发现时间**YYYY-MM-DD
**发现版本**vX.X.X
**问题描述**
[描述问题现象]
**影响范围**
- [影响的功能模块]
---
### 问题原因
**根本原因**[一句话概括]
**详细分析**
[详细的原因分析]
---
### 解决方案
**方案**[解决方案描述]
**代码修改**
```语言
// 修改前
[代码]
// 修改后
[代码]
```
---
### 解决状态
| 状态 | 日期 |
|------|------|
| ✅ 已修复 | YYYY-MM-DD |
---
```
---
_文档创建时间2026-03-15 | 维护者:小白 🐶_

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智队中枢 - Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "pit-chat-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@highlightjs/vue-plugin": "^2.1.0",
"axios": "^1.6.0",
"date-fns": "^3.0.0",
"highlight.js": "^11.11.1",
"markdown-it": "^14.1.1",
"pinia": "^2.1.0",
"socket.io-client": "^4.7.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"@vue/tsconfig": "^0.5.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^1.8.0"
}
}

17
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<div class="app">
<RouterView />
</div>
</template>
<style scoped>
.app {
width: 100%;
height: 100vh;
overflow: hidden;
}
</style>

79
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,79 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// Request interceptor - add auth token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle errors
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// Auth API
export const authApi = {
login: (username: string, password: string) =>
api.post('/auth/login', { username, password }),
register: (username: string, email: string, password: string) =>
api.post('/auth/register', { username, email, password }),
refresh: () => api.post('/auth/refresh'),
me: () => api.get('/auth/me')
}
// Bot API
export const botApi = {
list: (params?: { owner_only?: boolean }) =>
api.get('/bots', { params }),
get: (id: string) => api.get(`/bots/${id}`),
create: (data: any) => api.post('/bots', data),
update: (id: string, data: any) => api.put(`/bots/${id}`, data),
delete: (id: string) => api.delete(`/bots/${id}`),
bind: (id: string, agentId: string) =>
api.post(`/bots/${id}/bind`, { agent_id: agentId }),
unbind: (id: string) => api.post(`/bots/${id}/unbind`),
status: (id: string) => api.get(`/bots/${id}/status`),
stats: (id: string) => api.get(`/bots/${id}/stats`)
}
// Chat API
export const chatApi = {
sessions: {
list: (params?: { status?: string; bot_id?: string; limit?: number; offset?: number }) =>
api.get('/chat/sessions', { params }),
get: (id: string) => api.get(`/chat/sessions/${id}`),
create: (data: { bot_id: string; title?: string }) =>
api.post('/chat/sessions', data),
close: (id: string) => api.delete(`/chat/sessions/${id}`),
messages: {
list: (sessionId: string, params?: { limit?: number; before?: string }) =>
api.get(`/chat/sessions/${sessionId}/messages`, { params }),
send: (sessionId: string, data: { content: string; reply_to?: string }) =>
api.post(`/chat/sessions/${sessionId}/messages`, data)
},
markRead: (id: string) => api.put(`/chat/sessions/${id}/read`)
}
}
export default api

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import type { Bot } from '@/stores/chat'
defineProps<{
bots: Bot[]
}>()
const emit = defineEmits<{
select: [botId: string]
close: []
}>()
</script>
<template>
<div class="modal-overlay" @click.self="emit('close')">
<div class="modal">
<div class="modal-header">
<h3>选择机器人</h3>
<button class="close-btn" @click="emit('close')">×</button>
</div>
<div class="bot-list">
<div
v-for="bot in bots"
:key="bot.id"
class="bot-item"
@click="emit('select', bot.id)"
>
<div class="bot-avatar">
{{ bot.avatar || '🤖' }}
</div>
<div class="bot-info">
<h4>{{ bot.display_name || bot.name }}</h4>
<p v-if="bot.description">{{ bot.description }}</p>
<span class="status" :class="bot.status">
{{ bot.status === 'online' ? '🟢 在线' : '⚪ 离线' }}
</span>
</div>
</div>
<div class="empty" v-if="bots.length === 0">
<p>暂无可用机器人</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-primary);
border-radius: var(--border-radius-lg);
width: 100%;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
padding: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: var(--font-size-md);
font-weight: 600;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 24px;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.close-btn:hover {
background: var(--bg-secondary);
}
.bot-list {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
}
.bot-item {
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-md);
border-radius: var(--border-radius);
cursor: pointer;
transition: background 0.2s;
}
.bot-item:hover {
background: var(--bg-secondary);
}
.bot-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.bot-info {
flex: 1;
min-width: 0;
}
.bot-info h4 {
font-size: var(--font-size-md);
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.bot-info p {
font-size: var(--font-size-sm);
color: var(--text-secondary);
margin-bottom: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bot-info .status {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.bot-info .status.online {
color: var(--success-color);
}
.empty {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-tertiary);
}
</style>

View File

@@ -0,0 +1,410 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { Bot, Agent } from '@/stores/chat'
const props = defineProps<{
bot: Bot
}>()
const emit = defineEmits<{
close: []
updated: [bot: Bot]
}>()
const agents = ref<Agent[]>([])
const selectedAgentId = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
onMounted(async () => {
await fetchAgents()
selectedAgentId.value = props.bot.agent_id || null
})
async function fetchAgents() {
try {
const response = await fetch('/api/agents/available', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
})
const data = await response.json()
agents.value = data.agents || []
} catch (e) {
console.error('Fetch agents failed:', e)
}
}
async function handleBind() {
if (!selectedAgentId.value) {
error.value = '请选择一个 Agent'
return
}
loading.value = true
error.value = ''
try {
const response = await fetch(`/api/bots/${props.bot.id}/bind`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({ agent_id: selectedAgentId.value })
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || '绑定失败')
}
const data = await response.json()
emit('updated', data.bot)
emit('close')
} catch (e: any) {
error.value = e.message || '绑定失败'
} finally {
loading.value = false
}
}
async function handleUnbind() {
loading.value = true
error.value = ''
try {
const response = await fetch(`/api/bots/${props.bot.id}/unbind`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || '解绑失败')
}
const data = await response.json()
selectedAgentId.value = null
emit('updated', data.bot)
} catch (e: any) {
error.value = e.message || '解绑失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="modal-overlay" @click.self="emit('close')">
<div class="modal">
<div class="modal-header">
<h3>机器人设置</h3>
<button class="close-btn" @click="emit('close')">×</button>
</div>
<div class="modal-body">
<!-- Bot Info -->
<div class="bot-info">
<div class="bot-avatar">{{ bot.avatar || '🤖' }}</div>
<div class="bot-details">
<h4>{{ bot.display_name || bot.name }}</h4>
<p class="bot-status" :class="bot.status">
{{ bot.status === 'online' ? '🟢 在线' : '⚪ 离线' }}
</p>
</div>
</div>
<!-- Agent Selection -->
<div class="form-group">
<label>绑定 Agent</label>
<p class="hint">选择一个在线的 Agent 来启用此机器人</p>
<div class="agent-list" v-if="agents.length > 0">
<label
v-for="agent in agents"
:key="agent.id"
class="agent-item"
:class="{ selected: selectedAgentId === agent.id }"
>
<input
type="radio"
:value="agent.id"
v-model="selectedAgentId"
/>
<div class="agent-avatar">🤖</div>
<div class="agent-info">
<span class="agent-name">{{ agent.display_name || agent.name }}</span>
<span class="agent-status" :class="agent.status">
{{ agent.status === 'online' ? '在线' : '离线' }}
</span>
</div>
</label>
</div>
<div class="empty" v-else>
<p>暂无可用 Agent</p>
<p class="hint">请确保至少有一个 Agent 在线</p>
</div>
</div>
<!-- Error -->
<div v-if="error" class="error-message">
{{ error }}
</div>
<!-- Actions -->
<div class="modal-actions">
<button
v-if="bot.agent_id"
type="button"
class="btn-unbind"
@click="handleUnbind"
:disabled="loading"
>
解绑
</button>
<button
type="button"
class="btn-bind"
@click="handleBind"
:disabled="loading || !selectedAgentId"
>
{{ loading ? '处理中...' : '绑定' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-primary);
border-radius: var(--border-radius-lg);
width: 100%;
max-width: 480px;
overflow: hidden;
}
.modal-header {
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: var(--font-size-lg);
font-weight: 600;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 24px;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.close-btn:hover {
background: var(--bg-secondary);
}
.modal-body {
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.bot-info {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--bg-secondary);
border-radius: var(--border-radius);
}
.bot-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.bot-details h4 {
font-size: var(--font-size-md);
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.bot-status {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.bot-status.online {
color: var(--success-color);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.form-group label {
font-size: var(--font-size-sm);
font-weight: 500;
}
.hint {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.agent-list {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
max-height: 240px;
overflow-y: auto;
}
.agent-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.2s;
}
.agent-item:hover {
background: var(--bg-secondary);
}
.agent-item.selected {
border-color: var(--primary-color);
background: rgba(99, 102, 241, 0.1);
}
.agent-item input {
display: none;
}
.agent-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.agent-info {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.agent-name {
font-size: var(--font-size-sm);
font-weight: 500;
}
.agent-status {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.agent-status.online {
color: var(--success-color);
}
.empty {
text-align: center;
padding: var(--spacing-lg);
color: var(--text-tertiary);
}
.error-message {
color: var(--error-color);
font-size: var(--font-size-sm);
padding: var(--spacing-sm);
background: rgba(239, 68, 68, 0.1);
border-radius: var(--border-radius);
}
.modal-actions {
display: flex;
gap: var(--spacing-sm);
}
.btn-unbind,
.btn-bind {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius);
font-size: var(--font-size-md);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-unbind {
background: transparent;
border: 1px solid var(--error-color);
color: var(--error-color);
}
.btn-unbind:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
}
.btn-bind {
background: var(--primary-color);
border: none;
color: white;
}
.btn-bind:hover:not(:disabled) {
background: var(--primary-dark);
}
.btn-unbind:disabled,
.btn-bind:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useChatStore } from '@/stores/chat'
const emit = defineEmits<{
newChat: []
}>()
const chatStore = useChatStore()
onMounted(() => {
chatStore.fetchSessions()
})
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days}天前`
return date.toLocaleDateString()
}
</script>
<template>
<aside class="sidebar">
<div class="sidebar-header">
<h2>对话</h2>
<button class="new-chat-btn" @click="emit('newChat')">
+ 新对话
</button>
</div>
<div class="session-list">
<div
v-for="session in chatStore.sessions"
:key="session.id"
class="session-item"
:class="{ active: chatStore.currentSession?.id === session.id }"
@click="chatStore.joinSession(session.id)"
>
<div class="session-avatar">
{{ session.bot?.avatar || '🤖' }}
</div>
<div class="session-content">
<div class="session-header">
<span class="session-title">{{ session.title }}</span>
<span class="session-time">{{ formatDate(session.last_active_at || session.created_at) }}</span>
</div>
<p class="session-preview" v-if="session.last_message">
{{ session.last_message.content.slice(0, 30) }}...
</p>
</div>
</div>
<div class="empty" v-if="chatStore.sessions.length === 0">
<p>暂无对话</p>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 280px;
background: var(--bg-primary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-header {
padding: var(--spacing-md);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 {
font-size: var(--font-size-md);
font-weight: 600;
}
.new-chat-btn {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
cursor: pointer;
}
.new-chat-btn:hover {
background: var(--primary-dark);
}
.session-list {
flex: 1;
overflow-y: auto;
}
.session-item {
padding: var(--spacing-md);
cursor: pointer;
display: flex;
gap: var(--spacing-sm);
border-bottom: 1px solid var(--border-color);
transition: background 0.2s;
}
.session-item:hover {
background: var(--bg-secondary);
}
.session-item.active {
background: var(--bg-secondary);
border-left: 3px solid var(--primary-color);
}
.session-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.session-content {
flex: 1;
min-width: 0;
}
.session-header {
display: flex;
justify-content: space-between;
margin-bottom: var(--spacing-xs);
}
.session-title {
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-time {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.session-preview {
font-size: var(--font-size-xs);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty {
padding: var(--spacing-xl);
text-align: center;
color: var(--text-tertiary);
}
</style>

View File

@@ -0,0 +1,210 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import MarkdownMessage from '@/components/chat/MarkdownMessage.vue'
const chatStore = useChatStore()
const messagesContainer = ref<HTMLElement | null>(null)
const messages = computed(() => chatStore.messages)
watch(messages, () => {
nextTick(() => {
scrollToBottom()
})
}, { deep: true })
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
function formatTime(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
function isOwnMessage(msg: any): boolean {
return msg.sender_type === 'user'
}
</script>
<template>
<div class="chat-window">
<div class="chat-header">
<div class="chat-info">
<div class="bot-avatar">
{{ chatStore.currentSession?.bot?.avatar || '🤖' }}
</div>
<div class="chat-title">
<h3>{{ chatStore.currentSession?.title }}</h3>
<span class="status" :class="chatStore.currentSession?.bot?.status">
{{ chatStore.currentSession?.bot?.status === 'online' ? '在线' : '离线' }}
</span>
</div>
</div>
</div>
<div class="messages-container" ref="messagesContainer">
<div class="messages-list">
<div
v-for="message in messages"
:key="message.id"
class="message"
:class="{ own: isOwnMessage(message) }"
>
<div class="message-avatar">
{{ message.sender_type === 'user' ? '👤' : (chatStore.currentSession?.bot?.avatar || '🤖') }}
</div>
<div class="message-content">
<div class="message-header">
<span class="sender-name">{{ message.sender_name }}</span>
<span class="message-time">{{ formatTime(message.created_at) }}</span>
</div>
<div class="message-bubble">
<MarkdownMessage :content="message.content" />
</div>
</div>
</div>
<div class="empty-messages" v-if="messages.length === 0">
<p>暂无消息开始对话吧</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.chat-window {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-md);
}
.chat-info {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.bot-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.chat-title h3 {
font-size: var(--font-size-md);
font-weight: 600;
margin-bottom: 2px;
}
.chat-title .status {
font-size: var(--font-size-xs);
color: var(--text-secondary);
}
.chat-title .status.online {
color: var(--success-color);
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
}
.messages-list {
max-width: 900px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.message {
display: flex;
gap: var(--spacing-sm);
max-width: 80%;
}
.message.own {
flex-direction: row-reverse;
margin-left: auto;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex-shrink: 0;
}
.message-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.message-header {
display: flex;
gap: var(--spacing-sm);
align-items: center;
font-size: var(--font-size-xs);
}
.message.own .message-header {
flex-direction: row-reverse;
}
.sender-name {
font-weight: 500;
color: var(--text-secondary);
}
.message-time {
color: var(--text-tertiary);
}
.message-bubble {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-primary);
border-radius: var(--border-radius-lg);
color: var(--text-primary);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.message.own .message-bubble {
background: var(--primary-color);
color: white;
}
.empty-messages {
text-align: center;
color: var(--text-tertiary);
padding: var(--spacing-xl);
}
</style>

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
close: []
created: [bot: any]
}>()
const name = ref('')
const displayName = ref('')
const description = ref('')
const selectedAvatar = ref('🤖')
const loading = ref(false)
const error = ref('')
const avatars = [
'🤖', '🐶', '🐱', '🦊', '🐼', '🐸',
'🦁', '🐯', '🐨', '🐮', '🐷', '🐵'
]
function selectAvatar(avatar: string) {
selectedAvatar.value = avatar
}
async function handleCreate() {
error.value = ''
if (!name.value.trim()) {
error.value = '请输入机器人名称'
return
}
loading.value = true
try {
const response = await fetch('/api/bots', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify({
name: name.value.trim(),
display_name: displayName.value.trim() || undefined,
avatar: selectedAvatar.value,
description: description.value.trim() || undefined
})
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || '创建失败')
}
const data = await response.json()
emit('created', data.bot)
emit('close')
} catch (e: any) {
error.value = e.message || '创建失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="modal-overlay" @click.self="emit('close')">
<div class="modal">
<div class="modal-header">
<h3>新建机器人</h3>
<button class="close-btn" @click="emit('close')">×</button>
</div>
<form class="modal-body" @submit.prevent="handleCreate">
<!-- Avatar Selector -->
<div class="form-group">
<label>选择头像</label>
<div class="avatar-grid">
<button
v-for="avatar in avatars"
:key="avatar"
type="button"
class="avatar-btn"
:class="{ selected: selectedAvatar === avatar }"
@click="selectAvatar(avatar)"
>
{{ avatar }}
</button>
</div>
</div>
<!-- Name -->
<div class="form-group">
<label for="name">名称 <span class="required">*</span></label>
<input
id="name"
v-model="name"
type="text"
placeholder="机器人唯一标识(如 xiaobai"
maxlength="20"
/>
</div>
<!-- Display Name -->
<div class="form-group">
<label for="displayName">显示名称</label>
<input
id="displayName"
v-model="displayName"
type="text"
placeholder="显示给用户的名称(如 小白)"
maxlength="20"
/>
</div>
<!-- Description -->
<div class="form-group">
<label for="description">描述</label>
<textarea
id="description"
v-model="description"
placeholder="机器人简介..."
rows="3"
maxlength="200"
/>
</div>
<!-- Error -->
<div v-if="error" class="error-message">
{{ error }}
</div>
<!-- Actions -->
<div class="modal-actions">
<button type="button" class="btn-cancel" @click="emit('close')">
取消
</button>
<button type="submit" class="btn-create" :disabled="loading">
{{ loading ? '创建中...' : '创建' }}
</button>
</div>
</form>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-primary);
border-radius: var(--border-radius-lg);
width: 100%;
max-width: 440px;
overflow: hidden;
}
.modal-header {
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: var(--font-size-lg);
font-weight: 600;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 24px;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.close-btn:hover {
background: var(--bg-secondary);
}
.modal-body {
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.form-group label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
}
.required {
color: var(--error-color);
}
.form-group input,
.form-group textarea {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: var(--font-size-md);
background: var(--bg-secondary);
color: var(--text-primary);
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
}
.avatar-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: var(--spacing-sm);
}
.avatar-btn {
width: 48px;
height: 48px;
border: 2px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--bg-secondary);
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.avatar-btn:hover {
border-color: var(--primary-light);
}
.avatar-btn.selected {
border-color: var(--primary-color);
background: var(--primary-light);
background: rgba(99, 102, 241, 0.1);
}
.error-message {
color: var(--error-color);
font-size: var(--font-size-sm);
padding: var(--spacing-sm);
background: rgba(239, 68, 68, 0.1);
border-radius: var(--border-radius);
}
.modal-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-sm);
}
.btn-cancel,
.btn-create {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius);
font-size: var(--font-size-md);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-cancel {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.btn-cancel:hover {
background: var(--bg-secondary);
}
.btn-create {
background: var(--primary-color);
border: none;
color: white;
}
.btn-create:hover:not(:disabled) {
background: var(--primary-dark);
}
.btn-create:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed } from 'vue'
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
const props = defineProps<{
content: string
}>()
const md = new MarkdownIt({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
} catch (__) {}
}
return hljs.highlightAuto(str).value
},
linkify: true,
breaks: true
})
const renderedContent = computed(() => {
return md.render(props.content)
})
</script>
<template>
<div class="markdown-body" v-html="renderedContent" />
</template>
<style scoped>
.markdown-body {
color: inherit;
line-height: 1.6;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3),
.markdown-body :deep(h4),
.markdown-body :deep(h5),
.markdown-body :deep(h6) {
margin-top: 16px;
margin-bottom: 8px;
font-weight: 600;
}
.markdown-body :deep(h1) { font-size: 1.5em; }
.markdown-body :deep(h2) { font-size: 1.3em; }
.markdown-body :deep(h3) { font-size: 1.1em; }
.markdown-body :deep(h4) { font-size: 1em; }
.markdown-body :deep(p) {
margin-bottom: 12px;
}
.markdown-body :deep(ul),
.markdown-body :deep(ol) {
margin-bottom: 12px;
padding-left: 24px;
}
.markdown-body :deep(li) {
margin-bottom: 4px;
}
.markdown-body :deep(pre) {
background: #1e1e1e;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin-bottom: 12px;
}
.markdown-body :deep(code) {
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
font-size: 0.9em;
}
.markdown-body :deep(:not(pre) > code) {
background: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
}
.markdown-body :deep(a) {
color: var(--primary-color);
text-decoration: none;
}
.markdown-body :deep(a:hover) {
text-decoration: underline;
}
.markdown-body :deep(blockquote) {
border-left: 4px solid var(--primary-color);
padding-left: 16px;
margin-left: 0;
color: var(--text-secondary);
}
.markdown-body :deep(table) {
border-collapse: collapse;
width: 100%;
margin-bottom: 12px;
}
.markdown-body :deep(th),
.markdown-body :deep(td) {
border: 1px solid var(--border-color);
padding: 8px;
}
.markdown-body :deep(th) {
background: var(--bg-secondary);
}
</style>

12
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,40 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue')
},
{
path: '/chat/:sessionId?',
name: 'chat',
component: () => import('@/views/ChatView.vue')
},
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Navigation guard
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('access_token')
if (to.name !== 'login' && !token) {
next({ name: 'login' })
} else if (to.name === 'login' && token) {
next({ name: 'home' })
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,70 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api'
interface User {
id: string
username: string
email: string
nickname?: string
role: string
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('access_token'))
const loading = ref(false)
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
async function login(username: string, password: string) {
loading.value = true
try {
const response = await authApi.login(username, password)
const { access_token } = response.data
token.value = access_token
localStorage.setItem('access_token', access_token)
await fetchUser()
return true
} catch (error) {
console.error('Login failed:', error)
return false
} finally {
loading.value = false
}
}
async function fetchUser() {
if (!token.value) return
try {
const response = await authApi.me()
user.value = response.data.user
} catch (error) {
console.error('Fetch user failed:', error)
logout()
}
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('access_token')
}
// Initialize
if (token.value) {
fetchUser()
}
return {
user,
token,
loading,
isLoggedIn,
isAdmin,
login,
logout,
fetchUser
}
})

284
frontend/src/stores/chat.ts Normal file
View File

@@ -0,0 +1,284 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { io, Socket } from 'socket.io-client'
import { chatApi, botApi } from '@/api'
export interface Bot {
id: string
name: string
display_name?: string
avatar?: string
description?: string
status: string
agent_id?: string
is_system?: boolean
capabilities?: string[]
config?: Record<string, any>
}
export interface Agent {
id: string
name: string
display_name?: string
status: string
model?: string
capabilities?: string[]
}
export interface Message {
id: string
session_id: string
sender_type: string
sender_id: string
sender_name?: string
bot_id?: string
content: string
content_type: string
reply_to?: string
status: string
created_at: string
}
export interface ChatSession {
id: string
user_id: string
bot_id?: string
title: string
status: string
message_count: number
unread_count: number
created_at: string
last_active_at?: string
bot?: Bot
last_message?: {
id: string
content: string
sender_type: string
created_at: string
}
}
export const useChatStore = defineStore('chat', () => {
const socket = ref<Socket | null>(null)
const connected = ref(false)
const bots = ref<Bot[]>([])
const sessions = ref<ChatSession[]>([])
const currentSession = ref<ChatSession | null>(null)
const messages = ref<Message[]>([])
const loading = ref(false)
const hasActiveSession = computed(() => !!currentSession.value)
// Socket event handlers
function setupSocket() {
if (socket.value) return
const token = localStorage.getItem('access_token')
socket.value = io({
auth: { token },
transports: ['websocket']
})
socket.value.on('connect', () => {
connected.value = true
console.log('Socket connected')
})
socket.value.on('disconnect', () => {
connected.value = false
console.log('Socket disconnected')
})
socket.value.on('chat.message', (data: Message) => {
if (currentSession.value?.id === data.session_id) {
messages.value.push(data)
}
})
socket.value.on('chat.typing', (data: any) => {
// Handle typing indicator
})
socket.value.on('chat_error', (data: any) => {
console.error('Chat error:', data)
})
}
function disconnectSocket() {
if (socket.value) {
socket.value.disconnect()
socket.value = null
connected.value = false
}
}
// Bot actions
async function fetchBots() {
try {
const response = await botApi.list()
bots.value = response.data.bots
} catch (error) {
console.error('Fetch bots failed:', error)
}
}
// Session actions
async function fetchSessions() {
loading.value = true
try {
const response = await chatApi.sessions.list({ limit: 50 })
sessions.value = response.data.sessions
} catch (error) {
console.error('Fetch sessions failed:', error)
} finally {
loading.value = false
}
}
async function createSession(botId: string, title?: string) {
try {
const response = await chatApi.sessions.create({ bot_id: botId, title })
const session = response.data.session
sessions.value.unshift(session)
return session
} catch (error) {
console.error('Create session failed:', error)
throw error
}
}
async function joinSession(sessionId: string) {
try {
// Fetch session details
const sessionResponse = await chatApi.sessions.get(sessionId)
currentSession.value = sessionResponse.data.session
// Fetch messages
const messagesResponse = await chatApi.sessions.messages.list(sessionId)
messages.value = messagesResponse.data.messages
// Join socket room
if (socket.value) {
socket.value.emit('chat.send.join', { session_id: sessionId })
}
return currentSession.value
} catch (error) {
console.error('Join session failed:', error)
throw error
}
}
async function leaveSession() {
if (socket.value && currentSession.value) {
socket.value.emit('chat.send.leave', { session_id: currentSession.value.id })
}
currentSession.value = null
messages.value = []
}
// Message actions
async function sendMessage(content: string, replyTo?: string) {
if (!currentSession.value) return
try {
const response = await chatApi.sessions.messages.send(currentSession.value.id, {
content,
reply_to: replyTo
})
return response.data.message
} catch (error) {
console.error('Send message failed:', error)
throw error
}
}
function sendTyping(isTyping: boolean) {
if (socket.value && currentSession.value) {
socket.value.emit('chat.send.typing', {
session_id: currentSession.value.id,
is_typing: isTyping
})
}
}
// Agent actions
async function fetchAgents() {
try {
const response = await fetch('/api/agents/available')
return await response.json()
} catch (error) {
console.error('Fetch agents failed:', error)
throw error
}
}
// Bot actions
async function createBot(data: {
name: string
display_name?: string
avatar?: string
description?: string
}) {
try {
const response = await botApi.create(data)
const newBot = response.data.bot
bots.value.push(newBot)
return { bot: newBot, token: response.data.token }
} catch (error) {
console.error('Create bot failed:', error)
throw error
}
}
async function bindBotAgent(botId: string, agentId: string) {
try {
const response = await botApi.bind(botId, agentId)
const updatedBot = response.data.bot
// Update bot in list
const index = bots.value.findIndex(b => b.id === botId)
if (index !== -1) {
bots.value[index] = { ...bots.value[index], ...updatedBot }
}
return updatedBot
} catch (error) {
console.error('Bind bot agent failed:', error)
throw error
}
}
async function deleteBot(botId: string) {
try {
await botApi.delete(botId)
bots.value = bots.value.filter(b => b.id !== botId)
} catch (error) {
console.error('Delete bot failed:', error)
throw error
}
}
return {
socket,
connected,
bots,
sessions,
currentSession,
messages,
loading,
hasActiveSession,
setupSocket,
disconnectSocket,
fetchBots,
fetchAgents,
fetchSessions,
createBot,
createSession,
joinSession,
leaveSession,
sendMessage,
sendTyping,
bindBotAgent,
deleteBot
}
})

143
frontend/src/style.css Normal file
View File

@@ -0,0 +1,143 @@
/* Base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Colors */
--primary-color: #6366f1;
--primary-light: #818cf8;
--primary-dark: #4f46e5;
--secondary-color: #ec4899;
--success-color: #10b981;
--warning-color: #f59e0b;
--error-color: #ef4444;
/* Background */
--bg-primary: #ffffff;
--bg-secondary: #f3f4f6;
--bg-tertiary: #e5e7eb;
/* Text */
--text-primary: #111827;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
/* Border */
--border-color: #e5e7eb;
--border-radius: 8px;
--border-radius-lg: 12px;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* Shadow */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
/* Font */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 18px;
--font-size-xl: 20px;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--border-color: #334155;
}
}
body {
font-family: var(--font-family);
font-size: var(--font-size-md);
color: var(--text-primary);
background-color: var(--bg-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Utility classes */
.flex {
display: flex;
}
.flex-col {
display: flex;
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.gap-sm {
gap: var(--spacing-sm);
}
.gap-md {
gap: var(--spacing-md);
}
.gap-lg {
gap: var(--spacing-lg);
}
.text-secondary {
color: var(--text-secondary);
}
.text-tertiary {
color: var(--text-tertiary);
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import ChatSidebar from '@/components/chat/ChatSidebar.vue'
import ChatWindow from '@/components/chat/ChatWindow.vue'
import BotSelector from '@/components/chat/BotSelector.vue'
const route = useRoute()
const router = useRouter()
const chatStore = useChatStore()
const showBotSelector = ref(false)
const messageInput = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
onMounted(async () => {
await chatStore.fetchBots()
chatStore.setupSocket()
const sessionId = route.params.sessionId as string
if (sessionId) {
await chatStore.joinSession(sessionId)
}
})
watch(() => route.params.sessionId, async (newId) => {
if (newId && typeof newId === 'string') {
await chatStore.joinSession(newId)
} else {
chatStore.leaveSession()
}
})
watch(() => chatStore.messages, () => {
nextTick(() => {
scrollToBottom()
})
}, { deep: true })
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
async function handleSend() {
if (!messageInput.value.trim() || !chatStore.currentSession) return
const content = messageInput.value.trim()
messageInput.value = ''
try {
await chatStore.sendMessage(content)
} catch (error) {
console.error('Send failed:', error)
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
async function createSession(botId: string) {
showBotSelector.value = false
const session = await chatStore.createSession(botId)
router.push({ name: 'chat', params: { sessionId: session.id } })
}
</script>
<template>
<div class="chat-page">
<ChatSidebar @new-chat="showBotSelector = true" />
<div class="chat-main" v-if="chatStore.currentSession">
<ChatWindow />
<div class="chat-input-area">
<div class="input-wrapper">
<textarea
v-model="messageInput"
placeholder="输入消息..."
rows="1"
@keydown="handleKeydown"
@input="chatStore.sendTyping(true)"
/>
<button
class="send-btn"
@click="handleSend"
:disabled="!messageInput.trim()"
>
发送
</button>
</div>
</div>
</div>
<div class="empty-state" v-else>
<div class="empty-content">
<h2>👋 欢迎使用智队中枢</h2>
<p>选择一个机器人开始聊天</p>
<button class="start-btn" @click="showBotSelector = true">
开始新对话
</button>
</div>
</div>
<BotSelector
v-if="showBotSelector"
:bots="chatStore.bots"
@select="createSession"
@close="showBotSelector = false"
/>
</div>
</template>
<style scoped>
.chat-page {
display: flex;
height: 100vh;
background: var(--bg-secondary);
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-input-area {
background: var(--bg-primary);
border-top: 1px solid var(--border-color);
padding: var(--spacing-md);
}
.input-wrapper {
display: flex;
gap: var(--spacing-sm);
max-width: 900px;
margin: 0 auto;
}
.input-wrapper textarea {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: var(--font-size-md);
background: var(--bg-secondary);
color: var(--text-primary);
resize: none;
min-height: 44px;
max-height: 120px;
font-family: inherit;
}
.input-wrapper textarea:focus {
outline: none;
border-color: var(--primary-color);
}
.send-btn {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: var(--font-size-md);
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.send-btn:hover:not(:disabled) {
background: var(--primary-dark);
}
.send-btn:disabled {
background: var(--text-tertiary);
cursor: not-allowed;
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-content {
text-align: center;
color: var(--text-secondary);
}
.empty-content h2 {
font-size: var(--font-size-xl);
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.empty-content p {
margin-bottom: var(--spacing-lg);
}
.start-btn {
padding: var(--spacing-sm) var(--spacing-lg);
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: var(--font-size-md);
cursor: pointer;
}
.start-btn:hover {
background: var(--primary-dark);
}
</style>

View File

@@ -0,0 +1,402 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChatStore, type Bot } from '@/stores/chat'
import CreateBotModal from '@/components/chat/CreateBotModal.vue'
import BotSettingsModal from '@/components/chat/BotSettingsModal.vue'
const router = useRouter()
const authStore = useAuthStore()
const chatStore = useChatStore()
const showCreateBot = ref(false)
const showBotSettings = ref(false)
const selectedBot = ref<Bot | null>(null)
onMounted(async () => {
await chatStore.fetchBots()
await chatStore.fetchSessions()
chatStore.setupSocket()
})
function startChat(botId: string) {
router.push({ name: 'chat', params: {} })
chatStore.createSession(botId).then(session => {
router.push({ name: 'chat', params: { sessionId: session.id } })
})
}
function resumeChat(sessionId: string) {
router.push({ name: 'chat', params: { sessionId } })
}
function handleBotCreated(bot: Bot) {
chatStore.bots.push(bot)
}
function openBotSettings(bot: Bot, event: Event) {
event.stopPropagation()
selectedBot.value = bot
showBotSettings.value = true
}
function handleBotUpdated(bot: Bot) {
const index = chatStore.bots.findIndex(b => b.id === bot.id)
if (index !== -1) {
chatStore.bots[index] = bot
}
selectedBot.value = bot
}
</script>
<template>
<div class="home-page">
<header class="header">
<div class="header-content">
<h1>🐕 智队中枢</h1>
<div class="user-actions">
<button class="new-bot-btn" @click="showCreateBot = true">
+ 新建机器人
</button>
<div class="user-info">
<span>{{ authStore.user?.nickname || authStore.user?.username }}</span>
<button class="logout-btn" @click="authStore.logout">退出</button>
</div>
</div>
</div>
</header>
<main class="main-content">
<!-- Bot List -->
<section class="section">
<div class="section-header">
<h2 class="section-title">选择机器人开始聊天</h2>
<span class="bot-count">{{ chatStore.bots.length }} 个机器人</span>
</div>
<div class="bot-grid">
<div
v-for="bot in chatStore.bots"
:key="bot.id"
class="bot-card"
@click="startChat(bot.id)"
>
<div class="bot-avatar">
{{ bot.avatar || '🤖' }}
</div>
<div class="bot-info">
<h3 class="bot-name">{{ bot.display_name || bot.name }}</h3>
<p class="bot-status" :class="bot.status">
{{ bot.status === 'online' ? '🟢 在线' : '⚪ 离线' }}
</p>
</div>
<button
class="settings-btn"
@click="openBotSettings(bot, $event)"
>
</button>
</div>
</div>
</section>
<!-- Recent Sessions -->
<section class="section" v-if="chatStore.sessions.length > 0">
<h2 class="section-title">最近会话</h2>
<div class="session-list">
<div
v-for="session in chatStore.sessions.slice(0, 10)"
:key="session.id"
class="session-item"
@click="resumeChat(session.id)"
>
<div class="session-avatar">
{{ session.bot?.avatar || '🤖' }}
</div>
<div class="session-info">
<div class="session-header">
<h4 class="session-title">{{ session.title }}</h4>
<span class="session-time">{{ new Date(session.last_active_at || session.created_at).toLocaleDateString() }}</span>
</div>
<p class="session-preview" v-if="session.last_message">
{{ session.last_message.content.slice(0, 50) }}...
</p>
<p class="session-preview" v-else>暂无消息</p>
</div>
<div class="session-badge" v-if="session.unread_count > 0">
{{ session.unread_count }}
</div>
</div>
</div>
</section>
</main>
<CreateBotModal
v-if="showCreateBot"
@close="showCreateBot = false"
@created="handleBotCreated"
/>
<BotSettingsModal
v-if="showBotSettings && selectedBot"
:bot="selectedBot"
@close="showBotSettings = false"
@updated="handleBotUpdated"
/>
</div>
</template>
<style scoped>
.home-page {
min-height: 100vh;
background: var(--bg-secondary);
}
.header {
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-md) var(--spacing-lg);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: var(--font-size-lg);
color: var(--primary-color);
}
.user-actions {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.new-bot-btn {
padding: var(--spacing-xs) var(--spacing-md);
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.new-bot-btn:hover {
background: var(--primary-dark);
}
.user-info {
display: flex;
align-items: center;
gap: var(--spacing-md);
color: var(--text-secondary);
}
.logout-btn {
padding: var(--spacing-xs) var(--spacing-sm);
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--font-size-sm);
}
.logout-btn:hover {
border-color: var(--error-color);
color: var(--error-color);
}
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-lg);
}
.section {
margin-bottom: var(--spacing-xl);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-md);
}
.section-title {
font-size: var(--font-size-md);
font-weight: 600;
color: var(--text-primary);
}
.bot-count {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
}
.bot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--spacing-md);
}
.bot-card {
background: var(--bg-primary);
border-radius: var(--border-radius-lg);
padding: var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-md);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
}
.bot-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.settings-btn {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border: none;
background: var(--bg-tertiary);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
}
.bot-card:hover .settings-btn {
opacity: 1;
}
.settings-btn:hover {
background: var(--bg-primary);
}
.bot-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.bot-info {
flex: 1;
}
.bot-name {
font-size: var(--font-size-md);
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.bot-status {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.bot-status.online {
color: var(--success-color);
}
.session-list {
background: var(--bg-primary);
border-radius: var(--border-radius-lg);
overflow: hidden;
}
.session-item {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
cursor: pointer;
border-bottom: 1px solid var(--border-color);
transition: background 0.2s;
}
.session-item:last-child {
border-bottom: none;
}
.session-item:hover {
background: var(--bg-secondary);
}
.session-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.session-info {
flex: 1;
min-width: 0;
}
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-xs);
}
.session-title {
font-weight: 500;
color: var(--text-primary);
}
.session-time {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
.session-preview {
font-size: var(--font-size-sm);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-badge {
min-width: 20px;
height: 20px;
padding: 0 6px;
background: var(--error-color);
color: white;
border-radius: 10px;
font-size: var(--font-size-xs);
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const error = ref('')
async function handleLogin() {
error.value = ''
if (!username.value || !password.value) {
error.value = '请输入用户名和密码'
return
}
const success = await authStore.login(username.value, password.value)
if (success) {
router.push('/')
} else {
error.value = '登录失败,请检查用户名和密码'
}
}
</script>
<template>
<div class="login-page">
<div class="login-card">
<div class="logo">
<h1>🐕 智队中枢</h1>
<p>Personal Intelligent Team</p>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="form-group">
<label for="username">用户名</label>
<input
id="username"
v-model="username"
type="text"
placeholder="请输入用户名"
autocomplete="username"
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<input
id="password"
v-model="password"
type="password"
placeholder="请输入密码"
autocomplete="current-password"
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" class="login-btn" :disabled="authStore.loading">
{{ authStore.loading ? '登录中...' : '登录' }}
</button>
</form>
</div>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
}
.login-card {
background: var(--bg-primary);
border-radius: var(--border-radius-lg);
padding: var(--spacing-xl);
width: 100%;
max-width: 400px;
box-shadow: var(--shadow-lg);
}
.logo {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.logo h1 {
font-size: var(--font-size-xl);
color: var(--primary-color);
margin-bottom: var(--spacing-xs);
}
.logo p {
color: var(--text-secondary);
font-size: var(--font-size-sm);
}
.login-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.form-group label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--text-primary);
}
.form-group input {
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: var(--font-size-md);
background: var(--bg-primary);
color: var(--text-primary);
}
.form-group input:focus {
outline: none;
border-color: var(--primary-color);
}
.error-message {
color: var(--error-color);
font-size: var(--font-size-sm);
text-align: center;
}
.login-btn {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
font-size: var(--font-size-md);
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.login-btn:hover:not(:disabled) {
background: var(--primary-dark);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:9000',
changeOrigin: true
},
'/socket.io': {
target: 'http://localhost:9000',
ws: true
}
}
}
})

View File

@@ -0,0 +1,69 @@
"""add bot model
Revision ID: add_bot_model
Revises: initial
Create Date: 2026-03-15 10:22:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'add_bot_model'
down_revision = 'initial'
branch_labels = None
depends_on = None
def upgrade():
# 创建 bots 表
op.create_table('bots',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('name', sa.String(80), unique=True, nullable=False),
sa.Column('display_name', sa.String(80), nullable=True),
sa.Column('avatar', sa.String(256), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('owner_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False),
sa.Column('agent_id', sa.String(36), sa.ForeignKey('agents.id'), nullable=True),
sa.Column('token_hash', sa.String(256), nullable=True),
sa.Column('is_system', sa.Boolean(), default=False),
sa.Column('status', sa.String(20), default='offline'),
sa.Column('capabilities', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('config', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('last_active_at', sa.DateTime(), nullable=True),
)
# 为 sessions 表添加 bot_id 字段
op.add_column('sessions', sa.Column('bot_id', sa.String(36), sa.ForeignKey('bots.id'), nullable=True))
# 为 messages 表添加新字段
op.add_column('messages', sa.Column('sender_name', sa.String(80), nullable=True))
op.add_column('messages', sa.Column('bot_id', sa.String(36), sa.ForeignKey('bots.id'), nullable=True))
# 创建索引
op.create_index('ix_bots_name', 'bots', ['name'], unique=True)
op.create_index('ix_bots_owner_id', 'bots', ['owner_id'])
op.create_index('ix_bots_agent_id', 'bots', ['agent_id'])
op.create_index('ix_sessions_bot_id', 'sessions', ['bot_id'])
op.create_index('ix_messages_bot_id', 'messages', ['bot_id'])
def downgrade():
# 删除索引
op.drop_index('ix_messages_bot_id', 'messages')
op.drop_index('ix_sessions_bot_id', 'sessions')
op.drop_index('ix_bots_agent_id', 'bots')
op.drop_index('ix_bots_owner_id', 'bots')
op.drop_index('ix_bots_name', 'bots')
# 删除 messages 表的新字段
op.drop_column('messages', 'bot_id')
op.drop_column('messages', 'sender_name')
# 删除 sessions 表的 bot_id 字段
op.drop_column('sessions', 'bot_id')
# 删除 bots 表
op.drop_table('bots')