diff --git a/static/public/aibox.html b/static/public/aibox.html index 5436da9..89fb608 100644 --- a/static/public/aibox.html +++ b/static/public/aibox.html @@ -6,221 +6,7 @@ aibox 智能体 - +
@@ -244,400 +30,6 @@ body {
- + diff --git a/static/public/css/aibox.css b/static/public/css/aibox.css new file mode 100644 index 0000000..e71417b --- /dev/null +++ b/static/public/css/aibox.css @@ -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; } +} diff --git a/static/public/js/aibox.js b/static/public/js/aibox.js new file mode 100644 index 0000000..056676a --- /dev/null +++ b/static/public/js/aibox.js @@ -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(); +})();