fix(static): aibox Web客户端适配中断机制(Ctrl+C可靠停止)
问题(与BotOps v1.3修复一致): - interrupt_ack只显示系统消息,没有立即收尾前端状态 - 思考区域一直停留在"💭 思考中..."状态(服务端中断不发reasoning_end) - AI状态未重置,残留reasoning片段可能重新创建思考区域 修复: - 新增abortReasoning()函数:中断时更新思考区域标签为"🛑 思考已中断"(保留已展示内容) - appendReasoning()增加aiActive===false检查,中断后丢弃残留片段 - interrupt_ack处理改为:先abortReasoning()更新思考区域,再finishAi()收尾AI状态,最后显示通知 - 中断后残留chunk/reasoning/complete均被安全忽略(appendAi已有aiActive检查,finishAi幂等)
This commit is contained in:
1 parent
c7ef7e715c
commit
08f737ba2c
1 file changed
+60
-52
+60
-52
@@ -1,6 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
* aibox Web 客户端(经 gateway WS 反向代理,仅管理员可用)
|
* aibox Web 客户端 · 移动端版(经 gateway WS 反向代理,仅管理员可用)
|
||||||
* 协议与 BotOps/aibox client.py 完全兼容:
|
* 协议与 BotOps/aibox client.py 完全兼容:
|
||||||
* 发送:{"type":"message","content":...} / {"type":"command","command":"/..."} / {"type":"interrupt",...}
|
* 发送:{"type":"message","content":...} / {"type":"command","command":"/..."} / {"type":"interrupt",...}
|
||||||
* 接收:chunk(增量片段) / reasoning_start / reasoning / reasoning_end / complete /
|
* 接收:chunk(增量片段) / reasoning_start / reasoning / reasoning_end / complete /
|
||||||
@@ -12,19 +12,20 @@
|
|||||||
/* gateway [WsProxy] routes 中配置的路径(HttpOnly q4_token Cookie 随握手自动携带,JS 不经手 Token)。
|
/* gateway [WsProxy] routes 中配置的路径(HttpOnly q4_token Cookie 随握手自动携带,JS 不经手 Token)。
|
||||||
* 需与 gateway.ini 的 [WsProxy] routes 条目一致,如:routes = /ws/aibox=ws://127.0.0.1:8765; */
|
* 需与 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 WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/aibox';
|
||||||
const RECONNECT_MIN = 2000; /* 指数退避起点(与 BotOps 一致:2s→30s 封顶) */
|
const RECONNECT_MIN = 2000; /* 指数退避起点(2s→30s 封顶) */
|
||||||
const RECONNECT_MAX = 30000;
|
const RECONNECT_MAX = 30000;
|
||||||
const REASONING_TAIL = 3000; /* 思考区域超长时仅显示末尾字符数 */
|
const REASONING_TAIL = 3000; /* 思考区域超长时仅显示末尾字符数 */
|
||||||
|
|
||||||
|
/* ---------- Markdown 渲染(链接一律新标签打开且不带 referrer) ---------- */
|
||||||
const md = window.markdownit({ html: false, linkify: true, breaks: true });
|
const md = window.markdownit({ html: false, linkify: true, breaks: true });
|
||||||
/* 链接一律新标签打开且不带 referrer(CSP connect-src 'self' 允许 WS 同源) */
|
const defaultLinkOpen = md.renderer.rules.link_open || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options));
|
||||||
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); };
|
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||||
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
|
|
||||||
tokens[idx].attrSet('target', '_blank');
|
tokens[idx].attrSet('target', '_blank');
|
||||||
tokens[idx].attrSet('rel', 'noopener noreferrer');
|
tokens[idx].attrSet('rel', 'noopener noreferrer');
|
||||||
return defaultLinkOpen(tokens, idx, options, env, self);
|
return defaultLinkOpen(tokens, idx, options, env, self);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---------- DOM ---------- */
|
||||||
const chatEl = document.getElementById('chat');
|
const chatEl = document.getElementById('chat');
|
||||||
const inputEl = document.getElementById('input');
|
const inputEl = document.getElementById('input');
|
||||||
const sendBtn = document.getElementById('send-btn');
|
const sendBtn = document.getElementById('send-btn');
|
||||||
@@ -38,26 +39,26 @@ const taskToggle = document.getElementById('task-toggle');
|
|||||||
const taskBadge = document.getElementById('task-badge');
|
const taskBadge = document.getElementById('task-badge');
|
||||||
const backdrop = document.getElementById('backdrop');
|
const backdrop = document.getElementById('backdrop');
|
||||||
|
|
||||||
|
/* ---------- 状态 ---------- */
|
||||||
let ws = null;
|
let ws = null;
|
||||||
let manualClose = false; /* 手动断开则抑制自动重连 */
|
let manualClose = false; /* 手动断开则抑制自动重连 */
|
||||||
let reconnectDelay = RECONNECT_MIN;
|
let reconnectDelay = RECONNECT_MIN;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
|
|
||||||
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前,与 BotOps 同策略) */
|
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前) */
|
||||||
let aiBubble = null, aiText = '', aiActive = false;
|
let aiBubble = null, aiText = '', aiActive = false;
|
||||||
let reasoningBox = null, reasoningBody = null, reasoningHead = null, reasoningText = '';
|
let reasoningBox = null, reasoningBody = null, reasoningHead = null, reasoningText = '';
|
||||||
let streaming = false;
|
|
||||||
let unreadTasks = 0;
|
let unreadTasks = 0;
|
||||||
|
|
||||||
/* ---------- 工具 ---------- */
|
/* ---------- 滚动(距底 80px 内才自动跟随,用户上翻阅读不打断) ---------- */
|
||||||
function nearBottom() {
|
function nearBottom() {
|
||||||
return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80;
|
return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80;
|
||||||
}
|
}
|
||||||
function scrollToBottom(force) {
|
function scrollToBottom(force) {
|
||||||
if (force || nearBottom()) chatEl.scrollTop = chatEl.scrollHeight;
|
if (force || nearBottom()) chatEl.scrollTop = chatEl.scrollHeight;
|
||||||
}
|
}
|
||||||
function escapeText(s) { return String(s == null ? '' : s); }
|
|
||||||
|
|
||||||
|
/* ---------- 系统消息 / 任务面板行 ---------- */
|
||||||
function addSystem(text, isError) {
|
function addSystem(text, isError) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = isError ? 'msg msg-error' : 'msg msg-system';
|
div.className = isError ? 'msg msg-error' : 'msg msg-system';
|
||||||
@@ -130,7 +131,7 @@ function startReasoning(hint) {
|
|||||||
reasoningHead.textContent = '💭 ' + (hint || '思考中...');
|
reasoningHead.textContent = '💭 ' + (hint || '思考中...');
|
||||||
reasoningBody = document.createElement('div');
|
reasoningBody = document.createElement('div');
|
||||||
reasoningBody.className = 'reasoning-body';
|
reasoningBody.className = 'reasoning-body';
|
||||||
reasoningHead.addEventListener('click', function() { reasoningBody.classList.toggle('folded'); });
|
reasoningHead.addEventListener('click', () => reasoningBody.classList.toggle('folded'));
|
||||||
reasoningBox.appendChild(reasoningHead);
|
reasoningBox.appendChild(reasoningHead);
|
||||||
reasoningBox.appendChild(reasoningBody);
|
reasoningBox.appendChild(reasoningBody);
|
||||||
chatEl.appendChild(reasoningBox);
|
chatEl.appendChild(reasoningBox);
|
||||||
@@ -138,12 +139,12 @@ function startReasoning(hint) {
|
|||||||
}
|
}
|
||||||
function appendReasoning(delta) {
|
function appendReasoning(delta) {
|
||||||
if (!delta) return;
|
if (!delta) return;
|
||||||
|
if (aiActive === false) return; /* 已收尾(中断/完成),丢弃残留片段 */
|
||||||
if (!reasoningBox) startReasoning();
|
if (!reasoningBox) startReasoning();
|
||||||
reasoningText += delta;
|
reasoningText += delta;
|
||||||
const display = reasoningText.length > REASONING_TAIL
|
reasoningBody.textContent = reasoningText.length > REASONING_TAIL
|
||||||
? '...(前略)\n' + reasoningText.slice(-REASONING_TAIL)
|
? '...(前略)\n' + reasoningText.slice(-REASONING_TAIL)
|
||||||
: reasoningText;
|
: reasoningText;
|
||||||
reasoningBody.textContent = display;
|
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
function endReasoning() {
|
function endReasoning() {
|
||||||
@@ -152,6 +153,16 @@ function endReasoning() {
|
|||||||
reasoningBody.classList.add('folded'); /* 结束后默认折叠,避免占屏 */
|
reasoningBody.classList.add('folded'); /* 结束后默认折叠,避免占屏 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function abortReasoning() {
|
||||||
|
/* 用户中断:更新思考区域标签为已中断,保留已展示内容 */
|
||||||
|
if (reasoningHead) {
|
||||||
|
if (reasoningText) {
|
||||||
|
reasoningHead.textContent = '🛑 思考已中断(' + reasoningText.length + ' 字符)';
|
||||||
|
} else {
|
||||||
|
reasoningHead.textContent = '🛑 思考已中断';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 连接状态 ---------- */
|
/* ---------- 连接状态 ---------- */
|
||||||
function setStatus(cls, text) {
|
function setStatus(cls, text) {
|
||||||
@@ -159,7 +170,6 @@ function setStatus(cls, text) {
|
|||||||
statusText.textContent = text;
|
statusText.textContent = text;
|
||||||
}
|
}
|
||||||
function setStreaming(on) {
|
function setStreaming(on) {
|
||||||
streaming = on;
|
|
||||||
interruptBtn.style.display = on ? '' : 'none';
|
interruptBtn.style.display = on ? '' : 'none';
|
||||||
sendBtn.disabled = false; /* 流式期间仍可发送(服务端排队),仅中断按钮显隐 */
|
sendBtn.disabled = false; /* 流式期间仍可发送(服务端排队),仅中断按钮显隐 */
|
||||||
}
|
}
|
||||||
@@ -177,19 +187,19 @@ function connect() {
|
|||||||
scheduleReconnect();
|
scheduleReconnect();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ws.onopen = function() {
|
ws.onopen = () => {
|
||||||
reconnectDelay = RECONNECT_MIN;
|
reconnectDelay = RECONNECT_MIN;
|
||||||
setStatus('connected', '已连接');
|
setStatus('connected', '已连接');
|
||||||
connBtn.textContent = '断开';
|
connBtn.textContent = '断开';
|
||||||
connBtn.classList.add('on');
|
connBtn.classList.add('on');
|
||||||
};
|
};
|
||||||
ws.onmessage = function(ev) {
|
ws.onmessage = (ev) => {
|
||||||
let data;
|
let data;
|
||||||
try { data = JSON.parse(ev.data); }
|
try { data = JSON.parse(ev.data); }
|
||||||
catch (e) { data = { type: 'chunk', content: ev.data }; }
|
catch (e) { data = { type: 'chunk', content: ev.data }; }
|
||||||
routeMessage(data);
|
routeMessage(data);
|
||||||
};
|
};
|
||||||
ws.onclose = function() {
|
ws.onclose = () => {
|
||||||
setStatus('disconnected', '已断开');
|
setStatus('disconnected', '已断开');
|
||||||
connBtn.textContent = '连接';
|
connBtn.textContent = '连接';
|
||||||
connBtn.classList.remove('on');
|
connBtn.classList.remove('on');
|
||||||
@@ -197,14 +207,14 @@ function connect() {
|
|||||||
ws = null;
|
ws = null;
|
||||||
if (!manualClose) scheduleReconnect();
|
if (!manualClose) scheduleReconnect();
|
||||||
};
|
};
|
||||||
ws.onerror = function() { /* onclose 随后触发,统一在 onclose 处理 */ };
|
ws.onerror = () => { /* onclose 随后触发,统一在 onclose 处理 */ };
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect() {
|
function scheduleReconnect() {
|
||||||
if (manualClose) return;
|
if (manualClose) return;
|
||||||
setStatus('connecting', Math.round(reconnectDelay / 1000) + 's 后重连');
|
setStatus('connecting', Math.round(reconnectDelay / 1000) + 's 后重连');
|
||||||
clearTimeout(reconnectTimer);
|
clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = setTimeout(function() {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
||||||
connect();
|
connect();
|
||||||
}, reconnectDelay);
|
}, reconnectDelay);
|
||||||
@@ -219,7 +229,7 @@ function disconnect(manual) {
|
|||||||
connBtn.classList.remove('on');
|
connBtn.classList.remove('on');
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- aibox 协议消息路由(对应 BotOps app.py 的 _on_ws_message) ---------- */
|
/* ---------- aibox 协议消息路由 ---------- */
|
||||||
function routeMessage(data) {
|
function routeMessage(data) {
|
||||||
const t = data.type || '';
|
const t = data.type || '';
|
||||||
const content = typeof data.content === 'string' ? data.content : (data.content == null ? '' : String(data.content));
|
const content = typeof data.content === 'string' ? data.content : (data.content == null ? '' : String(data.content));
|
||||||
@@ -264,6 +274,9 @@ function routeMessage(data) {
|
|||||||
} else if (t === 'task_error') {
|
} else if (t === 'task_error') {
|
||||||
addTaskLine('❌ [' + (data.task_id || '?') + '] ' + (data.task_title || '?') + ': ' + (data.error || '未知错误'));
|
addTaskLine('❌ [' + (data.task_id || '?') + '] ' + (data.task_title || '?') + ': ' + (data.error || '未知错误'));
|
||||||
} else if (t === 'interrupt_ack') {
|
} else if (t === 'interrupt_ack') {
|
||||||
|
/* 服务端确认中断:立即收尾前端状态 */
|
||||||
|
abortReasoning();
|
||||||
|
finishAi('');
|
||||||
addSystem('🛑 ' + (content || '已中断'));
|
addSystem('🛑 ' + (content || '已中断'));
|
||||||
} else if (t === 'error') {
|
} else if (t === 'error') {
|
||||||
addSystem('❌ ' + content, true);
|
addSystem('❌ ' + content, true);
|
||||||
@@ -291,38 +304,31 @@ function wsSend(obj) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCAL_COMMANDS = ['/clear', '/help', '/connect', '/disconnect', '/interrupt'];
|
/* 本地命令表:命中则本地处理,其余 / 命令透传服务器 */
|
||||||
|
const LOCAL_COMMANDS = {
|
||||||
function handleLocalCommand(cmd) {
|
'/clear'() {
|
||||||
switch (cmd) {
|
|
||||||
case '/clear':
|
|
||||||
chatEl.replaceChildren();
|
chatEl.replaceChildren();
|
||||||
finishAi('');
|
finishAi('');
|
||||||
return true;
|
},
|
||||||
case '/help':
|
'/help'() {
|
||||||
addSystem('本地命令:/clear 清屏 · /connect 连接 · /disconnect 断开 · /interrupt 中断 · 其余 / 命令透传服务器');
|
addSystem('本地命令:/clear 清屏 · /connect 连接 · /disconnect 断开 · /interrupt 中断 · 其余 / 命令透传服务器');
|
||||||
return true;
|
},
|
||||||
case '/connect':
|
'/connect'() { connect(); },
|
||||||
connect();
|
'/disconnect'() { disconnect(true); },
|
||||||
return true;
|
'/interrupt'() { sendInterrupt(); },
|
||||||
case '/disconnect':
|
};
|
||||||
disconnect(true);
|
|
||||||
return true;
|
|
||||||
case '/interrupt':
|
|
||||||
sendInterrupt();
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendCurrentInput() {
|
function sendCurrentInput() {
|
||||||
const text = inputEl.value.trim();
|
const text = inputEl.value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (text.startsWith('/') && handleLocalCommand(text.split(/\s/)[0])) {
|
if (text.startsWith('/')) {
|
||||||
inputEl.value = '';
|
const local = LOCAL_COMMANDS[text.split(/\s/)[0]];
|
||||||
autoResize();
|
if (local) {
|
||||||
return;
|
local();
|
||||||
|
inputEl.value = '';
|
||||||
|
autoResize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/* 与 BotOps 一致:/ 开头按 command 发送,否则 message */
|
/* 与 BotOps 一致:/ 开头按 command 发送,否则 message */
|
||||||
const data = text.startsWith('/')
|
const data = text.startsWith('/')
|
||||||
@@ -347,18 +353,20 @@ function autoResize() {
|
|||||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
||||||
}
|
}
|
||||||
inputEl.addEventListener('input', autoResize);
|
inputEl.addEventListener('input', autoResize);
|
||||||
inputEl.addEventListener('keydown', function(e) {
|
inputEl.addEventListener('keydown', (e) => {
|
||||||
/* Enter 发送,Shift+Enter 换行(移动端依赖发送按钮,不影响) */
|
/* Enter 发送,Shift+Enter 换行;isComposing 防中文输入法选词误发 */
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendCurrentInput();
|
sendCurrentInput();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
/* 软键盘弹出动画结束后保持聊天区停留底部 */
|
||||||
|
inputEl.addEventListener('focus', () => setTimeout(() => scrollToBottom(true), 300));
|
||||||
sendBtn.addEventListener('click', sendCurrentInput);
|
sendBtn.addEventListener('click', sendCurrentInput);
|
||||||
interruptBtn.addEventListener('click', sendInterrupt);
|
interruptBtn.addEventListener('click', sendInterrupt);
|
||||||
|
|
||||||
/* ---------- 连接按钮与任务面板 ---------- */
|
/* ---------- 连接按钮与任务面板(抽屉开关) ---------- */
|
||||||
connBtn.addEventListener('click', function() {
|
connBtn.addEventListener('click', () => {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) disconnect(true);
|
if (ws && ws.readyState === WebSocket.OPEN) disconnect(true);
|
||||||
else connect();
|
else connect();
|
||||||
});
|
});
|
||||||
@@ -371,12 +379,12 @@ function openTaskPanel(open) {
|
|||||||
taskBadge.style.display = 'none';
|
taskBadge.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
taskToggle.addEventListener('click', function() { openTaskPanel(!taskPanel.classList.contains('open')); });
|
taskToggle.addEventListener('click', () => openTaskPanel(!taskPanel.classList.contains('open')));
|
||||||
document.getElementById('task-close').addEventListener('click', function() { openTaskPanel(false); });
|
document.getElementById('task-close').addEventListener('click', () => openTaskPanel(false));
|
||||||
backdrop.addEventListener('click', function() { openTaskPanel(false); });
|
backdrop.addEventListener('click', () => openTaskPanel(false));
|
||||||
|
|
||||||
/* ---------- 启动:管理员预检(WS 握手由 gateway 强制 is_admin,此处仅提示) ---------- */
|
/* ---------- 启动:管理员预检(WS 握手由 gateway 强制 is_admin,此处仅提示) ---------- */
|
||||||
(async function init() {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/whoami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
const res = await fetch('/whoami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||||
const data = res.ok ? await res.json() : null;
|
const data = res.ok ? await res.json() : null;
|
||||||
|
|||||||
Reference in new issue
Block a user