import { renderBrainUiApp } from "./app-shell.js"; import { API } from "./api-client.js"; import { bootstrapACUI } from "./acui/bootstrap.js"; import { initChat, friendlyChannelLabel } from "./chat.js"; import { initPanelCollapse } from "./panel-collapse.js"; import { ThoughtStream } from "./thought-stream.js"; import { initVoicePanel } from "./voice-panel.js"; import { initHotspot, toggleHotspot, setHotspotMode, moveVoicePanelToBody, restoreVoicePanel } from "./hotspot.js"; import { enrichVisiblePersonCardFromText, initPersonCard, setPersonCardMode, showPersonCardByName } from "./person-card.js"; import { initDocPanel, setDocPanelMode } from "./doc.js"; import { initWechatPopup, showWechatPopup } from "./wechat-popup.js"; renderBrainUiApp(document.body); const THEME_KEY = "jarvis-brain-ui-theme"; const PHYSICS_STORAGE_KEY = "jarvis-brain-ui-physics"; const ACTIVATION_WARMUP_KEY = "bailongma_activation_warmup_until"; const UI_ZOOM_STORAGE_KEY = "bailongma_ui_zoom_factor"; const MAX_CHAT_HISTORY = 60; const DEFAULT_AGENT_NAME = "小白龙"; const DEFAULT_UI_ZOOM = 1.1; const MIN_UI_ZOOM = 0.8; const MAX_UI_ZOOM = 1.8; const UI_ZOOM_STEP = 0.1; const UI_ZOOM_WHEEL_STEP = 0.05; const MEMORY_GRAPH_STORAGE_KEY = "bailongma-memory-graph-enabled"; const MEMORY_GRAPH_ENABLED = localStorage.getItem(MEMORY_GRAPH_STORAGE_KEY) !== "false"; const themeSwitcher = document.getElementById("theme-switcher"); const resetViewBtn = document.getElementById("reset-view-btn"); const physicsControl = document.getElementById("physics-control"); const physicsToggle = document.getElementById("physics-toggle"); const gravitySlider = document.getElementById("gravity-slider"); const repulsionSlider = document.getElementById("repulsion-slider"); const nodeSizeSlider = document.getElementById("node-size-slider"); const gravityValue = document.getElementById("gravity-value"); const repulsionValue = document.getElementById("repulsion-value"); const nodeSizeValue = document.getElementById("node-size-value"); const brandNameEl = document.getElementById("agent-brand-name"); const graphEl = document.getElementById("graph"); const focusBlockEl = document.getElementById("focus-block"); const focusStackEl = document.getElementById("focus-stack"); const focusDepthEl = document.getElementById("focus-depth"); const IGNORED_VERSION_KEY = "bailongma_ignored_update_version"; const SUPPRESS_UPDATES_KEY = "bailongma_suppress_update_notifications"; let agentName = DEFAULT_AGENT_NAME; let currentUiZoom = DEFAULT_UI_ZOOM; let chat = null; // 由 initSettings() 内部赋值,供 chat.js 的斜杠命令打开设置面板 let openSettingsRef = null; function addMsg(...args) { return chat?.addMsg(...args); } function openChat(...args) { return chat?.openChat(...args); } function updateLastJarvisMsg(...args) { return chat?.updateLastJarvisMsg(...args); } function isTyping() { return chat?.isTyping() || false; } function defaultInputPlaceholder() { return `向 ${agentName} 发消息…`; } function clampZoomFactor(factor) { return Math.min(MAX_UI_ZOOM, Math.max(MIN_UI_ZOOM, Number(factor) || DEFAULT_UI_ZOOM)); } function saveUiZoom(factor) { try { localStorage.setItem(UI_ZOOM_STORAGE_KEY, String(factor)); } catch {} } function loadSavedUiZoom() { try { const raw = Number(localStorage.getItem(UI_ZOOM_STORAGE_KEY)); if (Number.isFinite(raw)) return clampZoomFactor(raw); } catch {} return DEFAULT_UI_ZOOM; } function applyUiZoom(factor, { persist = true } = {}) { const nextZoom = clampZoomFactor(factor); currentUiZoom = nextZoom; const bridge = window.bailongma; if (bridge?.isElectron && typeof bridge.setZoomFactor === "function") { bridge.setZoomFactor(nextZoom); } else { document.documentElement.style.zoom = String(nextZoom); } if (persist) saveUiZoom(nextZoom); } function stepUiZoom(delta) { const nextZoom = Math.round((currentUiZoom + delta) * 100) / 100; applyUiZoom(nextZoom); } function initUiZoom() { const bridge = window.bailongma; const initialZoom = loadSavedUiZoom(); if (!bridge?.isElectron) { applyUiZoom(initialZoom, { persist: false }); } else { try { const bridgeZoom = bridge.getZoomFactor?.(); if (typeof bridgeZoom === "number" && Number.isFinite(bridgeZoom)) { currentUiZoom = clampZoomFactor(bridgeZoom); } } catch {} applyUiZoom(initialZoom, { persist: false }); } window.addEventListener("wheel", (event) => { if (!event.ctrlKey && !event.metaKey) return; event.preventDefault(); stepUiZoom(event.deltaY < 0 ? UI_ZOOM_WHEEL_STEP : -UI_ZOOM_WHEEL_STEP); }, { passive: false, capture: true }); window.addEventListener("keydown", (event) => { if (!event.ctrlKey && !event.metaKey) return; const key = event.key; if (key === "+" || key === "=" || key === "Add") { event.preventDefault(); stepUiZoom(UI_ZOOM_STEP); return; } if (key === "-" || key === "_" || key === "Subtract") { event.preventDefault(); stepUiZoom(-UI_ZOOM_STEP); return; } if (key === "0") { event.preventDefault(); applyUiZoom(DEFAULT_UI_ZOOM); } }); } function setAgentName(nextName) { const normalized = String(nextName || "").trim() || DEFAULT_AGENT_NAME; agentName = normalized; document.title = `${normalized} · Cognitive Surface`; if (brandNameEl) brandNameEl.textContent = `${normalized} AI Agent`; if (graphEl) graphEl.setAttribute("aria-label", `${normalized} memory graph`); const input = document.getElementById("msg-input"); if (input && !chat?.isComposerLocked?.() && document.activeElement === input) input.placeholder = defaultInputPlaceholder(); document.querySelectorAll(".msg-jarvis .msg-label").forEach((el) => { el.textContent = normalized; }); } async function loadAgentProfile() { try { const res = await fetch(`${API}/agent-profile`); if (!res.ok) return; const data = await res.json(); setAgentName(data.name); } catch {} } const physicsSettings = { gravity: 1, repulsion: 1.35, nodeSize: 1, }; requestAnimationFrame(() => { themeSwitcher.classList.add("visible"); resetViewBtn.classList.add("visible"); physicsControl.classList.add("visible"); }); function readCSSVar(name) { return getComputedStyle(document.body).getPropertyValue(name).trim(); } function readPhysicsSettings() { try { const raw = localStorage.getItem(PHYSICS_STORAGE_KEY); if (!raw) return; const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { if (typeof parsed.gravity === "number") physicsSettings.gravity = parsed.gravity; if (typeof parsed.repulsion === "number") physicsSettings.repulsion = parsed.repulsion; if (typeof parsed.nodeSize === "number") physicsSettings.nodeSize = parsed.nodeSize; } } catch {} } function savePhysicsSettings() { try { localStorage.setItem(PHYSICS_STORAGE_KEY, JSON.stringify(physicsSettings)); } catch {} } function updatePhysicsReadout() { gravitySlider.value = String(physicsSettings.gravity); repulsionSlider.value = String(physicsSettings.repulsion); nodeSizeSlider.value = String(physicsSettings.nodeSize); gravityValue.textContent = `${physicsSettings.gravity.toFixed(2)}x`; repulsionValue.textContent = `${physicsSettings.repulsion.toFixed(2)}x`; nodeSizeValue.textContent = `${physicsSettings.nodeSize.toFixed(2)}x`; } let themeColors = {}; function refreshThemeColors() { themeColors = { cool: readCSSVar("--cool"), warm: readCSSVar("--warm"), nodeLow: readCSSVar("--node-low"), nodeHigh: readCSSVar("--node-high"), dim: readCSSVar("--dim"), ink2: readCSSVar("--ink2"), linkStroke: readCSSVar("--link-stroke"), bg0: readCSSVar("--bg0"), }; } function applyTheme(theme) { document.body.dataset.theme = theme; try { localStorage.setItem(THEME_KEY, theme); } catch {} document.querySelectorAll(".theme-dot").forEach(el => { el.classList.toggle("active", el.dataset.t === theme); }); setTimeout(() => { refreshThemeColors(); renderLegend(); if (MEMORY_GRAPH_ENABLED && nodeSel && !nodeSel.empty()) { refreshNodeVisuals(); linkSel.attr("stroke", themeColors.linkStroke); } }, 20); } (function initTheme() { let saved = "midnight"; try { saved = localStorage.getItem(THEME_KEY) || "midnight"; } catch {} applyTheme(saved); })(); themeSwitcher.querySelectorAll(".theme-dot").forEach(el => { el.addEventListener("click", () => applyTheme(el.dataset.t)); }); physicsToggle.addEventListener("click", () => { const nextOpen = !physicsControl.classList.contains("open"); physicsControl.classList.toggle("open", nextOpen); physicsToggle.setAttribute("aria-expanded", String(nextOpen)); }); gravitySlider.addEventListener("input", () => { physicsSettings.gravity = Number(gravitySlider.value); applyPhysicsSettings(); }); repulsionSlider.addEventListener("input", () => { physicsSettings.repulsion = Number(repulsionSlider.value); applyPhysicsSettings(); }); nodeSizeSlider.addEventListener("input", () => { physicsSettings.nodeSize = Number(nodeSizeSlider.value); applyPhysicsSettings(); }); let W = window.innerWidth; let H = window.innerHeight; const svg = d3.select("#graph").attr("width", W).attr("height", H); const tip = d3.select("#tip"); const defs = svg.append("defs"); defs.html(` `); const world = svg.append("g"); const gLink = world.append("g").attr("stroke-linecap", "round"); const gNode = world.append("g"); const zoom = d3.zoom() .scaleExtent([0.1, 5]) .filter(event => event.type === "wheel") .on("zoom", event => world.attr("transform", event.transform)); svg.call(zoom); svg.on("wheel.zoom", null); svg.on("dblclick.zoom", null); svg.node().addEventListener("wheel", event => { event.preventDefault(); const current = d3.zoomTransform(svg.node()); const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12; const nextScale = Math.max(0.1, Math.min(5, current.k * factor)); const k = nextScale / current.k; const px = W / 2, py = H / 2; const nextX = px - (px - current.x) * k; const nextY = py - (py - current.y) * k; svg.call(zoom.transform, d3.zoomIdentity.translate(nextX, nextY).scale(nextScale)); }, { passive: false }); function resetZoom() { svg.transition().duration(420).call( zoom.transform, d3.zoomIdentity ); } const glowSet = new Map(); const usePulseSet = new Map(); let linkData = []; let nodeData = []; let linkSel = gLink.selectAll("line"); let nodeSel = gNode.selectAll("circle"); const nodeCountEl = document.getElementById("node-count"); const linkCountEl = document.getElementById("link-count"); const connStateEl = document.getElementById("conn-state"); function updateStats() { nodeCountEl.textContent = String(nodeData.length); linkCountEl.textContent = String(linkData.length); } function setConnectionState(text, live = true) { connStateEl.innerHTML = live ? `${text}` : text; connStateEl.classList.toggle("live", live); } function isGlowing(nid) { const expiry = glowSet.get(nid); if (!expiry) return false; if (Date.now() > expiry) { glowSet.delete(nid); return false; } return true; } function highlightNodes(nids, duration = 2400) { if (!MEMORY_GRAPH_ENABLED || !sim) return; if (!nids || !nids.length) return; const now = Date.now(); const expiry = now + duration; nids.forEach(nid => { const key = String(nid); glowSet.set(key, expiry); usePulseSet.set(key, { start: now, end: expiry }); }); refreshNodeVisuals(); sim.alpha(Math.max(sim.alpha(), 2)).restart(); setTimeout(() => { nids.forEach(nid => { const key = String(nid); glowSet.delete(key); usePulseSet.delete(key); }); refreshNodeVisuals(); }, duration + 80); } function nodeUseProgress(nid) { const key = String(nid); const pulse = usePulseSet.get(key); if (!pulse) return 0; const now = Date.now(); if (now >= pulse.end) { usePulseSet.delete(key); return 0; } const total = Math.max(1, pulse.end - pulse.start); return 1 - ((now - pulse.start) / total); } function nodeStrength(d) { if (typeof d._strength !== "number") { const deg = Math.min(1, (d._deg || 0) / 12); d._strength = 0.35 + deg * 0.55; } return d._strength; } function nodeColor(d) { if (d._core) return themeColors.warm || "#d39872"; const age = (Date.now() - (d._ts || Date.now())) / 18000; const fade = Math.max(0.25, 1 - age); const t = 0.18 + nodeStrength(d) * 0.5 * fade; const interp = d3.interpolateRgb(themeColors.nodeLow || "#3a556e", themeColors.nodeHigh || "#cfe3f5"); let color = interp(Math.min(1, t)); const base = d3.color(color); if (base) color = base.darker(0.55) + ""; const useBoost = nodeUseProgress(d._nid); if (isGlowing(d._nid) || useBoost > 0) { const c = d3.color(color); if (c) return c.brighter(2 + useBoost * 2) + ""; } return color; } function nodeRadius(d) { const base = d._core ? 9 : 3.4 + Math.min((d._deg || 0) * 0.9, 5.4); const childScale = 1 + Math.min(1.5, (d._childCount || 0) * 0.18); const useBoost = nodeUseProgress(d._nid); const glowScale = isGlowing(d._nid) ? 1.08 : 1; const pulseScale = 1 + (Math.sin((1 - useBoost) * Math.PI * 3) * 0.04 + useBoost * 0.12); const scaledBase = base * physicsSettings.nodeSize; return Math.min(scaledBase * 2.5, scaledBase * childScale * glowScale * Math.max(1, pulseScale)); } const sim = MEMORY_GRAPH_ENABLED ? d3.forceSimulation() .force("link", d3.forceLink().id(d => d._nid)) .force("charge", d3.forceManyBody()) .force("center", d3.forceCenter(W / 2, H / 2 - 10)) .force("x", d3.forceX(W / 2)) .force("y", d3.forceY(H / 2 - 10)) .force("radial", d3.forceRadial(180, W / 2, H / 2 - 10)) .force("collision", d3.forceCollide()) .alphaDecay(0.028) .velocityDecay(0.3) .on("tick", tick) : null; function linkDistance(link) { const countFactor = Math.min(34, Math.sqrt(Math.max(1, nodeData.length)) * 4.2); if (link._kind === "visual_parent") return 82 + countFactor * 0.45; if (link._kind === "visual_random") return 108 + countFactor; return 76 + countFactor * 0.55; } function linkStrength(link) { if (link._kind === "visual_parent") return 0.2; if (link._kind === "visual_random") return 0.035; return 0.16; } function chargeStrength(node) { const countBoost = Math.min(76, Math.sqrt(Math.max(1, nodeData.length)) * 3.5); const baseCharge = -92 - countBoost * 0.4 - (node._deg || 0) * 2.4 - (node._childCount || 0) * 1.2; return baseCharge * physicsSettings.repulsion; } function radialStrength() { const baseSpread = nodeData.length > 36 ? 0.1 : 0.1; return baseSpread * physicsSettings.gravity; } function centerPullStrength() { const basePull = nodeData.length > 36 ? 0.04 : 0.055; return basePull * physicsSettings.gravity; } function collisionRadius(node) { const countPadding = nodeData.length > 36 ? 6 : 4; return nodeRadius(node) + countPadding; } function updateSimulationForces() { if (!MEMORY_GRAPH_ENABLED || !sim) return; sim.force("link") .distance(linkDistance) .strength(linkStrength); sim.force("charge") .strength(chargeStrength); sim.force("x") .x(W / 2) .strength(centerPullStrength()); sim.force("y") .y(H / 2 - 10) .strength(centerPullStrength()); sim.force("radial") .radius(Math.min(Math.max(24, Math.sqrt(Math.max(1, nodeData.length)) * 6), 64)) .x(W / 2) .y(H / 2 - 10) .strength(radialStrength()); sim.force("collision") .radius(collisionRadius) .strength(0.82) .iterations(nodeData.length > 40 ? 2 : 1); } function applyPhysicsSettings(restartAlpha = 2) { updatePhysicsReadout(); if (!MEMORY_GRAPH_ENABLED || !sim) { savePhysicsSettings(); return; } updateSimulationForces(); refreshNodeVisuals(); sim.alpha(Math.max(sim.alpha(), restartAlpha)).restart(); savePhysicsSettings(); } function refreshNodeVisuals() { if (!MEMORY_GRAPH_ENABLED) return; if (!nodeSel || nodeSel.empty()) return; nodeSel .attr("r", nodeRadius) .attr("fill", nodeColor) .attr("filter", d => (d._core || isGlowing(d._nid) || nodeUseProgress(d._nid) > 0) ? "url(#neb-glow)" : null) .style("animation", d => nodeUseProgress(d._nid) > 0 ? "neb-node-use 10s ease-out" : null); } function dampTangentialMotion() { if (!MEMORY_GRAPH_ENABLED || !sim) return; const cx = W / 2; const cy = H / 2 - 10; const twitching = sim.alpha() > 0.45; nodeData.forEach(node => { if (!node || node.fx != null || node.fy != null) return; const dx = (node.x ?? cx) - cx; const dy = (node.y ?? cy) - cy; const dist = Math.hypot(dx, dy); if (dist < 0.001) return; const rx = dx / dist; const ry = dy / dist; const tx = -ry; const ty = rx; const vx = node.vx || 0; const vy = node.vy || 0; const radialVelocity = vx * rx + vy * ry; const tangentialVelocity = vx * tx + vy * ty; const tangentialDamping = twitching ? 0.14 : 0.24; node.vx = radialVelocity * rx + tangentialVelocity * tangentialDamping * tx; node.vy = radialVelocity * ry + tangentialVelocity * tangentialDamping * ty; }); } function naturalTwitch() { if (!MEMORY_GRAPH_ENABLED || !sim) return; if (nodeData.length < 2) { sim.alpha(1).restart(); return; } const nodeById = new Map(nodeData.map(node => [String(node._nid), node])); const anchorMap = new Map(); linkData.forEach(link => { if (link._kind !== "visual_parent" && link._kind !== "visual_random") return; const sourceId = typeof link.source === "object" ? String(link.source._nid) : String(link.source); const targetId = typeof link.target === "object" ? String(link.target._nid) : String(link.target); if (!anchorMap.has(sourceId) && nodeById.has(targetId)) { anchorMap.set(sourceId, nodeById.get(targetId)); } }); const twitchCount = Math.max(6, Math.floor(nodeData.length * 0.3)); const candidates = shuffleArray(nodeData.filter(node => !node._core)).slice(0, twitchCount); candidates.forEach(node => { const anchor = anchorMap.get(String(node._nid)) || nodeData[deterministicIndex(node._nid, nodeData.length)]; if (!anchor) return; const anchorX = anchor.x ?? (W / 2); const anchorY = anchor.y ?? (H / 2 - 10); const angle = Math.random() * Math.PI * 2; const offset = 36 + Math.random() * 52; const nextX = anchorX + Math.cos(angle) * offset; const nextY = anchorY + Math.sin(angle) * offset; const currentX = node.x ?? nextX; const currentY = node.y ?? nextY; node.x = currentX * 0.7 + nextX * 0.3; node.y = currentY * 0.7 + nextY * 0.3; node.vx = (node.vx || 0) + (nextX - currentX) * 0.14; node.vy = (node.vy || 0) + (nextY - currentY) * 0.14; }); sim.alpha(0.85).restart(); } function tick() { if (!MEMORY_GRAPH_ENABLED) return; dampTangentialMotion(); linkSel .attr("x1", d => d.source.x) .attr("y1", d => d.source.y) .attr("x2", d => d.target.x) .attr("y2", d => d.target.y); nodeSel .attr("cx", d => d.x) .attr("cy", d => d.y); } function computeDegrees() { const nodeById = new Map(nodeData.map(n => [n._nid, n])); nodeData.forEach(n => { n._deg = 0; n._childCount = 0; }); linkData.forEach(l => { const s = typeof l.source === "object" ? l.source : nodeById.get(String(l.source)); const t = typeof l.target === "object" ? l.target : nodeById.get(String(l.target)); if (s) s._deg = (s._deg || 0) + 1; if (t) t._deg = (t._deg || 0) + 1; }); nodeData.forEach(node => { const childTargets = semanticChildTargets(node); if (childTargets.size) { node._childCount = childTargets.size; return; } const selfId = String(node._nid || ""); node._childCount = nodeData.reduce((count, candidate) => ( candidate.parent_id != null && String(candidate.parent_id) === selfId ? count + 1 : count ), 0); }); } function showTip(event, d) { const label = d.title || (d.content || "").slice(0, 120) || d._nid; const type = d._core ? "self" : (d.event_type || "memory"); tip .style("display", "block") .style("left", `${event.clientX + 14}px`) .style("top", `${event.clientY + 12}px`) .html(`${type}
${label}
`); } function parseEntities(raw) { try { const p = typeof raw === "string" ? JSON.parse(raw || "[]") : (raw || []); return Array.isArray(p) ? p : []; } catch { return []; } } function parseLinks(raw) { try { const parsed = typeof raw === "string" ? JSON.parse(raw || "[]") : (raw || []); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function semanticChildTargets(node) { const targets = new Set(); parseLinks(node.links).forEach(link => { if (!link || typeof link !== "object") return; const relation = String(link.relation || "").toLowerCase(); const targetId = String(link.target_id || link.targetId || "").trim(); if (relation === "parent_of" && targetId) targets.add(targetId); }); return targets; } function markCore() { nodeData.forEach(n => { n._core = false; }); const core = nodeData.find(n => parseEntities(n.entities).includes("agent:jarvis")) || nodeData[0]; if (core) core._core = true; } function renderLegend() { const el = document.getElementById("legend"); if (!el) return; const total = nodeData.length; const active = nodeData.filter(n => (Date.now() - (n._ts || 0)) < 15000).length; const known = Math.max(0, total - active - 1); const decayed = nodeData.filter(n => (Date.now() - (n._ts || 0)) > 60000).length; const items = [ { name: "Constraint", count: 1, color: themeColors.warm }, { name: "Memory", count: total, color: themeColors.nodeHigh }, { name: "Knowledge", count: known, color: themeColors.cool }, { name: "Decayed", count: decayed, color: themeColors.dim }, ]; el.innerHTML = items.map(i => `
${i.name} ${i.count}
` ).join(""); } function renderGraph(restartAlpha = 2) { if (!MEMORY_GRAPH_ENABLED || !sim) { updateStats(); renderLegend(); return; } computeDegrees(); markCore(); updateStats(); renderLegend(); linkSel = linkSel.data(linkData, d => d._lid); linkSel.exit().remove(); linkSel = linkSel.enter().append("line") .attr("stroke", themeColors.linkStroke || "rgba(143,182,216,0.18)") .attr("stroke-width", 0.6) .merge(linkSel); nodeSel = nodeSel.data(nodeData, d => d._nid); nodeSel.exit().transition().duration(280).attr("r", 0).remove(); const enter = nodeSel.enter().append("circle") .attr("r", 0) .attr("fill", nodeColor) .style("cursor", "pointer") .call(d3.drag() .on("start", (event, d) => { if (!event.active) sim.alphaTarget(2).restart(); d.fx = d.x; d.fy = d.y; }) .on("drag", (event, d) => { d.fx = event.x; d.fy = event.y; }) .on("end", (event, d) => { if (!event.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })) .on("mouseover", showTip) .on("mousemove", event => { tip.style("left", `${event.clientX + 14}px`) .style("top", `${event.clientY + 12}px`); }) .on("mouseout", () => tip.style("display", "none")) .on("click", (event, d) => { d._ts = Date.now(); d._strength = Math.min(1, (d._strength || 0.5) + 0.25); highlightNodes([d._nid], 900); }); enter.transition().duration(360).attr("r", nodeRadius); nodeSel = enter.merge(nodeSel); sim.nodes(nodeData); sim.force("link").links(linkData); updateSimulationForces(); sim.alpha(0.5).restart(); refreshNodeVisuals(); } function deterministicIndex(seed, mod) { let hash = 2166136261; const text = String(seed); for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); } return Math.abs(hash >>> 0) % mod; } function shuffleArray(items) { const copy = [...items]; for (let i = copy.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; } return copy; } function createVisualOrder(nodes) { const coreNode = nodes.find(n => n._core || parseEntities(n.entities).includes("agent:jarvis")) || null; const rest = shuffleArray(nodes.filter(n => !coreNode || n._nid !== coreNode._nid)); return coreNode ? [coreNode, ...rest] : rest; } function chooseVisualParent(child, candidates, childCounts) { if (!candidates.length) return null; const weighted = []; candidates.forEach(candidate => { const currentChildren = childCounts.get(candidate._nid) || 0; const maxChildren = maxVisualChildren(candidate); const recencyBias = Math.max(0, 400000 - Math.abs((child._ts || 0) - (candidate._ts || 0))) / 100000; const coreBias = candidate._core ? 1.4 : 0; const strengthBias = (candidate._strength || 0.4) * 0.8; const remainingCapacity = Math.max(0, maxChildren - currentChildren); const capacityBias = currentChildren === 0 ? 1.2 : 0.35 + remainingCapacity * 0.25; const entryCount = 1 + Math.max(0, Math.round((recencyBias + coreBias + strengthBias + capacityBias) * 2)); for (let w = 0; w < entryCount; w++) { weighted.push(candidate); } }); if (!weighted.length) return candidates[Math.floor(Math.random() * candidates.length)] || null; return weighted[Math.floor(Math.random() * weighted.length)] || null; } function getCurrentVisualChildCounts(nodes) { const counts = new Map(nodes.map(n => [n._nid, 0])); linkData.forEach(link => { if (link._kind !== "visual_parent") return; const parentId = typeof link.target === "object" ? String(link.target._nid) : String(link.target); counts.set(parentId, (counts.get(parentId) || 0) + 1); }); return counts; } function maxVisualChildren(node) { if (!node) return 2; if (node._core) return 4; const degree = node._deg || 0; const strength = node._strength || 0; return (degree >= 4 || strength >= 0.72) ? 4 : 2; } function addSupplementalVisualLinks(linkSet, childCounts) { const ordered = createVisualOrder(nodeData); const extraLinks = Math.min(18, Math.max(2, Math.floor(nodeData.length / 5))); let added = 0; for (let i = 1; i < ordered.length && added < extraLinks; i++) { const source = ordered[i]; const candidates = shuffleArray( ordered.slice(0, i).filter(node => { if (node._nid === source._nid) return false; return (childCounts.get(node._nid) || 0) < maxVisualChildren(node); }) ); const target = candidates[0]; if (!target) continue; const lid = `visual-extra:${source._nid}=>${target._nid}`; const rev = `visual-extra:${target._nid}=>${source._nid}`; const base = `visual:${source._nid}=>${target._nid}`; const baseRev = `visual:${target._nid}=>${source._nid}`; if (linkSet.has(lid) || linkSet.has(rev) || linkSet.has(base) || linkSet.has(baseRev)) continue; linkSet.add(lid); linkData.push({ source: source._nid, target: target._nid, _lid: lid, _kind: "visual_random" }); childCounts.set(target._nid, (childCounts.get(target._nid) || 0) + 1); added += 1; } } function addRandomVisualLinks(linkSet) { if (nodeData.length < 2) return; const ordered = createVisualOrder(nodeData); const childCounts = new Map(ordered.map(n => [n._nid, 0])); for (let i = 1; i < ordered.length; i++) { const child = ordered[i]; const candidates = ordered .slice(0, i) .filter(node => (childCounts.get(node._nid) || 0) < maxVisualChildren(node)); const parent = chooseVisualParent(child, candidates, childCounts); if (!parent || parent._nid === child._nid) continue; const lid = `visual:${child._nid}=>${parent._nid}`; const rev = `visual:${parent._nid}=>${child._nid}`; if (linkSet.has(lid) || linkSet.has(rev)) continue; linkSet.add(lid); linkData.push({ source: child._nid, target: parent._nid, _lid: lid, _kind: "visual_parent" }); childCounts.set(parent._nid, (childCounts.get(parent._nid) || 0) + 1); } addSupplementalVisualLinks(linkSet, childCounts); } function findAnchorNode(memory, nodeMap) { const nodes = Array.from(nodeMap.values()); const childCounts = getCurrentVisualChildCounts(nodes); const candidates = createVisualOrder(nodes) .filter(node => (childCounts.get(node._nid) || 0) < maxVisualChildren(node)); return chooseVisualParent(memory, candidates, childCounts) || nodeData.find(n => n._core) || nodeData[0] || null; } async function loadMemories() { if (!MEMORY_GRAPH_ENABLED) return; try { const rows = await fetch(`${API}/memories?limit=120`).then(r => r.json()); if (!Array.isArray(rows)) return; const prevPositions = new Map(nodeData.map(n => [n._nid, { x: n.x, y: n.y, vx: n.vx, vy: n.vy, fx: n.fx, fy: n.fy, }])); nodeData = rows.map(row => { const nid = row.mem_id || String(row.id); const prev = prevPositions.get(nid); return { ...row, _nid: nid, _ts: prev ? Date.now() : Date.now() - Math.random() * 8000, x: prev ? prev.x : W / 2 + (Math.random() - 0.5) * 180, y: prev ? prev.y : H / 2 + (Math.random() - 0.5) * 180, vx: prev ? prev.vx : 0, vy: prev ? prev.vy : 0, fx: prev ? prev.fx : null, fy: prev ? prev.fy : null, }; }); const linkSet = new Set(); linkData = []; addRandomVisualLinks(linkSet); renderGraph(1.1); } catch (error) { console.warn("[graph] load failed:", error.message); setConnectionState("未连接", false); } } function addNewNodes(memories) { if (!MEMORY_GRAPH_ENABLED) return; const nodeMap = new Map(nodeData.map(n => [n._nid, n])); const newNids = []; memories.forEach(memory => { const nid = memory.mem_id || memory.id; if (!nid || nodeMap.has(String(nid))) return; const anchor = findAnchorNode(memory, nodeMap); const anchorX = anchor?.x ?? W / 2; const anchorY = anchor?.y ?? (H / 2 - 10); const node = { ...memory, _nid: String(nid), mem_id: String(nid), event_type: memory.event_type || memory.type || "fact", _ts: Date.now(), _strength: 0.85, x: anchorX + (Math.random() - 0.5) * 72, y: anchorY + (Math.random() - 0.5) * 72, vx: 0, vy: 0, }; nodeData.push(node); nodeMap.set(node._nid, node); newNids.push(node._nid); }); if (!newNids.length) return; const linkSet = new Set(); linkData = []; addRandomVisualLinks(linkSet); renderGraph(2); highlightNodes(newNids, 10000); } if (MEMORY_GRAPH_ENABLED) { setInterval(() => naturalTwitch(), 6000); setInterval(() => { nodeData.forEach(n => { if (n._strength) n._strength *= 0.97; }); }, 2500); } function parseUserMessageInput(raw) { const text = String(raw || ""); const match = text.match(/^\[([^\]]+)\]\s+(\S+)\s+\[([^\]]+)\]\s+([\s\S]*)$/); if (!match) return { content: text.trim(), time: null }; return { fromId: match[1], timestamp: match[2], channel: match[3], content: match[4].trim(), time: formatMsgTime(match[2]) }; } function formatMsgTime(stamp) { if (!stamp) return null; const m = String(stamp).match(/T(\d{2}):(\d{2}):(\d{2})/); if (m) return `${m[1]}:${m[2]}:${m[3]}`; const m2 = String(stamp).match(/(\d{2}):(\d{2}):(\d{2})/); if (m2) return `${m2[1]}:${m2[2]}:${m2[3]}`; return null; } const L1 = new ThoughtStream("si-l1", "cool", { readCSSVar, thinkingLabel: "思考中…", thinkingDoneLabel: "思考完成", toolDetailLength: 140, }); const L2 = new ThoughtStream("si-l2", "warm", { readCSSVar, thinkingLabel: "思考中", thinkingDoneLabel: "思考完成", toolDetailLength: 220, }); // L1 = processing flow triggered by user messages; L2 = processing flow triggered by TICK. // stream_*/tool_call events emitted by the backend carry no path tag; // routing to the correct panel is determined by the most recent message_received / tick event. let currentPath = "l2"; function currentStream() { return currentPath === "l1" ? L1 : L2; } function isBusyErrorMessage(message = "") { return /(429|rate limit|too many requests|busy|overload|temporarily unavailable|server busy|resource exhausted)/i.test(String(message || "")); } function formatRetryDelay(ms) { if (!ms || ms < 1000) return `${ms || 0}ms`; return `${(ms / 1000).toFixed(ms % 1000 === 0 ? 0 : 1)}s`; } let tokenAccum = 0; let tokenWindow = Date.now(); const tokRateEl = document.getElementById("tok-rate"); // 记忆系统观测(Memory-Optimization v0.1 Phase 0):每 60s 拉一次近 1 小时的 audit stats。 // 显示"N 次(平均 K 条)"——次数代表系统活跃度,平均条数代表召回/抽取的健康度。 // 0 命中数会让数字变橙提醒(命中率低 = 可能有召回漏);纯网络/服务失败保持 — 不告警。 const memRecallEl = document.getElementById("mem-recall-rate"); const memExtractEl = document.getElementById("mem-extract-rate"); // ── AI 当前正在做什么:派生展示 ──────────────────────────────── // 北极星([[feedback-ai-be-itself]]):通信问题靠界面侧派生可视化解决,不逼 AI 学人开口。 // 工作方式:纯被动接收 tool_call 事件流,按工具名归类统计最近 60s 活动,自动推导当前活动标签。 // AI 完全不需要为此多做任何动作;它只管干活,UI 自己把"在干什么"翻译给用户看。 const AI_ACTIVITY_WINDOW_MS = 60_000; const AI_ACTIVITY_IDLE_AFTER_MS = 15_000; const AI_TOOL_GROUPS = { "扫描文件": new Set(["read_file", "list_dir"]), "改动文件": new Set(["write_file", "make_dir", "delete_file"]), "执行命令": new Set(["exec_command", "kill_process", "list_processes"]), "上网": new Set(["fetch_url", "web_search", "browser_read"]), "调取记忆": new Set(["search_memory", "recall_memory", "probe_memory", "upsert_memory", "merge_memories", "downgrade_memory"]), "推送界面": new Set(["ui_show", "ui_update", "ui_hide", "ui_patch", "ui_register", "focus_banner"]), "处理多媒体": new Set(["speak", "generate_lyrics", "generate_music", "generate_image", "music", "media_mode"]), "回复用户": new Set(["send_message", "express"]), }; const aiActivityLog = []; let aiActivityFirstTs = 0; let aiActivityTimer = null; const aiActivityEl = document.getElementById("ai-activity"); const aiActivityLabelEl = document.getElementById("ai-activity-label"); const aiActivityDetailEl = document.getElementById("ai-activity-detail"); function classifyTool(name) { for (const [label, set] of Object.entries(AI_TOOL_GROUPS)) { if (set.has(name)) return label; } return "处理事务"; } function recordAiActivity(name) { if (!name) return; const now = Date.now(); if (aiActivityLog.length === 0) aiActivityFirstTs = now; aiActivityLog.push({ name, ts: now, group: classifyTool(name) }); refreshAiActivity(); } function refreshAiActivity() { if (!aiActivityEl) return; const now = Date.now(); while (aiActivityLog.length && now - aiActivityLog[0].ts > AI_ACTIVITY_WINDOW_MS) { aiActivityLog.shift(); } if (aiActivityLog.length === 0) { aiActivityEl.dataset.state = "idle"; aiActivityLabelEl.textContent = "空闲"; aiActivityDetailEl.textContent = ""; aiActivityFirstTs = 0; return; } const lastTs = aiActivityLog[aiActivityLog.length - 1].ts; if (now - lastTs > AI_ACTIVITY_IDLE_AFTER_MS) { aiActivityEl.dataset.state = "idle"; aiActivityLabelEl.textContent = "刚完成"; const ago = Math.round((now - lastTs) / 1000); aiActivityDetailEl.textContent = `${ago}s 前停止`; return; } const counts = {}; for (const e of aiActivityLog) counts[e.group] = (counts[e.group] || 0) + 1; let domGroup = "处理事务"; let domCount = 0; for (const [g, c] of Object.entries(counts)) { if (c > domCount) { domCount = c; domGroup = g; } } aiActivityEl.dataset.state = "busy"; aiActivityLabelEl.textContent = `正在${domGroup}`; const elapsed = Math.round((now - (aiActivityFirstTs || lastTs)) / 1000); aiActivityDetailEl.textContent = `· ${aiActivityLog.length} 次工具 · ${elapsed}s`; } if (aiActivityEl) { aiActivityEl.dataset.state = "idle"; aiActivityTimer = setInterval(refreshAiActivity, 1000); } async function refreshMemoryAuditStats() { if (!memRecallEl || !memExtractEl) return; try { const res = await fetch("/audit/stats?hours=1", { cache: "no-store" }); if (!res.ok) return; const data = await res.json(); const r = data?.recall || {}; const e = data?.extract || {}; const rTotal = Number(r.total || 0); const rAvg = Number(r.avg_chosen || 0); const rZero = Number(r.zero_match_count || 0); const eTotal = Number(e.total || 0); const eAvg = Number(e.avg_extracted || 0); const eSkip = Number(e.skipped_count || 0); memRecallEl.textContent = rTotal ? `${rTotal}·${rAvg.toFixed(1)}` : "0"; memExtractEl.textContent = eTotal ? `${eTotal}·${eAvg.toFixed(1)}` : "0"; memRecallEl.style.color = (rTotal > 0 && rZero / rTotal > 0.2) ? "var(--warn, #e8a23a)" : ""; memExtractEl.style.color = (eTotal > 0 && eSkip / eTotal > 0.5) ? "var(--warn, #e8a23a)" : ""; } catch { // 静默:dev/build 早期 audit 表可能还没数据,保持 — 即可 } } refreshMemoryAuditStats(); setInterval(refreshMemoryAuditStats, 60_000); function bumpTokens(text) { tokenAccum += (text || "").length / 3.4; const now = Date.now(); if (now - tokenWindow > 700) { const rate = tokenAccum / ((now - tokenWindow) / 1000); tokRateEl.textContent = rate.toFixed(1); tokenAccum = 0; tokenWindow = now; setTimeout(() => { if (tokRateEl.textContent !== "—" && tokenAccum === 0) tokRateEl.textContent = "—"; }, 4000); } } // ── 专注帧观察面板 (focus stack) ──────────────────────────────── // 设计文档 7.5:用户必须看得见 Agent 此刻在专注什么。 // 纯事件驱动:focus_frame → 全量重渲染;focus_compressed → 在栈顶尾部追加 conclusion 并淡入。 function escapeFocusText(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">"); } function truncateConclusion(text, max = 60) { const s = String(text || "").trim(); if (s.length <= max) return s; return s.slice(0, max - 1).trim() + "…"; } function renderFocusFrame(frame, { isTop }) { const conclusions = Array.isArray(frame?.conclusions) ? frame.conclusions : []; // 主行显示策略(progressive disclosure): // 1. 有 conclusion → 显示最新一条(这是子帧 pop 时压缩出的 1-2 句话结论) // 2. 无 conclusion 但有 topic → 显示 topic(v0 是 ngram,几百 ms 后被 LLM refine 成人类可读短语) // 3. 都没 → 返回空主行(上层 renderFocusStack 会进一步过滤) // 早期 conclusion 作为弱化辅助行(栈顶帧才显示,避免视觉过载) const latest = conclusions.length > 0 ? conclusions[conclusions.length - 1] : ""; const earlier = conclusions.length > 1 ? conclusions.slice(0, -1) : []; const topicSummary = Array.isArray(frame?.topic) && frame.topic.length > 0 ? frame.topic.slice(0, 3).join(" · ") : ""; let mainHTML = ""; if (latest) { mainHTML = `
${escapeFocusText(truncateConclusion(latest, isTop ? 120 : 80))}
`; } else if (topicSummary) { mainHTML = `
${escapeFocusText(truncateConclusion(topicSummary, isTop ? 60 : 40))}
`; } const earlierHTML = earlier.map((c) => `
${escapeFocusText(truncateConclusion(c, isTop ? 100 : 60))}
` ).join(""); // 该帧既无 conclusion 也无 topic(极短暂的"刚 push 还没赋 topic"状态),不渲染外层壳 if (!mainHTML && !earlierHTML) return ""; return ( `
` + mainHTML + earlierHTML + `
` ); } function renderFocusStack(stack) { if (!focusStackEl || !focusBlockEl) return; const list = Array.isArray(stack) ? stack : []; if (focusDepthEl) focusDepthEl.textContent = String(list.length); if (list.length === 0) { focusBlockEl.dataset.state = "empty"; focusStackEl.innerHTML = `
无专注
`; return; } focusBlockEl.dataset.state = "active"; // 渲染策略:只渲染"有 conclusion 的帧 + 栈顶帧"。 // 非栈顶 + 无 conclusion 的帧静默隐藏——这种帧是"已 push 但还没 pop"的活帧, // conclusion 永远空着,渲染出来只是占位文字("…"),堆叠多了视觉很噪。 // depth 数字仍然显示真实栈深度,让用户知道还有未压缩的帧挂着。 // 栈底 → 栈顶;视觉上栈顶在最下(最近一次最强),跟终端 / 思考流方向一致。 const html = list.map((frame, i) => { const isTop = i === list.length - 1; const hasConclusion = Array.isArray(frame?.conclusions) && frame.conclusions.length > 0; if (!isTop && !hasConclusion) return ""; return renderFocusFrame(frame, { isTop }); }).filter(Boolean).join(""); focusStackEl.innerHTML = html; } function flashFocusCompressed() { if (!focusBlockEl) return; // 让栈顶帧的主行(最新 conclusion)走淡入动画;同时整块做一次柔和高光。 focusBlockEl.classList.remove("focus-compress-pulse"); // 强制 reflow 让动画重启 void focusBlockEl.offsetWidth; focusBlockEl.classList.add("focus-compress-pulse"); const topFrame = focusStackEl?.querySelector(".focus-frame.top"); const mainEl = topFrame?.querySelector(".focus-frame-main"); if (mainEl) { mainEl.classList.remove("just-added"); void mainEl.offsetWidth; mainEl.classList.add("just-added"); } } function connectSSE() { setConnectionState("连接中", true); const es = new EventSource(`${API}/events`); es.onopen = () => setConnectionState("已连接", true); es.onmessage = event => { try { handle(JSON.parse(event.data)); } catch (_) {} }; es.onerror = () => { setConnectionState("重连中", false); es.close(); setTimeout(connectSSE, 3000); }; } function extractNids(memList) { return (memList || []) .map(m => m.mem_id || (m.id != null ? String(m.id) : null)) .filter(Boolean); } function handle({ type, data = {} }) { switch (type) { case "message_received": { currentPath = "l1"; L1.beginRound(); const parsed = parseUserMessageInput(data.input); L1.newLine("user message received", { content: parsed.content, time: parsed.time || undefined, }); // Immediately show a "thinking" indicator so the gap between message_received // and the first stream_start (injector + LLM TTFT, often 3–30s) doesn't look frozen. L1.startThinkingSession(); break; } case "tick": currentPath = "l2"; L2.beginRound(); L2.newLine("heartbeat tick"); L2.startThinkingSession(); break; case "stream_start": currentStream().startThinkingSession(); break; case "stream_chunk": // No longer rendering thought content — only drives the token-rate indicator currentStream().clearStatus(); bumpTokens(data.text); break; case "stream_end": currentStream().stopThinking(); break; case "tool_preparing": { // 思考动画已停,但工具尚未真正执行 —— 给一个占位状态避免 UI 死寂 const stream = currentStream(); const label = data.name ? stream.toolLabel(data.name) : ""; stream.setStatus(label ? `准备调用 ${label}…` : "准备工具调用…", "busy"); break; } case "tool_executing": { const stream = currentStream(); const label = data.name ? stream.toolLabel(data.name) : "工具"; stream.setTimedStatus(`正在执行 ${label}…`, "busy", { staleAfterMs: 45000, staleText: `执行 ${label} 时间偏长,仍在等结果…`, }); break; } case "tool_call": currentStream().tool(data.name, data.args, data.result, data.ok); recordAiActivity(data.name); break; case "response": // Round complete — stop all animations currentStream().end(); break; case "processing_preempted": currentStream().end(); break; case "llm_retry": { currentStream().startThinkingSession(); const nextAttempt = Number(data.nextAttempt || 2); const delayText = formatRetryDelay(Number(data.delayMs || 0)); currentStream().setStatus("LLM 繁忙,第 " + nextAttempt + " 次重试将于 " + delayText + " 后开始", "busy"); break; } case "message_requeued": { currentStream().startThinkingSession(); const retryCount = Number(data.retryCount || 1); currentStream().setStatus("LLM 繁忙,已入队重试 " + retryCount + "/3", "busy"); break; } case "message_dropped": currentStream().startThinkingSession(); currentStream().setStatus("LLM 繁忙,重试次数已达上限", "failed"); break; case "error": if (isBusyErrorMessage(data.error)) { currentStream().startThinkingSession(); currentStream().setStatus("LLM 繁忙,请稍后重试", "busy"); } else { currentStream().stopThinking(); currentStream().setStatus(data.error || "处理失败", "failed"); } break; case "protocol_violation": currentStream().end(); break; case "injector_result": { const nids = [...extractNids(data.matchedMemories), ...extractNids(data.recallMemories)]; if (nids.length) highlightNodes(nids, 10000); break; } case "focus_frame": { renderFocusStack(data.focusStack); break; } case "focus_compressed": { // 后端 emit 顺序:先 focus_frame(栈已 pop 完)→ 异步压缩完再 focus_compressed。 // 触发时栈顶帧的 conclusions 数组在后端已被追加,但前端 DOM 里还是旧的。 // 新布局:把新 conclusion 写入「主行」(.focus-frame-main); // 若主行原本是 fallback(暂无沉淀结论),就把它升级为正常主行。 // 若主行已有旧 conclusion,把旧值降级追加到「早期 conclusion」列表里,再覆盖主行。 // 下一次 focus_frame 事件会带最新 conclusions 全量覆盖,所以即使错位也很快收敛。 const topFrame = focusStackEl?.querySelector(".focus-frame.top"); if (topFrame && data.conclusion) { const mainEl = topFrame.querySelector(".focus-frame-main"); const newText = truncateConclusion(data.conclusion, 120); if (mainEl) { const wasFallback = mainEl.classList.contains("focus-frame-main-fallback"); if (!wasFallback && mainEl.textContent) { const earlier = document.createElement("div"); earlier.className = "focus-frame-conclusion focus-frame-conclusion-earlier"; earlier.textContent = mainEl.textContent; topFrame.appendChild(earlier); } mainEl.classList.remove("focus-frame-main-fallback"); mainEl.innerHTML = ""; mainEl.textContent = newText; } } flashFocusCompressed(); break; } case "memories_written": if (Array.isArray(data.memories) && data.memories.length) { addNewNodes(data.memories); } break; case "message": if (data.from === "consciousness") { lastJarvisContent = data.content; const viaLabel = friendlyChannelLabel(data.channel); const content = viaLabel ? `_→ ${viaLabel}_ \n${data.content}` : data.content; addMsg("jarvis", content); enrichVisiblePersonCardFromText(data.content, { source: 'assistant_message' }); openChat(true); } break; case "message_in": { // 外部渠道判定:channel 非空且非本地,或 from_id 仍带外部前缀(兼容连接器直接 emit 的事件) const ch = String(data.channel || "").toUpperCase(); const isExternal = (ch && ch !== "TUI" && ch !== "API" && ch !== "SYSTEM" && ch !== "REMINDER" && ch !== "APP_SIGNAL" && ch !== "VOICE" && ch !== "语音识别") || (data.from_id && /^(wechat|discord|feishu|wecom):/i.test(data.from_id)); if (isExternal) { const label = friendlyChannelLabel(data.channel) || data.from_id || "External"; addMsg("external", data.content, { label, alert: false }); openChat(true); } break; } case "agent_name_updated": setAgentName(data.name); break; case "media_mode": window.dispatchEvent(new CustomEvent("bailongma:media", { detail: data })); break; case "aivideo_mode": window.dispatchEvent(new CustomEvent("bailongma:aivideo", { detail: data })); break; case "hotspot_mode": setHotspotMode(!!data.active || data.action === "show" || data.action === "open", { source: "agent_event" }); break; case "doc_panel_mode": setDocPanelMode(!!data.active || data.action === "open", { topicId: data.topic || null, source: "agent_event" }); break; case "person_card_mode": setPersonCardMode(!!data.active || data.action === "show" || data.action === "open" || data.action === "update", { source: "agent_event", card: data.card || null }); break; case "social_status": window.dispatchEvent(new CustomEvent("bailongma:social_status", { detail: data })); break; case "show_wechat_popup": showWechatPopup(); break; case "audio_created": if (data.autoPlay && data.path) { const audioUrl = `${API}/${data.path}`; const audioEl = new Audio(audioUrl); audioEl.play().catch(() => {}); } break; case "tts_reply": if (data.text) playTTSReply(data.text); break; case "key_configured": chat.deleteLastUserMsg(); if (data.service === 'tts' && data.ttsText) playTTSReply(data.ttsText); break; case "startup_self_check_started": playJarvisStartupSound(); setTimeout(() => playTTSReply("系统启动中,正在运行自检"), 1500); break; default: break; } } // ── Jarvis-style startup self-check sound ──────────────────────────────────── function playJarvisStartupSound() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); if (ctx.state === "suspended") ctx.resume(); const t = ctx.currentTime; // Layer 1: low-frequency mechanical hum (sawtooth, simulates power-on) const drone = ctx.createOscillator(); const droneGain = ctx.createGain(); const droneFilter = ctx.createBiquadFilter(); drone.type = "sawtooth"; drone.frequency.setValueAtTime(50, t); drone.frequency.linearRampToValueAtTime(90, t + 0.5); droneFilter.type = "lowpass"; droneFilter.frequency.value = 350; droneFilter.Q.value = 3; droneGain.gain.setValueAtTime(0, t); droneGain.gain.linearRampToValueAtTime(0.09, t + 0.06); droneGain.gain.linearRampToValueAtTime(0.06, t + 0.4); droneGain.gain.linearRampToValueAtTime(0, t + 0.65); drone.connect(droneFilter); droneFilter.connect(droneGain); droneGain.connect(ctx.destination); drone.start(t); drone.stop(t + 0.7); // Layer 2: system-online frequency sweep (sine, low to high) const sweep = ctx.createOscillator(); const sweepGain = ctx.createGain(); sweep.type = "sine"; sweep.frequency.setValueAtTime(280, t + 0.12); sweep.frequency.exponentialRampToValueAtTime(2800, t + 1.0); sweepGain.gain.setValueAtTime(0, t + 0.12); sweepGain.gain.linearRampToValueAtTime(0.13, t + 0.22); sweepGain.gain.exponentialRampToValueAtTime(0.001, t + 1.05); sweep.connect(sweepGain); sweepGain.connect(ctx.destination); sweep.start(t + 0.12); sweep.stop(t + 1.1); // Layer 3: three confirmation beeps (square wave, self-check passed) [[880, 1.15], [1100, 1.28], [1320, 1.41]].forEach(([freq, bt]) => { const beep = ctx.createOscillator(); const beepGain = ctx.createGain(); const beepFilter = ctx.createBiquadFilter(); beep.type = "square"; beep.frequency.value = freq; beepFilter.type = "bandpass"; beepFilter.frequency.value = freq; beepFilter.Q.value = 8; beepGain.gain.setValueAtTime(0.14, t + bt); beepGain.gain.exponentialRampToValueAtTime(0.001, t + bt + 0.075); beep.connect(beepFilter); beepFilter.connect(beepGain); beepGain.connect(ctx.destination); beep.start(t + bt); beep.stop(t + bt + 0.09); }); setTimeout(() => ctx.close().catch(() => {}), 2500); } catch (_) { // silently ignore if browser does not support AudioContext } } // ── TTS reply playback ──────────────────────────────────────────────────────── let ttsAudioEl = null; let ttsCurrentText = ''; let ttsInterruptedRemaining = ''; let lastJarvisContent = ''; let ttsInterruptedOriginalContent = ''; let ttsInterruptionApplied = false; let ttsInterruptionDbTimer = null; // Estimate spoken char count from audio progress, snapping to a sentence boundary function calcRemainingText(text, currentTime, duration) { if (!text || !duration || duration <= 0) return { remaining: '', spokenUpTo: 0 }; const progress = Math.min(1, currentTime / duration); const spokenChars = Math.floor(text.length * progress); const BOUNDARIES = /[。!?,.!?,\n]/g; let bestPos = spokenChars; let match; BOUNDARIES.lastIndex = Math.max(0, spokenChars - 10); while ((match = BOUNDARIES.exec(text)) !== null) { if (match.index >= spokenChars) { bestPos = match.index + 1; break; } } return { remaining: text.slice(bestPos).trim(), spokenUpTo: bestPos }; } // Estimate cut position in original markdown based on spoken ratio in TTS plain text function findMarkdownCutPos(markdown, ttsFullLen, ttsSpokenUpTo) { if (!markdown || ttsFullLen <= 0) return 0; const ratio = ttsSpokenUpTo / ttsFullLen; const approxPos = Math.floor(markdown.length * ratio); const BOUNDARIES = /[。!?\n.!?]/g; let bestPos = approxPos; BOUNDARIES.lastIndex = Math.max(0, approxPos - 15); let match; while ((match = BOUNDARIES.exec(markdown)) !== null) { if (match.index >= approxPos) { bestPos = match.index + 1; break; } } return bestPos; } // Apply interruption marker to chat UI; delay DB write so false triggers can be undone function applyTTSInterruption(spokenUpTo) { const originalContent = lastJarvisContent || ttsCurrentText; if (!originalContent) return; ttsInterruptedOriginalContent = originalContent; ttsInterruptionApplied = true; const cutPos = findMarkdownCutPos(originalContent, ttsCurrentText.length, spokenUpTo); const spokenMarkdown = originalContent.slice(0, cutPos).trimEnd(); const displayText = spokenMarkdown ? spokenMarkdown + ' ✋' : '✋'; const dbContent = spokenMarkdown || '✋'; updateLastJarvisMsg(displayText); if (ttsInterruptionDbTimer) clearTimeout(ttsInterruptionDbTimer); ttsInterruptionDbTimer = setTimeout(() => { ttsInterruptionDbTimer = null; fetch(`${API}/tts/interrupted`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spokenContent: dbContent }), }).catch(() => {}); }, 4000); } // Called by voice-panel interruption detection: stop current TTS and record cut point window.stopTTS = () => { if (!ttsAudioEl) return; const { remaining, spokenUpTo } = calcRemainingText( ttsCurrentText, ttsAudioEl.currentTime, ttsAudioEl.duration, ); // When duration is not yet loaded (NaN): spokenUpTo=0, remaining='', falls back to full text ttsInterruptedRemaining = remaining || ttsCurrentText; applyTTSInterruption(spokenUpTo); ttsAudioEl.pause(); try { URL.revokeObjectURL(ttsAudioEl.src); } catch {} ttsAudioEl = null; }; // Called by voice-panel on impact noise: duck TTS volume without stopping window.duckTTS = () => { if (ttsAudioEl) ttsAudioEl.volume = 0.15; }; // Called by voice-panel after confirming noise: restore original volume window.unduckTTS = () => { if (ttsAudioEl) ttsAudioEl.volume = 1.0; }; // Called by voice-panel on false-positive noise: resume TTS from interruption point and restore chat window.resumeTTSIfNoSpeech = () => { const text = ttsInterruptedRemaining; ttsInterruptedRemaining = ''; if (!text) return; // Cancel the pending DB write and restore chat UI if (ttsInterruptionDbTimer) { clearTimeout(ttsInterruptionDbTimer); ttsInterruptionDbTimer = null; } if (ttsInterruptionApplied && ttsInterruptedOriginalContent) { updateLastJarvisMsg(ttsInterruptedOriginalContent); } ttsInterruptionApplied = false; ttsInterruptedOriginalContent = ''; playTTSReply(text); }; async function playTTSReply(text) { ttsCurrentText = text; ttsInterruptedRemaining = ''; ttsInterruptionApplied = false; ttsInterruptedOriginalContent = ''; try { const resp = await fetch(`${API}/tts/stream`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text }), }); if (!resp.ok) { let errMsg = `HTTP ${resp.status}`; try { const j = await resp.json(); errMsg = j.error || errMsg; } catch {} throw new Error(errMsg); } const blob = await resp.blob(); const url = URL.createObjectURL(blob); if (ttsAudioEl) { ttsAudioEl.pause(); URL.revokeObjectURL(ttsAudioEl.src); } ttsAudioEl = new Audio(url); ttsAudioEl.volume = 1.0; // ensure full volume (avoid residual duck state from previous play) // Suspend cloud ASR but keep the mic hardware open for interruption detection window.bailongmaVoice?.suspendForTTS?.(); ttsAudioEl.onended = () => { URL.revokeObjectURL(url); ttsAudioEl = null; ttsCurrentText = ''; window.bailongmaVoice?.resumeAfterMedia(); }; ttsAudioEl.onerror = () => { ttsAudioEl = null; ttsCurrentText = ''; window.bailongmaVoice?.resumeAfterMedia(); }; ttsAudioEl.play().catch(() => { window.bailongmaVoice?.resumeAfterMedia(); }); } catch { ttsCurrentText = ''; window.bailongmaVoice?.resumeAfterMedia(); } } resetViewBtn.addEventListener("click", resetZoom); document.querySelectorAll(".panel, .console, .theme-switcher, .reset-view").forEach(el => { el.addEventListener("wheel", event => event.stopPropagation(), { passive: true }); }); physicsControl.addEventListener("wheel", event => event.stopPropagation(), { passive: true }); window.addEventListener("resize", () => { W = window.innerWidth; H = window.innerHeight; svg.attr("width", W).attr("height", H); if (!MEMORY_GRAPH_ENABLED || !sim) return; sim.force("center", d3.forceCenter(W / 2, H / 2 - 10)) .force("x", d3.forceX(W / 2)) .force("y", d3.forceY(H / 2 - 10)) .force("radial", d3.forceRadial(180, W / 2, H / 2 - 10)); updateSimulationForces(); sim.alpha(5).restart(); }); let _lastVisualRefresh = 0; d3.timer(() => { if (!MEMORY_GRAPH_ENABLED) return true; if (glowSet.size === 0 && usePulseSet.size === 0) return; const now = Date.now(); if (now - _lastVisualRefresh < 48) return; _lastVisualRefresh = now; refreshNodeVisuals(); }); function extractPersonCardQuery(text = "") { const value = String(text || "").trim(); if (!value || /热点|热搜/.test(value)) return ""; const patterns = [ /^谁是\s*([\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9·.\-\s]{1,40})[??]?$/, /^([\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9·.\-\s]{1,40})\s*(?:是谁|是誰|是什么人|是什麼人|是干嘛的|简介|介绍|资料|履历)[??]?$/, /^(?:介绍一下|介绍下|查一下|了解一下|认识一下)\s*([\u4e00-\u9fa5A-Za-z][\u4e00-\u9fa5A-Za-z0-9·.\-\s]{1,40})[??]?$/, ]; for (const pattern of patterns) { const match = value.match(pattern); const name = match?.[1]?.trim(); if (name) return name.replace(/[,,。.!!::;;]+$/g, "").trim(); } return ""; } setAgentName(DEFAULT_AGENT_NAME); initUiZoom(); readPhysicsSettings(); updatePhysicsReadout(); refreshThemeColors(); chat = initChat({ apiBase: API, maxHistory: MAX_CHAT_HISTORY, activationWarmupKey: ACTIVATION_WARMUP_KEY, getAgentName: () => agentName, defaultInputPlaceholder, openSettings: (tab) => openSettingsRef?.(tab), onUserMessage: (text) => { if (document.body.classList.contains('hotspot-mode') && /关闭|退出|关掉|隐藏/.test(text)) { toggleHotspot(); return; } if (document.body.classList.contains('person-card-mode') && /关闭|退出|关掉|隐藏/.test(text)) { setPersonCardMode(false, { source: 'chat_input' }); return; } if (/热点|热搜/.test(text) && !document.body.classList.contains('hotspot-mode')) { toggleHotspot(); } const personQuery = extractPersonCardQuery(text); if (personQuery) { showPersonCardByName(personQuery, { source: 'chat_input' }); } }, }); chat.applyActivationWarmupLock(); if (MEMORY_GRAPH_ENABLED) { if (graphEl) graphEl.style.display = "block"; loadMemories(); setInterval(() => { loadMemories(); }, 5 * 60 * 1000); } connectSSE(); loadAgentProfile(); initPersonCard(); initDocPanel().catch((err) => console.warn('[DocPanel] init failed:', err)); chat.restoreChatHistory(); chat.unlockAudioOnFirstGesture(); bootstrapACUI(); initPanelCollapse(); initWechatPopup(); // ── TTS settings panel init ─────────────────────────────────────────────────── function initTTSSettings() { const providerSel = document.getElementById("tts-provider-select"); const voiceSel = document.getElementById("tts-voice-select"); const testBtn = document.getElementById("tts-test-btn"); const testStatus = document.getElementById("tts-test-status"); if (!providerSel) return; let allVoices = {}; const credSections = { doubao: document.getElementById("tts-creds-doubao"), minimax: document.getElementById("tts-creds-minimax"), openai: document.getElementById("tts-creds-openai"), elevenlabs: document.getElementById("tts-creds-elevenlabs"), volcano: document.getElementById("tts-creds-volcano"), }; function showCredSection(provider) { Object.entries(credSections).forEach(([k, el]) => { if (el) el.style.display = k === provider ? "" : "none"; }); } function updateVoiceOptions(provider, savedId) { if (!voiceSel) return; const voices = allVoices[provider] || []; voiceSel.innerHTML = voices.map(v => `` ).join(""); if (savedId && voices.some(v => v.id === savedId)) { voiceSel.value = savedId; } } providerSel.addEventListener("change", () => { showCredSection(providerSel.value); updateVoiceOptions(providerSel.value); }); fetch(`${API}/settings/tts`).then(r => r.json()).then(({ tts, voices }) => { if (voices) allVoices = voices; const provider = tts?.ttsProvider || "doubao"; if (tts?.ttsProvider) providerSel.value = tts.ttsProvider; else providerSel.value = "doubao"; updateVoiceOptions(provider, tts?.ttsVoiceId); const appidEl = document.getElementById("tts-volcano-appid"); if (appidEl && tts?.volcanoAppId?.value) appidEl.value = tts.volcanoAppId.value; const doubaoAppIdEl = document.getElementById("tts-doubao-appid"); if (doubaoAppIdEl && tts?.doubaoAppId?.value) doubaoAppIdEl.value = tts.doubaoAppId.value; const doubaoResourceEl = document.getElementById("tts-doubao-resource"); if (doubaoResourceEl && tts?.doubaoResourceId) doubaoResourceEl.value = tts.doubaoResourceId; const baseurlEl = document.getElementById("tts-openai-baseurl"); if (baseurlEl && tts?.openaiTtsBaseURL) baseurlEl.value = tts.openaiTtsBaseURL; showCredSection(provider); }).catch(() => {}); showCredSection(providerSel.value); const origSaveBtn = document.getElementById("settings-save-voice"); if (origSaveBtn) { origSaveBtn.addEventListener("click", () => { const ttsBody = { ttsProvider: providerSel.value }; const voiceId = voiceSel?.value?.trim(); if (voiceId) ttsBody.ttsVoiceId = voiceId; const minimaxKey = document.getElementById("tts-minimax-key")?.value?.trim(); if (minimaxKey) ttsBody.minimaxKey = minimaxKey; const doubaoKey = document.getElementById("tts-doubao-key")?.value?.trim(); if (doubaoKey) ttsBody.doubaoKey = doubaoKey; const doubaoResource = document.getElementById("tts-doubao-resource")?.value?.trim(); if (doubaoResource) ttsBody.doubaoResourceId = doubaoResource; const doubaoAppId = document.getElementById("tts-doubao-appid")?.value?.trim(); if (doubaoAppId) ttsBody.doubaoAppId = doubaoAppId; const doubaoAccessKey = document.getElementById("tts-doubao-access-key")?.value?.trim(); if (doubaoAccessKey) ttsBody.doubaoAccessKey = doubaoAccessKey; const openaiKey = document.getElementById("tts-openai-key")?.value?.trim(); if (openaiKey) ttsBody.openaiTtsKey = openaiKey; const baseURL = document.getElementById("tts-openai-baseurl")?.value?.trim(); if (baseURL) ttsBody.openaiTtsBaseURL = baseURL; const elevenKey = document.getElementById("tts-elevenlabs-key")?.value?.trim(); if (elevenKey) ttsBody.elevenLabsKey = elevenKey; const volcanoAppId = document.getElementById("tts-volcano-appid")?.value?.trim(); if (volcanoAppId) ttsBody.volcanoAppId = volcanoAppId; const volcanoToken = document.getElementById("tts-volcano-token")?.value?.trim(); if (volcanoToken) ttsBody.volcanoToken = volcanoToken; fetch(`${API}/settings/tts`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(ttsBody), }).then(() => { ["tts-minimax-key", "tts-doubao-key", "tts-doubao-access-key", "tts-openai-key", "tts-elevenlabs-key", "tts-volcano-token"].forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; }); }).catch(() => {}); }); } if (testBtn) { testBtn.addEventListener("click", async () => { testBtn.disabled = true; if (testStatus) testStatus.textContent = "保存配置中…"; try { const preBody = { ttsProvider: providerSel.value }; const currentVoice = voiceSel?.value?.trim(); if (currentVoice) preBody.ttsVoiceId = currentVoice; const minimaxKey2 = document.getElementById("tts-minimax-key")?.value?.trim(); if (minimaxKey2) preBody.minimaxKey = minimaxKey2; const doubaoKey = document.getElementById("tts-doubao-key")?.value?.trim(); if (doubaoKey) preBody.doubaoKey = doubaoKey; const doubaoResource = document.getElementById("tts-doubao-resource")?.value?.trim(); if (doubaoResource) preBody.doubaoResourceId = doubaoResource; const doubaoAppId = document.getElementById("tts-doubao-appid")?.value?.trim(); if (doubaoAppId) preBody.doubaoAppId = doubaoAppId; const doubaoAccessKey = document.getElementById("tts-doubao-access-key")?.value?.trim(); if (doubaoAccessKey) preBody.doubaoAccessKey = doubaoAccessKey; const openaiKey = document.getElementById("tts-openai-key")?.value?.trim(); if (openaiKey) preBody.openaiTtsKey = openaiKey; const elevenKey = document.getElementById("tts-elevenlabs-key")?.value?.trim(); if (elevenKey) preBody.elevenLabsKey = elevenKey; const volcanoAppId = document.getElementById("tts-volcano-appid")?.value?.trim(); if (volcanoAppId) preBody.volcanoAppId = volcanoAppId; const volcanoToken = document.getElementById("tts-volcano-token")?.value?.trim(); if (volcanoToken) preBody.volcanoToken = volcanoToken; await fetch(`${API}/settings/tts`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(preBody), }); if (testStatus) testStatus.textContent = "合成中…"; const ttsResp = await fetch(`${API}/tts/stream`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "你好,这是一段语音合成测试,听起来清晰自然吗?" }), }); if (!ttsResp.ok) { let errMsg = `合成失败(HTTP ${ttsResp.status})`; try { const j = await ttsResp.json(); errMsg = j.error || errMsg; } catch {} if (testStatus) testStatus.textContent = errMsg; return; } const ttsBlob = await ttsResp.blob(); if (ttsBlob.size === 0) { if (testStatus) testStatus.textContent = "合成失败:接口返回空数据,请检查 API Key 和账户配置。"; return; } const ttsUrl = URL.createObjectURL(ttsBlob); const ttsAudio = new Audio(ttsUrl); ttsAudio.onended = () => { URL.revokeObjectURL(ttsUrl); if (testStatus) testStatus.textContent = ""; }; ttsAudio.onerror = () => { URL.revokeObjectURL(ttsUrl); if (testStatus) testStatus.textContent = "播放失败"; }; await ttsAudio.play(); if (testStatus) testStatus.textContent = "播放中"; setTimeout(() => { if (testStatus && testStatus.textContent === "播放中") testStatus.textContent = ""; }, 8000); } catch { if (testStatus) testStatus.textContent = "失败 — 请检查配置和 API Key"; } finally { testBtn.disabled = false; } }); } } // ── Settings modal ── (function initSettings() { const settingsBtn = document.getElementById("settings-btn"); const overlay = document.getElementById("settings-overlay"); const closeBtn = document.getElementById("settings-close"); const providerSelect = document.getElementById("settings-provider-select"); const modelSelect = document.getElementById("settings-model-select"); const llmKeyInput = document.getElementById("settings-llm-key"); const saveLlmBtn = document.getElementById("settings-save-llm"); const llmFeedback = document.getElementById("settings-llm-feedback"); const tempSlider = document.getElementById("settings-temperature"); const tempVal = document.getElementById("settings-temperature-val"); const saveTempBtn = document.getElementById("settings-save-temperature"); const tempFeedback = document.getElementById("settings-temperature-feedback"); const minimaxKeyInput = document.getElementById("settings-minimax-key"); const saveMinimaxBtn = document.getElementById("settings-save-minimax"); const minimaxFeedback = document.getElementById("settings-minimax-feedback"); const saveSocialBtn = document.getElementById("settings-save-social"); const socialFeedback = document.getElementById("settings-social-feedback"); const saveVoiceBtn = document.getElementById("settings-save-voice"); const voiceFeedback = document.getElementById("settings-voice-feedback"); const voiceThreshSlider = document.getElementById("settings-voice-threshold"); const voiceThreshVal = document.getElementById("settings-voice-threshold-val"); if (!settingsBtn || !overlay) return; let cachedProviders = null; overlay.querySelectorAll(".settings-nav-item").forEach(btn => { btn.addEventListener("click", () => { overlay.querySelectorAll(".settings-nav-item").forEach(b => b.classList.remove("active")); overlay.querySelectorAll(".settings-tab").forEach(t => t.classList.remove("active")); btn.classList.add("active"); const tab = btn.dataset.tab; overlay.querySelector(`.settings-tab[data-tab="${tab}"]`)?.classList.add("active"); if (tab === "social") loadSocialSettings(); if (tab === "security") loadSecuritySettings(); if (tab === "web-search") loadWebSearchSettings(); if (tab === "update") loadUpdateSettings(); }); }); function showFeedback(el, msg, isError = false) { if (!el) return; el.textContent = msg; el.className = "settings-feedback" + (isError ? " error" : ""); setTimeout(() => { el.textContent = ""; el.className = "settings-feedback"; }, 3000); } function refreshConfigSummary({ llm, minimax }) { const cfgLlm = document.getElementById("settings-cfg-llm"); const cfgLlmDot = document.getElementById("settings-cfg-llm-dot"); const cfgMedia = document.getElementById("settings-cfg-media"); const cfgMediaDot = document.getElementById("settings-cfg-media-dot"); if (cfgLlm) cfgLlm.textContent = `${llm.provider || "—"} · ${llm.model || "—"}`; if (cfgLlmDot) { cfgLlmDot.textContent = "●"; cfgLlmDot.className = `settings-config-dot ${llm.activated ? "active" : "inactive"}`; cfgLlmDot.title = llm.activated ? "Running" : "Inactive"; } if (cfgMedia) cfgMedia.textContent = `minimax · ${minimax.configured ? "configured" : "not configured"}`; if (cfgMediaDot) { cfgMediaDot.textContent = "●"; cfgMediaDot.className = `settings-config-dot ${minimax.configured ? "active" : "inactive"}`; } } function populateModelSelect(models, current) { if (!modelSelect || !models) return; modelSelect.innerHTML = models .map(m => ``) .join(""); if (current) modelSelect.value = current; } function populateProviderSelect(providers, current) { if (!providerSelect || !providers) return; const selected = current || providerSelect.value || "auto"; const options = [``] .concat(Object.entries(providers).map(([id, provider]) => { const label = provider.label || id; return ``; })); providerSelect.innerHTML = options.join(""); providerSelect.value = providers[selected] || selected === "auto" ? selected : "auto"; } function applyCustomProviderUI(llm) { const customSection = document.getElementById("settings-custom-llm-section"); const modelRow = document.getElementById("settings-model-row"); if (llm?.provider === "custom") { if (customSection) customSection.style.display = ""; if (modelRow) modelRow.style.display = "none"; const baseUrlEl = document.getElementById("settings-custom-baseurl"); const modelEl = document.getElementById("settings-custom-model"); if (baseUrlEl && llm.baseURL) baseUrlEl.value = llm.baseURL; if (modelEl && llm.model) modelEl.value = llm.model; } else { if (customSection) customSection.style.display = "none"; if (modelRow) modelRow.style.display = ""; } } async function loadSettings() { try { const data = await fetch(`${API}/settings`).then(r => r.json()); const { llm, minimax, providers } = data; if (providers) cachedProviders = providers; refreshConfigSummary({ llm, minimax }); populateProviderSelect(providers, llm.provider || "auto"); if (providerSelect && llm.provider) providerSelect.value = llm.provider; applyCustomProviderUI(llm); if (llm.provider !== "custom") populateModelSelect(llm.models, llm.model); if (typeof llm.temperature === "number" && tempSlider) { tempSlider.value = String(llm.temperature); if (tempVal) tempVal.textContent = llm.temperature.toFixed(2); } } catch {} } const SOCIAL_FIELD_MAP = { "social-discord-token": "DISCORD_BOT_TOKEN", "social-feishu-appid": "FEISHU_APP_ID", "social-feishu-secret": "FEISHU_APP_SECRET", "social-feishu-token": "FEISHU_VERIFICATION_TOKEN", "social-wechat-appid": "WECHAT_OFFICIAL_APP_ID", "social-wechat-secret": "WECHAT_OFFICIAL_APP_SECRET", "social-wechat-token": "WECHAT_OFFICIAL_TOKEN", "social-wecom-botkey": "WECOM_BOT_KEY", "social-wecom-token": "WECOM_INCOMING_TOKEN", }; const SOCIAL_PLATFORM_STATUS = { "social-status-discord": ["DISCORD_BOT_TOKEN"], "social-status-feishu": ["FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_VERIFICATION_TOKEN"], "social-status-wechat": ["WECHAT_OFFICIAL_APP_ID", "WECHAT_OFFICIAL_APP_SECRET", "WECHAT_OFFICIAL_TOKEN"], "social-status-wecom": ["WECOM_BOT_KEY", "WECOM_INCOMING_TOKEN"], }; async function loadSocialSettings() { try { const { social } = await fetch(`${API}/settings/social`).then(r => r.json()); for (const [statusId, keys] of Object.entries(SOCIAL_PLATFORM_STATUS)) { const el = document.getElementById(statusId); if (!el) continue; const configuredCount = keys.filter(k => social[k]?.configured).length; if (configuredCount === keys.length) { el.textContent = "● 已配置"; el.className = "settings-platform-status ok"; } else if (configuredCount > 0) { el.textContent = `● 部分配置 (${configuredCount}/${keys.length})`; el.className = "settings-platform-status miss"; } else { el.textContent = "○ 未配置"; el.className = "settings-platform-status miss"; } } } catch {} } const fileSandboxToggle = document.getElementById("security-file-sandbox"); const execSandboxToggle = document.getElementById("security-exec-sandbox"); const saveSecurityBtn = document.getElementById("settings-save-security"); const securityFeedback = document.getElementById("settings-security-feedback"); async function loadWebSearchSettings() { try { const { webSearch } = await fetch(`${API}/settings/web-search`).then(r => r.json()); const urlEl = document.getElementById("websearch-searxng-url"); if (urlEl) urlEl.value = webSearch?.searxngUrl || ""; const setStatus = (id, configured, fromEnv, extra) => { const el = document.getElementById(id); if (!el) return; const truncated = extra && extra.length > 60 ? extra.slice(0, 60) + "…" : extra; if (configured) { el.textContent = `已配置${fromEnv ? "(环境变量)" : ""}${truncated ? ` · ${truncated}` : ""}`; el.style.color = "var(--ok, #4caf50)"; } else { el.textContent = "未配置(兜底链中跳过)"; el.style.color = "var(--ink2)"; } }; setStatus("websearch-status-serper", !!webSearch?.serperConfigured, !!webSearch?.serperFromEnv); setStatus("websearch-status-brave", !!webSearch?.braveConfigured, !!webSearch?.braveFromEnv); setStatus("websearch-status-tavily", !!webSearch?.tavilyConfigured, !!webSearch?.tavilyFromEnv); setStatus("websearch-status-jina", !!webSearch?.jinaConfigured, !!webSearch?.jinaFromEnv); const searxngConfigured = !!webSearch?.searxngUrl || !!webSearch?.searxngFromEnv; setStatus("websearch-status-searxng", searxngConfigured, !!webSearch?.searxngFromEnv, webSearch?.effectiveSearxngUrl || ""); } catch {} } const saveWebSearchBtn = document.getElementById("settings-save-web-search"); const webSearchFeedback = document.getElementById("settings-web-search-feedback"); if (saveWebSearchBtn) { saveWebSearchBtn.addEventListener("click", async () => { const updates = {}; const serperEl = document.getElementById("websearch-serper-key"); const braveEl = document.getElementById("websearch-brave-key"); const tavilyEl = document.getElementById("websearch-tavily-key"); const jinaEl = document.getElementById("websearch-jina-key"); const searxngEl = document.getElementById("websearch-searxng-url"); const serperVal = serperEl?.value?.trim(); const braveVal = braveEl?.value?.trim(); const tavilyVal = tavilyEl?.value?.trim(); const jinaVal = jinaEl?.value?.trim(); const searxngVal = searxngEl?.value?.trim(); if (serperVal) updates.serperKey = serperVal; if (braveVal) updates.braveKey = braveVal; if (tavilyVal) updates.tavilyKey = tavilyVal; if (jinaVal) updates.jinaKey = jinaVal; // SearXNG URL:空字符串也要传,让用户能清掉 if (searxngEl) updates.searxngUrl = searxngVal || ""; saveWebSearchBtn.disabled = true; try { const res = await fetch(`${API}/settings/web-search`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }); const data = await res.json(); if (data.ok) { showFeedback(webSearchFeedback, "已保存"); if (serperEl) serperEl.value = ""; if (braveEl) braveEl.value = ""; if (tavilyEl) tavilyEl.value = ""; if (jinaEl) jinaEl.value = ""; loadWebSearchSettings(); } else { showFeedback(webSearchFeedback, data.error || "保存失败", true); } } catch { showFeedback(webSearchFeedback, "请求失败", true); } finally { saveWebSearchBtn.disabled = false; } }); } async function loadSecuritySettings() { try { const { security } = await fetch(`${API}/settings/security`).then(r => r.json()); if (fileSandboxToggle) fileSandboxToggle.checked = security.fileSandbox !== false; if (execSandboxToggle) execSandboxToggle.checked = security.execSandbox !== false; document.querySelectorAll(".security-blocked-tool").forEach(cb => { cb.checked = (security.blockedTools || []).includes(cb.value); }); } catch {} } if (saveSecurityBtn) { saveSecurityBtn.addEventListener("click", async () => { const blockedTools = [...document.querySelectorAll(".security-blocked-tool")] .filter(cb => cb.checked) .map(cb => cb.value); const body = { fileSandbox: fileSandboxToggle ? fileSandboxToggle.checked : true, execSandbox: execSandboxToggle ? execSandboxToggle.checked : true, blockedTools, }; saveSecurityBtn.disabled = true; try { const res = await fetch(`${API}/settings/security`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); if (data.ok) { showFeedback(securityFeedback, "已保存 — 立即生效"); } else { showFeedback(securityFeedback, data.error || "保存失败", true); } } catch { showFeedback(securityFeedback, "请求失败", true); } finally { saveSecurityBtn.disabled = false; } }); } if (saveSocialBtn) { saveSocialBtn.addEventListener("click", async () => { const updates = {}; for (const [fieldId, envKey] of Object.entries(SOCIAL_FIELD_MAP)) { const val = document.getElementById(fieldId)?.value?.trim() || ""; if (val) updates[envKey] = val; } saveSocialBtn.disabled = true; try { const res = await fetch(`${API}/settings/social`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }); const data = await res.json(); if (data.ok) { showFeedback(socialFeedback, "已保存"); Object.keys(SOCIAL_FIELD_MAP).forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; }); loadSocialSettings(); } else { showFeedback(socialFeedback, data.error || "保存失败", true); } } catch { showFeedback(socialFeedback, "请求失败", true); } finally { saveSocialBtn.disabled = false; } }); } if (tempSlider && tempVal) { tempSlider.addEventListener("input", () => { tempVal.textContent = parseFloat(tempSlider.value).toFixed(2); }); } if (saveTempBtn) { saveTempBtn.addEventListener("click", async () => { const temperature = parseFloat(tempSlider?.value ?? "0.5"); saveTempBtn.disabled = true; try { const res = await fetch(`${API}/settings/temperature`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ temperature }), }); const data = await res.json(); if (data.ok) { showFeedback(tempFeedback, `已设为 ${data.temperature.toFixed(2)}`); } else { showFeedback(tempFeedback, data.error || "保存失败", true); } } catch { showFeedback(tempFeedback, "请求失败", true); } finally { saveTempBtn.disabled = false; } }); } const VOICE_LANG_KEY = "bailongma-voice-lang"; const VOICE_AUTO_SEND_KEY = "bailongma-voice-auto-send"; const VOICE_AUTO_MIC_KEY = "bailongma-voice-auto-mic"; const VOICE_THRESHOLD_KEY = "bailongma-voice-threshold"; const VOICE_PROVIDER_KEY = "bailongma-voice-provider"; function applyVoiceProviderUI(provider) { const panels = { aliyun: "voice-cred-aliyun", volcengine: "voice-cred-volcengine", tencent: "voice-cred-tencent", xunfei: "voice-cred-xunfei", }; for (const [key, id] of Object.entries(panels)) { const el = document.getElementById(id); if (el) el.style.display = key === provider ? "" : "none"; } } function detectVoiceProviderFromKey(key) { const value = (key || "").trim(); if (!value) return null; if (/^sk-[A-Za-z0-9_\-.]{20,}$/.test(value)) { return { provider: "aliyun", label: "阿里云 ASR", fieldId: "voice-aliyun-key" }; } if (/^AKID/i.test(value)) { return { provider: "tencent", label: "腾讯云 ASR", fieldId: "voice-tencent-sid" }; } if (/^\d{6,10}$/.test(value)) { return { provider: "xunfei", label: "科大讯飞", fieldId: "voice-xunfei-appid" }; } if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) { return { provider: "volcengine", label: "火山豆包 ASR", fieldId: "voice-volc-apikey", defaults: { "voice-volc-resourceid": "volc.bigasr.sauc.duration" }, }; } return null; } const voiceProviderSelect = document.getElementById("voice-provider-select"); if (voiceProviderSelect) { voiceProviderSelect.addEventListener("change", () => applyVoiceProviderUI(voiceProviderSelect.value)); } const voiceAutoKey = document.getElementById("voice-auto-key"); const voiceAutoDetect = document.getElementById("voice-auto-detect"); if (voiceAutoKey) { voiceAutoKey.addEventListener("input", () => { const detected = detectVoiceProviderFromKey(voiceAutoKey.value); if (!detected) { if (voiceAutoDetect) voiceAutoDetect.textContent = voiceAutoKey.value.trim() ? "未识别" : ""; return; } if (voiceProviderSelect) voiceProviderSelect.value = detected.provider; applyVoiceProviderUI(detected.provider); const target = document.getElementById(detected.fieldId); if (target) target.value = voiceAutoKey.value.trim(); for (const [id, value] of Object.entries(detected.defaults || {})) { const el = document.getElementById(id); if (el && !el.value.trim()) el.value = value; } if (voiceAutoDetect) voiceAutoDetect.textContent = detected.label; }); } async function loadVoiceSettings() { const langSelect = document.getElementById("voice-lang-select"); const autoSend = document.getElementById("voice-auto-send"); if (langSelect) langSelect.value = localStorage.getItem(VOICE_LANG_KEY) || "zh-CN"; if (autoSend) autoSend.checked = localStorage.getItem(VOICE_AUTO_SEND_KEY) !== "false"; const autoMic = document.getElementById("voice-auto-mic"); if (autoMic) autoMic.checked = localStorage.getItem(VOICE_AUTO_MIC_KEY) === "true"; const savedThresh = parseFloat(localStorage.getItem(VOICE_THRESHOLD_KEY) || "0.008"); if (voiceThreshSlider) voiceThreshSlider.value = String(savedThresh); if (voiceThreshVal) voiceThreshVal.textContent = savedThresh.toFixed(3); let savedProvider = localStorage.getItem(VOICE_PROVIDER_KEY) || "aliyun"; try { const resp = await fetch("http://127.0.0.1:3721/settings/voice"); const data = await resp.json().catch(() => ({})); if (resp.ok && data?.voice?.voiceProvider) { savedProvider = data.voice.voiceProvider; localStorage.setItem(VOICE_PROVIDER_KEY, savedProvider); } } catch {} if (voiceProviderSelect) voiceProviderSelect.value = savedProvider; applyVoiceProviderUI(savedProvider); } if (voiceThreshSlider && voiceThreshVal) { voiceThreshSlider.addEventListener("input", () => { voiceThreshVal.textContent = parseFloat(voiceThreshSlider.value).toFixed(3); }); } if (saveVoiceBtn) { saveVoiceBtn.addEventListener("click", async () => { const lang = document.getElementById("voice-lang-select")?.value || "zh-CN"; const autoSend = document.getElementById("voice-auto-send")?.checked ?? true; const autoMic = document.getElementById("voice-auto-mic")?.checked ?? false; const threshold = parseFloat(voiceThreshSlider?.value ?? "0.008"); const provider = voiceProviderSelect?.value || "aliyun"; localStorage.setItem(VOICE_LANG_KEY, lang); localStorage.setItem(VOICE_AUTO_SEND_KEY, String(autoSend)); localStorage.setItem(VOICE_AUTO_MIC_KEY, String(autoMic)); localStorage.setItem(VOICE_THRESHOLD_KEY, String(threshold)); localStorage.setItem(VOICE_PROVIDER_KEY, provider); window.dispatchEvent(new CustomEvent("bailongma:voice-threshold", { detail: { threshold } })); const body = { voiceProvider: provider }; const aliyunKey = document.getElementById("voice-aliyun-key")?.value?.trim(); if (aliyunKey) body.aliyunApiKey = aliyunKey; const tencentSid = document.getElementById("voice-tencent-sid")?.value?.trim(); if (tencentSid) body.tencentSecretId = tencentSid; const tencentSkey = document.getElementById("voice-tencent-skey")?.value?.trim(); if (tencentSkey) body.tencentSecretKey = tencentSkey; const tencentAppid = document.getElementById("voice-tencent-appid")?.value?.trim(); if (tencentAppid) body.tencentAppId = tencentAppid; const xunfeiAppid = document.getElementById("voice-xunfei-appid")?.value?.trim(); if (xunfeiAppid) body.xunfeiAppId = xunfeiAppid; const xunfeiApikey = document.getElementById("voice-xunfei-apikey")?.value?.trim(); if (xunfeiApikey) body.xunfeiApiKey = xunfeiApikey; const volcApiKey = document.getElementById("voice-volc-apikey")?.value?.trim(); if (volcApiKey) body.volcAsrApiKey = volcApiKey; const volcResourceId = document.getElementById("voice-volc-resourceid")?.value?.trim(); if (volcResourceId) body.volcAsrResourceId = volcResourceId; const volcAppKey = document.getElementById("voice-volc-appkey")?.value?.trim(); if (volcAppKey) body.volcAsrAppKey = volcAppKey; const volcAccessKey = document.getElementById("voice-volc-accesskey")?.value?.trim(); if (volcAccessKey) body.volcAsrAccessKey = volcAccessKey; if (Object.keys(body).length > 0) { try { saveVoiceBtn.disabled = true; const resp = await fetch("http://127.0.0.1:3721/settings/voice", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!resp.ok) throw new Error("保存失败"); [ "voice-aliyun-key", "voice-auto-key", "voice-tencent-sid", "voice-tencent-skey", "voice-xunfei-apikey", "voice-volc-apikey", "voice-volc-appkey", "voice-volc-accesskey", ].forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; }); if (voiceAutoDetect) voiceAutoDetect.textContent = ""; showFeedback(voiceFeedback, "已保存"); } catch { showFeedback(voiceFeedback, "保存失败", true); } finally { saveVoiceBtn.disabled = false; } } else { showFeedback(voiceFeedback, "已保存"); } }); } initTTSSettings(); const memoryGraphToggle = document.getElementById("settings-memory-graph-toggle"); const memoryGraphFeedback = document.getElementById("settings-memory-graph-feedback"); if (memoryGraphToggle) { memoryGraphToggle.checked = localStorage.getItem(MEMORY_GRAPH_STORAGE_KEY) !== "false"; memoryGraphToggle.addEventListener("change", () => { localStorage.setItem(MEMORY_GRAPH_STORAGE_KEY, String(memoryGraphToggle.checked)); if (memoryGraphFeedback) { memoryGraphFeedback.textContent = "下次刷新页面后生效"; memoryGraphFeedback.className = "settings-feedback"; setTimeout(() => { memoryGraphFeedback.textContent = ""; }, 3000); } }); } function openSettings(tab = null) { overlay.hidden = false; loadSettings(); loadVoiceSettings(); if (tab) { overlay.querySelectorAll(".settings-nav-item").forEach(b => { b.classList.toggle("active", b.dataset.tab === tab); }); overlay.querySelectorAll(".settings-tab").forEach(t => { t.classList.toggle("active", t.dataset.tab === tab); }); if (tab === "social") loadSocialSettings(); if (tab === "web-search") loadWebSearchSettings(); if (tab === "update") loadUpdateSettings(); } } function closeSettings() { overlay.hidden = true; if (llmKeyInput) llmKeyInput.value = ""; if (minimaxKeyInput) minimaxKeyInput.value = ""; } // 暴露给 chat.js 的斜杠命令使用 openSettingsRef = openSettings; settingsBtn.addEventListener("click", () => openSettings()); closeBtn.addEventListener("click", closeSettings); overlay.addEventListener("click", (e) => { if (e.target === overlay) closeSettings(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !overlay.hidden) closeSettings(); }); if (providerSelect) { providerSelect.addEventListener("change", () => { const provider = providerSelect.value; const customSection = document.getElementById("settings-custom-llm-section"); const modelRow = document.getElementById("settings-model-row"); if (provider === "custom") { if (customSection) customSection.style.display = ""; if (modelRow) modelRow.style.display = "none"; } else { if (customSection) customSection.style.display = "none"; if (modelRow) modelRow.style.display = ""; if (cachedProviders?.[provider]) populateModelSelect(cachedProviders[provider].models, null); } }); } saveLlmBtn?.addEventListener("click", async () => { const provider = providerSelect?.value || "auto"; const apiKey = llmKeyInput.value.trim(); saveLlmBtn.disabled = true; if (provider === "custom") { const baseURL = document.getElementById("settings-custom-baseurl")?.value?.trim(); const model = document.getElementById("settings-custom-model")?.value?.trim(); if (!baseURL || !model) { showFeedback(llmFeedback, "请填入 Base URL 和模型名称", true); saveLlmBtn.disabled = false; return; } try { const res = await fetch(`${API}/activate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: "custom", baseURL, model, apiKey: apiKey || "none" }), }); const data = await res.json(); if (data.ok) { showFeedback(llmFeedback, `已连接:${data.model}`); llmKeyInput.value = ""; loadSettings(); } else { showFeedback(llmFeedback, data.error || "连接失败", true); } } catch { showFeedback(llmFeedback, "请求失败", true); } finally { saveLlmBtn.disabled = false; } return; } const model = modelSelect.value; try { const body = apiKey ? { provider, apiKey, ...(provider === "auto" ? {} : { model }) } : { model }; const res = await fetch(apiKey ? `${API}/activate` : `${API}/settings/model`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); if (data.ok) { showFeedback(llmFeedback, "已保存"); llmKeyInput.value = ""; loadSettings(); } else { showFeedback(llmFeedback, data.error || "保存失败", true); } } catch { showFeedback(llmFeedback, "请求失败", true); } finally { saveLlmBtn.disabled = false; } }); saveMinimaxBtn?.addEventListener("click", async () => { const apiKey = minimaxKeyInput.value.trim(); if (!apiKey) { showFeedback(minimaxFeedback, "API Key 不能为空", true); return; } saveMinimaxBtn.disabled = true; try { const res = await fetch(`${API}/settings/minimax`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey }), }); const data = await res.json(); if (data.ok) { showFeedback(minimaxFeedback, "已保存"); minimaxKeyInput.value = ""; loadSettings(); } else { showFeedback(minimaxFeedback, data.error || "保存失败", true); } } catch { showFeedback(minimaxFeedback, "请求失败", true); } finally { saveMinimaxBtn.disabled = false; } }); const clawbotConnectBtn = document.getElementById("clawbot-connect-btn"); const clawbotLogoutBtn = document.getElementById("clawbot-logout-btn"); const clawbotQrArea = document.getElementById("clawbot-qr-area"); const clawbotQrImg = document.getElementById("clawbot-qr-img"); const clawbotQrHint = document.getElementById("clawbot-qr-hint"); const clawbotFeedback = document.getElementById("clawbot-feedback"); const clawbotStatus = document.getElementById("social-status-clawbot"); let clawbotPollTimer = null; function setClawbotStatus(text, ok) { if (!clawbotStatus) return; clawbotStatus.textContent = ok ? `● ${text}` : `○ ${text}`; clawbotStatus.className = `settings-platform-status ${ok ? "ok" : "miss"}`; } function stopClawbotPoll() { if (clawbotPollTimer) { clearInterval(clawbotPollTimer); clawbotPollTimer = null; } } async function pollClawbotQR() { try { const data = await fetch(`${API}/social/wechat-clawbot/qr`).then(r => r.json()); if (data.status === "connected") { stopClawbotPoll(); if (clawbotQrArea) clawbotQrArea.style.display = "none"; setClawbotStatus("已连接", true); if (clawbotFeedback) showFeedback(clawbotFeedback, "微信绑定成功!"); loadSocialSettings(); } else if (data.status === "qr_ready" && data.qr_url) { if (clawbotQrImg) clawbotQrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(data.qr_url)}`; if (clawbotQrArea) clawbotQrArea.style.display = "block"; if (clawbotQrHint) clawbotQrHint.textContent = "等待扫码…"; setClawbotStatus("等待扫码", false); } else if (data.status === "qr_pending") { if (clawbotQrHint) clawbotQrHint.textContent = "正在生成二维码…"; } else if (data.status === "error") { stopClawbotPoll(); if (clawbotQrArea) clawbotQrArea.style.display = "none"; setClawbotStatus("连接失败", false); if (clawbotFeedback) showFeedback(clawbotFeedback, data.error || "连接失败", true); } } catch {} } if (clawbotConnectBtn) { pollClawbotQR(); } clawbotConnectBtn?.addEventListener("click", async () => { if (clawbotQrArea) clawbotQrArea.style.display = "none"; setClawbotStatus("启动中…", false); stopClawbotPoll(); try { await fetch(`${API}/settings/social`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ _clawbot_connect: "1" }), }); } catch {} await pollClawbotQR(); clawbotPollTimer = setInterval(pollClawbotQR, 2000); }); clawbotLogoutBtn?.addEventListener("click", async () => { stopClawbotPoll(); if (clawbotQrArea) clawbotQrArea.style.display = "none"; try { await fetch(`${API}/social/wechat-clawbot/logout`, { method: "POST" }); setClawbotStatus("已断开", false); showFeedback(clawbotFeedback, "微信已断开"); } catch { showFeedback(clawbotFeedback, "请求失败", true); } }); window.addEventListener("bailongma:social_status", (e) => { const d = e.detail; if (d?.platform !== "wechat-clawbot") return; if (d.status === "connected") { stopClawbotPoll(); if (clawbotQrArea) clawbotQrArea.style.display = "none"; setClawbotStatus("已连接", true); } else if (d.status === "qr_ready") { if (!clawbotPollTimer) clawbotPollTimer = setInterval(pollClawbotQR, 2000); pollClawbotQR(); } else if (d.status === "session_expired") { stopClawbotPoll(); setClawbotStatus("会话已过期 — 请重新扫码", false); } else if (d.status === "idle") { setClawbotStatus("未连接", false); } }); const settingsCheckUpdateBtn = document.getElementById("settings-check-update-btn"); const settingsDownloadUpdateBtn = document.getElementById("settings-download-update-btn"); const settingsInstallUpdateBtn = document.getElementById("settings-install-update-btn"); const settingsIgnoreUpdateBtn = document.getElementById("settings-ignore-update-btn"); const settingsUpdateStatusEl = document.getElementById("settings-update-status"); const settingsUpdateFeedback = document.getElementById("settings-update-feedback"); const settingsCurrentVersion = document.getElementById("settings-current-version"); const settingsSuppressToggle = document.getElementById("settings-suppress-updates"); const settingsIgnoredSection = document.getElementById("settings-ignored-section"); const settingsIgnoredVersionEl = document.getElementById("settings-ignored-version-val"); const settingsClearIgnoredBtn = document.getElementById("settings-clear-ignored-btn"); let pendingUpdateVersion = null; let removeUpdaterListener = null; function setUpdateStatusText(text, state = "idle") { if (!settingsUpdateStatusEl) return; settingsUpdateStatusEl.textContent = text; settingsUpdateStatusEl.dataset.state = state; } function setUpdateFeedback(text, isError = false) { if (!settingsUpdateFeedback) return; settingsUpdateFeedback.textContent = text || ""; settingsUpdateFeedback.className = isError ? "settings-feedback error" : "settings-feedback"; } function showUpdateButtons({ check = true, checkDisabled = false, checkLabel = "检查更新", download = false, install = false, ignore = false } = {}) { if (settingsCheckUpdateBtn) { settingsCheckUpdateBtn.classList.toggle("hidden", !check); settingsCheckUpdateBtn.disabled = checkDisabled; settingsCheckUpdateBtn.textContent = checkLabel; } settingsDownloadUpdateBtn?.classList.toggle("hidden", !download); settingsInstallUpdateBtn?.classList.toggle("hidden", !install); settingsIgnoreUpdateBtn?.classList.toggle("hidden", !ignore); } function syncUpdateSettings() { const ignored = localStorage.getItem(IGNORED_VERSION_KEY) || null; const suppressed = localStorage.getItem(SUPPRESS_UPDATES_KEY) === "true"; if (settingsSuppressToggle) settingsSuppressToggle.checked = suppressed; if (settingsIgnoredSection) settingsIgnoredSection.style.display = ignored ? "" : "none"; if (settingsIgnoredVersionEl && ignored) settingsIgnoredVersionEl.textContent = ignored; } async function loadUpdateSettings() { syncUpdateSettings(); const bridge = window.bailongma; if (!bridge?.isElectron) { if (settingsCurrentVersion) settingsCurrentVersion.textContent = "仅桌面端可用"; if (settingsCheckUpdateBtn) settingsCheckUpdateBtn.disabled = true; setUpdateStatusText("仅桌面端可用", "muted"); return; } try { const ver = await bridge.getVersion?.(); if (settingsCurrentVersion && ver) settingsCurrentVersion.textContent = ver; } catch {} removeUpdaterListener = bridge.onUpdaterStatus?.((payload = {}) => { const stage = payload.stage || "idle"; const ver = payload.version || ""; const percent = typeof payload.percent === "number" ? Math.round(payload.percent) : null; switch (stage) { case "checking": setUpdateStatusText("正在检查更新…", "checking"); showUpdateButtons({ checkDisabled: true, checkLabel: "检查中…" }); break; case "available": pendingUpdateVersion = ver; setUpdateStatusText(`发现新版本 ${ver}`, "available"); showUpdateButtons({ check: false, download: true, ignore: true }); break; case "downloading": setUpdateStatusText(`下载中${percent !== null ? ` ${percent}%` : "…"}`, "downloading"); showUpdateButtons({ check: false }); break; case "downloaded": setUpdateStatusText(`版本 ${ver} 已就绪 — 重启后安装`, "ready"); showUpdateButtons({ check: false, install: true }); break; case "up-to-date": setUpdateStatusText(`已是最新版本 ${ver}`, "idle"); showUpdateButtons({ checkLabel: "检查更新" }); break; case "error": setUpdateStatusText(`更新失败:${payload.message || "请稍后再试"}`, "error"); showUpdateButtons({ checkLabel: "重试" }); break; case "dev": setUpdateStatusText("开发模式不检查更新", "muted"); showUpdateButtons({ checkDisabled: true, checkLabel: "开发模式" }); break; default: showUpdateButtons({}); break; } }) || null; } window.addEventListener("beforeunload", () => { if (typeof removeUpdaterListener === "function") { removeUpdaterListener(); removeUpdaterListener = null; } }); settingsSuppressToggle?.addEventListener("change", () => { localStorage.setItem(SUPPRESS_UPDATES_KEY, settingsSuppressToggle.checked ? "true" : "false"); syncUpdateSettings(); }); settingsClearIgnoredBtn?.addEventListener("click", () => { localStorage.removeItem(IGNORED_VERSION_KEY); syncUpdateSettings(); }); settingsCheckUpdateBtn?.addEventListener("click", async () => { const bridge = window.bailongma; if (!bridge?.isElectron) return; setUpdateStatusText("正在检查更新…", "checking"); setUpdateFeedback(""); showUpdateButtons({ checkDisabled: true, checkLabel: "检查中…" }); try { const result = await bridge.checkForUpdates?.(); if (result?.ok === false && result?.message) { setUpdateStatusText(`更新失败:${result.message}`, "error"); showUpdateButtons({ checkLabel: "重试" }); } } catch (err) { setUpdateStatusText(`更新失败:${err?.message || "请稍后再试"}`, "error"); showUpdateButtons({ checkLabel: "重试" }); } }); settingsDownloadUpdateBtn?.addEventListener("click", async () => { const bridge = window.bailongma; if (!bridge?.isElectron) return; setUpdateStatusText("开始下载…", "downloading"); showUpdateButtons({ check: false }); try { await bridge.startDownload?.(); } catch (err) { setUpdateStatusText(`下载失败:${err?.message || "请稍后再试"}`, "error"); showUpdateButtons({ checkLabel: "重试" }); } }); settingsInstallUpdateBtn?.addEventListener("click", () => { window.bailongma?.quitAndInstall?.(); }); settingsIgnoreUpdateBtn?.addEventListener("click", () => { if (pendingUpdateVersion) { localStorage.setItem(IGNORED_VERSION_KEY, pendingUpdateVersion); syncUpdateSettings(); } setUpdateStatusText("已忽略此版本", "muted"); showUpdateButtons({ checkLabel: "检查更新" }); }); })(); // ── Voice panel ── initVoicePanel({ btnId: "voice-btn", panelId: "voice-panel", canvasId: "voice-canvas", statusId: "voice-status", transcriptId: "voice-transcript", getChatInput: () => document.getElementById("msg-input"), getSendBtn: () => document.getElementById("send-btn"), getSendMessage: (options) => chat?.send?.(options), getLang: () => localStorage.getItem("bailongma-voice-lang") || "zh-CN", getAutoSend: () => localStorage.getItem("bailongma-voice-auto-send") !== "false", getAutoMic: () => localStorage.getItem("bailongma-voice-auto-mic") === "true", }); // ── Hotspot mode ── initHotspot().catch((err) => console.warn('[Hotspot] init failed:', err)); // ── Media modes (video / image) ── (function initMediaModes() { const videoBtn = document.getElementById("video-btn"); const videoExitBtn = document.getElementById("video-exit-btn"); const videoFeed = document.getElementById("video-feed"); const videoFrame = document.getElementById("video-frame"); const videoSurface = document.getElementById("video-surface"); const videoBackdrop = document.getElementById("video-backdrop"); const videoTitle = document.getElementById("video-title"); const imageExitBtn = document.getElementById("image-exit-btn"); const imageDisplay = document.getElementById("image-display"); const imageSurface = document.getElementById("image-surface"); const imageTitle = document.getElementById("image-title"); let videoStream = null; let videoActive = false; let imageActive = false; let videoKind = "empty"; let currentVideoSource = ""; let currentVideoStart = null; // wall-clock ms when current play started/resumed; used to estimate elapsed // for cross-origin iframes (bilibili) where we can't read currentTime. let playResumeAt = null; function normalizeUrl(url = "") { return String(url || "").trim(); } function localPathToUrl(src) { const s = String(src || "").trim(); if (!s) return ""; if (/^https?:\/\//i.test(s)) return s; // Local path (file:// or absolute) → backend HTTP media endpoint to avoid file:// CORS restriction let resolved = s; if (/^file:\/\//i.test(s)) { resolved = decodeURIComponent(s.replace(/^file:\/\/\//i, "").replace(/^file:\/\//i, "")); } const filename = resolved.split(/[\\/]/).filter(Boolean).pop() || ""; if (!filename) return s; return "/media/music/" + encodeURIComponent(filename); } function extractYoutubeId(url) { return normalizeUrl(url).match( /(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|shorts\/))([A-Za-z0-9_-]{6,})/ )?.[1] || null; } function youtubeEmbedUrl(url, { autoplay = false, start = null } = {}) { const id = extractYoutubeId(url); if (!id) return null; const params = new URLSearchParams({ enablejsapi: "1", playsinline: "1", rel: "0", autoplay: autoplay ? "1" : "0", }); if (Number.isFinite(Number(start))) params.set("start", String(Math.max(0, Math.round(Number(start))))); return `https://www.youtube.com/embed/${id}?${params.toString()}`; } function extractBilibiliId(url) { const raw = normalizeUrl(url); return raw.match(/\/video\/(BV[A-Za-z0-9]+)/i)?.[1] || raw.match(/\b(BV[A-Za-z0-9]+)\b/i)?.[1] || null; } function bilibiliEmbedUrl(url, { autoplay = false, start = null } = {}) { const bvid = extractBilibiliId(url); if (!bvid) return null; const params = new URLSearchParams({ bvid, autoplay: autoplay ? "1" : "0", high_quality: "1", }); if (Number.isFinite(Number(start))) params.set("t", String(Math.max(0, Math.round(Number(start))))); return `https://player.bilibili.com/player.html?${params.toString()}`; } function iframeUrlFor(url, options) { return youtubeEmbedUrl(url, options) || bilibiliEmbedUrl(url, options); } function saveMediaHistory({ url, title, kind, videoId = null, platform = null }) { fetch(`${API}/media/history`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, title: title || "", kind, videoId, platform }), }).catch(() => {}); } async function validateYoutubeUrl(url) { try { const oembed = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`; const res = await fetch(oembed, { signal: AbortSignal.timeout(5000) }); return res.ok; } catch { return null; // network failure — don't block, allow playback to proceed } } function stopCamera() { videoStream?.getTracks().forEach(t => t.stop()); videoStream = null; } function setPanelVisible(visible) { videoActive = Boolean(visible); document.body.classList.toggle("video-mode", videoActive); videoBtn?.classList.toggle("active", videoActive); if (videoActive) moveVoicePanelToBody(); else restoreVoicePanel(); window.dispatchEvent(new CustomEvent("bailongma:video-mode", { detail: { active: videoActive, kind: videoKind }, })); } function pauseCurrentVideo() { if (videoKind === "youtube") { postFrameCommand("pauseVideo"); playResumeAt = null; } else if (videoKind === "bilibili") { // bilibili iframe 跨域读不到 currentTime,用 wall-clock 估算累计进度 if (playResumeAt) { const elapsed = (Date.now() - playResumeAt) / 1000; currentVideoStart = (Number(currentVideoStart) || 0) + elapsed; } playResumeAt = null; reloadFrameAutoplay(false); } else if (videoKind === "file") { try { videoFeed?.pause?.(); } catch {} playResumeAt = null; } } function resumeCurrentVideo() { if (videoKind === "youtube") { postFrameCommand("playVideo"); playResumeAt = Date.now(); } else if (videoKind === "bilibili") { reloadFrameAutoplay(true); playResumeAt = Date.now(); } else if (videoKind === "file") { videoFeed?.play?.().catch(() => {}); playResumeAt = Date.now(); } } function resetVideoSurface() { stopCamera(); if (videoFeed) { try { videoFeed.pause(); } catch {} videoFeed.removeAttribute("src"); videoFeed.srcObject = null; videoFeed.hidden = true; videoFeed.load?.(); } if (videoFrame) { videoFrame.src = "about:blank"; videoFrame.hidden = true; } if (videoBackdrop) videoBackdrop.style.backgroundImage = ""; videoSurface?.classList.remove("has-media"); videoKind = "empty"; currentVideoSource = ""; currentVideoStart = null; playResumeAt = null; } function toggleVideoPanelVisibility() { if (videoActive) { pauseCurrentVideo(); setPanelVisible(false); } else { if (musicActive) closeMusicPanel(); setPanelVisible(true); if (videoKind !== "empty") resumeCurrentVideo(); } } function closeAndDestroyVideo() { setPanelVisible(false); resetVideoSurface(); } function setVideoModeActive(active) { if (!active) { closeAndDestroyVideo(); } else { setPanelVisible(true); } } function setBackdrop(kind, url) { if (!videoBackdrop) return; if (kind === "youtube") { const id = extractYoutubeId(url); if (id) { videoBackdrop.style.backgroundImage = `url(https://img.youtube.com/vi/${id}/maxresdefault.jpg)`; return; } } // Bilibili / file / camera: solid color fallback (CSS already sets #000 background) videoBackdrop.style.backgroundImage = ""; } async function showCamera({ title = "Camera", autoplay = true } = {}) { setPanelVisible(true); resetVideoSurface(); if (videoTitle) videoTitle.textContent = title; try { videoStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); if (videoFeed) { videoFeed.hidden = false; videoFeed.muted = true; videoFeed.srcObject = videoStream; if (autoplay) videoFeed.play?.().catch(() => {}); } videoSurface?.classList.add("has-media"); videoKind = "camera"; } catch (e) { console.warn("Camera access failed:", e); } } async function showVideo({ url = "", title = "Video", autoplay = true, muted = false, volume = null, currentTime = null, camera = false, } = {}) { if (camera) { showCamera({ title, autoplay }); return; } const source = normalizeUrl(url); if (musicActive) closeMusicPanel(); setPanelVisible(true); resetVideoSurface(); currentVideoSource = source; currentVideoStart = Number.isFinite(Number(currentTime)) ? Math.max(0, Number(currentTime)) : null; if (videoTitle) videoTitle.textContent = title || "Video"; const embedUrl = iframeUrlFor(source, { autoplay, start: currentTime }); if (embedUrl && videoFrame) { videoFrame.hidden = false; videoFrame.src = embedUrl; videoSurface?.classList.add("has-media"); videoKind = embedUrl.includes("youtube.com") ? "youtube" : "bilibili"; if (autoplay) playResumeAt = Date.now(); setBackdrop(videoKind, source); saveMediaHistory({ url: source, title, kind: videoKind, videoId: videoKind === "youtube" ? extractYoutubeId(source) : extractBilibiliId(source), platform: videoKind, }); if (videoKind === "youtube") { validateYoutubeUrl(source).then(ok => { if (ok === false) console.warn("[Media] YouTube video may not play (region block / private / deleted):", source); }); } return; } if (videoFeed && source) { videoFeed.hidden = false; videoFeed.src = source; videoFeed.muted = Boolean(muted); if (Number.isFinite(Number(volume))) videoFeed.volume = Math.max(0, Math.min(1, Number(volume))); if (Number.isFinite(Number(currentTime))) videoFeed.currentTime = Math.max(0, Number(currentTime)); videoSurface?.classList.add("has-media"); videoKind = "file"; saveMediaHistory({ url: source, title, kind: "file" }); if (autoplay) { videoFeed.play?.().catch(() => {}); playResumeAt = Date.now(); } } } function postFrameCommand(command, args = []) { if (!videoFrame?.contentWindow || videoFrame.hidden) return; if (videoKind === "youtube") { videoFrame.contentWindow.postMessage(JSON.stringify({ event: "command", func: command, args, }), "*"); } } function reloadFrameAutoplay(autoplay) { if (!videoFrame || videoFrame.hidden || !currentVideoSource) return; const nextUrl = iframeUrlFor(currentVideoSource, { autoplay, start: currentVideoStart, }); if (nextUrl) videoFrame.src = nextUrl; } function controlVideo({ action, volume, currentTime, autoplay } = {}) { const op = action || (autoplay ? "play" : null); if (op === "hide" || op === "close") { closeAndDestroyVideo(); return; } if (op === "play") resumeCurrentVideo(); if (op === "pause") pauseCurrentVideo(); if (Number.isFinite(Number(volume))) { const v = Math.max(0, Math.min(1, Number(volume))); if (videoFeed) { videoFeed.volume = v; videoFeed.muted = v === 0; } postFrameCommand("setVolume", [Math.round(v * 100)]); } if (Number.isFinite(Number(currentTime))) { const t = Math.max(0, Number(currentTime)); currentVideoStart = t; if (videoFeed) videoFeed.currentTime = t; postFrameCommand("seekTo", [t, true]); // seek 后重置 elapsed 基线,下次 pause 时累计才正确 if (playResumeAt) playResumeAt = Date.now(); } } function setImageModeActive(active) { imageActive = Boolean(active); document.body.classList.toggle("image-mode", imageActive); if (!imageActive && imageDisplay) { imageDisplay.removeAttribute("src"); imageDisplay.alt = ""; imageSurface?.classList.remove("has-media"); } } function showImage({ url = "", title = "Image", alt = "" } = {}) { const source = normalizeUrl(url); setImageModeActive(true); if (imageTitle) imageTitle.textContent = title || "Image"; if (imageDisplay && source) { imageDisplay.src = source; imageDisplay.alt = alt || title || ""; imageSurface?.classList.add("has-media"); } } function handleMediaCommand(payload = {}) { const mode = payload.mode || payload.kind; const action = payload.action || "show"; if (mode === "image") { if (action === "hide" || action === "close") setImageModeActive(false); else showImage(payload); return { ok: true, mode: "image", action }; } if (mode === "camera") { if (action === "hide" || action === "close") closeAndDestroyVideo(); else showCamera(payload); return { ok: true, mode: "camera", action }; } if (mode === "video") { if (action === "show" || payload.url || payload.camera) showVideo(payload); else controlVideo(payload); return { ok: true, mode: "video", action }; } if (mode === "music") { if (action === "show" || payload.src || payload.playlist) showMusic(payload); else controlMusic(payload); return { ok: true, mode: "music", action }; } return { ok: false, error: "unknown media mode" }; } // ── Music mode ──────────────────────────────────────────────────────────── const musicBtn = document.getElementById("music-btn"); const musicExitBtn = document.getElementById("music-exit-btn"); const musicAudio = document.getElementById("music-audio"); const musicPlayBtn = document.getElementById("music-play"); const musicPrevBtn = document.getElementById("music-prev"); const musicNextBtn = document.getElementById("music-next"); const musicSeek = document.getElementById("music-seek"); const musicVolInput = document.getElementById("music-vol"); const musicTimeCur = document.getElementById("music-time-cur"); const musicTimeTotal = document.getElementById("music-time-total"); const musicMetaTitle = document.getElementById("music-meta-title"); const musicMetaArtist = document.getElementById("music-meta-artist"); const musicCoverEl = document.getElementById("music-cover"); const musicCoverTitle = document.getElementById("music-cover-title"); const musicCoverArtist = document.getElementById("music-cover-artist"); const musicLyricsScroll = document.getElementById("music-lyrics-scroll"); const musicNoLyrics = document.getElementById("music-no-lyrics"); let musicActive = false; let musicPlaying = false; let musicWasPlayingBeforeHide = false; let lrcLines = []; let playlist = []; let playlistIdx = 0; let isSeeking = false; function parseLrc(text) { const lines = []; const re = /\[(\d+):(\d{1,2}(?:\.\d+)?)\](.*)/g; let m; while ((m = re.exec(text)) !== null) { const t = parseInt(m[1], 10) * 60 + parseFloat(m[2]); const txt = m[3].trim(); if (txt) lines.push({ time: t, text: txt }); } return lines.sort((a, b) => a.time - b.time); } function fmtTime(s) { if (!isFinite(s) || s < 0) return "0:00"; return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; } function setMusicPanelVisible(visible) { musicActive = Boolean(visible); document.body.classList.toggle("music-mode", musicActive); musicBtn?.classList.toggle("active", musicActive); window.dispatchEvent(new CustomEvent("bailongma:music-mode", { detail: { active: musicActive }, })); } function setMusicPlaying(playing) { musicPlaying = Boolean(playing); document.body.classList.toggle("music-playing", musicPlaying); if (musicPlayBtn) musicPlayBtn.textContent = musicPlaying ? "⏸" : "▶"; if (musicPlaying) { musicAudio?.play?.().catch(() => {}); } else { musicAudio?.pause?.(); } } function loadLrc(lrcText) { lrcLines = lrcText ? parseLrc(lrcText) : []; if (musicLyricsScroll) { musicLyricsScroll.innerHTML = lrcLines .map((l, i) => `
${l.text}
`) .join(""); } if (musicNoLyrics) musicNoLyrics.hidden = lrcLines.length > 0; } function syncLyrics(currentTime) { if (!lrcLines.length || !musicLyricsScroll) return; let active = -1; for (let i = 0; i < lrcLines.length; i++) { if (lrcLines[i].time <= currentTime + 0.3) active = i; else break; } if (active < 0) return; const lines = musicLyricsScroll.querySelectorAll(".lrc-line"); lines.forEach((el, i) => el.classList.toggle("active", i === active)); const activeLine = lines[active]; if (activeLine) { const pane = document.getElementById("music-lyrics-pane"); if (pane) pane.scrollTo({ top: activeLine.offsetTop - pane.clientHeight / 2 + activeLine.clientHeight / 2, behavior: "smooth" }); } } function loadTrack(index, autoplay = true) { const track = playlist[index]; if (!track || !musicAudio) return; musicAudio.src = localPathToUrl(track.src || ""); musicAudio.volume = parseFloat(musicVolInput?.value ?? "0.8"); const title = track.title || "未知曲目"; const artist = track.artist || ""; if (musicMetaTitle) musicMetaTitle.textContent = title; if (musicMetaArtist) musicMetaArtist.textContent = artist; if (musicCoverTitle) musicCoverTitle.textContent = title.slice(0, 14); if (musicCoverArtist) musicCoverArtist.textContent = artist; if (musicTimeCur) musicTimeCur.textContent = "0:00"; if (musicTimeTotal) musicTimeTotal.textContent = "0:00"; if (musicSeek) { musicSeek.value = "0"; musicSeek.max = "100"; } if (track.cover && musicCoverEl) { musicCoverEl.style.backgroundImage = `url(${track.cover})`; musicCoverEl.style.background = ""; } else if (musicCoverEl) { musicCoverEl.style.backgroundImage = ""; let hash = 0; for (const ch of title) hash = (hash * 31 + ch.charCodeAt(0)) & 0xffffffff; const hue = Math.abs(hash) % 360; musicCoverEl.style.background = `hsl(${hue}, 45%, 32%)`; } loadLrc(track.lrc || ""); if (autoplay) setMusicPlaying(true); } function showMusic({ src = "", title = "", artist = "", lrc = "", cover = "", autoplay = true, playlist: pl = null, } = {}) { if (videoActive) closeAndDestroyVideo(); setMusicPanelVisible(true); if (pl && pl.length) { playlist = pl; } else { playlist = [{ src, title, artist, lrc, cover }]; } playlistIdx = 0; loadTrack(0, autoplay); } function closeMusicPanel() { setMusicPlaying(false); setMusicPanelVisible(false); if (musicAudio) musicAudio.src = ""; lrcLines = []; if (musicLyricsScroll) musicLyricsScroll.innerHTML = ""; if (musicNoLyrics) musicNoLyrics.hidden = false; } function controlMusic({ action, volume, currentTime } = {}) { if (action === "hide" || action === "close") { closeMusicPanel(); return; } if (action === "play") setMusicPlaying(true); if (action === "pause") setMusicPlaying(false); if (Number.isFinite(Number(volume))) { const v = Math.max(0, Math.min(1, Number(volume))); if (musicAudio) musicAudio.volume = v; if (musicVolInput) musicVolInput.value = String(v); } if (Number.isFinite(Number(currentTime)) && musicAudio) { musicAudio.currentTime = Math.max(0, Number(currentTime)); } } function toggleMusicPanelVisibility() { if (musicActive) { musicWasPlayingBeforeHide = musicPlaying; setMusicPlaying(false); setMusicPanelVisible(false); } else if (musicAudio?.src) { if (videoActive) closeAndDestroyVideo(); setMusicPanelVisible(true); if (musicWasPlayingBeforeHide) setMusicPlaying(true); } } if (musicAudio) { musicAudio.addEventListener("loadedmetadata", () => { if (musicTimeTotal) musicTimeTotal.textContent = fmtTime(musicAudio.duration); if (musicSeek) musicSeek.max = String(musicAudio.duration || 100); }); musicAudio.addEventListener("timeupdate", () => { if (isSeeking) return; const t = musicAudio.currentTime; if (musicTimeCur) musicTimeCur.textContent = fmtTime(t); if (musicSeek && musicAudio.duration) musicSeek.value = String(t); syncLyrics(t); }); musicAudio.addEventListener("ended", () => { setMusicPlaying(false); if (playlistIdx < playlist.length - 1) { playlistIdx++; loadTrack(playlistIdx, true); } }); } musicPlayBtn?.addEventListener("click", () => setMusicPlaying(!musicPlaying)); musicPrevBtn?.addEventListener("click", () => { if (playlistIdx > 0) { playlistIdx--; loadTrack(playlistIdx, musicPlaying); } else if (musicAudio) musicAudio.currentTime = 0; }); musicNextBtn?.addEventListener("click", () => { if (playlistIdx < playlist.length - 1) { playlistIdx++; loadTrack(playlistIdx, musicPlaying); } }); musicVolInput?.addEventListener("input", () => { if (musicAudio) musicAudio.volume = parseFloat(musicVolInput.value); }); musicSeek?.addEventListener("mousedown", () => { isSeeking = true; }); musicSeek?.addEventListener("input", () => { if (musicTimeCur) musicTimeCur.textContent = fmtTime(parseFloat(musicSeek.value)); }); musicSeek?.addEventListener("change", () => { if (musicAudio) musicAudio.currentTime = parseFloat(musicSeek.value); isSeeking = false; }); musicExitBtn?.addEventListener("click", closeMusicPanel); musicBtn?.addEventListener("click", toggleMusicPanelVisibility); window.addEventListener("keydown", (e) => { if (e.target?.tagName === "INPUT" || e.target?.tagName === "TEXTAREA" || e.target?.isContentEditable) return; if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.key === "m" || e.key === "M") { e.preventDefault(); toggleMusicPanelVisibility(); } }); window.bailongmaMedia = { handle: handleMediaCommand, showVideo, controlVideo, showImage, showCamera, showMusic, controlMusic }; window.addEventListener("bailongma:media", (event) => handleMediaCommand(event.detail || {})); // Push-to-talk:按住空格说话;Agent 正在说话时按下空格直接打断 (() => { let pttHeld = false; const isSpace = (e) => e.code === "Space" || e.key === " " || e.key === "Spacebar"; const isTypingTarget = (t) => !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable); window.addEventListener("keydown", (e) => { if (!isSpace(e)) return; if (isTypingTarget(e.target)) return; if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; e.preventDefault(); if (e.repeat) return; if (pttHeld) return; pttHeld = true; // 不论是否在播,stopTTS 内部已做 no-op 守卫 try { window.stopTTS?.(); } catch {} window.bailongmaVoice?.pttStart?.(); }, { capture: true }); window.addEventListener("keyup", (e) => { if (!isSpace(e)) return; if (!pttHeld) return; pttHeld = false; e.preventDefault(); window.bailongmaVoice?.pttEnd?.(); }, { capture: true }); // 切到后台时如果还按着,强制释放,避免 mic 永远不关 window.addEventListener("blur", () => { if (!pttHeld) return; pttHeld = false; window.bailongmaVoice?.pttEnd?.(); }); })(); videoBtn?.addEventListener("click", toggleVideoPanelVisibility); videoExitBtn?.addEventListener("click", closeAndDestroyVideo); imageExitBtn?.addEventListener("click", () => setImageModeActive(false)); window.addEventListener("keydown", (e) => { if (e.target?.tagName === "INPUT" || e.target?.tagName === "TEXTAREA" || e.target?.isContentEditable) return; if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.key === "v" || e.key === "V") { e.preventDefault(); toggleVideoPanelVisibility(); } // H key: toggle hotspot mode if (e.key === "h" || e.key === "H") { e.preventDefault(); toggleHotspot(); } }); })(); // ── AI 视频生成模式(Seedance · 生成工作台)── // 三段式:生成栏(多任务队列) + 播放区 + 输入区。全程由 aivideo_mode SSE 事件驱动。 (function initAIVideoMode(){ var el = function(id){ return document.getElementById(id); }; var panel = el("aivideo-panel"); if (!panel) return; var queueEl=el("aivideo-queue"), stage=el("aivideo-stage"), stageEmpty=el("aivideo-stage-empty"), feed=el("aivideo-feed"), dlBtn=el("aivideo-dl"), playerMeta=el("aivideo-player-meta"), dropzone=el("aivideo-dropzone"), modeTag=el("aivideo-modetag"), modeHint=el("aivideo-modehint"), promptInput=el("aivideo-prompt-input"), ratioSel=el("aivideo-ratio"), resSel=el("aivideo-resolution"), durSel=el("aivideo-duration"), submitBtn=el("aivideo-submit"), composeErr=el("aivideo-compose-err"), fileInput=el("aivideo-file-input"), newBtn=el("aivideo-new-btn"), exitBtn=el("aivideo-exit-btn"); var active=false, jobs=[], selId=null, images=[], submitting=false; var toastEl=document.createElement("div"); toastEl.className="aivideo-toast"; document.body.appendChild(toastEl); var toastTimer=null; function showToast(html){ toastEl.innerHTML=html; toastEl.classList.add("show"); clearTimeout(toastTimer); toastTimer=setTimeout(function(){ toastEl.classList.remove("show"); },3200); } function mediaUrl(u){ var s=String(u||""); if(!s) return ""; return s.charAt(0)==="/" ? (API+s) : s; } function modeLabel(m){ return m==="flf"?"首尾帧":(m==="image"?"图生视频":"文生视频"); } function jobById(id){ for(var i=0;i"+(j.res?" · "+j.res:"")+(j.ratio?" · "+j.ratio:"")+(j.dur?" · "+j.dur+"s":""); if(feed.play) feed.play().catch(function(){}); renderQueue(); } function download(){ if(!selId) return; dlBtn.disabled=true; var old=dlBtn.textContent; dlBtn.textContent="保存中…"; fetch(API+"/aivideo/save",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({jobId:selId})}) .then(function(r){ return r.json(); }) .then(function(d){ if(d&&d.ok){ showToast('已保存到 '+(d.path||"")+''); } else { showToast('保存失败:'+((d&&d.error)||"未知错误")); } }) .catch(function(e){ showToast('保存失败:'+e.message); }) .then(function(){ dlBtn.disabled=false; dlBtn.textContent=old; }); } dlBtn.addEventListener("click", download); // —— 输入区:加图(点击/拖拽/粘贴,最多 2 张)—— function renderDropzone(){ dropzone.innerHTML=""; images.forEach(function(src,i){ var cell=document.createElement("div"); cell.className="av-imgcell"; var im=document.createElement("img"); im.src=src; cell.appendChild(im); var r=document.createElement("div"); r.className="role"; r.textContent=images.length===2?(i===0?"首帧":"尾帧"):"参考图"; cell.appendChild(r); var rm=document.createElement("button"); rm.className="rm"; rm.textContent="×"; rm.onclick=function(e){ e.stopPropagation(); images.splice(i,1); renderDropzone(); updateMode(); }; cell.appendChild(rm); dropzone.appendChild(cell); }); if(images.length<2){ var add=document.createElement("div"); add.className="av-addcell"; add.innerHTML='+图片点击/拖拽/粘贴'; add.onclick=function(){ fileInput.click(); }; dropzone.appendChild(add); } } var hadImages=false; function updateMode(){ var m=images.length>=2?"flf":(images.length===1?"image":"text"); // 进入图生/首尾帧默认「适配图片」(输出比例跟随上传图);退回文生时恢复 16:9。仅在边界切换,尊重用户在同一模式内的手动选择 if(images.length>0 && !hadImages){ ratioSel.value="adaptive"; } else if(images.length===0 && hadImages && ratioSel.value==="adaptive"){ ratioSel.value="16:9"; } hadImages=images.length>0; modeTag.textContent=modeLabel(m); modeTag.classList.toggle("flf", m==="flf"); modeHint.textContent = m==="text" ? "不加图 = 文生视频 · 1 张 = 图生视频 · 2 张 = 首尾帧" : m==="image" ? "已加 1 张参考图 → 图生视频(比例已设为「适配图片」)" : "已加 2 张 → 首尾帧:第 1 张为「首帧」,第 2 张为「尾帧」"; } function addImage(src){ if(images.length>=2) return; images.push(src); renderDropzone(); updateMode(); } fileInput.addEventListener("change", function(e){ var f=e.target.files&&e.target.files[0]; if(f){ var rd=new FileReader(); rd.onload=function(){ addImage(String(rd.result||"")); }; rd.readAsDataURL(f); } e.target.value=""; }); ["dragenter","dragover"].forEach(function(ev){ dropzone.addEventListener(ev,function(e){ e.preventDefault(); dropzone.classList.add("dragover"); }); }); ["dragleave","drop"].forEach(function(ev){ dropzone.addEventListener(ev,function(e){ e.preventDefault(); dropzone.classList.remove("dragover"); }); }); dropzone.addEventListener("drop", function(e){ var f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0]; if(f&&f.type.indexOf("image/")===0){ var rd=new FileReader(); rd.onload=function(){ addImage(String(rd.result||"")); }; rd.readAsDataURL(f); } }); document.addEventListener("paste", function(e){ if(!active) return; var cd=e.clipboardData||window.clipboardData; var items=cd&&cd.items; if(!items) return; for(var i=0;i