refactor(static): aibox.html 拆分内联 CSS/JS 为独立文件
This commit is contained in:
1 parent
227fbb17f4
commit
65580e7e50
3 files changed
+608
-610
No files matched your search
+2
-610
@@ -6,221 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover">
|
||||||
<title>aibox 智能体</title>
|
<title>aibox 智能体</title>
|
||||||
<link rel="stylesheet" href="css/theme.css">
|
<link rel="stylesheet" href="css/theme.css">
|
||||||
<style>
|
<link rel="stylesheet" href="css/aibox.css">
|
||||||
/* ===== 全屏应用布局(覆盖 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -244,400 +30,6 @@ body {
|
|||||||
</aside>
|
</aside>
|
||||||
<div id="backdrop"></div>
|
<div id="backdrop"></div>
|
||||||
<script src="js/markdown-it-12_0_0.js"></script>
|
<script src="js/markdown-it-12_0_0.js"></script>
|
||||||
<script>
|
<script src="js/aibox.js"></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/* ===== 全屏应用布局(覆盖 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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
'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();
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user