Compare commits
25 Commits
becb5bcd77
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d747fd33a | ||
|
|
11f4347864 | ||
| dcf8494db7 | |||
| 021ce8b50b | |||
| 15b001bab5 | |||
|
|
cb2bb5ec35 | ||
|
|
c5aaee66b5 | ||
| e651f21324 | |||
| 04132c298a | |||
| b74ec0b73d | |||
| 608e53ed2f | |||
| 1ba9f78bd8 | |||
| 8673eaf655 | |||
| 44582a8199 | |||
|
|
066ccf0d28 | ||
| 92dede8793 | |||
| 0386da3905 | |||
| 9d6a489a31 | |||
| 70a3643b77 | |||
| 42ac87f79c | |||
| 645d226f0d | |||
| cb0a496f7d | |||
| 8c912b8808 | |||
| a09afc1c4c | |||
| 4b707a49f4 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,11 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
*.db
|
||||
*.sqlite
|
||||
.env
|
||||
venv/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
venv/
|
||||
frontend/node_modules/
|
||||
frontend/package-lock.json
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
362
app/routes/bots.py
Normal 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
382
app/routes/chat.py
Normal 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
|
||||
@@ -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
277
app/services/bot_service.py
Normal 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 ID,None 表示解绑
|
||||
"""
|
||||
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,
|
||||
}
|
||||
391
app/socketio/chat_handlers.py
Normal file
391
app/socketio/chat_handlers.py
Normal 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']
|
||||
@@ -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)
|
||||
|
||||
@@ -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
166
docs/ISSUES.md
Normal 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
15
frontend/index.html
Normal 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
29
frontend/package.json
Normal 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
17
frontend/src/App.vue
Normal 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
79
frontend/src/api/index.ts
Normal 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
|
||||
167
frontend/src/components/chat/BotSelector.vue
Normal file
167
frontend/src/components/chat/BotSelector.vue
Normal 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>
|
||||
410
frontend/src/components/chat/BotSettingsModal.vue
Normal file
410
frontend/src/components/chat/BotSettingsModal.vue
Normal 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>
|
||||
174
frontend/src/components/chat/ChatSidebar.vue
Normal file
174
frontend/src/components/chat/ChatSidebar.vue
Normal 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>
|
||||
210
frontend/src/components/chat/ChatWindow.vue
Normal file
210
frontend/src/components/chat/ChatWindow.vue
Normal 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>
|
||||
316
frontend/src/components/chat/CreateBotModal.vue
Normal file
316
frontend/src/components/chat/CreateBotModal.vue
Normal 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>
|
||||
119
frontend/src/components/chat/MarkdownMessage.vue
Normal file
119
frontend/src/components/chat/MarkdownMessage.vue
Normal 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
12
frontend/src/main.ts
Normal 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')
|
||||
40
frontend/src/router/index.ts
Normal file
40
frontend/src/router/index.ts
Normal 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
|
||||
70
frontend/src/stores/auth.ts
Normal file
70
frontend/src/stores/auth.ts
Normal 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
284
frontend/src/stores/chat.ts
Normal 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
143
frontend/src/style.css
Normal 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;
|
||||
}
|
||||
222
frontend/src/views/ChatView.vue
Normal file
222
frontend/src/views/ChatView.vue
Normal 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>
|
||||
402
frontend/src/views/HomeView.vue
Normal file
402
frontend/src/views/HomeView.vue
Normal 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>
|
||||
166
frontend/src/views/LoginView.vue
Normal file
166
frontend/src/views/LoginView.vue
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal 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
25
frontend/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
69
migrations/versions/add_bot_model.py
Normal file
69
migrations/versions/add_bot_model.py
Normal 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')
|
||||
Reference in New Issue
Block a user