- 新增 OpenClaw Gateway 连接器 (api/openclaw_connector.py)
- 新增聊天 WebSocket 路由 (api/chat.py)
- 新增聊天界面模板 (templates/chat/index.html)
- 新增聊天样式 (static/css/chat.css)
- 修改 app.py 支持 SocketIO
- 登录后默认跳转到聊天界面
作者:小白 🐶
322 lines
12 KiB
HTML
322 lines
12 KiB
HTML
<!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>
|