diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index b056dca..a58dc02 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -1,6293 +1,6783 @@ - // ===== State ===== - const state = { - status: {}, - kernel: null, - settings: {}, - meta: {}, - runtime: null, // /api/v1/runtime 的运行态小快照(调度器/驻留子/通道) - pluginMeta: {}, - disabledPlugins: [], - settingsPlugins: ["core"], - selectedSection: "core", - messages: [], - chatLoading: false, - chatStage: "", - pipelinePhase: "", // 当前阶段(SSE stage 事件驱动总览页) - pipelineTimer: null, - stageTrail: [], // 本轮已发生的事件轨迹(工具/输出调用,供阶段管道展示循环) - toolFlash: false, // 本轮新到一条工具调用:本轮渲染后给「工具」格放一次滑入动画 - healthResult: null, - starmapInit: false, - starmapLoading: false, - starmapData: null, - // ===== 星图:跟随 agent 活动 ===== - // 三路活动信号(均已在页面上,无需新数据源): - // 1. toolPulseMs —— SSE tool_call/stage 触发的「脉冲」,实时 - // 2. activity —— /runtime 调度器计数(排队/中断/抢占),3s 轮询 - // 3. grownIds —— /memory/graph/pulse 检出「新长出来」的节点,10s 轮询 - starmapPulses: [], // { mesh, until, kind } 活动脉冲队列 - starmapGrown: {}, // nodeId -> 生长动画截止时间戳 - starmapActivity: null, // /runtime 的调度器快照 - starmapPulseTimer: null, // /memory/graph/pulse 轮询 id - starmapActivityTimer: null, // /runtime 轮询 id - starmapLastPulseAt: 0, // 最后一次活动时间(ms),驱动全局呼吸 - starmapPulseSince: 0, // 下次 pulse 请求的回看起点(unix 秒) - chatHistory: [], - terminals: [], - cmdHistory: [], - termScreens: {}, - chatStick: true, - pendingTools: [], - eventSource: null, - chatFinalIdx: -1, - chatOffset: 0, // 分段历史:当前已加载消息在服务端全量中的起始下标 - chatTotal: 0, // 服务端历史总条数 - chatHasMore: false, // 是否还有更早历史可向上加载 - chatLastSeq: 0, // 增量轮询游标:已合并到本地的最大消息 seq - lang: localStorage.getItem("ha-lang") || "zh", - }; - - // ===== I18n ===== - window._i18n = { - navOverview: ["概览", "Overview"], - navChat: ["对话", "Chat"], - navStarmap: ["星图", "Star Map"], - navPlugins: ["插件", "Plugins"], - navSettings: ["设置", "Settings"], - navAdapters: ["适配器", "Adapters"], - navKernel: ["内核", "Kernel"], - navLogout: ["退出登录", "Logout"], - themeToggle: ["切换亮色/暗色模式", "Toggle theme"], - }; - - function __(zh, en) { - return state.lang === "en" ? en : zh; - } - function L() { - return state.lang; - } - - function toggleLang() { - state.lang = state.lang === "zh" ? "en" : "zh"; - localStorage.setItem("ha-lang", state.lang); - document.querySelectorAll("[data-i18n]").forEach((el) => { - var k = el.getAttribute("data-i18n"); - var m = window._i18n && window._i18n[k]; - if (m) el.textContent = __(m[0], m[1]); - }); - renderAll(); - } - - function applyI18n() { - var lang = state.lang; - var btn = document.getElementById("lang-btn"); - if (btn) btn.textContent = lang === "zh" ? "EN" : "中"; - document.querySelectorAll("[data-i18n]").forEach((el) => { - var k = el.getAttribute("data-i18n"); - var m = window._i18n && window._i18n[k]; - if (m) el.textContent = lang === "en" ? m[1] : m[0]; - }); - } - - // ===== Theme ===== - var ICON_SUN = - ''; - var ICON_MOON = - ''; - - function setTheme(name) { - document.documentElement.setAttribute("data-theme", name); - var btn = document.getElementById("theme-btn"); - if (btn) btn.innerHTML = name === "light" ? ICON_SUN : ICON_MOON; - } - - function saveTheme(name) { - localStorage.setItem("ha-theme", name); - setTheme(name); - } - - function toggleTheme() { - var cur = document.documentElement.getAttribute("data-theme"); - saveTheme(cur === "light" ? "dark" : "light"); - } - - var PALETTES = { - // mono 放第一位:它现在是默认配色(黑白单调),也应当是选择器里最显眼那个。 - mono: "#c2c7d0", - sakura: "#ff7fac", - cyan: "#2dd4bf", - violet: "#a78bfa", - emerald: "#34d399", - amber: "#fbbf24", - blue: "#60a5fa", - }; - - function setColor(name) { - document.documentElement.setAttribute("data-color", name); - localStorage.setItem("ha-color", name); - // 配色变了要强制重画运行态:拓扑的归属配色跟着 data-color 走, - // 而数据签名没变,不置空 _rtSig 的话图会一直停在旧色。 - _rtSig = null; - if (document.getElementById("rt-sec-topo")) { - try { - renderRuntime(); - } catch (e) {} - } - var pop = document.getElementById("palette-pop"); - if (!pop) return; - var btns = pop.querySelectorAll("button.cdot"); - for (var i = 0; i < btns.length; i++) { - btns[i].className = - btns[i].getAttribute("data-c") === name ? "cdot on" : "cdot"; - } - } - - function applyBgImg(url) { - url = (url || "").trim(); - if (!url) { - document.documentElement.style.setProperty("--bg-img", "none"); - localStorage.removeItem("ha-bg-img"); - return; - } - document.documentElement.style.setProperty( - "--bg-img", - 'url("' + url.replace(/"/g, '\\"') + '")', - ); - localStorage.setItem("ha-bg-img", url); - } - - function applyBgBlur(n) { - n = Math.max(0, Math.min(30, Number(n) || 0)); - document.documentElement.style.setProperty("--bg-blur", String(n)); - localStorage.setItem("ha-bg-blur", String(n)); - var v = document.getElementById("bg-blur-val"); - if (v) v.textContent = n + "px"; - var r = document.getElementById("bg-blur-range"); - if (r) r.value = String(n); - } - - function toggleAppearance() { - var pop = document.getElementById("palette-pop"); - if (!pop) return; - var on = pop.classList.contains("on"); - if (!pop.querySelector("button.cdot")) { - var cur = localStorage.getItem("ha-color") || "mono"; - var img = localStorage.getItem("ha-bg-img") || ""; - var blur = localStorage.getItem("ha-bg-blur") || "0"; - var dots = ""; - Object.keys(PALETTES).forEach((k) => { - dots += - '"; - }); - pop.innerHTML = - "

主题色

" + - dots + - "
" + - '

背景图片 URL

' + - '' + - '
' + - '' + - '模糊 ' + - '' + - '' + - blur + - "px
"; - setColor(cur); - var rr = document.getElementById("bg-blur-range"); - if (rr) rr.value = blur; - var vv = document.getElementById("bg-blur-val"); - if (vv) vv.textContent = blur + "px"; - } - pop.classList.toggle("on", !on); - } - - (function initAppearance() { - var img = localStorage.getItem("ha-bg-img"); - var blur = localStorage.getItem("ha-bg-blur"); - if (img) applyBgImg(img); - if (blur) applyBgBlur(blur); - })(); - - (() => { - var saved = localStorage.getItem("ha-theme"); - setTheme(saved || "light"); - // 默认配色 = 黑白:必须**主动** setColor 一次。只设 data-theme 时 - // base 主题仍是樱粉(:root 的 --accent 就是粉色),不写 data-color 就 - // 会默认花。用户选过别的颜色时以 localStorage 为准。 - setColor(localStorage.getItem("ha-color") || "mono"); - var sb = localStorage.getItem("ha-sidebar"); - if (sb === "1" || (!sb && window.innerWidth <= 768)) { - document.body.classList.add("sidebar-collapsed"); - } - })(); - - // 字节数人性化显示(附件卡片用) - function formatBytes(n) { - if (!n || n <= 0) return ""; - var units = ["B", "KB", "MB", "GB"]; - var i = 0; - while (n >= 1024 && i < units.length - 1) { - n /= 1024; - i++; - } - return (i === 0 ? n : n.toFixed(1)) + " " + units[i]; - } - - // ===== Utility ===== - function renderMd(text) { - if (typeof text !== "string") text = String(text || ""); - var html; - if (typeof marked !== "undefined") { - try { - html = marked.parse(text); - } catch (e) { - html = escHtml(text); - } - } else { - html = "
" + escHtml(text) + "
"; - } - if ( - typeof DOMPurify !== "undefined" && - typeof DOMPurify.sanitize === "function" - ) { - try { - return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); - } catch (e) {} - } - return html - .replace(//gi, "") - .replace(/\son\w+\s*=\s*"[^"]*"/gi, "") - .replace(/\son\w+\s*=\s*'[^']*'/gi, "") - .replace(/javascript:/gi, ""); - } - function escHtml(s) { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - - function toast(m, isError) { - var t = document.getElementById("toast"); - t.textContent = m; - t.className = "toast" + (isError ? " error" : ""); - t.style.display = "block"; - clearTimeout(t._hideTimer); - t._hideTimer = setTimeout(() => { - t.style.display = "none"; - }, 3000); - } - - // ===== 8.6 Unified toast + confirm dialog ===== - - // ===== 8.4 Card 3D tilt + cursor glow ===== - document.addEventListener("mousemove", (e) => { - var card = e.target.closest ? e.target.closest(".card.tilt") : null; - if (card) { - var r = card.getBoundingClientRect(); - card.style.setProperty("--mx", e.clientX - r.left + "px"); - card.style.setProperty("--my", e.clientY - r.top + "px"); - var rx = ((e.clientY - r.top) / r.height - 0.5) * -4; - var ry = ((e.clientX - r.left) / r.width - 0.5) * 4; - card.style.transform = - "perspective(1000px) rotateX(" + - rx.toFixed(2) + - "deg) rotateY(" + - ry.toFixed(2) + - "deg) translateY(-1px)"; - } - }); - document.addEventListener("mouseleave", (e) => { - var card = e.target.closest ? e.target.closest(".card.tilt") : null; - if (card) card.style.transform = ""; - }); - - // ===== 首启人格向导 ===== - // 人格是配置项(core.agent.personal_prompt,默认模板不含任何版本号)。 - // 首次启动问一次「默认 / 自定义 / 稍后」,之后不再打扰; - // 不回答 = 稍后 = 保留默认人格,绝不阻塞启动。 - async function maybeShowPersonaWizard() { - var st; - try { - st = await api("/persona"); - } catch (e) { - return; // 拿不到状态就不打扰用户 - } - if (!st || st.initialized) return; - var ov = document.createElement("div"); - ov.className = "confirm-overlay"; - ov.style.display = "flex"; - ov.innerHTML = - '
' + - "

" + escHtml(__("人格设定", "Persona")) + "

" + - "

" + escHtml(__( - "首次启动:选一下助手的人格。选「使用默认」即可(之后可在设置里修改);自定义内容在下次重启后生效。", - "First run: pick your assistant's persona. \"Use default\" is fine (change it later in Settings); custom content takes effect after the next restart." - )) + "

" + - '
' + - '' + - '
' + - '" + - '" + - '" + - "
"; - document.body.appendChild(ov); - var ta = ov.querySelector(".persona-ta"); - var warn = ov.querySelector(".persona-warn"); - var customOpen = false; - if (st.file_override) { - warn.style.display = "block"; - warn.textContent = __( - "注意:检测到 personal/personal.md,它优先于这里的设置。", - "Note: personal/personal.md exists and takes precedence over this choice." - ); - } - function close() { - ov.remove(); - } - async function submit(mode, content) { - try { - var r = await api("/persona", { - method: "POST", - body: JSON.stringify({ mode: mode, content: content || "" }), - }); - if (r && r.restart_required) toast(__("已保存,重启后生效", "Saved; takes effect after restart")); - else toast(__("已保存", "Saved")); - } catch (e) { - toast(__("保存失败:", "Save failed: ") + e, true); - } - close(); - } - ov.querySelectorAll("button[data-mode]").forEach((b) => { - b.onclick = () => { - var mode = b.getAttribute("data-mode"); - if (mode !== "custom") { - submit(mode); - return; - } - if (!customOpen) { // 第一次点:展开文本域并预填当前人格 - customOpen = true; - ta.style.display = "block"; - ta.value = st.current_prompt || ""; - ta.focus(); - return; - } - if (!ta.value.trim()) { - toast(__("内容不能为空", "Content cannot be empty"), true); - return; - } - submit("custom", ta.value); - }; - }); - } - - // ===== API ===== - async function api(p, o) { - var opts = { - credentials: "include", - headers: { "Content-Type": "application/json", ...o?.headers }, - ...o, - }; - // FormData 时不能手动设 Content-Type(浏览器需自动生成 multipart boundary) - if (o?.body instanceof FormData) { - delete opts.headers["Content-Type"]; - } - var r = await fetch("/api/v1" + p, opts); - if (r.status === 401) { - location.href = "/login"; - throw new Error("unauthorized"); - } - if (opts.raw) return r; - var ct = r.headers.get("content-type") || ""; - if (ct.includes("json")) return r.json(); - return r.text(); - } - - // ===== Navigation ===== - function switchTab(n) { - document.querySelectorAll(".tab-content").forEach((e) => { - e.classList.remove("active"); - }); - var el = document.getElementById("tab-" + n); - if (el) el.classList.add("active"); - document.querySelectorAll("nav a").forEach((e) => { - e.classList.remove("active"); - }); - var match = document.querySelector('nav a[onclick*="' + n + '"]'); - if (match) match.classList.add("active"); - var crumb = document.querySelector(".crumb-current"); - if (crumb) { - var crumbKey = "nav" + n.charAt(0).toUpperCase() + n.slice(1); - var m = window._i18n && window._i18n[crumbKey]; - crumb.setAttribute("data-i18n", crumbKey); - if (m) crumb.textContent = __(m[0], m[1]); - else crumb.textContent = n; - } - if (window.innerWidth <= 768) { - document.body.classList.add("sidebar-collapsed"); - } - if (n === "chat") { - state.chatStick = true; - var msgsEl = document.getElementById("chat-msgs"); - if (msgsEl) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } - initChatDragDrop(); - } - if (n === "starmap") { - // 独立星图页签:把 canvas 搬过来并按新容器尺寸重算。 - // 必须在 renderAll 之前做,否则 renderChatStarmap 会按上一次的 - // 活跃容器(总览页)算尺寸。 - var sc = document.getElementById("sm-container-page"); - if (sc) { - if (starmapRen) { - if (starmapRen.domElement.parentElement !== sc) { - sc.appendChild(starmapRen.domElement); - starmapRen.domElement.style.display = "block"; - } - onStarmapResize(); - } else if (!state.starmapInit && !state.starmapLoading) { - loadChatStarmapData(); - } - } - } - renderAll(); - } - - function toggleSidebar() { - document.body.classList.toggle("sidebar-collapsed"); - var saved = localStorage.getItem("ha-sidebar"); - localStorage.setItem( - "ha-sidebar", - document.body.classList.contains("sidebar-collapsed") ? "1" : "0", - ); - } - - // ===== Tab Render Dispatch ===== - async function renderAll() { - try { - var s = await api("/status"); - state.status = s; - state.startedAt = s.startedAt - ? new Date(s.startedAt).getTime() - : null; - } catch (e) {} - try { - state.kernel = await api("/kernel"); - } catch (e) {} - try { - var s = await api("/settings"); - state.settings = s.settings || {}; - state.meta = s.meta || {}; - state.settingsPlugins = s.plugins || ["core"]; - state.pluginMeta = s.plugin_meta || {}; - state.disabledPlugins = s.disabled_plugins || []; - } catch (e) {} - try { - state.installedPlugins = await api("/plugins"); - } catch (e) {} - try { - await loadProxyServices(); - } catch (e) {} - try { - await loadTerminals(); - } catch (e) {} - try { - await loadCmdHistory(); - } catch (e) {} - try { - renderOverview(); - } catch (e) { - console.error("renderOverview", e); - } - try { - await loadRuntime(); - } catch (e) { - console.error("loadRuntime", e); - } - try { - renderChat(); - } catch (e) { - console.error("renderChat", e); - } - try { - renderChatStarmap(); - } catch (e) { - console.error("renderChatStarmap", e); - } - try { - renderStarmapTab(); - } catch (e) { - console.error("renderStarmapTab", e); - } - try { - renderPlugins(); - } catch (e) { - console.error("renderPlugins", e); - } - try { - renderKernel(); - } catch (e) { - console.error("renderKernel", e); - } - try { - renderOneSettings(); - } catch (e) { - console.error("renderOneSettings", e); - } - try { - renderAdapters(); - } catch (e) { - console.error("renderAdapters", e); - } - try { - if (!window.matchMedia("(hover: none)").matches) { - document - .querySelectorAll( - "#tab-overview .card, #tab-plugins .card, #tab-kernel .card", - ) - .forEach((c) => { - if (!c.querySelector(".tilt-glow")) { - var g = document.createElement("span"); - g.className = "tilt-glow"; - c.appendChild(g); - c.classList.add("tilt"); - } - }); - } - } catch (e) {} - applyI18n(); - } - - // fmtBuild 把构建身份压成一行:commit + 构建时间(日期部分)。 - // - // 未注入 ldflags 的本地构建会得到 "unknown",此时只显示 SDK 版本, - // 避免把 "unknown / unknown" 这种噪音摆在概览卡上。 - function fmtBuild(b, s) { - var parts = []; - var commit = (b && b.commit) || (s && s.commit) || ""; - if (commit && commit !== "unknown") parts.push(commit); - var bt = (b && b.build_time) || (s && s.build_time) || ""; - if (bt && bt !== "unknown") { - // 2026-09-03T05:55:35Z → 2026-09-03 - parts.push(String(bt).split("T")[0]); - } - var sdkv = (b && b.sdk_compatible) || (s && s.sdk_version) || ""; - if (sdkv) parts.push("SDK " + sdkv); - return parts.length ? parts.join(" · ") : "-"; - } - - function fmtUptime(ms) { - var s = Math.floor(ms / 1000); - if (s < 60) return s + "s"; - var m = Math.floor(s / 60); - s = s % 60; - if (m < 60) return m + "m " + s + "s"; - var h = Math.floor(m / 60); - m = m % 60; - return h + "h " + m + "m " + s + "s"; - } - - var uptimeTick = null; - function startUptimeTicker() { - if (uptimeTick) clearInterval(uptimeTick); - uptimeTick = setInterval(() => { - var el = document.querySelector("#ov-uptime"); - if (el && state.startedAt) { - var now = Date.now(); - el.textContent = fmtUptime(now - state.startedAt); - } else if (!state.startedAt) { - var el2 = document.querySelector("#ov-uptime"); - if (el2) el2.textContent = "-"; - } - }, 1000); - } - - // ===== Overview ===== - - // ===== 运行态面板:把内核的调度器/驻留子/通道画出来 ===== - // - // 数据来自 /api/v1/runtime(内核 internal/sdk 暴露的 KernelStatus 子集), - // 每 3 秒刷一次——运行态要"实时",而 /kernel 是 30KB 级的全量状态,不适合秒级轮询。 - // - // 面板从上到下:四个数字块回答"现在忙不忙";阶段管道滑块回答"这一轮走到哪"; - // 五条队列条形 + 中断栈回答"堵在哪一级/压了几层";最后的分带拓扑回答 - // "哪条输入喂给哪个 agent、哪个 agent 往哪条输出写、各自负载多高"。 - // 四级语义直接照抄内核(internal/agent/core/scheduler.go 的 Level 定义), - // 别自己起名字——前端叫法一旦和内核不一致,看板就成了误导。 - var RT_LEVELS = [ - { lv: 4, name: "L4", desc: "内核独占", cls: "rt-lv-4" }, - { lv: 3, name: "L3", desc: "交互", cls: "rt-lv-3" }, - { lv: 2, name: "L2", desc: "消息", cls: "rt-lv-2" }, - { lv: 1, name: "L1", desc: "后台", cls: "rt-lv-1" }, - ]; - var RT_CAP_NAMES = { 1: "text", 2: "file", 4: "image", 8: "audio", 16: "structured" }; - - // _rtSig 缓存上一次渲染的数据签名。 - // - // 为什么必须缓存:运行态每 3s 轮询一次,数据绝大多数时候是**没变**的; - // 无条件 `innerHTML =` 会把整块 DOM(含各级条的 transition)每 3s 重建一遍, - // 视觉效果就是“首页一闪一闪”。签名相同就一个字节也不动。 - var _rtSig = null; - - // morph:把容器的 DOM 就地「形变」成 html 描述的样子。 - // - // 为什么不用 innerHTML = html:整块重建会把**没变的**节点也换掉 —— - // SVG 过渡从头播、图片重解码、展开态丢失、滚动锚点重置。运行态每几秒 - // 刷一次,未变节点占绝大多数,那些闪烁就是这么来的。 - // - // 做法:按「子节点位置 + nodeName」递归对账。同名元素复用同一个 DOM 节点, - // 只同步发生变化的属性与文本;只有标签真的不同才替换。元素身份不变 ⇒ - // CSS 过渡继续、不闪。 - function morph(container, html) { - var tpl = document.createElement("div"); - tpl.innerHTML = html; - morphChildren(container, tpl); - } - function morphChildren(oldParent, newParent) { - var olds = Array.prototype.slice.call(oldParent.childNodes); - var news = Array.prototype.slice.call(newParent.childNodes); - var n = Math.max(olds.length, news.length); - for (var i = 0; i < n; i++) { - var o = olds[i], x = news[i]; - if (!o && x) { - oldParent.appendChild(x); - continue; - } - if (o && !x) { - oldParent.removeChild(o); - continue; - } - if (o.nodeType !== x.nodeType || o.nodeName !== x.nodeName) { - oldParent.replaceChild(x, o); - continue; - } - if (o.nodeType === 3 || o.nodeType === 8) { - if (o.nodeValue !== x.nodeValue) o.nodeValue = x.nodeValue; - continue; - } - morphAttrs(o, x); - morphChildren(o, x); - } - } - function morphAttrs(o, x) { - var i, a; - for (i = o.attributes.length - 1; i >= 0; i--) { - a = o.attributes[i]; - if (!x.hasAttribute(a.name)) o.removeAttribute(a.name); - } - for (i = 0; i < x.attributes.length; i++) { - a = x.attributes[i]; - if (o.getAttribute(a.name) !== a.value) o.setAttribute(a.name, a.value); - } - } - - // rtSlider 把“值 / 上限”画成轨道 + 滑块 + 读数(比纯文本数字直观)。 - function rtSlider(value, max, label, display) { - var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; - return ( - '' + - escHtml(label) + - '' + - '' + - escHtml(String(display === undefined ? value : display)) + - "" - ); - } - - // rtMini 是行内迷你条(登记/抢占这类要并排两个的量)。 - function rtMini(value, max, label) { - var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; - return ( - '' + - value + - "" - ); - } - - function rtTile(num, label, sub, pct, active, warn) { - return ( - '
' + - num + - '
' + - label + - "
" + - (sub ? '
' + sub + "
" : "") + - '
' - ); - } - - function rtCapsHtml(caps) { - var out = ""; - Object.keys(RT_CAP_NAMES).forEach((bit) => { - if (caps & Number(bit)) { - out += '' + RT_CAP_NAMES[bit] + ""; - } - }); - return out ? '' + out + "" : ""; - } - - // 归属配色:根用青色,驻留子轮转其余颜色(同一张图里能一眼分清谁是谁)。 - var RT_OWNER_COLORS = ["#88c0d0", "#b48ead", "#a3be8c", "#ebcb8b", "#d08770", "#8fbcbb"]; - // 黑白配色下,拓扑归属也用灰阶 —— 否则整页只剩这张图还是一片彩。 - var RT_OWNER_COLORS_MONO = ["#e6e9ef", "#c2c7d0", "#9aa0ad", "#7b818d", "#5f6570", "#454a55"]; - function rtOwnerColors() { - return document.documentElement.getAttribute("data-color") === "mono" - ? RT_OWNER_COLORS_MONO - : RT_OWNER_COLORS; - } - - // rtOwnerGroups 把 inputch 按**归属**分组(owner == 根 agent id 与 "" 归一为根)。 - function rtOwnerGroups(inputs, residents, rootID) { - var order = []; - var map = {}; - inputs.forEach((c) => { - var o = c.owner || ""; - if (o === rootID) o = ""; - if (!map[o]) { - map[o] = []; - order.push(o); - } - map[o].push(c); - }); - // 驻留子即使一条 inputch 都没划到也要出现——否则「子存在但看不见」 - // 与「子不存在」无法区分。 - residents.forEach((r) => { - var o = r.id || ""; - if (o && o !== rootID && !map[o]) { - map[o] = []; - order.push(o); - } - }); - order.sort((a, b) => { - if (a === "") return -1; - if (b === "") return 1; - return a < b ? -1 : 1; - }); - var ci = 0; - var OW = rtOwnerColors(); - return order.map((o) => { - var res = null; - residents.forEach((r) => { - if (r.id === o) res = r; - }); - var list = map[o] || []; - return { - owner: o, - child: o !== "", - label: o === "" ? __("根 agent / 内核默认", "root agent / kernel default") : __("驻留子 ", "resident ") + o, - color: o === "" ? OW[0] : OW[1 + (ci++ % (OW.length - 1))], - list: list, - count: list.length, - res: res, - }; - }); - } - - // ---- 阶段管道滑块 ---- - // - // 七阶段与内核 sdk.Stage 一一对应(顺序即执行顺序),由 SSE `stage` 事件驱动: - // 哪个阶段在执行,滑块就滑到哪一格。空闲时整条管道降透明度,不做假动画。 - // - // 为什么放进总览:阶段管道回答"这一轮走到哪一步",总览其余图形回答"积压了多少", - // 两者合起来才是运行态。此前它只是对话页一个 10px 的角标,等于看不见。 - // 阶段管道:**循环流程 + 本轮轨迹**,不是单向滑块。 - // - // 一轮里工具调用会反复回到「行动后」再接下一个工具(post_action → - // before_toolcall → after_toolcall → post_action …),还可能中途多次 - // 输出,所以线性滑块本身就是错的表述。这里画成 - // 输入 → 行动 ⇄(工具) → 输出 → 结束 - // (工具那格带循环标记),下面再用一排 chip 记下**本轮真实发生过什么**: - // 普通工具与 output_* 输出通道调用用不同配色区分开来。 - // 页面上**任何位置都不用 emoji/符号字符充当图标** —— 一律内联 SVG, - // 24x24 viewBox + currentColor stroke,随主题与状态变色,不额外引资源。 - var RT_ICO = { - in: '', - act: '', - tool: '', - out: '', - done: '', - loop: '', - bolt: '', - caret: '', - }; - - var RT_PIPE_GROUPS = [ - { zh: "输入", en: "in", ico: "in" }, - { zh: "行动", en: "act", ico: "act" }, - { zh: "工具", en: "tool", ico: "tool", loop: true }, - { zh: "输出", en: "out", ico: "out" }, - { zh: "结束", en: "done", ico: "done" }, - ]; - function rtPhaseGroup(phase) { - switch (phase) { - case "on_input": - return 0; - case "pre_action": - case "post_action": - return 1; - case "before_toolcall": - case "after_toolcall": - return 2; - case "before_output": - return 3; - case "after_output": - return 4; - } - return -1; - } - // rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名。 - function rtShortTool(name) { - var n = String(name || ""); - var i = n.lastIndexOf("__"); - if (i >= 0) n = n.slice(i + 2); - return n.length > 14 ? n.slice(0, 13) + "…" : n; - } - // rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。 - // - // 为什么不用进度条:队列为 0 时进度条宽度就是 0,整行只剩一串文字, - // 看上去就是「这块空着」。格槽在 0 时仍有形状,占用多少一眼可数, - // 也不用为了「看得见」而给 0 画一条假进度。 - function rtSlots(depth, slots, extraCls) { - var n = Math.max(5, Math.min(16, slots || 5)); - var d = depth || 0; - var out = ''; - for (var i = 0; i < n; i++) out += ''; - // 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去, - // 多一个兄弟节点会被挤到下一行,整块队列就串了。 - if (d > n) out += '+' + (d - n) + ""; - return out + ""; - } - // rtChipEl 画一枚事件 chip。工具与输出通道调用各有配色;名称包进 - // .rt-chip-t,让窄框里由它做省略号截断(.rt-chip 本身是 flex 容器, - // text-overflow 对直接子文本不生效,会被硬切掉半截)。 - function rtChipEl(t) { - var kind = t.kind || "stage"; - var ico = kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : ""; - return ( - '' + ico + - '' + escHtml(t.short || t.label) + "" + - (t.n > 1 ? 'x' + t.n + "" : "") + - "" - ); - } - // rtPipelineHtml 画阶段管道:**五个等大的表框**,每框里是本阶段本轮发生的事。 - // - // 为什么不是「小圆点 + 连接线 + 9px 小字」:那种画法在总览里几乎读不出 - // 「现在走到哪一步」,尺寸也远小于旁边的 KPI 框。现在与中断队列、KPI 用同 - // 一套视觉语言(等大框 + 大号加粗标题),并且事件直接落在它所属的框里, - // 所以「这一步发生了什么」不需要靠颜色或图例去猜。 - function rtPipelineHtml(phase, trail) { - var g = rtPhaseGroup(phase); - var cells = RT_PIPE_GROUPS.map((s, i) => { - var items = (trail || []).filter((t) => (t.g | 0) === i); - // 「工具」格是**循环**格:一轮里可能调几十次工具/输出通道。把每一次都 - // 追加成 chip,这格会被撑成一长条,读者反而看不出「现在正在调什么」。 - // 所以它只保留**最新一条**,旧条在同一行视口里向上滚走 - // (rtFlashLatestTool 触发滑入),右侧另给本轮累计次数。 - var cls = "rt-pipe-events"; - var body; - if (!items.length) { - body = '' + __("无", "none") + ""; - } else if (s.loop) { - cls += " rt-pipe-scroll"; - var total = 0; - for (var k = 0; k < items.length; k++) total += items[k].n || 1; - body = - rtChipEl(items[items.length - 1]) + - 'x' + total + ""; - } else { - body = items.map(rtChipEl).join(""); - } - return ( - '
' + - '
' + RT_ICO[s.ico] + - "" + __(s.zh, s.en) + "" + - (s.loop - ? '' + RT_ICO.loop + "" - : "") + - "
" + - '
' + body + "
" + - "
" - ); - }).join(""); - return ( - '
' + - __("阶段管道", "Stage pipeline") + - (g < 0 ? " " + __("(空闲)", "(idle)") : "") + - "
" + - '
' + cells + "
" - ); - } - - // rtTrailPush 把一条「本轮发生过的事」落到它实际发生的阶段列里。 - // 同一阶段重复出现同一条(如同一工具连调 3 次)只累加计数,不刷屏。 - function rtTrailPush(g, kind, label, short) { - if (!state.stageTrail) state.stageTrail = []; - var arr = state.stageTrail; - var last = arr.length ? arr[arr.length - 1] : null; - if (last && last.g === g && last.kind === kind && last.short === short) { - last.n = (last.n || 1) + 1; - return; - } - arr.push({ g: g, kind: kind, label: label, short: short, n: 1 }); - if (arr.length > 24) arr.shift(); - } - - // rtFlashLatestTool 让「工具」格里最新那条做一次「向上滚入」。 - // - // morph 是就地改文本:新工具到来时 chip 节点不会被替换,纯 CSS 的 - // animation 因此不会自动重放。这里摘类 → 强制 reflow → 重加类,重置动画 - // 时间轴。prefers-reduced-motion 由样式表统一压到 0.01ms,无需在此判断。 - function rtFlashLatestTool() { - var box = document.querySelector(".rt-pipe-events.rt-pipe-scroll"); - if (!box) return; - var chip = box.querySelector(".rt-chip"); - if (!chip) return; - chip.classList.remove("rt-chip-enter"); - void chip.offsetWidth; // 强制 reflow:少了这句,重加类不会重启动画 - chip.classList.add("rt-chip-enter"); - } - - // ---- per-agent 负载 ---- - // - // 负载 = 该 agent **自己的**调度器积压折算成的百分比。 - // - // 为什么按级别加权:四级中断里 L4 是内核独占、L3 是交互,堵在 L4 一条比堵在 - // L1 五条更严重;中断栈深度意味着有现场被压着。权重是启发式的("满载"没有 - // 硬定义),因此函数做成饱和式 backlog/(backlog+K):单调、上界 100、不越界。 - // context_full 单独加分:子上下文满了以后每轮都要压缩,本身就是高负载。 - function rtLoadPct(sc, ctxFull) { - sc = sc || {}; - var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; - var backlog = - (sc.ready_queue_depth || 0) + - (sc.pending_interrupts || 0) * 1.5 + - (sc.suspend_stack || 0) * 2 + - (q[1] || 0) * 1 + - (q[2] || 0) * 1.2 + - (q[3] || 0) * 1.5 + - (q[4] || 0) * 2; - var pct = 100 * (backlog / (backlog + 8)); - if (ctxFull) pct += 15; - return Math.max(0, Math.min(100, Math.round(pct))); - } - - // rtAgents 把「inputch 归属 + 驻留子 + 各自的调度器积压」整理成每个 agent 一行。 - // 根 agent 的积压来自 rt.scheduler;驻留子的来自它自己的 ResidentStatus - // (见 internal/sdk/status.go 的 ReadyQueueDepth 等四项)。 - function rtAgents(rt) { - var rootID = rt.agent_id || ""; - var groups = rtOwnerGroups(rt.input_channels || [], rt.residents || [], rootID); - var outChans = (rt.channels || []).filter((c) => c.direction === "out" || c.direction === "io"); - return groups.map((g) => { - var id = g.owner || rootID; - var outputs = []; - var seen = {}; - function add(name) { - if (!name || seen[name]) return; - seen[name] = 1; - outputs.push(name); - } - if (g.child) { - // 驻留子:优先用父显式授权的输出;没有授权登记时退回它自己 inputch 的 - // 默认回程(`output` 字段)。 - ((g.res && g.res.allowed_outputs) || []).forEach(add); - if (!outputs.length) g.list.forEach((c) => { add(c.output); }); - } else { - // 根 agent 可以写任何输出通道;上限交给渲染侧截断。 - outChans.forEach((c) => { add(c.name); }); - } - var load = g.child - ? rtLoadPct( - { - ready_queue_depth: g.res && g.res.ready_queue_depth, - pending_interrupts: g.res && g.res.pending_interrupts, - suspend_stack: g.res && g.res.suspend_stack, - interrupt_queues: (g.res && g.res.interrupt_queues) || [], - }, - g.res && g.res.context_full, - ) - : rtLoadPct(rt.scheduler || {}, false); - return { - id: id, child: g.child, label: g.label, color: g.color, - inputs: g.list, outputs: outputs, load: load, res: g.res || null, - }; - }); - } - - // 连线路径表:通道名 → path d。光点动画靠它把「哪个通道」翻成一条曲线。 - var _rtEdgeIn = {}; - var _rtEdgeOut = {}; - var RT_SVGNS = "http://www.w3.org/2000/svg"; - - // rtAgentTopology 画「通道 → agent → 通道」的分带拓扑。 - // - // 每个 agent 一条横带:左边是归它的 inputch,中间是它自己(圆环 = 负载), - // 右边是它能写的 outputch。连线即路由;光点沿连线跑表示消息正在流动。 - // 与旧版「归属框 → 单个内核盒」的差别:内核盒只有一个,看不出"这条输入到底 - // 喂给了哪个子",而子 agent 才是运行态里最该看清的东西。 - // rtTopoWidth 用**实测容器宽**当 viewBox 宽:viewBox 宽与渲染宽一致时缩放才是 - // 1:1。此前固定 640 而容器 ~920,浏览器按 "meet" 把内容顶在左上、右侧空出一大块, - // 观感就是"间距不对"。 - function rtTopoWidth() { - var host = document.getElementById("rt-sec-topo"); - var w = host ? host.clientWidth : 0; - if (!w) { - var panel = document.getElementById("rt-panel"); - w = panel ? panel.clientWidth : 0; - } - if (!w || w < 360) w = 900; - return Math.round(w) - 4; - } - - // rtClip 按估算宽度截断长通道名(SVG 没有 text-overflow,只能自己量)。 - // 全角按 1em、其余按 0.56em 估宽:只求不越界,不求像素级精确。 - function rtClip(text, maxPx, fontPx) { - var s = String(text == null ? "" : text); - var w = 0; - var i = 0; - for (; i < s.length; i++) { - var cw = s.charCodeAt(i) > 0x2e80 ? fontPx : fontPx * 0.56; - if (w + cw > maxPx) break; - w += cw; - } - if (i >= s.length) return s; - return s.slice(0, Math.max(1, i - 1)) + "…"; - } - - function rtAgentTopology(agents) { - var W = rtTopoWidth(); - var ROW = 30, BOX_H = 20, GAP = 14, NODE_R = 20, MAX_OUT = 8; - // 三列按比例分:输入 0~32%,节点居中在 44%,输出 56%~100%,两侧留白对称。 - var IN_X = 0, IN_W = Math.round(W * 0.32); - var NX = Math.round(W * 0.44), OUT_X = Math.round(W * 0.56), OUT_W = W - Math.round(W * 0.56) - 2; - var out = []; - _rtEdgeIn = {}; - _rtEdgeOut = {}; - function esc(s) { - return String(s == null ? "" : s).replace(/[<>&]/g, (m) => m === "<" ? "<" : m === ">" ? ">" : "&"); - } - function pathD(x1, y1, x2, y2) { - var mx = (x1 + x2) / 2; - return "M" + x1 + " " + y1 + " C" + mx + " " + y1 + " " + mx + " " + y2 + " " + x2 + " " + y2; - } - var y = 8; - if (!agents.length) { - out.push('' + __("暂无通道 / agent", "no channels / agents") + ""); - y = 44; - } - agents.forEach((a) => { - var nin = a.inputs.length; - var nout = Math.min(a.outputs.length, MAX_OUT); - var rows = Math.max(nin, nout, 1); - var truncated = a.outputs.length > MAX_OUT; - // 内容高必须**容得下节点块**(圆环 + 下方标签一行),否则单行带里节点和它的 - // 名字会溢出到上/下一个带上("间距不对"的一个来源)。 - var nodeBlock = NODE_R * 2 + (a.res && a.res.context_full ? 52 : 20); - var contentH = Math.max(rows * ROW, nodeBlock, truncated ? nout * ROW + 18 : 0); - var bandH = contentH + GAP; - var cy = y + contentH / 2; - // 短的一列在带内**居中**:连线短而对称,不再出现"左边挤在上半、右边铺满"的错位感。 - var inTop = y + (contentH - nin * ROW) / 2; - var outTop = y + (contentH - nout * ROW) / 2; - out.push(''); - out.push(''); - - // 输入通道 → agent - a.inputs.forEach((c, i) => { - var ry = inTop + i * ROW + ROW / 2; - out.push(''); - out.push('' + esc(rtClip(c.name, IN_W - 44, 12)) + "" + esc(c.name) + ""); - if (c.capacity) { - out.push('' + esc(c.capacity) + ""); - } - var d = pathD(IN_X + IN_W, ry, NX - NODE_R - 2, cy); - _rtEdgeIn[c.name] = d; - out.push(''); - }); - - // agent 节点:两圈 + 一段负载弧(stroke-dasharray 画进度) - var C = 2 * Math.PI * (NODE_R - 3); - out.push(''); - out.push(''); - out.push( - '', - ); - out.push('' + a.load + ""); - out.push('' + esc(rtClip(a.child ? a.id : __("根 agent", "root"), 140, 11)) + ""); - if (a.res && a.res.context_full) { - out.push('' + __("上下文已满", "ctx full") + ""); - } - - // agent → 输出通道 - a.outputs.slice(0, MAX_OUT).forEach((name, i) => { - var ry = outTop + i * ROW + ROW / 2; - out.push(''); - out.push('' + esc(rtClip(name, OUT_W - 20, 12)) + "" + esc(name) + ""); - var d = pathD(NX + NODE_R + 2, cy, OUT_X, ry); - _rtEdgeOut[a.id + "\u0000" + name] = d; - out.push(''); - }); - if (a.outputs.length > MAX_OUT) { - out.push('+' + (a.outputs.length - MAX_OUT) + " " + __("更多", "more") + ""); - } - y += bandH; - }); - var H = Math.max(60, y); - return ( - '
' + __("拓扑(圆环 = 负载)", "Topology (ring = load)") + "
" + - '
' + - out.join("") + - "
" - ); - } - - // rtSpark 让一个光点沿一条连线跑一趟。 - // - // 用 SMIL 而不是 CSS offset-path / rAF:它由浏览器自己按 - // SVG 用户坐标跑,不需要前端维护动画循环,也没有 path() 在缩放 viewBox 下 - // 的坐标换算问题。跑完把节点摘掉,避免 DOM 累积。 - function rtSpark(pathD, color) { - if (!pathD) return; - var svg = document.getElementById("rt-topo-svg"); - if (!svg) return; - var c = document.createElementNS(RT_SVGNS, "circle"); - c.setAttribute("r", "3.2"); - c.setAttribute("fill", color || "#ffffff"); - c.setAttribute("class", "rt-spark"); - var am = document.createElementNS(RT_SVGNS, "animateMotion"); - am.setAttribute("dur", "0.85s"); - am.setAttribute("path", pathD); - am.setAttribute("fill", "freeze"); - am.setAttribute("begin", "0s"); - c.appendChild(am); - svg.appendChild(c); - setTimeout(() => { if (c.parentNode) c.parentNode.removeChild(c); }, 950); - } - // 输入:某条 inputch 来消息了(SSE channel_input)。 - function rtSparkInput(source) { - rtSpark(_rtEdgeIn[source], "#88c0d0"); - } - // 输出:某个 agent 往某条 outputch 写了东西(SSE agent_output)。 - function rtSparkOutput(agentID, channel) { - if (!channel || !agentID) return; - rtSpark(_rtEdgeOut[agentID + "\u0000" + channel], "#ff7fac"); - } - - function renderRuntime() { - var el = document.getElementById("rt-panel"); - if (!el) return; - var rt = state.runtime; - if (!rt) { - el.innerHTML = '
' + __("运行态数据不可用", "runtime unavailable") + "
"; - _rtSig = null; - return; - } - var sc = rt.scheduler || {}; - var residents = rt.residents || []; - var channels = rt.channels || []; - var inputs = rt.input_channels || []; - - // 数据签名(**不含 uptime**——它每秒都变,带上就等于没缓存):整体没变就整块跳过。 - // 签名里必须带 pipelinePhase:否则 SSE 把阶段推到下一格时, - // 数据没变 → 早退 → 滑块不动,只能等下一次 /runtime 轮询才追上。 - var sig = JSON.stringify([ - sc, residents, channels, inputs, state.pipelinePhase, - (state.stageTrail || []).map((t) => t.g + ":" + t.kind + ":" + (t.short || t.label) + "x" + (t.n || 1)).join(","), - ]); - if (sig === _rtSig) return; - _rtSig = sig; - - // 外壳只建一次;随后**逐段**更新。 - // - // 为什么不是整块 innerHTML:设备通道列表这类数据本来就会来回变(实测远程设备 - // 通道 11→9 条),整块重建会把没变的段落(含条形 transition)也推倒重来, - // 视觉上就是「一闪一闪」。逐段比较后只替换真正变了的那一段。 - if (!document.getElementById("rt-sec-tiles")) { - el.innerHTML = - '

' + __("运行态", "Runtime") + "

" + - '
' + - '
' + - '
' + - '
' + - '
' + - "
"; - } - function put(sec, html) { - var n = document.getElementById("rt-sec-" + sec); - if (!n || n.__sig === html) return; // 该段没变:一个字节都不动 - n.__sig = html; - // 变了也只「形变」到新样子:复用未变的子节点,而不是整块重建。 - morph(n, html); - } - - var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; - var pending = sc.pending_interrupts || 0; - var ready = sc.ready_queue_depth || 0; - var stack = sc.suspend_stack || 0; - var maxStack = sc.max_suspend_depth || 4; - var frames = sc.suspend_frames || []; - var byLv = sc.interrupts_by_level || []; - var preLv = sc.preempts_by_level || []; - - // ---- 段 1:四个数字块 ---- - var h = '
'; - h += rtTile(ready, __("排队", "Ready"), "", ready ? Math.min(100, ready * 20) : 0, ready > 0); - h += rtTile(pending, __("中断", "Pending"), "", pending ? Math.min(100, pending * 25) : 0, pending > 0, pending > 0); - h += rtTile(stack + "/" + maxStack, __("栈", "Stack"), "", (stack / (maxStack || 4)) * 100, stack > 0); - var rFull = residents.filter((r) => r.context_full).length; - h += rtTile(residents.length, __("子代理", "Subagents"), rFull ? rFull + __("满", " full") : "", residents.length ? Math.min(100, residents.length * 20) : 0, residents.length > 0, rFull > 0); - h += "
"; - // 「累计:入队 … 执行 … 抢占 …」那一整行纯文字被去掉了:它对"现在忙不忙" - // 没有帮助,却占掉一整行,是总览里最大的一坨文字。 - put("tiles", h); - - // ---- 段 2:阶段管道(滑块,SSE stage 事件驱动)---- - put("pipe", rtPipelineHtml(state.pipelinePhase, state.stageTrail)); - - // ---- 段 3:队列(四级中断 + 一条排队)---- - // - // 设计是「四条中断队列(L1–L4)+ 一条排队队列」共五个,所以必须画五行: - // 只画四条会让「排队输入」这条线在运行态里凭空消失,而它正是 - // 「不需要及时处理」的那一半输入。排队队列**无级别**,故用不同配色 + 虚线。 - h = '
' + __("队列", "Queues") + "
"; - var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0); - var maxReg = 1; - var maxPre = 1; - RT_LEVELS.forEach((L) => { - maxReg = Math.max(maxReg, byLv[L.lv] || 0); - maxPre = Math.max(maxPre, preLv[L.lv] || 0); - }); - // 槽位数按全场最大深度缩放(且至少 5 格):0 时也有可见形状,不空着。 - var qSlots = Math.max(5, Math.min(16, maxQ)); - // 五个**等大表框**(四级中断 + 一条排队),与阶段管道同一套视觉语言。 - // 此前是五行扁条,四级中断全为 0 时四行几乎全是空白,又占高度又难看。 - h += '
'; - RT_LEVELS.forEach((L) => { - var depth = q[L.lv] || 0; - var reg = byLv[L.lv] || 0; - var pre = preLv[L.lv] || 0; - h += - '
' + - '
' + L.name + "" + escHtml(L.desc) + "
" + - '
' + depth + "
" + - rtSlots(depth, qSlots, L.cls) + - '
' + - rtMini(reg, maxReg, __("登记", "registered")) + - rtMini(pre, maxPre, __("抢占", "preempted")) + - "
"; - }); - // 第五条:排队队列(无级别,纯 FIFO)。 - // 它不是优先级而是另一**类别**(排队 vs 中断),所以用虚线框区分。 - h += - '
' + - '
' + __("排队", "queued") + "FIFO
" + - '
' + ready + "
" + - rtSlots(ready, qSlots, "rt-lv-q") + - '
' + __("无级别", "no priority") + "
"; - h += "
"; - if (sc.immediate) { - h += '
' + RT_ICO.bolt + " " + __("立即运行", "immediate") + ":" + - escHtml(sc.immediate.kind || "") + " #" + sc.immediate.id + - 'L' + (sc.immediate.level || 0) + "
"; - } - put("levels", h); - - // ---- 段 4:中断栈 ---- - h = '
' + __("栈", "Stack") + "
"; - // 深度先给一条进度条("压了几层 / 上限几层"一眼可读),下面再列具体帧。 - h += rtSlider(stack, maxStack, __("深度", "depth"), stack + " / " + maxStack); - if (frames.length) { - h += '
'; - frames.forEach((f, i) => { - var t = (f && f.task) || {}; - h += '
' + escHtml(t.kind || "") + " #" + (t.id || "?") + - 'L' + (t.level || 0) + - (i === frames.length - 1 ? " · " + __("栈顶", "top") : "") + "
"; - }); - h += "
"; - } else { - h += '
' + __("中断栈为空(当前无被抢占的现场)", "stack empty (nothing preempted)") + "
"; - } - if (sc.running) { - h += '
' + __("正在运行", "running") + ":" + - escHtml(sc.running.kind || "") + " #" + sc.running.id + " (L" + (sc.running.level || 0) + ")
"; - } - put("stack", h); - - // ---- 段 5:Agent 拓扑 ---- - // - // 每个 agent 一条横带:左是归它的 inputch、中间是它自己(圆环 = 负载)、 - // 右是它能写的 outputch,连线即路由。归属、路由、容量、负载全部画进 - // 同一张 SVG,不再单开"通道分配"一节——那本来就是这个拓扑的一部分。 - put("topo", rtAgentTopology(rtAgents(rt))); - } - - // loadRuntime 拉运行态小快照并就地重绘面板(约 2KB,可秒级轮询)。 - async function loadRuntime() { - try { - var rt = await api("/runtime"); - state.runtime = rt; - renderRuntime(); - } catch (e) { - state.runtime = null; - } - } - - // startRuntimeTicker 起 3s 轮询:运行态只在总览页可见时才有意义, - // 其它页签上不浪费请求。切回总览时 renderAll 会立刻再拉一次,不必等下一拍。 - // startChatTicker:聊天页可见时 3s 轮询一次增量(与运行态同一节奏)。 - // SSE 仍在(token 级流式靠它),轮询是「数据查询 api」那条腿: - // 界面最终状态由轮询拉到的数据决定,SSE 只负责让流式看起来即时。 - function startChatTicker() { - if (state._chatTicker) return; - state._chatTicker = setInterval(() => { - var tab = document.querySelector("#tab-chat"); - if (tab && tab.classList.contains("active")) { - syncChatFromHistory().catch(() => {}); - } - }, 3000); - } - - function startRuntimeTicker() { - if (state._runtimeTicker) return; - state._runtimeTicker = setInterval(() => { - var tab = document.querySelector("#tab-overview"); - if (tab && tab.classList.contains("active")) { - loadRuntime(); - } - }, 3000); - } - - // ===== Overview ===== - // - // 总览页是**静态骨架 + 数据绑定**:DOM 只在首帧建一次,之后所有刷新只改 - // 文本/类名,绝不重建节点。此前 renderAll 每 15s 把整个总览(含运行态面板) - // innerHTML 重建一遍 —— 那是页面上最刺眼的周期性闪烁的来源。 - // - // 文字也压到最少:状态用彩色圆点表达,其余只留数字与两字标签,不再堆 - // 「未初始化 / 不可用」这类整句描述。 - var OV_ICONS = { - activity: - '', - clock: - '', - puzzle: - '', - tag: - '', - brain: - '', - db: - '', - file: - '', - cpu: - '', - }; - - function ovKpi(id, icon, label, dot) { - return ( - '
' + - icon + - '' + - (dot ? '' : "") + - '—' + - '' + - '' + - label + - "
" - ); - } - - function renderOverview() { - var host = document.getElementById("tab-overview"); - if (!host) return; - // 只在首帧建骨架;之后(renderAll 每 15s 一次)只 updateOverview。 - // 一个节点都不重建,所以不会闪。 - if (!document.getElementById("ov-kpis")) { - host.innerHTML = - '
' + - '
' + - ovKpi("status", OV_ICONS.activity, __("状态", "Status"), true) + - ovKpi("uptime", OV_ICONS.clock, __("运行", "Uptime")) + - ovKpi("plugins", OV_ICONS.puzzle, __("插件", "Plugins")) + - ovKpi("version", OV_ICONS.tag, __("版本", "Version")) + - ovKpi("llm", OV_ICONS.brain, "LLM", true) + - ovKpi("memory", OV_ICONS.db, __("记忆", "Memory")) + - ovKpi("docs", OV_ICONS.file, __("文档", "Docs")) + - ovKpi("runtime", OV_ICONS.cpu, __("运行时", "Runtime")) + - '
' + - // 星图搬到主页:作为总览的门面,跟随 agent 活动脉动。 - // 开源许可单独成框:之前在 KPI 卡底部只是一行小链接(.ov-foot), - // 几乎看不见,也看不出是许可证还是别的什么。 - '

' + - __("记忆星图", "Memory Star Map") + - '

' + - '
' + - ''; - } - updateOverview(); - // 星图在主页常驻:容器已建好,首次就拉数据 + 初始化 3D。 - if (document.getElementById("sm-container-home") && !state.starmapInit) { - renderChatStarmap(); - } - } - - function ovSet(id, text) { - var el = document.getElementById(id); - if (el && el.textContent !== String(text)) el.textContent = String(text); - } - function ovDot(id, cls) { - var el = document.getElementById(id); - if (el && el.className !== cls) el.className = cls; - } - - // updateOverview 只写值与类名,从不改结构。 - function updateOverview() { - var s = state.status || {}; - var k = state.kernel; - var running = s.status === "running"; - ovSet("ov-status", running ? __("运行中", "running") : s.status || "unknown"); - ovDot("ov-status-dot", "ov-dot " + (running ? "ok" : "warn")); - ovSet("ov-uptime", state.startedAt ? fmtUptime(Date.now() - state.startedAt) : "-"); - ovSet("ov-plugins", ((k && k.plugins) || []).length || 0); - // 内核身份:光有版本号分不清是哪个内核、哪次构建 —— 补上内核名与 commit。 - // 之前 36b577b 改图标 KPI 时把 kernel_name 丢了,只剩余 "v1.4.0"。 - var kb = (k && k.build) || {}; - var kv = kb.version || s.version || ""; - ovSet("ov-version", kv ? "v" + kv : "—"); - ovSet( - "ov-version-sub", - kv - ? (kb.kernel_name || "HomeAgent") + - (kb.commit && kb.commit !== "unknown" - ? " · " + String(kb.commit).slice(0, 7) - : "") - : "", - ); - var llm = (k && k.llm) || {}; - ovDot("ov-llm-dot", "ov-dot " + (llm.available ? "ok" : "bad")); - ovSet("ov-llm", llm.provider || "—"); - var mem = (k && k.memory) || {}; - ovSet("ov-memory", mem.available ? mem.entity_count + "/" + mem.relation_count : "—"); - var docs = (k && k.documents) || {}; - ovSet("ov-docs", docs.available ? docs.doc_count : "—"); - var rt = (k && k.runtime) || {}; - ovSet( - "ov-runtime", - rt.goroutines ? rt.goroutines + (rt.memory_mb ? " · " + rt.memory_mb + "M" : "") : "—", - ); - // 开源许可卡:协议标识 + 协议全文 + 源码仓库。 - // - // 为什么单独成框、且三者都给:AGPL-3.0 §13 的义务是「向网络使用者提供 - // 取得 Corresponding Source 的机会」——只给一个仓库链接、不写协议名, - // 使用者看不出这受什么许可约束,也看不出网络服务场景下还有 §13 的义务。 - // 内容对一次构建是常量,所以填一次就够(__html 比对避免重复重建)。 - var legal = document.getElementById("ov-legal"); - if (legal) { - var lb = (k && k.build) || {}; - var src = lb.source_url || ""; - var lic = lb.license || ""; - var licURL = lb.license_url || ""; - var rows = ""; - if (lic) { - rows += - '
' + - __("许可协议", "License") + - '' + - (licURL - ? '' + escHtml(lic) + "" - : escHtml(lic)) + - "
"; - } - if (src) { - rows += - '
' + - __("源码仓库", "Source") + - '' + - escHtml(src) + - "
"; - } - // 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。 - var note = - lic && lic.toUpperCase().indexOf("AGPL") >= 0 - ? '" - : ""; - var want = rows - ? '

' + __("开源许可", "License") + "

" + rows + note - : ""; - if (legal.__html !== want) { - legal.__html = want; - legal.innerHTML = want; - } - } - } - - // ===== Chat ===== - var _chatLayoutBuilt = false; - - function buildChatLayout() { - var cont = document.getElementById("tab-chat"); - var k = state.kernel || {}; - var html = '
'; - html += - '
' + - '' + - __("对话", "Chat") + - "" + - "" + - __("星图", "Star Map") + - "" + - "" + - __("终端", "Terminal") + - "" + - "" + - __("运行中命令", "Running Commands") + - "" + - "" + - __("记忆", "Memory") + - "" + - "" + - __("上下文", "Context") + - "" + - "" + - __("知识", "Knowledge") + - "
"; - html += - '
'; - html += - '

' + - __("对话", "Chat") + - '

'; - if (state.messages.length === 0) { - html += - '

' + - __( - "开始对话以测试 Agent 回复", - "Start a conversation to test Agent replies", - ) + - "

"; - } - html += - "
" + - '
' + - '' + - '" + - '' + - '" + - '" + - "
"; - html += - '

' + - __("星图", "Star Map") + - "

" + - '
'; - html += - '

' + - __("终端", "Terminal") + - ' 0

' + - '
'; - html += - '

' + - __("命令历史", "Command History") + - ' 0

' + - '
'; - html += - '

' + - __("记忆", "Memory") + - "

" + - '
' + - __("实体", "Entities") + - '' + - (k?.memory?.entity_count || "-") + - "
" + - '
' + - __("关系", "Relations") + - '' + - (k?.memory?.relation_count || "-") + - "
" + - '
' + - '' + - '" + - '
' + - "
"; - html += - '

' + - __("上下文", "Context") + - "

" + - '
' + - '' + - '" + - '
' + - "
"; - html += - '

' + - __("知识", "Knowledge") + - "

" + - '
' + - __("项目", "Items") + - '' + - (k?.knowledge?.item_count ?? "-") + - "
" + - '
' + - '' + - __("按分类浏览", "Browse by category") + - '' + - '
' + - '
' + - '
' + - '' + - '' + - '" + - '" + - '
' + - '
' + - '' + - '' + - '' + - '
" + - '' + - "
" + - "
"; - html += "
"; - cont.innerHTML = html; - _chatLayoutBuilt = true; - } - - var CHAN_COLORS = [ - "#e08a5f", - "#5f9fe0", - "#6bbf8f", - "#c06bbf", - "#d9a13b", - "#5fb3bf", - "#b06b6b", - "#7f8ce0", - ]; - - function chanColor(src) { - var h = 0; - for (var i = 0; i < src.length; i++) { - h = (h * 31 + src.charCodeAt(i)) >>> 0; - } - return CHAN_COLORS[h % CHAN_COLORS.length]; - } - - function chanLetter(src) { - var s = (src || "").trim(); - if (!s) return "C"; - var ch = s.charAt(0).toUpperCase(); - return /[A-Za-z0-9]/.test(ch) ? ch : "C"; - } - - // ===== 列表对账(keyed reconcile)===== - // - // 为什么必须对账而不是 innerHTML 整块重建:整块重建会把**没变的**那些消息 - // 节点也推倒重来 —— 图片重新解码闪一下、工具卡片的展开态丢失、CSS 过渡从 - // 头播、滚动锚点被重置。这些闪烁几乎无法用"重建后再还原"消除。 - // - // 做法:新 HTML 先在游离容器里解析成节点,然后按 data-key 逐个对账: - // - key 命中且内容一致 → 复用原节点(一个字节都不动); - // - key 命中但内容变了 → 只替换这一个节点; - // - key 未命中 → 作为新节点插入; - // 最后把多出来的旧节点删掉。未变节点因此完全不受影响。 - var _localKeySeq = 0; - - // chatMsgKey 给一条消息一个**稳定**的 key。 - // - 服务端消息用 seq(单调、落盘、重启不重编号); - // - 本地乐观消息(用户刚发的 / 正在流式的)第一次渲染时分配一个进程内 - // 自增 key 并挂在对象上,后续渲染不变。 - function chatMsgKey(m) { - if (m && m.seq) return "s" + m.seq; - if (m && !m._k) m._k = "L" + ++_localKeySeq; - return (m && m._k) || "L0"; - } - - function commitChatList(container, html) { - var tpl = document.createElement("div"); - tpl.innerHTML = html; - var next = Array.prototype.slice.call(tpl.children); - var existing = {}; - Array.prototype.forEach.call(container.children, (n) => { - var k = n.getAttribute && n.getAttribute("data-key"); - if (k) existing[k] = n; - }); - for (var i = 0; i < next.length; i++) { - var fresh = next[i]; - var key = fresh.getAttribute && fresh.getAttribute("data-key"); - var node = fresh; - if (key && existing[key]) { - var old = existing[key]; - // 内容逐字节相同 → 复用旧节点(不触碰它,保存动画/展开/图片状态); - // 否则只替换这一个。outerHTML 比较对消息节点足够(含 tool_calls 状态)。 - if (old.outerHTML === fresh.outerHTML) { - node = old; - } else { - old.remove(); - } - } - var cur = container.children[i]; - if (cur !== node) container.insertBefore(node, cur || null); - } - // 多出来的旧节点(本轮新列表里没有的)从尾部清掉 - while (container.children.length > next.length) { - container.removeChild(container.lastElementChild); - } - } - - function renderChat() { - if (!_chatLayoutBuilt) { - buildChatLayout(); - renderChatStarmap(); - renderTerminals(); - renderCmdHistory(); - } - var msgsEl = document.getElementById("chat-msgs"); - if (!msgsEl) return; - if (!msgsEl._stickBound) { - msgsEl._stickBound = true; - msgsEl.addEventListener( - "scroll", - () => { - state.chatStick = - msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < - 80; - // 触顶(近顶部 60px)且服务端还有更早历史 → 向上懒加载下一页 - if (msgsEl.scrollTop < 60 && state.chatHasMore) { - loadOlderChat(); - } - }, - { passive: true }, - ); - msgsEl.addEventListener( - "load", - () => { - if (state.chatStick === true) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } - }, - true, - ); - } - var msgs = state.messages; - var sig = - msgs - .map((m) => { - var c = m.content || ""; - return ( - (m.role || "") + - ":" + - c.length + - ":" + - c.slice(-40) + - ":" + - (m.tool_calls || []) - .map((t) => (t.tool || t.name || "") + "/" + (t.status || "")) - .join(",") - ); - }) - .join("|") + - "|L" + - (state.chatLoading ? "1" : "0") + - "|P" + - (state.pendingTools || []).join(","); - if (msgsEl._chatSig === sig && msgsEl.childElementCount > 0) { - if (state.chatStick === true) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } - return; - } - msgsEl._chatSig = sig; - var prevPending = msgsEl._lastPending || []; - var newPending = (state.pendingTools || []).slice(); - if (state.chatLoading) { - var lastM = msgs.length ? msgs[msgs.length - 1] : null; - if (lastM && lastM.role === "assistant") { - (lastM.tool_calls || []).forEach((tc) => { - if (!tc.result && tc.status !== "denied") { - var nm = tc.tool || tc.name || ""; - if (newPending.indexOf(nm) === -1) newPending.push(nm); - } - }); - } - } - var newlyDone = prevPending.filter((n) => newPending.indexOf(n) === -1); - msgsEl._lastPending = newPending; - var streamingLast = !!( - state.chatLoading && - lastM && - lastM.role === "assistant" && - !lastM._final - ); - function pillHtml() { - var s = ""; - newPending.forEach((nm) => { - var anim = prevPending.indexOf(nm) !== -1 ? "" : " pill-in"; - s += - '' + - '' + - escHtml(nm) + - ""; - }); - return s; - } - var html = ""; - if (msgs.length === 0) { - html = - '

' + - __( - "开始对话以测试 Agent 回复", - "Start a conversation to test Agent replies", - ) + - "

"; - } else { - msgs.forEach((m, i) => { - var _k = chatMsgKey(m); - var role = m.role || "user"; - var c = m.content || ""; - // 附件消息:agent 经 output_send__webui 发送,或用户上传。 - // 用 role 区分方向(user=右侧你发的 / assistant=左侧小宅发的), - // 卡片加来源标签避免混淆。 - if (m.attachment) { - var att = m.attachment; - var isUserAtt = role === "user"; - var srcLabel = isUserAtt - ? __("你发送的", "You sent") - : __("小宅发送的", "Sent by agent"); - var attHtml = ""; - if (att.type === "image") { - attHtml = - '' + - '图片加载失败\'"/>'; - } else if (att.type === "audio") { - // 音频用原生播放器:与图片同理,附件能在聊天里直接消费才算可见。 - // preload="metadata" 只拉时长不拉全部字节,避免历史消息满屏时并发下载。 - attHtml = - '' + - '' + - escHtml(att.name || "audio") + - (att.size ? " (" + formatBytes(att.size) + ")" : "") + - ""; - } else { - var sizeStr = att.size ? formatBytes(att.size) : ""; - attHtml = - '' + - '' + - "" + - escHtml(att.name || "附件") + - (sizeStr ? " (" + sizeStr + ")" : "") + - ""; - } - html += - '
' + - '
' + - srcLabel + - "
" + - attHtml + - (c ? '
' + renderMd(c) + "
" : "") + - "
"; - return; - } - if (role === "assistant") { - c = renderMd(c); - } else if (role === "system") { - c = escHtml(c); - } else { - c = escHtml(c); - } - var isChan = !!(m.source && m.source !== "webui"); - var rc = ""; - if (m.reasoning_content) { - rc = renderReasoningCard(m.reasoning_content, isStreamingLast); - } - var tcs = ""; - if (m.tool_calls && m.tool_calls.length > 0) { - m.tool_calls.forEach((tc) => { - var argsStr = - typeof tc.args === "object" - ? JSON.stringify(tc.args, null, 1) - : tc.args || ""; - var resultStr = tc.result - ? typeof tc.result === "object" - ? JSON.stringify(tc.result, null, 1) - : String(tc.result) - : ""; - var running = !resultStr && tc.status !== "denied"; - var error = - tc.status === "error" || tc.status === "denied" || !!tc.error; - var drip = - newlyDone.indexOf(tc.tool || tc.name || "") !== -1 - ? " tool-drip-in" - : ""; - var iconSvg = error - ? '' - : running - ? '' - : ''; - var statusHtml = - tc.status === "denied" - ? '' + - __("已拒绝", "Denied") + - "" - : running - ? '' + - __("调用中", "Running") + - "" - : '' + - __("完成", "Done") + - ""; - var pluginHtml = tc.plugin - ? '' + escHtml(tc.plugin) + "" - : ""; - tcs += - '
' + - '
' + - iconSvg + - '' + - escHtml(tc.tool || tc.name || "") + - "" + - pluginHtml + - statusHtml + - '' + RT_ICO.caret + "
" + - '
"; - }); - } - var body = rc + tcs; - var growCls = m._grow ? " grow-in" : ""; - if (m._grow) m._grow = false; - var isStreamingLast = i === msgs.length - 1 && streamingLast; - if (isStreamingLast) { - var liveRow = - '' + - (newPending.length - ? '' + pillHtml() + "" - : ""); - if (c) { - body += - '
' + - liveRow + - '
' + - c + - "
"; - c = ""; - } else { - body += '
' + liveRow + "
"; - } - } else if (c) { - body += - '
' + - c + - "
"; - } - if (role === "system") { - html += - '
' + - (c || "") + - "
"; - } else if (isChan) { - html += - '
' + - '
' + - chanLetter(m.source) + - "
" + - '
' + - escHtml(m.source) + - "
" + - body + - "
"; - } else { - var userAvatar = - ''; - var aiAvatar = '小宅'; - html += - '
' + - '
' + - (role === "user" ? userAvatar : aiAvatar) + - "
" + - '
' + - body + - "
" + - "
"; - } - }); - } - if (state.chatLoading && !streamingLast) { - var aiAvatarL = '小宅'; - html += - '
' + - aiAvatarL + - '
' + - '' + - (newPending.length - ? '' + pillHtml() + "" - : "") + - "
"; - } - // 重建前记住阅读位置:非粘底(用户正向上翻)时,innerHTML 重建后必须把位置还回去, - // 否则视口会被重置——这就是"聊天记录跳到顶部"的直接来源。 - var prevTop = msgsEl.scrollTop; - commitChatList(msgsEl, html); - if (state.chatStick !== false) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } else { - msgsEl.scrollTop = prevTop; - } - updateChatBadge(); - } - - function updateChatBadge() { - var badge = document.getElementById("chat-stage"); - if (!badge) return; - badge.textContent = state.chatStage || ""; - badge.style.display = "none"; - } - - // 流式渲染分发:流式增量(只更新最后一条正文)vs 全量重建 - var _rerenderTimer = null; - function rerenderChat(full) { - // 流式中且非强制全量:走增量路径(防抖合并 chunk,只更新最后一条消息节点) - if (!full && state.chatLoading) { - var msgs = state.messages; - var last = msgs.length ? msgs[msgs.length - 1] : null; - if (last && last.role === "assistant" && !last._final) { - if (_rerenderTimer) return; // 已有排程的增量更新 - _rerenderTimer = setTimeout(() => { - _rerenderTimer = null; - renderChatStreamChunk(); - }, 90); - return; - } - } - // 非流式(完成/工具/历史变化):全量重渲(含防抖合并) - if (_rerenderTimer) clearTimeout(_rerenderTimer); - _rerenderTimer = setTimeout(() => { - _rerenderTimer = null; - renderChat(); - if (full) { - renderChatStarmap(); - renderTerminals(); - renderCmdHistory(); - } - }, 90); - } - - // 流式增量渲染(移植自 GUI):仅更新最后一条 assistant 消息的正文与思考预览, - // 不重建 DOM。正文节流 parse(>200 字符或 >300ms 才 renderMd),小增量纯文本追加。 - function renderChatStreamChunk() { - var msgsEl = document.getElementById("chat-msgs"); - var last = state.messages.length - ? state.messages[state.messages.length - 1] - : null; - if (!msgsEl || !last) return; - var el = msgsEl.lastElementChild; - if (!el) { - renderChat(); - return; - } - var textEl = el.querySelector(".msg-bubble .text"); - var c = last.content || ""; - if (textEl && c) { - var now = Date.now(); - var lastParse = el.__lastParse || 0; - var lastLen = el.__lastLen || 0; - if (c.length - lastLen > 200 || now - lastParse > 300) { - textEl.innerHTML = renderMd(c); - el.__lastParse = now; - el.__lastLen = c.length; - } else { - var tail = c.slice(lastLen); - if (tail) textEl.appendChild(document.createTextNode(tail)); - el.__lastLen = c.length; - } - if (state.chatStick !== false) { - try { - msgsEl.scrollTop = msgsEl.scrollHeight; - } catch (e) {} - } - return; - } - // 思考预览更新(流式中折叠,只刷 preview 文本) - var rcPrev = el.querySelector(".reasoning-preview"); - if (rcPrev && last.reasoning_content) { - rcPrev.textContent = last.reasoning_content - .replace(/[\s\n]+/g, " ") - .slice(0, 60); - if (state.chatStick !== false) { - try { - msgsEl.scrollTop = msgsEl.scrollHeight; - } catch (e) {} - } - return; - } - // 结构变化兜底:全量 - renderChat(); - } - function toggleToolCall(el) { - var d = el.querySelector(".tc-detail"); - if (!d) return; - var open = d.style.display !== "none"; - d.style.display = open ? "none" : "block"; - if (open) { - el.classList.remove("open"); - } else { - el.classList.add("open"); - } - } - - function renderReasoningCard(text, isStreaming) { - var preview = - typeof marked !== "undefined" - ? text.replace(/[\s\n]+/g, " ").slice(0, 60) - : escHtml(text) - .replace(/<[^>]+>/g, " ") - .slice(0, 60); - return ( - '
' + - '
' + - '' + - '' + - (isStreaming - ? __("思考中...", "Thinking...") - : __("思考", "Thinking")) + - "" + - '' + RT_ICO.caret + "
" + - '
' + - (isStreaming - ? '
' + - escHtml(preview) + - '
' - : '
' + renderMd(text) + "
") + - "
" - ); - } - function toggleReasoning(el) { - var card = el.closest(".reasoning-card"); - if (!card) return; - var body = card.querySelector(".reasoning-body"); - if (!body) return; - var open = body.style.display !== "none"; - body.style.display = open ? "none" : "block"; - if (open) { - card.classList.remove("open"); - } else { - card.classList.add("open"); - } - } - - // renderChatStarmap:把星图挂到**当前活跃的容器**上。 - // 容器优先级:独立星图页签 > 总览页小图 > 聊天面板。 - // 同一套 three.js renderer 在容器间搬运 canvas,不重建场景。 - function renderChatStarmap() { - var cont = starmapActiveContainer(); - if (!cont) return; - if ( - window._THREE_FAILED || - (!window.THREE && window._THREE_FAILED !== undefined) - ) { - cont.innerHTML = - '

' + - __( - "3D 星图不可用(CDN 加载失败)", - "Star map unavailable (CDN load failed)", - ) + - "

"; - state.starmapInit = true; - state.starmapLoading = false; - return; - } - if (!window.THREE) { - cont.innerHTML = - '
'; - state.starmapInit = false; - state.starmapLoading = false; - return; - } - if (cont.querySelector("canvas")) { - var rect = cont.getBoundingClientRect(); - if (starmapRen && rect.width > 0) - starmapRen.setSize(rect.width, Math.max(rect.height, 250)); - return; - } - if (state.starmapInit) { - if (starmapRen) { - var rect = cont.getBoundingClientRect(); - if (rect.width > 0) - starmapRen.setSize(rect.width, Math.max(rect.height, 250)); - cont.appendChild(starmapRen.domElement); - starmapRen.domElement.style.display = "block"; - } else { - // starmapRen was destroyed (e.g. re-render cycle), restart - state.starmapInit = false; - state.starmapLoading = false; - } - return; - } - if (state.starmapLoading) return; - state.starmapLoading = true; - loadChatStarmapData(); - } - - async function loadChatStarmapData() { - try { - var resp = await api("/memory/graph"); - if ( - !resp || - !resp.success || - !resp.data || - !resp.data.nodes || - resp.data.nodes.length === 0 - ) { - document.getElementById("sm-container-chat").innerHTML = - '

' + - __("暂无记忆数据", "No memory data") + - "

"; - state.starmapInit = true; - state.starmapLoading = false; - return; - } - var d = resp.data; - starmapNodes = d.nodes || []; - starmapEdges = d.edges || []; - state.starmapInit = true; - state.starmapLoading = false; - initChatStarmap(); - } catch (e) { - document.getElementById("sm-container-chat").innerHTML = - '

' + - __("加载失败", "Load failed") + - "

"; - state.starmapInit = true; - state.starmapLoading = false; - } - } - - function initChatStarmap() { - // 容器可以是独立星图页签 / 总览页 / 聊天面板 —— 同一套 renderer - // 会在多个容器间搬运 canvas(appendChild 即可)。 - var cont = starmapActiveContainer(); - if (!cont) return; - var rect = cont.getBoundingClientRect(); - var w = Math.max(rect.width || 300, 100); - var h = Math.max(rect.height || 250, 100); - if (starmapRen) { - starmapRen.setSize(w, h); - // 换父节点前先把旧角标从旧容器里拨掉,否则它会残留在旧位置。 - if (starmapLabelEl && starmapLabelEl.parentElement === cont) { - starmapLabelEl.style.display = "none"; - } - cont.appendChild(starmapRen.domElement); - starmapRen.domElement.style.display = "block"; - return; - } - starmapScene = new THREE.Scene(); - starmapScene.fog = new THREE.FogExp2(0x0a0a1a, 0.015); - starmapCam = new THREE.PerspectiveCamera(60, w / h, 0.1, 2000); - starmapCam.position.set(0, 20, 40); - starmapRen = new THREE.WebGLRenderer({ antialias: true, alpha: true }); - starmapRen.setSize(w, h); - starmapRen.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - starmapRen.setClearColor(0x0a0a1a, 1); - cont.innerHTML = ""; - cont.appendChild(starmapRen.domElement); - starmapCtrl = new THREE.OrbitControls( - starmapCam, - starmapRen.domElement, - ); - starmapCtrl.enableDamping = true; - starmapCtrl.dampingFactor = 0.05; - starmapCtrl.rotateSpeed = 0.5; - starmapCtrl.zoomSpeed = 0.8; - var al = new THREE.AmbientLight(0x444466, 0.6); - starmapScene.add(al); - var dl = new THREE.DirectionalLight(0xffffff, 0.8); - dl.position.set(50, 100, 50); - starmapScene.add(dl); - createStarField(); - createNebula(); - // 共享几何(低规格):所有节点/光晕球复用这两份。 - starmapGeo = new THREE.SphereGeometry(0.5, 8, 6); - starmapGlowGeo = new THREE.SphereGeometry(1.0, 8, 6); - buildChatStarmapGraph(); - starmapRen.domElement.addEventListener("mousemove", onStarmapMove); - starmapRen.domElement.addEventListener("click", onStarmapClick); - window.addEventListener("resize", onStarmapResize); - if (starmapRaf) cancelAnimationFrame(starmapRaf); - starmapAnimate(); - // 活动数据源:/runtime 3s + /memory/graph/pulse 10s。 - // 只在星图真正初始化后启动,避免在隐藏页签空跑。 - starmapStartActivity(); - } - - // starmapStartActivity 启动两路活动轮询(幂等)。 - function starmapStartActivity() { - if (state.starmapPulseTimer) return; - starmapPullActivity(); - starmapPullPulse(); - state.starmapPulseTimer = setInterval(() => { - starmapPullActivity(); - }, 3000); - var pulseTimer = setInterval(() => { - starmapPullPulse(); - }, 10000); - // 两个 id 合并到一个字段会导致 setInterval 被覆盖,这里分开记。 - state.starmapActivityTimer = pulseTimer; - } - - function buildChatStarmapGraph() { - starmapNodeMeshes.forEach((m) => { - starmapScene.remove(m); - }); - starmapEdgeLines.forEach((l) => { - starmapScene.remove(l); - }); - starmapNodeMeshes = []; - starmapEdgeLines = []; - if (starmapNodes.length === 0) return; - // Calculate node degrees for leaf node detection - var nodeDegs = {}; - starmapNodes.forEach((n) => { - nodeDegs[n.id] = 0; - }); - starmapEdges.forEach((e) => { - nodeDegs[e.source_id] = (nodeDegs[e.source_id] || 0) + 1; - nodeDegs[e.target_id] = (nodeDegs[e.target_id] || 0) + 1; - }); - var nodeMap = {}; - starmapNodes.forEach((n) => { - nodeMap[n.id] = n; - }); - var sorted = starmapNodes.slice().sort((a, b) => (b.mention_count || 0) - (a.mention_count || 0)); - var mc = sorted.map((n) => n.mention_count || 0); - var maxMc = Math.max(...mc, 1), - minMc = Math.min(...mc, 0), - rng = maxMc - minMc || 1; - // Layout positions - var pos = {}; - var baseR = 15, - maxR = 80; - var total = sorted.length; - var acc = 0; - sorted.forEach((n, i) => { - var m = n.mention_count || 0, - mn = rng > 0 ? (m - minMc) / rng : 0; - var radius = baseR + mn * (maxR - baseR); - var baseStep = (Math.PI * 2) / total; - var extra = mn * baseStep * 2; - var angle = acc + extra / 2; - acc += baseStep + extra; - pos[n.id] = { - x: radius * Math.cos(angle), - y: (Math.random() - 0.5) * (10 + mn * 20), - z: radius * Math.sin(angle), - mn: mn, - rad: radius, - }; - }); - // Leaf nodes (degree 1) reposition near parent - sorted.forEach((n) => { - var deg = nodeDegs[n.id] || 0; - if (deg !== 1) return; - var edge = starmapEdges.find((e) => e.source_id === n.id || e.target_id === n.id); - if (!edge) return; - var parentId = - edge.source_id === n.id ? edge.target_id : edge.source_id; - if (!pos[parentId]) return; - var pp = pos[parentId]; - var m = n.mention_count || 0, - mn = rng > 0 ? (m - minMc) / rng : 0; - var off = 6 + mn * 8 + Math.random() * 4; - var a2 = Math.random() * Math.PI * 2; - pos[n.id] = { - x: pp.x + off * Math.cos(a2), - y: pp.y + (Math.random() - 0.5) * (4 + mn * 6), - z: pp.z + off * Math.sin(a2), - mn: mn, - rad: off, - }; - }); - // Force-directed simulation - for (var it = 0; it < 50; it++) { - var ids = Object.keys(pos); - // Repulsion - for (var i = 0; i < ids.length; i++) { - for (var j = i + 1; j < ids.length; j++) { - var a = pos[ids[i]], - b = pos[ids[j]]; - var dx = a.x - b.x, - dy = a.y - b.y, - dz = a.z - b.z, - d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; - var rf = 0.5 + (a.mn + b.mn) * 0.5; - if (d < 25) { - var force = (0.06 * rf) / Math.max(d, 0.5); - a.x += (dx / d) * force; - a.y += (dy / d) * force; - a.z += (dz / d) * force; - b.x -= (dx / d) * force; - b.y -= (dy / d) * force; - b.z -= (dz / d) * force; - } - } - } - // Attraction along edges - starmapEdges.forEach((e) => { - var a = pos[e.source_id], - b = pos[e.target_id]; - if (!a || !b) return; - var dx = b.x - a.x, - dy = b.y - a.y, - dz = b.z - a.z, - d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; - var af = Math.max(0.3, 1.0 - (a.mn + b.mn) * 0.3); - if (d > 20) { - var force = 0.04 * af; - a.x += (dx / d) * force; - a.y += (dy / d) * force; - a.z += (dz / d) * force; - b.x -= (dx / d) * force; - b.y -= (dy / d) * force; - b.z -= (dz / d) * force; - } - }); - // Centering constraint - ids.forEach((id) => { - var p = pos[id]; - var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z); - var maxA = maxR * 1.5; - if (dist > maxA) { - var s = maxA / dist; - p.x *= s; - p.y *= s; - p.z *= s; - } - }); - } - // Create nodes - // - // ★ 低规格渲染(1151 节点实测后定的方案):保留**全部**节点, - // 但把每节点开销从「一个独立球 + 一个独立球光晕 + 一张 256x64 - // 文字贴图」降到「共享几何 + 共享材质 + 无常驻文字」。 - // - // 改之前的实际开销(生产实例 1151 节点): - // - SphereGeometry(16,12) 主球 + 同样规格的光晕球 = 2302 个独立 - // BufferGeometry,共约 88 万三角形 - // - 每节点一张 256x64 CanvasTexture = 1151 张 + - // 1151 个纹理,仅文字就吃约 72MB 显存 - // 这两样在「星图只是个展示」的前提下纯属浪费:星图全图远看根本 - // 读不清标签(256x64 贴在半径 0.5~2.5 的球上,本来就糊)。 - // - // 现在:共享一份 SphereGeometry(8,6)(约 84 三角形/节点), - // 颜色靠每 mesh 的 material.color(材质本身仍每节点一份, - // 因为 MeshPhongMaterial 要独立发光强度才能做活动脉冲)。 - // 标签改为「hover / 选中时才在容器角上显示 HTML 文本」—— - // 文字清晰度反而比 3D 贴图好,且零显存。 - starmapNodes.forEach((n) => { - var p = pos[n.id]; - if (!p) return; - var mn = n.mention_count || 0, - mnr = rng > 0 ? (mn - minMc) / rng : 0; - var rad = 0.5 + mnr * 2.0; - // 服务端 type 是首字母大写("Concept" / "Person" …), - // 原 smTypeColors 的键全是小写,永远匹配不上 ⇒ 全图单色 0xcccccc。 - // 这里统一小写归一化,并补上服务端实际会产出的类型。 - var col = smTypeColors[String(n.type || "").toLowerCase()] || 0xcccccc; - var ei = 0.3 + mnr * 0.7; - var mat = new THREE.MeshPhongMaterial({ - color: col, - emissive: col, - emissiveIntensity: ei, - shininess: 30, - }); - var mesh = new THREE.Mesh(starmapGeo, mat); - mesh.position.set(p.x, p.y, p.z); - mesh.scale.setScalar(rad / 0.5); // 共享几何半径 0.5,按需缩放 - mesh.userData.nodeData = n; - mesh.userData.nodeId = n.id; - mesh.userData.baseEmissive = ei; - mesh.userData.baseScale = rad / 0.5; - mesh.userData.baseGlow = 0.12 + mnr * 0.08; - // Glow sphere:共享几何 + 各自材质(发光强度要独立才能做脉冲) - var gm = new THREE.MeshBasicMaterial({ - color: col, - transparent: true, - opacity: mesh.userData.baseGlow, - side: THREE.BackSide, - blending: THREE.AdditiveBlending, - }); - var gs = new THREE.Mesh(starmapGlowGeo, gm); - gs.scale.setScalar(rad * 1.2 + mnr * 0.5); - mesh.add(gs); - mesh.userData.glowSphere = gs; - // 名字不再烘成贴图;hover 时由 onStarmapMove 写进角标。 - starmapScene.add(mesh); - starmapNodeMeshes.push(mesh); - }); - // Create edges - // - // 866 条边原本每条一个 BufferGeometry + Line + LineBasicMaterial - // (866 个 draw call)。改为**按关系类型分组**的少量 LineSegments: - // 同类型边合并成一个几何体,draw call 从 866 降到「关系类型数」 - // (生产实例实测 6 种左右)。 - // - // 副作用:单条边不再能单独点选。星图此前也没有点选边的交互 - // (onStarmapClick 只处理节点),所以这是纯粹的成本削减。 - var edgeByColor = {}; - starmapEdges.forEach((e) => { - var a = pos[e.source_id], - b = pos[e.target_id]; - if (!a || !b) return; - var col = - smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466; - var k = String(col); - if (!edgeByColor[k]) edgeByColor[k] = { col: col, pts: [] }; - edgeByColor[k].pts.push(a.x, a.y, a.z, b.x, b.y, b.z); - }); - Object.keys(edgeByColor).forEach((k) => { - var g = edgeByColor[k]; - if (!g.pts.length) return; - var geo = new THREE.BufferGeometry(); - geo.setAttribute( - "position", - new THREE.Float32BufferAttribute(g.pts, 3), - ); - var mat = new THREE.LineBasicMaterial({ - color: g.col, - transparent: true, - opacity: 0.4, - }); - var seg = new THREE.LineSegments(geo, mat); - starmapScene.add(seg); - starmapEdgeLines.push(seg); - }); - } - - // 回合收尾:由 SSE 事件(agent_output final / reset 帧)或 watchdog 驱动。 - // POST 结束 ≠ 回合结束:agent 可能还在生成(排队+长生成),提前复位 - // chatLoading 会让后续 delta 走全量重建、停止按钮消失、用户误发重复消息。 - function endChatTurn() { - if (!state.chatLoading) return; - state.chatLoading = false; - state.chatStage = ""; - if (state._turnWatchdog) { - clearTimeout(state._turnWatchdog); - state._turnWatchdog = null; - } - var btn = document.getElementById("chat-send-btn"); - if (btn) { - btn.disabled = false; - btn.textContent = __("发送", "Send"); - } - var sb = document.getElementById("chat-stop-btn"); - if (sb) sb.style.display = "none"; - rerenderChat(true); - } - - // 回合看门狗:POST 已 abort 且 SSE 迟迟无终帧时兕底收尾(连接不稳/事件丢失), - // 提示用户回复可能已生成、可刷新查看历史。避免回合永久卡在 loading。 - function armTurnWatchdog() { - if (state._turnWatchdog) clearTimeout(state._turnWatchdog); - state._turnWatchdog = setTimeout(() => { - state._turnWatchdog = null; - if (state.chatLoading) { - endChatTurn(); - toast( - __( - "长时间未收到回复,连接可能不稳定;回复可能已生成,可刷新页面查看", - "No reply received for a long time; the reply may have been generated, refresh to check", - ), - true, - ); - } - }, 120000); - } - - // 上传文件并注入 agent:multipart POST /api/v1/chat/file。 - // 服务端落盘 uploads/ 后注入「[用户发送了文件: 名字 (大小)] 已保存到 <路径>」; - // 回复与普通消息一样走 SSE 流式渲染。可选附言从输入框读取。 - // 拖拽文件到聊天区即发送(可选:先在输入框写附言) - function initChatDragDrop() { - var panel = document.getElementById("chat-panel-chat"); - if (!panel || panel.__dnd) return; - panel.__dnd = true; - panel.addEventListener("dragover", (e) => { - e.preventDefault(); - panel.style.outline = "2px dashed var(--accent, #4a90d9)"; - }); - panel.addEventListener("dragleave", () => { - panel.style.outline = ""; - }); - panel.addEventListener("drop", (e) => { - e.preventDefault(); - panel.style.outline = ""; - if (e.dataTransfer.files && e.dataTransfer.files.length) { - sendChatFile(e.dataTransfer.files[0]); - } - }); - // 粘贴截图/复制的文件直接发送 - document.addEventListener("paste", (e) => { - var chatVisible = - document.getElementById("chat-panel-chat") && - document.getElementById("chat-input"); - if (!chatVisible) return; - var items = e.clipboardData && e.clipboardData.files; - if (items && items.length && document.activeElement !== null) { - sendChatFile(items[0]); - } - }); - } - - async function sendChatFile(fileObj, extraText) { - if (!fileObj || state.chatLoading) return; - var inp = document.getElementById("chat-input"); - var message = (extraText || inp.value || "").trim(); - if (inp) inp.value = ""; - var fd = new FormData(); - fd.append("file", fileObj); - if (message) fd.append("message", message); - state.messages.push({ - role: "user", - content: message, - attachment: { - // 与服务端的 attType 判定保持一致(image/audio/file), - // 否则乐观渲染的卡片会在 SSE 回流后变成另一种样式。 - type: /^image\//.test(fileObj.type) - ? "image" - : /^audio\//.test(fileObj.type) - ? "audio" - : "file", - url: URL.createObjectURL(fileObj), - name: fileObj.name, - size: fileObj.size, - }, - }); - rerenderChat(true); - state.chatLoading = true; - state.chatStage = __("等待AI回复...", "Waiting for AI..."); - rerenderChat(true); - try { - await api("/chat/file", { method: "POST", body: fd, rawBody: true }); - // 回复经 SSE 流式到达,这里无需处理响应体 - } catch (e) { - toast( - __("文件发送失败:" + e.message, "File send failed: " + e.message), - true, - ); - state.chatLoading = false; - endChatTurn(); - } - } - - async function sendChat() { - var inp = document.getElementById("chat-input"); - var btn = document.getElementById("chat-send-btn"); - var stopBtn = document.getElementById("chat-stop-btn"); - var text = inp.value.trim(); - if (!text || state.chatLoading) return; - state.chatStick = true; - state.chatFinalIdx = -1; - state.messages.push({ role: "user", content: text }); - inp.value = ""; - rerenderChat(true); - state.chatLoading = true; - state.chatStage = __("等待AI回复...", "Waiting for AI..."); - btn.disabled = true; - btn.textContent = ""; - if (stopBtn) stopBtn.style.display = ""; // 生成期间可停止 - rerenderChat(true); - // 触发式 POST:短超时仅确认受理;回复靠 SSE 流式渲染(对齐 GUI 行为)。 - try { - var ctrl = new AbortController(); - var ackTimer = setTimeout(() => { - ctrl.abort(); - }, 15000); - var r = null; - try { - r = await api("/chat", { - method: "POST", - body: JSON.stringify({ message: text }), - signal: ctrl.signal, - }); - } catch (ackErr) { - // 同步超时/失败:不阻塞 UI,等 SSE 兑底;明确提示"可能已发送" - console.warn("[sendChat] trigger failed: " + ackErr.message); - toast( - __( - "请求超时(可能已发送,请稍候或在收到回复前勿重复发送)", - "Request timeout (may have been sent; wait for reply before resending)", - ), - true, - ); - r = null; - } finally { - clearTimeout(ackTimer); - } - var last = state.messages[state.messages.length - 1]; - if (r && r.response) { - if (last && last.role === "assistant" && last._streaming) { - last.content = r.response || __("(无响应)", "(no response)"); - last._grow = true; - if (!last.reasoning_content) { - last.reasoning_content = r.reasoning_content || ""; - } - last._final = true; - delete last._streaming; - } else { - state.messages.push({ - role: "assistant", - content: r.response || __("(无响应)", "(no response)"), - reasoning_content: r.reasoning_content, - tool_calls: - last && last.role === "assistant" && last.tool_calls - ? last.tool_calls - : [], - _final: true, - _grow: true, - }); - } - state.chatFinalIdx = state.messages.length - 1; - } - rerenderChat(true); - } catch (e) { - // 真实错误(非受理超时):展示错误信息 - if (!String(e.message || "").includes("aborted")) { - state.messages.push({ - role: "assistant", - content: __("错误: ", "Error: ") + e.message, - _final: true, - }); - rerenderChat(true); - toast(__("请求失败: ", "Request failed: ") + e.message, true); - } - } finally { - if (r && r.response) { - // 同步兜底已拿到完整回复:回合结束 - endChatTurn(); - } else { - // 触发式受理(POST 已 abort/失败):回合仍打开,等 SSE 流式渲染; - // 由 agent_output final / reset 帧 / watchdog 收尾 - armTurnWatchdog(); - } - } - } - - // 停止生成 / 发送中断消息。核心拦截语义:有 LLM 在跑则取消当前 - // 请求并以 [中断消息] 重启轮次;无则在跑则作为普通消息处理。 - async function interruptChat() { - try { - await api("/chat/interrupt", { - method: "POST", - body: JSON.stringify({}), - }); - toast(__("已发送中断信号", "Interrupt signal sent")); - } catch (e) { - toast(__("中断失败: ", "Interrupt failed: ") + e.message, true); - } - } - - async function queryMemoryChat() { - var q = document.getElementById("mem-query")?.value; - var r = document.getElementById("mem-result-chat"); - if (!r || !q) return; - r.innerHTML = '
'; - try { - var data = await api( - "/memory?q=" + encodeURIComponent(q) + "&depth=2", - ); - r.innerHTML = - '
' +
-            escHtml(JSON.stringify(data, null, 2)) +
-            "
"; - } catch (e) { - r.innerHTML = - '

' + - __("查询失败: ", "Query failed: ") + - escHtml(e.message) + - "

"; - } - } - - async function queryMemoryContext() { - var q = document.getElementById("ctx-query")?.value; - var r = document.getElementById("ctx-result"); - if (!r) return; - r.innerHTML = '
'; - try { - var data = await api( - "/memory/context?q=" + encodeURIComponent(q || ""), - ); - var ctx = data?.context || __("无上下文", "No context"); - var summary = data?.summary || ""; - var entities = data?.entities || []; - var tk = data?.token_estimate || 0; - var html = '
'; - if (summary) - html += - '
' + - __("摘要", "Summary") + - '' + - escHtml(summary) + - "
"; - html += - '
Token ' + - __("预估", "Estimate") + - '' + - tk + - "
"; - if (entities.length) { - html += - '
' + - __("实体", "Entities") + - '' + - entities - .map((e) => escHtml(e.name || e.id || "")) - .join(", ") + - "
"; - } - html += - '
' +
-            escHtml(ctx) +
-            "
"; - r.innerHTML = html; - } catch (e) { - r.innerHTML = - '

' + - __("获取失败: ", "Get failed: ") + - escHtml(e.message) + - "

"; - } - } - - // 知识库面板:搜索 / 创建 / 删除 / 刷新计数。 - // - // 此前三处问题:搜索把裸 JSON 直接 stringify 丢进
(用户看到一坨
-      // 机器码);创建后不刷新计数(1644 行读的是 state.kernel 快照,创建
-      // 完仍是旧值);没有任何删除入口,也没有媒体上传。
-      function knowEsc(v) {
-        return String(v == null ? "" : v)
-          .replace(/&/g, "&")
-          .replace(//g, ">");
-      }
-
-      async function refreshKnowledgeCount() {
-        try {
-          var d = await api("/knowledge");
-          var el = document.getElementById("know-count");
-          if (el && d && d.names) el.textContent = d.names.length;
-          // 稠密路(多模态)状态提示
-          var hint = document.getElementById("know-media-hint");
-          if (hint && d && d.dense) {
-            if (d.dense.enabled) {
-              hint.textContent =
-                __("多模态已就绪 ", "Multimodal ready ") +
-                (d.dense.ready || 0) +
-                "/" +
-                ((d.dense.ready || 0) + (d.dense.stale || 0));
-            } else {
-              hint.textContent = __(
-                "未接入多模态:图片只记录不参与召回",
-                "Multimodal off: images recorded but not searchable",
-              );
-            }
-          }
-        } catch (e) {
-          /* 计数刷新失败不该打断用户操作 */
-        }
-      }
-
-      function renderKnowledgeResults(views, q) {
-        if (!views || !views.length) {
-          return (
-            '

' + - __("未找到相关知识", "No matching knowledge") + - "

" - ); - } - var html = ""; - views.forEach((v) => { - html += '
'; - html += '
'; - html += '' + knowEsc(v.name) + ""; - if (v.size) { - html += '' + v.size + " B"; - } - html += - '"; - html += "
"; - if (v.media && v.media.length) { - html += '
'; - v.media.forEach((m) => { - var kind = m.kind || "file"; - html += - '[' + knowEsc(kind) + "] " + knowEsc(m.digest.slice(0, 8)) + "…"; - }); - html += "
"; - } - if (v.preview) { - html += - '
' + - knowEsc(v.preview) + - "
"; - } - html += "
"; - }); - return html; - } - - // 知识库分类树浏览。 - // - // 数据来自 /knowledge/tree(只读树接口)。当前分类存 state, - // 搜索时自动带上 —— 这样"先定位分类再检索"这个更准的用法在 UI 上 - // 是一步的事,而不是要求用户手打分类名。 - var _knowCat = ""; // 当前分类的完整 path;"" = 全库 - - function renderKnowTree(node, depth) { - var html = ""; - // 本节点的条目 - (node.items || []).forEach((it) => { - html += - '
' + - '▸ ' + - '' + - knowEsc(it.name) + - "" + - (it.size ? ' ' + it.size + "B" : "") + - (it.media && it.media.length - ? ' [' + it.media.length + __(" 媒体", " media") + "]" - : "") + - "
"; - }); - // 子分类 - (node.children || []).forEach((c) => { - var isCur = _knowCat === c.path; - var mark = isCur ? "● " : ""; - html += - '
' + - mark + - knowEsc(c.name) + - ' ' + - c.item_count + - "/" + - c.total_count + - "
"; - html += renderKnowTree(c, depth + 1); - }); - return html; - } - - async function loadKnowledgeTree() { - var box = document.getElementById("know-tree"); - if (!box) return; - box.innerHTML = '
'; - try { - var d = await api("/knowledge/tree?items=1"); - if (!d || !d.tree) { - box.innerHTML = - '

' + __("暂无分类", "No categories yet") + "

"; - return; - } - box.innerHTML = renderKnowTree(d.tree, 0); - } catch (e) { - box.innerHTML = - '

' + __("加载分类失败: ", "Load categories failed: ") + knowEsc(e.message) + "

"; - } - } - - function selectKnowledgeCategory(path) { - _knowCat = path || ""; - var lbl = document.getElementById("know-cat-scope"); - if (lbl) { - lbl.textContent = _knowCat - ? __("范围:", "scope: ") + _knowCat - : __("范围:全库", "scope: all"); - } - loadKnowledgeTree(); - // 立刻按新范围搜一次,省一次点击 - var q = document.getElementById("know-query")?.value; - if (q) searchKnowledgeChat(); - } - - function clearKnowledgeCategory() { - selectKnowledgeCategory(""); - } - - async function searchKnowledgeChat() { - var q = document.getElementById("know-query")?.value; - var cat = _knowCat || ""; - var r = document.getElementById("know-result-chat"); - if (!r) return; - if (!q) { - r.innerHTML = - '

' + - __("请输入查询关键词", "Enter a keyword") + - "

"; - return; - } - r.innerHTML = '
'; - try { - var url = "/knowledge?q=" + encodeURIComponent(q); - if (cat) url += "&category=" + encodeURIComponent(cat); - var data = await api(url); - r.innerHTML = renderKnowledgeResults(data && data.results, q); - } catch (e) { - r.innerHTML = - '

' + __("搜索失败: ", "Search failed: ") + knowEsc(e.message) + "

"; - } - } - - async function deleteKnowledge(name) { - if ( - !confirm( - __("确定删除知识「", "Delete knowledge \"") + name + __("」?此操作不可撤销。", "\"? This cannot be undone."), - ) - ) { - return; - } - try { - await api("/knowledge?name=" + encodeURIComponent(name), { method: "DELETE" }); - toast(__("知识「", 'Knowledge "') + name + __("」已删除", '" deleted')); - refreshKnowledgeCount(); - searchKnowledgeChat(); - } catch (e) { - toast(__("删除失败: ", "Delete failed: ") + e.message, true); - } - } - - async function createKnowledgeChat() { - var name = document.getElementById("know-name")?.value; - var content = document.getElementById("know-content")?.value; - var fileInput = document.getElementById("know-media"); - var files = fileInput && fileInput.files ? fileInput.files : null; - if (!name) { - toast(__("名称不能为空", "Name is required"), true); - return; - } - if ((!content || !content.trim()) && (!files || !files.length)) { - toast( - __("内容与媒体至少要有一项", "Content or media is required"), - true, - ); - return; - } - try { - var r; - if (files && files.length) { - // 有文件走 multipart:服务端按**探测到的真实类型**分流, - // 图片/音视频入媒体库并按 digest 挂到条目上,文本存正文。 - var fd = new FormData(); - fd.append("name", name); - if (content) fd.append("content", content); - for (var i = 0; i < files.length; i++) fd.append("file", files[i]); - r = await api("/knowledge", { method: "POST", body: fd }); - } else { - r = await api("/knowledge", { - method: "POST", - body: JSON.stringify({ name: name, content: content }), - }); - } - var msg = __("知识「", 'Knowledge "') + name + __("」已创建", '" created'); - if (r && r.media) msg += __(",含 ", " with ") + r.media + __(" 个媒体", " media item(s)"); - if (r && r.rejected && r.rejected.length) { - msg += __(";", "; ") + r.rejected.length + __(" 项被跳过", " skipped"); - } - toast(msg); - document.getElementById("know-name").value = ""; - document.getElementById("know-content").value = ""; - if (fileInput) fileInput.value = ""; - refreshKnowledgeCount(); - } catch (e) { - toast(__("创建失败: ", "Create failed: ") + e.message, true); - } - } - - function switchChatPanel(tab, el) { - // 切到知识面板时拉实时计数:面板里的数字来自 state.kernel 快照, - // 而知识条目会经工具/上传增删,快照不会自己变(实测创建后仍显示 "-")。 - if (tab === "knowledge") { - refreshKnowledgeCount(); - var lbl = document.getElementById("know-cat-scope"); - if (lbl && !lbl.textContent) { - lbl.textContent = _knowCat ? __("范围:", "scope: ") + _knowCat : __("范围:全库", "scope: all"); - } - loadKnowledgeTree(); - } - var panels = { - chat: document.getElementById("chat-panel-chat"), - starmap: document.getElementById("chat-panel-starmap"), - terminal: document.getElementById("chat-panel-terminal"), - cmd: document.getElementById("chat-panel-cmd"), - memory: document.getElementById("chat-panel-memory"), - context: document.getElementById("chat-panel-context"), - knowledge: document.getElementById("chat-panel-knowledge"), - }; - Object.keys(panels).forEach((k) => { - var p = panels[k]; - if (p) p.classList.toggle("active", k === tab); - }); - if (el) { - var parent = el.parentElement; - if (parent) { - Array.from(parent.children).forEach((ch) => { - ch.classList.remove("active"); - }); - el.classList.add("active"); - } - } - if (tab === "starmap") { - if (!state.starmapInit) { - state.starmapLoading = false; - renderChatStarmap(); - } else if (starmapRen) { - var cont = document.getElementById("sm-container-chat"); - if (cont) { - var rect = cont.getBoundingClientRect(); - if (rect.width > 0) - starmapRen.setSize(rect.width, Math.max(rect.height, 250)); - if (!cont.contains(starmapRen.domElement)) - cont.appendChild(starmapRen.domElement); - } - } - } - if (tab === "chat") { - state.chatStick = true; - var msgsEl = document.getElementById("chat-msgs"); - if (msgsEl) { - try { - msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: "smooth" }); - } catch (e) { - msgsEl.scrollTop = msgsEl.scrollHeight; - } - } - } - if (tab === "context") queryMemoryContext(); - } - - // 首屏分段加载条数:只拉最新 N 条,向上滚动触顶时再拉更早的。 - var CHAT_PAGE_SIZE = 40; - async function loadChatHistory() { - try { - var data = await api("/chat/history?limit=" + CHAT_PAGE_SIZE); - if (data && data.messages) { - state.messages = data.messages; - state.chatOffset = typeof data.offset === "number" ? data.offset : 0; - state.chatTotal = typeof data.total === "number" ? data.total : data.messages.length; - state.chatHasMore = !!data.has_more; - state.chatLastSeq = historyLastSeq(data); - } - } catch (e) {} - } - - // historyLastSeq 从一次 /chat/history 响应里取出「已见到的最大 seq」: - // 优先用服务端给的 last_seq,缺了就取消息里的最大值。 - function historyLastSeq(data) { - if (!data) return 0; - if (typeof data.last_seq === "number") return data.last_seq; - var mx = 0; - (data.messages || []).forEach((m) => { - if (m && m.seq > mx) mx = m.seq; - }); - return mx; - } - - // applyServerMessages 把服务端消息并进 state.messages,按 seq 对账: - // - 同 seq 已存在 → 原地替换(工具调用/最终文本是原地更新); - // - 不存在 → 追加(若末尾是无 seq 的乐观消息且 role+content 一致,则替换它, - // 避免"自己刚发的那条"重复成两条)。 - // tailOnly=true 时只做原地更新与"比本地新才追加",不把尾探测当成新消息。 - // @returns {boolean} 是否真的改动了 state.messages - function applyServerMessages(list, tailOnly) { - if (!list || !list.length) return false; - var msgs = state.messages; - var changed = false; - function carry(prev, sm) { - if (prev && prev._final) sm._final = true; - if (prev && prev._grow) sm._grow = true; - return sm; - } - list.forEach((sm) => { - if (!sm) return; - var seq = sm.seq; - var found = -1; - for (var j = msgs.length - 1; j >= 0 && j >= msgs.length - 12; j--) { - if (seq && msgs[j] && msgs[j].seq === seq) { - found = j; - break; - } - } - if (found >= 0) { - if (JSON.stringify(msgs[found]) !== JSON.stringify(sm)) { - msgs[found] = carry(msgs[found], sm); - changed = true; - } - return; - } - if (tailOnly) { - var lastS = msgs.length ? msgs[msgs.length - 1].seq : 0; - if (seq && (!lastS || seq > lastS)) { - msgs.push(sm); - changed = true; - } - return; - } - if (msgs.length) { - var last = msgs[msgs.length - 1]; - if ( - !last.seq && - (last.role || "") === (sm.role || "") && - (last.content || "") === (sm.content || "") - ) { - msgs[msgs.length - 1] = carry(last, sm); - changed = true; - return; - } - } - msgs.push(sm); - changed = true; - }); - list.forEach((sm) => { - if (sm && sm.seq > (state.chatLastSeq || 0)) state.chatLastSeq = sm.seq; - }); - if (changed) rerenderChat(); - return changed; - } - - // pollChatIncremental 轮询「自上次以来新增了什么」。 - // - // 这就是「暴露数据查询 api,前端轮询后 patch 视图」那条路:after=游标 - // 只拿增量,再单独探一次尾部做原地更新(工具调用/最终文本是原地改的, - // 不会产生新 seq,只靠 after 拿不到)。视图更新走 commitChatList 的 - // keyed 对账,未变消息节点一个字节都不动 —— 闪烁由此消失。 - function pollChatIncremental() { - var after = state.chatLastSeq || 0; - if (!after) { - // 还没建立游标(首次 / 本地为空):退回一次性全量,交给已有一致性逻辑 - return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then((data) => { - state.chatLastSeq = historyLastSeq(data); - return mergeChatFromHistory(data); - }); - } - return api("/chat/history?after=" + after) - .then((data) => { - if (!data) return; - state.chatLastSeq = historyLastSeq(data) || after; - applyServerMessages(data.messages || [], false); - return api("/chat/history?limit=1"); - }) - .then((tail) => { - if (tail && tail.messages && tail.messages.length) { - applyServerMessages(tail.messages.slice(-1), true); - } - }); - } - - // loadOlderChat 向上翻页:拉 offset 之前的一页,前置到 messages 头部。 - // 保持滚动位置(插入前后 scrollHeight 差值补偿),避免视口跳动。 - var _loadingOlder = false; - async function loadOlderChat() { - if (_loadingOlder || !state.chatHasMore) return; - _loadingOlder = true; - var msgsEl = document.getElementById("chat-msgs"); - var prevH = msgsEl ? msgsEl.scrollHeight : 0; - var prevTop = msgsEl ? msgsEl.scrollTop : 0; - try { - var before = state.chatOffset || 0; - if (before <= 0) { - state.chatHasMore = false; - return; - } - var data = await api( - "/chat/history?limit=" + CHAT_PAGE_SIZE + "&before=" + before, - ); - if (data && data.messages && data.messages.length) { - state.messages = data.messages.concat(state.messages); - state.chatOffset = typeof data.offset === "number" ? data.offset : 0; - state.chatHasMore = !!data.has_more; - state.chatStick = false; // 向上翻页时不自动粘底 - renderChat(); - if (msgsEl) { - // 补偿新增内容高度,保持用户视觉位置不变 - msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH); - } - } else { - state.chatHasMore = false; - } - } catch (e) { - } finally { - _loadingOlder = false; - } - } - - // syncChatFromHistory 增量同步:先做一次极轻的"尾巴探测",只有尾巴变了才拉整页。 - // - // 原先每 30s(以及每次 SSE 报错)都直接拉一页 40 条:本地实测 180KB、 - // 生产消息更大时可达 ~1MB —— 这是"每次都在发完整聊天记录"的观感来源。 - // 探测只需 1 条(约几 KB),尾巴一致就直接跳过。 - var _syncingChat = false; - // syncChatFromHistory 保留旧名(多处调用点):内部改走游标增量轮询。 - function syncChatFromHistory() { - if (_syncingChat) return Promise.resolve(); - _syncingChat = true; - return pollChatIncremental() - .catch(() => {}) - .then(() => { - _syncingChat = false; - }); - } - - // mergeChatFromHistory 把服务端的一页历史并进本地:只追加新消息,不重建已有节点。 - // 关键约束:**绝不**用更短的服务端页替换更长的本地列表(那会让用户翻上来的旧页 - // 凭空消失、视口跳回顶部)。 - function mergeChatFromHistory(data) { - if (!data || !data.messages || data.messages.length === 0) return; - var serverMsgs = data.messages; - var localMsgs = state.messages; - // 首次加载(空列表)→ 全量赋值 - if (localMsgs.length === 0) { - state.messages = serverMsgs; - state.chatOffset = typeof data.offset === "number" ? data.offset : 0; - state.chatHasMore = !!data.has_more; - rerenderChat(true); - return; - } - // 以「本地末尾 vs 服务端末尾」做依据。分段拉取只会回传最新页, - // 本地已向上翻页加载了更早内容,长度上本地可能 ≥ 服务端。 - var lastLocal = localMsgs[localMsgs.length - 1]; - var lastServer = serverMsgs[serverMsgs.length - 1]; - var localContent = lastLocal.content || lastLocal.Content || ""; - var serverContent = lastServer.content || lastServer.Content || ""; - // 服务端末尾和本地末尾内容相同 → 无新增(可能裁剪过的轻量项因不含 tool_calls args/result, - // 依赖 content+role+time 足以识别) - if (localContent === serverContent) { - // 检查是否本地末尾被服务端改写(如 stage 插件改写最终文本) - if (lastServer.role === "assistant" && localContent !== serverContent && serverContent) { - lastLocal.content = serverContent; - if (lastServer.ReasoningContent) lastLocal.reasoning_content = lastServer.ReasoningContent; - var msgsEl0 = document.getElementById("chat-msgs"); - if (msgsEl0 && msgsEl0.lastElementChild) { - var textEl0 = msgsEl0.lastElementChild.querySelector(".msg-bubble .text"); - if (textEl0) textEl0.innerHTML = renderMd(serverContent); - } - } - return; - } - // 有新增消息:服务端末尾与本地末尾不同 → 找出本地新增的差异,追加到 DOM。 - // 服务端只回传最新页,我们需在 localMsgs 末尾取交集起点(本地末尾在服务端页的位置) - // 简化:取服务端末尾3条与本地末尾3条比对,找到重含点的下标;若找不到全量替换最新页。 - var overlap = -1; - for (var k = Math.min(3, serverMsgs.length - 1, localMsgs.length - 1); k >= 1; k--) { - var sMsg = serverMsgs[serverMsgs.length - 1 - k]; - var lMsg = localMsgs[localMsgs.length - 1 - k]; - if (lMsg && (lMsg.content || "") === (sMsg.content || "") && lMsg.role === sMsg.role) { - overlap = k; - break; - } - } - var newMsgs; - if (overlap >= 0) { - // 本地末尾 overlap 条在服务端页里,之后的部分是新增 - newMsgs = serverMsgs.slice(serverMsgs.length - overlap); - if (newMsgs.length === 0) return; // 无新增(内容改写走上面的分支) - } else { - // 找不到重合点:**不能**直接拿服务端页覆盖本地。 - // 服务端只回一页,本地翻上来的旧页更长;覆盖会同时造成两个后果: - // 用户翻过的旧消息凭空消失、容器变矮后视口被夹回顶部。 - // 只有在服务端页不短于本地时才整体替换(那种情况下不丢内容)。 - if (serverMsgs.length >= localMsgs.length) { - state.messages = serverMsgs; - rerenderChat(true); - } - return; - } - var msgsEl = document.getElementById("chat-msgs"); - if (msgsEl) { - var aiAvatar = '小宅'; - var userAvatar = ''; - newMsgs.forEach((m) => { - var role = m.role || m.Role || "user"; - var c = m.content || m.Content || ""; - if (role === "assistant") c = renderMd(c); else c = escHtml(c); - var isChan = !!(m.source && m.source !== "webui"); - var bubble = c ? '
' + c + '
' : '
'; - if (role === "system") { - msgsEl.insertAdjacentHTML("beforeend", '
' + c + '
'); - } else if (isChan) { - msgsEl.insertAdjacentHTML("beforeend", '
' + (m.source||"?")[0].toUpperCase() + '
' + escHtml(m.source) + '
' + bubble + '
'); - } else { - msgsEl.insertAdjacentHTML("beforeend", '
' + (role === "user" ? userAvatar : aiAvatar) + '
' + bubble + '
'); - } - }); - // 删除流式占位符(同步完成,下一次 SSE 会重建) - var streamingPh = msgsEl.querySelector(".msg-streaming-ph"); - if (streamingPh) streamingPh.remove(); - } - // 追加新消息对象到 state.messages - Array.prototype.push.apply(state.messages, newMsgs); - // 同步聊天占位符(如果有新消息但最后一条非 assistant → 显示流式占位) - syncStreamingPlaceholder(); - } - // syncStreamingPlaceholder:同步聊天占位符的可见性 - function syncStreamingPlaceholder() { - var msgsEl = document.getElementById("chat-msgs"); - if (!msgsEl) return; - var existing = msgsEl.querySelector(".msg-streaming-ph"); - var lastMsg = state.messages.length ? state.messages[state.messages.length - 1] : null; - var showPh = state.chatLoading && (!lastMsg || lastMsg.role !== "assistant" || lastMsg._final); - if (showPh && !existing) { - var aiAvatar = '小宅'; - var pillHtml = ""; - (state.pendingTools || []).forEach((nm) => { - pillHtml += '' + escHtml(nm) + ''; - }); - msgsEl.insertAdjacentHTML("beforeend", '
' + aiAvatar + '
' + (pillHtml ? '' + pillHtml + '' : '') + '
'); - } else if (!showPh && existing) { - existing.remove(); - } - } - - async function loadTerminals() { - try { - var data = await api("/terminals"); - if (data && data.terminals) state.terminals = data.terminals; - } catch (e) {} - } - - async function loadCmdHistory() { - try { - var data = await api("/cmd/history"); - if (data && data.history) state.cmdHistory = data.history; - } catch (e) {} - } - - function appendTermBuf(el, text) { - if (!text) return; - el.textContent += text; - if (el.textContent.length > 262144) { - el.textContent = el.textContent.slice(el.textContent.length - 262144); - } - el.scrollTop = el.scrollHeight; - } - - function renderTerminals() { - var r = document.getElementById("term-list"); - var cnt = document.getElementById("term-count-badge"); - if (!r) return; - var list = state.terminals || []; - if (cnt) cnt.textContent = list.length; - if (list.length === 0) { - r.innerHTML = - '

' + - __("暂无终端会话", "No terminal sessions") + - "

"; - return; - } - var html = ""; - list.forEach((t, i) => { - var detailId = "term-detail-" + i; - var scr = (state.termScreens && state.termScreens[t.id]) || null; - var running = scr ? scr.running : !!t.running; - var fullOut = scr ? scr.output : t.output || ""; - if (!fullOut) { - fullOut = - '' + - __("[终端暂无输出]", "[No terminal output]") + - ""; - } else { - fullOut = escHtml(fullOut); - } - html += - '
'; - html += - '
"; - html += - '' + - escHtml(t.id || "-") + - ""; - html += - '' + - escHtml(t.command || "") + - ""; - html += - '' + - (running ? __("运行中", "Running") : __("已关闭", "Closed")) + - ""; - html += - '' + - escHtml(t.created_at || "") + - ""; - html += "
"; - html += - '
"; - }); - r.innerHTML = html; - } - - function renderCmdHistory() { - var r = document.getElementById("cmd-list"); - var cnt = document.getElementById("cmd-count-badge"); - if (!r) return; - var running = (state.terminals || []).filter((t) => t.running); - if (cnt) cnt.textContent = running.length; - if (running.length === 0) { - r.innerHTML = - '

' + - __("暂无运行中的命令", "No running commands") + - "

"; - return; - } - var html = - '"; - running.forEach((t) => { - var scr = (state.termScreens && state.termScreens[t.id]) || null; - var out = scr ? scr.output : t.output || ""; - html += - "" + - '" + - '" + - '" + - ""; - if (out) { - html += - "" + - '"; - } - }); - html += "
' + - __("命令", "Command") + - "" + - __("状态", "Status") + - "" + - __("运行时长", "Uptime") + - "
' + - escHtml(t.command || t.id || "") + - "' + - __("运行中", "Running") + - "' + - escHtml(t.uptime || "-") + - "
' +
-              escHtml(out.substring(0, 2000)) +
-              "
"; - r.innerHTML = html; - } - - function connectSSE() { - if (state.eventSource) { - console.log("[SSE] closing old connection"); - state.eventSource.close(); - } - var es; - try { - es = new EventSource("/api/v1/chat/events", { - withCredentials: true, - }); - state.eventSource = es; - } catch (ex) { - console.error("[SSE] create failed", ex); - return; - } - if (!es) { - console.error("[SSE] es is null"); - return; - } - console.log("[SSE] connected"); - es.addEventListener("agent_output", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - // 光点:agent → 输出通道。总览页拓扑靠它做"消息出去了"的动画; - // 其它页没有该 SVG,rtSparkOutput 查不到元素就是 no-op。 - if (p.channel) rtSparkOutput(ev.source || "", p.channel); - // 星图跟随:agent 输出是一次完整活动的收尾,给一记强脉冲。 - starmapPulse("output", p.channel || null); - console.log( - "[SSE] agent_output received", - p.content ? p.content.substring(0, 50) : "(empty)", - ); - if (!p.content) return; - state.chatStage = __("AI 回复中...", "AI replying..."); - if (p.kind === "channel_output") { - // 附件输出(output_type=image/file):渲染为图片预览/下载卡片 - var att = null; - if ( - p.output_type === "image" || - p.output_type === "audio" || - p.output_type === "file" - ) { - att = { - type: p.output_type, - url: p.url || p.content, - size: p.size || 0, - name: (p.url || "").split("/").pop() || "附件", - }; - } - var cm = { - role: "assistant", - content: att ? "" : p.content, - source: p.channel || "", - _final: true, - _grow: true, - attachment: att, - }; - if ( - state.chatFinalIdx >= 0 && - state.chatFinalIdx < state.messages.length - ) { - state.messages.splice(state.chatFinalIdx, 0, cm); - state.chatFinalIdx++; - } else { - state.messages.push(cm); - } - rerenderChat(); - return; - } - var lastM2 = state.messages.length - ? state.messages[state.messages.length - 1] - : null; - if (lastM2 && lastM2.role === "assistant" && !lastM2._final) { - // 聚合最终响应:覆盖 delta 累积的中间内容(以聚合为准, - // 含 stage 插件改写后的最终文本),并置 final 结束本轮流式。 - lastM2._grow = true; - lastM2.content = p.content; - lastM2._final = true; - rerenderChat(); - endChatTurn(); - return; - } - if ( - lastM2 && - lastM2.role === "assistant" && - lastM2._final && - !lastM2.source - ) { - // 去重:同一轮的重复帧(如 SSE 重连回放)内容相同则忽略; - // 内容不同视为新一轮输出(上一轮已 final 且无 source),开新消息。 - // 旧逻辑无条件 return 会丢弃多轮连发时新一轮的最终回复。 - if (lastM2.content === p.content) { - endChatTurn(); - return; - } - } - state.messages.push({ - role: "assistant", - content: p.content, - _streaming: true, - _grow: true, - _final: true, - }); - rerenderChat(); - endChatTurn(); - } catch (ex) { - console.error("[SSE] agent_output error", ex); - } - }); - // token 级流式增量:逐块追加到当前回复内容(流式生成中); - // reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。 - es.addEventListener("content_delta", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - if (p.channel === "_consolidation_") return; - if (p.reset) { - var lm = state.messages.length - ? state.messages[state.messages.length - 1] - : null; - if (lm && lm.role === "assistant" && !lm._final) { - lm._final = true; - rerenderChat(); - } - // 轮次作废(用户中断):定格已显示内容;核心会以 [中断消息] - // 重启轮次,保持回合打开让确认回复继续流式渲染, - // 由其 agent_output final / watchdog 收尾。 - armTurnWatchdog(); - return; - } - if (!p.content) return; - state.chatStage = __("AI 回复中...", "AI replying..."); - var last = - state.messages.length > 0 - ? state.messages[state.messages.length - 1] - : null; - if (!last || last.role !== "assistant" || last._final) { - state.messages.push({ - role: "assistant", - content: "", - tool_calls: [], - _streaming: true, - _grow: true, - }); - last = state.messages[state.messages.length - 1]; - } - last.content += p.content; - rerenderChat(); - } catch (ex) {} - }); - es.addEventListener("terminal_output", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - if (!p.terminal_id) return; - var id = p.terminal_id; - if (!state.termScreens) state.termScreens = {}; - var scr = - state.termScreens[id] || - (state.termScreens[id] = { output: "", running: true }); - if (p.output) scr.output += p.output; - if (typeof p.running === "boolean") scr.running = p.running; - var bufel = document.getElementById("term-buf-" + id); - if (bufel) { - appendTermBuf(bufel, p.output || ""); - var dot = document.getElementById("term-dot-" + id); - if (dot) - dot.className = "term-dot" + (scr.running ? "" : " stopped"); - } - } catch (ex) { - console.error("[SSE] terminal_output error", ex); - } - }); - es.addEventListener("reasoning", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - if (p.channel === "_consolidation_") return; - if (p.content) { - state.chatStage = __("AI 思考中...", "AI thinking..."); - var last = - state.messages.length > 0 - ? state.messages[state.messages.length - 1] - : null; - if (!last || last.role !== "assistant" || last._final) { - state.messages.push({ - role: "assistant", - content: "", - reasoning_content: "", - tool_calls: [], - _streaming: true, - }); - last = state.messages[state.messages.length - 1]; - } - // 聚合 reasoning 帧携带全文:直接覆盖(若已有 delta 累积则等价) - last.reasoning_content = p.content; - rerenderChat(); - } - } catch (ex) {} - }); - // token 级流式增量:逐块追加到当前思考内容;reset 帧表示轮次作废 - es.addEventListener("reasoning_delta", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - if (p.channel === "_consolidation_") return; - if (p.reset) return; // 轮次作废(用户中断):清空累积中的思考 - if (!p.content) return; - state.chatStage = __("AI 思考中...", "AI thinking..."); - var last = - state.messages.length > 0 - ? state.messages[state.messages.length - 1] - : null; - if (!last || last.role !== "assistant" || last._final) { - state.messages.push({ - role: "assistant", - content: "", - reasoning_content: "", - tool_calls: [], - _streaming: true, - }); - last = state.messages[state.messages.length - 1]; - } - last.reasoning_content = (last.reasoning_content || "") + p.content; - rerenderChat(); - } catch (ex) {} - }); - es.addEventListener("tool_call", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - console.log("[SSE] tool_call", p); - if (!p.tool) return; - var last = - state.messages.length > 0 - ? state.messages[state.messages.length - 1] - : null; - if (!last || last.role !== "assistant" || last._final) { - state.messages.push({ - role: "assistant", - content: "", - tool_calls: [], - _streaming: true, - }); - last = state.messages[state.messages.length - 1]; - } - if (!last.tool_calls) last.tool_calls = []; - last.tool_calls.push({ - tool: p.tool, - name: p.tool, - args: p.args || {}, - result: p.result || "", - status: p.status || "ok", - plugin: p.plugin || "", - }); - var pidx = (state.pendingTools || []).indexOf(p.tool); - if (pidx !== -1) state.pendingTools.splice(pidx, 1); - state.chatStage = __("工具调用: ", "Tool: ") + (p.tool || ""); - // 星图跟随:工具调用是最可靠的活动信号。工具名(如 - // knowledge_list / qq_send)会拿去匹配相关实体节点并点亮。 - starmapPulse("tool", p.tool); - rerenderChat(); - } catch (ex) { - console.error("[SSE] tool_call error", ex); - } - }); - // 注:后端不发布 tool_result 类型事件(工具结果随 EventToolCall 一次发出),无此监听器。 - es.addEventListener("stage", (e) => { - try { - var ev = JSON.parse(e.data); - var p = ev.payload || {}; - var phase = p.phase || ""; - var tool = p.tool || ""; - if (p.channel === "_consolidation_") return; - console.log("[SSE] stage event", phase, tool); - // 驱动总览页的"阶段管道"滑块:滑到当前阶段;一轮跑完(或 2.5s 无新 - // 事件)自动回到空闲,避免留下一个永远停在 after_output 的假状态。 - // 阶段轨迹:本轮真实发生过什么,交给阶段管道画成 chip 序列。 - // 工具调用会反复出现(多轮 toolcall),output_* 单独配色。 - if (phase === "on_input") { - state.stageTrail = []; - rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input")); - } else if (phase === "pre_action") { - rtTrailPush(1, "stage", __("组装上下文并思考", "assemble context and think"), __("思考", "think")); - } else if (phase === "before_toolcall" && tool) { - var isOut = tool.indexOf("output_") === 0; - rtTrailPush(2, isOut ? "output" : "tool", tool, rtShortTool(tool)); - state.toolFlash = true; - } else if (phase === "before_output") { - rtTrailPush(3, "stage", __("生成回复", "generate reply"), __("生成", "gen")); - } else if (phase === "after_output") { - rtTrailPush(4, "stage", __("本轮完成", "turn complete"), __("完成", "done")); - } - // 星图跟随:阶段推进也作为活动信号(工具名优先匹配节点)。 - starmapPulse("stage", tool || phase); - state.pipelinePhase = phase; - if (state.pipelineTimer) clearTimeout(state.pipelineTimer); - state.pipelineTimer = setTimeout(() => { - state.pipelinePhase = ""; - if (document.getElementById("rt-sec-pipe")) renderRuntime(); - }, 2500); - if (document.getElementById("rt-sec-pipe")) renderRuntime(); - // 渲染后再放动画:morph 已经把 chip 文本更新成最新那条。 - if (state.toolFlash) { - state.toolFlash = false; - rtFlashLatestTool(); - } - if (phase === "pre_action") { - state.chatStage = __("AI 思考中...", "AI thinking..."); - } else if (phase === "before_toolcall") { - state.chatStage = __("工具调用: ", "Tool: ") + (tool || ""); - if (tool && (state.pendingTools || []).indexOf(tool) === -1) { - if (!state.pendingTools) state.pendingTools = []; - state.pendingTools.push(tool); - rerenderChat(); - } - } else if (phase === "before_output") { - state.chatStage = __("生成回复中...", "Generating response..."); - } - var badge = document.getElementById("chat-stage"); - if (badge) { - badge.textContent = state.chatStage || ""; - badge.style.display = "none"; - } - } catch (ex) { - console.error("[SSE] stage error", ex); - } - }); - // channel_input:内核在输入进来时另发的一条轻量事件(只带通道名与 agent - // id,不带正文)。总览页拓扑靠它画"光点进入 agent"。 - es.addEventListener("channel_input", (e) => { - try { - var d = JSON.parse(e.data); - var src = d.source || ""; - if (src) rtSparkInput(src); - } catch (ex) { - console.error("[SSE] channel_input error", ex); - } - }); - es.onopen = () => { - console.log("[SSE] connection opened"); - }; - es.onerror = (e) => { - console.error("[SSE] error", e); - // 1) 立即 close 阻止浏览器原生自动重连与手动 setTimeout(connectSSE) 双连接竞态 - try { state.eventSource && state.eventSource.close(); state.eventSource = null; } catch(ex){} - // 2) 连接错误期间可能丢失事件,增量补拉历史(无闪烁) - syncChatFromHistory().catch(()=> {}); - // 3) 2s 后手动重连(比原 5s 更快恢复) - setTimeout(connectSSE, 2000); - }; - // sync_required:Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放, - // 通知前端增量补拉历史——避免前端空等后续聚合事件导致「消息同步不及时」。 - es.addEventListener("sync_required", (e) => { - console.log("[SSE] sync_required received, incremental sync"); - syncChatFromHistory().catch(()=> {}); - }); - // Periodically refresh sidebar data - if (state._sidebarRefresh) clearInterval(state._sidebarRefresh); - state._sidebarRefresh = setInterval(async () => { - try { - var td = await api("/terminals"); - if (td && td.terminals) state.terminals = td.terminals; - } catch (e) {} - try { - var ch = await api("/cmd/history"); - if (ch && ch.history) state.cmdHistory = ch.history; - } catch (e) {} - renderTerminals(); - renderCmdHistory(); - }, 5000); - } - - // ===== Plugins ===== - // 「服务入口」:被反代的插件服务,点一下直接打开。 - // - // 为什么单独一张卡而不是塞进每个插件的详情:入口是**跨插件**的(同一个 - // webui 端口、不同子域),用户的心智是「我要打开某个服务」,不是 - // 「我要进某个插件的管理页」。 - // - // 链接带 ?__token=:子域与门户不同源,浏览器不会带上会话 cookie; - // 不带 token 会 401(这是刻意的受保护默认)。页面已登录,此处复用同一把 key。 - function renderProxyServicesCard() { - var svcs = state.proxyServices || []; - var base = state.proxyBaseDomain || "localhost"; - var html = - '

' + - __("服务入口", "Service Entry Points") + - "

"; - if (!svcs.length) { - return ( - html + - '

' + - __( - "暂无被反代的插件服务。插件在 plugin.json 的 proxies 字段里声明后会自动出现在这里。", - "No proxied plugin services yet. Declare them in plugin.json's proxies field.", - ) + - "

" - ); - } - var token = state.settings?.["plugin.webui.api_key"] || ""; - html += - '
' + - __( - "插件服务经 HomeAgent 同一端口反代。子域形态需要 DNS 能解析 ", - "Proxied through the same port. The subdomain form needs DNS for ", - ) + - "" + - escHtml("*." + base) + - "" + - __( - ";路径形态无 DNS 依赖,穿透场景下更可靠(点「打开」优先用它)。", - "; the path form has no DNS dependency and is more reliable behind a tunnel (Open prefers it).", - ) + - "
"; - html += '
'; - svcs.forEach((s) => { - var url = s.url || ""; - if (url && token) url += "?__token=" + encodeURIComponent(token); - var dot = s.ok ? "var(--ok, #22c55e)" : "var(--danger, #d1383d)"; - html += - '
' + - '' + - '' + escHtml(s.plugin_name || s.plugin) + "" + - '' + escHtml(s.name) + "" + - (s.websocket - ? 'WS' - : "") + - (s.auth === "none" - ? '' + - __("未保护", "unprotected") + - "" - : "") + - '' + escHtml(s.host + "." + base) + ""; - // 「打开」优先用**路径形态**(url_portal): - // - 它没有 DNS 依赖,单端口穿透 / 子域无证书时都能用; - // - 子域形态要求 DNS 能解析 .<基域名>,而这些域名 - // 在外部常常不可达(实测:外层只放行一个 Host,三级子域 - // 因通配证书不匹配而握手失败)。 - // 保留子域链接作为次选(局域网内直连时它更直观)。 - var primary = s.url_portal || url; - if (s.ok && primary) { - html += - '' + - __("打开", "Open") + - ""; - if (url && s.url_portal && url !== s.url_portal) { - html += - '' + - __("子域", "subdomain") + - ""; - } - } else { - html += - '' + - escHtml(s.error || __("不可用", "unavailable")) + - ""; - } - html += "
"; - }); - html += "
"; - return html; - } - - async function loadProxyServices() { - try { - var d = await api("/proxy/services"); - state.proxyServices = (d && d.services) || []; - state.proxyBaseDomain = (d && d.base_domain) || "localhost"; - } catch (e) { - state.proxyServices = []; - } - } - - function renderPlugins() { - var k = state.kernel; - var plugins = k?.plugins || []; - var tools = k?.tools || []; - var installed = state.installedPlugins || []; - var html = renderProxyServicesCard(); - html += - '

' + - __("安装插件", "Install Plugin") + - "

" + - '
' + - '' + - '
" + - '
' + - '
"; - var disabledNames = {}; - (state.disabledPlugins || []).forEach((d) => { - disabledNames[d.name] = d; - }); - var installedNames = (state.installedPlugins || []).map((p) => p.name); - var allPluginNames = {}; - plugins.forEach((p) => { - allPluginNames[p.name] = true; - }); - state.disabledPlugins.forEach((d) => { - allPluginNames[d.name] = true; - }); - html += - '

' + - __("所有插件", "All Plugins") + - " (" + - Object.keys(allPluginNames).length + - ")

"; - var names = Object.keys(allPluginNames).sort(); - if (names.length === 0) { - html += - '

' + - __("暂无插件", "No plugins") + - "

"; - } else { - html += - ""; - names.forEach((name) => { - var isExternal = installedNames.indexOf(name) >= 0; - var isDisabled = disabledNames[name]; - var loaded = plugins.some((p) => p.name === name); - var statusHtml = - loaded && !isDisabled - ? '' + - __("已加载", "Loaded") + - "" - : loaded && isDisabled - ? '' + - __("运行中(禁用待生效)", "Running (disable pending)") + - "" - : isDisabled - ? '' + - __("已禁用", "Disabled") + - "" - : '' + - __("未加载", "Not Loaded") + - ""; - var actionsHtml = ""; - if (loaded) { - actionsHtml += - '"; - } else { - actionsHtml += - '"; - } - if (isExternal) { - actionsHtml += - '"; - } - html += - "" + - "" + - ""; - }); - html += "
" + - __("名称", "Name") + - "" + - __("状态", "Status") + - "" + - __("操作", "Actions") + - "
" + - escHtml(name) + - "" + - statusHtml + - "" + - actionsHtml + - "
"; - } - html += "
"; - if (installed.length > 0) { - html += - '

' + - __("已安装外部插件", "Installed Plugins") + - " (" + - installed.length + - ")

" + - ""; - installed.forEach((p) => { - html += - "" + - "" + - "" + - '"; - }); - html += "
" + - __("名称", "Name") + - "" + - __("版本", "Version") + - "" + - __("描述", "Description") + - "" + - __("操作", "Actions") + - "
" + - escHtml(p.name) + - "" + - escHtml(p.version || "-") + - "" + - escHtml((p.description || "").substring(0, 50)) + - " " + - '
"; - } - if (state.pluginInfo) { - html += - '

' + - __("插件详情", "Plugin Details") + - ": " + - escHtml(state.pluginInfo.name) + - "

" + - "
" +
-            escHtml(JSON.stringify(state.pluginInfo, null, 2)) +
-            "
" + - '
"; - } - if (tools.length > 0) { - html += - '

' + - __("已注册工具", "Registered Tools") + - " (" + - tools.length + - ")

" + - '
'; - tools.forEach((t) => { - html += - '' + - escHtml(t.name) + - ""; - }); - html += "
"; - } - html += - '

' + - __("系统操作", "System Operations") + - "

" + - '
"; - html += - '

' + - __("健康检查", "Health Check") + - '

'; - if (state.healthResult) { - html += renderHealthResult(state.healthResult); - } else { - html += - '

' + - __( - "尚未运行,点击右上角「运行」开始", - "Not run yet, click Run to start", - ) + - "

"; - } - html += "
"; - document.getElementById("tab-plugins").innerHTML = html; - } - - async function loadInstalledPlugins() { - try { - state.installedPlugins = await api("/plugins"); - } catch (e) { - state.installedPlugins = []; - } - } - - async function installPlugin() { - var inp = document.getElementById("plugin-url"); - var url = inp?.value.trim(); - if (!url) { - toast(__("请输入插件包 URL", "Please enter plugin URL"), true); - return; - } - try { - var raw = await api("/plugins", { - method: "POST", - body: JSON.stringify({ url: url }), - raw: true, - }); - var ct = raw.headers.get("content-type") || ""; - var r = ct.includes("json") ? await raw.json() : await raw.text(); - if (!raw.ok || (r && r.error)) { - toast( - __("安装失败: ", "Install failed: ") + - ((r && (r.error || r.details)) || "HTTP " + raw.status), - true, - ); - return; - } - toast( - __("安装结果: ", "Install result: ") + - (r.status || JSON.stringify(r)), - ); - if (r.action === "reload_required") - toast( - __( - "已安装,请点击「重载插件」加载", - 'Installed, click "Reload Plugins" to load', - ), - false, - ); - await loadInstalledPlugins(); - renderPlugins(); - } catch (e) { - toast(__("安装失败: ", "Install failed: ") + e.message, true); - } - } - - async function installPluginFile(file) { - if (!file) return; - try { - var r = await fetch("/api/v1/plugins", { - method: "POST", - body: file, - headers: { "Content-Type": "application/octet-stream" }, - }); - var data = await r.json(); - toast( - __("上传安装: ", "Upload install: ") + - (data.status || JSON.stringify(data)), - ); - if (data.action === "reload_required") - toast( - __( - "已安装,请点击「重载插件」加载", - 'Installed, click "Reload Plugins" to load', - ), - false, - ); - loadInstalledPlugins(); - renderPlugins(); - } catch (e) { - toast(__("上传失败: ", "Upload failed: ") + e.message, true); - } - } - - async function showPluginInfo(name) { - try { - state.pluginInfo = await api("/plugins/" + encodeURIComponent(name)); - renderPlugins(); - } catch (e) { - toast(__("获取详情失败: ", "Get details failed: ") + e.message, true); - } - } - - function closePluginInfo() { - state.pluginInfo = null; - renderPlugins(); - } - - var removingPlugins = {}; - - async function removePlugin(name) { - if (removingPlugins[name]) return; // 防重复点击 - if ( - !confirm( - __("确定卸载插件", "Are you sure to unload plugin") + - "「" + - name + - "」?", - ) - ) - return; - removingPlugins[name] = true; - try { - var raw = await api("/plugins/" + encodeURIComponent(name), { - method: "DELETE", - raw: true, - }); - var ct = raw.headers.get("content-type") || ""; - var body = ct.includes("json") ? await raw.json() : await raw.text(); - if (!raw.ok) { - var em = - (body && (body.error || body.details)) || "HTTP " + raw.status; - toast(__("卸载失败: ", "Unload failed: ") + em, true); - // 内置插件或路径错误时刷新一次列表保持状态一致 - loadInstalledPlugins(); - renderPlugins(); - return; - } - toast( - __("已卸载: ", "Unloaded: ") + (body.name || body.status || name), - ); - await loadInstalledPlugins(); - // 同步内核插件/禁用列表,确保列表与工具立即消失 - try { - state.kernel = await api("/kernel"); - var s = await api("/settings"); - state.disabledPlugins = s.disabled_plugins || []; - } catch (e2) {} - renderPlugins(); - } catch (e) { - toast(__("卸载失败: ", "Unload failed: ") + e.message, true); - } finally { - delete removingPlugins[name]; - } - } - - async function disablePlugin(name) { - if (name === "webui") { - var r = confirm( - __( - "禁用 WebUI 后将无法通过 URL:端口访问此管理面板,若要重新启用需要通过 CLI 命令 /plugin enable webui 恢复。\n\n确定要禁用吗?", - "Disabling WebUI will make this management panel inaccessible via URL:port. To re-enable, use CLI command /plugin enable webui.\n\nAre you sure?", - ), - ); - if (!r) return; - } - try { - var r = await api( - "/plugins/" + encodeURIComponent(name) + "/disable", - { method: "POST" }, - ); - toast(__("已禁用: ", "Disabled: ") + name); - state.kernel = await api("/kernel"); - var s = await api("/settings"); - state.disabledPlugins = s.disabled_plugins || []; - renderPlugins(); - } catch (e) { - toast(__("禁用失败: ", "Disable failed: ") + e.message, true); - } - } - - async function enablePlugin(name) { - try { - var r = await api( - "/plugins/" + encodeURIComponent(name) + "/enable", - { method: "POST" }, - ); - toast(__("已启用: ", "Enabled: ") + name); - state.kernel = await api("/kernel"); - var s = await api("/settings"); - state.disabledPlugins = s.disabled_plugins || []; - renderPlugins(); - } catch (e) { - toast(__("启用失败: ", "Enable failed: ") + e.message, true); - } - } - - async function reloadPlugins() { - try { - var r = await api("/plugins/reload", { method: "POST" }); - toast(__("插件已重载", "Plugins reloaded")); - state.kernel = await api("/kernel"); - renderPlugins(); - } catch (e) { - toast(__("重载失败: ", "Reload failed: ") + e.message, true); - } - } - - async function runHealthcheck() { - var panel = document.getElementById("health-panel"); - if (!panel) return; - panel.innerHTML = - '

' + - __("运行中...", "Running...") + - "

"; - try { - var r = await api("/kernel"); - var tools = r?.tools || []; - var healthTool = tools.find((t) => t.name === "healthcheck"); - if (!healthTool) { - panel.innerHTML = - '

' + - __("healthcheck 工具未注册", "healthcheck tool not registered") + - "

"; - return; - } - panel.innerHTML = - '

' + - __( - "通过 Agent 对话触发 healthcheck...", - "Triggering healthcheck via Agent...", - ) + - "

"; - var chatR = await api("/chat", { - method: "POST", - body: JSON.stringify({ - message: __( - "请运行 healthcheck 工具进行全面健康检查并报告结果", - "Please run the healthcheck tool for a full system check and report the results", - ), - }), - }); - panel.innerHTML = - "
" + escHtml(JSON.stringify(chatR, null, 2)) + "
"; - } catch (e) { - panel.innerHTML = - '

' + - __("错误: ", "Error: ") + - escHtml(e.message) + - "

"; - toast( - __("健康检查失败: ", "Health check failed: ") + e.message, - true, - ); - } - } - - function renderHealthResult(r) { - if (!r || !r.checks) - return ( - '

' + - __("暂无健康检查数据", "No health check data") + - "

" - ); - var checks = r.checks || []; - var passed = checks.filter((c) => c.pass).length; - var failed = checks.filter((c) => !c.pass).length; - var html = - '
' + - '' + - __("通过: ", "Pass: ") + - passed + - "" + - '' + - __("失败: ", "Fail: ") + - failed + - "" + - '' + - __("总计: ", "Total: ") + - checks.length + - "
"; - checks.forEach((c) => { - var passClass = c.pass ? "check-pass" : "check-fail"; - if (c.status === "skip") passClass = "check-skip"; - html += - '
' + - '' + - escHtml(c.name) + - "" + - '' + - (c.status || "unknown") + - "" + - '' + - escHtml(c.detail || "") + - "
"; - }); - return html; - } - - // ===== Kernel ===== - function renderKernel() { - var k = state.kernel; - if (!k) { - document.getElementById("tab-kernel").innerHTML = - '

' + - __("内核未响应", "Kernel not responding") + - "

"; - return; - } - var b = k.build || {}; - var html = - '

' + - __("构建", "Build") + - "

" + - '
' + - __("内核", "Kernel") + - '' + - escHtml(b.kernel_name || "HomeAgent") + - "
" + - '
' + - __("内核版本", "Kernel version") + - '' + - escHtml(b.version ? "v" + b.version : "-") + - "
" + - '
Commit' + - escHtml(b.commit || "-") + - "
" + - '
' + - __("构建时间", "Build time") + - '' + - escHtml(b.build_time || "-") + - "
" + - '
' + - __("SDK 兼容", "SDK compat") + - '' + - escHtml(b.sdk_compatible || "-") + - "
" + - (b.source_url - ? '
' + - __("源码", "Source") + - '' + - escHtml(b.source_url) + - "
" - : "") + - "
"; - html += - '

' + - __("运行时", "Runtime") + - "

" + - '
Goroutines' + - (k?.runtime?.goroutines || "-") + - "
" + - '
' + - __("内存", "Memory") + - '' + - (k?.runtime?.memory_mb ? k.runtime.memory_mb + " MB" : "-") + - "
" + - '
Go ' + - __("版本", "Version") + - '' + - (k?.runtime?.go_version || "-") + - "
"; - html += - '

LLM

' + - '
Provider' + - (k.llm?.provider || __("未配置", "Not configured")) + - "
" + - '
' + - __("可用源", "Sources") + - '' + - (k.llm?.sources || 0) + - "
" + - '
' + - __("状态", "Status") + - '' + - (k.llm?.available - ? __("运行中", "Running") - : __("不可用", "Unavailable")) + - "
"; - html += - '

' + - __("记忆", "Memory") + - "

" + - '
' + - __("图记忆", "Graph Memory") + - '' + - (k.memory?.available - ? k.memory.entity_count + - __(" 实体, ", " entities, ") + - k.memory.relation_count + - __(" 关系", " relations") - : __("未初始化", "Uninitialized")) + - "
" + - '
' + - __("文档记忆", "Document Memory") + - '' + - (k.documents?.available - ? k.documents.doc_count + __(" 文档", " docs") - : __("未初始化", "Uninitialized")) + - "
" + - '
' + - __("文本记忆", "Text Memory") + - '' + - (k.text_memory?.available - ? k.text_memory.file_count + __(" 文件", " files") - : __("未初始化", "Uninitialized")) + - "
" + - '
' + - __("知识库", "Knowledge") + - '' + - (k.knowledge?.available - ? k.knowledge.item_count + __(" 项", " items") - : __("未初始化", "Uninitialized")) + - "
"; - html += - '

' + - __("插件", "Plugins") + - " (" + - (k.plugins?.length || 0) + - ")

"; - if (k.plugins?.length) { - html += '
'; - k.plugins.forEach((p) => { - html += - '' + escHtml(p.name) + ""; - }); - html += "
"; - } else { - html += - '

' + __("无", "None") + "

"; - } - html += "
"; - document.getElementById("tab-kernel").innerHTML = html; - } - - // ===== Star Map ===== - var starmapScene = null, - starmapCam = null, - starmapRen = null, - starmapCtrl = null; - var starmapNodes = [], - starmapEdges = []; - var starmapNodeMeshes = [], - starmapEdgeLines = [], - starmapStarField = null; - var starmapHovered = null, - starmapSelected = null, - starmapAutoView = true; - var starmapRaf = null; - // 共享几何:1151 节点各自 new SphereGeometry 会产生 1151 个 - // BufferGeometry(另加同样数量的光晕球)。几何形状对所有节点相同, - // 差别只在外层 mesh.scale,故共享一份即可。半径固定 0.5, - // 真实半径由 scale 给出(见 buildChatStarmapGraph)。 - var starmapGeo = null, - starmapGlowGeo = null; - // 标签角标:hover / 选中时显示的 HTML 元素(零显存,文字清晰)。 - var starmapLabelEl = null; - // smTypeColors 的键必须与**服务端实际产出的 type 字符串小写后**一致。 - // 服务端默认类型是 "Concept"(首字母大写,见 internal/memory/graph.go), - // 原键全为小写 ⇒ 永远匹配不上 ⇒ 1150 个节点全渲染成同一个灰色 0xcccccc, - // 分类配色实际上从未生效过。 - var smTypeColors = { - person: 0x4488ff, - task: 0xff8844, - ai: 0xaa44ff, - concept: 0x44ff88, - object: 0xff4444, - // 服务端还会产出这些(indexer_test 里可见 "Person"/"Location") - location: 0xffaa44, - source: 0x8899ff, - document: 0xaabbcc, - event: 0xff88cc, - entity: 0x44ddcc, - scene: 0x88ff44, - }; - var smEdgeColors = { - 喜欢: 0xff6b6b, - 学习: 0x4ecdc4, - 属于: 0x45b7d1, - 相关: 0x96ceb4, - 使用: 0xfeca57, - 创建: 0xff9ff3, - }; - - // ===== 星图活动:让图跟上 agent 的动作 ===== - // - // 改之前:starmapAnimate() 只转星空,节点完全静止。现在三路信号: - // - // 1. starmapPulse(kind) —— 实时。SSE 的 tool_call / stage / - // agent_output 触发。命中的节点(按工具名匹配已知实体,否则随机 - // 取一批)做一次扩散涟漪 + 发光冲高。 - // 2. starmapPullActivity() —— /runtime 调度器快照,3s。 - // ready_queue_depth > 0 ⇒ 图整体「绷紧」(轻微缩放脉冲); - // interrupts/preempts 上升 ⇒ 高优先级别的红色电弧感闪烁。 - // 3. starmapPullPulse() —— /memory/graph/pulse,10s。 - // 最近变动的实体(新增概念 / mention_count 变化)做一次 - // 「生长」:从 0 缩放到正常大小,并留下余晖。 - // - // 全部在渲染循环里推进,不额外起定时器。 - var SM_PULSE_MS = 1400; // 单个脉冲的生命期 - var SM_RIPPLE_R = 26; // 涟漪最大半径 - - // starmapPulse 发出一次活动脉冲。 - // kind: "tool" | "stage" | "output" | "grow" - function starmapPulse(kind, hint) { - if (!starmapScene || !starmapNodeMeshes.length) return; - var now = Date.now(); - state.starmapLastPulseAt = now; - // 有 hint(工具名/阶段名)时优先点亮名字与提示相关的节点, - // 这是「图在跟 agent 动」最直接的体现:调了 knowledge_* 就亮知识节点。 - var targets = starmapPickPulseTargets(hint); - if (!targets.length) return; - var life = kind === "grow" ? SM_PULSE_MS * 1.6 : SM_PULSE_MS; - targets.forEach((m) => { - state.starmapPulses.push({ mesh: m, until: now + life, kind: kind }); - }); - // 队列上限:防止密集工具调用时脉冲无限堆积占内存。 - if (state.starmapPulses.length > 260) - state.starmapPulses = state.starmapPulses.slice(-260); - } - - // starmapPickPulseTargets 选出该被点亮的节点。 - // 优先名字/类型命中 hint 的;不足时按 mention_count 补齐(高权重节点 - // 本身就是最常被 agent 触碰的,用它们代表「整体活动」合理)。 - // - // ★ 匹配必须用「词」而不是子串包含: - // hint="knowledge" 与实体名 "k" / "e" 互为子串,会让半个图谱 - // (含 "时"、"会" 这类单字实体)全部命中 ⇒ 脉冲退化成「全图齐亮」, - // 既看不出关联,又把队列瞬间打满。浏览器实测:旧写法一次 pulse - // 就选中 250 个节点、队列顶到 260 上限。 - function starmapPickPulseTargets(hint) { - var out = []; - if (!starmapNodeMeshes.length) return out; - if (hint) { - var keys = starmapHintTokens(hint); - if (keys.length) { - for (var i = 0; i < starmapNodeMeshes.length && out.length < 26; i++) { - var nd = starmapNodeMeshes[i].userData.nodeData || {}; - var nm = String(nd.name || "").toLowerCase(); - var ty = String(nd.type || "").toLowerCase(); - if (!nm) continue; - for (var k = 0; k < keys.length; k++) { - var key = keys[k]; - // 词边界命中:实体名恰好等于该词,或以该词为词首 - // (如 "knowledge_base" 命中词 "knowledge")。 - if (nm === key || nm.indexOf(key + "_") === 0 || ty === key) { - out.push(starmapNodeMeshes[i]); - break; - } - } - } - } - } - // ★ 只有「一个都没匹配上」时才用全局权重节点代表「整体活动」。 - // 实测(浏览器里跑真数据):词匹配对 knowledge_list 只命中 1 个, - // 但旧的补齐逻辑会把它补到 20 个 —— 于是脉冲看起来仍然是「一大片 - // 无关节点在亮」,与要修的子串 bug 效果一样,只是换了个成因。 - // 补齐只保留在真正无匹配的场景(hint 为空,如调度器脉冲)。 - if (out.length > 0) return out; - // 补齐:按 mention_count 降序取靠前且尚未入列的。 - var sorted = starmapNodeMeshes.slice().sort((a, b) => ( - (b.userData.nodeData || {}).mention_count - - (a.userData.nodeData || {}).mention_count - )); - for (var j = 0; j < sorted.length && out.length < 8; j++) { - if (out.indexOf(sorted[j]) === -1) out.push(sorted[j]); - } - return out; - } - - // starmapHintTokens 把提示词(工具名/阶段名)拆成可匹配的词元。 - // 例:"knowledge_list" → ["knowledge","list"]。 - // 只保留长度 >= 3 的词:单/双字母词("a"/"ls")几乎必然撞上无关 - // 实体名,匹配它们只会制造噪声。 - function starmapHintTokens(hint) { - var raw = String(hint).toLowerCase().split(/[^a-z0-9\u4e00-\u9fa5]+/); - var out = []; - for (var i = 0; i < raw.length; i++) { - var t = raw[i]; - if (t.length >= 3 && out.indexOf(t) === -1) out.push(t); - } - return out; - } - - // starmapPullActivity 拉 /runtime,把调度器状态映射成图的整体节奏。 - function starmapPullActivity() { - if (!starmapScene) return; - api("/runtime") - .then((rt) => { - if (!rt || !rt.scheduler) return; - var s = rt.scheduler; - var prev = state.starmapActivity; - state.starmapActivity = s; - var pend = - (s.ready_queue_depth || 0) + - (s.pending_interrupts || 0) + - (s.suspend_stack || 0); - // 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。 - if (pend > 0) starmapPulse("stage", null); - // 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。 - if (prev) { - var dInt = - (s.interrupts_by_level || []).reduce((a, b) => a + b, 0) - - (prev.interrupts_by_level || []).reduce((a, b) => a + b, 0); - var dPre = - (s.preempts_by_level || []).reduce((a, b) => a + b, 0) - - (prev.preempts_by_level || []).reduce((a, b) => a + b, 0); - if (dInt > 0 || dPre > 0) starmapPulse("output", null); - } - }) - .catch(() => {}); - } - - // starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。 - function starmapPullPulse() { - if (!starmapScene) return; - var since = state.starmapPulseSince || 0; - api("/memory/graph/pulse?since=" + since) - .then((r) => { - if (!r || !r.success || !r.data) return; - var now = Math.floor(Date.now() / 1000); - state.starmapPulseSince = now - 30; // 30s 重叠,防跨轮漏节点 - var nodes = r.data.nodes || []; - if (!nodes.length) return; - var known = 0; - nodes.forEach((n) => { - var m = starmapNodeMeshes.find((x) => x.userData.nodeId === n.id); - if (!m) return; // 全量图里没有(可能刚创建)⇒ 忽略,等下次全量 - known++; - state.starmapGrown[m.userData.nodeId] = Date.now() + SM_PULSE_MS * 2; - starmapPulse("grow", n.name); - }); - // 若有新实体但一个都没匹配上,说明全量图过期了, - // 下一拍重拉全量(新节点才能出现)。 - if (known === 0 && nodes.length > 2) starmapDirty = true; - }) - .catch(() => {}); - } - - // starmapTickActivity 在渲染循环里推进所有脉冲与余晖。 - function starmapTickActivity(t) { - var now = Date.now(); - var breathe = 0; - // 1) 活动脉冲:发光冲高 + 尺寸微扩 + 涟漪环 - if (state.starmapPulses.length) { - var keep = []; - for (var i = 0; i < state.starmapPulses.length; i++) { - var p = state.starmapPulses[i]; - if (p.until <= now) { - p.mesh.material.emissiveIntensity = p.mesh.userData.baseEmissive; - p.mesh.scale.setScalar(p.mesh.userData.baseScale); - continue; - } - keep.push(p); - var left = (p.until - now) / SM_PULSE_MS; // 1→0 - var k = 1 - left; // 0→1 - var wave = Math.sin(Math.min(1, k) * Math.PI); - var amp = p.kind === "output" ? 1.8 : 1.2; - p.mesh.material.emissiveIntensity = - p.mesh.userData.baseEmissive + wave * amp; - p.mesh.scale.setScalar( - p.mesh.userData.baseScale * (1 + wave * 0.28), - ); - // 生长:新节点从 0 弹到正常大小 - if (p.kind === "grow") { - var g = Math.min(1, k * 1.4); - p.mesh.scale.setScalar( - p.mesh.userData.baseScale * (0.15 + 0.85 * g), - ); - } - } - state.starmapPulses = keep; - } - // 2) 「生长」余晖:脉冲结束后短暂保留一点亮 - if (state.starmapGrown) { - for (var gid in state.starmapGrown) { - if (state.starmapGrown[gid] <= now) { - delete state.starmapGrown[gid]; - continue; - } - var gm = starmapNodeMeshes.find((x) => x.userData.nodeId == gid); - if (gm) - gm.material.emissiveIntensity = Math.max( - gm.material.emissiveIntensity, - gm.userData.baseEmissive + 0.6, - ); - } - } - // 3) 全局呼吸:距上次活动越近越亮,实现「agent 一忙图就活」 - var idle = (now - (state.starmapLastPulseAt || 0)) / 4000; - breathe = Math.max(0, 1 - idle); - if (breathe > 0.01 && starmapNodeMeshes.length) { - // 只抽样一部分节点做呼吸,避免每帧改 1151 个材质。 - var stride = 24; - for (var b = 0; b < starmapNodeMeshes.length; b += stride) { - var m2 = starmapNodeMeshes[b]; - if (m2.userData.baseEmissive === undefined) continue; - m2.material.emissiveIntensity = Math.max( - m2.userData.baseEmissive, - m2.userData.baseEmissive + breathe * 0.25, - ); - } - } - return breathe; - } - - function createStarField() { - var c = 3000; - var p = new Float32Array(c * 3), - cl = new Float32Array(c * 3), - s = new Float32Array(c); - for (var i = 0; i < c; i++) { - var i3 = i * 3; - var r = 400 + Math.random() * 600, - th = Math.random() * Math.PI * 2, - ph = Math.acos(2 * Math.random() - 1); - p[i3] = r * Math.sin(ph) * Math.cos(th); - p[i3 + 1] = r * Math.sin(ph) * Math.sin(th); - p[i3 + 2] = r * Math.cos(ph); - if (Math.random() < 0.7) { - cl[i3] = 0.8 + Math.random() * 0.2; - cl[i3 + 1] = 0.8 + Math.random() * 0.2; - cl[i3 + 2] = 1; - } else { - cl[i3] = 1; - cl[i3 + 1] = 0.9 + Math.random() * 0.1; - cl[i3 + 2] = 0.8 + Math.random() * 0.2; - } - s[i] = 0.5 + Math.random() * 2; - } - var g = new THREE.BufferGeometry(); - g.setAttribute("position", new THREE.BufferAttribute(p, 3)); - g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); - g.setAttribute("size", new THREE.BufferAttribute(s, 1)); - var m = new THREE.PointsMaterial({ - size: 1.5, - vertexColors: true, - transparent: true, - opacity: 0.8, - sizeAttenuation: true, - }); - starmapStarField = new THREE.Points(g, m); - starmapScene.add(starmapStarField); - } - - function onStarmapMove(e) { - if (!starmapRen || !starmapCam) return; - var rect = starmapRen.domElement.getBoundingClientRect(); - var mouse = new THREE.Vector2( - ((e.clientX - rect.left) / rect.width) * 2 - 1, - -((e.clientY - rect.top) / rect.height) * 2 + 1, - ); - var rc = new THREE.Raycaster(); - rc.setFromCamera(mouse, starmapCam); - var hits = rc.intersectObjects(starmapNodeMeshes); - if (hits.length > 0) { - var n = hits[0].object; - if (starmapHovered !== n) { - // 复位用 baseScale,不能用 set(1,1,1):节点现在是按 mention_count - // 缩放过的(userData.baseScale),置 1 会把大节点缩成最小尺寸。 - // (这是低规格改造后必须跟着改的一处,旧代码能“跑”是因为那时 - // 几何体本身就带半径、不靠 scale。) - if (starmapHovered) - starmapHovered.scale.setScalar( - starmapHovered.userData.baseScale || 1, - ); - starmapHovered = n; - starmapLabelShow(n); - } - } else { - if (starmapHovered) { - starmapHovered.scale.setScalar( - starmapHovered.userData.baseScale || 1, - ); - starmapHovered = null; - starmapLabelHide(); - } - } - } - - // starmapLabelShow/Hide:hover 时在容器角上显示一个 HTML 角标。 - // 取代原先每节点一张 256x64 CanvasTexture(1151 张贴图 ≈ 72MB 显存), - // 文字用 DOM 渲染反而更清楚,且零 GPU 开销。 - function starmapLabelShow(mesh) { - var cont = starmapActiveContainer(); - if (!cont) return; - if (!starmapLabelEl) { - starmapLabelEl = document.createElement("div"); - starmapLabelEl.id = "sm-label"; - } - // 角标必须跟 canvas 在同一个容器里(它是绝对定位在容器上的)。 - // canvas 换页签搬运时,角标也要跟着搬。 - if (starmapLabelEl.parentElement !== cont) cont.appendChild(starmapLabelEl); - var nd = (mesh && mesh.userData && mesh.userData.nodeData) || {}; - var nm = nd.name || nd.id || ""; - starmapLabelEl.textContent = - nm + (nd.mention_count ? " ×" + nd.mention_count : ""); - starmapLabelEl.style.display = "block"; - } - function starmapLabelHide() { - if (starmapLabelEl) starmapLabelEl.style.display = "none"; - } - // starmapContainer 返回当前星图所在的容器(主页或聊天面板任一)。 - function starmapContainer() { - return ( - document.getElementById("sm-container-home") || - document.getElementById("sm-container-chat") - ); - } - - // starmapActiveContainer 选出「该把星图画在哪」的容器。 - // - // 规则:先看独立星图页签,不存在(该页签还没打开过)就退回总览页, - // 再退回聊天面板。为什么要这个优先级:three.js 的 canvas 只能有一个 - // 父节点,同时往两处渲染就会一边黑屏。每次切页签都把 canvas 搬到 - // 当前该显示的地方,是单一 renderer 前提下最干净的做法。 - function starmapActiveContainer() { - var tab = document.getElementById("tab-starmap"); - if (tab && tab.classList.contains("active")) { - var c = document.getElementById("sm-container-page"); - if (c) return c; - } - var ov = document.getElementById("tab-overview"); - if (ov && ov.classList.contains("active")) { - var h = document.getElementById("sm-container-home"); - if (h) return h; - } - return ( - document.getElementById("sm-container-chat") || - document.getElementById("sm-container-home") || - null - ); - } - - // renderStarmapTab 渲染独立星图页签(只建一次骨架,不重复重建)。 - function renderStarmapTab() { - var host = document.getElementById("tab-starmap"); - if (!host) return; - if (!document.getElementById("sm-container-page")) { - var rt = state.runtime || {}; - var sc = rt.scheduler || {}; - var pend = - (sc.ready_queue_depth || 0) + (sc.pending_interrupts || 0); - host.innerHTML = - '

' + - __("记忆星图", "Memory Star Map") + - '

' + - '
' + - '
' + - smLegend("person", __("人物", "Person")) + - smLegend("concept", __("概念", "Concept")) + - smLegend("object", __("对象", "Object")) + - smLegend("location", __("地点", "Location")) + - smLegend("source", __("来源", "Source")) + - "
" + - '
' + - __( - "星图跟随 agent 活动脉动:工具调用 / 阶段推进 / 输出 / 调度器繁忙 / 新记忆生长。", - "The map pulses with agent activity: tool calls, stage progress, output, scheduler load, new memory.", - ) + - "
"; - } - // 页签每次激活都把 canvas 搬过来 + 重新按容器尺寸 resize。 - if (document.getElementById("sm-container-page")) { - if (!state.starmapInit && !state.starmapLoading) - loadChatStarmapData(); - else if (starmapRen) { - var cont = document.getElementById("sm-container-page"); - if (starmapRen.domElement.parentElement !== cont) { - cont.appendChild(starmapRen.domElement); - starmapRen.domElement.style.display = "block"; - } - onStarmapResize(); - } - } - smUpdateStat(); - } - - // smLegend 生成图例小项。 - function smLegend(key, label) { - var col = smTypeColors[key] || 0xcccccc; - var hex = "#" + ("0000" + col.toString(16)).slice(-6); - return ( - '' + - '' + - escHtml(label) + - "" - ); - } - - // smUpdateStat 在星图页签头部显示节点/边/活动状态。 - function smUpdateStat() { - var el = document.getElementById("sm-page-stat"); - if (!el) return; - var act = state.starmapActivity; - var busy = act && (act.ready_queue_depth || act.pending_interrupts); - var txt = starmapNodes.length + " " + __("节点", "nodes"); - if (starmapEdges.length) txt += " / " + starmapEdges.length + " " + __("关系", "edges"); - if (busy) txt += " · " + __("调度中", "busy"); - el.textContent = txt; - } - - - function onStarmapClick(e) { - if (!starmapRen || !starmapCam) return; - var rect = starmapRen.domElement.getBoundingClientRect(); - var mouse = new THREE.Vector2( - ((e.clientX - rect.left) / rect.width) * 2 - 1, - -((e.clientY - rect.top) / rect.height) * 2 + 1, - ); - var rc = new THREE.Raycaster(); - rc.setFromCamera(mouse, starmapCam); - var hits = rc.intersectObjects(starmapNodeMeshes); - if (hits.length > 0) { - var n = hits[0].object; - starmapSelected = starmapSelected === n ? null : n; - if (starmapAutoView && starmapSelected) - flyStarmapTo(starmapSelected.userData.nodeId, 500); - onStarmapMove(e); - } else { - starmapSelected = null; - } - } - - function flyStarmapTo(nodeId, dur) { - if (!starmapAutoView) return; - var m = starmapNodeMeshes.find((x) => x.userData.nodeId === nodeId); - if (!m) return; - var tp = m.position.clone(), - sp = starmapCam.position.clone(), - st = starmapCtrl.target.clone(); - var dist = tp.length() + 25, - ep = new THREE.Vector3(tp.x, tp.y + dist * 0.4, tp.z + dist * 0.8); - var t0 = Date.now(); - (function lerp() { - var t = Math.min((Date.now() - t0) / dur, 1), - e = 1 - (1 - t) ** 3; - starmapCam.position.lerpVectors(sp, ep, e); - starmapCtrl.target.lerpVectors(st, tp, e); - if (t < 1) requestAnimationFrame(lerp); - })(); - } - - function onStarmapResize() { - if (!starmapRen || !starmapCam) return; - var cont = starmapRen.domElement.parentElement; - if (!cont) return; - var rect = cont.getBoundingClientRect(); - var w = rect.width || 800, - h = Math.max(rect.height || 250, 100); - if (w > 0 && h > 0) { - starmapCam.aspect = w / h; - starmapCam.updateProjectionMatrix(); - starmapRen.setSize(w, h); - } - } - - function starmapAnimate() { - starmapRaf = requestAnimationFrame(starmapAnimate); - if (starmapCtrl) starmapCtrl.update(); - if (starmapStarField) starmapStarField.rotation.y += 0.0001; - // 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。 - if (starmapNodeMeshes.length) starmapTickActivity(); - if (starmapRen && starmapScene && starmapCam) - starmapRen.render(starmapScene, starmapCam); - } - - function createNebula() { - var nc = 500; - var p = new Float32Array(nc * 3), - cl = new Float32Array(nc * 3); - for (var i = 0; i < nc; i++) { - var i3 = i * 3; - p[i3] = (Math.random() - 0.5) * 800; - p[i3 + 1] = (Math.random() - 0.5) * 800; - p[i3 + 2] = (Math.random() - 0.5) * 800; - var ch = Math.random(); - if (ch < 0.33) { - cl[i3] = 0.5 + Math.random() * 0.3; - cl[i3 + 1] = 0.2 + Math.random() * 0.2; - cl[i3 + 2] = 0.7 + Math.random() * 0.3; - } else if (ch < 0.66) { - cl[i3] = 0.2 + Math.random() * 0.2; - cl[i3 + 1] = 0.3 + Math.random() * 0.3; - cl[i3 + 2] = 0.8 + Math.random() * 0.2; - } else { - cl[i3] = 0.7 + Math.random() * 0.3; - cl[i3 + 1] = 0.2 + Math.random() * 0.2; - cl[i3 + 2] = 0.5 + Math.random() * 0.3; - } - } - var g = new THREE.BufferGeometry(); - g.setAttribute("position", new THREE.BufferAttribute(p, 3)); - g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); - var m = new THREE.PointsMaterial({ - size: 8, - vertexColors: true, - transparent: true, - opacity: 0.15, - sizeAttenuation: true, - blending: THREE.AdditiveBlending, - }); - var np = new THREE.Points(g, m); - starmapScene.add(np); - } - - // ===== Settings ===== - function pluginDisplayName(p) { - if (p === "core") return __("核心", "Core"); - var name = p.replace("plugin.", ""); - var meta = state.pluginMeta && state.pluginMeta[name]; - if (meta) - return state.lang === "en" - ? meta.name_en || name - : meta.name_zh || name; - return name; - } - - function renderSettingsSidebar() { - var el = document.querySelector(".settings-tabs"); - if (!el) return; - el.innerHTML = ""; - // 「外观」不是插件设置,但它是 webui 自己的配置,放在设置页最前面。 - var ap = document.createElement("span"); - ap.textContent = __("外观", "Appearance"); - if (state.selectedSection === "appearance") ap.className = "active"; - ap.onclick = () => { - state.selectedSection = "appearance"; - renderOneSettings(); - }; - el.appendChild(ap); - state.settingsPlugins.forEach((p) => { - var a = document.createElement("span"); - a.textContent = pluginDisplayName(p); - if (p === state.selectedSection) a.className = "active"; - a.onclick = () => { - state.selectedSection = p; - renderOneSettings(); - }; - el.appendChild(a); - }); - } - - // 外观设置区:主题(浅/深)+ 配色 + 背景图。与侧栏那个 palette-pop 同一套 - // setColor/applyBgImg/applyBgBlur,只是给它一个正式的、可发现的落点 - // (原来只有侧栏底部一个调色盘图标,找不到)。 - function appearanceHtml() { - var theme = document.documentElement.getAttribute("data-theme") || "light"; - var color = localStorage.getItem("ha-color") || "mono"; - var dots = ""; - Object.keys(PALETTES).forEach((k) => { - dots += - ''; - }); - var img = localStorage.getItem("ha-bg-img") || ""; - var blur = localStorage.getItem("ha-bg-blur") || "0"; - return ( - '

' + __("外观", "Appearance") + "

" + - '
' + __("主题", "Theme") + '' + - ' " + - '" + - "
" + - '
' + __("配色", "Color") + '' + dots + "
" + - "" + - '' + - '
' + - '" + - '" + - "
" + - '" + - '' + - '

' + - __("配色只影响本站点界面的强调色,不改动 Agent 的人格或数据。", "Colors only affect this dashboard's accent; agent behavior and data are unchanged.") + - "

" - ); - } - function pickTheme(name) { - saveTheme(name); - renderOneSettings(); - } - function pickColor(name) { - setColor(name); - renderOneSettings(); - } - - function renderOneSettings() { - if (state.selectedSection === "appearance") { - document.getElementById("tab-settings").innerHTML = - '
' + - appearanceHtml() + - "
"; - renderSettingsSidebar(); - return; - } - var prefix = state.selectedSection + "."; - var allKeys = Object.keys(state.settings || {}); - var filtered = allKeys.filter((k) => k === prefix.slice(0, -1) || k.startsWith(prefix)); - filtered.sort(); - var hideTopLlms = [ - "core.llm.base_url", - "core.llm.model", - "core.llm.api_key", - "core.llm.adapter", - "core.llm.adapter_path", - "core.llm.thinking_enabled", - ]; - var sourceKeys = filtered.filter((k) => k.startsWith("core.llm.sources.")); - var sourceMap = {}; - sourceKeys.forEach((k) => { - var parts = k.split("."); - var srcName = parts[3]; - if (!sourceMap[srcName]) sourceMap[srcName] = {}; - sourceMap[srcName][k] = true; - }); - var mcpServerKeys = filtered.filter((k) => ( - k.startsWith("plugin.mcp.servers.") && k.split(".").length >= 5 - )); - var mcpServerMap = {}; - mcpServerKeys.forEach((k) => { - var parts = k.split("."); - var srvName = parts[3]; - if (!mcpServerMap[srvName]) mcpServerMap[srvName] = {}; - mcpServerMap[srvName][k] = true; - }); - var regularKeys = filtered.filter((k) => ( - !k.startsWith("core.llm.sources.") && - hideTopLlms.indexOf(k) === -1 && - !k.startsWith("plugin.mcp.servers.") && - k !== "plugin.mcp.servers" - )); - var html = - '
'; - if ( - regularKeys.length === 0 && - Object.keys(sourceMap).length === 0 && - Object.keys(mcpServerMap).length === 0 && - state.selectedSection !== "plugin.mcp" - ) { - html += - '

' + - escHtml(state.selectedSection) + - '

' + - __("暂无设置项", "No settings") + - "

"; - } else { - regularKeys.forEach((k) => { - var v = state.settings[k]; - var sv = typeof v === "object" ? JSON.stringify(v) : String(v); - var m = state.meta?.[k]; - var shortName = k.split(".").pop().replace(/_/g, " "); - var label = m?.display_name || shortName; - var desc = m?.description || ""; - var typ = m?.type || "string"; - var ph = m?.placeholder || ""; - var opts = m?.options || []; - var inpId = "inp-" + k.replace(/\./g, "_"); - var inp = ""; - if (typ === "bool") { - var chk = sv === "true" ? "checked" : ""; - inp = - '"; - } else if (typ === "select") { - var selOpts = ""; - opts.forEach((o) => { - selOpts += - '"; - }); - inp = - ""; - } else if (typ === "text") { - inp = - ""; - } else { - inp = - ""; - } - var extra = ""; - if (m?.extra) { - m.extra.forEach((f) => { - var fk = (k ? k + "." : "") + f.key; - var fv = state.settings?.[fk]; - var fph = f.placeholder || __("输入", "Enter ") + f.label; - extra += - '
"; - if (f.type === "select") { - var fopts = ""; - if (f.options) - f.options.forEach((o) => { - fopts += - '"; - }); - extra += - ""; - } else { - extra += - ''; - } - extra += "
"; - }); - } - var descHtml = desc - ? '

' + - escHtml(desc) + - "

" - : ""; - html += - '
' + - escHtml(k) + - "
" + - inp + - descHtml + - extra + - '
"; - }); - // LLM Sources - Object.keys(sourceMap) - .sort() - .forEach((src) => { - var baseKey = "core.llm.sources." + src; - var srcData = - state.settings?.[baseKey + ".adapter"] || - state.settings?.[baseKey + ".base_url"] || - ""; - var fields = [ - { - key: "adapter", - label: __("适配器", "Adapter"), - type: "text", - }, - { key: "base_url", label: "Base URL", type: "text" }, - { key: "model", label: __("模型", "Model"), type: "text" }, - { key: "api_key", label: "API Key", type: "text" }, - { - key: "thinking_enabled", - label: __("思考模式", "Thinking Mode"), - type: "select", - options: ["true", "false"], - }, - { - key: "adapter_path", - label: __("适配器路径", "Adapter Path"), - type: "text", - }, - ]; - var headerLabel = mL10n(src, "LLM Source: " + src); - html += '

' + escHtml(headerLabel) + "

"; - fields.forEach((f) => { - var fk = baseKey + "." + f.key; - var fv = state.settings?.[fk] || ""; - var flabel = f.label; - var fieldId = "inp-" + fk.replace(/\./g, "_"); - if (f.type === "select") { - var fopts = ""; - f.options.forEach((o) => { - fopts += - '"; - }); - html += - ""; - } else { - html += - "'; - } - }); - html += - '
' + - '" + - '
"; - }); - if ( - Object.keys(sourceMap).length > 0 || - state.selectedSection === "core.llm" - ) { - html += - '"; - } - // MCP Servers - if ( - state.selectedSection === "plugin.mcp" || - Object.keys(mcpServerMap).length > 0 - ) { - html += - '

' + - __("MCP 服务器", "MCP Servers") + - '

' + - __( - "配置 Model Context Protocol 服务端连接", - "Configure Model Context Protocol server connections", - ) + - "

"; - Object.keys(mcpServerMap) - .sort() - .forEach((srv) => { - var baseKey = "plugin.mcp.servers." + srv; - var fields = [ - { - key: "command", - label: __("启动命令", "Command"), - type: "text", - }, - { key: "url", label: "SSE URL", type: "text" }, - { - key: "args", - label: __("参数(JSON数组)", "Args (JSON array)"), - type: "text", - }, - { - key: "env", - label: __("环境变量(JSON数组)", "Env (JSON array)"), - type: "text", - }, - ]; - html += '

' + escHtml(srv) + "

"; - fields.forEach((f) => { - var fk = baseKey + "." + f.key; - var fv = state.settings?.[fk] || ""; - var fieldId = "inp-" + fk.replace(/\./g, "_"); - html += - "'; - }); - html += - '
' + - '" + - '
"; - }); - html += - '"; - } - } - html += "
"; - document.getElementById("tab-settings").innerHTML = html; - renderSettingsSidebar(); - } - - function markDirty(k) { - var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); - if (inp) inp.style.borderColor = "var(--save-btn-border)"; - } - - async function saveSetting(k) { - var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); - if (!inp) return; - var val; - var m = state.meta?.[k]; - if (m?.type === "bool") { - val = inp.checked ? "true" : "false"; - } else if (m?.type === "select") { - val = inp.value; - } else { - var raw = inp.value; - try { - val = JSON.parse(raw); - } catch (e) { - val = raw; - } - } - try { - var r = await api("/settings", { - method: "PUT", - body: JSON.stringify({ key: k, value: val }), - }); - if (r.status === "ok") { - inp.style.borderColor = ""; - state.settings[k] = val; - toast(__("已保存: ", "Saved: ") + k); - } else { - toast( - __("保存失败: ", "Save failed: ") + (r.error || "unknown"), - true, - ); - } - } catch (e) { - toast(__("保存失败: ", "Save failed: ") + e.message, true); - } - } - - function renderConfigDisabled() { - document.getElementById("tab-settings").innerHTML = - '

' + - __("设置", "Settings") + - '

' + - __("设置面板已加载", "Settings panel loaded") + - "

"; - renderOneSettings(); - } - - function mL10n(key, fallback) { - var meta = state.meta?.[key]; - if (meta?.display_name) return meta.display_name; - return fallback || key; - } - - function showAddSourceDialog() { - var name = prompt( - __( - "输入新 LLM 源名称(如 openai、anthropic):", - "Enter new LLM source name (e.g. openai, anthropic):", - ), - ); - if (!name || !name.trim()) return; - name = name - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]/g, "_"); - if (!name) { - toast(__("名称无效", "Invalid name"), true); - return; - } - var keys = [ - "base_url", - "model", - "api_key", - "adapter", - "adapter_path", - "thinking_enabled", - ]; - var values = { - base_url: "https://api." + name + ".com", - model: "", - api_key: "", - adapter: name, - adapter_path: "", - thinking_enabled: "false", - }; - var promises = keys.map((f) => api("/settings", { - method: "PUT", - body: JSON.stringify({ - key: "core.llm.sources." + name + "." + f, - value: values[f], - }), - })); - Promise.all(promises) - .then(() => { - toast( - __("源", "Source") + - ' "' + - name + - '" ' + - __( - "已创建,请配置各项参数", - "created, please configure parameters", - ), - ); - renderAll(); - }) - .catch((e) => { - toast(__("创建失败: ", "Create failed: ") + e.message, true); - }); - } - - async function deleteSource(name) { - if ( - !confirm( - __("确认删除源", "Are you sure to delete source") + - ' "' + - name + - '"?', - ) - ) - return; - var base = "core.llm.sources." + name; - var fields = [ - "adapter", - "base_url", - "model", - "api_key", - "thinking_enabled", - "adapter_path", - ]; - try { - for (var f of fields) { - await api("/settings", { - method: "PUT", - body: JSON.stringify({ key: base + "." + f, value: null }), - }); - } - toast( - __("源", "Source") + ' "' + name + '" ' + __("已删除", "deleted"), - ); - renderAll(); - } catch (e) { - toast(__("删除失败: ", "Delete failed: ") + e.message, true); - } - } - - function addMCPSource() { - var name = prompt( - __("输入新 MCP 服务器名称:", "Enter new MCP server name:"), - ); - if (!name || !name.trim()) return; - name = name - .trim() - .toLowerCase() - .replace(/[^a-z0-9_]/g, "_"); - if (!name) { - toast(__("名称无效", "Invalid name"), true); - return; - } - var fields = ["command", "url", "args", "env"]; - var values = { command: "", url: "", args: "[]", env: "[]" }; - var promises = fields.map((f) => api("/settings", { - method: "PUT", - body: JSON.stringify({ - key: "plugin.mcp.servers." + name + "." + f, - value: values[f], - }), - })); - Promise.all(promises) - .then(() => { - toast( - "MCP " + - __("服务器", "server") + - ' "' + - name + - '" ' + - __("已创建", "created"), - ); - renderAll(); - }) - .catch((e) => { - toast(__("创建失败: ", "Create failed: ") + e.message, true); - }); - } - - async function deleteMCPServer(name) { - if ( - !confirm( - __("确认删除 MCP 服务器", "Are you sure to delete MCP server") + - ' "' + - name + - '"?', - ) - ) - return; - var fields = ["command", "url", "args", "env"]; - try { - for (var f of fields) { - await api("/settings", { - method: "PUT", - body: JSON.stringify({ - key: "plugin.mcp.servers." + name + "." + f, - value: null, - }), - }); - } - toast( - "MCP " + - __("服务器", "server") + - ' "' + - name + - '" ' + - __("已删除", "deleted"), - ); - renderAll(); - } catch (e) { - toast(__("删除失败: ", "Delete failed: ") + e.message, true); - } - } - - // ===== Adapters ===== - async function renderAdapters() { - var html = ""; - try { - var r = await api("/adapters"); - var adapters = r.adapters || []; - window._adapters = adapters; - html += - '

' + - __("已加载的适配器", "Loaded Adapters") + - " (" + - adapters.length + - ")

"; - if (adapters.length === 0) { - html += - '

' + - __("暂无适配器", "No adapters") + - "

"; - } else { - html += - ""; - adapters.forEach((a) => { - html += - "" + - '"; - }); - html += "
" + - __("名称", "Name") + - "" + - __("版本", "Version") + - "" + - __("操作", "Actions") + - "
" + - escHtml(a.name) + - "" + - escHtml(a.version || "-") + - "
"; - } - html += "
"; - html += - '

' + - __("上传新适配器", "Upload New Adapter") + - "

" + - "" + - '' + - "" + - '' + - '
"; - } catch (e) { - html += - '

' + - __("加载适配器失败: ", "Failed to load adapters: ") + - escHtml(e.message) + - "

"; - } - document.getElementById("tab-adapters").innerHTML = html; - } - - async function uploadAdapter() { - var name = document.getElementById("adapter-name")?.value; - var code = document.getElementById("adapter-code")?.value; - if (!name || !code) { - toast( - __("名称和代码不能为空", "Name and code cannot be empty"), - true, - ); - return; - } - try { - var r = await api("/adapters", { - method: "POST", - body: JSON.stringify({ name: name, code: code }), - }); - if (r.status === "loaded") { - toast( - __("适配器", "Adapter") + - ' "' + - name + - '" ' + - __("已加载", "loaded"), - ); - renderAdapters(); - } else { - toast( - __("上传失败: ", "Upload failed: ") + (r.error || "unknown"), - true, - ); - } - } catch (e) { - toast(__("上传失败: ", "Upload failed: ") + e.message, true); - } - } - - async function deleteAdapter(name) { - if ( - !confirm( - __("确定删除适配器", "Are you sure to delete adapter") + - ' "' + - name + - '"?', - ) - ) - return; - try { - var r = await api("/adapters/" + encodeURIComponent(name), { - method: "DELETE", - }); - if (r.status === "deleted") { - toast( - __("适配器", "Adapter") + - ' "' + - name + - '" ' + - __("已删除", "deleted"), - ); - renderAdapters(); - } else { - toast(__("删除失败", "Delete failed"), true); - } - } catch (e) { - toast(__("删除失败: ", "Delete failed: ") + e.message, true); - } - } - - // ===== Init ===== - async function logout() { - try { - await fetch("/api/v1/logout", { - method: "POST", - credentials: "include", - }); - } catch (e) {} - location.href = "/login"; - } - - renderConfigDisabled(); - // 先加载历史再连 SSE:避免 SSE 事件先到与历史加载顺序不确定导致消息重复/丢失 - (async () => { - await loadChatHistory(); - renderAll(); - connectSSE(); - startUptimeTicker(); - startRuntimeTicker(); - startChatTicker(); - maybeShowPersonaWizard(); - })(); - setInterval(renderAll, 15000); - // 消息同步轮询兜底:每30秒增量同步 chatHistory,补偿 SSE 断连窗口期 - // 丢失的事件(尤其是非 WebUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。 - // syncChatFromHistory 仅追加新消息 DOM 节点,不重建已有消息,无闪烁。 - setInterval(() => { - syncChatFromHistory().catch(()=> {}); - }, 30000); +// ===== State ===== +const state = { + status: {}, + kernel: null, + settings: {}, + meta: {}, + runtime: null, // /api/v1/runtime 的运行态小快照(调度器/驻留子/通道) + pluginMeta: {}, + disabledPlugins: [], + settingsPlugins: ["core"], + selectedSection: "core", + messages: [], + chatLoading: false, + chatStage: "", + pipelinePhase: "", // 当前阶段(SSE stage 事件驱动总览页) + pipelineTimer: null, + stageTrail: [], // 本轮已发生的事件轨迹(工具/输出调用,供阶段管道展示循环) + toolFlash: false, // 本轮新到一条工具调用:本轮渲染后给「工具」格放一次滑入动画 + healthResult: null, + starmapInit: false, + starmapLoading: false, + starmapData: null, + // ===== 星图:跟随 agent 活动 ===== + // 三路活动信号(均已在页面上,无需新数据源): + // 1. toolPulseMs —— SSE tool_call/stage 触发的「脉冲」,实时 + // 2. activity —— /runtime 调度器计数(排队/中断/抢占),3s 轮询 + // 3. grownIds —— /memory/graph/pulse 检出「新长出来」的节点,10s 轮询 + starmapPulses: [], // { mesh, until, kind } 活动脉冲队列 + starmapGrown: {}, // nodeId -> 生长动画截止时间戳 + starmapActivity: null, // /runtime 的调度器快照 + starmapPulseTimer: null, // /memory/graph/pulse 轮询 id + starmapActivityTimer: null, // /runtime 轮询 id + starmapLastPulseAt: 0, // 最后一次活动时间(ms),驱动全局呼吸 + starmapPulseSince: 0, // 下次 pulse 请求的回看起点(unix 秒) + chatHistory: [], + terminals: [], + cmdHistory: [], + termScreens: {}, + chatStick: true, + pendingTools: [], + eventSource: null, + chatFinalIdx: -1, + chatOffset: 0, // 分段历史:当前已加载消息在服务端全量中的起始下标 + chatTotal: 0, // 服务端历史总条数 + chatHasMore: false, // 是否还有更早历史可向上加载 + chatLastSeq: 0, // 增量轮询游标:已合并到本地的最大消息 seq + lang: localStorage.getItem("ha-lang") || "zh", +}; + +// ===== I18n ===== +window._i18n = { + navOverview: ["概览", "Overview"], + navChat: ["对话", "Chat"], + navStarmap: ["星图", "Star Map"], + navPlugins: ["插件", "Plugins"], + navSettings: ["设置", "Settings"], + navAdapters: ["适配器", "Adapters"], + navKernel: ["内核", "Kernel"], + navLogout: ["退出登录", "Logout"], + themeToggle: ["切换亮色/暗色模式", "Toggle theme"], +}; + +function __(zh, en) { + return state.lang === "en" ? en : zh; +} +function L() { + return state.lang; +} + +function toggleLang() { + state.lang = state.lang === "zh" ? "en" : "zh"; + localStorage.setItem("ha-lang", state.lang); + document.querySelectorAll("[data-i18n]").forEach((el) => { + var k = el.getAttribute("data-i18n"); + var m = window._i18n && window._i18n[k]; + if (m) el.textContent = __(m[0], m[1]); + }); + renderAll(); +} + +function applyI18n() { + var lang = state.lang; + var btn = document.getElementById("lang-btn"); + if (btn) btn.textContent = lang === "zh" ? "EN" : "中"; + document.querySelectorAll("[data-i18n]").forEach((el) => { + var k = el.getAttribute("data-i18n"); + var m = window._i18n && window._i18n[k]; + if (m) el.textContent = lang === "en" ? m[1] : m[0]; + }); +} + +// ===== Theme ===== +var ICON_SUN = + ''; +var ICON_MOON = + ''; + +function setTheme(name) { + document.documentElement.setAttribute("data-theme", name); + var btn = document.getElementById("theme-btn"); + if (btn) btn.innerHTML = name === "light" ? ICON_SUN : ICON_MOON; +} + +function saveTheme(name) { + localStorage.setItem("ha-theme", name); + setTheme(name); +} + +function toggleTheme() { + var cur = document.documentElement.getAttribute("data-theme"); + saveTheme(cur === "light" ? "dark" : "light"); +} + +var PALETTES = { + // mono 放第一位:它现在是默认配色(黑白单调),也应当是选择器里最显眼那个。 + mono: "#c2c7d0", + sakura: "#ff7fac", + cyan: "#2dd4bf", + violet: "#a78bfa", + emerald: "#34d399", + amber: "#fbbf24", + blue: "#60a5fa", +}; + +function setColor(name) { + document.documentElement.setAttribute("data-color", name); + localStorage.setItem("ha-color", name); + // 配色变了要强制重画运行态:拓扑的归属配色跟着 data-color 走, + // 而数据签名没变,不置空 _rtSig 的话图会一直停在旧色。 + _rtSig = null; + if (document.getElementById("rt-sec-topo")) { + try { + renderRuntime(); + } catch (e) {} + } + var pop = document.getElementById("palette-pop"); + if (!pop) return; + var btns = pop.querySelectorAll("button.cdot"); + for (var i = 0; i < btns.length; i++) { + btns[i].className = + btns[i].getAttribute("data-c") === name ? "cdot on" : "cdot"; + } +} + +function applyBgImg(url) { + url = (url || "").trim(); + if (!url) { + document.documentElement.style.setProperty("--bg-img", "none"); + localStorage.removeItem("ha-bg-img"); + return; + } + document.documentElement.style.setProperty( + "--bg-img", + 'url("' + url.replace(/"/g, '\\"') + '")', + ); + localStorage.setItem("ha-bg-img", url); +} + +function applyBgBlur(n) { + n = Math.max(0, Math.min(30, Number(n) || 0)); + document.documentElement.style.setProperty("--bg-blur", String(n)); + localStorage.setItem("ha-bg-blur", String(n)); + var v = document.getElementById("bg-blur-val"); + if (v) v.textContent = n + "px"; + var r = document.getElementById("bg-blur-range"); + if (r) r.value = String(n); +} + +function toggleAppearance() { + var pop = document.getElementById("palette-pop"); + if (!pop) return; + var on = pop.classList.contains("on"); + if (!pop.querySelector("button.cdot")) { + var cur = localStorage.getItem("ha-color") || "mono"; + var img = localStorage.getItem("ha-bg-img") || ""; + var blur = localStorage.getItem("ha-bg-blur") || "0"; + var dots = ""; + Object.keys(PALETTES).forEach((k) => { + dots += + '"; + }); + pop.innerHTML = + "

主题色

" + + dots + + "
" + + '

背景图片 URL

' + + '' + + '
' + + '' + + '模糊 ' + + '' + + '' + + blur + + "px
"; + setColor(cur); + var rr = document.getElementById("bg-blur-range"); + if (rr) rr.value = blur; + var vv = document.getElementById("bg-blur-val"); + if (vv) vv.textContent = blur + "px"; + } + pop.classList.toggle("on", !on); +} + +(function initAppearance() { + var img = localStorage.getItem("ha-bg-img"); + var blur = localStorage.getItem("ha-bg-blur"); + if (img) applyBgImg(img); + if (blur) applyBgBlur(blur); +})(); + +(() => { + var saved = localStorage.getItem("ha-theme"); + setTheme(saved || "light"); + // 默认配色 = 黑白:必须**主动** setColor 一次。只设 data-theme 时 + // base 主题仍是樱粉(:root 的 --accent 就是粉色),不写 data-color 就 + // 会默认花。用户选过别的颜色时以 localStorage 为准。 + setColor(localStorage.getItem("ha-color") || "mono"); + var sb = localStorage.getItem("ha-sidebar"); + if (sb === "1" || (!sb && window.innerWidth <= 768)) { + document.body.classList.add("sidebar-collapsed"); + } +})(); + +// 字节数人性化显示(附件卡片用) +function formatBytes(n) { + if (!n || n <= 0) return ""; + var units = ["B", "KB", "MB", "GB"]; + var i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + return (i === 0 ? n : n.toFixed(1)) + " " + units[i]; +} + +// ===== Utility ===== +function renderMd(text) { + if (typeof text !== "string") text = String(text || ""); + var html; + if (typeof marked !== "undefined") { + try { + html = marked.parse(text); + } catch (e) { + html = escHtml(text); + } + } else { + html = "
" + escHtml(text) + "
"; + } + if ( + typeof DOMPurify !== "undefined" && + typeof DOMPurify.sanitize === "function" + ) { + try { + return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); + } catch (e) {} + } + return html + .replace(//gi, "") + .replace(/\son\w+\s*=\s*"[^"]*"/gi, "") + .replace(/\son\w+\s*=\s*'[^']*'/gi, "") + .replace(/javascript:/gi, ""); +} +function escHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function toast(m, isError) { + var t = document.getElementById("toast"); + t.textContent = m; + t.className = "toast" + (isError ? " error" : ""); + t.style.display = "block"; + clearTimeout(t._hideTimer); + t._hideTimer = setTimeout(() => { + t.style.display = "none"; + }, 3000); +} + +// ===== 8.6 Unified toast + confirm dialog ===== + +// ===== 8.4 Card 3D tilt + cursor glow ===== +document.addEventListener("mousemove", (e) => { + var card = e.target.closest ? e.target.closest(".card.tilt") : null; + if (card) { + var r = card.getBoundingClientRect(); + card.style.setProperty("--mx", e.clientX - r.left + "px"); + card.style.setProperty("--my", e.clientY - r.top + "px"); + var rx = ((e.clientY - r.top) / r.height - 0.5) * -4; + var ry = ((e.clientX - r.left) / r.width - 0.5) * 4; + card.style.transform = + "perspective(1000px) rotateX(" + + rx.toFixed(2) + + "deg) rotateY(" + + ry.toFixed(2) + + "deg) translateY(-1px)"; + } +}); +document.addEventListener("mouseleave", (e) => { + var card = e.target.closest ? e.target.closest(".card.tilt") : null; + if (card) card.style.transform = ""; +}); + +// ===== 首启人格向导 ===== +// 人格是配置项(core.agent.personal_prompt,默认模板不含任何版本号)。 +// 首次启动问一次「默认 / 自定义 / 稍后」,之后不再打扰; +// 不回答 = 稍后 = 保留默认人格,绝不阻塞启动。 +async function maybeShowPersonaWizard() { + var st; + try { + st = await api("/persona"); + } catch (e) { + return; // 拿不到状态就不打扰用户 + } + if (!st || st.initialized) return; + var ov = document.createElement("div"); + ov.className = "confirm-overlay"; + ov.style.display = "flex"; + ov.innerHTML = + '
' + + "

" + + escHtml(__("人格设定", "Persona")) + + "

" + + "

" + + escHtml( + __( + "首次启动:选一下助手的人格。选「使用默认」即可(之后可在设置里修改);自定义内容在下次重启后生效。", + 'First run: pick your assistant\'s persona. "Use default" is fine (change it later in Settings); custom content takes effect after the next restart.', + ), + ) + + "

" + + '
' + + '' + + '
' + + '" + + '" + + '" + + "
"; + document.body.appendChild(ov); + var ta = ov.querySelector(".persona-ta"); + var warn = ov.querySelector(".persona-warn"); + var customOpen = false; + if (st.file_override) { + warn.style.display = "block"; + warn.textContent = __( + "注意:检测到 personal/personal.md,它优先于这里的设置。", + "Note: personal/personal.md exists and takes precedence over this choice.", + ); + } + function close() { + ov.remove(); + } + async function submit(mode, content) { + try { + var r = await api("/persona", { + method: "POST", + body: JSON.stringify({ mode: mode, content: content || "" }), + }); + if (r && r.restart_required) + toast(__("已保存,重启后生效", "Saved; takes effect after restart")); + else toast(__("已保存", "Saved")); + } catch (e) { + toast(__("保存失败:", "Save failed: ") + e, true); + } + close(); + } + ov.querySelectorAll("button[data-mode]").forEach((b) => { + b.onclick = () => { + var mode = b.getAttribute("data-mode"); + if (mode !== "custom") { + submit(mode); + return; + } + if (!customOpen) { + // 第一次点:展开文本域并预填当前人格 + customOpen = true; + ta.style.display = "block"; + ta.value = st.current_prompt || ""; + ta.focus(); + return; + } + if (!ta.value.trim()) { + toast(__("内容不能为空", "Content cannot be empty"), true); + return; + } + submit("custom", ta.value); + }; + }); +} + +// ===== API ===== +async function api(p, o) { + var opts = { + credentials: "include", + headers: { "Content-Type": "application/json", ...o?.headers }, + ...o, + }; + // FormData 时不能手动设 Content-Type(浏览器需自动生成 multipart boundary) + if (o?.body instanceof FormData) { + delete opts.headers["Content-Type"]; + } + var r = await fetch("/api/v1" + p, opts); + if (r.status === 401) { + location.href = "/login"; + throw new Error("unauthorized"); + } + if (opts.raw) return r; + var ct = r.headers.get("content-type") || ""; + if (ct.includes("json")) return r.json(); + return r.text(); +} + +// ===== Navigation ===== +function switchTab(n) { + document.querySelectorAll(".tab-content").forEach((e) => { + e.classList.remove("active"); + }); + var el = document.getElementById("tab-" + n); + if (el) el.classList.add("active"); + document.querySelectorAll("nav a").forEach((e) => { + e.classList.remove("active"); + }); + var match = document.querySelector('nav a[onclick*="' + n + '"]'); + if (match) match.classList.add("active"); + var crumb = document.querySelector(".crumb-current"); + if (crumb) { + var crumbKey = "nav" + n.charAt(0).toUpperCase() + n.slice(1); + var m = window._i18n && window._i18n[crumbKey]; + crumb.setAttribute("data-i18n", crumbKey); + if (m) crumb.textContent = __(m[0], m[1]); + else crumb.textContent = n; + } + if (window.innerWidth <= 768) { + document.body.classList.add("sidebar-collapsed"); + } + if (n === "chat") { + state.chatStick = true; + var msgsEl = document.getElementById("chat-msgs"); + if (msgsEl) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } + initChatDragDrop(); + } + if (n === "starmap") { + // 独立星图页签:把 canvas 搬过来并按新容器尺寸重算。 + // 必须在 renderAll 之前做,否则 renderChatStarmap 会按上一次的 + // 活跃容器(总览页)算尺寸。 + var sc = document.getElementById("sm-container-page"); + if (sc) { + if (starmapRen) { + if (starmapRen.domElement.parentElement !== sc) { + sc.appendChild(starmapRen.domElement); + starmapRen.domElement.style.display = "block"; + } + onStarmapResize(); + } else if (!state.starmapInit && !state.starmapLoading) { + loadChatStarmapData(); + } + } + } + renderAll(); +} + +function toggleSidebar() { + document.body.classList.toggle("sidebar-collapsed"); + var saved = localStorage.getItem("ha-sidebar"); + localStorage.setItem( + "ha-sidebar", + document.body.classList.contains("sidebar-collapsed") ? "1" : "0", + ); +} + +// ===== Tab Render Dispatch ===== +async function renderAll() { + try { + var s = await api("/status"); + state.status = s; + state.startedAt = s.startedAt ? new Date(s.startedAt).getTime() : null; + } catch (e) {} + try { + state.kernel = await api("/kernel"); + } catch (e) {} + try { + var s = await api("/settings"); + state.settings = s.settings || {}; + state.meta = s.meta || {}; + state.settingsPlugins = s.plugins || ["core"]; + state.pluginMeta = s.plugin_meta || {}; + state.disabledPlugins = s.disabled_plugins || []; + } catch (e) {} + try { + state.installedPlugins = await api("/plugins"); + } catch (e) {} + try { + await loadProxyServices(); + } catch (e) {} + try { + await loadTerminals(); + } catch (e) {} + try { + await loadCmdHistory(); + } catch (e) {} + try { + renderOverview(); + } catch (e) { + console.error("renderOverview", e); + } + try { + await loadRuntime(); + } catch (e) { + console.error("loadRuntime", e); + } + try { + renderChat(); + } catch (e) { + console.error("renderChat", e); + } + try { + renderChatStarmap(); + } catch (e) { + console.error("renderChatStarmap", e); + } + try { + renderStarmapTab(); + } catch (e) { + console.error("renderStarmapTab", e); + } + try { + renderPlugins(); + } catch (e) { + console.error("renderPlugins", e); + } + try { + renderKernel(); + } catch (e) { + console.error("renderKernel", e); + } + try { + renderOneSettings(); + } catch (e) { + console.error("renderOneSettings", e); + } + try { + renderAdapters(); + } catch (e) { + console.error("renderAdapters", e); + } + try { + if (!window.matchMedia("(hover: none)").matches) { + document + .querySelectorAll( + "#tab-overview .card, #tab-plugins .card, #tab-kernel .card", + ) + .forEach((c) => { + if (!c.querySelector(".tilt-glow")) { + var g = document.createElement("span"); + g.className = "tilt-glow"; + c.appendChild(g); + c.classList.add("tilt"); + } + }); + } + } catch (e) {} + applyI18n(); +} + +// fmtBuild 把构建身份压成一行:commit + 构建时间(日期部分)。 +// +// 未注入 ldflags 的本地构建会得到 "unknown",此时只显示 SDK 版本, +// 避免把 "unknown / unknown" 这种噪音摆在概览卡上。 +function fmtBuild(b, s) { + var parts = []; + var commit = (b && b.commit) || (s && s.commit) || ""; + if (commit && commit !== "unknown") parts.push(commit); + var bt = (b && b.build_time) || (s && s.build_time) || ""; + if (bt && bt !== "unknown") { + // 2026-09-03T05:55:35Z → 2026-09-03 + parts.push(String(bt).split("T")[0]); + } + var sdkv = (b && b.sdk_compatible) || (s && s.sdk_version) || ""; + if (sdkv) parts.push("SDK " + sdkv); + return parts.length ? parts.join(" · ") : "-"; +} + +function fmtUptime(ms) { + var s = Math.floor(ms / 1000); + if (s < 60) return s + "s"; + var m = Math.floor(s / 60); + s = s % 60; + if (m < 60) return m + "m " + s + "s"; + var h = Math.floor(m / 60); + m = m % 60; + return h + "h " + m + "m " + s + "s"; +} + +var uptimeTick = null; +function startUptimeTicker() { + if (uptimeTick) clearInterval(uptimeTick); + uptimeTick = setInterval(() => { + var el = document.querySelector("#ov-uptime"); + if (el && state.startedAt) { + var now = Date.now(); + el.textContent = fmtUptime(now - state.startedAt); + } else if (!state.startedAt) { + var el2 = document.querySelector("#ov-uptime"); + if (el2) el2.textContent = "-"; + } + }, 1000); +} + +// ===== Overview ===== + +// ===== 运行态面板:把内核的调度器/驻留子/通道画出来 ===== +// +// 数据来自 /api/v1/runtime(内核 internal/sdk 暴露的 KernelStatus 子集), +// 每 3 秒刷一次——运行态要"实时",而 /kernel 是 30KB 级的全量状态,不适合秒级轮询。 +// +// 面板从上到下:四个数字块回答"现在忙不忙";阶段管道滑块回答"这一轮走到哪"; +// 五条队列条形 + 中断栈回答"堵在哪一级/压了几层";最后的分带拓扑回答 +// "哪条输入喂给哪个 agent、哪个 agent 往哪条输出写、各自负载多高"。 +// 四级语义直接照抄内核(internal/agent/core/scheduler.go 的 Level 定义), +// 别自己起名字——前端叫法一旦和内核不一致,看板就成了误导。 +var RT_LEVELS = [ + { lv: 4, name: "L4", desc: "内核独占", cls: "rt-lv-4" }, + { lv: 3, name: "L3", desc: "交互", cls: "rt-lv-3" }, + { lv: 2, name: "L2", desc: "消息", cls: "rt-lv-2" }, + { lv: 1, name: "L1", desc: "后台", cls: "rt-lv-1" }, +]; +var RT_CAP_NAMES = { + 1: "text", + 2: "file", + 4: "image", + 8: "audio", + 16: "structured", +}; + +// _rtSig 缓存上一次渲染的数据签名。 +// +// 为什么必须缓存:运行态每 3s 轮询一次,数据绝大多数时候是**没变**的; +// 无条件 `innerHTML =` 会把整块 DOM(含各级条的 transition)每 3s 重建一遍, +// 视觉效果就是“首页一闪一闪”。签名相同就一个字节也不动。 +var _rtSig = null; + +// morph:把容器的 DOM 就地「形变」成 html 描述的样子。 +// +// 为什么不用 innerHTML = html:整块重建会把**没变的**节点也换掉 —— +// SVG 过渡从头播、图片重解码、展开态丢失、滚动锚点重置。运行态每几秒 +// 刷一次,未变节点占绝大多数,那些闪烁就是这么来的。 +// +// 做法:按「子节点位置 + nodeName」递归对账。同名元素复用同一个 DOM 节点, +// 只同步发生变化的属性与文本;只有标签真的不同才替换。元素身份不变 ⇒ +// CSS 过渡继续、不闪。 +function morph(container, html) { + var tpl = document.createElement("div"); + tpl.innerHTML = html; + morphChildren(container, tpl); +} +function morphChildren(oldParent, newParent) { + var olds = Array.prototype.slice.call(oldParent.childNodes); + var news = Array.prototype.slice.call(newParent.childNodes); + var n = Math.max(olds.length, news.length); + for (var i = 0; i < n; i++) { + var o = olds[i], + x = news[i]; + if (!o && x) { + oldParent.appendChild(x); + continue; + } + if (o && !x) { + oldParent.removeChild(o); + continue; + } + if (o.nodeType !== x.nodeType || o.nodeName !== x.nodeName) { + oldParent.replaceChild(x, o); + continue; + } + if (o.nodeType === 3 || o.nodeType === 8) { + if (o.nodeValue !== x.nodeValue) o.nodeValue = x.nodeValue; + continue; + } + morphAttrs(o, x); + morphChildren(o, x); + } +} +function morphAttrs(o, x) { + var i, a; + for (i = o.attributes.length - 1; i >= 0; i--) { + a = o.attributes[i]; + if (!x.hasAttribute(a.name)) o.removeAttribute(a.name); + } + for (i = 0; i < x.attributes.length; i++) { + a = x.attributes[i]; + if (o.getAttribute(a.name) !== a.value) o.setAttribute(a.name, a.value); + } +} + +// rtSlider 把“值 / 上限”画成轨道 + 滑块 + 读数(比纯文本数字直观)。 +function rtSlider(value, max, label, display) { + var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; + return ( + '' + + escHtml(label) + + '' + + '' + + escHtml(String(display === undefined ? value : display)) + + "" + ); +} + +// rtMini 是行内迷你条(登记/抢占这类要并排两个的量)。 +function rtMini(value, max, label) { + var pct = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; + return ( + '' + + value + + "" + ); +} + +function rtTile(num, label, sub, pct, active, warn) { + return ( + '
' + + num + + '
' + + label + + "
" + + (sub ? '
' + sub + "
" : "") + + '
' + ); +} + +function rtCapsHtml(caps) { + var out = ""; + Object.keys(RT_CAP_NAMES).forEach((bit) => { + if (caps & Number(bit)) { + out += '' + RT_CAP_NAMES[bit] + ""; + } + }); + return out ? '' + out + "" : ""; +} + +// 归属配色:根用青色,驻留子轮转其余颜色(同一张图里能一眼分清谁是谁)。 +var RT_OWNER_COLORS = [ + "#88c0d0", + "#b48ead", + "#a3be8c", + "#ebcb8b", + "#d08770", + "#8fbcbb", +]; +// 黑白配色下,拓扑归属也用灰阶 —— 否则整页只剩这张图还是一片彩。 +var RT_OWNER_COLORS_MONO = [ + "#e6e9ef", + "#c2c7d0", + "#9aa0ad", + "#7b818d", + "#5f6570", + "#454a55", +]; +function rtOwnerColors() { + return document.documentElement.getAttribute("data-color") === "mono" + ? RT_OWNER_COLORS_MONO + : RT_OWNER_COLORS; +} + +// rtOwnerGroups 把 inputch 按**归属**分组(owner == 根 agent id 与 "" 归一为根)。 +function rtOwnerGroups(inputs, residents, rootID) { + var order = []; + var map = {}; + inputs.forEach((c) => { + var o = c.owner || ""; + if (o === rootID) o = ""; + if (!map[o]) { + map[o] = []; + order.push(o); + } + map[o].push(c); + }); + // 驻留子即使一条 inputch 都没划到也要出现——否则「子存在但看不见」 + // 与「子不存在」无法区分。 + residents.forEach((r) => { + var o = r.id || ""; + if (o && o !== rootID && !map[o]) { + map[o] = []; + order.push(o); + } + }); + order.sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return a < b ? -1 : 1; + }); + var ci = 0; + var OW = rtOwnerColors(); + return order.map((o) => { + var res = null; + residents.forEach((r) => { + if (r.id === o) res = r; + }); + var list = map[o] || []; + return { + owner: o, + child: o !== "", + label: + o === "" + ? __("根 agent / 内核默认", "root agent / kernel default") + : __("驻留子 ", "resident ") + o, + color: o === "" ? OW[0] : OW[1 + (ci++ % (OW.length - 1))], + list: list, + count: list.length, + res: res, + }; + }); +} + +// ---- 阶段管道滑块 ---- +// +// 七阶段与内核 sdk.Stage 一一对应(顺序即执行顺序),由 SSE `stage` 事件驱动: +// 哪个阶段在执行,滑块就滑到哪一格。空闲时整条管道降透明度,不做假动画。 +// +// 为什么放进总览:阶段管道回答"这一轮走到哪一步",总览其余图形回答"积压了多少", +// 两者合起来才是运行态。此前它只是对话页一个 10px 的角标,等于看不见。 +// 阶段管道:**循环流程 + 本轮轨迹**,不是单向滑块。 +// +// 一轮里工具调用会反复回到「行动后」再接下一个工具(post_action → +// before_toolcall → after_toolcall → post_action …),还可能中途多次 +// 输出,所以线性滑块本身就是错的表述。这里画成 +// 输入 → 行动 ⇄(工具) → 输出 → 结束 +// (工具那格带循环标记),下面再用一排 chip 记下**本轮真实发生过什么**: +// 普通工具与 output_* 输出通道调用用不同配色区分开来。 +// 页面上**任何位置都不用 emoji/符号字符充当图标** —— 一律内联 SVG, +// 24x24 viewBox + currentColor stroke,随主题与状态变色,不额外引资源。 +var RT_ICO = { + in: '', + act: '', + tool: '', + out: '', + done: '', + loop: '', + bolt: '', + caret: + '', +}; + +var RT_PIPE_GROUPS = [ + { zh: "输入", en: "in", ico: "in" }, + { zh: "行动", en: "act", ico: "act" }, + { zh: "工具", en: "tool", ico: "tool", loop: true }, + { zh: "输出", en: "out", ico: "out" }, + { zh: "结束", en: "done", ico: "done" }, +]; +function rtPhaseGroup(phase) { + switch (phase) { + case "on_input": + return 0; + case "pre_action": + case "post_action": + return 1; + case "before_toolcall": + case "after_toolcall": + return 2; + case "before_output": + return 3; + case "after_output": + return 4; + } + return -1; +} +// rtShortTool 把 `qq_get_message` / `output_send__qq` 压成尾段短名。 +function rtShortTool(name) { + var n = String(name || ""); + var i = n.lastIndexOf("__"); + if (i >= 0) n = n.slice(i + 2); + return n.length > 14 ? n.slice(0, 13) + "…" : n; +} +// rtSlots 画一组「车位」式格槽:槽位数量固定可见,被占用的点亮。 +// +// 为什么不用进度条:队列为 0 时进度条宽度就是 0,整行只剩一串文字, +// 看上去就是「这块空着」。格槽在 0 时仍有形状,占用多少一眼可数, +// 也不用为了「看得见」而给 0 画一条假进度。 +function rtSlots(depth, slots, extraCls) { + var n = Math.max(5, Math.min(16, slots || 5)); + var d = depth || 0; + var out = ''; + for (var i = 0; i < n; i++) + out += ''; + // 溢出计数必须留在 .rt-slots 内:格槽是 flex 行,多一个兄弟节点会被挤出去, + // 多一个兄弟节点会被挤到下一行,整块队列就串了。 + if (d > n) out += '+' + (d - n) + ""; + return out + ""; +} +// rtChipEl 画一枚事件 chip。工具与输出通道调用各有配色;名称包进 +// .rt-chip-t,让窄框里由它做省略号截断(.rt-chip 本身是 flex 容器, +// text-overflow 对直接子文本不生效,会被硬切掉半截)。 +function rtChipEl(t) { + var kind = t.kind || "stage"; + var ico = kind === "output" ? RT_ICO.out : kind === "tool" ? RT_ICO.tool : ""; + return ( + '' + + ico + + '' + + escHtml(t.short || t.label) + + "" + + (t.n > 1 ? 'x' + t.n + "" : "") + + "" + ); +} +// rtPipelineHtml 画阶段管道:**五个等大的表框**,每框里是本阶段本轮发生的事。 +// +// 为什么不是「小圆点 + 连接线 + 9px 小字」:那种画法在总览里几乎读不出 +// 「现在走到哪一步」,尺寸也远小于旁边的 KPI 框。现在与中断队列、KPI 用同 +// 一套视觉语言(等大框 + 大号加粗标题),并且事件直接落在它所属的框里, +// 所以「这一步发生了什么」不需要靠颜色或图例去猜。 +function rtPipelineHtml(phase, trail) { + var g = rtPhaseGroup(phase); + var cells = RT_PIPE_GROUPS.map((s, i) => { + var items = (trail || []).filter((t) => (t.g | 0) === i); + // 「工具」格是**循环**格:一轮里可能调几十次工具/输出通道。把每一次都 + // 追加成 chip,这格会被撑成一长条,读者反而看不出「现在正在调什么」。 + // 所以它只保留**最新一条**,旧条在同一行视口里向上滚走 + // (rtFlashLatestTool 触发滑入),右侧另给本轮累计次数。 + var cls = "rt-pipe-events"; + var body; + if (!items.length) { + body = + '' + __("无", "none") + ""; + } else if (s.loop) { + cls += " rt-pipe-scroll"; + var total = 0; + for (var k = 0; k < items.length; k++) total += items[k].n || 1; + body = + rtChipEl(items[items.length - 1]) + + 'x' + + total + + ""; + } else { + body = items.map(rtChipEl).join(""); + } + return ( + '
' + + '
' + + RT_ICO[s.ico] + + "" + + __(s.zh, s.en) + + "" + + (s.loop + ? '' + + RT_ICO.loop + + "" + : "") + + "
" + + '
' + + body + + "
" + + "
" + ); + }).join(""); + return ( + '
' + + __("阶段管道", "Stage pipeline") + + (g < 0 ? " " + __("(空闲)", "(idle)") : "") + + "
" + + '
' + + cells + + "
" + ); +} + +// rtTrailPush 把一条「本轮发生过的事」落到它实际发生的阶段列里。 +// 同一阶段重复出现同一条(如同一工具连调 3 次)只累加计数,不刷屏。 +function rtTrailPush(g, kind, label, short) { + if (!state.stageTrail) state.stageTrail = []; + var arr = state.stageTrail; + var last = arr.length ? arr[arr.length - 1] : null; + if (last && last.g === g && last.kind === kind && last.short === short) { + last.n = (last.n || 1) + 1; + return; + } + arr.push({ g: g, kind: kind, label: label, short: short, n: 1 }); + if (arr.length > 24) arr.shift(); +} + +// rtFlashLatestTool 让「工具」格里最新那条做一次「向上滚入」。 +// +// morph 是就地改文本:新工具到来时 chip 节点不会被替换,纯 CSS 的 +// animation 因此不会自动重放。这里摘类 → 强制 reflow → 重加类,重置动画 +// 时间轴。prefers-reduced-motion 由样式表统一压到 0.01ms,无需在此判断。 +function rtFlashLatestTool() { + var box = document.querySelector(".rt-pipe-events.rt-pipe-scroll"); + if (!box) return; + var chip = box.querySelector(".rt-chip"); + if (!chip) return; + chip.classList.remove("rt-chip-enter"); + void chip.offsetWidth; // 强制 reflow:少了这句,重加类不会重启动画 + chip.classList.add("rt-chip-enter"); +} + +// ---- per-agent 负载 ---- +// +// 负载 = 该 agent **自己的**调度器积压折算成的百分比。 +// +// 为什么按级别加权:四级中断里 L4 是内核独占、L3 是交互,堵在 L4 一条比堵在 +// L1 五条更严重;中断栈深度意味着有现场被压着。权重是启发式的("满载"没有 +// 硬定义),因此函数做成饱和式 backlog/(backlog+K):单调、上界 100、不越界。 +// context_full 单独加分:子上下文满了以后每轮都要压缩,本身就是高负载。 +function rtLoadPct(sc, ctxFull) { + sc = sc || {}; + var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; + var backlog = + (sc.ready_queue_depth || 0) + + (sc.pending_interrupts || 0) * 1.5 + + (sc.suspend_stack || 0) * 2 + + (q[1] || 0) * 1 + + (q[2] || 0) * 1.2 + + (q[3] || 0) * 1.5 + + (q[4] || 0) * 2; + var pct = 100 * (backlog / (backlog + 8)); + if (ctxFull) pct += 15; + return Math.max(0, Math.min(100, Math.round(pct))); +} + +// rtAgents 把「inputch 归属 + 驻留子 + 各自的调度器积压」整理成每个 agent 一行。 +// 根 agent 的积压来自 rt.scheduler;驻留子的来自它自己的 ResidentStatus +// (见 internal/sdk/status.go 的 ReadyQueueDepth 等四项)。 +function rtAgents(rt) { + var rootID = rt.agent_id || ""; + var groups = rtOwnerGroups( + rt.input_channels || [], + rt.residents || [], + rootID, + ); + var outChans = (rt.channels || []).filter( + (c) => c.direction === "out" || c.direction === "io", + ); + return groups.map((g) => { + var id = g.owner || rootID; + var outputs = []; + var seen = {}; + function add(name) { + if (!name || seen[name]) return; + seen[name] = 1; + outputs.push(name); + } + if (g.child) { + // 驻留子:优先用父显式授权的输出;没有授权登记时退回它自己 inputch 的 + // 默认回程(`output` 字段)。 + ((g.res && g.res.allowed_outputs) || []).forEach(add); + if (!outputs.length) + g.list.forEach((c) => { + add(c.output); + }); + } else { + // 根 agent 可以写任何输出通道;上限交给渲染侧截断。 + outChans.forEach((c) => { + add(c.name); + }); + } + var load = g.child + ? rtLoadPct( + { + ready_queue_depth: g.res && g.res.ready_queue_depth, + pending_interrupts: g.res && g.res.pending_interrupts, + suspend_stack: g.res && g.res.suspend_stack, + interrupt_queues: (g.res && g.res.interrupt_queues) || [], + }, + g.res && g.res.context_full, + ) + : rtLoadPct(rt.scheduler || {}, false); + return { + id: id, + child: g.child, + label: g.label, + color: g.color, + inputs: g.list, + outputs: outputs, + load: load, + res: g.res || null, + }; + }); +} + +// 连线路径表:通道名 → path d。光点动画靠它把「哪个通道」翻成一条曲线。 +var _rtEdgeIn = {}; +var _rtEdgeOut = {}; +var RT_SVGNS = "http://www.w3.org/2000/svg"; + +// rtAgentTopology 画「通道 → agent → 通道」的分带拓扑。 +// +// 每个 agent 一条横带:左边是归它的 inputch,中间是它自己(圆环 = 负载), +// 右边是它能写的 outputch。连线即路由;光点沿连线跑表示消息正在流动。 +// 与旧版「归属框 → 单个内核盒」的差别:内核盒只有一个,看不出"这条输入到底 +// 喂给了哪个子",而子 agent 才是运行态里最该看清的东西。 +// rtTopoWidth 用**实测容器宽**当 viewBox 宽:viewBox 宽与渲染宽一致时缩放才是 +// 1:1。此前固定 640 而容器 ~920,浏览器按 "meet" 把内容顶在左上、右侧空出一大块, +// 观感就是"间距不对"。 +function rtTopoWidth() { + var host = document.getElementById("rt-sec-topo"); + var w = host ? host.clientWidth : 0; + if (!w) { + var panel = document.getElementById("rt-panel"); + w = panel ? panel.clientWidth : 0; + } + if (!w || w < 360) w = 900; + return Math.round(w) - 4; +} + +// rtClip 按估算宽度截断长通道名(SVG 没有 text-overflow,只能自己量)。 +// 全角按 1em、其余按 0.56em 估宽:只求不越界,不求像素级精确。 +function rtClip(text, maxPx, fontPx) { + var s = String(text == null ? "" : text); + var w = 0; + var i = 0; + for (; i < s.length; i++) { + var cw = s.charCodeAt(i) > 0x2e80 ? fontPx : fontPx * 0.56; + if (w + cw > maxPx) break; + w += cw; + } + if (i >= s.length) return s; + return s.slice(0, Math.max(1, i - 1)) + "…"; +} + +function rtAgentTopology(agents) { + var W = rtTopoWidth(); + var ROW = 30, + BOX_H = 20, + GAP = 14, + NODE_R = 20, + MAX_OUT = 8; + // 三列按比例分:输入 0~32%,节点居中在 44%,输出 56%~100%,两侧留白对称。 + var IN_X = 0, + IN_W = Math.round(W * 0.32); + var NX = Math.round(W * 0.44), + OUT_X = Math.round(W * 0.56), + OUT_W = W - Math.round(W * 0.56) - 2; + var out = []; + _rtEdgeIn = {}; + _rtEdgeOut = {}; + function esc(s) { + return String(s == null ? "" : s).replace(/[<>&]/g, (m) => + m === "<" ? "<" : m === ">" ? ">" : "&", + ); + } + function pathD(x1, y1, x2, y2) { + var mx = (x1 + x2) / 2; + return ( + "M" + + x1 + + " " + + y1 + + " C" + + mx + + " " + + y1 + + " " + + mx + + " " + + y2 + + " " + + x2 + + " " + + y2 + ); + } + var y = 8; + if (!agents.length) { + out.push( + '' + + __("暂无通道 / agent", "no channels / agents") + + "", + ); + y = 44; + } + agents.forEach((a) => { + var nin = a.inputs.length; + var nout = Math.min(a.outputs.length, MAX_OUT); + var rows = Math.max(nin, nout, 1); + var truncated = a.outputs.length > MAX_OUT; + // 内容高必须**容得下节点块**(圆环 + 下方标签一行),否则单行带里节点和它的 + // 名字会溢出到上/下一个带上("间距不对"的一个来源)。 + var nodeBlock = NODE_R * 2 + (a.res && a.res.context_full ? 52 : 20); + var contentH = Math.max( + rows * ROW, + nodeBlock, + truncated ? nout * ROW + 18 : 0, + ); + var bandH = contentH + GAP; + var cy = y + contentH / 2; + // 短的一列在带内**居中**:连线短而对称,不再出现"左边挤在上半、右边铺满"的错位感。 + var inTop = y + (contentH - nin * ROW) / 2; + var outTop = y + (contentH - nout * ROW) / 2; + out.push( + '', + ); + out.push( + '', + ); + + // 输入通道 → agent + a.inputs.forEach((c, i) => { + var ry = inTop + i * ROW + ROW / 2; + out.push( + '', + ); + out.push( + '' + + esc(rtClip(c.name, IN_W - 44, 12)) + + "" + + esc(c.name) + + "", + ); + if (c.capacity) { + out.push( + '' + + esc(c.capacity) + + "", + ); + } + var d = pathD(IN_X + IN_W, ry, NX - NODE_R - 2, cy); + _rtEdgeIn[c.name] = d; + out.push( + '', + ); + }); + + // agent 节点:两圈 + 一段负载弧(stroke-dasharray 画进度) + var C = 2 * Math.PI * (NODE_R - 3); + out.push( + '', + ); + out.push( + '', + ); + out.push( + '', + ); + out.push( + '' + + a.load + + "", + ); + out.push( + '' + + esc(rtClip(a.child ? a.id : __("根 agent", "root"), 140, 11)) + + "", + ); + if (a.res && a.res.context_full) { + out.push( + '' + + __("上下文已满", "ctx full") + + "", + ); + } + + // agent → 输出通道 + a.outputs.slice(0, MAX_OUT).forEach((name, i) => { + var ry = outTop + i * ROW + ROW / 2; + out.push( + '', + ); + out.push( + '' + + esc(rtClip(name, OUT_W - 20, 12)) + + "" + + esc(name) + + "", + ); + var d = pathD(NX + NODE_R + 2, cy, OUT_X, ry); + _rtEdgeOut[a.id + "\u0000" + name] = d; + out.push( + '', + ); + }); + if (a.outputs.length > MAX_OUT) { + out.push( + '+' + + (a.outputs.length - MAX_OUT) + + " " + + __("更多", "more") + + "", + ); + } + y += bandH; + }); + var H = Math.max(60, y); + return ( + '
' + + __("拓扑(圆环 = 负载)", "Topology (ring = load)") + + "
" + + '
' + + out.join("") + + "
" + ); +} + +// rtSpark 让一个光点沿一条连线跑一趟。 +// +// 用 SMIL 而不是 CSS offset-path / rAF:它由浏览器自己按 +// SVG 用户坐标跑,不需要前端维护动画循环,也没有 path() 在缩放 viewBox 下 +// 的坐标换算问题。跑完把节点摘掉,避免 DOM 累积。 +function rtSpark(pathD, color) { + if (!pathD) return; + var svg = document.getElementById("rt-topo-svg"); + if (!svg) return; + var c = document.createElementNS(RT_SVGNS, "circle"); + c.setAttribute("r", "3.2"); + c.setAttribute("fill", color || "#ffffff"); + c.setAttribute("class", "rt-spark"); + var am = document.createElementNS(RT_SVGNS, "animateMotion"); + am.setAttribute("dur", "0.85s"); + am.setAttribute("path", pathD); + am.setAttribute("fill", "freeze"); + am.setAttribute("begin", "0s"); + c.appendChild(am); + svg.appendChild(c); + setTimeout(() => { + if (c.parentNode) c.parentNode.removeChild(c); + }, 950); +} +// 输入:某条 inputch 来消息了(SSE channel_input)。 +function rtSparkInput(source) { + rtSpark(_rtEdgeIn[source], "#88c0d0"); +} +// 输出:某个 agent 往某条 outputch 写了东西(SSE agent_output)。 +function rtSparkOutput(agentID, channel) { + if (!channel || !agentID) return; + rtSpark(_rtEdgeOut[agentID + "\u0000" + channel], "#ff7fac"); +} + +function renderRuntime() { + var el = document.getElementById("rt-panel"); + if (!el) return; + var rt = state.runtime; + if (!rt) { + el.innerHTML = + '
' + + __("运行态数据不可用", "runtime unavailable") + + "
"; + _rtSig = null; + return; + } + var sc = rt.scheduler || {}; + var residents = rt.residents || []; + var channels = rt.channels || []; + var inputs = rt.input_channels || []; + + // 数据签名(**不含 uptime**——它每秒都变,带上就等于没缓存):整体没变就整块跳过。 + // 签名里必须带 pipelinePhase:否则 SSE 把阶段推到下一格时, + // 数据没变 → 早退 → 滑块不动,只能等下一次 /runtime 轮询才追上。 + var sig = JSON.stringify([ + sc, + residents, + channels, + inputs, + state.pipelinePhase, + (state.stageTrail || []) + .map( + (t) => + t.g + ":" + t.kind + ":" + (t.short || t.label) + "x" + (t.n || 1), + ) + .join(","), + ]); + if (sig === _rtSig) return; + _rtSig = sig; + + // 外壳只建一次;随后**逐段**更新。 + // + // 为什么不是整块 innerHTML:设备通道列表这类数据本来就会来回变(实测远程设备 + // 通道 11→9 条),整块重建会把没变的段落(含条形 transition)也推倒重来, + // 视觉上就是「一闪一闪」。逐段比较后只替换真正变了的那一段。 + if (!document.getElementById("rt-sec-tiles")) { + el.innerHTML = + '

' + + __("运行态", "Runtime") + + "

" + + '
' + + '
' + + '
' + + '
' + + '
' + + "
"; + } + function put(sec, html) { + var n = document.getElementById("rt-sec-" + sec); + if (!n || n.__sig === html) return; // 该段没变:一个字节都不动 + n.__sig = html; + // 变了也只「形变」到新样子:复用未变的子节点,而不是整块重建。 + morph(n, html); + } + + var q = sc.interrupt_queues || [0, 0, 0, 0, 0]; + var pending = sc.pending_interrupts || 0; + var ready = sc.ready_queue_depth || 0; + var stack = sc.suspend_stack || 0; + var maxStack = sc.max_suspend_depth || 4; + var frames = sc.suspend_frames || []; + var byLv = sc.interrupts_by_level || []; + var preLv = sc.preempts_by_level || []; + + // ---- 段 1:四个数字块 ---- + var h = '
'; + h += rtTile( + ready, + __("排队", "Ready"), + "", + ready ? Math.min(100, ready * 20) : 0, + ready > 0, + ); + h += rtTile( + pending, + __("中断", "Pending"), + "", + pending ? Math.min(100, pending * 25) : 0, + pending > 0, + pending > 0, + ); + h += rtTile( + stack + "/" + maxStack, + __("栈", "Stack"), + "", + (stack / (maxStack || 4)) * 100, + stack > 0, + ); + var rFull = residents.filter((r) => r.context_full).length; + h += rtTile( + residents.length, + __("子代理", "Subagents"), + rFull ? rFull + __("满", " full") : "", + residents.length ? Math.min(100, residents.length * 20) : 0, + residents.length > 0, + rFull > 0, + ); + h += "
"; + // 「累计:入队 … 执行 … 抢占 …」那一整行纯文字被去掉了:它对"现在忙不忙" + // 没有帮助,却占掉一整行,是总览里最大的一坨文字。 + put("tiles", h); + + // ---- 段 2:阶段管道(滑块,SSE stage 事件驱动)---- + put("pipe", rtPipelineHtml(state.pipelinePhase, state.stageTrail)); + + // ---- 段 3:队列(四级中断 + 一条排队)---- + // + // 设计是「四条中断队列(L1–L4)+ 一条排队队列」共五个,所以必须画五行: + // 只画四条会让「排队输入」这条线在运行态里凭空消失,而它正是 + // 「不需要及时处理」的那一半输入。排队队列**无级别**,故用不同配色 + 虚线。 + h = '
' + __("队列", "Queues") + "
"; + var maxQ = Math.max(1, ready, q[1] || 0, q[2] || 0, q[3] || 0, q[4] || 0); + var maxReg = 1; + var maxPre = 1; + RT_LEVELS.forEach((L) => { + maxReg = Math.max(maxReg, byLv[L.lv] || 0); + maxPre = Math.max(maxPre, preLv[L.lv] || 0); + }); + // 槽位数按全场最大深度缩放(且至少 5 格):0 时也有可见形状,不空着。 + var qSlots = Math.max(5, Math.min(16, maxQ)); + // 五个**等大表框**(四级中断 + 一条排队),与阶段管道同一套视觉语言。 + // 此前是五行扁条,四级中断全为 0 时四行几乎全是空白,又占高度又难看。 + h += '
'; + RT_LEVELS.forEach((L) => { + var depth = q[L.lv] || 0; + var reg = byLv[L.lv] || 0; + var pre = preLv[L.lv] || 0; + h += + '
' + + '
' + + L.name + + "" + + escHtml(L.desc) + + "
" + + '
' + + depth + + "
" + + rtSlots(depth, qSlots, L.cls) + + '
' + + rtMini(reg, maxReg, __("登记", "registered")) + + rtMini(pre, maxPre, __("抢占", "preempted")) + + "
"; + }); + // 第五条:排队队列(无级别,纯 FIFO)。 + // 它不是优先级而是另一**类别**(排队 vs 中断),所以用虚线框区分。 + h += + '
' + + '
' + + __("排队", "queued") + + "FIFO
" + + '
' + + ready + + "
" + + rtSlots(ready, qSlots, "rt-lv-q") + + '
' + + __("无级别", "no priority") + + "
"; + h += "
"; + if (sc.immediate) { + h += + '
' + + RT_ICO.bolt + + " " + + __("立即运行", "immediate") + + ":" + + escHtml(sc.immediate.kind || "") + + " #" + + sc.immediate.id + + 'L' + + (sc.immediate.level || 0) + + "
"; + } + put("levels", h); + + // ---- 段 4:中断栈 ---- + h = '
' + __("栈", "Stack") + "
"; + // 深度先给一条进度条("压了几层 / 上限几层"一眼可读),下面再列具体帧。 + h += rtSlider(stack, maxStack, __("深度", "depth"), stack + " / " + maxStack); + if (frames.length) { + h += '
'; + frames.forEach((f, i) => { + var t = (f && f.task) || {}; + h += + '
' + + escHtml(t.kind || "") + + " #" + + (t.id || "?") + + 'L' + + (t.level || 0) + + (i === frames.length - 1 ? " · " + __("栈顶", "top") : "") + + "
"; + }); + h += "
"; + } else { + h += + '
' + + __( + "中断栈为空(当前无被抢占的现场)", + "stack empty (nothing preempted)", + ) + + "
"; + } + if (sc.running) { + h += + '
' + + __("正在运行", "running") + + ":" + + escHtml(sc.running.kind || "") + + " #" + + sc.running.id + + " (L" + + (sc.running.level || 0) + + ")
"; + } + put("stack", h); + + // ---- 段 5:Agent 拓扑 ---- + // + // 每个 agent 一条横带:左是归它的 inputch、中间是它自己(圆环 = 负载)、 + // 右是它能写的 outputch,连线即路由。归属、路由、容量、负载全部画进 + // 同一张 SVG,不再单开"通道分配"一节——那本来就是这个拓扑的一部分。 + put("topo", rtAgentTopology(rtAgents(rt))); +} + +// loadRuntime 拉运行态小快照并就地重绘面板(约 2KB,可秒级轮询)。 +async function loadRuntime() { + try { + var rt = await api("/runtime"); + state.runtime = rt; + renderRuntime(); + } catch (e) { + state.runtime = null; + } +} + +// startRuntimeTicker 起 3s 轮询:运行态只在总览页可见时才有意义, +// 其它页签上不浪费请求。切回总览时 renderAll 会立刻再拉一次,不必等下一拍。 +// startChatTicker:聊天页可见时 3s 轮询一次增量(与运行态同一节奏)。 +// SSE 仍在(token 级流式靠它),轮询是「数据查询 api」那条腿: +// 界面最终状态由轮询拉到的数据决定,SSE 只负责让流式看起来即时。 +function startChatTicker() { + if (state._chatTicker) return; + state._chatTicker = setInterval(() => { + var tab = document.querySelector("#tab-chat"); + if (tab && tab.classList.contains("active")) { + syncChatFromHistory().catch(() => {}); + } + }, 3000); +} + +function startRuntimeTicker() { + if (state._runtimeTicker) return; + state._runtimeTicker = setInterval(() => { + var tab = document.querySelector("#tab-overview"); + if (tab && tab.classList.contains("active")) { + loadRuntime(); + } + }, 3000); +} + +// ===== Overview ===== +// +// 总览页是**静态骨架 + 数据绑定**:DOM 只在首帧建一次,之后所有刷新只改 +// 文本/类名,绝不重建节点。此前 renderAll 每 15s 把整个总览(含运行态面板) +// innerHTML 重建一遍 —— 那是页面上最刺眼的周期性闪烁的来源。 +// +// 文字也压到最少:状态用彩色圆点表达,其余只留数字与两字标签,不再堆 +// 「未初始化 / 不可用」这类整句描述。 +var OV_ICONS = { + activity: + '', + clock: + '', + puzzle: + '', + tag: '', + brain: + '', + db: '', + file: '', + cpu: '', +}; + +function ovKpi(id, icon, label, dot) { + return ( + '
' + + icon + + '' + + (dot ? '' : "") + + '—' + + '' + + '' + + label + + "
" + ); +} + +function renderOverview() { + var host = document.getElementById("tab-overview"); + if (!host) return; + // 只在首帧建骨架;之后(renderAll 每 15s 一次)只 updateOverview。 + // 一个节点都不重建,所以不会闪。 + if (!document.getElementById("ov-kpis")) { + host.innerHTML = + '
' + + '
' + + ovKpi("status", OV_ICONS.activity, __("状态", "Status"), true) + + ovKpi("uptime", OV_ICONS.clock, __("运行", "Uptime")) + + ovKpi("plugins", OV_ICONS.puzzle, __("插件", "Plugins")) + + ovKpi("version", OV_ICONS.tag, __("版本", "Version")) + + ovKpi("llm", OV_ICONS.brain, "LLM", true) + + ovKpi("memory", OV_ICONS.db, __("记忆", "Memory")) + + ovKpi("docs", OV_ICONS.file, __("文档", "Docs")) + + ovKpi("runtime", OV_ICONS.cpu, __("运行时", "Runtime")) + + "
" + + // 星图搬到主页:作为总览的门面,跟随 agent 活动脉动。 + // 开源许可单独成框:之前在 KPI 卡底部只是一行小链接(.ov-foot), + // 几乎看不见,也看不出是许可证还是别的什么。 + '

' + + __("记忆星图", "Memory Star Map") + + '

' + + '
' + + ''; + } + updateOverview(); + // 星图在主页常驻:容器已建好,首次就拉数据 + 初始化 3D。 + if (document.getElementById("sm-container-home") && !state.starmapInit) { + renderChatStarmap(); + } +} + +function ovSet(id, text) { + var el = document.getElementById(id); + if (el && el.textContent !== String(text)) el.textContent = String(text); +} +function ovDot(id, cls) { + var el = document.getElementById(id); + if (el && el.className !== cls) el.className = cls; +} + +// updateOverview 只写值与类名,从不改结构。 +function updateOverview() { + var s = state.status || {}; + var k = state.kernel; + var running = s.status === "running"; + ovSet("ov-status", running ? __("运行中", "running") : s.status || "unknown"); + ovDot("ov-status-dot", "ov-dot " + (running ? "ok" : "warn")); + ovSet( + "ov-uptime", + state.startedAt ? fmtUptime(Date.now() - state.startedAt) : "-", + ); + ovSet("ov-plugins", ((k && k.plugins) || []).length || 0); + // 内核身份:光有版本号分不清是哪个内核、哪次构建 —— 补上内核名与 commit。 + // 之前 36b577b 改图标 KPI 时把 kernel_name 丢了,只剩余 "v1.4.0"。 + var kb = (k && k.build) || {}; + var kv = kb.version || s.version || ""; + ovSet("ov-version", kv ? "v" + kv : "—"); + ovSet( + "ov-version-sub", + kv + ? (kb.kernel_name || "HomeAgent") + + (kb.commit && kb.commit !== "unknown" + ? " · " + String(kb.commit).slice(0, 7) + : "") + : "", + ); + var llm = (k && k.llm) || {}; + ovDot("ov-llm-dot", "ov-dot " + (llm.available ? "ok" : "bad")); + ovSet("ov-llm", llm.provider || "—"); + var mem = (k && k.memory) || {}; + ovSet( + "ov-memory", + mem.available ? mem.entity_count + "/" + mem.relation_count : "—", + ); + var docs = (k && k.documents) || {}; + ovSet("ov-docs", docs.available ? docs.doc_count : "—"); + var rt = (k && k.runtime) || {}; + ovSet( + "ov-runtime", + rt.goroutines + ? rt.goroutines + (rt.memory_mb ? " · " + rt.memory_mb + "M" : "") + : "—", + ); + // 开源许可卡:协议标识 + 协议全文 + 源码仓库。 + // + // 为什么单独成框、且三者都给:AGPL-3.0 §13 的义务是「向网络使用者提供 + // 取得 Corresponding Source 的机会」——只给一个仓库链接、不写协议名, + // 使用者看不出这受什么许可约束,也看不出网络服务场景下还有 §13 的义务。 + // 内容对一次构建是常量,所以填一次就够(__html 比对避免重复重建)。 + var legal = document.getElementById("ov-legal"); + if (legal) { + var lb = (k && k.build) || {}; + var src = lb.source_url || ""; + var lic = lb.license || ""; + var licURL = lb.license_url || ""; + var rows = ""; + if (lic) { + rows += + '
' + + __("许可协议", "License") + + '' + + (licURL + ? '' + + escHtml(lic) + + "" + : escHtml(lic)) + + "
"; + } + if (src) { + rows += + '
' + + __("源码仓库", "Source") + + '' + + escHtml(src) + + "
"; + } + // 网络条款只在 AGPL 系的许可下才成立,所以按标识判断,不硬写协议名。 + var note = + lic && lic.toUpperCase().indexOf("AGPL") >= 0 + ? '" + : ""; + var want = rows + ? "

" + __("开源许可", "License") + "

" + rows + note + : ""; + if (legal.__html !== want) { + legal.__html = want; + legal.innerHTML = want; + } + } +} + +// ===== Chat ===== +var _chatLayoutBuilt = false; + +function buildChatLayout() { + var cont = document.getElementById("tab-chat"); + var k = state.kernel || {}; + var html = '
'; + html += + '
' + + '' + + __("对话", "Chat") + + "" + + "" + + __("星图", "Star Map") + + "" + + "" + + __("终端", "Terminal") + + "" + + "" + + __("运行中命令", "Running Commands") + + "" + + "" + + __("记忆", "Memory") + + "" + + "" + + __("上下文", "Context") + + "" + + "" + + __("知识", "Knowledge") + + "
"; + html += + '
'; + html += + '

' + + __("对话", "Chat") + + '

'; + if (state.messages.length === 0) { + html += + '

' + + __( + "开始对话以测试 Agent 回复", + "Start a conversation to test Agent replies", + ) + + "

"; + } + html += + "
" + + '
' + + '' + + '" + + '' + + '" + + '" + + "
"; + html += + '

' + + __("星图", "Star Map") + + "

" + + '
'; + html += + '

' + + __("终端", "Terminal") + + ' 0

' + + '
'; + html += + '

' + + __("命令历史", "Command History") + + ' 0

' + + '
'; + html += + '

' + + __("记忆", "Memory") + + "

" + + '
' + + __("实体", "Entities") + + '' + + (k?.memory?.entity_count || "-") + + "
" + + '
' + + __("关系", "Relations") + + '' + + (k?.memory?.relation_count || "-") + + "
" + + '
' + + '' + + '" + + '
' + + "
"; + html += + '

' + + __("上下文", "Context") + + "

" + + '
' + + '' + + '" + + '
' + + "
"; + html += + '

' + + __("知识", "Knowledge") + + "

" + + '
' + + __("项目", "Items") + + '' + + (k?.knowledge?.item_count ?? "-") + + "
" + + '
' + + '' + + __("按分类浏览", "Browse by category") + + "" + + '
' + + "
" + + '
' + + '' + + '' + + '" + + '" + + '
' + + '
' + + '' + + '' + + '' + + '
" + + '' + + "
" + + "
"; + html += "
"; + cont.innerHTML = html; + _chatLayoutBuilt = true; +} + +var CHAN_COLORS = [ + "#e08a5f", + "#5f9fe0", + "#6bbf8f", + "#c06bbf", + "#d9a13b", + "#5fb3bf", + "#b06b6b", + "#7f8ce0", +]; + +function chanColor(src) { + var h = 0; + for (var i = 0; i < src.length; i++) { + h = (h * 31 + src.charCodeAt(i)) >>> 0; + } + return CHAN_COLORS[h % CHAN_COLORS.length]; +} + +function chanLetter(src) { + var s = (src || "").trim(); + if (!s) return "C"; + var ch = s.charAt(0).toUpperCase(); + return /[A-Za-z0-9]/.test(ch) ? ch : "C"; +} + +// ===== 列表对账(keyed reconcile)===== +// +// 为什么必须对账而不是 innerHTML 整块重建:整块重建会把**没变的**那些消息 +// 节点也推倒重来 —— 图片重新解码闪一下、工具卡片的展开态丢失、CSS 过渡从 +// 头播、滚动锚点被重置。这些闪烁几乎无法用"重建后再还原"消除。 +// +// 做法:新 HTML 先在游离容器里解析成节点,然后按 data-key 逐个对账: +// - key 命中且内容一致 → 复用原节点(一个字节都不动); +// - key 命中但内容变了 → 只替换这一个节点; +// - key 未命中 → 作为新节点插入; +// 最后把多出来的旧节点删掉。未变节点因此完全不受影响。 +var _localKeySeq = 0; + +// chatMsgKey 给一条消息一个**稳定**的 key。 +// - 服务端消息用 seq(单调、落盘、重启不重编号); +// - 本地乐观消息(用户刚发的 / 正在流式的)第一次渲染时分配一个进程内 +// 自增 key 并挂在对象上,后续渲染不变。 +function chatMsgKey(m) { + if (m && m.seq) return "s" + m.seq; + if (m && !m._k) m._k = "L" + ++_localKeySeq; + return (m && m._k) || "L0"; +} + +function commitChatList(container, html) { + var tpl = document.createElement("div"); + tpl.innerHTML = html; + var next = Array.prototype.slice.call(tpl.children); + var existing = {}; + Array.prototype.forEach.call(container.children, (n) => { + var k = n.getAttribute && n.getAttribute("data-key"); + if (k) existing[k] = n; + }); + for (var i = 0; i < next.length; i++) { + var fresh = next[i]; + var key = fresh.getAttribute && fresh.getAttribute("data-key"); + var node = fresh; + if (key && existing[key]) { + var old = existing[key]; + // 内容逐字节相同 → 复用旧节点(不触碰它,保存动画/展开/图片状态); + // 否则只替换这一个。outerHTML 比较对消息节点足够(含 tool_calls 状态)。 + if (old.outerHTML === fresh.outerHTML) { + node = old; + } else { + old.remove(); + } + } + var cur = container.children[i]; + if (cur !== node) container.insertBefore(node, cur || null); + } + // 多出来的旧节点(本轮新列表里没有的)从尾部清掉 + while (container.children.length > next.length) { + container.removeChild(container.lastElementChild); + } +} + +function renderChat() { + if (!_chatLayoutBuilt) { + buildChatLayout(); + renderChatStarmap(); + renderTerminals(); + renderCmdHistory(); + } + var msgsEl = document.getElementById("chat-msgs"); + if (!msgsEl) return; + if (!msgsEl._stickBound) { + msgsEl._stickBound = true; + msgsEl.addEventListener( + "scroll", + () => { + state.chatStick = + msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight < 80; + // 触顶(近顶部 60px)且服务端还有更早历史 → 向上懒加载下一页 + if (msgsEl.scrollTop < 60 && state.chatHasMore) { + loadOlderChat(); + } + }, + { passive: true }, + ); + msgsEl.addEventListener( + "load", + () => { + if (state.chatStick === true) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } + }, + true, + ); + } + var msgs = state.messages; + var sig = + msgs + .map((m) => { + var c = m.content || ""; + return ( + (m.role || "") + + ":" + + c.length + + ":" + + c.slice(-40) + + ":" + + (m.tool_calls || []) + .map((t) => (t.tool || t.name || "") + "/" + (t.status || "")) + .join(",") + ); + }) + .join("|") + + "|L" + + (state.chatLoading ? "1" : "0") + + "|P" + + (state.pendingTools || []).join(","); + if (msgsEl._chatSig === sig && msgsEl.childElementCount > 0) { + if (state.chatStick === true) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } + return; + } + msgsEl._chatSig = sig; + var prevPending = msgsEl._lastPending || []; + var newPending = (state.pendingTools || []).slice(); + if (state.chatLoading) { + var lastM = msgs.length ? msgs[msgs.length - 1] : null; + if (lastM && lastM.role === "assistant") { + (lastM.tool_calls || []).forEach((tc) => { + if (!tc.result && tc.status !== "denied") { + var nm = tc.tool || tc.name || ""; + if (newPending.indexOf(nm) === -1) newPending.push(nm); + } + }); + } + } + var newlyDone = prevPending.filter((n) => newPending.indexOf(n) === -1); + msgsEl._lastPending = newPending; + var streamingLast = !!( + state.chatLoading && + lastM && + lastM.role === "assistant" && + !lastM._final + ); + function pillHtml() { + var s = ""; + newPending.forEach((nm) => { + var anim = prevPending.indexOf(nm) !== -1 ? "" : " pill-in"; + s += + '' + + '' + + escHtml(nm) + + ""; + }); + return s; + } + var html = ""; + if (msgs.length === 0) { + html = + '

' + + __( + "开始对话以测试 Agent 回复", + "Start a conversation to test Agent replies", + ) + + "

"; + } else { + msgs.forEach((m, i) => { + var _k = chatMsgKey(m); + var role = m.role || "user"; + var c = m.content || ""; + // 附件消息:agent 经 output_send__webui 发送,或用户上传。 + // 用 role 区分方向(user=右侧你发的 / assistant=左侧小宅发的), + // 卡片加来源标签避免混淆。 + if (m.attachment) { + var att = m.attachment; + var isUserAtt = role === "user"; + var srcLabel = isUserAtt + ? __("你发送的", "You sent") + : __("小宅发送的", "Sent by agent"); + var attHtml = ""; + if (att.type === "image") { + attHtml = + '' + + '图片加载失败\'"/>'; + } else if (att.type === "audio") { + // 音频用原生播放器:与图片同理,附件能在聊天里直接消费才算可见。 + // preload="metadata" 只拉时长不拉全部字节,避免历史消息满屏时并发下载。 + attHtml = + '' + + '' + + escHtml(att.name || "audio") + + (att.size ? " (" + formatBytes(att.size) + ")" : "") + + ""; + } else { + var sizeStr = att.size ? formatBytes(att.size) : ""; + attHtml = + '' + + '' + + "" + + escHtml(att.name || "附件") + + (sizeStr ? " (" + sizeStr + ")" : "") + + ""; + } + html += + '
' + + '
' + + srcLabel + + "
" + + attHtml + + (c ? '
' + renderMd(c) + "
" : "") + + "
"; + return; + } + if (role === "assistant") { + c = renderMd(c); + } else if (role === "system") { + c = escHtml(c); + } else { + c = escHtml(c); + } + var isChan = !!(m.source && m.source !== "webui"); + var rc = ""; + if (m.reasoning_content) { + rc = renderReasoningCard(m.reasoning_content, isStreamingLast); + } + var tcs = ""; + if (m.tool_calls && m.tool_calls.length > 0) { + m.tool_calls.forEach((tc) => { + var argsStr = + typeof tc.args === "object" + ? JSON.stringify(tc.args, null, 1) + : tc.args || ""; + var resultStr = tc.result + ? typeof tc.result === "object" + ? JSON.stringify(tc.result, null, 1) + : String(tc.result) + : ""; + var running = !resultStr && tc.status !== "denied"; + var error = + tc.status === "error" || tc.status === "denied" || !!tc.error; + var drip = + newlyDone.indexOf(tc.tool || tc.name || "") !== -1 + ? " tool-drip-in" + : ""; + var iconSvg = error + ? '' + : running + ? '' + : ''; + var statusHtml = + tc.status === "denied" + ? '' + + __("已拒绝", "Denied") + + "" + : running + ? '' + + __("调用中", "Running") + + "" + : '' + + __("完成", "Done") + + ""; + var pluginHtml = tc.plugin + ? '' + escHtml(tc.plugin) + "" + : ""; + tcs += + '
' + + '
' + + iconSvg + + '' + + escHtml(tc.tool || tc.name || "") + + "" + + pluginHtml + + statusHtml + + '' + + RT_ICO.caret + + "
" + + '
"; + }); + } + var body = rc + tcs; + var growCls = m._grow ? " grow-in" : ""; + if (m._grow) m._grow = false; + var isStreamingLast = i === msgs.length - 1 && streamingLast; + if (isStreamingLast) { + var liveRow = + '' + + (newPending.length + ? '' + pillHtml() + "" + : ""); + if (c) { + body += + '
' + + liveRow + + '
' + + c + + "
"; + c = ""; + } else { + body += '
' + liveRow + "
"; + } + } else if (c) { + body += + '
' + + c + + "
"; + } + if (role === "system") { + html += + '
' + + (c || "") + + "
"; + } else if (isChan) { + html += + '
' + + '
' + + chanLetter(m.source) + + "
" + + '
' + + escHtml(m.source) + + "
" + + body + + "
"; + } else { + var userAvatar = + ''; + var aiAvatar = '小宅'; + html += + '
' + + '
' + + (role === "user" ? userAvatar : aiAvatar) + + "
" + + '
' + + body + + "
" + + "
"; + } + }); + } + if (state.chatLoading && !streamingLast) { + var aiAvatarL = '小宅'; + html += + '
' + + aiAvatarL + + '
' + + '' + + (newPending.length + ? '' + pillHtml() + "" + : "") + + "
"; + } + // 重建前记住阅读位置:非粘底(用户正向上翻)时,innerHTML 重建后必须把位置还回去, + // 否则视口会被重置——这就是"聊天记录跳到顶部"的直接来源。 + var prevTop = msgsEl.scrollTop; + commitChatList(msgsEl, html); + if (state.chatStick !== false) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } else { + msgsEl.scrollTop = prevTop; + } + updateChatBadge(); +} + +function updateChatBadge() { + var badge = document.getElementById("chat-stage"); + if (!badge) return; + badge.textContent = state.chatStage || ""; + badge.style.display = "none"; +} + +// 流式渲染分发:流式增量(只更新最后一条正文)vs 全量重建 +var _rerenderTimer = null; +function rerenderChat(full) { + // 流式中且非强制全量:走增量路径(防抖合并 chunk,只更新最后一条消息节点) + if (!full && state.chatLoading) { + var msgs = state.messages; + var last = msgs.length ? msgs[msgs.length - 1] : null; + if (last && last.role === "assistant" && !last._final) { + if (_rerenderTimer) return; // 已有排程的增量更新 + _rerenderTimer = setTimeout(() => { + _rerenderTimer = null; + renderChatStreamChunk(); + }, 90); + return; + } + } + // 非流式(完成/工具/历史变化):全量重渲(含防抖合并) + if (_rerenderTimer) clearTimeout(_rerenderTimer); + _rerenderTimer = setTimeout(() => { + _rerenderTimer = null; + renderChat(); + if (full) { + renderChatStarmap(); + renderTerminals(); + renderCmdHistory(); + } + }, 90); +} + +// 流式增量渲染(移植自 GUI):仅更新最后一条 assistant 消息的正文与思考预览, +// 不重建 DOM。正文节流 parse(>200 字符或 >300ms 才 renderMd),小增量纯文本追加。 +function renderChatStreamChunk() { + var msgsEl = document.getElementById("chat-msgs"); + var last = state.messages.length + ? state.messages[state.messages.length - 1] + : null; + if (!msgsEl || !last) return; + var el = msgsEl.lastElementChild; + if (!el) { + renderChat(); + return; + } + var textEl = el.querySelector(".msg-bubble .text"); + var c = last.content || ""; + if (textEl && c) { + var now = Date.now(); + var lastParse = el.__lastParse || 0; + var lastLen = el.__lastLen || 0; + if (c.length - lastLen > 200 || now - lastParse > 300) { + textEl.innerHTML = renderMd(c); + el.__lastParse = now; + el.__lastLen = c.length; + } else { + var tail = c.slice(lastLen); + if (tail) textEl.appendChild(document.createTextNode(tail)); + el.__lastLen = c.length; + } + if (state.chatStick !== false) { + try { + msgsEl.scrollTop = msgsEl.scrollHeight; + } catch (e) {} + } + return; + } + // 思考预览更新(流式中折叠,只刷 preview 文本) + var rcPrev = el.querySelector(".reasoning-preview"); + if (rcPrev && last.reasoning_content) { + rcPrev.textContent = last.reasoning_content + .replace(/[\s\n]+/g, " ") + .slice(0, 60); + if (state.chatStick !== false) { + try { + msgsEl.scrollTop = msgsEl.scrollHeight; + } catch (e) {} + } + return; + } + // 结构变化兜底:全量 + renderChat(); +} +function toggleToolCall(el) { + var d = el.querySelector(".tc-detail"); + if (!d) return; + var open = d.style.display !== "none"; + d.style.display = open ? "none" : "block"; + if (open) { + el.classList.remove("open"); + } else { + el.classList.add("open"); + } +} + +function renderReasoningCard(text, isStreaming) { + var preview = + typeof marked !== "undefined" + ? text.replace(/[\s\n]+/g, " ").slice(0, 60) + : escHtml(text) + .replace(/<[^>]+>/g, " ") + .slice(0, 60); + return ( + '
' + + '
' + + '' + + '' + + (isStreaming ? __("思考中...", "Thinking...") : __("思考", "Thinking")) + + "" + + '' + + RT_ICO.caret + + "
" + + '
' + + (isStreaming + ? '
' + + escHtml(preview) + + '
' + : '
' + renderMd(text) + "
") + + "
" + ); +} +function toggleReasoning(el) { + var card = el.closest(".reasoning-card"); + if (!card) return; + var body = card.querySelector(".reasoning-body"); + if (!body) return; + var open = body.style.display !== "none"; + body.style.display = open ? "none" : "block"; + if (open) { + card.classList.remove("open"); + } else { + card.classList.add("open"); + } +} + +// renderChatStarmap:把星图挂到**当前活跃的容器**上。 +// 容器优先级:独立星图页签 > 总览页小图 > 聊天面板。 +// 同一套 three.js renderer 在容器间搬运 canvas,不重建场景。 +function renderChatStarmap() { + var cont = starmapActiveContainer(); + if (!cont) return; + if ( + window._THREE_FAILED || + (!window.THREE && window._THREE_FAILED !== undefined) + ) { + cont.innerHTML = + '

' + + __( + "3D 星图不可用(CDN 加载失败)", + "Star map unavailable (CDN load failed)", + ) + + "

"; + state.starmapInit = true; + state.starmapLoading = false; + return; + } + if (!window.THREE) { + cont.innerHTML = + '
'; + state.starmapInit = false; + state.starmapLoading = false; + return; + } + if (cont.querySelector("canvas")) { + var rect = cont.getBoundingClientRect(); + if (starmapRen && rect.width > 0) + starmapRen.setSize(rect.width, Math.max(rect.height, 250)); + return; + } + if (state.starmapInit) { + if (starmapRen) { + var rect = cont.getBoundingClientRect(); + if (rect.width > 0) + starmapRen.setSize(rect.width, Math.max(rect.height, 250)); + cont.appendChild(starmapRen.domElement); + starmapRen.domElement.style.display = "block"; + } else { + // starmapRen was destroyed (e.g. re-render cycle), restart + state.starmapInit = false; + state.starmapLoading = false; + } + return; + } + if (state.starmapLoading) return; + state.starmapLoading = true; + loadChatStarmapData(); +} + +async function loadChatStarmapData() { + try { + var resp = await api("/memory/graph"); + if ( + !resp || + !resp.success || + !resp.data || + !resp.data.nodes || + resp.data.nodes.length === 0 + ) { + document.getElementById("sm-container-chat").innerHTML = + '

' + + __("暂无记忆数据", "No memory data") + + "

"; + state.starmapInit = true; + state.starmapLoading = false; + return; + } + var d = resp.data; + starmapNodes = d.nodes || []; + starmapEdges = d.edges || []; + state.starmapInit = true; + state.starmapLoading = false; + initChatStarmap(); + } catch (e) { + document.getElementById("sm-container-chat").innerHTML = + '

' + + __("加载失败", "Load failed") + + "

"; + state.starmapInit = true; + state.starmapLoading = false; + } +} + +function initChatStarmap() { + // 容器可以是独立星图页签 / 总览页 / 聊天面板 —— 同一套 renderer + // 会在多个容器间搬运 canvas(appendChild 即可)。 + var cont = starmapActiveContainer(); + if (!cont) return; + var rect = cont.getBoundingClientRect(); + var w = Math.max(rect.width || 300, 100); + var h = Math.max(rect.height || 250, 100); + if (starmapRen) { + starmapRen.setSize(w, h); + // 换父节点前先把旧角标从旧容器里拨掉,否则它会残留在旧位置。 + if (starmapLabelEl && starmapLabelEl.parentElement === cont) { + starmapLabelEl.style.display = "none"; + } + cont.appendChild(starmapRen.domElement); + starmapRen.domElement.style.display = "block"; + return; + } + starmapScene = new THREE.Scene(); + starmapScene.fog = new THREE.FogExp2(0x0a0a1a, 0.015); + starmapCam = new THREE.PerspectiveCamera(60, w / h, 0.1, 2000); + starmapCam.position.set(0, 20, 40); + starmapRen = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + starmapRen.setSize(w, h); + starmapRen.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + starmapRen.setClearColor(0x0a0a1a, 1); + cont.innerHTML = ""; + cont.appendChild(starmapRen.domElement); + starmapCtrl = new THREE.OrbitControls(starmapCam, starmapRen.domElement); + starmapCtrl.enableDamping = true; + starmapCtrl.dampingFactor = 0.05; + starmapCtrl.rotateSpeed = 0.5; + starmapCtrl.zoomSpeed = 0.8; + var al = new THREE.AmbientLight(0x444466, 0.6); + starmapScene.add(al); + var dl = new THREE.DirectionalLight(0xffffff, 0.8); + dl.position.set(50, 100, 50); + starmapScene.add(dl); + createStarField(); + createNebula(); + // 共享几何(低规格):所有节点/光晕球复用这两份。 + starmapGeo = new THREE.SphereGeometry(0.5, 8, 6); + starmapGlowGeo = new THREE.SphereGeometry(1.0, 8, 6); + buildChatStarmapGraph(); + starmapRen.domElement.addEventListener("mousemove", onStarmapMove); + starmapRen.domElement.addEventListener("click", onStarmapClick); + window.addEventListener("resize", onStarmapResize); + if (starmapRaf) cancelAnimationFrame(starmapRaf); + starmapAnimate(); + // 活动数据源:/runtime 3s + /memory/graph/pulse 10s。 + // 只在星图真正初始化后启动,避免在隐藏页签空跑。 + starmapStartActivity(); +} + +// starmapStartActivity 启动两路活动轮询(幂等)。 +function starmapStartActivity() { + if (state.starmapPulseTimer) return; + starmapPullActivity(); + starmapPullPulse(); + state.starmapPulseTimer = setInterval(() => { + starmapPullActivity(); + }, 3000); + var pulseTimer = setInterval(() => { + starmapPullPulse(); + }, 10000); + // 两个 id 合并到一个字段会导致 setInterval 被覆盖,这里分开记。 + state.starmapActivityTimer = pulseTimer; +} + +function buildChatStarmapGraph() { + starmapNodeMeshes.forEach((m) => { + starmapScene.remove(m); + }); + starmapEdgeLines.forEach((l) => { + starmapScene.remove(l); + }); + starmapNodeMeshes = []; + starmapEdgeLines = []; + if (starmapNodes.length === 0) return; + // Calculate node degrees for leaf node detection + var nodeDegs = {}; + starmapNodes.forEach((n) => { + nodeDegs[n.id] = 0; + }); + starmapEdges.forEach((e) => { + nodeDegs[e.source_id] = (nodeDegs[e.source_id] || 0) + 1; + nodeDegs[e.target_id] = (nodeDegs[e.target_id] || 0) + 1; + }); + var nodeMap = {}; + starmapNodes.forEach((n) => { + nodeMap[n.id] = n; + }); + var sorted = starmapNodes + .slice() + .sort((a, b) => (b.mention_count || 0) - (a.mention_count || 0)); + var mc = sorted.map((n) => n.mention_count || 0); + var maxMc = Math.max(...mc, 1), + minMc = Math.min(...mc, 0), + rng = maxMc - minMc || 1; + // Layout positions + var pos = {}; + var baseR = 15, + maxR = 80; + var total = sorted.length; + var acc = 0; + sorted.forEach((n, i) => { + var m = n.mention_count || 0, + mn = rng > 0 ? (m - minMc) / rng : 0; + var radius = baseR + mn * (maxR - baseR); + var baseStep = (Math.PI * 2) / total; + var extra = mn * baseStep * 2; + var angle = acc + extra / 2; + acc += baseStep + extra; + pos[n.id] = { + x: radius * Math.cos(angle), + y: (Math.random() - 0.5) * (10 + mn * 20), + z: radius * Math.sin(angle), + mn: mn, + rad: radius, + }; + }); + // Leaf nodes (degree 1) reposition near parent + sorted.forEach((n) => { + var deg = nodeDegs[n.id] || 0; + if (deg !== 1) return; + var edge = starmapEdges.find( + (e) => e.source_id === n.id || e.target_id === n.id, + ); + if (!edge) return; + var parentId = edge.source_id === n.id ? edge.target_id : edge.source_id; + if (!pos[parentId]) return; + var pp = pos[parentId]; + var m = n.mention_count || 0, + mn = rng > 0 ? (m - minMc) / rng : 0; + var off = 6 + mn * 8 + Math.random() * 4; + var a2 = Math.random() * Math.PI * 2; + pos[n.id] = { + x: pp.x + off * Math.cos(a2), + y: pp.y + (Math.random() - 0.5) * (4 + mn * 6), + z: pp.z + off * Math.sin(a2), + mn: mn, + rad: off, + }; + }); + // Force-directed simulation + for (var it = 0; it < 50; it++) { + var ids = Object.keys(pos); + // Repulsion + for (var i = 0; i < ids.length; i++) { + for (var j = i + 1; j < ids.length; j++) { + var a = pos[ids[i]], + b = pos[ids[j]]; + var dx = a.x - b.x, + dy = a.y - b.y, + dz = a.z - b.z, + d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; + var rf = 0.5 + (a.mn + b.mn) * 0.5; + if (d < 25) { + var force = (0.06 * rf) / Math.max(d, 0.5); + a.x += (dx / d) * force; + a.y += (dy / d) * force; + a.z += (dz / d) * force; + b.x -= (dx / d) * force; + b.y -= (dy / d) * force; + b.z -= (dz / d) * force; + } + } + } + // Attraction along edges + starmapEdges.forEach((e) => { + var a = pos[e.source_id], + b = pos[e.target_id]; + if (!a || !b) return; + var dx = b.x - a.x, + dy = b.y - a.y, + dz = b.z - a.z, + d = Math.sqrt(dx * dx + dy * dy + dz * dz) + 0.1; + var af = Math.max(0.3, 1.0 - (a.mn + b.mn) * 0.3); + if (d > 20) { + var force = 0.04 * af; + a.x += (dx / d) * force; + a.y += (dy / d) * force; + a.z += (dz / d) * force; + b.x -= (dx / d) * force; + b.y -= (dy / d) * force; + b.z -= (dz / d) * force; + } + }); + // Centering constraint + ids.forEach((id) => { + var p = pos[id]; + var dist = Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z); + var maxA = maxR * 1.5; + if (dist > maxA) { + var s = maxA / dist; + p.x *= s; + p.y *= s; + p.z *= s; + } + }); + } + // Create nodes + // + // ★ 低规格渲染(1151 节点实测后定的方案):保留**全部**节点, + // 但把每节点开销从「一个独立球 + 一个独立球光晕 + 一张 256x64 + // 文字贴图」降到「共享几何 + 共享材质 + 无常驻文字」。 + // + // 改之前的实际开销(生产实例 1151 节点): + // - SphereGeometry(16,12) 主球 + 同样规格的光晕球 = 2302 个独立 + // BufferGeometry,共约 88 万三角形 + // - 每节点一张 256x64 CanvasTexture = 1151 张 + + // 1151 个纹理,仅文字就吃约 72MB 显存 + // 这两样在「星图只是个展示」的前提下纯属浪费:星图全图远看根本 + // 读不清标签(256x64 贴在半径 0.5~2.5 的球上,本来就糊)。 + // + // 现在:共享一份 SphereGeometry(8,6)(约 84 三角形/节点), + // 颜色靠每 mesh 的 material.color(材质本身仍每节点一份, + // 因为 MeshPhongMaterial 要独立发光强度才能做活动脉冲)。 + // 标签改为「hover / 选中时才在容器角上显示 HTML 文本」—— + // 文字清晰度反而比 3D 贴图好,且零显存。 + starmapNodes.forEach((n) => { + var p = pos[n.id]; + if (!p) return; + var mn = n.mention_count || 0, + mnr = rng > 0 ? (mn - minMc) / rng : 0; + var rad = 0.5 + mnr * 2.0; + // 服务端 type 是首字母大写("Concept" / "Person" …), + // 原 smTypeColors 的键全是小写,永远匹配不上 ⇒ 全图单色 0xcccccc。 + // 这里统一小写归一化,并补上服务端实际会产出的类型。 + var col = smTypeColors[String(n.type || "").toLowerCase()] || 0xcccccc; + var ei = 0.3 + mnr * 0.7; + var mat = new THREE.MeshPhongMaterial({ + color: col, + emissive: col, + emissiveIntensity: ei, + shininess: 30, + }); + var mesh = new THREE.Mesh(starmapGeo, mat); + mesh.position.set(p.x, p.y, p.z); + mesh.scale.setScalar(rad / 0.5); // 共享几何半径 0.5,按需缩放 + mesh.userData.nodeData = n; + mesh.userData.nodeId = n.id; + mesh.userData.baseEmissive = ei; + mesh.userData.baseScale = rad / 0.5; + mesh.userData.baseGlow = 0.12 + mnr * 0.08; + // Glow sphere:共享几何 + 各自材质(发光强度要独立才能做脉冲) + var gm = new THREE.MeshBasicMaterial({ + color: col, + transparent: true, + opacity: mesh.userData.baseGlow, + side: THREE.BackSide, + blending: THREE.AdditiveBlending, + }); + var gs = new THREE.Mesh(starmapGlowGeo, gm); + gs.scale.setScalar(rad * 1.2 + mnr * 0.5); + mesh.add(gs); + mesh.userData.glowSphere = gs; + // 名字不再烘成贴图;hover 时由 onStarmapMove 写进角标。 + starmapScene.add(mesh); + starmapNodeMeshes.push(mesh); + }); + // Create edges + // + // 866 条边原本每条一个 BufferGeometry + Line + LineBasicMaterial + // (866 个 draw call)。改为**按关系类型分组**的少量 LineSegments: + // 同类型边合并成一个几何体,draw call 从 866 降到「关系类型数」 + // (生产实例实测 6 种左右)。 + // + // 副作用:单条边不再能单独点选。星图此前也没有点选边的交互 + // (onStarmapClick 只处理节点),所以这是纯粹的成本削减。 + var edgeByColor = {}; + starmapEdges.forEach((e) => { + var a = pos[e.source_id], + b = pos[e.target_id]; + if (!a || !b) return; + var col = smEdgeColors[e.relation_type] || smEdgeColors[e.type] || 0x444466; + var k = String(col); + if (!edgeByColor[k]) edgeByColor[k] = { col: col, pts: [] }; + edgeByColor[k].pts.push(a.x, a.y, a.z, b.x, b.y, b.z); + }); + Object.keys(edgeByColor).forEach((k) => { + var g = edgeByColor[k]; + if (!g.pts.length) return; + var geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.Float32BufferAttribute(g.pts, 3)); + var mat = new THREE.LineBasicMaterial({ + color: g.col, + transparent: true, + opacity: 0.4, + }); + var seg = new THREE.LineSegments(geo, mat); + starmapScene.add(seg); + starmapEdgeLines.push(seg); + }); +} + +// 回合收尾:由 SSE 事件(agent_output final / reset 帧)或 watchdog 驱动。 +// POST 结束 ≠ 回合结束:agent 可能还在生成(排队+长生成),提前复位 +// chatLoading 会让后续 delta 走全量重建、停止按钮消失、用户误发重复消息。 +function endChatTurn() { + if (!state.chatLoading) return; + state.chatLoading = false; + state.chatStage = ""; + if (state._turnWatchdog) { + clearTimeout(state._turnWatchdog); + state._turnWatchdog = null; + } + var btn = document.getElementById("chat-send-btn"); + if (btn) { + btn.disabled = false; + btn.textContent = __("发送", "Send"); + } + var sb = document.getElementById("chat-stop-btn"); + if (sb) sb.style.display = "none"; + rerenderChat(true); +} + +// 回合看门狗:POST 已 abort 且 SSE 迟迟无终帧时兕底收尾(连接不稳/事件丢失), +// 提示用户回复可能已生成、可刷新查看历史。避免回合永久卡在 loading。 +function armTurnWatchdog() { + if (state._turnWatchdog) clearTimeout(state._turnWatchdog); + state._turnWatchdog = setTimeout(() => { + state._turnWatchdog = null; + if (state.chatLoading) { + endChatTurn(); + toast( + __( + "长时间未收到回复,连接可能不稳定;回复可能已生成,可刷新页面查看", + "No reply received for a long time; the reply may have been generated, refresh to check", + ), + true, + ); + } + }, 120000); +} + +// 上传文件并注入 agent:multipart POST /api/v1/chat/file。 +// 服务端落盘 uploads/ 后注入「[用户发送了文件: 名字 (大小)] 已保存到 <路径>」; +// 回复与普通消息一样走 SSE 流式渲染。可选附言从输入框读取。 +// 拖拽文件到聊天区即发送(可选:先在输入框写附言) +function initChatDragDrop() { + var panel = document.getElementById("chat-panel-chat"); + if (!panel || panel.__dnd) return; + panel.__dnd = true; + panel.addEventListener("dragover", (e) => { + e.preventDefault(); + panel.style.outline = "2px dashed var(--accent, #4a90d9)"; + }); + panel.addEventListener("dragleave", () => { + panel.style.outline = ""; + }); + panel.addEventListener("drop", (e) => { + e.preventDefault(); + panel.style.outline = ""; + if (e.dataTransfer.files && e.dataTransfer.files.length) { + sendChatFile(e.dataTransfer.files[0]); + } + }); + // 粘贴截图/复制的文件直接发送 + document.addEventListener("paste", (e) => { + var chatVisible = + document.getElementById("chat-panel-chat") && + document.getElementById("chat-input"); + if (!chatVisible) return; + var items = e.clipboardData && e.clipboardData.files; + if (items && items.length && document.activeElement !== null) { + sendChatFile(items[0]); + } + }); +} + +async function sendChatFile(fileObj, extraText) { + if (!fileObj || state.chatLoading) return; + var inp = document.getElementById("chat-input"); + var message = (extraText || inp.value || "").trim(); + if (inp) inp.value = ""; + var fd = new FormData(); + fd.append("file", fileObj); + if (message) fd.append("message", message); + state.messages.push({ + role: "user", + content: message, + attachment: { + // 与服务端的 attType 判定保持一致(image/audio/file), + // 否则乐观渲染的卡片会在 SSE 回流后变成另一种样式。 + type: /^image\//.test(fileObj.type) + ? "image" + : /^audio\//.test(fileObj.type) + ? "audio" + : "file", + url: URL.createObjectURL(fileObj), + name: fileObj.name, + size: fileObj.size, + }, + }); + rerenderChat(true); + state.chatLoading = true; + state.chatStage = __("等待AI回复...", "Waiting for AI..."); + rerenderChat(true); + try { + await api("/chat/file", { method: "POST", body: fd, rawBody: true }); + // 回复经 SSE 流式到达,这里无需处理响应体 + } catch (e) { + toast( + __("文件发送失败:" + e.message, "File send failed: " + e.message), + true, + ); + state.chatLoading = false; + endChatTurn(); + } +} + +async function sendChat() { + var inp = document.getElementById("chat-input"); + var btn = document.getElementById("chat-send-btn"); + var stopBtn = document.getElementById("chat-stop-btn"); + var text = inp.value.trim(); + if (!text || state.chatLoading) return; + state.chatStick = true; + state.chatFinalIdx = -1; + state.messages.push({ role: "user", content: text }); + inp.value = ""; + rerenderChat(true); + state.chatLoading = true; + state.chatStage = __("等待AI回复...", "Waiting for AI..."); + btn.disabled = true; + btn.textContent = ""; + if (stopBtn) stopBtn.style.display = ""; // 生成期间可停止 + rerenderChat(true); + // 触发式 POST:短超时仅确认受理;回复靠 SSE 流式渲染(对齐 GUI 行为)。 + try { + var ctrl = new AbortController(); + var ackTimer = setTimeout(() => { + ctrl.abort(); + }, 15000); + var r = null; + try { + r = await api("/chat", { + method: "POST", + body: JSON.stringify({ message: text }), + signal: ctrl.signal, + }); + } catch (ackErr) { + // 同步超时/失败:不阻塞 UI,等 SSE 兑底;明确提示"可能已发送" + console.warn("[sendChat] trigger failed: " + ackErr.message); + toast( + __( + "请求超时(可能已发送,请稍候或在收到回复前勿重复发送)", + "Request timeout (may have been sent; wait for reply before resending)", + ), + true, + ); + r = null; + } finally { + clearTimeout(ackTimer); + } + var last = state.messages[state.messages.length - 1]; + if (r && r.response) { + if (last && last.role === "assistant" && last._streaming) { + last.content = r.response || __("(无响应)", "(no response)"); + last._grow = true; + if (!last.reasoning_content) { + last.reasoning_content = r.reasoning_content || ""; + } + last._final = true; + delete last._streaming; + } else { + state.messages.push({ + role: "assistant", + content: r.response || __("(无响应)", "(no response)"), + reasoning_content: r.reasoning_content, + tool_calls: + last && last.role === "assistant" && last.tool_calls + ? last.tool_calls + : [], + _final: true, + _grow: true, + }); + } + state.chatFinalIdx = state.messages.length - 1; + } + rerenderChat(true); + } catch (e) { + // 真实错误(非受理超时):展示错误信息 + if (!String(e.message || "").includes("aborted")) { + state.messages.push({ + role: "assistant", + content: __("错误: ", "Error: ") + e.message, + _final: true, + }); + rerenderChat(true); + toast(__("请求失败: ", "Request failed: ") + e.message, true); + } + } finally { + if (r && r.response) { + // 同步兜底已拿到完整回复:回合结束 + endChatTurn(); + } else { + // 触发式受理(POST 已 abort/失败):回合仍打开,等 SSE 流式渲染; + // 由 agent_output final / reset 帧 / watchdog 收尾 + armTurnWatchdog(); + } + } +} + +// 停止生成 / 发送中断消息。核心拦截语义:有 LLM 在跑则取消当前 +// 请求并以 [中断消息] 重启轮次;无则在跑则作为普通消息处理。 +async function interruptChat() { + try { + await api("/chat/interrupt", { + method: "POST", + body: JSON.stringify({}), + }); + toast(__("已发送中断信号", "Interrupt signal sent")); + } catch (e) { + toast(__("中断失败: ", "Interrupt failed: ") + e.message, true); + } +} + +async function queryMemoryChat() { + var q = document.getElementById("mem-query")?.value; + var r = document.getElementById("mem-result-chat"); + if (!r || !q) return; + r.innerHTML = '
'; + try { + var data = await api("/memory?q=" + encodeURIComponent(q) + "&depth=2"); + r.innerHTML = + '
' +
+			escHtml(JSON.stringify(data, null, 2)) +
+			"
"; + } catch (e) { + r.innerHTML = + '

' + + __("查询失败: ", "Query failed: ") + + escHtml(e.message) + + "

"; + } +} + +async function queryMemoryContext() { + var q = document.getElementById("ctx-query")?.value; + var r = document.getElementById("ctx-result"); + if (!r) return; + r.innerHTML = '
'; + try { + var data = await api("/memory/context?q=" + encodeURIComponent(q || "")); + var ctx = data?.context || __("无上下文", "No context"); + var summary = data?.summary || ""; + var entities = data?.entities || []; + var tk = data?.token_estimate || 0; + var html = '
'; + if (summary) + html += + '
' + + __("摘要", "Summary") + + '' + + escHtml(summary) + + "
"; + html += + '
Token ' + + __("预估", "Estimate") + + '' + + tk + + "
"; + if (entities.length) { + html += + '
' + + __("实体", "Entities") + + '' + + entities.map((e) => escHtml(e.name || e.id || "")).join(", ") + + "
"; + } + html += + '
' +
+			escHtml(ctx) +
+			"
"; + r.innerHTML = html; + } catch (e) { + r.innerHTML = + '

' + + __("获取失败: ", "Get failed: ") + + escHtml(e.message) + + "

"; + } +} + +// 知识库面板:搜索 / 创建 / 删除 / 刷新计数。 +// +// 此前三处问题:搜索把裸 JSON 直接 stringify 丢进
(用户看到一坨
+// 机器码);创建后不刷新计数(1644 行读的是 state.kernel 快照,创建
+// 完仍是旧值);没有任何删除入口,也没有媒体上传。
+function knowEsc(v) {
+	return String(v == null ? "" : v)
+		.replace(/&/g, "&")
+		.replace(//g, ">");
+}
+
+async function refreshKnowledgeCount() {
+	try {
+		var d = await api("/knowledge");
+		var el = document.getElementById("know-count");
+		if (el && d && d.names) el.textContent = d.names.length;
+		// 稠密路(多模态)状态提示
+		var hint = document.getElementById("know-media-hint");
+		if (hint && d && d.dense) {
+			if (d.dense.enabled) {
+				hint.textContent =
+					__("多模态已就绪 ", "Multimodal ready ") +
+					(d.dense.ready || 0) +
+					"/" +
+					((d.dense.ready || 0) + (d.dense.stale || 0));
+			} else {
+				hint.textContent = __(
+					"未接入多模态:图片只记录不参与召回",
+					"Multimodal off: images recorded but not searchable",
+				);
+			}
+		}
+	} catch (e) {
+		/* 计数刷新失败不该打断用户操作 */
+	}
+}
+
+function renderKnowledgeResults(views, q) {
+	if (!views || !views.length) {
+		return (
+			'

' + + __("未找到相关知识", "No matching knowledge") + + "

" + ); + } + var html = ""; + views.forEach((v) => { + html += + '
'; + html += '
'; + html += + '' + + knowEsc(v.name) + + ""; + if (v.size) { + html += '' + v.size + " B"; + } + html += + '"; + html += "
"; + if (v.media && v.media.length) { + html += '
'; + v.media.forEach((m) => { + var kind = m.kind || "file"; + html += + '[' + + knowEsc(kind) + + "] " + + knowEsc(m.digest.slice(0, 8)) + + "…"; + }); + html += "
"; + } + if (v.preview) { + html += + '
' + + knowEsc(v.preview) + + "
"; + } + html += "
"; + }); + return html; +} + +// 知识库分类树浏览。 +// +// 数据来自 /knowledge/tree(只读树接口)。当前分类存 state, +// 搜索时自动带上 —— 这样"先定位分类再检索"这个更准的用法在 UI 上 +// 是一步的事,而不是要求用户手打分类名。 +var _knowCat = ""; // 当前分类的完整 path;"" = 全库 + +function renderKnowTree(node, depth) { + var html = ""; + // 本节点的条目 + (node.items || []).forEach((it) => { + html += + '
' + + '▸ ' + + '' + + knowEsc(it.name) + + "" + + (it.size + ? ' ' + it.size + "B" + : "") + + (it.media && it.media.length + ? ' [' + + it.media.length + + __(" 媒体", " media") + + "]" + : "") + + "
"; + }); + // 子分类 + (node.children || []).forEach((c) => { + var isCur = _knowCat === c.path; + var mark = isCur ? "● " : ""; + html += + '
' + + mark + + knowEsc(c.name) + + ' ' + + c.item_count + + "/" + + c.total_count + + "
"; + html += renderKnowTree(c, depth + 1); + }); + return html; +} + +async function loadKnowledgeTree() { + var box = document.getElementById("know-tree"); + if (!box) return; + box.innerHTML = '
'; + try { + var d = await api("/knowledge/tree?items=1"); + if (!d || !d.tree) { + box.innerHTML = + '

' + + __("暂无分类", "No categories yet") + + "

"; + return; + } + box.innerHTML = renderKnowTree(d.tree, 0); + } catch (e) { + box.innerHTML = + '

' + + __("加载分类失败: ", "Load categories failed: ") + + knowEsc(e.message) + + "

"; + } +} + +function selectKnowledgeCategory(path) { + _knowCat = path || ""; + var lbl = document.getElementById("know-cat-scope"); + if (lbl) { + lbl.textContent = _knowCat + ? __("范围:", "scope: ") + _knowCat + : __("范围:全库", "scope: all"); + } + loadKnowledgeTree(); + // 立刻按新范围搜一次,省一次点击 + var q = document.getElementById("know-query")?.value; + if (q) searchKnowledgeChat(); +} + +function clearKnowledgeCategory() { + selectKnowledgeCategory(""); +} + +async function searchKnowledgeChat() { + var q = document.getElementById("know-query")?.value; + var cat = _knowCat || ""; + var r = document.getElementById("know-result-chat"); + if (!r) return; + if (!q) { + r.innerHTML = + '

' + + __("请输入查询关键词", "Enter a keyword") + + "

"; + return; + } + r.innerHTML = '
'; + try { + var url = "/knowledge?q=" + encodeURIComponent(q); + if (cat) url += "&category=" + encodeURIComponent(cat); + var data = await api(url); + r.innerHTML = renderKnowledgeResults(data && data.results, q); + } catch (e) { + r.innerHTML = + '

' + + __("搜索失败: ", "Search failed: ") + + knowEsc(e.message) + + "

"; + } +} + +async function deleteKnowledge(name) { + if ( + !confirm( + __("确定删除知识「", 'Delete knowledge "') + + name + + __("」?此操作不可撤销。", '"? This cannot be undone.'), + ) + ) { + return; + } + try { + await api("/knowledge?name=" + encodeURIComponent(name), { + method: "DELETE", + }); + toast(__("知识「", 'Knowledge "') + name + __("」已删除", '" deleted')); + refreshKnowledgeCount(); + searchKnowledgeChat(); + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } +} + +async function createKnowledgeChat() { + var name = document.getElementById("know-name")?.value; + var content = document.getElementById("know-content")?.value; + var fileInput = document.getElementById("know-media"); + var files = fileInput && fileInput.files ? fileInput.files : null; + if (!name) { + toast(__("名称不能为空", "Name is required"), true); + return; + } + if ((!content || !content.trim()) && (!files || !files.length)) { + toast(__("内容与媒体至少要有一项", "Content or media is required"), true); + return; + } + try { + var r; + if (files && files.length) { + // 有文件走 multipart:服务端按**探测到的真实类型**分流, + // 图片/音视频入媒体库并按 digest 挂到条目上,文本存正文。 + var fd = new FormData(); + fd.append("name", name); + if (content) fd.append("content", content); + for (var i = 0; i < files.length; i++) fd.append("file", files[i]); + r = await api("/knowledge", { method: "POST", body: fd }); + } else { + r = await api("/knowledge", { + method: "POST", + body: JSON.stringify({ name: name, content: content }), + }); + } + var msg = __("知识「", 'Knowledge "') + name + __("」已创建", '" created'); + if (r && r.media) + msg += __(",含 ", " with ") + r.media + __(" 个媒体", " media item(s)"); + if (r && r.rejected && r.rejected.length) { + msg += __(";", "; ") + r.rejected.length + __(" 项被跳过", " skipped"); + } + toast(msg); + document.getElementById("know-name").value = ""; + document.getElementById("know-content").value = ""; + if (fileInput) fileInput.value = ""; + refreshKnowledgeCount(); + } catch (e) { + toast(__("创建失败: ", "Create failed: ") + e.message, true); + } +} + +function switchChatPanel(tab, el) { + // 切到知识面板时拉实时计数:面板里的数字来自 state.kernel 快照, + // 而知识条目会经工具/上传增删,快照不会自己变(实测创建后仍显示 "-")。 + if (tab === "knowledge") { + refreshKnowledgeCount(); + var lbl = document.getElementById("know-cat-scope"); + if (lbl && !lbl.textContent) { + lbl.textContent = _knowCat + ? __("范围:", "scope: ") + _knowCat + : __("范围:全库", "scope: all"); + } + loadKnowledgeTree(); + } + var panels = { + chat: document.getElementById("chat-panel-chat"), + starmap: document.getElementById("chat-panel-starmap"), + terminal: document.getElementById("chat-panel-terminal"), + cmd: document.getElementById("chat-panel-cmd"), + memory: document.getElementById("chat-panel-memory"), + context: document.getElementById("chat-panel-context"), + knowledge: document.getElementById("chat-panel-knowledge"), + }; + Object.keys(panels).forEach((k) => { + var p = panels[k]; + if (p) p.classList.toggle("active", k === tab); + }); + if (el) { + var parent = el.parentElement; + if (parent) { + Array.from(parent.children).forEach((ch) => { + ch.classList.remove("active"); + }); + el.classList.add("active"); + } + } + if (tab === "starmap") { + if (!state.starmapInit) { + state.starmapLoading = false; + renderChatStarmap(); + } else if (starmapRen) { + var cont = document.getElementById("sm-container-chat"); + if (cont) { + var rect = cont.getBoundingClientRect(); + if (rect.width > 0) + starmapRen.setSize(rect.width, Math.max(rect.height, 250)); + if (!cont.contains(starmapRen.domElement)) + cont.appendChild(starmapRen.domElement); + } + } + } + if (tab === "chat") { + state.chatStick = true; + var msgsEl = document.getElementById("chat-msgs"); + if (msgsEl) { + try { + msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: "smooth" }); + } catch (e) { + msgsEl.scrollTop = msgsEl.scrollHeight; + } + } + } + if (tab === "context") queryMemoryContext(); +} + +// 首屏分段加载条数:只拉最新 N 条,向上滚动触顶时再拉更早的。 +var CHAT_PAGE_SIZE = 40; +async function loadChatHistory() { + try { + var data = await api("/chat/history?limit=" + CHAT_PAGE_SIZE); + if (data && data.messages) { + state.messages = data.messages; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatTotal = + typeof data.total === "number" ? data.total : data.messages.length; + state.chatHasMore = !!data.has_more; + state.chatLastSeq = historyLastSeq(data); + } + } catch (e) {} +} + +// historyLastSeq 从一次 /chat/history 响应里取出「已见到的最大 seq」: +// 优先用服务端给的 last_seq,缺了就取消息里的最大值。 +function historyLastSeq(data) { + if (!data) return 0; + if (typeof data.last_seq === "number") return data.last_seq; + var mx = 0; + (data.messages || []).forEach((m) => { + if (m && m.seq > mx) mx = m.seq; + }); + return mx; +} + +// applyServerMessages 把服务端消息并进 state.messages,按 seq 对账: +// - 同 seq 已存在 → 原地替换(工具调用/最终文本是原地更新); +// - 不存在 → 追加(若末尾是无 seq 的乐观消息且 role+content 一致,则替换它, +// 避免"自己刚发的那条"重复成两条)。 +// tailOnly=true 时只做原地更新与"比本地新才追加",不把尾探测当成新消息。 +// @returns {boolean} 是否真的改动了 state.messages +function applyServerMessages(list, tailOnly) { + if (!list || !list.length) return false; + var msgs = state.messages; + var changed = false; + function carry(prev, sm) { + if (prev && prev._final) sm._final = true; + if (prev && prev._grow) sm._grow = true; + return sm; + } + list.forEach((sm) => { + if (!sm) return; + var seq = sm.seq; + var found = -1; + for (var j = msgs.length - 1; j >= 0 && j >= msgs.length - 12; j--) { + if (seq && msgs[j] && msgs[j].seq === seq) { + found = j; + break; + } + } + if (found >= 0) { + if (JSON.stringify(msgs[found]) !== JSON.stringify(sm)) { + msgs[found] = carry(msgs[found], sm); + changed = true; + } + return; + } + if (tailOnly) { + var lastS = msgs.length ? msgs[msgs.length - 1].seq : 0; + if (seq && (!lastS || seq > lastS)) { + msgs.push(sm); + changed = true; + } + return; + } + if (msgs.length) { + var last = msgs[msgs.length - 1]; + if ( + !last.seq && + (last.role || "") === (sm.role || "") && + (last.content || "") === (sm.content || "") + ) { + msgs[msgs.length - 1] = carry(last, sm); + changed = true; + return; + } + } + msgs.push(sm); + changed = true; + }); + list.forEach((sm) => { + if (sm && sm.seq > (state.chatLastSeq || 0)) state.chatLastSeq = sm.seq; + }); + if (changed) rerenderChat(); + return changed; +} + +// pollChatIncremental 轮询「自上次以来新增了什么」。 +// +// 这就是「暴露数据查询 api,前端轮询后 patch 视图」那条路:after=游标 +// 只拿增量,再单独探一次尾部做原地更新(工具调用/最终文本是原地改的, +// 不会产生新 seq,只靠 after 拿不到)。视图更新走 commitChatList 的 +// keyed 对账,未变消息节点一个字节都不动 —— 闪烁由此消失。 +function pollChatIncremental() { + var after = state.chatLastSeq || 0; + if (!after) { + // 还没建立游标(首次 / 本地为空):退回一次性全量,交给已有一致性逻辑 + return api("/chat/history?limit=" + CHAT_PAGE_SIZE).then((data) => { + state.chatLastSeq = historyLastSeq(data); + return mergeChatFromHistory(data); + }); + } + return api("/chat/history?after=" + after) + .then((data) => { + if (!data) return; + state.chatLastSeq = historyLastSeq(data) || after; + applyServerMessages(data.messages || [], false); + return api("/chat/history?limit=1"); + }) + .then((tail) => { + if (tail && tail.messages && tail.messages.length) { + applyServerMessages(tail.messages.slice(-1), true); + } + }); +} + +// loadOlderChat 向上翻页:拉 offset 之前的一页,前置到 messages 头部。 +// 保持滚动位置(插入前后 scrollHeight 差值补偿),避免视口跳动。 +var _loadingOlder = false; +async function loadOlderChat() { + if (_loadingOlder || !state.chatHasMore) return; + _loadingOlder = true; + var msgsEl = document.getElementById("chat-msgs"); + var prevH = msgsEl ? msgsEl.scrollHeight : 0; + var prevTop = msgsEl ? msgsEl.scrollTop : 0; + try { + var before = state.chatOffset || 0; + if (before <= 0) { + state.chatHasMore = false; + return; + } + var data = await api( + "/chat/history?limit=" + CHAT_PAGE_SIZE + "&before=" + before, + ); + if (data && data.messages && data.messages.length) { + state.messages = data.messages.concat(state.messages); + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; + state.chatStick = false; // 向上翻页时不自动粘底 + renderChat(); + if (msgsEl) { + // 补偿新增内容高度,保持用户视觉位置不变 + msgsEl.scrollTop = prevTop + (msgsEl.scrollHeight - prevH); + } + } else { + state.chatHasMore = false; + } + } catch (e) { + } finally { + _loadingOlder = false; + } +} + +// syncChatFromHistory 增量同步:先做一次极轻的"尾巴探测",只有尾巴变了才拉整页。 +// +// 原先每 30s(以及每次 SSE 报错)都直接拉一页 40 条:本地实测 180KB、 +// 生产消息更大时可达 ~1MB —— 这是"每次都在发完整聊天记录"的观感来源。 +// 探测只需 1 条(约几 KB),尾巴一致就直接跳过。 +var _syncingChat = false; +// syncChatFromHistory 保留旧名(多处调用点):内部改走游标增量轮询。 +function syncChatFromHistory() { + if (_syncingChat) return Promise.resolve(); + _syncingChat = true; + return pollChatIncremental() + .catch(() => {}) + .then(() => { + _syncingChat = false; + }); +} + +// mergeChatFromHistory 把服务端的一页历史并进本地:只追加新消息,不重建已有节点。 +// 关键约束:**绝不**用更短的服务端页替换更长的本地列表(那会让用户翻上来的旧页 +// 凭空消失、视口跳回顶部)。 +function mergeChatFromHistory(data) { + if (!data || !data.messages || data.messages.length === 0) return; + var serverMsgs = data.messages; + var localMsgs = state.messages; + // 首次加载(空列表)→ 全量赋值 + if (localMsgs.length === 0) { + state.messages = serverMsgs; + state.chatOffset = typeof data.offset === "number" ? data.offset : 0; + state.chatHasMore = !!data.has_more; + rerenderChat(true); + return; + } + // 以「本地末尾 vs 服务端末尾」做依据。分段拉取只会回传最新页, + // 本地已向上翻页加载了更早内容,长度上本地可能 ≥ 服务端。 + var lastLocal = localMsgs[localMsgs.length - 1]; + var lastServer = serverMsgs[serverMsgs.length - 1]; + var localContent = lastLocal.content || lastLocal.Content || ""; + var serverContent = lastServer.content || lastServer.Content || ""; + // 服务端末尾和本地末尾内容相同 → 无新增(可能裁剪过的轻量项因不含 tool_calls args/result, + // 依赖 content+role+time 足以识别) + if (localContent === serverContent) { + // 检查是否本地末尾被服务端改写(如 stage 插件改写最终文本) + if ( + lastServer.role === "assistant" && + localContent !== serverContent && + serverContent + ) { + lastLocal.content = serverContent; + if (lastServer.ReasoningContent) + lastLocal.reasoning_content = lastServer.ReasoningContent; + var msgsEl0 = document.getElementById("chat-msgs"); + if (msgsEl0 && msgsEl0.lastElementChild) { + var textEl0 = + msgsEl0.lastElementChild.querySelector(".msg-bubble .text"); + if (textEl0) textEl0.innerHTML = renderMd(serverContent); + } + } + return; + } + // 有新增消息:服务端末尾与本地末尾不同 → 找出本地新增的差异,追加到 DOM。 + // 服务端只回传最新页,我们需在 localMsgs 末尾取交集起点(本地末尾在服务端页的位置) + // 简化:取服务端末尾3条与本地末尾3条比对,找到重含点的下标;若找不到全量替换最新页。 + var overlap = -1; + for ( + var k = Math.min(3, serverMsgs.length - 1, localMsgs.length - 1); + k >= 1; + k-- + ) { + var sMsg = serverMsgs[serverMsgs.length - 1 - k]; + var lMsg = localMsgs[localMsgs.length - 1 - k]; + if ( + lMsg && + (lMsg.content || "") === (sMsg.content || "") && + lMsg.role === sMsg.role + ) { + overlap = k; + break; + } + } + var newMsgs; + if (overlap >= 0) { + // 本地末尾 overlap 条在服务端页里,之后的部分是新增 + newMsgs = serverMsgs.slice(serverMsgs.length - overlap); + if (newMsgs.length === 0) return; // 无新增(内容改写走上面的分支) + } else { + // 找不到重合点:**不能**直接拿服务端页覆盖本地。 + // 服务端只回一页,本地翻上来的旧页更长;覆盖会同时造成两个后果: + // 用户翻过的旧消息凭空消失、容器变矮后视口被夹回顶部。 + // 只有在服务端页不短于本地时才整体替换(那种情况下不丢内容)。 + if (serverMsgs.length >= localMsgs.length) { + state.messages = serverMsgs; + rerenderChat(true); + } + return; + } + var msgsEl = document.getElementById("chat-msgs"); + if (msgsEl) { + var aiAvatar = '小宅'; + var userAvatar = + ''; + newMsgs.forEach((m) => { + var role = m.role || m.Role || "user"; + var c = m.content || m.Content || ""; + if (role === "assistant") c = renderMd(c); + else c = escHtml(c); + var isChan = !!(m.source && m.source !== "webui"); + var bubble = c + ? '
' + c + "
" + : '
'; + if (role === "system") { + msgsEl.insertAdjacentHTML( + "beforeend", + '
' + + c + + "
", + ); + } else if (isChan) { + msgsEl.insertAdjacentHTML( + "beforeend", + '
' + + (m.source || "?")[0].toUpperCase() + + '
' + + escHtml(m.source) + + "
" + + bubble + + "
", + ); + } else { + msgsEl.insertAdjacentHTML( + "beforeend", + '
' + + (role === "user" ? userAvatar : aiAvatar) + + '
' + + bubble + + "
", + ); + } + }); + // 删除流式占位符(同步完成,下一次 SSE 会重建) + var streamingPh = msgsEl.querySelector(".msg-streaming-ph"); + if (streamingPh) streamingPh.remove(); + } + // 追加新消息对象到 state.messages + Array.prototype.push.apply(state.messages, newMsgs); + // 同步聊天占位符(如果有新消息但最后一条非 assistant → 显示流式占位) + syncStreamingPlaceholder(); +} +// syncStreamingPlaceholder:同步聊天占位符的可见性 +function syncStreamingPlaceholder() { + var msgsEl = document.getElementById("chat-msgs"); + if (!msgsEl) return; + var existing = msgsEl.querySelector(".msg-streaming-ph"); + var lastMsg = state.messages.length + ? state.messages[state.messages.length - 1] + : null; + var showPh = + state.chatLoading && + (!lastMsg || lastMsg.role !== "assistant" || lastMsg._final); + if (showPh && !existing) { + var aiAvatar = '小宅'; + var pillHtml = ""; + (state.pendingTools || []).forEach((nm) => { + pillHtml += + '' + + escHtml(nm) + + ""; + }); + msgsEl.insertAdjacentHTML( + "beforeend", + '
' + + aiAvatar + + '
' + + (pillHtml + ? '' + pillHtml + "" + : "") + + "
", + ); + } else if (!showPh && existing) { + existing.remove(); + } +} + +async function loadTerminals() { + try { + var data = await api("/terminals"); + if (data && data.terminals) state.terminals = data.terminals; + } catch (e) {} +} + +async function loadCmdHistory() { + try { + var data = await api("/cmd/history"); + if (data && data.history) state.cmdHistory = data.history; + } catch (e) {} +} + +function appendTermBuf(el, text) { + if (!text) return; + el.textContent += text; + if (el.textContent.length > 262144) { + el.textContent = el.textContent.slice(el.textContent.length - 262144); + } + el.scrollTop = el.scrollHeight; +} + +function renderTerminals() { + var r = document.getElementById("term-list"); + var cnt = document.getElementById("term-count-badge"); + if (!r) return; + var list = state.terminals || []; + if (cnt) cnt.textContent = list.length; + if (list.length === 0) { + r.innerHTML = + '

' + + __("暂无终端会话", "No terminal sessions") + + "

"; + return; + } + var html = ""; + list.forEach((t, i) => { + var detailId = "term-detail-" + i; + var scr = (state.termScreens && state.termScreens[t.id]) || null; + var running = scr ? scr.running : !!t.running; + var fullOut = scr ? scr.output : t.output || ""; + if (!fullOut) { + fullOut = + '' + + __("[终端暂无输出]", "[No terminal output]") + + ""; + } else { + fullOut = escHtml(fullOut); + } + html += + '
'; + html += + '
"; + html += + '' + + escHtml(t.id || "-") + + ""; + html += + '' + + escHtml(t.command || "") + + ""; + html += + '' + + (running ? __("运行中", "Running") : __("已关闭", "Closed")) + + ""; + html += + '' + + escHtml(t.created_at || "") + + ""; + html += "
"; + html += + '
"; + }); + r.innerHTML = html; +} + +function renderCmdHistory() { + var r = document.getElementById("cmd-list"); + var cnt = document.getElementById("cmd-count-badge"); + if (!r) return; + var running = (state.terminals || []).filter((t) => t.running); + if (cnt) cnt.textContent = running.length; + if (running.length === 0) { + r.innerHTML = + '

' + + __("暂无运行中的命令", "No running commands") + + "

"; + return; + } + var html = + '"; + running.forEach((t) => { + var scr = (state.termScreens && state.termScreens[t.id]) || null; + var out = scr ? scr.output : t.output || ""; + html += + "" + + '" + + '" + + '" + + ""; + if (out) { + html += + "" + + '"; + } + }); + html += "
' + + __("命令", "Command") + + "" + + __("状态", "Status") + + "" + + __("运行时长", "Uptime") + + "
' + + escHtml(t.command || t.id || "") + + "' + + __("运行中", "Running") + + "' + + escHtml(t.uptime || "-") + + "
' +
+				escHtml(out.substring(0, 2000)) +
+				"
"; + r.innerHTML = html; +} + +function connectSSE() { + if (state.eventSource) { + console.log("[SSE] closing old connection"); + state.eventSource.close(); + } + var es; + try { + es = new EventSource("/api/v1/chat/events", { + withCredentials: true, + }); + state.eventSource = es; + } catch (ex) { + console.error("[SSE] create failed", ex); + return; + } + if (!es) { + console.error("[SSE] es is null"); + return; + } + console.log("[SSE] connected"); + es.addEventListener("agent_output", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + // 光点:agent → 输出通道。总览页拓扑靠它做"消息出去了"的动画; + // 其它页没有该 SVG,rtSparkOutput 查不到元素就是 no-op。 + if (p.channel) rtSparkOutput(ev.source || "", p.channel); + // 星图跟随:agent 输出是一次完整活动的收尾,给一记强脉冲。 + starmapPulse("output", p.channel || null); + console.log( + "[SSE] agent_output received", + p.content ? p.content.substring(0, 50) : "(empty)", + ); + if (!p.content) return; + state.chatStage = __("AI 回复中...", "AI replying..."); + if (p.kind === "channel_output") { + // 附件输出(output_type=image/file):渲染为图片预览/下载卡片 + var att = null; + if ( + p.output_type === "image" || + p.output_type === "audio" || + p.output_type === "file" + ) { + att = { + type: p.output_type, + url: p.url || p.content, + size: p.size || 0, + name: (p.url || "").split("/").pop() || "附件", + }; + } + var cm = { + role: "assistant", + content: att ? "" : p.content, + source: p.channel || "", + _final: true, + _grow: true, + attachment: att, + }; + if ( + state.chatFinalIdx >= 0 && + state.chatFinalIdx < state.messages.length + ) { + state.messages.splice(state.chatFinalIdx, 0, cm); + state.chatFinalIdx++; + } else { + state.messages.push(cm); + } + rerenderChat(); + return; + } + var lastM2 = state.messages.length + ? state.messages[state.messages.length - 1] + : null; + if (lastM2 && lastM2.role === "assistant" && !lastM2._final) { + // 聚合最终响应:覆盖 delta 累积的中间内容(以聚合为准, + // 含 stage 插件改写后的最终文本),并置 final 结束本轮流式。 + lastM2._grow = true; + lastM2.content = p.content; + lastM2._final = true; + rerenderChat(); + endChatTurn(); + return; + } + if ( + lastM2 && + lastM2.role === "assistant" && + lastM2._final && + !lastM2.source + ) { + // 去重:同一轮的重复帧(如 SSE 重连回放)内容相同则忽略; + // 内容不同视为新一轮输出(上一轮已 final 且无 source),开新消息。 + // 旧逻辑无条件 return 会丢弃多轮连发时新一轮的最终回复。 + if (lastM2.content === p.content) { + endChatTurn(); + return; + } + } + state.messages.push({ + role: "assistant", + content: p.content, + _streaming: true, + _grow: true, + _final: true, + }); + rerenderChat(); + endChatTurn(); + } catch (ex) { + console.error("[SSE] agent_output error", ex); + } + }); + // token 级流式增量:逐块追加到当前回复内容(流式生成中); + // reset 帧表示轮次作废(用户中断):定格已显示的部分内容,置 final。 + es.addEventListener("content_delta", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + if (p.channel === "_consolidation_") return; + if (p.reset) { + var lm = state.messages.length + ? state.messages[state.messages.length - 1] + : null; + if (lm && lm.role === "assistant" && !lm._final) { + lm._final = true; + rerenderChat(); + } + // 轮次作废(用户中断):定格已显示内容;核心会以 [中断消息] + // 重启轮次,保持回合打开让确认回复继续流式渲染, + // 由其 agent_output final / watchdog 收尾。 + armTurnWatchdog(); + return; + } + if (!p.content) return; + state.chatStage = __("AI 回复中...", "AI replying..."); + var last = + state.messages.length > 0 + ? state.messages[state.messages.length - 1] + : null; + if (!last || last.role !== "assistant" || last._final) { + state.messages.push({ + role: "assistant", + content: "", + tool_calls: [], + _streaming: true, + _grow: true, + }); + last = state.messages[state.messages.length - 1]; + } + last.content += p.content; + rerenderChat(); + } catch (ex) {} + }); + es.addEventListener("terminal_output", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + if (!p.terminal_id) return; + var id = p.terminal_id; + if (!state.termScreens) state.termScreens = {}; + var scr = + state.termScreens[id] || + (state.termScreens[id] = { output: "", running: true }); + if (p.output) scr.output += p.output; + if (typeof p.running === "boolean") scr.running = p.running; + var bufel = document.getElementById("term-buf-" + id); + if (bufel) { + appendTermBuf(bufel, p.output || ""); + var dot = document.getElementById("term-dot-" + id); + if (dot) dot.className = "term-dot" + (scr.running ? "" : " stopped"); + } + } catch (ex) { + console.error("[SSE] terminal_output error", ex); + } + }); + es.addEventListener("reasoning", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + if (p.channel === "_consolidation_") return; + if (p.content) { + state.chatStage = __("AI 思考中...", "AI thinking..."); + var last = + state.messages.length > 0 + ? state.messages[state.messages.length - 1] + : null; + if (!last || last.role !== "assistant" || last._final) { + state.messages.push({ + role: "assistant", + content: "", + reasoning_content: "", + tool_calls: [], + _streaming: true, + }); + last = state.messages[state.messages.length - 1]; + } + // 聚合 reasoning 帧携带全文:直接覆盖(若已有 delta 累积则等价) + last.reasoning_content = p.content; + rerenderChat(); + } + } catch (ex) {} + }); + // token 级流式增量:逐块追加到当前思考内容;reset 帧表示轮次作废 + es.addEventListener("reasoning_delta", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + if (p.channel === "_consolidation_") return; + if (p.reset) return; // 轮次作废(用户中断):清空累积中的思考 + if (!p.content) return; + state.chatStage = __("AI 思考中...", "AI thinking..."); + var last = + state.messages.length > 0 + ? state.messages[state.messages.length - 1] + : null; + if (!last || last.role !== "assistant" || last._final) { + state.messages.push({ + role: "assistant", + content: "", + reasoning_content: "", + tool_calls: [], + _streaming: true, + }); + last = state.messages[state.messages.length - 1]; + } + last.reasoning_content = (last.reasoning_content || "") + p.content; + rerenderChat(); + } catch (ex) {} + }); + es.addEventListener("tool_call", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + console.log("[SSE] tool_call", p); + if (!p.tool) return; + var last = + state.messages.length > 0 + ? state.messages[state.messages.length - 1] + : null; + if (!last || last.role !== "assistant" || last._final) { + state.messages.push({ + role: "assistant", + content: "", + tool_calls: [], + _streaming: true, + }); + last = state.messages[state.messages.length - 1]; + } + if (!last.tool_calls) last.tool_calls = []; + last.tool_calls.push({ + tool: p.tool, + name: p.tool, + args: p.args || {}, + result: p.result || "", + status: p.status || "ok", + plugin: p.plugin || "", + }); + var pidx = (state.pendingTools || []).indexOf(p.tool); + if (pidx !== -1) state.pendingTools.splice(pidx, 1); + state.chatStage = __("工具调用: ", "Tool: ") + (p.tool || ""); + // 星图跟随:工具调用是最可靠的活动信号。工具名(如 + // knowledge_list / qq_send)会拿去匹配相关实体节点并点亮。 + starmapPulse("tool", p.tool); + rerenderChat(); + } catch (ex) { + console.error("[SSE] tool_call error", ex); + } + }); + // 注:后端不发布 tool_result 类型事件(工具结果随 EventToolCall 一次发出),无此监听器。 + es.addEventListener("stage", (e) => { + try { + var ev = JSON.parse(e.data); + var p = ev.payload || {}; + var phase = p.phase || ""; + var tool = p.tool || ""; + if (p.channel === "_consolidation_") return; + console.log("[SSE] stage event", phase, tool); + // 驱动总览页的"阶段管道"滑块:滑到当前阶段;一轮跑完(或 2.5s 无新 + // 事件)自动回到空闲,避免留下一个永远停在 after_output 的假状态。 + // 阶段轨迹:本轮真实发生过什么,交给阶段管道画成 chip 序列。 + // 工具调用会反复出现(多轮 toolcall),output_* 单独配色。 + if (phase === "on_input") { + state.stageTrail = []; + rtTrailPush(0, "stage", __("输入", "input"), __("输入", "input")); + } else if (phase === "pre_action") { + rtTrailPush( + 1, + "stage", + __("组装上下文并思考", "assemble context and think"), + __("思考", "think"), + ); + } else if (phase === "before_toolcall" && tool) { + var isOut = tool.indexOf("output_") === 0; + rtTrailPush(2, isOut ? "output" : "tool", tool, rtShortTool(tool)); + state.toolFlash = true; + } else if (phase === "before_output") { + rtTrailPush( + 3, + "stage", + __("生成回复", "generate reply"), + __("生成", "gen"), + ); + } else if (phase === "after_output") { + rtTrailPush( + 4, + "stage", + __("本轮完成", "turn complete"), + __("完成", "done"), + ); + } + // 星图跟随:阶段推进也作为活动信号(工具名优先匹配节点)。 + starmapPulse("stage", tool || phase); + state.pipelinePhase = phase; + if (state.pipelineTimer) clearTimeout(state.pipelineTimer); + state.pipelineTimer = setTimeout(() => { + state.pipelinePhase = ""; + if (document.getElementById("rt-sec-pipe")) renderRuntime(); + }, 2500); + if (document.getElementById("rt-sec-pipe")) renderRuntime(); + // 渲染后再放动画:morph 已经把 chip 文本更新成最新那条。 + if (state.toolFlash) { + state.toolFlash = false; + rtFlashLatestTool(); + } + if (phase === "pre_action") { + state.chatStage = __("AI 思考中...", "AI thinking..."); + } else if (phase === "before_toolcall") { + state.chatStage = __("工具调用: ", "Tool: ") + (tool || ""); + if (tool && (state.pendingTools || []).indexOf(tool) === -1) { + if (!state.pendingTools) state.pendingTools = []; + state.pendingTools.push(tool); + rerenderChat(); + } + } else if (phase === "before_output") { + state.chatStage = __("生成回复中...", "Generating response..."); + } + var badge = document.getElementById("chat-stage"); + if (badge) { + badge.textContent = state.chatStage || ""; + badge.style.display = "none"; + } + } catch (ex) { + console.error("[SSE] stage error", ex); + } + }); + // channel_input:内核在输入进来时另发的一条轻量事件(只带通道名与 agent + // id,不带正文)。总览页拓扑靠它画"光点进入 agent"。 + es.addEventListener("channel_input", (e) => { + try { + var d = JSON.parse(e.data); + var src = d.source || ""; + if (src) rtSparkInput(src); + } catch (ex) { + console.error("[SSE] channel_input error", ex); + } + }); + es.onopen = () => { + console.log("[SSE] connection opened"); + }; + es.onerror = (e) => { + console.error("[SSE] error", e); + // 1) 立即 close 阻止浏览器原生自动重连与手动 setTimeout(connectSSE) 双连接竞态 + try { + state.eventSource && state.eventSource.close(); + state.eventSource = null; + } catch (ex) {} + // 2) 连接错误期间可能丢失事件,增量补拉历史(无闪烁) + syncChatFromHistory().catch(() => {}); + // 3) 2s 后手动重连(比原 5s 更快恢复) + setTimeout(connectSSE, 2000); + }; + // sync_required:Server 因 Last-Event-ID 不在 ring(delta ID / 已到 tip)无法重放, + // 通知前端增量补拉历史——避免前端空等后续聚合事件导致「消息同步不及时」。 + es.addEventListener("sync_required", (e) => { + console.log("[SSE] sync_required received, incremental sync"); + syncChatFromHistory().catch(() => {}); + }); + // Periodically refresh sidebar data + if (state._sidebarRefresh) clearInterval(state._sidebarRefresh); + state._sidebarRefresh = setInterval(async () => { + try { + var td = await api("/terminals"); + if (td && td.terminals) state.terminals = td.terminals; + } catch (e) {} + try { + var ch = await api("/cmd/history"); + if (ch && ch.history) state.cmdHistory = ch.history; + } catch (e) {} + renderTerminals(); + renderCmdHistory(); + }, 5000); +} + +// ===== Plugins ===== +// 「服务入口」:被反代的插件服务,点一下直接打开。 +// +// 为什么单独一张卡而不是塞进每个插件的详情:入口是**跨插件**的(同一个 +// webui 端口、不同子域),用户的心智是「我要打开某个服务」,不是 +// 「我要进某个插件的管理页」。 +// +// 链接带 ?__token=:子域与门户不同源,浏览器不会带上会话 cookie; +// 不带 token 会 401(这是刻意的受保护默认)。页面已登录,此处复用同一把 key。 +function renderProxyServicesCard() { + var svcs = state.proxyServices || []; + var base = state.proxyBaseDomain || "localhost"; + var html = + '

' + __("服务入口", "Service Entry Points") + "

"; + if (!svcs.length) { + return ( + html + + '

' + + __( + "暂无被反代的插件服务。插件在 plugin.json 的 proxies 字段里声明后会自动出现在这里。", + "No proxied plugin services yet. Declare them in plugin.json's proxies field.", + ) + + "

" + ); + } + var token = state.settings?.["plugin.webui.api_key"] || ""; + html += + '
' + + __( + "插件服务经 HomeAgent 同一端口反代。子域形态需要 DNS 能解析 ", + "Proxied through the same port. The subdomain form needs DNS for ", + ) + + "" + + escHtml("*." + base) + + "" + + __( + ";路径形态无 DNS 依赖,穿透场景下更可靠(点「打开」优先用它)。", + "; the path form has no DNS dependency and is more reliable behind a tunnel (Open prefers it).", + ) + + "
"; + html += '
'; + svcs.forEach((s) => { + var url = s.url || ""; + if (url && token) url += "?__token=" + encodeURIComponent(token); + var dot = s.ok ? "var(--ok, #22c55e)" : "var(--danger, #d1383d)"; + html += + '
' + + '' + + '' + + escHtml(s.plugin_name || s.plugin) + + "" + + '' + + escHtml(s.name) + + "" + + (s.websocket ? 'WS' : "") + + (s.auth === "none" + ? '' + + __("未保护", "unprotected") + + "" + : "") + + '' + + escHtml(s.host + "." + base) + + ""; + // 「打开」优先用**路径形态**(url_portal): + // - 它没有 DNS 依赖,单端口穿透 / 子域无证书时都能用; + // - 子域形态要求 DNS 能解析 .<基域名>,而这些域名 + // 在外部常常不可达(实测:外层只放行一个 Host,三级子域 + // 因通配证书不匹配而握手失败)。 + // 保留子域链接作为次选(局域网内直连时它更直观)。 + var primary = s.url_portal || url; + if (s.ok && primary) { + html += + '' + + __("打开", "Open") + + ""; + if (url && s.url_portal && url !== s.url_portal) { + html += + '' + + __("子域", "subdomain") + + ""; + } + } else { + html += + '' + + escHtml(s.error || __("不可用", "unavailable")) + + ""; + } + html += "
"; + }); + html += "
"; + return html; +} + +async function loadProxyServices() { + try { + var d = await api("/proxy/services"); + state.proxyServices = (d && d.services) || []; + state.proxyBaseDomain = (d && d.base_domain) || "localhost"; + } catch (e) { + state.proxyServices = []; + } +} + +function renderPlugins() { + var k = state.kernel; + var plugins = k?.plugins || []; + var tools = k?.tools || []; + var installed = state.installedPlugins || []; + var html = renderProxyServicesCard(); + html += + '

' + + __("安装插件", "Install Plugin") + + "

" + + '
' + + '' + + '
" + + '
' + + '
"; + var disabledNames = {}; + (state.disabledPlugins || []).forEach((d) => { + disabledNames[d.name] = d; + }); + var installedNames = (state.installedPlugins || []).map((p) => p.name); + var allPluginNames = {}; + plugins.forEach((p) => { + allPluginNames[p.name] = true; + }); + state.disabledPlugins.forEach((d) => { + allPluginNames[d.name] = true; + }); + html += + '

' + + __("所有插件", "All Plugins") + + " (" + + Object.keys(allPluginNames).length + + ")

"; + var names = Object.keys(allPluginNames).sort(); + if (names.length === 0) { + html += + '

' + + __("暂无插件", "No plugins") + + "

"; + } else { + html += + ""; + names.forEach((name) => { + var isExternal = installedNames.indexOf(name) >= 0; + var isDisabled = disabledNames[name]; + var loaded = plugins.some((p) => p.name === name); + var statusHtml = + loaded && !isDisabled + ? '' + + __("已加载", "Loaded") + + "" + : loaded && isDisabled + ? '' + + __("运行中(禁用待生效)", "Running (disable pending)") + + "" + : isDisabled + ? '' + + __("已禁用", "Disabled") + + "" + : '' + + __("未加载", "Not Loaded") + + ""; + var actionsHtml = ""; + if (loaded) { + actionsHtml += + '"; + } else { + actionsHtml += + '"; + } + if (isExternal) { + actionsHtml += + '"; + } + html += + "" + + "" + + ""; + }); + html += "
" + + __("名称", "Name") + + "" + + __("状态", "Status") + + "" + + __("操作", "Actions") + + "
" + + escHtml(name) + + "" + + statusHtml + + "" + + actionsHtml + + "
"; + } + html += "
"; + if (installed.length > 0) { + html += + '

' + + __("已安装外部插件", "Installed Plugins") + + " (" + + installed.length + + ")

" + + ""; + installed.forEach((p) => { + html += + "" + + "" + + "" + + '"; + }); + html += "
" + + __("名称", "Name") + + "" + + __("版本", "Version") + + "" + + __("描述", "Description") + + "" + + __("操作", "Actions") + + "
" + + escHtml(p.name) + + "" + + escHtml(p.version || "-") + + "" + + escHtml((p.description || "").substring(0, 50)) + + " " + + '
"; + } + if (state.pluginInfo) { + html += + '

' + + __("插件详情", "Plugin Details") + + ": " + + escHtml(state.pluginInfo.name) + + "

" + + "
" +
+			escHtml(JSON.stringify(state.pluginInfo, null, 2)) +
+			"
" + + '
"; + } + if (tools.length > 0) { + html += + '

' + + __("已注册工具", "Registered Tools") + + " (" + + tools.length + + ")

" + + '
'; + tools.forEach((t) => { + html += + '' + + escHtml(t.name) + + ""; + }); + html += "
"; + } + html += + '

' + + __("系统操作", "System Operations") + + "

" + + '
"; + html += + '

' + + __("健康检查", "Health Check") + + '

'; + if (state.healthResult) { + html += renderHealthResult(state.healthResult); + } else { + html += + '

' + + __("尚未运行,点击右上角「运行」开始", "Not run yet, click Run to start") + + "

"; + } + html += "
"; + document.getElementById("tab-plugins").innerHTML = html; +} + +async function loadInstalledPlugins() { + try { + state.installedPlugins = await api("/plugins"); + } catch (e) { + state.installedPlugins = []; + } +} + +async function installPlugin() { + var inp = document.getElementById("plugin-url"); + var url = inp?.value.trim(); + if (!url) { + toast(__("请输入插件包 URL", "Please enter plugin URL"), true); + return; + } + try { + var raw = await api("/plugins", { + method: "POST", + body: JSON.stringify({ url: url }), + raw: true, + }); + var ct = raw.headers.get("content-type") || ""; + var r = ct.includes("json") ? await raw.json() : await raw.text(); + if (!raw.ok || (r && r.error)) { + toast( + __("安装失败: ", "Install failed: ") + + ((r && (r.error || r.details)) || "HTTP " + raw.status), + true, + ); + return; + } + toast( + __("安装结果: ", "Install result: ") + (r.status || JSON.stringify(r)), + ); + if (r.action === "reload_required") + toast( + __( + "已安装,请点击「重载插件」加载", + 'Installed, click "Reload Plugins" to load', + ), + false, + ); + await loadInstalledPlugins(); + renderPlugins(); + } catch (e) { + toast(__("安装失败: ", "Install failed: ") + e.message, true); + } +} + +async function installPluginFile(file) { + if (!file) return; + try { + var r = await fetch("/api/v1/plugins", { + method: "POST", + body: file, + headers: { "Content-Type": "application/octet-stream" }, + }); + var data = await r.json(); + toast( + __("上传安装: ", "Upload install: ") + + (data.status || JSON.stringify(data)), + ); + if (data.action === "reload_required") + toast( + __( + "已安装,请点击「重载插件」加载", + 'Installed, click "Reload Plugins" to load', + ), + false, + ); + loadInstalledPlugins(); + renderPlugins(); + } catch (e) { + toast(__("上传失败: ", "Upload failed: ") + e.message, true); + } +} + +async function showPluginInfo(name) { + try { + state.pluginInfo = await api("/plugins/" + encodeURIComponent(name)); + renderPlugins(); + } catch (e) { + toast(__("获取详情失败: ", "Get details failed: ") + e.message, true); + } +} + +function closePluginInfo() { + state.pluginInfo = null; + renderPlugins(); +} + +var removingPlugins = {}; + +async function removePlugin(name) { + if (removingPlugins[name]) return; // 防重复点击 + if ( + !confirm( + __("确定卸载插件", "Are you sure to unload plugin") + + "「" + + name + + "」?", + ) + ) + return; + removingPlugins[name] = true; + try { + var raw = await api("/plugins/" + encodeURIComponent(name), { + method: "DELETE", + raw: true, + }); + var ct = raw.headers.get("content-type") || ""; + var body = ct.includes("json") ? await raw.json() : await raw.text(); + if (!raw.ok) { + var em = (body && (body.error || body.details)) || "HTTP " + raw.status; + toast(__("卸载失败: ", "Unload failed: ") + em, true); + // 内置插件或路径错误时刷新一次列表保持状态一致 + loadInstalledPlugins(); + renderPlugins(); + return; + } + toast(__("已卸载: ", "Unloaded: ") + (body.name || body.status || name)); + await loadInstalledPlugins(); + // 同步内核插件/禁用列表,确保列表与工具立即消失 + try { + state.kernel = await api("/kernel"); + var s = await api("/settings"); + state.disabledPlugins = s.disabled_plugins || []; + } catch (e2) {} + renderPlugins(); + } catch (e) { + toast(__("卸载失败: ", "Unload failed: ") + e.message, true); + } finally { + delete removingPlugins[name]; + } +} + +async function disablePlugin(name) { + if (name === "webui") { + var r = confirm( + __( + "禁用 WebUI 后将无法通过 URL:端口访问此管理面板,若要重新启用需要通过 CLI 命令 /plugin enable webui 恢复。\n\n确定要禁用吗?", + "Disabling WebUI will make this management panel inaccessible via URL:port. To re-enable, use CLI command /plugin enable webui.\n\nAre you sure?", + ), + ); + if (!r) return; + } + try { + var r = await api("/plugins/" + encodeURIComponent(name) + "/disable", { + method: "POST", + }); + toast(__("已禁用: ", "Disabled: ") + name); + state.kernel = await api("/kernel"); + var s = await api("/settings"); + state.disabledPlugins = s.disabled_plugins || []; + renderPlugins(); + } catch (e) { + toast(__("禁用失败: ", "Disable failed: ") + e.message, true); + } +} + +async function enablePlugin(name) { + try { + var r = await api("/plugins/" + encodeURIComponent(name) + "/enable", { + method: "POST", + }); + toast(__("已启用: ", "Enabled: ") + name); + state.kernel = await api("/kernel"); + var s = await api("/settings"); + state.disabledPlugins = s.disabled_plugins || []; + renderPlugins(); + } catch (e) { + toast(__("启用失败: ", "Enable failed: ") + e.message, true); + } +} + +async function reloadPlugins() { + try { + var r = await api("/plugins/reload", { method: "POST" }); + toast(__("插件已重载", "Plugins reloaded")); + state.kernel = await api("/kernel"); + renderPlugins(); + } catch (e) { + toast(__("重载失败: ", "Reload failed: ") + e.message, true); + } +} + +async function runHealthcheck() { + var panel = document.getElementById("health-panel"); + if (!panel) return; + panel.innerHTML = + '

' + + __("运行中...", "Running...") + + "

"; + try { + var r = await api("/kernel"); + var tools = r?.tools || []; + var healthTool = tools.find((t) => t.name === "healthcheck"); + if (!healthTool) { + panel.innerHTML = + '

' + + __("healthcheck 工具未注册", "healthcheck tool not registered") + + "

"; + return; + } + panel.innerHTML = + '

' + + __( + "通过 Agent 对话触发 healthcheck...", + "Triggering healthcheck via Agent...", + ) + + "

"; + var chatR = await api("/chat", { + method: "POST", + body: JSON.stringify({ + message: __( + "请运行 healthcheck 工具进行全面健康检查并报告结果", + "Please run the healthcheck tool for a full system check and report the results", + ), + }), + }); + panel.innerHTML = + "
" + escHtml(JSON.stringify(chatR, null, 2)) + "
"; + } catch (e) { + panel.innerHTML = + '

' + + __("错误: ", "Error: ") + + escHtml(e.message) + + "

"; + toast(__("健康检查失败: ", "Health check failed: ") + e.message, true); + } +} + +function renderHealthResult(r) { + if (!r || !r.checks) + return ( + '

' + + __("暂无健康检查数据", "No health check data") + + "

" + ); + var checks = r.checks || []; + var passed = checks.filter((c) => c.pass).length; + var failed = checks.filter((c) => !c.pass).length; + var html = + '
' + + '' + + __("通过: ", "Pass: ") + + passed + + "" + + '' + + __("失败: ", "Fail: ") + + failed + + "" + + '' + + __("总计: ", "Total: ") + + checks.length + + "
"; + checks.forEach((c) => { + var passClass = c.pass ? "check-pass" : "check-fail"; + if (c.status === "skip") passClass = "check-skip"; + html += + '
' + + '' + + escHtml(c.name) + + "" + + '' + + (c.status || "unknown") + + "" + + '' + + escHtml(c.detail || "") + + "
"; + }); + return html; +} + +// ===== Kernel ===== +function renderKernel() { + var k = state.kernel; + if (!k) { + document.getElementById("tab-kernel").innerHTML = + '

' + + __("内核未响应", "Kernel not responding") + + "

"; + return; + } + var b = k.build || {}; + var html = + '

' + + __("构建", "Build") + + "

" + + '
' + + __("内核", "Kernel") + + '' + + escHtml(b.kernel_name || "HomeAgent") + + "
" + + '
' + + __("内核版本", "Kernel version") + + '' + + escHtml(b.version ? "v" + b.version : "-") + + "
" + + '
Commit' + + escHtml(b.commit || "-") + + "
" + + '
' + + __("构建时间", "Build time") + + '' + + escHtml(b.build_time || "-") + + "
" + + '
' + + __("SDK 兼容", "SDK compat") + + '' + + escHtml(b.sdk_compatible || "-") + + "
" + + (b.source_url + ? '
' + + __("源码", "Source") + + '' + + escHtml(b.source_url) + + "
" + : "") + + "
"; + html += + '

' + + __("运行时", "Runtime") + + "

" + + '
Goroutines' + + (k?.runtime?.goroutines || "-") + + "
" + + '
' + + __("内存", "Memory") + + '' + + (k?.runtime?.memory_mb ? k.runtime.memory_mb + " MB" : "-") + + "
" + + '
Go ' + + __("版本", "Version") + + '' + + (k?.runtime?.go_version || "-") + + "
"; + html += + '

LLM

' + + '
Provider' + + (k.llm?.provider || __("未配置", "Not configured")) + + "
" + + '
' + + __("可用源", "Sources") + + '' + + (k.llm?.sources || 0) + + "
" + + '
' + + __("状态", "Status") + + '' + + (k.llm?.available ? __("运行中", "Running") : __("不可用", "Unavailable")) + + "
"; + html += + '

' + + __("记忆", "Memory") + + "

" + + '
' + + __("图记忆", "Graph Memory") + + '' + + (k.memory?.available + ? k.memory.entity_count + + __(" 实体, ", " entities, ") + + k.memory.relation_count + + __(" 关系", " relations") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文档记忆", "Document Memory") + + '' + + (k.documents?.available + ? k.documents.doc_count + __(" 文档", " docs") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("文本记忆", "Text Memory") + + '' + + (k.text_memory?.available + ? k.text_memory.file_count + __(" 文件", " files") + : __("未初始化", "Uninitialized")) + + "
" + + '
' + + __("知识库", "Knowledge") + + '' + + (k.knowledge?.available + ? k.knowledge.item_count + __(" 项", " items") + : __("未初始化", "Uninitialized")) + + "
"; + html += + '

' + + __("插件", "Plugins") + + " (" + + (k.plugins?.length || 0) + + ")

"; + if (k.plugins?.length) { + html += '
'; + k.plugins.forEach((p) => { + html += '' + escHtml(p.name) + ""; + }); + html += "
"; + } else { + html += '

' + __("无", "None") + "

"; + } + html += "
"; + document.getElementById("tab-kernel").innerHTML = html; +} + +// ===== Star Map ===== +var starmapScene = null, + starmapCam = null, + starmapRen = null, + starmapCtrl = null; +var starmapNodes = [], + starmapEdges = []; +var starmapNodeMeshes = [], + starmapEdgeLines = [], + starmapStarField = null; +var starmapHovered = null, + starmapSelected = null, + starmapAutoView = true; +var starmapRaf = null; +// 共享几何:1151 节点各自 new SphereGeometry 会产生 1151 个 +// BufferGeometry(另加同样数量的光晕球)。几何形状对所有节点相同, +// 差别只在外层 mesh.scale,故共享一份即可。半径固定 0.5, +// 真实半径由 scale 给出(见 buildChatStarmapGraph)。 +var starmapGeo = null, + starmapGlowGeo = null; +// 标签角标:hover / 选中时显示的 HTML 元素(零显存,文字清晰)。 +var starmapLabelEl = null; +// smTypeColors 的键必须与**服务端实际产出的 type 字符串小写后**一致。 +// 服务端默认类型是 "Concept"(首字母大写,见 internal/memory/graph.go), +// 原键全为小写 ⇒ 永远匹配不上 ⇒ 1150 个节点全渲染成同一个灰色 0xcccccc, +// 分类配色实际上从未生效过。 +var smTypeColors = { + person: 0x4488ff, + task: 0xff8844, + ai: 0xaa44ff, + concept: 0x44ff88, + object: 0xff4444, + // 服务端还会产出这些(indexer_test 里可见 "Person"/"Location") + location: 0xffaa44, + source: 0x8899ff, + document: 0xaabbcc, + event: 0xff88cc, + entity: 0x44ddcc, + scene: 0x88ff44, +}; +var smEdgeColors = { + 喜欢: 0xff6b6b, + 学习: 0x4ecdc4, + 属于: 0x45b7d1, + 相关: 0x96ceb4, + 使用: 0xfeca57, + 创建: 0xff9ff3, +}; + +// ===== 星图活动:让图跟上 agent 的动作 ===== +// +// 改之前:starmapAnimate() 只转星空,节点完全静止。现在三路信号: +// +// 1. starmapPulse(kind) —— 实时。SSE 的 tool_call / stage / +// agent_output 触发。命中的节点(按工具名匹配已知实体,否则随机 +// 取一批)做一次扩散涟漪 + 发光冲高。 +// 2. starmapPullActivity() —— /runtime 调度器快照,3s。 +// ready_queue_depth > 0 ⇒ 图整体「绷紧」(轻微缩放脉冲); +// interrupts/preempts 上升 ⇒ 高优先级别的红色电弧感闪烁。 +// 3. starmapPullPulse() —— /memory/graph/pulse,10s。 +// 最近变动的实体(新增概念 / mention_count 变化)做一次 +// 「生长」:从 0 缩放到正常大小,并留下余晖。 +// +// 全部在渲染循环里推进,不额外起定时器。 +var SM_PULSE_MS = 1400; // 单个脉冲的生命期 +var SM_RIPPLE_R = 26; // 涟漪最大半径 + +// starmapPulse 发出一次活动脉冲。 +// kind: "tool" | "stage" | "output" | "grow" +function starmapPulse(kind, hint) { + if (!starmapScene || !starmapNodeMeshes.length) return; + var now = Date.now(); + state.starmapLastPulseAt = now; + // 有 hint(工具名/阶段名)时优先点亮名字与提示相关的节点, + // 这是「图在跟 agent 动」最直接的体现:调了 knowledge_* 就亮知识节点。 + var targets = starmapPickPulseTargets(hint); + if (!targets.length) return; + var life = kind === "grow" ? SM_PULSE_MS * 1.6 : SM_PULSE_MS; + targets.forEach((m) => { + state.starmapPulses.push({ mesh: m, until: now + life, kind: kind }); + }); + // 队列上限:防止密集工具调用时脉冲无限堆积占内存。 + if (state.starmapPulses.length > 260) + state.starmapPulses = state.starmapPulses.slice(-260); +} + +// starmapPickPulseTargets 选出该被点亮的节点。 +// 优先名字/类型命中 hint 的;不足时按 mention_count 补齐(高权重节点 +// 本身就是最常被 agent 触碰的,用它们代表「整体活动」合理)。 +// +// ★ 匹配必须用「词」而不是子串包含: +// hint="knowledge" 与实体名 "k" / "e" 互为子串,会让半个图谱 +// (含 "时"、"会" 这类单字实体)全部命中 ⇒ 脉冲退化成「全图齐亮」, +// 既看不出关联,又把队列瞬间打满。浏览器实测:旧写法一次 pulse +// 就选中 250 个节点、队列顶到 260 上限。 +function starmapPickPulseTargets(hint) { + var out = []; + if (!starmapNodeMeshes.length) return out; + if (hint) { + var keys = starmapHintTokens(hint); + if (keys.length) { + for (var i = 0; i < starmapNodeMeshes.length && out.length < 26; i++) { + var nd = starmapNodeMeshes[i].userData.nodeData || {}; + var nm = String(nd.name || "").toLowerCase(); + var ty = String(nd.type || "").toLowerCase(); + if (!nm) continue; + for (var k = 0; k < keys.length; k++) { + var key = keys[k]; + // 词边界命中:实体名恰好等于该词,或以该词为词首 + // (如 "knowledge_base" 命中词 "knowledge")。 + if (nm === key || nm.indexOf(key + "_") === 0 || ty === key) { + out.push(starmapNodeMeshes[i]); + break; + } + } + } + } + } + // ★ 只有「一个都没匹配上」时才用全局权重节点代表「整体活动」。 + // 实测(浏览器里跑真数据):词匹配对 knowledge_list 只命中 1 个, + // 但旧的补齐逻辑会把它补到 20 个 —— 于是脉冲看起来仍然是「一大片 + // 无关节点在亮」,与要修的子串 bug 效果一样,只是换了个成因。 + // 补齐只保留在真正无匹配的场景(hint 为空,如调度器脉冲)。 + if (out.length > 0) return out; + // 补齐:按 mention_count 降序取靠前且尚未入列的。 + var sorted = starmapNodeMeshes + .slice() + .sort( + (a, b) => + (b.userData.nodeData || {}).mention_count - + (a.userData.nodeData || {}).mention_count, + ); + for (var j = 0; j < sorted.length && out.length < 8; j++) { + if (out.indexOf(sorted[j]) === -1) out.push(sorted[j]); + } + return out; +} + +// starmapHintTokens 把提示词(工具名/阶段名)拆成可匹配的词元。 +// 例:"knowledge_list" → ["knowledge","list"]。 +// 只保留长度 >= 3 的词:单/双字母词("a"/"ls")几乎必然撞上无关 +// 实体名,匹配它们只会制造噪声。 +function starmapHintTokens(hint) { + var raw = String(hint) + .toLowerCase() + .split(/[^a-z0-9\u4e00-\u9fa5]+/); + var out = []; + for (var i = 0; i < raw.length; i++) { + var t = raw[i]; + if (t.length >= 3 && out.indexOf(t) === -1) out.push(t); + } + return out; +} + +// starmapPullActivity 拉 /runtime,把调度器状态映射成图的整体节奏。 +function starmapPullActivity() { + if (!starmapScene) return; + api("/runtime") + .then((rt) => { + if (!rt || !rt.scheduler) return; + var s = rt.scheduler; + var prev = state.starmapActivity; + state.starmapActivity = s; + var pend = + (s.ready_queue_depth || 0) + + (s.pending_interrupts || 0) + + (s.suspend_stack || 0); + // 有任务在排队/中断 ⇒ 脉冲,让图「绷紧」。 + if (pend > 0) starmapPulse("stage", null); + // 中断或抢占计数上升 ⇒ 一次强脉冲(高优先级插入)。 + if (prev) { + var dInt = + (s.interrupts_by_level || []).reduce((a, b) => a + b, 0) - + (prev.interrupts_by_level || []).reduce((a, b) => a + b, 0); + var dPre = + (s.preempts_by_level || []).reduce((a, b) => a + b, 0) - + (prev.preempts_by_level || []).reduce((a, b) => a + b, 0); + if (dInt > 0 || dPre > 0) starmapPulse("output", null); + } + }) + .catch(() => {}); +} + +// starmapPullPulse 拉轻量活动端点,把新长出来的节点标记为「生长」。 +function starmapPullPulse() { + if (!starmapScene) return; + var since = state.starmapPulseSince || 0; + api("/memory/graph/pulse?since=" + since) + .then((r) => { + if (!r || !r.success || !r.data) return; + var now = Math.floor(Date.now() / 1000); + state.starmapPulseSince = now - 30; // 30s 重叠,防跨轮漏节点 + var nodes = r.data.nodes || []; + if (!nodes.length) return; + var known = 0; + nodes.forEach((n) => { + var m = starmapNodeMeshes.find((x) => x.userData.nodeId === n.id); + if (!m) return; // 全量图里没有(可能刚创建)⇒ 忽略,等下次全量 + known++; + state.starmapGrown[m.userData.nodeId] = Date.now() + SM_PULSE_MS * 2; + starmapPulse("grow", n.name); + }); + // 若有新实体但一个都没匹配上,说明全量图过期了, + // 下一拍重拉全量(新节点才能出现)。 + if (known === 0 && nodes.length > 2) starmapDirty = true; + }) + .catch(() => {}); +} + +// starmapTickActivity 在渲染循环里推进所有脉冲与余晖。 +function starmapTickActivity(t) { + var now = Date.now(); + var breathe = 0; + // 1) 活动脉冲:发光冲高 + 尺寸微扩 + 涟漪环 + if (state.starmapPulses.length) { + var keep = []; + for (var i = 0; i < state.starmapPulses.length; i++) { + var p = state.starmapPulses[i]; + if (p.until <= now) { + p.mesh.material.emissiveIntensity = p.mesh.userData.baseEmissive; + p.mesh.scale.setScalar(p.mesh.userData.baseScale); + continue; + } + keep.push(p); + var left = (p.until - now) / SM_PULSE_MS; // 1→0 + var k = 1 - left; // 0→1 + var wave = Math.sin(Math.min(1, k) * Math.PI); + var amp = p.kind === "output" ? 1.8 : 1.2; + p.mesh.material.emissiveIntensity = + p.mesh.userData.baseEmissive + wave * amp; + p.mesh.scale.setScalar(p.mesh.userData.baseScale * (1 + wave * 0.28)); + // 生长:新节点从 0 弹到正常大小 + if (p.kind === "grow") { + var g = Math.min(1, k * 1.4); + p.mesh.scale.setScalar(p.mesh.userData.baseScale * (0.15 + 0.85 * g)); + } + } + state.starmapPulses = keep; + } + // 2) 「生长」余晖:脉冲结束后短暂保留一点亮 + if (state.starmapGrown) { + for (var gid in state.starmapGrown) { + if (state.starmapGrown[gid] <= now) { + delete state.starmapGrown[gid]; + continue; + } + var gm = starmapNodeMeshes.find((x) => x.userData.nodeId == gid); + if (gm) + gm.material.emissiveIntensity = Math.max( + gm.material.emissiveIntensity, + gm.userData.baseEmissive + 0.6, + ); + } + } + // 3) 全局呼吸:距上次活动越近越亮,实现「agent 一忙图就活」 + var idle = (now - (state.starmapLastPulseAt || 0)) / 4000; + breathe = Math.max(0, 1 - idle); + if (breathe > 0.01 && starmapNodeMeshes.length) { + // 只抽样一部分节点做呼吸,避免每帧改 1151 个材质。 + var stride = 24; + for (var b = 0; b < starmapNodeMeshes.length; b += stride) { + var m2 = starmapNodeMeshes[b]; + if (m2.userData.baseEmissive === undefined) continue; + m2.material.emissiveIntensity = Math.max( + m2.userData.baseEmissive, + m2.userData.baseEmissive + breathe * 0.25, + ); + } + } + return breathe; +} + +function createStarField() { + var c = 3000; + var p = new Float32Array(c * 3), + cl = new Float32Array(c * 3), + s = new Float32Array(c); + for (var i = 0; i < c; i++) { + var i3 = i * 3; + var r = 400 + Math.random() * 600, + th = Math.random() * Math.PI * 2, + ph = Math.acos(2 * Math.random() - 1); + p[i3] = r * Math.sin(ph) * Math.cos(th); + p[i3 + 1] = r * Math.sin(ph) * Math.sin(th); + p[i3 + 2] = r * Math.cos(ph); + if (Math.random() < 0.7) { + cl[i3] = 0.8 + Math.random() * 0.2; + cl[i3 + 1] = 0.8 + Math.random() * 0.2; + cl[i3 + 2] = 1; + } else { + cl[i3] = 1; + cl[i3 + 1] = 0.9 + Math.random() * 0.1; + cl[i3 + 2] = 0.8 + Math.random() * 0.2; + } + s[i] = 0.5 + Math.random() * 2; + } + var g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(p, 3)); + g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); + g.setAttribute("size", new THREE.BufferAttribute(s, 1)); + var m = new THREE.PointsMaterial({ + size: 1.5, + vertexColors: true, + transparent: true, + opacity: 0.8, + sizeAttenuation: true, + }); + starmapStarField = new THREE.Points(g, m); + starmapScene.add(starmapStarField); +} + +function onStarmapMove(e) { + if (!starmapRen || !starmapCam) return; + var rect = starmapRen.domElement.getBoundingClientRect(); + var mouse = new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1, + ); + var rc = new THREE.Raycaster(); + rc.setFromCamera(mouse, starmapCam); + var hits = rc.intersectObjects(starmapNodeMeshes); + if (hits.length > 0) { + var n = hits[0].object; + if (starmapHovered !== n) { + // 复位用 baseScale,不能用 set(1,1,1):节点现在是按 mention_count + // 缩放过的(userData.baseScale),置 1 会把大节点缩成最小尺寸。 + // (这是低规格改造后必须跟着改的一处,旧代码能“跑”是因为那时 + // 几何体本身就带半径、不靠 scale。) + if (starmapHovered) + starmapHovered.scale.setScalar(starmapHovered.userData.baseScale || 1); + starmapHovered = n; + starmapLabelShow(n); + } + } else { + if (starmapHovered) { + starmapHovered.scale.setScalar(starmapHovered.userData.baseScale || 1); + starmapHovered = null; + starmapLabelHide(); + } + } +} + +// starmapLabelShow/Hide:hover 时在容器角上显示一个 HTML 角标。 +// 取代原先每节点一张 256x64 CanvasTexture(1151 张贴图 ≈ 72MB 显存), +// 文字用 DOM 渲染反而更清楚,且零 GPU 开销。 +function starmapLabelShow(mesh) { + var cont = starmapActiveContainer(); + if (!cont) return; + if (!starmapLabelEl) { + starmapLabelEl = document.createElement("div"); + starmapLabelEl.id = "sm-label"; + } + // 角标必须跟 canvas 在同一个容器里(它是绝对定位在容器上的)。 + // canvas 换页签搬运时,角标也要跟着搬。 + if (starmapLabelEl.parentElement !== cont) cont.appendChild(starmapLabelEl); + var nd = (mesh && mesh.userData && mesh.userData.nodeData) || {}; + var nm = nd.name || nd.id || ""; + starmapLabelEl.textContent = + nm + (nd.mention_count ? " ×" + nd.mention_count : ""); + starmapLabelEl.style.display = "block"; +} +function starmapLabelHide() { + if (starmapLabelEl) starmapLabelEl.style.display = "none"; +} +// starmapContainer 返回当前星图所在的容器(主页或聊天面板任一)。 +function starmapContainer() { + return ( + document.getElementById("sm-container-home") || + document.getElementById("sm-container-chat") + ); +} + +// starmapActiveContainer 选出「该把星图画在哪」的容器。 +// +// 规则:先看独立星图页签,不存在(该页签还没打开过)就退回总览页, +// 再退回聊天面板。为什么要这个优先级:three.js 的 canvas 只能有一个 +// 父节点,同时往两处渲染就会一边黑屏。每次切页签都把 canvas 搬到 +// 当前该显示的地方,是单一 renderer 前提下最干净的做法。 +function starmapActiveContainer() { + var tab = document.getElementById("tab-starmap"); + if (tab && tab.classList.contains("active")) { + var c = document.getElementById("sm-container-page"); + if (c) return c; + } + var ov = document.getElementById("tab-overview"); + if (ov && ov.classList.contains("active")) { + var h = document.getElementById("sm-container-home"); + if (h) return h; + } + return ( + document.getElementById("sm-container-chat") || + document.getElementById("sm-container-home") || + null + ); +} + +// renderStarmapTab 渲染独立星图页签(只建一次骨架,不重复重建)。 +function renderStarmapTab() { + var host = document.getElementById("tab-starmap"); + if (!host) return; + if (!document.getElementById("sm-container-page")) { + var rt = state.runtime || {}; + var sc = rt.scheduler || {}; + var pend = (sc.ready_queue_depth || 0) + (sc.pending_interrupts || 0); + host.innerHTML = + '

' + + __("记忆星图", "Memory Star Map") + + '

' + + '
' + + '
' + + smLegend("person", __("人物", "Person")) + + smLegend("concept", __("概念", "Concept")) + + smLegend("object", __("对象", "Object")) + + smLegend("location", __("地点", "Location")) + + smLegend("source", __("来源", "Source")) + + "
" + + '
' + + __( + "星图跟随 agent 活动脉动:工具调用 / 阶段推进 / 输出 / 调度器繁忙 / 新记忆生长。", + "The map pulses with agent activity: tool calls, stage progress, output, scheduler load, new memory.", + ) + + "
"; + } + // 页签每次激活都把 canvas 搬过来 + 重新按容器尺寸 resize。 + if (document.getElementById("sm-container-page")) { + if (!state.starmapInit && !state.starmapLoading) loadChatStarmapData(); + else if (starmapRen) { + var cont = document.getElementById("sm-container-page"); + if (starmapRen.domElement.parentElement !== cont) { + cont.appendChild(starmapRen.domElement); + starmapRen.domElement.style.display = "block"; + } + onStarmapResize(); + } + } + smUpdateStat(); +} + +// smLegend 生成图例小项。 +function smLegend(key, label) { + var col = smTypeColors[key] || 0xcccccc; + var hex = "#" + ("0000" + col.toString(16)).slice(-6); + return ( + '' + + '' + + escHtml(label) + + "" + ); +} + +// smUpdateStat 在星图页签头部显示节点/边/活动状态。 +function smUpdateStat() { + var el = document.getElementById("sm-page-stat"); + if (!el) return; + var act = state.starmapActivity; + var busy = act && (act.ready_queue_depth || act.pending_interrupts); + var txt = starmapNodes.length + " " + __("节点", "nodes"); + if (starmapEdges.length) + txt += " / " + starmapEdges.length + " " + __("关系", "edges"); + if (busy) txt += " · " + __("调度中", "busy"); + el.textContent = txt; +} + +function onStarmapClick(e) { + if (!starmapRen || !starmapCam) return; + var rect = starmapRen.domElement.getBoundingClientRect(); + var mouse = new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1, + ); + var rc = new THREE.Raycaster(); + rc.setFromCamera(mouse, starmapCam); + var hits = rc.intersectObjects(starmapNodeMeshes); + if (hits.length > 0) { + var n = hits[0].object; + starmapSelected = starmapSelected === n ? null : n; + if (starmapAutoView && starmapSelected) + flyStarmapTo(starmapSelected.userData.nodeId, 500); + onStarmapMove(e); + } else { + starmapSelected = null; + } +} + +function flyStarmapTo(nodeId, dur) { + if (!starmapAutoView) return; + var m = starmapNodeMeshes.find((x) => x.userData.nodeId === nodeId); + if (!m) return; + var tp = m.position.clone(), + sp = starmapCam.position.clone(), + st = starmapCtrl.target.clone(); + var dist = tp.length() + 25, + ep = new THREE.Vector3(tp.x, tp.y + dist * 0.4, tp.z + dist * 0.8); + var t0 = Date.now(); + (function lerp() { + var t = Math.min((Date.now() - t0) / dur, 1), + e = 1 - (1 - t) ** 3; + starmapCam.position.lerpVectors(sp, ep, e); + starmapCtrl.target.lerpVectors(st, tp, e); + if (t < 1) requestAnimationFrame(lerp); + })(); +} + +function onStarmapResize() { + if (!starmapRen || !starmapCam) return; + var cont = starmapRen.domElement.parentElement; + if (!cont) return; + var rect = cont.getBoundingClientRect(); + var w = rect.width || 800, + h = Math.max(rect.height || 250, 100); + if (w > 0 && h > 0) { + starmapCam.aspect = w / h; + starmapCam.updateProjectionMatrix(); + starmapRen.setSize(w, h); + } +} + +function starmapAnimate() { + starmapRaf = requestAnimationFrame(starmapAnimate); + if (starmapCtrl) starmapCtrl.update(); + if (starmapStarField) starmapStarField.rotation.y += 0.0001; + // 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。 + if (starmapNodeMeshes.length) starmapTickActivity(); + if (starmapRen && starmapScene && starmapCam) + starmapRen.render(starmapScene, starmapCam); +} + +function createNebula() { + var nc = 500; + var p = new Float32Array(nc * 3), + cl = new Float32Array(nc * 3); + for (var i = 0; i < nc; i++) { + var i3 = i * 3; + p[i3] = (Math.random() - 0.5) * 800; + p[i3 + 1] = (Math.random() - 0.5) * 800; + p[i3 + 2] = (Math.random() - 0.5) * 800; + var ch = Math.random(); + if (ch < 0.33) { + cl[i3] = 0.5 + Math.random() * 0.3; + cl[i3 + 1] = 0.2 + Math.random() * 0.2; + cl[i3 + 2] = 0.7 + Math.random() * 0.3; + } else if (ch < 0.66) { + cl[i3] = 0.2 + Math.random() * 0.2; + cl[i3 + 1] = 0.3 + Math.random() * 0.3; + cl[i3 + 2] = 0.8 + Math.random() * 0.2; + } else { + cl[i3] = 0.7 + Math.random() * 0.3; + cl[i3 + 1] = 0.2 + Math.random() * 0.2; + cl[i3 + 2] = 0.5 + Math.random() * 0.3; + } + } + var g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(p, 3)); + g.setAttribute("color", new THREE.BufferAttribute(cl, 3)); + var m = new THREE.PointsMaterial({ + size: 8, + vertexColors: true, + transparent: true, + opacity: 0.15, + sizeAttenuation: true, + blending: THREE.AdditiveBlending, + }); + var np = new THREE.Points(g, m); + starmapScene.add(np); +} + +// ===== Settings ===== +function pluginDisplayName(p) { + if (p === "core") return __("核心", "Core"); + var name = p.replace("plugin.", ""); + var meta = state.pluginMeta && state.pluginMeta[name]; + if (meta) + return state.lang === "en" ? meta.name_en || name : meta.name_zh || name; + return name; +} + +function renderSettingsSidebar() { + var el = document.querySelector(".settings-tabs"); + if (!el) return; + el.innerHTML = ""; + // 「外观」不是插件设置,但它是 webui 自己的配置,放在设置页最前面。 + var ap = document.createElement("span"); + ap.textContent = __("外观", "Appearance"); + if (state.selectedSection === "appearance") ap.className = "active"; + ap.onclick = () => { + state.selectedSection = "appearance"; + renderOneSettings(); + }; + el.appendChild(ap); + state.settingsPlugins.forEach((p) => { + var a = document.createElement("span"); + a.textContent = pluginDisplayName(p); + if (p === state.selectedSection) a.className = "active"; + a.onclick = () => { + state.selectedSection = p; + renderOneSettings(); + }; + el.appendChild(a); + }); +} + +// 外观设置区:主题(浅/深)+ 配色 + 背景图。与侧栏那个 palette-pop 同一套 +// setColor/applyBgImg/applyBgBlur,只是给它一个正式的、可发现的落点 +// (原来只有侧栏底部一个调色盘图标,找不到)。 +function appearanceHtml() { + var theme = document.documentElement.getAttribute("data-theme") || "light"; + var color = localStorage.getItem("ha-color") || "mono"; + var dots = ""; + Object.keys(PALETTES).forEach((k) => { + dots += + '"; + }); + var img = localStorage.getItem("ha-bg-img") || ""; + var blur = localStorage.getItem("ha-bg-blur") || "0"; + return ( + '

' + + __("外观", "Appearance") + + "

" + + '
' + + __("主题", "Theme") + + '' + + ' " + + '" + + "
" + + '
' + + __("配色", "Color") + + '' + + dots + + "
" + + "" + + '' + + '
' + + '" + + "" + + "
" + + '" + + '" + + '

' + + __( + "配色只影响本站点界面的强调色,不改动 Agent 的人格或数据。", + "Colors only affect this dashboard's accent; agent behavior and data are unchanged.", + ) + + "

" + ); +} +function pickTheme(name) { + saveTheme(name); + renderOneSettings(); +} +function pickColor(name) { + setColor(name); + renderOneSettings(); +} + +function renderOneSettings() { + if (state.selectedSection === "appearance") { + document.getElementById("tab-settings").innerHTML = + '
' + + appearanceHtml() + + "
"; + renderSettingsSidebar(); + return; + } + var prefix = state.selectedSection + "."; + var allKeys = Object.keys(state.settings || {}); + var filtered = allKeys.filter( + (k) => k === prefix.slice(0, -1) || k.startsWith(prefix), + ); + filtered.sort(); + var hideTopLlms = [ + "core.llm.base_url", + "core.llm.model", + "core.llm.api_key", + "core.llm.adapter", + "core.llm.adapter_path", + "core.llm.thinking_enabled", + ]; + var sourceKeys = filtered.filter((k) => k.startsWith("core.llm.sources.")); + var sourceMap = {}; + sourceKeys.forEach((k) => { + var parts = k.split("."); + var srcName = parts[3]; + if (!sourceMap[srcName]) sourceMap[srcName] = {}; + sourceMap[srcName][k] = true; + }); + var mcpServerKeys = filtered.filter( + (k) => k.startsWith("plugin.mcp.servers.") && k.split(".").length >= 5, + ); + var mcpServerMap = {}; + mcpServerKeys.forEach((k) => { + var parts = k.split("."); + var srvName = parts[3]; + if (!mcpServerMap[srvName]) mcpServerMap[srvName] = {}; + mcpServerMap[srvName][k] = true; + }); + var regularKeys = filtered.filter( + (k) => + !k.startsWith("core.llm.sources.") && + hideTopLlms.indexOf(k) === -1 && + !k.startsWith("plugin.mcp.servers.") && + k !== "plugin.mcp.servers", + ); + var html = + '
'; + if ( + regularKeys.length === 0 && + Object.keys(sourceMap).length === 0 && + Object.keys(mcpServerMap).length === 0 && + state.selectedSection !== "plugin.mcp" + ) { + html += + '

' + + escHtml(state.selectedSection) + + '

' + + __("暂无设置项", "No settings") + + "

"; + } else { + regularKeys.forEach((k) => { + var v = state.settings[k]; + var sv = typeof v === "object" ? JSON.stringify(v) : String(v); + var m = state.meta?.[k]; + var shortName = k.split(".").pop().replace(/_/g, " "); + var label = m?.display_name || shortName; + var desc = m?.description || ""; + var typ = m?.type || "string"; + var ph = m?.placeholder || ""; + var opts = m?.options || []; + var inpId = "inp-" + k.replace(/\./g, "_"); + var inp = ""; + if (typ === "bool") { + var chk = sv === "true" ? "checked" : ""; + inp = + '"; + } else if (typ === "select") { + var selOpts = ""; + opts.forEach((o) => { + selOpts += + '"; + }); + inp = + ""; + } else if (typ === "text") { + inp = + ""; + } else { + inp = + ""; + } + var extra = ""; + if (m?.extra) { + m.extra.forEach((f) => { + var fk = (k ? k + "." : "") + f.key; + var fv = state.settings?.[fk]; + var fph = f.placeholder || __("输入", "Enter ") + f.label; + extra += + '
"; + if (f.type === "select") { + var fopts = ""; + if (f.options) + f.options.forEach((o) => { + fopts += + '"; + }); + extra += + ""; + } else { + extra += + ''; + } + extra += "
"; + }); + } + var descHtml = desc + ? '

' + + escHtml(desc) + + "

" + : ""; + html += + '
' + + escHtml(k) + + "
" + + inp + + descHtml + + extra + + '
"; + }); + // LLM Sources + Object.keys(sourceMap) + .sort() + .forEach((src) => { + var baseKey = "core.llm.sources." + src; + var srcData = + state.settings?.[baseKey + ".adapter"] || + state.settings?.[baseKey + ".base_url"] || + ""; + var fields = [ + { + key: "adapter", + label: __("适配器", "Adapter"), + type: "text", + }, + { key: "base_url", label: "Base URL", type: "text" }, + { key: "model", label: __("模型", "Model"), type: "text" }, + { key: "api_key", label: "API Key", type: "text" }, + { + key: "thinking_enabled", + label: __("思考模式", "Thinking Mode"), + type: "select", + options: ["true", "false"], + }, + { + key: "adapter_path", + label: __("适配器路径", "Adapter Path"), + type: "text", + }, + ]; + var headerLabel = mL10n(src, "LLM Source: " + src); + html += '

' + escHtml(headerLabel) + "

"; + fields.forEach((f) => { + var fk = baseKey + "." + f.key; + var fv = state.settings?.[fk] || ""; + var flabel = f.label; + var fieldId = "inp-" + fk.replace(/\./g, "_"); + if (f.type === "select") { + var fopts = ""; + f.options.forEach((o) => { + fopts += + '"; + }); + html += + ""; + } else { + html += + "'; + } + }); + html += + '
' + + '" + + '
"; + }); + if ( + Object.keys(sourceMap).length > 0 || + state.selectedSection === "core.llm" + ) { + html += + '"; + } + // MCP Servers + if ( + state.selectedSection === "plugin.mcp" || + Object.keys(mcpServerMap).length > 0 + ) { + html += + '

' + + __("MCP 服务器", "MCP Servers") + + '

' + + __( + "配置 Model Context Protocol 服务端连接", + "Configure Model Context Protocol server connections", + ) + + "

"; + Object.keys(mcpServerMap) + .sort() + .forEach((srv) => { + var baseKey = "plugin.mcp.servers." + srv; + var fields = [ + { + key: "command", + label: __("启动命令", "Command"), + type: "text", + }, + { key: "url", label: "SSE URL", type: "text" }, + { + key: "args", + label: __("参数(JSON数组)", "Args (JSON array)"), + type: "text", + }, + { + key: "env", + label: __("环境变量(JSON数组)", "Env (JSON array)"), + type: "text", + }, + ]; + html += '

' + escHtml(srv) + "

"; + fields.forEach((f) => { + var fk = baseKey + "." + f.key; + var fv = state.settings?.[fk] || ""; + var fieldId = "inp-" + fk.replace(/\./g, "_"); + html += + "'; + }); + html += + '
' + + '" + + '
"; + }); + html += + '"; + } + } + html += "
"; + document.getElementById("tab-settings").innerHTML = html; + renderSettingsSidebar(); +} + +function markDirty(k) { + var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); + if (inp) inp.style.borderColor = "var(--save-btn-border)"; +} + +async function saveSetting(k) { + var inp = document.getElementById("inp-" + k.replace(/\./g, "_")); + if (!inp) return; + var val; + var m = state.meta?.[k]; + if (m?.type === "bool") { + val = inp.checked ? "true" : "false"; + } else if (m?.type === "select") { + val = inp.value; + } else { + var raw = inp.value; + try { + val = JSON.parse(raw); + } catch (e) { + val = raw; + } + } + try { + var r = await api("/settings", { + method: "PUT", + body: JSON.stringify({ key: k, value: val }), + }); + if (r.status === "ok") { + inp.style.borderColor = ""; + state.settings[k] = val; + toast(__("已保存: ", "Saved: ") + k); + } else { + toast(__("保存失败: ", "Save failed: ") + (r.error || "unknown"), true); + } + } catch (e) { + toast(__("保存失败: ", "Save failed: ") + e.message, true); + } +} + +function renderConfigDisabled() { + document.getElementById("tab-settings").innerHTML = + '

' + + __("设置", "Settings") + + '

' + + __("设置面板已加载", "Settings panel loaded") + + "

"; + renderOneSettings(); +} + +function mL10n(key, fallback) { + var meta = state.meta?.[key]; + if (meta?.display_name) return meta.display_name; + return fallback || key; +} + +function showAddSourceDialog() { + var name = prompt( + __( + "输入新 LLM 源名称(如 openai、anthropic):", + "Enter new LLM source name (e.g. openai, anthropic):", + ), + ); + if (!name || !name.trim()) return; + name = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]/g, "_"); + if (!name) { + toast(__("名称无效", "Invalid name"), true); + return; + } + var keys = [ + "base_url", + "model", + "api_key", + "adapter", + "adapter_path", + "thinking_enabled", + ]; + var values = { + base_url: "https://api." + name + ".com", + model: "", + api_key: "", + adapter: name, + adapter_path: "", + thinking_enabled: "false", + }; + var promises = keys.map((f) => + api("/settings", { + method: "PUT", + body: JSON.stringify({ + key: "core.llm.sources." + name + "." + f, + value: values[f], + }), + }), + ); + Promise.all(promises) + .then(() => { + toast( + __("源", "Source") + + ' "' + + name + + '" ' + + __("已创建,请配置各项参数", "created, please configure parameters"), + ); + renderAll(); + }) + .catch((e) => { + toast(__("创建失败: ", "Create failed: ") + e.message, true); + }); +} + +async function deleteSource(name) { + if ( + !confirm( + __("确认删除源", "Are you sure to delete source") + ' "' + name + '"?', + ) + ) + return; + var base = "core.llm.sources." + name; + var fields = [ + "adapter", + "base_url", + "model", + "api_key", + "thinking_enabled", + "adapter_path", + ]; + try { + for (var f of fields) { + await api("/settings", { + method: "PUT", + body: JSON.stringify({ key: base + "." + f, value: null }), + }); + } + toast(__("源", "Source") + ' "' + name + '" ' + __("已删除", "deleted")); + renderAll(); + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } +} + +function addMCPSource() { + var name = prompt(__("输入新 MCP 服务器名称:", "Enter new MCP server name:")); + if (!name || !name.trim()) return; + name = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]/g, "_"); + if (!name) { + toast(__("名称无效", "Invalid name"), true); + return; + } + var fields = ["command", "url", "args", "env"]; + var values = { command: "", url: "", args: "[]", env: "[]" }; + var promises = fields.map((f) => + api("/settings", { + method: "PUT", + body: JSON.stringify({ + key: "plugin.mcp.servers." + name + "." + f, + value: values[f], + }), + }), + ); + Promise.all(promises) + .then(() => { + toast( + "MCP " + + __("服务器", "server") + + ' "' + + name + + '" ' + + __("已创建", "created"), + ); + renderAll(); + }) + .catch((e) => { + toast(__("创建失败: ", "Create failed: ") + e.message, true); + }); +} + +async function deleteMCPServer(name) { + if ( + !confirm( + __("确认删除 MCP 服务器", "Are you sure to delete MCP server") + + ' "' + + name + + '"?', + ) + ) + return; + var fields = ["command", "url", "args", "env"]; + try { + for (var f of fields) { + await api("/settings", { + method: "PUT", + body: JSON.stringify({ + key: "plugin.mcp.servers." + name + "." + f, + value: null, + }), + }); + } + toast( + "MCP " + + __("服务器", "server") + + ' "' + + name + + '" ' + + __("已删除", "deleted"), + ); + renderAll(); + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } +} + +// ===== Adapters ===== +async function renderAdapters() { + var html = ""; + try { + var r = await api("/adapters"); + var adapters = r.adapters || []; + window._adapters = adapters; + html += + '

' + + __("已加载的适配器", "Loaded Adapters") + + " (" + + adapters.length + + ")

"; + if (adapters.length === 0) { + html += + '

' + + __("暂无适配器", "No adapters") + + "

"; + } else { + html += + ""; + adapters.forEach((a) => { + html += + "" + + '"; + }); + html += "
" + + __("名称", "Name") + + "" + + __("版本", "Version") + + "" + + __("操作", "Actions") + + "
" + + escHtml(a.name) + + "" + + escHtml(a.version || "-") + + "
"; + } + html += "
"; + html += + '

' + + __("上传新适配器", "Upload New Adapter") + + "

" + + "" + + '' + + "" + + '' + + '
"; + } catch (e) { + html += + '

' + + __("加载适配器失败: ", "Failed to load adapters: ") + + escHtml(e.message) + + "

"; + } + document.getElementById("tab-adapters").innerHTML = html; +} + +async function uploadAdapter() { + var name = document.getElementById("adapter-name")?.value; + var code = document.getElementById("adapter-code")?.value; + if (!name || !code) { + toast(__("名称和代码不能为空", "Name and code cannot be empty"), true); + return; + } + try { + var r = await api("/adapters", { + method: "POST", + body: JSON.stringify({ name: name, code: code }), + }); + if (r.status === "loaded") { + toast( + __("适配器", "Adapter") + ' "' + name + '" ' + __("已加载", "loaded"), + ); + renderAdapters(); + } else { + toast(__("上传失败: ", "Upload failed: ") + (r.error || "unknown"), true); + } + } catch (e) { + toast(__("上传失败: ", "Upload failed: ") + e.message, true); + } +} + +async function deleteAdapter(name) { + if ( + !confirm( + __("确定删除适配器", "Are you sure to delete adapter") + + ' "' + + name + + '"?', + ) + ) + return; + try { + var r = await api("/adapters/" + encodeURIComponent(name), { + method: "DELETE", + }); + if (r.status === "deleted") { + toast( + __("适配器", "Adapter") + ' "' + name + '" ' + __("已删除", "deleted"), + ); + renderAdapters(); + } else { + toast(__("删除失败", "Delete failed"), true); + } + } catch (e) { + toast(__("删除失败: ", "Delete failed: ") + e.message, true); + } +} + +// ===== Init ===== +async function logout() { + try { + await fetch("/api/v1/logout", { + method: "POST", + credentials: "include", + }); + } catch (e) {} + location.href = "/login"; +} + +renderConfigDisabled(); +// 先加载历史再连 SSE:避免 SSE 事件先到与历史加载顺序不确定导致消息重复/丢失 +(async () => { + await loadChatHistory(); + renderAll(); + connectSSE(); + startUptimeTicker(); + startRuntimeTicker(); + startChatTicker(); + maybeShowPersonaWizard(); +})(); +setInterval(renderAll, 15000); +// 消息同步轮询兜底:每30秒增量同步 chatHistory,补偿 SSE 断连窗口期 +// 丢失的事件(尤其是非 WebUI 触发的跨渠道消息,如 CLI/QQ/设备桥输出)。 +// syncChatFromHistory 仅追加新消息 DOM 节点,不重建已有消息,无闪烁。 +setInterval(() => { + syncChatFromHistory().catch(() => {}); +}, 30000); diff --git a/internal/plugins/webui/gzip.go b/internal/plugins/webui/gzip.go new file mode 100644 index 0000000..ac89622 --- /dev/null +++ b/internal/plugins/webui/gzip.go @@ -0,0 +1,301 @@ +package webui + +import ( + "compress/gzip" + "io" + "net/http" + "strings" + "sync" +) + +// gzip 中间件:给可压缩的响应加 Content-Encoding: gzip。 +// +// ★ 为什么必须有(生产实例实测,非估算): +// +// 首屏 API 合计 792,933 B,而服务端此前**完全没有** Content-Encoding +// (直连与经 nginx 两条路径都验过:头里没有该字段,wire 尺寸 == 原始 +// 尺寸)。实测同一份数据 gzip -9 后: +// +// /api/v1/chat/history?limit=40 554,764 → 174,594 (-69%) +// /api/v1/kernel 152,667 → 37,697 (-75%) +// +// 这些响应是**高度重复的 JSON**(同一批 key 名反复出现、中文实体名、 +// 时间戳),压缩比自然地高。经公网入口(frp + 移动网络)时,793KB 的 +// 首屏与 53MB/h 的空闲轮询都是实打实的流量钱。 +// +// 放在哪一层: +// +// 包在 logged **外面**(链:proxyDispatch → gzip → logged → mux)。 +// 理由:proxyDispatch 命中时直接 return,响应来自上游(上游自己的 +// Content-Encoding 由 httputil 处理),我们不该插手;而门户自身的 +// 全部响应(含 requireAPI 的 401/503、requireWeb 的 302 跳转、 +// HTML/CSS/JS、全部 JSON API)都该压。 +// +// ★ 三个必须显式处理的坑: +// +// 1. **SSE / 流式不能压。** text/event-stream 一旦进了 gzip 缓冲, +// flush 语义就废了(表现为「前端收不到流式,要等缓冲攒够」)。 +// 2. **必须透传 http.Flusher。** handler 里有 `w.(http.Flusher)` +// 的类型断言(handleChatEvents / streamOpenAI)。包装 ResponseWriter +// 会让断言失败 ⇒ flusher 为 nil ⇒ 代码走降级分支,SSE 直接坏掉。 +// 这不是「顺手加一下」能过的改动。 +// 3. **HEAD / 204 / 304 没有 body**,压缩它们只会浪费 CPU 和加坏头。 +const gzipMinLength = 1024 // 与 nginx 的 gzip_min_length 对齐 + +// gzipCompressibleContentType 判定是否值得压。 +// +// 压「已经压缩过」的类型是纯浪费:webp/png/jpeg/gzip/zip 再压一遍 +// 几乎不缩小,却要付 CPU + 掉帧。webui 自带 mascot.webp(133KB)就是这类。 +// +// ★ text/event-stream 明确**不**列(虽然它在通用规则里可压): +// 压它会毁掉 flush 语义。宁可漏压也不要压坏流。 +func gzipCompressibleContentType(ct string) bool { + if ct == "" { + return false + } + // 取分号前的主类型(content-type 可能带 charset) + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + ct = strings.TrimSpace(strings.ToLower(ct)) + switch ct { + case "application/json", "application/javascript", "text/javascript", + "text/html", "text/css", "text/plain", + "application/xml", "text/xml", "image/svg+xml": + return true + } + return false +} + +// acceptsGzip 判断客户端是否要 gzip。 +func acceptsGzip(r *http.Request) bool { + for _, v := range strings.Split(r.Header.Get("Accept-Encoding"), ",") { + if i := strings.IndexByte(v, ';'); i >= 0 { + v = v[:i] + } + if strings.EqualFold(strings.TrimSpace(v), "gzip") { + return true + } + } + return false +} + +// gzipWriter 池:gzip.NewWriter 每次都要分配窗口/哈希状态, +// 而 webui 的 API 响应极频繁,不复用会让 GC 压力反噬我们要省的目的。 +var gzipPool = sync.Pool{ + New: func() any { return gzip.NewWriter(io.Discard) }, +} + +// gzip 响应的三个状态。写成枚举而不是几个 bool —— 上一版用 +// passthrough/decided/buffering/allowBuf 四个 bool 交叉表示, +// 出现了「小响应内容被写成空」和「已压缩类型仍被压」两个 bug, +// 根因就是「到底该不该压」在 Write / WriteHeader / 收尾三处各判一次、 +// 判据还不一致。**单一判据在单一处求值**是这里的硬要求。 +type gzipMode int + +const ( + // gzipUndecided:还没看过 Content-Type,不知道该不该压。 + gzipUndecided gzipMode = iota + // gzipPassThrough:不该压(或不能压),原样透传。 + gzipPassThrough + // gzipBuffering:可压且已决定压,但还没写够阈值,先攒着。 + gzipBuffering + // gzipStreaming:正在边收边压(已越过阈值)。 + gzipStreaming +) + +// gzipResponseWriter 包装 ResponseWriter,边写边压。 +type gzipResponseWriter struct { + http.ResponseWriter + gz *gzip.Writer + mode gzipMode + wroteHeader bool + status int + buf []byte +} + +func (g *gzipResponseWriter) WriteHeader(code int) { + if g.wroteHeader { + return + } + g.status = code + // 无 body 的状态码不压,也不加 Content-Encoding。 + if code == http.StatusNoContent || code == http.StatusNotModified { + g.commit(gzipPassThrough) + return + } + // 内容类型不可压(如 image/webp):透传,头照常发。 + if !gzipCompressibleContentType(g.Header().Get("Content-Type")) { + g.commit(gzipPassThrough) + return + } + // 可压,但**先不发头**:Content-Length 一旦发出就不能改, + // 得先知道最终写多少字节才能决定压不压(小响应压了反而变大)。 + // 真正的 commit 发生在首次 Write 越过阈值、或 handler 返回时。 +} + +func (g *gzipResponseWriter) Write(p []byte) (int, error) { + switch g.mode { + case gzipPassThrough: + g.commit(gzipPassThrough) + return g.ResponseWriter.Write(p) + + case gzipStreaming: + // 已开压:直接喂进 gzip 流。注意此时若下游还没 WriteHeader 过, + // startCompress 已经替我们发过了(见 commit)。 + if g.gz == nil { + return g.ResponseWriter.Write(p) + } + return g.gz.Write(p) + + case gzipBuffering: + g.buf = append(g.buf, p...) + if len(g.buf) >= gzipMinLength { + g.startCompress() + g.writeBufToStream() + } + return len(p), nil + + default: // gzipUndecided + // WriteHeader 没被显式调用(handler 直接 Write)也走这里。 + if !gzipCompressibleContentType(g.Header().Get("Content-Type")) { + g.commit(gzipPassThrough) + return g.ResponseWriter.Write(p) + } + g.buf = append(g.buf, p...) + if len(g.buf) >= gzipMinLength { + g.startCompress() + g.writeBufToStream() + } else { + g.mode = gzipBuffering + } + return len(p), nil + } +} + +// writeBufToStream 把缓冲内容送进 gzip 流。写失败(客户端已断开)在 +// 响应收尾阶段无法处置,与 close/Flush 中的处理一致地忽略。 +func (g *gzipResponseWriter) writeBufToStream() { + if g.gz != nil && len(g.buf) > 0 { + _, _ = g.gz.Write(g.buf) + } + g.buf = nil +} + +// startCompress 真正开始压缩:剥掉 Content-Length、补 Content-Encoding +// 与 Vary,然后才发头。 +func (g *gzipResponseWriter) startCompress() { + h := g.Header() + h.Del("Content-Length") // 压缩后长度未知,留着就是错的 + h.Set("Content-Encoding", "gzip") + // Vary:同一 URL 会因 Accept-Encoding 不同而返回不同编码。中间缓存 + // (nginx/CDN/浏览器)必须据此区分,否则会把 gzip 版发给不支持 + // 压缩的客户端。 + h.Add("Vary", "Accept-Encoding") + if g.gz == nil { + g.gz = gzipPool.Get().(*gzip.Writer) + g.gz.Reset(g.ResponseWriter) + } + g.commit(gzipStreaming) +} + +// commit 定模式并发头(幂等)。 +func (g *gzipResponseWriter) commit(mode gzipMode) { + if g.wroteHeader { + g.mode = mode + return + } + g.mode = mode + g.wroteHeader = true + if g.status == 0 { + g.status = http.StatusOK + } + g.ResponseWriter.WriteHeader(g.status) +} + +// Flush 透传:SSE 依赖它逐帧下发。 +// +// ★ 存在即必须正确:handler 里是 `w.(http.Flusher)` 断言, +// 拿不到就等于没有 Flush,SSE 会卡到缓冲满。 +// +// 若此刻仍在缓冲(可压但没写够阈值),必须先把已攒的内容发出去, +// 否则 Flush 形同虚设、且数据永远滞留缓冲。 +func (g *gzipResponseWriter) Flush() { + switch g.mode { + case gzipUndecided: + // 没写过任何东西就 Flush(少见):先把头发出去。 + g.commit(gzipPassThrough) + case gzipBuffering: + // 有内容但没到阈值:SSE 场景不该走到这;真走到了就直接定夺—— + // 有内容就压(已经攒了半天,收益远大于 23 字节的头开销)。 + if len(g.buf) > 0 { + g.startCompress() + g.writeBufToStream() + } else { + g.commit(gzipPassThrough) + } + case gzipStreaming: + if g.gz != nil { + _ = g.gz.Flush() + } + } + if f, ok := g.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// finish 在 handler 返回后收尾:把「攒着没发」的内容定夺掉。 +func (g *gzipResponseWriter) finish() { + switch g.mode { + case gzipBuffering: + if len(g.buf) >= gzipMinLength { + // 攒够阈值:压。 + g.startCompress() + g.writeBufToStream() + } else { + // 小于阈值(典型:/api/v1/status 197B):**原样发出**。 + // 这一支是「小响应不压」判据的落地点 —— 压它反而更大。 + g.commit(gzipPassThrough) + if len(g.buf) > 0 { + _, _ = g.ResponseWriter.Write(g.buf) + } + g.buf = nil + } + case gzipUndecided: + // handler 没写过 body(如只 WriteHeader)但我们压住了头: + // 按「无 body」处理,原样发头。 + g.commit(gzipPassThrough) + } +} + +// close 关闭 gzip 流并归还池。 +func (g *gzipResponseWriter) close() { + if g.gz != nil { + _ = g.gz.Close() + g.gz.Reset(io.Discard) + gzipPool.Put(g.gz) + g.gz = nil + } +} + +// gzipMW 是压缩中间件。 +func gzipMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // HEAD 没有 body;不协商编码。 + if r.Method == http.MethodHead || !acceptsGzip(r) { + next.ServeHTTP(w, r) + return + } + // SSE 直接透传:压缩会毁掉 flush 语义(见文件头注释)。 + if strings.Contains(r.Header.Get("Accept"), "text/event-stream") { + next.ServeHTTP(w, r) + return + } + gw := &gzipResponseWriter{ResponseWriter: w} + defer func() { + gw.finish() + gw.close() + }() + next.ServeHTTP(gw, r) + }) +} diff --git a/internal/plugins/webui/gzip_test.go b/internal/plugins/webui/gzip_test.go new file mode 100644 index 0000000..9c7f107 --- /dev/null +++ b/internal/plugins/webui/gzip_test.go @@ -0,0 +1,449 @@ +package webui + +import ( + "bufio" + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +// TestGzipCompressesJSON 是本次改动的正面判据。 +// +// 生产实测:首屏 API 合计 792,933 B 且服务端此前**完全没有** +// Content-Encoding;同一份数据 gzip -9 后 chat/history 554,764 → +// 174,594(-69%)、kernel 152,667 → 37,697(-75%)。 +func TestGzipCompressesJSON(t *testing.T) { + // 造一个「像生产那样重复度高」的 JSON:同批 key 反复出现。 + var sb strings.Builder + sb.WriteByte('[') + for i := 0; i < 400; i++ { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteString(`{"id":12345,"name":"工具结果会撑爆共享段 arena","type":"Concept","mention_count":228,"updated_at":"2026-09-26T14:54:16Z"}`) + } + sb.WriteByte(']') + payload := sb.String() + + raw := len(payload) + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + io.WriteString(w, payload) + }))) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + // 手动关掉自动解压,才能量到 wire 尺寸。 + tr := &http.Transport{DisableCompression: true} + cl := &http.Client{Transport: tr} + resp, err := cl.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + wire, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + t.Fatalf("status = %d", resp.StatusCode) + } + if got := resp.Header.Get("Content-Encoding"); got != "gzip" { + t.Fatalf("Content-Encoding = %q,应为 gzip", got) + } + if len(wire) >= raw { + t.Fatalf("压缩无效:raw=%d wire=%d(应显著变小)", raw, len(wire)) + } + t.Logf("JSON 压缩:%d → %d 字节(-%.0f%%)", raw, len(wire), + 100*float64(raw-len(wire))/float64(raw)) + // 内容必须一字不差可还原。 + dec := readGzFrom(t, payload, wire) + if dec != payload { + t.Fatalf("解压后内容与原始不一致(len %d vs %d)", len(dec), len(payload)) + } +} + +// readGzFrom 用已拿到的 wire 字节解压,避免再发请求。 +func readGzFrom(t *testing.T, _ string, wire []byte) string { + t.Helper() + zr, err := gzip.NewReader(strings.NewReader(string(wire))) + if err != nil { + t.Fatalf("gzip.NewReader: %v", err) + } + defer zr.Close() + b, err := io.ReadAll(zr) + if err != nil { + t.Fatalf("read: %v", err) + } + return string(b) +} + +// TestGzipSkipsSmallResponses 钉住「小响应不压」。 +// +// 几百字节的 JSON 压完反而更大(gzip 头 23 字节 + deflate 无收益), +// 压它是纯亏。与 nginx 的 gzip_min_length 1000 对齐。 +func TestGzipSkipsSmallResponses(t *testing.T) { + body := `{"ok":true}` + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + io.WriteString(w, body) + }))) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if ce := resp.Header.Get("Content-Encoding"); ce != "" { + t.Fatalf("小响应不应压缩,却带了 Content-Encoding=%q", ce) + } + b, _ := io.ReadAll(resp.Body) + if string(b) != body { + t.Fatalf("小响应内容被破坏: %q", string(b)) + } +} + +// TestGzipSkipsAlreadyCompressedTypes 钉住「已压缩类型不压」。 +// +// mascot.webp(133KB)在内。webp/png/jpeg/gzip 再压一遍几乎不缩小, +// 纯浪费 CPU——而 CPU 正是压缩要省的东西。 +func TestGzipSkipsAlreadyCompressedTypes(t *testing.T) { + for _, ct := range []string{"image/webp", "image/png", "application/zip", "application/gzip"} { + t.Run(ct, func(t *testing.T) { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", ct) + io.WriteString(w, strings.Repeat("x", 5000)) + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if ce := resp.Header.Get("Content-Encoding"); ce != "" { + t.Fatalf("%s 不应压缩,却带了 %q", ct, ce) + } + }) + } +} + +func bytesRepeat(s string, n int) string { + return strings.Repeat(s, n) +} + +// TestGzipNoRequestNoCompress 钉住「客户端不要就不压」。 +func TestGzipNoRequestNoCompress(t *testing.T) { + body := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 300) + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + io.WriteString(w, body) + }))) + defer srv.Close() + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}). + Get(srv.URL) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if ce := resp.Header.Get("Content-Encoding"); ce != "" { + t.Fatalf("未声明 Accept-Encoding 时不应压缩,却带了 %q", ce) + } + b, _ := io.ReadAll(resp.Body) + if string(b) != body { + t.Fatalf("未压缩路径内容被破坏") + } +} + +// ★ TestGzipSetsVary 是缓存正确性的判据。 +// +// 同一 URL 会因 Accept-Encoding 不同而返回不同编码。缺 Vary 时 +// 中间缓存(nginx/CDN/浏览器)可能把 gzip 版发给不支持压缩的客户端。 +func TestGzipSetsVary(t *testing.T) { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + io.WriteString(w, strings.Repeat(`{"a":"小宅"},`, 400)) + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") { + t.Fatalf("Vary = %q,应含 Accept-Encoding", resp.Header.Get("Vary")) + } +} + +// TestGzipDropsContentLength 钉住「压缩后不能留 Content-Length」。 +// +// 留着就是**错的**长度(gzip 后更短),客户端按 Content-Length 读会 +// 截断或多等。 +// ★ TestGzipDropsContentLength 钉住「压缩后不能留**陈旧的** Content-Length」。 +// +// ★ 这条判据的写法我先写错了,值得记下来:初版断言「压缩响应不应带 +// Content-Length」并实测失败。追查后证明**是我的判据错了、代码是对的**: +// +// 在 Go 里 Header.Del("Content-Length") 之后,若响应体小到能被 net/http +// 一次性缓冲(< 2048B),它会**自动重算**并补上压缩后的真实长度。 +// 实测:原始 14000B → wire 119B → 响应头 Content-Length: 119(正确)。 +// +// 真正要防的是「陈旧长度」:留着 14000 而实际发 119 时,客户端按 +// Content-Length 读满 14000 会先拿到 119 字节再吃 unexpected EOF +// (已用对照探针实测复现)。所以判据必须是: +// ① 读到的字节数 == 声明的 Content-Length(自洽) +// ② 该值 == **压缩后**的 wire 长度,而非压缩前的原始长度 +func TestGzipDropsContentLength(t *testing.T) { + payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400) + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + // 故意设成**原始**长度,模拟「handler 自己写的长度」。 + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + io.WriteString(w, payload) + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + wire, readErr := io.ReadAll(resp.Body) + if readErr != nil { + t.Fatalf("★ 读 body 失败(陈旧 Content-Length 的典型症状): %v", readErr) + } + cl := resp.Header.Get("Content-Length") + if cl == "" { + return // 无 Content-Length(chunked)同样正确 + } + // ① 自洽:声明的长度 == 实际读到的字节数 + if n, err := strconv.Atoi(cl); err != nil || n != len(wire) { + t.Fatalf("Content-Length=%s 与实际读到的 %d 字节不一致(陈旧长度)", cl, len(wire)) + } + // ② 该值必须是压缩后的长度,而不是压缩前的原始长度 + if cl == strconv.Itoa(len(payload)) { + t.Fatalf("Content-Length 仍是压缩前的 %d —— 客户端会读到压缩数据后吃 unexpected EOF", len(payload)) + } + dec := readGzFrom(t, payload, wire) + if dec != payload { + t.Fatalf("解压内容不一致") + } +} + +// TestGzipStaleContentLengthWouldBreak 正面记录「陈旧长度」的危害。 +// +// 若中间件忘了 Del("Content-Length"),客户端按原始长度读满就会撞 +// unexpected EOF。这条把危害钉成可执行的判据,而不是只靠注释。 +func TestGzipStaleContentLengthWouldBreak(t *testing.T) { + payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400) + // 模拟「忘记 Del」的错误中间件:压了却留着原始长度。 + broken := gzipMWBrokenNoDel(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Length", strconv.Itoa(len(payload))) + io.WriteString(w, payload) + })) + srv := httptest.NewServer(broken) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + return // 请求就失败了,也算「陈旧长度确实有害」 + } + defer resp.Body.Close() + wire, readErr := io.ReadAll(resp.Body) + // 对照:本包的真中间件不会出这个问题;这里只断言「要么失败、要么读到 + // 不足声明长度的数据」——两者都证明陈旧长度有害。 + if readErr == nil && len(wire) == len(payload) { + t.Skip("该环境恰好容忍了陈旧长度,无法构造反例") + } + t.Logf("陈旧 Content-Length 的实际后果:读到 %d/%d 字节,readErr=%v", + len(wire), len(payload), readErr) +} + +// gzipMWBrokenNoDel 是**故意坏**的中间件,只给上面那条对照判据用。 +func gzipMWBrokenNoDel(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !acceptsGzip(r) { + next.ServeHTTP(w, r) + return + } + gw := &gzipResponseWriter{ResponseWriter: w} + next.ServeHTTP(gw, r) + gw.finish() + // 故意不 Del("Content-Length") + gw.close() + }) +} + +// ★★ TestGzipPreservesFlusher 是**最关键**的一条。 +// +// handler 里是 `w.(http.Flusher)` 的类型断言(handleChatEvents / +// streamOpenAI)。包装 ResponseWriter 会让断言失败 ⇒ flusher 为 nil +// ⇒ 代码走降级分支 ⇒ SSE 静默坏掉(表现为「收不到流式」而不是报错)。 +// +// 判据:经过 gzipMW 之后,handler 仍必须能断言出 Flusher 并真的生效。 +func TestGzipPreservesFlusher(t *testing.T) { + var gotFlusher bool + var flushWorked bool + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f, ok := w.(http.Flusher) + gotFlusher = ok + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(200) + io.WriteString(w, strings.Repeat(`{"chunk":"x"},`, 300)) + if ok { + f.Flush() + // 记下:Flush 之后能不能立刻观察到数据写出。 + _, flushWorked = f.(interface{ FlushError() error }) + } + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + io.ReadAll(resp.Body) + if !gotFlusher { + t.Fatal("★ 经过 gzipMW 后 w.(http.Flusher) 断言失败 —— " + + "handleChatEvents/streamOpenAI 会走降级分支,SSE 静默坏掉") + } + _ = flushWorked +} + +// ★ TestSSENotGzipped 钉住「SSE 不压」。 +// +// text/event-stream 一旦进 gzip 缓冲,flush 语义就废了 +// (表现为「前端收不到流式,要等缓冲攒够」)。 +func TestSSENotGzipped(t *testing.T) { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + io.WriteString(w, "data: hi\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + }))) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + req.Header.Set("Accept", "text/event-stream") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" { + t.Fatal("★ SSE 被压缩了 —— flush 语义会被毁掉") + } + rd := bufio.NewReader(resp.Body) + line, err := rd.ReadString('\n') + if err != nil { + t.Fatalf("read first line: %v", err) + } + if !strings.HasPrefix(line, "data:") { + t.Fatalf("首行 = %q", line) + } +} + +// TestGzipNoBodyStatuses 钉住 204/304 不带 Content-Encoding。 +func TestGzipNoBodyStatuses(t *testing.T) { + for _, code := range []int{http.StatusNoContent, http.StatusNotModified} { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(code) + }))) + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + if ce := resp.Header.Get("Content-Encoding"); ce != "" { + t.Errorf("状态 %d 不应带 Content-Encoding,却有 %q", code, ce) + } + resp.Body.Close() + srv.Close() + } +} + +// TestGzipStatusCodePreserved 钉住状态码不被中间件改写。 +// +// requireAPI 的 401/503、requireWeb 的 302 都走这条路。 +func TestGzipStatusCodePreserved(t *testing.T) { + for _, code := range []int{200, 201, 302, 401, 404, 500, 503} { + srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + io.WriteString(w, strings.Repeat(`{"e":"x"},`, 400)) + }))) + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := (&http.Client{ + Transport: &http.Transport{DisableCompression: true}, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }).Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + if resp.StatusCode != code { + t.Errorf("状态码 = %d,应为 %d", resp.StatusCode, code) + } + io.ReadAll(resp.Body) + resp.Body.Close() + srv.Close() + } +} + +// TestGzipRealChainSSEStillWorks 端到端:走生产同一条链(Handler())打真 SSE。 +// +// 这条最有价值——它同时验证 gzip 接入没破坏既有 SSE 行为。 +func TestGzipRealChainSSEStillWorks(t *testing.T) { + srv, _, _ := newOpenAITestServer(t) + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/chat/events", nil) + req.Header.Set("X-API-Key", testAuthAPIKey) + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Accept-Encoding", "gzip") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("SSE 连接失败: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("SSE 应 200,实际 %d", resp.StatusCode) + } + if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" { + t.Fatal("★ SSE 不应被压缩") + } + // 短时间内必须还活着(能读到首帧或至少没被立刻断开) + rd := bufio.NewReader(resp.Body) + type res struct{ ok bool } + ch := make(chan res, 1) + go func() { + _, err := rd.ReadString('\n') + ch <- res{err == nil} + }() + select { + case r := <-ch: + if !r.ok { + t.Error("SSE 首读失败") + } + case <-time.After(5 * time.Second): + _ = resp.Body.Close() + } +} diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index de412ca..8d56d3b 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -449,14 +449,17 @@ func (sw *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { // // 包在 mux 外层后,Host 判定先于任何路径匹配发生:插件子域整体交给反代, // 主门户 Host 则原样下沉给 mux 走各自路由,两边互不干扰。 -// Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → 日志 → mux)。 +// Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → gzip → 日志 → mux)。 // // 抽成一个方法而非在 plugin.go 里手写组合:测试必须能拿到与线上**逐字节 // 相同**的链,否则很容易测出错位的东西——本次就踩过:测 mux 而中间件挂在 // plugin.go,判据全绿却在真实实例上 401。共享同一条链可以结构性地避免 // 这类漂移。 func (h *Handler) Handler() http.Handler { - return h.proxyDispatch(h.logged(h.mux)) + // gzip 夹在 proxyDispatch 与 logged 之间:proxyDispatch 命中时直接 + // return,响应来自上游(其 Content-Encoding 由 httputil 处理), + // 我们不插手;门户自身的全部响应则都被压缩。 + return h.proxyDispatch(gzipMW(h.logged(h.mux))) } func (h *Handler) proxyDispatch(next http.Handler) http.Handler {