diff --git a/api/chat.py b/api/chat.py new file mode 100644 index 0000000..d13aa23 --- /dev/null +++ b/api/chat.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +聊天 WebSocket 路由 +处理前端与 OpenClaw Gateway 之间的消息转发 +作者:小白 🐶 +""" + +from flask import request, session +from flask_login import current_user +from flask_socketio import emit, join_room, leave_room +import json +import time + +from .openclaw_connector import gateway_manager + +# Gateway 配置(从环境变量或配置文件读取) +GATEWAYS = { + "local": { + "url": "ws://127.0.0.1:18888", + "token": "ae4d5989ba173a01cc721200614a8a8a8226724b46d5af13a65089aa628c32b9" + } +} + + +def init_gateways(): + """初始化 Gateway 连接""" + for name, config in GATEWAYS.items(): + connector = gateway_manager.add_gateway( + name, + config["url"], + config["token"] + ) + # 设置消息回调 + connector.message_callback = handle_gateway_response + gateway_manager.connect_all() + print(f"[Chat] Initialized {len(GATEWAYS)} gateway(s)") + + +def handle_gateway_response(gateway_name: str, data: dict): + """处理 Gateway 响应并转发给前端""" + from app import socketio + + # 获取用户房间 + user_id = data.get("params", {}).get("userId", "anonymous") + room = f"user_{user_id}" + + # 转发给前端 + socketio.emit('agent_response', { + 'gateway': gateway_name, + 'data': data, + 'timestamp': int(time.time() * 1000) + }, room=room, namespace='/chat') + + print(f"[Chat] Forwarded response to {room}") + + +def register_socket_handlers(socketio): + """注册 Socket.IO 事件处理器""" + + @socketio.on('connect', namespace='/chat') + def handle_connect(): + """用户连接""" + if not current_user.is_authenticated: + print("[Chat] Unauthorized connection attempt") + return False + + user_id = current_user.id + room = f"user_{user_id}" + join_room(room) + + # 返回 Gateway 列表和状态 + emit('connected', { + 'gateways': gateway_manager.list_gateways(), + 'status': gateway_manager.get_status(), + 'userId': user_id + }) + + print(f"[Chat] User {current_user.username} connected, room: {room}") + + + @socketio.on('disconnect', namespace='/chat') + def handle_disconnect(): + """用户断开连接""" + if current_user.is_authenticated: + room = f"user_{current_user.id}" + leave_room(room) + print(f"[Chat] User {current_user.username} disconnected") + + + @socketio.on('send_message', namespace='/chat') + def handle_message(data): + """处理用户消息""" + if not current_user.is_authenticated: + emit('error', {'message': '未授权'}) + return + + gateway_name = data.get('gateway', 'local') + message = data.get('message', '').strip() + + if not message: + emit('error', {'message': '消息不能为空'}) + return + + connector = gateway_manager.get_gateway(gateway_name) + + if connector and connector.connected: + # 构造会话 key + session_key = f"webchat:user_{current_user.id}" + + # 发送到 Gateway + success = connector.send_message(message, session_key) + + if success: + # 确认收到 + emit('message_sent', { + 'gateway': gateway_name, + 'message': message, + 'timestamp': int(time.time() * 1000) + }) + print(f"[Chat] User {current_user.username} sent message to {gateway_name}") + else: + emit('error', {'message': f'发送失败:Gateway {gateway_name} 连接异常'}) + else: + emit('error', {'message': f'Gateway {gateway_name} 未连接'}) + + + @socketio.on('switch_gateway', namespace='/chat') + def handle_switch(data): + """切换 Gateway""" + if not current_user.is_authenticated: + return + + gateway_name = data.get('gateway') + connector = gateway_manager.get_gateway(gateway_name) + + if connector: + emit('gateway_changed', { + 'gateway': gateway_name, + 'connected': connector.connected, + 'status': gateway_manager.get_status() + }) + print(f"[Chat] User {current_user.username} switched to {gateway_name}") + else: + emit('error', {'message': f'Gateway {gateway_name} 不存在'}) + + + @socketio.on('get_status', namespace='/chat') + def handle_get_status(): + """获取 Gateway 状态""" + emit('status_update', { + 'status': gateway_manager.get_status() + }) + + print("[Chat] Socket handlers registered") diff --git a/api/openclaw_connector.py b/api/openclaw_connector.py new file mode 100644 index 0000000..0fd6fcb --- /dev/null +++ b/api/openclaw_connector.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +OpenClaw Gateway 连接器 +用于连接多个 OpenClaw 实例并转发消息 +作者:小白 🐶 +""" + +import websocket +import json +import threading +import time +from typing import Dict, Optional, Callable, Any + + +class OpenClawConnector: + """OpenClaw Gateway 连接器""" + + def __init__(self, gateway_url: str, token: str, name: str = "default"): + self.gateway_url = gateway_url + self.token = token + self.name = name + self.ws: Optional[websocket.WebSocketApp] = None + self.connected = False + self.message_callback: Optional[Callable[[str, dict], None]] = None + self._reconnect_delay = 1 + self._max_reconnect_delay = 30 + self._should_reconnect = True + + def connect(self): + """连接到 OpenClaw Gateway""" + if not self.gateway_url or not self.token: + print(f"[Connector:{self.name}] Missing gateway_url or token") + return + + url = f"{self.gateway_url}?token={self.token}" + print(f"[Connector:{self.name}] Connecting to {self.gateway_url}...") + + try: + self.ws = websocket.WebSocketApp( + url, + on_open=self._on_open, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close + ) + + # 启动后台线程 + thread = threading.Thread(target=self._run_forever, daemon=True) + thread.start() + except Exception as e: + print(f"[Connector:{self.name}] Connection error: {e}") + + def _run_forever(self): + """后台运行 WebSocket""" + while self._should_reconnect: + try: + self.ws.run_forever() + except Exception as e: + print(f"[Connector:{self.name}] WebSocket error: {e}") + + # 重连逻辑 + if self._should_reconnect: + print(f"[Connector:{self.name}] Reconnecting in {self._reconnect_delay}s...") + time.sleep(self._reconnect_delay) + self._reconnect_delay = min(self._reconnect_delay * 2, self._max_reconnect_delay) + + def _on_open(self, ws): + self.connected = True + self._reconnect_delay = 1 + print(f"[Connector:{self.name}] ✅ Connected") + + def _on_message(self, ws, message): + """收到 Gateway 消息""" + try: + data = json.loads(message) + print(f"[Connector:{self.name}] Received: {data.get('method', 'unknown')}") + if self.message_callback: + self.message_callback(self.name, data) + except json.JSONDecodeError: + print(f"[Connector:{self.name}] Invalid JSON: {message[:100]}") + + def _on_error(self, ws, error): + print(f"[Connector:{self.name}] Error: {error}") + + def _on_close(self, ws, close_status_code, close_msg): + self.connected = False + print(f"[Connector:{self.name}] Disconnected: {close_status_code} {close_msg}") + + def send_message(self, message: str, session_key: str = None) -> bool: + """发送消息到 Gateway""" + if not self.connected or not self.ws: + print(f"[Connector:{self.name}] Not connected") + return False + + payload = { + "method": "agent.turn", + "id": str(int(time.time() * 1000)), + "params": { + "message": message, + "sessionKey": session_key or f"webchat:{self.name}", + "deliver": False # 不直接发送,等待响应 + } + } + + try: + self.ws.send(json.dumps(payload)) + print(f"[Connector:{self.name}] Sent message: {message[:50]}...") + return True + except Exception as e: + print(f"[Connector:{self.name}] Send error: {e}") + return False + + def disconnect(self): + """断开连接""" + self._should_reconnect = False + if self.ws: + self.ws.close() + + +class MultiGatewayManager: + """多 Gateway 连接管理器""" + + def __init__(self): + self.gateways: Dict[str, OpenClawConnector] = {} + self._lock = threading.Lock() + + def add_gateway(self, name: str, url: str, token: str) -> OpenClawConnector: + """添加 Gateway 连接""" + with self._lock: + if name in self.gateways: + self.gateways[name].disconnect() + + connector = OpenClawConnector(url, token, name) + self.gateways[name] = connector + return connector + + def connect_all(self): + """连接所有 Gateway""" + for connector in self.gateways.values(): + connector.connect() + + def get_gateway(self, name: str) -> Optional[OpenClawConnector]: + return self.gateways.get(name) + + def list_gateways(self) -> list: + return list(self.gateways.keys()) + + def get_status(self) -> Dict[str, Any]: + """获取所有 Gateway 状态""" + return { + name: { + "connected": connector.connected, + "url": connector.gateway_url + } + for name, connector in self.gateways.items() + } + + def disconnect_all(self): + """断开所有连接""" + for connector in self.gateways.values(): + connector.disconnect() + self.gateways.clear() + + +# 全局单例 +gateway_manager = MultiGatewayManager() diff --git a/app.py b/app.py index aa05c0d..4bae17e 100644 --- a/app.py +++ b/app.py @@ -2,13 +2,14 @@ # -*- coding: utf-8 -*- """ OpenClaw Mission Control - Flask 版本 -支持登录、注册、控制中心功能 +支持登录、注册、控制中心、聊天功能 作者:小白 🐶 """ from flask import Flask, render_template, redirect, url_for, request, flash, make_response from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user from flask_sqlalchemy import SQLAlchemy +from flask_socketio import SocketIO from werkzeug.security import generate_password_hash, check_password_hash import os @@ -18,6 +19,9 @@ app.config['SECRET_KEY'] = 'xiaobai-secret-key-2026' app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///users.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +# 初始化 SocketIO +socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') + # 禁用缓存 - 解决浏览器缓存问题 @app.after_request def add_no_cache_headers(response): @@ -40,6 +44,9 @@ login_manager.login_view = 'login' from api import api app.register_blueprint(api) +# 注册聊天 Socket Handlers +from api.chat import register_socket_handlers, init_gateways + # 用户模型 class User(UserMixin, db.Model): @@ -63,10 +70,17 @@ def load_user(user_id): @app.route('/') def index(): if current_user.is_authenticated: - return redirect(url_for('dashboard')) + return redirect(url_for('chat')) return render_template('index.html') +# 路由 - 聊天界面 +@app.route('/chat') +@login_required +def chat(): + return render_template('chat/index.html', username=current_user.username) + + # 路由 - 控制中心仪表盘 @app.route('/dashboard') @login_required @@ -166,4 +180,13 @@ with app.app_context(): if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=False) + # 注册 Socket.IO 事件处理器 + register_socket_handlers(socketio) + + # 初始化 Gateway 连接 + init_gateways() + + # 启动应用(使用 SocketIO) + print("🚀 启动 OpenClaw Mission Control...") + print("📍 访问地址: http://0.0.0.0:5000") + socketio.run(app, host='0.0.0.0', port=5000, debug=False, allow_unsafe_werkzeug=True) diff --git a/static/css/chat.css b/static/css/chat.css new file mode 100644 index 0000000..9202573 --- /dev/null +++ b/static/css/chat.css @@ -0,0 +1,389 @@ +/* 聊天界面样式 + * 作者:小白 🐶 + */ + +/* 基础样式重置 */ +.chat-body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +/* 主容器 */ +.chat-container { + max-width: 900px; + margin: 0 auto; + height: 100vh; + display: flex; + flex-direction: column; + background: #fff; + box-shadow: 0 0 40px rgba(0, 0, 0, 0.1); +} + +/* 顶部导航栏 */ +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.header-left { + display: flex; + align-items: center; + gap: 15px; +} + +.logo { + font-size: 20px; + font-weight: 600; +} + +.gateway-selector { + padding: 6px 12px; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 6px; + background: rgba(255, 255, 255, 0.1); + color: white; + font-size: 14px; + cursor: pointer; +} + +.gateway-selector option { + background: #333; + color: white; +} + +.header-right { + display: flex; + align-items: center; + gap: 15px; +} + +.status-badge { + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; +} + +.status-badge.connecting { + background: #ffc107; + color: #333; +} + +.status-badge.connected { + background: #28a745; + color: white; +} + +.status-badge.disconnected { + background: #dc3545; + color: white; +} + +.user-info { + font-size: 14px; + opacity: 0.9; +} + +.logout-btn { + color: white; + text-decoration: none; + font-size: 14px; + opacity: 0.8; + transition: opacity 0.2s; +} + +.logout-btn:hover { + opacity: 1; +} + +/* 消息列表区域 */ +.message-list { + flex: 1; + overflow-y: auto; + padding: 20px; + background: #f5f7fb; +} + +/* 欢迎消息 */ +.welcome-message { + display: flex; + align-items: center; + gap: 15px; + padding: 30px; + background: white; + border-radius: 12px; + margin-bottom: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.welcome-avatar { + font-size: 48px; +} + +.welcome-text h3 { + margin: 0 0 8px 0; + color: #333; +} + +.welcome-text p { + margin: 0; + color: #666; +} + +/* 消息气泡 */ +.message { + display: flex; + gap: 10px; + margin-bottom: 16px; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message.user { + flex-direction: row-reverse; +} + +.message-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + background: #e9ecef; + flex-shrink: 0; +} + +.message.user .message-avatar { + background: #667eea; +} + +.message-content { + max-width: 70%; + min-width: 100px; +} + +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + gap: 10px; +} + +.message-sender { + font-size: 12px; + color: #666; + font-weight: 500; +} + +.message-time { + font-size: 11px; + color: #999; +} + +.message-text { + padding: 12px 16px; + border-radius: 16px; + background: white; + color: #333; + line-height: 1.5; + word-wrap: break-word; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.message.user .message-text { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-bottom-right-radius: 4px; +} + +.message.agent .message-text { + border-bottom-left-radius: 4px; +} + +.message.system .message-text { + background: #fff3cd; + color: #856404; + border-radius: 8px; +} + +/* 代码块样式 */ +.message-text pre { + background: #1e1e1e; + color: #d4d4d4; + padding: 12px; + border-radius: 8px; + overflow-x: auto; + margin: 8px 0; +} + +.message-text code { + background: #f0f0f0; + padding: 2px 6px; + border-radius: 4px; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 13px; +} + +.message-text pre code { + background: transparent; + padding: 0; +} + +/* 输入区域 */ +.chat-input-area { + padding: 15px 20px; + background: white; + border-top: 1px solid #e9ecef; +} + +.input-wrapper { + display: flex; + gap: 10px; + align-items: flex-end; +} + +#message-input { + flex: 1; + padding: 12px 16px; + border: 2px solid #e9ecef; + border-radius: 12px; + font-size: 15px; + resize: none; + outline: none; + transition: border-color 0.2s; + font-family: inherit; + max-height: 150px; + line-height: 1.5; +} + +#message-input:focus { + border-color: #667eea; +} + +#send-btn { + padding: 12px 24px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 12px; + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s, transform 0.1s; +} + +#send-btn:hover:not(:disabled) { + opacity: 0.9; +} + +#send-btn:active:not(:disabled) { + transform: scale(0.98); +} + +#send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.input-hint { + margin-top: 8px; + height: 20px; +} + +/* 打字指示器 */ +.typing-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + color: #666; + font-size: 13px; +} + +.typing-indicator .dot { + width: 6px; + height: 6px; + background: #667eea; + border-radius: 50%; + animation: bounce 1.4s infinite ease-in-out; +} + +.typing-indicator .dot:nth-child(1) { animation-delay: 0s; } +.typing-indicator .dot:nth-child(2) { animation-delay: 0.2s; } +.typing-indicator .dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes bounce { + 0%, 80%, 100% { + transform: scale(0.8); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* 滚动条样式 */ +.message-list::-webkit-scrollbar { + width: 6px; +} + +.message-list::-webkit-scrollbar-track { + background: transparent; +} + +.message-list::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 3px; +} + +.message-list::-webkit-scrollbar-thumb:hover { + background: #aaa; +} + +/* 响应式设计 */ +@media (max-width: 768px) { + .chat-container { + height: 100vh; + } + + .chat-header { + padding: 10px 15px; + } + + .logo { + font-size: 18px; + } + + .message-content { + max-width: 85%; + } + + .chat-input-area { + padding: 10px 15px; + } + + #send-btn { + padding: 12px 16px; + } +} diff --git a/static/openclaw_report.docx b/static/openclaw_report.docx new file mode 100644 index 0000000..5ec37cf Binary files /dev/null and b/static/openclaw_report.docx differ diff --git a/templates/chat/index.html b/templates/chat/index.html new file mode 100644 index 0000000..8c1c458 --- /dev/null +++ b/templates/chat/index.html @@ -0,0 +1,321 @@ + + +
+ + +