Files
micro_servers/static/public/aibox.html
T
qing 6ed88feaa4 feat(static): 新增 aibox 智能体 Web 客户端(移动端适配)
- aibox.html:自包含移动端聊天页,经 gateway [WsProxy] 反向代理与 aibox 通信
- 协议兼容 BotOps client.py:chunk 流式渲染 / reasoning 思考区 / task 任务面板 / 指数退避重连
- 移动端适配:100dvh 动态视口 + safe-area 刘海屏 + 44px 触控目标 + 任务面板抽屉式
- 安全:握手由 gateway 强制 is_admin 验签,Token 全程 HttpOnly Cookie,CSP 合规(本地 markdown-it)
- README 同步页面清单与功能说明
2026-08-01 05:18:30 +08:00

644 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<!-- viewport-fit=cover:配合 safe-area 适配刘海屏;maximum-scale 防输入框聚焦自动缩放 -->
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover">
<title>aibox 智能体</title>
<link rel="stylesheet" href="css/theme.css">
<style>
/* ===== 全屏应用布局(覆盖 theme.css 的居中卡片页默认) ===== */
html, body { height: 100%; }
body {
display: block;
padding: 0;
height: 100dvh; /* 移动端动态视口高度,规避地址栏遮挡 */
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
height: 100dvh;
max-width: 900px;
margin: 0 auto;
background: var(--bg);
transition: margin-right 0.25s;
}
/* ===== 顶栏 ===== */
#topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
padding-top: calc(10px + env(safe-area-inset-top));
background: var(--card);
border-bottom: 1px solid var(--border-light);
flex: none;
}
#topbar .title { font-size: 1rem; font-weight: 700; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#conn-status { display: flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--fg-secondary); flex: none; }
#conn-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
#conn-status.connected .dot { background: var(--success); box-shadow: 0 0 6px var(--success); }
#conn-status.connecting .dot { background: var(--warning); }
#conn-status.disconnected .dot { background: var(--error); }
#topbar button { flex: none; padding: 0.45rem 0.8rem; font-size: 0.8rem; min-height: 36px; }
#task-toggle { background: var(--accent-soft); color: var(--accent-dark); position: relative; }
#task-badge {
display: none;
position: absolute;
top: -6px; right: -6px;
min-width: 16px; height: 16px;
border-radius: 8px;
background: var(--error);
color: #fff;
font-size: 0.62rem;
line-height: 16px;
text-align: center;
padding: 0 4px;
}
#conn-btn { background: var(--accent-soft); color: var(--accent-dark); }
#conn-btn.on { background: var(--error-bg); color: var(--error); }
/* ===== 非管理员提示条 ===== */
#admin-tip {
flex: none;
padding: 8px 12px;
font-size: 0.78rem;
background: var(--error-bg);
color: var(--error);
text-align: center;
}
/* ===== 聊天区 ===== */
#chat {
flex: 1;
overflow-y: auto;
padding: 14px 12px;
-webkit-overflow-scrolling: touch;
}
.msg { margin-bottom: 12px; overflow-wrap: break-word; }
.msg-user {
margin-left: 15%;
padding: 0.65rem 0.9rem;
background: var(--accent-soft);
border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md);
font-size: 0.92rem;
white-space: pre-wrap;
}
.msg-ai {
margin-right: 6%;
padding: 0.75rem 1rem;
background: var(--card);
border: 1px solid var(--border-light);
border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px;
box-shadow: var(--shadow-sm);
font-size: 0.92rem;
line-height: 1.65;
}
.msg-ai p { margin: 0.4em 0; }
.msg-ai p:first-child { margin-top: 0; }
.msg-ai p:last-child { margin-bottom: 0; }
.msg-ai pre {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.6rem;
overflow-x: auto;
font-size: 0.8rem;
}
.msg-ai code { font-size: 0.82em; background: var(--accent-soft); border-radius: 4px; padding: 0.1em 0.3em; }
.msg-ai pre code { background: none; padding: 0; }
.msg-ai a { color: var(--accent); }
.msg-ai.streaming::after {
content: '▌';
color: var(--accent);
animation: blink 0.9s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.msg-system, .msg-error { text-align: center; font-size: 0.78rem; color: var(--fg-secondary); }
.msg-error { color: var(--error); }
/* 思考区域:灰色斜体可折叠 */
.msg-reasoning {
margin-right: 6%;
border-left: 3px solid var(--border);
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
color: var(--muted);
font-style: italic;
}
.reasoning-head {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-style: normal;
font-size: 0.75rem;
color: var(--fg-secondary);
min-height: 28px;
}
.reasoning-body { white-space: pre-wrap; margin-top: 4px; }
.reasoning-body.folded { display: none; }
/* ===== 输入栏 ===== */
#input-bar {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 10px 12px;
padding-bottom: calc(10px + env(safe-area-inset-bottom));
background: var(--card);
border-top: 1px solid var(--border-light);
flex: none;
}
#input {
flex: 1;
min-height: 44px;
max-height: 120px;
resize: none;
font-size: 16px; /* iOS <16px 会聚焦自动缩放 */
line-height: 1.4;
padding: 0.65rem 0.9rem;
}
#send-btn { flex: none; min-height: 44px; background: var(--accent); color: #fff; }
#interrupt-btn { flex: none; min-height: 44px; min-width: 44px; padding: 0.45rem 0.7rem; background: var(--error-bg); color: var(--error); }
#send-btn:disabled, #interrupt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ===== 任务面板:移动端为抽屉式覆盖层 ===== */
#task-panel {
position: fixed;
top: 0; right: 0; bottom: 0;
width: min(85vw, 340px);
background: var(--card);
border-left: 1px solid var(--border-light);
box-shadow: var(--shadow-md);
transform: translateX(100%);
transition: transform 0.25s;
display: flex;
flex-direction: column;
z-index: 100;
padding-top: env(safe-area-inset-top);
}
#task-panel.open { transform: translateX(0); }
#task-panel header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
font-size: 0.9rem;
font-weight: 600;
border-bottom: 1px solid var(--border-light);
flex: none;
}
#task-close { background: none; border: none; font-size: 1.3rem; color: var(--fg-secondary); padding: 4px 8px; min-height: 36px; min-width: 36px; }
#task-list {
flex: 1;
overflow-y: auto;
padding: 10px 12px;
font-size: 0.78rem;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word;
color: var(--fg-secondary);
}
#backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s;
z-index: 99;
}
#backdrop.show { opacity: 1; pointer-events: auto; }
/* ===== 桌面端:任务面板固化为右侧栏 ===== */
@media (min-width: 900px) {
#app { margin-right: 340px; max-width: none; }
#task-panel { transform: none; box-shadow: none; }
#task-toggle, #task-close, #backdrop { display: none; }
}
</style>
</head>
<body>
<div id="app">
<header id="topbar">
<span class="title">🤖 aibox 智能体</span>
<span id="conn-status" class="disconnected"><span class="dot"></span><b id="status-text">未连接</b></span>
<button id="task-toggle" type="button">📋 任务<span id="task-badge"></span></button>
<button id="conn-btn" type="button">连接</button>
</header>
<div id="admin-tip" style="display:none"></div>
<main id="chat"></main>
<div id="input-bar">
<button id="interrupt-btn" type="button" style="display:none" title="中断当前生成">⏸</button>
<textarea id="input" rows="1" placeholder="输入消息,/help 查看命令"></textarea>
<button id="send-btn" type="button">发送</button>
</div>
</div>
<aside id="task-panel">
<header>任务面板<button id="task-close" type="button" aria-label="关闭">×</button></header>
<div id="task-list"></div>
</aside>
<div id="backdrop"></div>
<script src="js/markdown-it-12_0_0.js"></script>
<script>
'use strict';
/* ============================================================
* aibox Web 客户端(经 gateway WS 反向代理,仅管理员可用)
* 协议与 BotOps/aibox client.py 完全兼容:
* 发送:{"type":"message","content":...} / {"type":"command","command":"/..."} / {"type":"interrupt",...}
* 接收:chunk(增量片段) / reasoning_start / reasoning / reasoning_end / complete /
* line(过程行) / task_process / task_status / task_error / task_added /
* dialogue_ended / dialogue_reset / interrupt_ack / error / info /
* command_result / connection_ack / notification / pong
* ============================================================ */
/* gateway [WsProxy] routes 中配置的路径(HttpOnly q4_token Cookie 随握手自动携带,JS 不经手 Token)。
* 需与 gateway.ini 的 [WsProxy] routes 条目一致,如:routes = /ws/aibox=ws://127.0.0.1:8765; */
const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/aibox';
const RECONNECT_MIN = 2000; /* 指数退避起点(与 BotOps 一致:2s→30s 封顶) */
const RECONNECT_MAX = 30000;
const REASONING_TAIL = 3000; /* 思考区域超长时仅显示末尾字符数 */
const md = window.markdownit({ html: false, linkify: true, breaks: true });
/* 链接一律新标签打开且不带 referrer(CSP connect-src 'self' 允许 WS 同源) */
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); };
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
tokens[idx].attrSet('target', '_blank');
tokens[idx].attrSet('rel', 'noopener noreferrer');
return defaultLinkOpen(tokens, idx, options, env, self);
};
const chatEl = document.getElementById('chat');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send-btn');
const interruptBtn = document.getElementById('interrupt-btn');
const connBtn = document.getElementById('conn-btn');
const statusEl = document.getElementById('conn-status');
const statusText = document.getElementById('status-text');
const taskPanel = document.getElementById('task-panel');
const taskList = document.getElementById('task-list');
const taskToggle = document.getElementById('task-toggle');
const taskBadge = document.getElementById('task-badge');
const backdrop = document.getElementById('backdrop');
let ws = null;
let manualClose = false; /* 手动断开则抑制自动重连 */
let reconnectDelay = RECONNECT_MIN;
let reconnectTimer = null;
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前,与 BotOps 同策略) */
let aiBubble = null, aiText = '', aiActive = false;
let reasoningBox = null, reasoningBody = null, reasoningHead = null, reasoningText = '';
let streaming = false;
let unreadTasks = 0;
/* ---------- 工具 ---------- */
function nearBottom() {
return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80;
}
function scrollToBottom(force) {
if (force || nearBottom()) chatEl.scrollTop = chatEl.scrollHeight;
}
function escapeText(s) { return String(s == null ? '' : s); }
function addSystem(text, isError) {
const div = document.createElement('div');
div.className = isError ? 'msg msg-error' : 'msg msg-system';
div.textContent = text;
chatEl.appendChild(div);
scrollToBottom();
}
function addTaskLine(text) {
const div = document.createElement('div');
div.textContent = text;
taskList.appendChild(div);
taskList.scrollTop = taskList.scrollHeight;
if (!taskPanel.classList.contains('open')) {
unreadTasks += 1;
taskBadge.style.display = '';
taskBadge.textContent = unreadTasks > 99 ? '99+' : String(unreadTasks);
}
}
/* ---------- 消息气泡 ---------- */
function addUserBubble(text) {
const div = document.createElement('div');
div.className = 'msg msg-user';
div.textContent = text;
chatEl.appendChild(div);
scrollToBottom(true);
}
function ensureAiBubble() {
if (aiBubble) return;
aiBubble = document.createElement('div');
aiBubble.className = 'msg msg-ai streaming';
chatEl.appendChild(aiBubble);
scrollToBottom(true);
}
/* 服务端 chunk 为增量片段(发送后即清零),直接拼接 */
function appendAi(delta) {
if (!delta || aiActive === false) return;
ensureAiBubble();
aiText += delta;
aiBubble.innerHTML = md.render(aiText);
scrollToBottom();
}
/* complete 为收尾信号:chunk 流式是内容权威来源,仅异常丢帧时用 full_text 补建 */
function finishAi(fullText) {
if (aiActive === false && !aiBubble && !reasoningBox) return;
if (!aiBubble && fullText) {
ensureAiBubble();
aiText = fullText;
aiBubble.innerHTML = md.render(aiText);
}
if (aiBubble) aiBubble.classList.remove('streaming');
aiBubble = null; aiText = ''; aiActive = false;
reasoningBox = null; reasoningBody = null; reasoningHead = null; reasoningText = '';
setStreaming(false);
scrollToBottom();
}
/* ---------- 思考区域(可折叠) ---------- */
function startReasoning(hint) {
if (reasoningBox) return;
reasoningText = '';
reasoningBox = document.createElement('div');
reasoningBox.className = 'msg msg-reasoning';
reasoningHead = document.createElement('div');
reasoningHead.className = 'reasoning-head';
reasoningHead.textContent = '💭 ' + (hint || '思考中...');
reasoningBody = document.createElement('div');
reasoningBody.className = 'reasoning-body';
reasoningHead.addEventListener('click', function() { reasoningBody.classList.toggle('folded'); });
reasoningBox.appendChild(reasoningHead);
reasoningBox.appendChild(reasoningBody);
chatEl.appendChild(reasoningBox);
scrollToBottom(true);
}
function appendReasoning(delta) {
if (!delta) return;
if (!reasoningBox) startReasoning();
reasoningText += delta;
const display = reasoningText.length > REASONING_TAIL
? '...(前略)\n' + reasoningText.slice(-REASONING_TAIL)
: reasoningText;
reasoningBody.textContent = display;
scrollToBottom();
}
function endReasoning() {
if (reasoningHead && reasoningText) {
reasoningHead.textContent = '💭 思考过程(' + reasoningText.length + ' 字符,点击折叠/展开)';
reasoningBody.classList.add('folded'); /* 结束后默认折叠,避免占屏 */
}
}
/* ---------- 连接状态 ---------- */
function setStatus(cls, text) {
statusEl.className = cls;
statusText.textContent = text;
}
function setStreaming(on) {
streaming = on;
interruptBtn.style.display = on ? '' : 'none';
sendBtn.disabled = false; /* 流式期间仍可发送(服务端排队),仅中断按钮显隐 */
}
/* ---------- WebSocket 生命周期(指数退避自动重连,手动断开抑制) ---------- */
function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
clearTimeout(reconnectTimer);
manualClose = false;
setStatus('connecting', '连接中');
try {
ws = new WebSocket(WS_URL);
} catch (e) {
addSystem('连接失败:' + e.message, true);
scheduleReconnect();
return;
}
ws.onopen = function() {
reconnectDelay = RECONNECT_MIN;
setStatus('connected', '已连接');
connBtn.textContent = '断开';
connBtn.classList.add('on');
};
ws.onmessage = function(ev) {
let data;
try { data = JSON.parse(ev.data); }
catch (e) { data = { type: 'chunk', content: ev.data }; }
routeMessage(data);
};
ws.onclose = function() {
setStatus('disconnected', '已断开');
connBtn.textContent = '连接';
connBtn.classList.remove('on');
finishAi('');
ws = null;
if (!manualClose) scheduleReconnect();
};
ws.onerror = function() { /* onclose 随后触发,统一在 onclose 处理 */ };
}
function scheduleReconnect() {
if (manualClose) return;
setStatus('connecting', Math.round(reconnectDelay / 1000) + 's 后重连');
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(function() {
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
connect();
}, reconnectDelay);
}
function disconnect(manual) {
manualClose = manual !== false;
clearTimeout(reconnectTimer);
if (ws) { ws.close(); ws = null; }
setStatus('disconnected', manualClose ? '已手动断开' : '已断开');
connBtn.textContent = '连接';
connBtn.classList.remove('on');
}
/* ---------- aibox 协议消息路由(对应 BotOps app.py 的 _on_ws_message) ---------- */
function routeMessage(data) {
const t = data.type || '';
const content = typeof data.content === 'string' ? data.content : (data.content == null ? '' : String(data.content));
if (t === 'chunk') {
appendAi(content);
} else if (t === 'reasoning_start') {
startReasoning(content || '深度思考中...');
} else if (t === 'reasoning') {
appendReasoning(content);
} else if (t === 'reasoning_end') {
endReasoning();
} else if (t === 'line') {
/* 过程行:工具调用提示 / 工具输出 → 任务面板;助手前缀 / 思考提示聊天区已有承载,跳过 */
const stripped = content.trim();
if (!stripped) return;
if (/^(💬 助手|💬 Assistant|🤔 思考中|🤔 Thinking|🤔 深度思考中)/.test(stripped)) return;
addTaskLine(stripped);
} else if (t === 'complete') {
finishAi(content);
} else if (t === 'dialogue_ended') {
finishAi(aiText);
if (content) addSystem('✅ ' + content);
} else if (t === 'task_added') {
addSystem('➕ ' + content);
} else if (t === 'dialogue_reset') {
if (content) addSystem('🔄 ' + content);
} else if (t === 'pong') {
/* 心跳响应,忽略 */
} else if (t === 'task_process') {
const sub = data.msg_type || '';
const title = data.task_title || '未知任务';
if (sub === 'chunk') {
const tail = content.slice(-80);
if (tail.trim()) addTaskLine('⚡ ' + title + ': ' + tail);
} else if (sub === 'complete') {
addTaskLine('✅ 任务完成: ' + title);
}
} else if (t === 'task_status') {
const icon = { executing: '⚙️', complete: '✅', failed: '❌', added: '➕', batch_start: '🚀', batch_complete: '🏁' }[data.status] || '📋';
addTaskLine(icon + ' [' + (data.task_id || '?') + '] ' + (data.title || '') + ': ' + (data.message || content));
} else if (t === 'task_error') {
addTaskLine('❌ [' + (data.task_id || '?') + '] ' + (data.task_title || '?') + ': ' + (data.error || '未知错误'));
} else if (t === 'interrupt_ack') {
addSystem('🛑 ' + (content || '已中断'));
} else if (t === 'error') {
addSystem('❌ ' + content, true);
} else if (t === 'info') {
/* 过滤隐式通知(对话开始等),避免污染聊天流 */
if (!/(Conversation started|对话开始|🎭)/.test(content)) addSystem('ℹ️ ' + content);
} else if (t === 'command_result') {
addSystem(content);
} else if (t === 'connection_ack') {
addSystem('✅ ' + content);
} else if (t === 'notification') {
addSystem('🔔 ' + content);
} else if (content) {
appendAi(content); /* 未知类型兜底:按 AI 增量处理(与 BotOps 一致) */
}
}
/* ---------- 发送 ---------- */
function wsSend(obj) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
addSystem('未连接到服务器', true);
return false;
}
ws.send(JSON.stringify(obj));
return true;
}
const LOCAL_COMMANDS = ['/clear', '/help', '/connect', '/disconnect', '/interrupt'];
function handleLocalCommand(cmd) {
switch (cmd) {
case '/clear':
chatEl.replaceChildren();
finishAi('');
return true;
case '/help':
addSystem('本地命令:/clear 清屏 · /connect 连接 · /disconnect 断开 · /interrupt 中断 · 其余 / 命令透传服务器');
return true;
case '/connect':
connect();
return true;
case '/disconnect':
disconnect(true);
return true;
case '/interrupt':
sendInterrupt();
return true;
default:
return false;
}
}
function sendCurrentInput() {
const text = inputEl.value.trim();
if (!text) return;
if (text.startsWith('/') && handleLocalCommand(text.split(/\s/)[0])) {
inputEl.value = '';
autoResize();
return;
}
/* 与 BotOps 一致:/ 开头按 command 发送,否则 message */
const data = text.startsWith('/')
? { type: 'command', command: text }
: { type: 'message', content: text };
if (!wsSend(data)) return;
addUserBubble(text);
aiActive = true; /* 开始接收本轮 AI 流 */
setStreaming(true);
inputEl.value = '';
autoResize();
inputEl.focus();
}
function sendInterrupt() {
wsSend({ type: 'interrupt', content: '用户按下了 Ctrl+C 中断当前操作' });
}
/* ---------- 输入区自适应高度 ---------- */
function autoResize() {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
}
inputEl.addEventListener('input', autoResize);
inputEl.addEventListener('keydown', function(e) {
/* Enter 发送,Shift+Enter 换行(移动端依赖发送按钮,不影响) */
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
sendCurrentInput();
}
});
sendBtn.addEventListener('click', sendCurrentInput);
interruptBtn.addEventListener('click', sendInterrupt);
/* ---------- 连接按钮与任务面板 ---------- */
connBtn.addEventListener('click', function() {
if (ws && ws.readyState === WebSocket.OPEN) disconnect(true);
else connect();
});
function openTaskPanel(open) {
taskPanel.classList.toggle('open', open);
backdrop.classList.toggle('show', open);
if (open) {
unreadTasks = 0;
taskBadge.style.display = 'none';
}
}
taskToggle.addEventListener('click', function() { openTaskPanel(!taskPanel.classList.contains('open')); });
document.getElementById('task-close').addEventListener('click', function() { openTaskPanel(false); });
backdrop.addEventListener('click', function() { openTaskPanel(false); });
/* ---------- 启动:管理员预检(WS 握手由 gateway 强制 is_admin,此处仅提示) ---------- */
(async function init() {
try {
const res = await fetch('/whoami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const data = res.ok ? await res.json() : null;
const tip = document.getElementById('admin-tip');
if (!data || !data.login) {
tip.textContent = '未登录:aibox 仅管理员可用,请先登录管理员账号';
tip.style.display = '';
} else if (!data.is_admin) {
tip.textContent = '当前账号不是管理员,连接将被网关拒绝';
tip.style.display = '';
}
} catch (e) { /* 预检失败不阻塞,由 WS 握手兜底 */ }
connect();
})();
</script>
</body>
</html>