/* ============================================================ BLACK BEAN · 黑豆 — 前端逻辑(v20260812f 全面重写) 设计原则: 1. 任何用户操作都有可见反馈(toast / 状态栏 / 聊天区),绝不静默失败 2. WebSocket 生命周期明确:心跳保活、有限次指数退避重连、会话失效自动重建、无重连风暴 3. 流式状态可自愈:卡死的流自动清理,绝不阻塞后续发送 4. 渲染永不抛异常:渲染失败降级为纯文本 ============================================================ */ "use strict"; const PAGE_SIZE = 50; // 无任何 Agent 流事件超过该时长,且当前没有运行中的工具步骤 → 判定流卡死 const STREAM_STALE_MS = 60000; // 心跳间隔(服务端 90s 读超时,每 25s 发一次应用层 ping 刷新) const HEARTBEAT_MS = 25000; const INPUT_PLACEHOLDER = "描述你的任务,例如:分析 server.log 中响应时间最长的 5 条请求..."; const state = { sessions: [], currentId: null, streaming: false, activeStream: null, // 当前进行中的流(含后台协作通知的被动流) config: null, currentMessages: [], hasMore: false, loadingOlder: false, lastEventAt: 0, // 最近一次 Agent 流事件时间戳(用于识别卡死的流) llmConfigured: false // 是否已配置 LLM API Key(环境变量或设置页) }; const els = { sidebar: document.getElementById("sidebar"), sessionList: document.getElementById("session-list"), sessionSearch: document.getElementById("session-search"), newChatBtn: document.getElementById("new-chat-btn"), refreshBtn: document.getElementById("refresh-btn"), menuBtn: document.getElementById("menu-btn"), sessionTitle: document.getElementById("session-title"), sessionSub: document.getElementById("session-sub"), chat: document.getElementById("chat"), scrollBottomBtn: document.getElementById("scroll-bottom-btn"), emptyState: document.getElementById("empty-state"), input: document.getElementById("input"), sendBtn: document.getElementById("send-btn"), stopBtn: document.getElementById("stop-btn"), statusDot: document.getElementById("status-dot"), statusText: document.getElementById("status-text"), contextHint: document.getElementById("context-hint"), ctxChip: document.getElementById("ctx-chip"), modelChip: document.getElementById("model-chip"), brandModel: null, settingsBtn: document.getElementById("settings-btn"), settingsModal: document.getElementById("settings-modal"), dockerSocketInput: document.getElementById("docker-socket-input"), coopEngineSelect: document.getElementById("coop-engine-select"), coopModeSelect: document.getElementById("coop-mode-select"), providerSelect: document.getElementById("provider-select"), providerHint: document.getElementById("provider-hint"), apiKeyInput: document.getElementById("api-key-input"), baseUrlInput: document.getElementById("base-url-input"), modelInput: document.getElementById("model-input"), saveSettingsBtn: document.getElementById("save-settings-btn"), resetSettingsBtn: document.getElementById("reset-settings-btn"), closeSettingsBtn: document.getElementById("close-settings-btn"), toast: document.getElementById("toast"), coopPanel: document.getElementById("coop-panel"), coopPanelHeader: document.getElementById("coop-panel-header"), coopCount: document.getElementById("coop-count"), coopList: document.getElementById("coop-list"), authModal: document.getElementById("auth-modal"), authCodeInput: document.getElementById("auth-code-input"), authSubmitBtn: document.getElementById("auth-submit-btn"), authError: document.getElementById("auth-error") }; /* ============================================================ 图标 ============================================================ */ const ICONS = { task: '', terminal: '', file: '', folder: '', python: '', docker: '', coop: '', tool: '', compress: '' }; function toolIcon(name) { if (name.startsWith("docker_")) return ICONS.docker; if (name === "run_coop") return ICONS.coop; if (name === "read_file" || name === "write_file") return ICONS.file; if (name === "list_directory") return ICONS.folder; if (name === "run_python") return ICONS.python; return ICONS.terminal; } /* ============================================================ 基础工具 ============================================================ */ async function api(path, options = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options }); if (!res.ok) { let message = `HTTP ${res.status}`; try { const data = await res.json(); message = data.error || message; } catch (_) {} // 授权失效(授权码被重置 / Cookie 丢失):弹窗引导重新授权 if (res.status === 401 && !path.startsWith("/api/v1/auth/")) { showAuthModal(); } throw new Error(message); } if (res.status === 204) return null; return res.json(); } /* ============================================================ 访问授权 默认关闭;仅当服务端通过环境变量 AGENT_AUTH_CODE 启用授权时, 前端先查询 /auth/status,未授权则弹窗要求输入授权码, 验证通过后服务端签发 HttpOnly Cookie,后续 API / WS 自动携带。 ============================================================ */ // 确认授权就绪:无需授权或已通过验证返回 true,否则展示授权弹窗等待验证 async function ensureAuthed() { let status; try { status = await api("/api/v1/auth/status"); } catch (e) { showToast("无法连接服务器: " + e.message, "error"); setStatus("连接失败", false, true); return false; } if (!status.required || status.authorized) return true; return showAuthModal(); } // 读取 URL 查询参数(如 ?authcode=xxx) function getQueryParam(name) { try { return new URLSearchParams(window.location.search).get(name); } catch (e) { return null; } } // 验证成功后从地址栏清除 authcode,避免授权码残留在 URL 中 function clearAuthcodeFromUrl() { try { const params = new URLSearchParams(window.location.search); if (params.has("authcode")) { params.delete("authcode"); const qs = params.toString(); const newUrl = window.location.pathname + (qs ? "?" + qs : "") + window.location.hash; history.replaceState(null, "", newUrl); } } catch (e) { /* 忽略清理失败,不影响功能 */ } } // 展示授权弹窗并等待用户验证,返回是否验证成功 function showAuthModal() { els.authError.classList.add("hidden"); // 支持 URL 查询参数 ?authcode=xxx 自动填入授权码(便于分享链接给他人直接使用) const urlCode = getQueryParam("authcode"); els.authCodeInput.value = urlCode || ""; els.authModal.classList.remove("hidden"); els.authCodeInput.focus(); return new Promise((resolve) => { const cleanup = () => { els.authSubmitBtn.removeEventListener("click", onSubmit); els.authCodeInput.removeEventListener("keydown", onKey); }; const onSubmit = async () => { const code = els.authCodeInput.value.trim(); if (!code) { els.authError.textContent = "请输入授权码"; els.authError.classList.remove("hidden"); return; } els.authSubmitBtn.disabled = true; try { await api("/api/v1/auth/login", { method: "POST", body: JSON.stringify({ code }) }); clearAuthcodeFromUrl(); // 验证成功后清除地址栏授权码 els.authModal.classList.add("hidden"); cleanup(); resolve(true); } catch (e) { els.authError.textContent = e.message || "授权失败,请重试"; els.authError.classList.remove("hidden"); } finally { els.authSubmitBtn.disabled = false; } }; const onKey = (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); onSubmit(); } }; els.authSubmitBtn.addEventListener("click", onSubmit); els.authCodeInput.addEventListener("keydown", onKey); // URL 携带 authcode 时自动触发验证,他人打开分享链接即可直接使用 if (urlCode) { setTimeout(() => onSubmit(), 50); } }); } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function showToast(message, type = "") { els.toast.textContent = message; els.toast.className = `toast show ${type}`; clearTimeout(showToast.timer); showToast.timer = setTimeout(() => { els.toast.className = "toast"; }, 3200); } function setStatus(text, busy = false, error = false) { els.statusText.textContent = text; els.statusDot.classList.toggle("busy", busy); els.statusDot.classList.toggle("error", error); } function debounce(fn, delay) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; } /* ============================================================ Markdown 渲染(永不抛异常) ============================================================ */ // 行内格式:先用占位符保护行内代码,再处理图片/链接/删除线/粗体/斜体,最后还原代码 function renderInline(text) { const codes = []; let v = String(text).replace(/`([^`]+)`/g, (_, code) => { codes.push(code); return "\u0000" + (codes.length - 1) + "\u0000"; }); v = escapeHtml(v); v = v.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, alt, src) => `${escapeHtml(alt)}`); v = v.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, href) => `${label}`); v = v.replace(/~~([^~]+)~~/g, "$1"); v = v.replace(/\*\*([^*]+)\*\*/g, "$1"); v = v.replace(/(^|[^*])\*([^*\s][^*]*)\*(?!\*)/g, "$1$2"); return v.replace(/\u0000(\d+)\u0000/g, (_, i) => `${escapeHtml(codes[Number(i)])}`); } // 仅允许安全协议,避免 javascript: 等注入 function sanitizeUrl(url) { const value = String(url).trim(); if (/^(https?:|mailto:|tel:|#|\/)/i.test(value)) return value; if (/^\.{1,2}\//.test(value)) return value; return "#"; } // 对外安全入口:任何渲染异常都降级为纯文本,绝不阻断消息收发 function renderMarkdown(text) { try { return renderMarkdownRaw(text); } catch (_) { return `

