feat: 添加聊天会话界面
- 新增 OpenClaw Gateway 连接器 (api/openclaw_connector.py)
- 新增聊天 WebSocket 路由 (api/chat.py)
- 新增聊天界面模板 (templates/chat/index.html)
- 新增聊天样式 (static/css/chat.css)
- 修改 app.py 支持 SocketIO
- 登录后默认跳转到聊天界面
作者:小白 🐶
This commit is contained in:
155
api/chat.py
Normal file
155
api/chat.py
Normal file
@@ -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")
|
||||||
167
api/openclaw_connector.py
Normal file
167
api/openclaw_connector.py
Normal file
@@ -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()
|
||||||
29
app.py
29
app.py
@@ -2,13 +2,14 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
OpenClaw Mission Control - Flask 版本
|
OpenClaw Mission Control - Flask 版本
|
||||||
支持登录、注册、控制中心功能
|
支持登录、注册、控制中心、聊天功能
|
||||||
作者:小白 🐶
|
作者:小白 🐶
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Flask, render_template, redirect, url_for, request, flash, make_response
|
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_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_socketio import SocketIO
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
import os
|
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_DATABASE_URI'] = 'sqlite:///users.db'
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
# 初始化 SocketIO
|
||||||
|
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
|
||||||
|
|
||||||
# 禁用缓存 - 解决浏览器缓存问题
|
# 禁用缓存 - 解决浏览器缓存问题
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def add_no_cache_headers(response):
|
def add_no_cache_headers(response):
|
||||||
@@ -40,6 +44,9 @@ login_manager.login_view = 'login'
|
|||||||
from api import api
|
from api import api
|
||||||
app.register_blueprint(api)
|
app.register_blueprint(api)
|
||||||
|
|
||||||
|
# 注册聊天 Socket Handlers
|
||||||
|
from api.chat import register_socket_handlers, init_gateways
|
||||||
|
|
||||||
|
|
||||||
# 用户模型
|
# 用户模型
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
@@ -63,10 +70,17 @@ def load_user(user_id):
|
|||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('chat'))
|
||||||
return render_template('index.html')
|
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')
|
@app.route('/dashboard')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -166,4 +180,13 @@ with app.app_context():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
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)
|
||||||
|
|||||||
389
static/css/chat.css
Normal file
389
static/css/chat.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
static/openclaw_report.docx
Normal file
BIN
static/openclaw_report.docx
Normal file
Binary file not shown.
321
templates/chat/index.html
Normal file
321
templates/chat/index.html
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>小白聊天 - OpenClaw</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/chat.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="chat-body">
|
||||||
|
<div class="chat-container">
|
||||||
|
<!-- 顶部导航栏 -->
|
||||||
|
<div class="chat-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<span class="logo">🐶 小白</span>
|
||||||
|
<select id="gateway-select" class="gateway-selector">
|
||||||
|
<option value="local">本地 OpenClaw</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span id="connection-status" class="status-badge connecting">连接中...</span>
|
||||||
|
<span class="user-info">{{ username }}</span>
|
||||||
|
<a href="{{ url_for('logout') }}" class="logout-btn">退出</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息列表区域 -->
|
||||||
|
<div id="message-list" class="message-list">
|
||||||
|
<div class="welcome-message">
|
||||||
|
<div class="welcome-avatar">🐶</div>
|
||||||
|
<div class="welcome-text">
|
||||||
|
<h3>你好,{{ username }}!</h3>
|
||||||
|
<p>我是小白,你的 AI 助手。有什么可以帮你的吗?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入区域 -->
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<div class="input-wrapper">
|
||||||
|
<textarea
|
||||||
|
id="message-input"
|
||||||
|
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
|
||||||
|
rows="1"
|
||||||
|
autofocus
|
||||||
|
></textarea>
|
||||||
|
<button id="send-btn" onclick="sendMessage()" disabled>
|
||||||
|
<span>发送</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="input-hint">
|
||||||
|
<span id="typing-indicator" class="typing-indicator" style="display: none;">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="dot"></span>
|
||||||
|
小白正在思考...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// 全局变量
|
||||||
|
let socket = null;
|
||||||
|
let currentGateway = 'local';
|
||||||
|
let isConnected = false;
|
||||||
|
let userId = null;
|
||||||
|
let messageHistory = [];
|
||||||
|
|
||||||
|
// DOM 元素
|
||||||
|
const messageList = document.getElementById('message-list');
|
||||||
|
const messageInput = document.getElementById('message-input');
|
||||||
|
const sendBtn = document.getElementById('send-btn');
|
||||||
|
const gatewaySelect = document.getElementById('gateway-select');
|
||||||
|
const connectionStatus = document.getElementById('connection-status');
|
||||||
|
const typingIndicator = document.getElementById('typing-indicator');
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
document.addEventListener('DOMContentLoaded', initChat);
|
||||||
|
|
||||||
|
function initChat() {
|
||||||
|
// 连接 Socket.IO
|
||||||
|
socket = io('/chat', {
|
||||||
|
transports: ['websocket', 'polling']
|
||||||
|
});
|
||||||
|
|
||||||
|
// 事件监听
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('[Socket] Connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connected', (data) => {
|
||||||
|
console.log('[Chat] Connected:', data);
|
||||||
|
userId = data.userId;
|
||||||
|
isConnected = true;
|
||||||
|
updateConnectionStatus(true);
|
||||||
|
|
||||||
|
// 更新 Gateway 列表
|
||||||
|
gatewaySelect.innerHTML = data.gateways.map(g =>
|
||||||
|
`<option value="${g}">${g}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
updateGatewayStatus(data.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('[Socket] Disconnected');
|
||||||
|
isConnected = false;
|
||||||
|
updateConnectionStatus(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('message_sent', (data) => {
|
||||||
|
console.log('[Chat] Message sent:', data);
|
||||||
|
addMessage('user', data.message, data.timestamp);
|
||||||
|
showTypingIndicator();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent_response', (data) => {
|
||||||
|
console.log('[Chat] Agent response:', data);
|
||||||
|
hideTypingIndicator();
|
||||||
|
handleAgentResponse(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (data) => {
|
||||||
|
console.error('[Chat] Error:', data);
|
||||||
|
hideTypingIndicator();
|
||||||
|
addMessage('system', `❌ ${data.message}`, Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('gateway_changed', (data) => {
|
||||||
|
console.log('[Chat] Gateway changed:', data);
|
||||||
|
currentGateway = data.gateway;
|
||||||
|
updateConnectionStatus(data.connected);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('status_update', (data) => {
|
||||||
|
updateGatewayStatus(data.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 输入框事件
|
||||||
|
messageInput.addEventListener('input', handleInputChange);
|
||||||
|
messageInput.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// Gateway 切换
|
||||||
|
gatewaySelect.addEventListener('change', (e) => {
|
||||||
|
currentGateway = e.target.value;
|
||||||
|
socket.emit('switch_gateway', { gateway: currentGateway });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputChange() {
|
||||||
|
const hasContent = messageInput.value.trim().length > 0;
|
||||||
|
sendBtn.disabled = !hasContent || !isConnected;
|
||||||
|
|
||||||
|
// 自动调整高度
|
||||||
|
messageInput.style.height = 'auto';
|
||||||
|
messageInput.style.height = Math.min(messageInput.scrollHeight, 150) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const message = messageInput.value.trim();
|
||||||
|
if (!message || !isConnected) return;
|
||||||
|
|
||||||
|
socket.emit('send_message', {
|
||||||
|
gateway: currentGateway,
|
||||||
|
message: message
|
||||||
|
});
|
||||||
|
|
||||||
|
messageInput.value = '';
|
||||||
|
handleInputChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(type, content, timestamp) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `message ${type}`;
|
||||||
|
|
||||||
|
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatar = type === 'user' ? '👤' : '🐶';
|
||||||
|
const sender = type === 'user' ? '我' : '小白';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="message-avatar">${avatar}</div>
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="message-sender">${sender}</span>
|
||||||
|
<span class="message-time">${time}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-text">${escapeHtml(content)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
messageList.appendChild(div);
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
// 保存历史
|
||||||
|
messageHistory.push({ type, content, timestamp });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAgentResponse(data) {
|
||||||
|
const responseData = data.data || {};
|
||||||
|
|
||||||
|
// 尝试提取文本内容
|
||||||
|
let content = '';
|
||||||
|
if (responseData.result) {
|
||||||
|
content = responseData.result;
|
||||||
|
} else if (responseData.content) {
|
||||||
|
content = responseData.content;
|
||||||
|
} else if (responseData.params?.result) {
|
||||||
|
content = responseData.params.result;
|
||||||
|
} else if (responseData.error) {
|
||||||
|
content = `错误: ${responseData.error}`;
|
||||||
|
} else {
|
||||||
|
// 显示原始响应
|
||||||
|
content = JSON.stringify(responseData, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
addMessage('agent', content, data.timestamp || Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(type, content, timestamp) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `message ${type}`;
|
||||||
|
|
||||||
|
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatar = type === 'user' ? '👤' : (type === 'agent' ? '🐶' : '⚠️');
|
||||||
|
const sender = type === 'user' ? '我' : (type === 'agent' ? '小白' : '系统');
|
||||||
|
|
||||||
|
// 处理 markdown 代码块
|
||||||
|
let displayContent = escapeHtml(content);
|
||||||
|
if (type === 'agent') {
|
||||||
|
displayContent = formatContent(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="message-avatar">${avatar}</div>
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="message-sender">${sender}</span>
|
||||||
|
<span class="message-time">${time}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-text">${displayContent}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
messageList.appendChild(div);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContent(content) {
|
||||||
|
// 简单的 markdown 处理
|
||||||
|
let html = escapeHtml(content);
|
||||||
|
|
||||||
|
// 代码块
|
||||||
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>');
|
||||||
|
|
||||||
|
// 行内代码
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
|
// 粗体
|
||||||
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// 换行
|
||||||
|
html = html.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
messageList.scrollTop = messageList.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConnectionStatus(connected) {
|
||||||
|
if (connected) {
|
||||||
|
connectionStatus.className = 'status-badge connected';
|
||||||
|
connectionStatus.textContent = '已连接';
|
||||||
|
} else {
|
||||||
|
connectionStatus.className = 'status-badge disconnected';
|
||||||
|
connectionStatus.textContent = '未连接';
|
||||||
|
}
|
||||||
|
sendBtn.disabled = !connected || !messageInput.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGatewayStatus(status) {
|
||||||
|
// 可以显示各 Gateway 的连接状态
|
||||||
|
console.log('[Chat] Gateway status:', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTypingIndicator() {
|
||||||
|
typingIndicator.style.display = 'inline-flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTypingIndicator() {
|
||||||
|
typingIndicator.style.display = 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user