${escapeHtml(String(text))}

`; } } function renderMarkdownRaw(text) { const parts = []; const codeRegex = /```([\w+-]*)\r?\n?([\s\S]*?)```/g; let lastIndex = 0; let match; while ((match = codeRegex.exec(text)) !== null) { parts.push({ type: "text", text: text.slice(lastIndex, match.index) }); parts.push({ type: "code", lang: match[1], code: escapeHtml(match[2].replace(/\n$/, "")) }); lastIndex = match.index + match[0].length; } parts.push({ type: "text", text: text.slice(lastIndex) }); return parts.map((part) => { if (part.type === "code") { const lang = part.lang ? ` class="language-${escapeHtml(part.lang)}"` : ""; return `
${part.code}
`; } // 表格块 const tableRegex = /^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/gm; const textHtml = part.text.replace(tableRegex, (_, header, _sep, rows) => { const cells = (line) => line .trim() .replace(/^\||\|$/g, "") .split("|") .map((cell) => cell.trim()); const head = cells(header).map((c) => `${renderInline(c)}`).join(""); const body = rows .trim() .split("\n") .map((row) => `${cells(row).map((c) => `${renderInline(c)}`).join("")}`) .join(""); return `${head}${body}
`; }); const lines = textHtml.split("\n"); let html = ""; let listTag = null; for (const line of lines) { const ulMatch = line.match(/^[-*] (.*)$/); const olMatch = line.match(/^\d+[.)] (.*)$/); const listMatch = ulMatch || olMatch; const nextTag = ulMatch ? "ul" : "ol"; if (listMatch) { if (listTag !== nextTag) { if (listTag) html += ``; html += `<${nextTag}>`; listTag = nextTag; } html += `
  • ${renderInline(listMatch[1])}
  • `; continue; } if (listTag) { html += ``; listTag = null; } const hMatch = line.match(/^(#{1,3})\s+(.*)$/); if (hMatch) { const level = hMatch[1].length; html += `${renderInline(hMatch[2])}`; } else if (/^/.test(line)) { html += line; } else if (/^> ?/.test(line)) { html += `
    ${renderInline(line.replace(/^> ?/, ""))}
    `; } else if (/^(---+|\*\*\*+)$/.test(line)) { html += "
    "; } else if (line.trim()) { html += `

    ${renderInline(line)}

    `; } } if (listTag) html += ``; return html; }).join(""); } function prettyArguments(raw) { try { return JSON.stringify(JSON.parse(raw), null, 2); } catch (_) { return raw; } } /* ============================================================ WebSocket 实时通道 关键设计: - 心跳保活(应用层 ping,服务端已适配刷新 90s 读超时) - 只有"曾成功打开"的连接意外断开才自动重连(指数退避,最多 5 次) - 从未打开的连接(如会话已删除返回 404)不自动重连,避免无限风暴; 发送时 ensureValidSession() 会自动重建会话并连接 ============================================================ */ const ws = { conn: null, pingTimer: null, reconnectTimer: null, boundSession: null, retries: 0, MAX_RETRIES: 5, connect(sessionId) { if (!sessionId) return; this.disconnect(); this.boundSession = sessionId; // 注意:retries 不在这里清零,只在 onopen(真正建立成功)时清零, // 否则服务端重启期间的"连接被拒"会被每次 connect 重置计数, // 导致 MAX_RETRIES 形同虚设,产生无限重连风暴。 const proto = location.protocol === "https:" ? "wss:" : "ws:"; const url = `${proto}//${location.host}/ws?session=${sessionId}`; let conn; try { conn = new WebSocket(url); } catch (error) { setStatus("无法建立连接", false, true); showToast("WebSocket 创建失败: " + error.message, "error"); return; } this.conn = conn; let opened = false; conn.onopen = () => { opened = true; this.retries = 0; this.startHeartbeat(); setStatus("就绪", false, false); }; conn.onmessage = (event) => { let frame; try { frame = JSON.parse(event.data); } catch (_) { return; } handleWsEvent(frame.type, frame.data || {}); }; conn.onclose = () => { this.stopHeartbeat(); if (this.conn !== conn) return; this.conn = null; if (state.streaming) { forceResetStream(true); } if (opened) { // 曾正常建立(服务端重启 / 网络波动):有限次指数退避重连 setStatus("连接已断开,正在重连…", false, true); } else { // 从未成功打开(404 / 服务端刚重启被拒):仍按有限次数重连。 // 服务端重启期间连接会被拒绝(opened=false),若此时放弃重连, // 页面将永远收不到后续事件,直到用户手动刷新——应等服务恢复后自动连上。 setStatus("未连接,正在尝试重连…", false, true); } this.scheduleReconnect(); }; conn.onerror = () => { // onclose 会随之触发 }; }, scheduleReconnect() { clearTimeout(this.reconnectTimer); if (this.retries >= this.MAX_RETRIES) { setStatus("连接失败,发送消息时将自动重连", false, true); return; } const delay = Math.min(2000 * Math.pow(2, this.retries), 16000); this.retries++; this.reconnectTimer = setTimeout(() => { if (state.currentId) this.connect(state.currentId); }, delay); }, disconnect() { clearTimeout(this.reconnectTimer); this.stopHeartbeat(); if (this.conn) { const old = this.conn; this.conn = null; old.onclose = null; old.onerror = null; try { old.close(); } catch (_) {} } this.boundSession = null; }, startHeartbeat() { clearInterval(this.pingTimer); this.pingTimer = setInterval(() => { this.send({ type: "ping" }); }, HEARTBEAT_MS); }, stopHeartbeat() { clearInterval(this.pingTimer); this.pingTimer = null; }, isOpen() { return !!this.conn && this.conn.readyState === WebSocket.OPEN; }, send(obj) { if (!this.isOpen()) return false; try { this.conn.send(JSON.stringify(obj)); return true; } catch (_) { return false; } }, sendChat(content) { return this.send({ type: "chat", content }); }, stop() { this.send({ type: "stop" }); }, switchSession(id) { this.send({ type: "switch", session_id: id }); } }; // 等待 WebSocket 连接建立(最多 timeout 毫秒),返回是否已就绪 function waitWsOpen(timeout = 6000) { const start = Date.now(); return new Promise((resolve) => { const check = () => { if (ws.isOpen()) { resolve(true); return; } if (Date.now() - start > timeout) { resolve(false); return; } setTimeout(check, 100); }; check(); }); } /* 服务端事件分发:Agent 流式事件(与后端 agent.Event 同名) */ function handleWsEvent(type, data) { // 心跳回包不算流事件,避免干扰"卡死"判定 if (type === "pong") return; state.lastEventAt = Date.now(); switch (type) { case "meta": if (data.title) els.sessionTitle.textContent = displayTitle(data.title); break; case "message": { const stream = state.activeStream; if (!stream || !stream.els) break; stream.text += data.delta || ""; stream.els.text.innerHTML = renderMarkdown(stream.text); stream.els.text.classList.add("typing-caret"); setStatus("生成回答中", true); scrollToBottom(); break; } case "tool": { const stream = state.activeStream; if (!stream) break; if (!stream.toolRecords.has(data.call_id)) { const rec = { callId: data.call_id, name: data.name, input: data.input || "", output: "", success: true, done: false, startedAt: Date.now(), finishedAt: null, el: null }; stream.toolRecords.set(data.call_id, rec); if (stream.els) { stream.els.steps.appendChild(createStepElement(rec)); setStatus(`执行 ${data.name} · 第 ${stream.toolRecords.size} 次`, true); scrollToBottom(); } } break; } case "tool_result": { const stream = state.activeStream; const rec = stream && stream.toolRecords.get(data.call_id); if (rec) { rec.output = data.output || ""; rec.success = data.success; rec.done = true; rec.finishedAt = Date.now(); updateStepElement(rec); setStatus(rec.success ? "工具执行完成" : "工具执行失败", true); scrollToBottom(); } break; } case "notice": if (state.currentId) appendNotice(data.text || "提示"); break; case "turn_start": { const user = (data && data.user) || ""; if (state.activeStream) { state.activeStream.userText = user; state.activeStream.els = appendTask(user); state.activeStream.text = ""; state.activeStream.toolRecords = new Map(); } else { // 后台协作任务完成通知:创建被动流 state.activeStream = { sessionId: state.currentId, userText: user, text: "", toolRecords: new Map(), els: appendTask(user), compressed: false, iterations: 0, passive: true }; setStreaming(true); } setStatus("Agent 处理中", true); scrollToBottom(); break; } case "done": { const stream = state.activeStream; if (!stream) break; stream.iterations = data.iterations || 0; stream.compressed = !!data.compressed; if (stream.els) { const tools = stream.toolRecords.size; if (tools || stream.compressed) { setAnswerMeta(stream.els, { tools, compressed: stream.compressed }); } finalizeTask(stream.els); } state.activeStream = null; setStreaming(false); setStatus(`完成 · 迭代 ${stream.iterations} 轮`, false, false); refreshCoopTasks(); loadSessions(); loadMessages(0, { silent: true }).then((page) => { if (page) { state.currentMessages = page.messages || []; state.hasMore = !!page.has_more; } }); break; } case "error": if (state.activeStream) { appendErrorMessage(data.message || "发生未知错误"); state.activeStream = null; setStreaming(false); setStatus("出错", false, true); } else { showToast(data.message || "发生未知错误", "error"); } break; } } /* ============================================================ 智能滚动 ============================================================ */ let userNearBottom = true; function toggleScrollBottomBtn() { const hasContent = els.chat.querySelector(".task, .notice, .error-message"); const show = !userNearBottom && !!hasContent; els.scrollBottomBtn.classList.toggle("hidden", !show); } function scrollToBottom(force = false) { if (!force && !userNearBottom) { toggleScrollBottomBtn(); return; } requestAnimationFrame(() => { els.chat.scrollTop = els.chat.scrollHeight; }); } function clearChat() { els.chat.querySelectorAll(".task, .notice, .error-message").forEach((node) => node.remove()); els.emptyState.style.display = "flex"; } function appendNotice(text) { els.emptyState.style.display = "none"; const notice = document.createElement("div"); notice.className = "notice"; notice.textContent = text; els.chat.appendChild(notice); scrollToBottom(); } function appendErrorMessage(text) { els.emptyState.style.display = "none"; const error = document.createElement("div"); error.className = "error-message"; error.textContent = text; els.chat.appendChild(error); scrollToBottom(); } /* ============================================================ 任务块渲染 ============================================================ */ function appendTask(text) { els.emptyState.style.display = "none"; const task = document.createElement("div"); task.className = "task"; task.innerHTML = `
    ${ICONS.task}
    ${renderMarkdown(text)}
    `; els.chat.appendChild(task); scrollToBottom(); return { task, steps: task.querySelector(".task-steps"), output: task.querySelector(".task-output"), text: task.querySelector(".assistant-text"), meta: task.querySelector(".answer-meta") }; } function finalizeTask(view) { if (!view) return; view.text.classList.remove("typing-caret"); const hasMeta = view.meta.children.length > 0; if (!view.text.textContent.trim() && !hasMeta) { view.output.remove(); } } /* ============================================================ 工具步骤时间轴 ============================================================ */ function fmtStepTime(rec) { const end = rec.finishedAt || Date.now(); const ms = Math.max(0, end - rec.startedAt); return ms < 1000 ? `${(ms / 1000).toFixed(1)}s` : `${(ms / 1000).toFixed(0)}s`; } function createStepElement(rec) { const step = document.createElement("div"); step.className = "step running" + (rec.done ? "" : " open"); step.dataset.callId = rec.callId; step.innerHTML = `
    ${toolIcon(rec.name)} ${escapeHtml(rec.name)} ${fmtStepTime(rec)} 运行中
    ${escapeHtml(prettyArguments(rec.input))}
    等待执行结果...
    `; const head = step.querySelector(".step-head"); head.addEventListener("click", () => { step.classList.toggle("open"); const text = step.querySelector(".step-toggle-text"); if (text) text.textContent = step.classList.contains("open") ? "收起" : "展开"; }); rec.el = step; return step; } function updateStepElement(rec) { const step = rec.el; if (!step) return; step.classList.remove("running", "success", "failed"); step.classList.add(rec.done ? (rec.success ? "success" : "failed") : "running"); step.querySelector(".step-state").textContent = rec.done ? (rec.success ? "完成" : "失败") : "运行中"; step.querySelector(".step-time").textContent = fmtStepTime(rec); const label = step.querySelector(".step-section:nth-child(2) .step-section-label"); const outPre = step.querySelector(".step-section:nth-child(2) pre"); if (rec.done) { label.innerHTML = `输出 ${rec.success ? "✓" : "✗"}`; if (rec.output && rec.output.trim()) { outPre.textContent = rec.output; outPre.classList.remove("output-empty"); } else { outPre.textContent = "(无输出)"; outPre.classList.add("output-empty"); } } else { label.textContent = "输出"; outPre.textContent = "等待执行结果..."; outPre.classList.add("output-empty"); } if (rec.done && rec.success) step.classList.remove("open"); const toggleText = step.querySelector(".step-toggle-text"); if (toggleText) toggleText.textContent = step.classList.contains("open") ? "收起" : "展开"; } /* 输出面板底部小结徽标 */ function setAnswerMeta(view, { tools = 0, compressed = false }) { if (!view || !view.meta) return; view.meta.innerHTML = ""; if (tools > 0) { const badge = document.createElement("span"); badge.className = "meta-badge"; badge.innerHTML = `${ICONS.tool} 工具调用 ${tools} 次`; view.meta.appendChild(badge); } if (compressed) { const badge = document.createElement("span"); badge.className = "meta-badge"; badge.innerHTML = `${ICONS.compress} 上下文已压缩`; view.meta.appendChild(badge); } } /* ============================================================ 会话渲染(聚合为任务块) ============================================================ */ function renderSessionMessages(messages) { clearChat(); if (!messages.length) { els.emptyState.style.display = "flex"; return; } let task = null; const toolSteps = new Map(); for (const message of messages) { if (message.role === "user" && message.content) { task = appendTask(message.content); toolSteps.clear(); } else if (message.role === "assistant" && task) { const calls = message.tool_calls || []; for (const call of calls) { const rec = { callId: call.id, name: call.function?.name || "tool", input: call.function?.arguments || "", output: "", success: true, done: false, startedAt: Date.now(), finishedAt: null, el: null }; task.steps.appendChild(createStepElement(rec)); toolSteps.set(call.id, rec); } if (message.content) { task.text.innerHTML = renderMarkdown(message.content); task.text.classList.remove("typing-caret"); } if (calls.length) setAnswerMeta(task, { tools: calls.length }); } else if (message.role === "tool" && toolSteps.has(message.tool_call_id)) { const rec = toolSteps.get(message.tool_call_id); rec.output = message.content || ""; rec.success = true; rec.done = true; rec.finishedAt = Date.now(); updateStepElement(rec); } } document.querySelectorAll(".task").forEach((t) => { const textEl = t.querySelector(".assistant-text"); const metaEl = t.querySelector(".answer-meta"); if (!textEl.textContent.trim() && !metaEl.children.length) { t.querySelector(".task-output").remove(); } }); scrollToBottom(); } /* ============================================================ 会话 / 消息加载 ============================================================ */ async function loadMessages(offset, { silent = false } = {}) { if (!state.currentId) return null; try { return await api(`/api/v1/sessions/${state.currentId}/messages?limit=${PAGE_SIZE}&offset=${offset}`); } catch (e) { if (!silent) showToast(e.message, "error"); return null; } } function renderLoadEarlier() { let btn = document.getElementById("load-earlier-btn"); if (state.hasMore) { if (!btn) { btn = document.createElement("button"); btn.id = "load-earlier-btn"; btn.className = "load-earlier"; btn.textContent = "加载更早消息"; btn.addEventListener("click", loadEarlier); els.chat.prepend(btn); } } else if (btn) { btn.remove(); } } async function loadEarlier() { if (state.loadingOlder || !state.currentId) return; state.loadingOlder = true; const btn = document.getElementById("load-earlier-btn"); if (btn) { btn.textContent = "加载中…"; btn.disabled = true; } const page = await loadMessages(state.currentMessages.length, { silent: true }); state.loadingOlder = false; if (!page) { if (btn) btn.textContent = "加载更早消息"; return; } const prevScroll = els.chat.scrollTop; const prevHeight = els.chat.scrollHeight; state.currentMessages = [...(page.messages || []), ...state.currentMessages]; state.hasMore = !!page.has_more; renderSessionMessages(state.currentMessages); els.chat.scrollTop = prevScroll + (els.chat.scrollHeight - prevHeight); renderLoadEarlier(); if (btn) { btn.textContent = "加载更早消息"; btn.disabled = false; } } async function loadSessions() { const data = await api("/api/v1/sessions"); state.sessions = data.sessions; renderSessions(); } function renderSessions() { els.sessionList.innerHTML = ""; for (const session of state.sessions) { const item = document.createElement("div"); item.className = "session-item" + (session.id === state.currentId ? " active" : ""); const main = document.createElement("div"); main.className = "session-item-main"; const title = document.createElement("span"); title.className = "session-item-title"; title.textContent = displayTitle(session.title); const preview = document.createElement("span"); preview.className = "session-item-preview"; preview.textContent = session.preview || (session.message_count ? `${session.message_count} 条消息` : ""); main.append(title, preview); const time = document.createElement("time"); time.textContent = formatTime(session.updated_at); const del = document.createElement("button"); del.className = "session-delete"; del.title = "删除会话"; del.innerHTML = ` `; del.addEventListener("click", async (event) => { event.stopPropagation(); try { await api(`/api/v1/sessions/${session.id}`, { method: "DELETE" }); if (state.currentId === session.id) { state.currentId = null; await loadSessions(); if (state.sessions.length > 0) { await selectSession(state.sessions[0].id); } else { updateUrlForSession(null); els.sessionTitle.textContent = "新任务"; els.sessionSub.textContent = ""; state.currentMessages = []; state.hasMore = false; renderSessionMessages([]); renderLoadEarlier(); setStatus("就绪", false, false); } } else { await loadSessions(); } } catch (error) { showToast(error.message, "error"); } }); item.append(main, time, del); item.addEventListener("click", () => selectSession(session.id)); els.sessionList.appendChild(item); } } function formatTime(value) { const date = new Date(value); const now = new Date(); if (date.toDateString() === now.toDateString()) { return date.toTimeString().slice(0, 5); } return `${date.getMonth() + 1}/${date.getDate()}`; } function displayTitle(title) { return !title || title === "新对话" ? "新任务" : title; } function getSessionFromUrl() { const match = window.location.pathname.match(/^\/([0-9a-f]{24})$/); if (match) return match[1]; return new URLSearchParams(window.location.search).get("session"); } function updateUrlForSession(id) { window.history.replaceState(null, "", id ? `/${id}` : "/"); } async function selectSession(id) { state.currentId = id; ws.switchSession(id); const stream = state.activeStream; if (stream && stream.passive && stream.sessionId !== id) { state.activeStream = null; setStreaming(false); } if (state.streaming && stream && stream.sessionId === id) { updateUrlForSession(id); try { const s = await api(`/api/v1/sessions/${id}`); els.sessionTitle.textContent = displayTitle(s.session?.title); els.sessionSub.textContent = `${s.session?.messages?.length || 0} 条消息`; } catch (_) {} rebuildStreamView(stream); renderSessions(); return { session: { title: null, messages: [] } }; } setStatus("载入中…", true); const [detail, page] = await Promise.all([ api(`/api/v1/sessions/${id}`).catch(() => null), loadMessages(0, { silent: true }) ]); updateUrlForSession(id); els.sessionTitle.textContent = displayTitle(detail?.session?.title); els.sessionSub.textContent = `${detail?.session?.messages?.length ?? 0} 条消息`; state.currentMessages = page?.messages || []; state.hasMore = !!page?.has_more; renderSessionMessages(state.currentMessages); renderLoadEarlier(); renderSessions(); setStatus("就绪", false, false); return detail; } // 用流状态重建正在生成的会话视图 function rebuildStreamView(stream) { clearChat(); els.emptyState.style.display = "none"; const view = appendTask(stream.userText); if (stream.text.trim()) { view.text.innerHTML = renderMarkdown(stream.text); view.text.classList.remove("typing-caret"); } for (const rec of stream.toolRecords.values()) { view.steps.appendChild(createStepElement(rec)); updateStepElement(rec); } stream.els = view; scrollToBottom(); } // 确保存在有效会话:currentId 缺失或已被删除时自动新建 async function ensureValidSession() { if (!state.currentId) { await createNewSession(); return; } const detail = await api(`/api/v1/sessions/${state.currentId}`).catch(() => null); if (!detail || !detail.session) { showToast("当前会话已失效,已自动创建新任务", "error"); await createNewSession(); } } async function createNewSession() { const session = await api("/api/v1/sessions", { method: "POST" }); state.currentId = session.id; updateUrlForSession(session.id); await loadSessions(); } async function openFromUrlOrCreate() { const id = getSessionFromUrl(); if (id) { try { state.currentId = id; ws.connect(id); const detail = await api(`/api/v1/sessions/${id}`).catch(() => null); if (detail && detail.session) { await selectSession(id); return; } state.currentId = null; ws.disconnect(); showToast("会话不存在或已删除,已创建新任务", "error"); } catch (_) { state.currentId = null; ws.disconnect(); showToast("会话不存在或已删除,已创建新任务", "error"); } } if (!state.currentId) { await createNewSession(); } await selectSession(state.currentId); } /* ============================================================ 流状态管理(可自愈) ============================================================ */ function setStreaming(streaming) { state.streaming = streaming; els.sendBtn.classList.toggle("hidden", streaming); els.stopBtn.classList.toggle("hidden", !streaming); els.input.placeholder = streaming ? "Agent 正在执行任务,请稍候..." : INPUT_PLACEHOLDER; if (!streaming) setStatus("就绪", false, false); } // 强制清理流状态(中断/卡死时调用) function forceResetStream(showNotice = false) { state.activeStream = null; state.streaming = false; els.sendBtn.classList.remove("hidden"); els.stopBtn.classList.add("hidden"); els.input.placeholder = INPUT_PLACEHOLDER; setStatus("就绪", false, false); if (showNotice) appendNotice("任务已中断,可重新发送"); } // 流是否仍然"活着":连接在线,且最近有事件,或有运行中的工具步骤(长任务保护) function streamIsAlive() { if (!ws.isOpen()) return false; if (Date.now() - state.lastEventAt < STREAM_STALE_MS) return true; const stream = state.activeStream; if (stream && stream.toolRecords) { for (const rec of stream.toolRecords.values()) { if (!rec.done) return true; } } return false; } function stopStreaming() { if (!state.activeStream) return; const stream = state.activeStream; state.activeStream = null; ws.stop(); setStreaming(false); if (stream.els) finalizeTask(stream.els); appendNotice("已停止生成"); } /* ============================================================ 消息发送(核心:任何失败都有可见反馈,绝不静默) ============================================================ */ async function sendMessage(prefill) { const text = (prefill !== undefined ? prefill : els.input.value).trim(); if (!text) { showToast("请输入内容后再发送", "error"); return; } // 未配置 LLM 时引导先配置,不发起无效对话(后端同样有兜底校验) if (!state.llmConfigured) { showToast("尚未配置 LLM API,请先配置 API Key 与模型", "error"); openSettings(); return; } // 1. 清理一切卡死状态;若确有真正在运行的流,则不打扰并提示 if (state.streaming) { if (!streamIsAlive()) { forceResetStream(true); } else { showToast("Agent 正在执行任务,请等待完成或点击停止", "error"); return; } } // 2. 确保会话有效且 ws 已连接(失败都会给出可见提示) const ready = await ensureReadyForSend(); if (!ready) return; // 3. 正式发送 userNearBottom = true; els.input.value = ""; autoResize(); const stream = { sessionId: state.currentId, userText: text, text: "", toolRecords: new Map(), els: appendTask(text), compressed: false, iterations: 0, passive: false }; state.activeStream = stream; setStreaming(true); setStatus("Agent 处理中", true); if (!ws.sendChat(text)) { // 发送失败:恢复输入内容并复位状态,让用户可重试 els.input.value = text; autoResize(); state.activeStream = null; setStreaming(false); showToast("消息未能发送,请重试", "error"); } } // 发送前准备:校验会话 → 保证连接绑定当前会话 async function ensureReadyForSend() { try { await ensureValidSession(); if (ws.isOpen() && ws.boundSession === state.currentId) { return true; } ws.connect(state.currentId); const opened = await waitWsOpen(6000); if (!opened) { setStatus("未连接", false, true); showToast("无法连接服务器,请刷新后重试", "error"); return false; } return true; } catch (error) { showToast(error && error.message ? error.message : String(error), "error"); return false; } } function autoResize() { els.input.style.height = "auto"; els.input.style.height = Math.min(els.input.scrollHeight, 180) + "px"; } /* ============================================================ 协作任务实时状态 ============================================================ */ function fmtElapsed(created, finished) { const end = finished ? new Date(finished) : new Date(); const total = Math.max(0, Math.floor((end - new Date(created)) / 1000)); if (total < 60) return `${total}秒`; const minutes = Math.floor(total / 60); if (minutes < 60) return `${minutes}分${total % 60}秒`; return `${Math.floor(minutes / 60)}时${minutes % 60}分`; } function renderCoopTasks(tasks) { if (!tasks || !tasks.length) { els.coopPanel.classList.add("hidden"); els.coopList.innerHTML = ""; return; } els.coopPanel.classList.remove("hidden"); els.coopList.innerHTML = ""; els.coopCount.textContent = `${tasks.length} 个`; for (const task of tasks) { const running = task.status === "running"; const failed = task.status === "done" && !!task.error; const stateClass = failed ? "failed" : running ? "running" : "done"; const stateLabel = failed ? "失败" : running ? "运行中" : "完成"; const round = task.round_max > 1 ? `第 ${Math.max(task.current_round || 1, 1)}/${task.round_max} 轮` : "单轮"; const container = running && task.container_status ? ` · ${task.container_status}` : ""; const elapsed = fmtElapsed(task.created_at, task.finished_at); const progress = task.round_max > 0 ? Math.min(100, Math.round((Math.max(task.current_round || 1, 1) / task.round_max) * 100)) : 100; const card = document.createElement("div"); card.className = `coop-card ${stateClass}`; card.innerHTML = `
    ${round}${container}
    ID ${escapeHtml(task.id.slice(0, 8))} · ${elapsed}
    ${stateLabel}
    `; els.coopList.appendChild(card); } } async function refreshCoopTasks() { if (!state.currentId) { renderCoopTasks([]); return; } try { const data = await api(`/api/v1/coop/tasks?session=${state.currentId}`); renderCoopTasks(data.tasks || []); } catch (_) {} } /* ============================================================ 配置与设置 ============================================================ */ async function loadConfig() { state.config = await api("/api/v1/config"); const ctxK = Math.round(state.config.max_context_tokens / 1000); els.contextHint.textContent = `上下文 ${ctxK}k · 工具上限 ${state.config.max_iterations} 次`; els.ctxChip.textContent = `${ctxK}k ctx`; els.ctxChip.classList.remove("hidden"); try { const lc = await api("/api/v1/llm/config"); state.llmConfigured = !!lc.api_key_configured; els.modelChip.textContent = lc.api_key_configured ? (lc.model || state.config.model) : "模型未配置"; els.modelChip.classList.toggle("unconfigured", !lc.api_key_configured); } catch (_) {} } async function loadSettings() { try { const dc = await api("/api/v1/docker/config"); els.dockerSocketInput.value = dc.docker_socket || ""; els.dockerSocketInput.placeholder = dc.default_socket || ""; } catch (_) {} try { const lc = await api("/api/v1/llm/config"); els.providerSelect.value = lc.provider === "anthropic" ? "anthropic" : "openai"; updateProviderHint(); const engineVal = lc.engine === "pigo" || lc.engine === "claude" ? lc.engine : "pi"; els.coopEngineSelect.value = engineVal; els.coopModeSelect.value = lc.coop_mode === "docker" ? "docker" : "local"; els.apiKeyInput.value = ""; els.apiKeyInput.placeholder = lc.api_key_configured ? `已配置(${lc.api_key_masked || "****"}),留空保持不变` : "未配置"; els.baseUrlInput.value = lc.base_url || ""; els.baseUrlInput.placeholder = lc.default_base_url || ""; els.modelInput.value = lc.model || ""; els.modelInput.placeholder = lc.default_model || ""; } catch (error) { showToast("加载 LLM 配置失败: " + error.message, "error"); } } function updateProviderHint() { const anthropic = els.providerSelect.value === "anthropic"; els.providerHint.textContent = anthropic ? "Anthropic 模式使用 x-api-key 认证,Base URL 填 https://api.anthropic.com 即可(自动补全 /v1/messages)。" : "OpenAI 兼容模式使用 Bearer Token 认证,Base URL 填写 /v1/chat/completions 地址。"; els.baseUrlInput.placeholder = anthropic ? "https://api.anthropic.com" : "https://api.siliconflow.cn/v1/chat/completions"; } function openSettings() { loadSettings(); els.settingsModal.classList.remove("hidden"); } function closeSettings() { els.settingsModal.classList.add("hidden"); } async function saveSettings() { try { const socket = els.dockerSocketInput.value.trim(); await api("/api/v1/docker/config", { method: "PUT", body: JSON.stringify({ socket }) }); const apiKey = els.apiKeyInput.value.trim(); const baseUrl = els.baseUrlInput.value.trim(); const model = els.modelInput.value.trim(); const provider = els.providerSelect.value; const engine = els.coopEngineSelect.value; const coopMode = els.coopModeSelect.value; const lc = await api("/api/v1/llm/config", { method: "PUT", body: JSON.stringify({ api_key: apiKey || null, base_url: baseUrl || null, model: model || null, provider: provider || null, engine: engine || null, coop_mode: coopMode || null }) }); state.llmConfigured = !!lc.api_key_configured; els.modelChip.textContent = lc.api_key_configured ? (lc.model || state.config.model) : "模型未配置"; els.modelChip.classList.toggle("unconfigured", !lc.api_key_configured); showToast("设置已保存"); closeSettings(); } catch (error) { showToast("保存失败: " + error.message, "error"); } } async function resetSettings() { try { await api("/api/v1/docker/config", { method: "PUT", body: JSON.stringify({ socket: "" }) }); const lc = await api("/api/v1/llm/config", { method: "PUT", body: JSON.stringify({ api_key: "", base_url: "", model: "", provider: "", engine: "", coop_mode: "" }) }); state.llmConfigured = !!lc.api_key_configured; els.modelChip.textContent = lc.api_key_configured ? (lc.model || state.config.model) : "模型未配置"; els.modelChip.classList.toggle("unconfigured", !lc.api_key_configured); els.coopEngineSelect.value = lc.engine === "pigo" || lc.engine === "claude" ? lc.engine : "pi"; els.coopModeSelect.value = lc.coop_mode === "docker" ? "docker" : "local"; showToast("已恢复默认配置"); closeSettings(); } catch (error) { showToast("保存失败: " + error.message, "error"); } } /* ============================================================ 初始化 ============================================================ */ function init() { els.newChatBtn.addEventListener("click", async () => { try { await createNewSession(); await selectSession(state.currentId); } catch (error) { showToast(error.message, "error"); } }); els.refreshBtn.addEventListener("click", async () => { try { await loadSessions(); const stream = state.activeStream; if (state.currentId && !(state.streaming && stream && stream.sessionId === state.currentId)) { const page = await loadMessages(0, { silent: true }); if (page) { state.currentMessages = page.messages || []; state.hasMore = !!page.has_more; renderSessionMessages(state.currentMessages); renderLoadEarlier(); } } showToast("会话已刷新"); } catch (error) { showToast(error.message, "error"); } }); els.menuBtn.addEventListener("click", () => { els.sidebar.classList.toggle("open"); }); // 会话搜索(防抖) els.sessionSearch.addEventListener( "input", debounce(async () => { try { const q = els.sessionSearch.value.trim(); if (!q) { await loadSessions(); return; } const data = await api(`/api/v1/sessions?q=${encodeURIComponent(q)}`); state.sessions = data.sessions; renderSessions(); } catch (e) { showToast(e.message, "error"); } }, 300) ); els.settingsBtn.addEventListener("click", openSettings); els.closeSettingsBtn.addEventListener("click", closeSettings); els.saveSettingsBtn.addEventListener("click", saveSettings); els.resetSettingsBtn.addEventListener("click", resetSettings); els.providerSelect.addEventListener("change", updateProviderHint); els.settingsModal.addEventListener("click", (event) => { if (event.target === els.settingsModal) closeSettings(); }); // 发送 / 停止(sendMessage 内部自带兜底,任何异常都有 toast) els.sendBtn.addEventListener("click", () => { try { sendMessage(); } catch (error) { console.error(error); showToast("发送异常: " + (error && error.message || error), "error"); } }); els.stopBtn.addEventListener("click", stopStreaming); // 智能滚动 els.chat.addEventListener("scroll", () => { userNearBottom = els.chat.scrollHeight - els.chat.scrollTop - els.chat.clientHeight < 120; toggleScrollBottomBtn(); }); els.scrollBottomBtn.addEventListener("click", () => { userNearBottom = true; scrollToBottom(true); toggleScrollBottomBtn(); }); // Esc 停止生成 document.addEventListener("keydown", (e) => { if (e.key === "Escape") stopStreaming(); }); // 代码块复制(事件委托) els.chat.addEventListener("click", async (event) => { const btn = event.target.closest(".code-copy"); if (!btn) return; const codeEl = btn.closest(".code-block")?.querySelector("code"); if (!codeEl) return; const text = codeEl.textContent; try { await navigator.clipboard.writeText(text); } catch (_) { const ta = document.createElement("textarea"); ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); } btn.textContent = "已复制"; setTimeout(() => { btn.textContent = "复制"; }, 1800); }); // 协作任务面板:点击标题折叠 / 展开 els.coopPanelHeader.addEventListener("click", () => { els.coopPanel.classList.toggle("collapsed"); }); els.input.addEventListener("input", autoResize); els.input.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); try { sendMessage(); } catch (error) { console.error(error); showToast("发送异常: " + (error && error.message || error), "error"); } } }); // 空状态示例任务:点击直接投递 document.querySelectorAll(".example").forEach((btn) => { btn.addEventListener("click", () => { sendMessage(btn.dataset.prompt || ""); }); }); // 流卡死看门狗:每 5 秒检查一次,自动清理卡死的流,绝不让 streaming 卡住发送 setInterval(() => { if (!state.streaming) return; if (!streamIsAlive()) { forceResetStream(true); setStatus("任务中断,已自动清理", false, true); } }, 5000); // 运行中工具步骤的耗时计时器 setInterval(() => { if (!state.streaming || !state.activeStream) return; for (const rec of state.activeStream.toolRecords.values()) { if (!rec.done && rec.el) { const timeEl = rec.el.querySelector(".step-time"); if (timeEl) timeEl.textContent = fmtStepTime(rec); } } }, 1000); // 每 3 秒轮询一次协作任务状态 refreshCoopTasks(); setInterval(refreshCoopTasks, 3000); // 全局兜底:任何未捕获错误都必须可见,避免"点击没反应" window.addEventListener("error", (event) => { console.error(event.error || event.message); showToast("页面脚本错误: " + (event.message || "unknown"), "error"); }); window.addEventListener("unhandledrejection", (event) => { const reason = event.reason; console.error(reason); showToast("请求失败: " + ((reason && reason.message) || "unknown"), "error"); }); // 启动:确认授权 → 配置 → 会话列表 → 打开 URL 会话或新建 ensureAuthed() .then((authed) => { if (!authed) throw new Error("未通过授权验证,请刷新页面重试"); }) .then(loadConfig) .then(loadSessions) .then(openFromUrlOrCreate) .catch((error) => { showToast("初始化失败: " + error.message, "error"); setStatus("连接失败", false, true); }); } init();