From dad361e4b00a3011cea90a5b7187fea76ab60e80 Mon Sep 17 00:00:00 2001 From: AwesomeXiangQi Date: Sun, 13 Sep 2026 14:12:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Kimi=20=E6=94=B9=E5=9D=8F?= =?UTF-8?q?=E7=9A=84=E4=B8=A4=E5=A4=84:=20=E8=B5=B0=E6=B3=95=E6=AD=A3?= =?UTF-8?q?=E5=88=99=E6=8B=92=E7=BB=9D=E4=B8=A4=E4=BD=8D=E8=A1=8C=E5=8F=B7?= =?UTF-8?q?(h10g8),=20=E6=89=A7=E5=AD=90=E8=AF=86=E5=88=AB=20DOM=20?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E6=94=B6=E7=AA=84=E8=AF=AF=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=20svg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- xiangqi-engine.user.js | 631 +++++++++++++++++++++++++++-------------- 1 file changed, 418 insertions(+), 213 deletions(-) diff --git a/xiangqi-engine.user.js b/xiangqi-engine.user.js index f7c69c6..532d216 100644 --- a/xiangqi-engine.user.js +++ b/xiangqi-engine.user.js @@ -2,7 +2,7 @@ // @name Xiangqi.com 本地皮卡鱼引擎对接 // @name:en Xiangqi.com Local Pikafish Engine // @namespace awesome-xiangqi -// @version 1.3.0 +// @version 1.4.1 // @description 在 play.xiangqi.com 对局中调用本地皮卡鱼引擎(http://127.0.0.1:9377)自动/手动走子 // @description:en Hook the site WebSocket and let a local Pikafish engine play for you // @match https://play.xiangqi.com/* @@ -11,294 +11,476 @@ // ==/UserScript== (function () { - 'use strict'; + "use strict"; - const ENGINE_URL = 'http://127.0.0.1:9377'; - const NS = '42/xiangqi,'; - const uW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; + const ENGINE_URL = "http://127.0.0.1:9377"; + const NS = "42/xiangqi,"; + const FIRST_MOVE_EXTRA_SEC = 5; + const VERSION = "1.4.1"; + const PATCH_MARK = Symbol.for("xiangqi.pikafish.ws.patched"); + const uW = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; - // ---------- 坐标转换: 引擎 UCCI(行0-9) <-> 站点(行1-10) ---------- - const ucciToSite = m => m.replace(/\d+/g, d => String(+d + 1)); - // 站点 -> 引擎在服务端做; 这里只需要 ucciToSite - - // ---------- 对局状态 ---------- - const G = { - id: null, - moves: [], // 站点坐标走法序列, 如 ['b3e3', 'h10g8'] - fen: null, // 服务器推送的当前局面 FEN - mySide: null, // 'red' | 'black' | null(未知) - over: false, - thinking: false, - firstMoveDeadline: null, // 红方首步延迟截止时间戳 - }; + const storage = (() => { + const mem = new Map(); + const safe = { + get(k) { + try { + return localStorage.getItem(k); + } catch (_) { + return mem.has(k) ? mem.get(k) : null; + } + }, + set(k, v) { + mem.set(k, String(v)); + try { + localStorage.setItem(k, String(v)); + } catch (_) {} + }, + }; + return { + get: (k, d = null) => { + const v = safe.get(k); + return v == null ? d : v; + }, + set: safe.set, + }; + })(); const cfg = { - enabled: localStorage.getItem('pf_enabled') === '1', - movetime: +(localStorage.getItem('pf_movetime') || 800), - depth: +(localStorage.getItem('pf_depth') || 0), // 0 = 按用时; >0 = 固定深度 - firstDelay: +(localStorage.getItem('pf_firstdelay') || 0), // 红方首步延迟秒数(实际=此值+5) - sideOverride: localStorage.getItem('pf_side') || 'auto', // auto|red|black + enabled: storage.get("pf_enabled", "0") === "1", + movetime: Math.max(50, +storage.get("pf_movetime", 800) || 800), + depth: Math.max(0, +storage.get("pf_depth", 0) || 0), + firstDelay: Math.max(0, +storage.get("pf_firstdelay", 0) || 0), + sideOverride: ["auto", "red", "black"].includes( + storage.get("pf_side", "auto"), + ) + ? storage.get("pf_side", "auto") + : "auto", }; function saveCfg() { - localStorage.setItem('pf_enabled', cfg.enabled ? '1' : '0'); - localStorage.setItem('pf_movetime', cfg.movetime); - localStorage.setItem('pf_depth', cfg.depth); - localStorage.setItem('pf_firstdelay', cfg.firstDelay); - localStorage.setItem('pf_side', cfg.sideOverride); + storage.set("pf_enabled", cfg.enabled ? "1" : "0"); + storage.set("pf_movetime", cfg.movetime); + storage.set("pf_depth", cfg.depth); + storage.set("pf_firstdelay", cfg.firstDelay); + storage.set("pf_side", cfg.sideOverride); } - // ---------- 工具 ---------- + const G = { + id: null, + moves: [], + fen: null, + mySide: null, + over: false, + thinking: false, + firstMoveDeadline: 0, + reqId: 0, + turnTimer: 0, + sideCache: { at: 0, side: null }, + evCount: 0, + }; + + const ui = {}; + const clampInt = (v, min, max, dflt) => { + const n = Number.parseInt(v, 10); + if (Number.isNaN(n)) return dflt; + return Math.min(max, Math.max(min, n)); + }; + const sideName = (s) => (s === "red" ? "红方" : s === "black" ? "黑方" : "?"); + const setStatus = (msg, keepScore = false) => { + if (!ui.status) return; + if (ui.status.__txt !== msg) { + ui.status.__txt = msg; + ui.status.textContent = msg; + } + if (!keepScore && ui.score) ui.score.textContent = ""; + }; + const setScore = (txt) => { + if (ui.score && ui.score.textContent !== txt) ui.score.textContent = txt; + }; + const setSideHint = (txt) => { + if (ui.sideHint && ui.sideHint.textContent !== txt) + ui.sideHint.textContent = txt; + }; + function jwtUsername() { try { for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); - if (!/jwt/i.test(k)) continue; - const v = String(localStorage.getItem(k)); + if (!/jwt/i.test(k || "")) continue; + const v = String(localStorage.getItem(k) || ""); const m = v.match(/^eyJ[\w-]+\.(eyJ[\w-]+)\./); if (!m) continue; - const payload = JSON.parse(decodeURIComponent(escape(atob(m[1].replace(/-/g, '+').replace(/_/g, '/'))))); - if (payload.sub) return payload.sub; + const payload = JSON.parse( + decodeURIComponent( + encodeURIComponent( + atob(m[1].replace(/-/g, "+").replace(/_/g, "/")), + ), + ), + ); + if (payload && payload.sub) return payload.sub; } - } catch (e) { /* ignore */ } + } catch (_) {} return null; } function isMyTurn() { if (!G.mySide || G.over) return false; - return G.mySide === 'red' ? G.moves.length % 2 === 0 : G.moves.length % 2 === 1; + return G.mySide === "red" + ? G.moves.length % 2 === 0 + : G.moves.length % 2 === 1; } - function status(msg, keepScore) { - ui.status.textContent = msg; - if (!keepScore) ui.score.textContent = ''; - } - - // ---------- 帧解析 ---------- function parseFrame(raw) { - if (typeof raw !== 'string' || !raw.startsWith(NS)) return null; + if (typeof raw !== "string" || !raw.startsWith(NS)) return null; const body = raw.slice(NS.length); const m = body.match(/^(\d+)?(\[[\s\S]*\])$/); if (!m) return null; try { const arr = JSON.parse(m[2]); return { name: arr[0], data: arr.length > 1 ? arr[1] : {} }; - } catch (e) { return null; } + } catch (_) { + return null; + } } - // ---------- 对局事件 ---------- - function onGameEvent(name, data) { - if (name === 'game.get.success') { - const d = data.data || {}; - G.id = d.id || G.id; - G.moves = (d.move_history || []).map(m => String(m.move || m).split(' ')[0]); - G.fen = d.fen || null; - G.over = false; G.thinking = false; - G.firstMoveDeadline = null; // 新对局重置首步延迟 - detectSide(d); - status(`已同步对局 ${G.id}(${G.moves.length} 步, 我方${G.mySide === 'red' ? '红' : G.mySide === 'black' ? '黑' : '?'})`); - maybeMove(); - } else if (name === 'game.start.success') { - G.over = false; G.thinking = false; - G.firstMoveDeadline = null; - setTimeout(maybeMove, 600); - setTimeout(maybeMove, 1800); // 等棋盘渲染出后再试一次 - } else if (name === 'game.move.opponent') { - const d = data.data || {}; - const mv = String(d.move || '').split(' ')[0]; - if (d.fen) G.fen = d.fen; - if (mv && G.moves[G.moves.length - 1] !== mv) { - G.moves.push(mv); - status(`对手: ${mv}`); - maybeMove(); - } - } else if (name === 'game.move.success') { - // 我方走子被服务器接受; 若该走法不在本地序列(例如手动点的), 记录之 - const d = data.data || {}; - if (d.fen) G.fen = d.fen; - const mv = String(d.move || '').split(' ')[0]; - if (mv && G.moves[G.moves.length - 1] !== mv) { - G.moves.push(mv); - status(`已走: ${mv}`); - } - G.thinking = false; - } else if (name === 'game.server.end') { - G.over = true; G.thinking = false; - status(`对局结束(end_reason=${(data.data || {}).end_reason})`); + function detectSideFromBoard(force = false) { + const now = Date.now(); + if (!force && now - G.sideCache.at < 1500 && G.sideCache.side) { + G.mySide = G.sideCache.side; + return G.mySide; + } + try { + // 直接全文档查棋子: 收窄到 .board/[class*=board]/svg 容易命中无棋子的节点导致识别失败 + const avgY = (cls) => { + const els = [...document.querySelectorAll("img[alt]")].filter((i) => { + if (i.closest("#pf-panel")) return false; + const a = i.alt || ""; + return ( + a.includes(cls) && + /pawn|rook|cannon|horse|elephant|advisor|king/i.test(a) + ); + }); + if (!els.length) return null; + return ( + els.reduce((s, p) => s + p.getBoundingClientRect().y, 0) / els.length + ); + }; + const redY = avgY("red"); + const blkY = avgY("brown") ?? avgY("black"); + if (redY == null || blkY == null) return null; + G.mySide = redY > blkY ? "red" : "black"; + G.sideCache = { at: now, side: G.mySide }; + setSideHint(`我方: ${sideName(G.mySide)}`); + return G.mySide; + } catch (_) { + return null; } } function detectSide(d) { - const want = cfg.sideOverride !== 'auto' ? cfg.sideOverride : jwtUsername(); - const r = d.rplayer || {}, b = d.bplayer || {}; + const want = cfg.sideOverride !== "auto" ? cfg.sideOverride : jwtUsername(); + const r = d.rplayer || {}; + const b = d.bplayer || {}; G.mySide = null; - if (r.username === want || r.identity === want) G.mySide = 'red'; - else if (b.username === want || b.identity === want) G.mySide = 'black'; - if (!G.mySide && cfg.sideOverride === 'auto') detectSideFromBoard(); - if (G.mySide) ui.sideHint.textContent = `我方: ${G.mySide === 'red' ? '红方' : '黑方'}`; - else ui.sideHint.textContent = '未识别执子, 请在下方手动指定'; + G.sideCache = { at: 0, side: null }; + if (want && (r.username === want || r.identity === want)) G.mySide = "red"; + else if (want && (b.username === want || b.identity === want)) + G.mySide = "black"; + if (!G.mySide && cfg.sideOverride === "auto") detectSideFromBoard(true); + setSideHint( + G.mySide ? `我方: ${sideName(G.mySide)}` : "未识别执子, 请在下方手动指定", + ); } - // 兜底: 站点总是把我方棋子渲染在下半区, 直接从棋盘 DOM 判断颜色 - function detectSideFromBoard() { + let liveWS = null; + + async function engineBestmove({ moves, fen, movetime, depth, signal }) { + const timeoutMs = Math.max(15000, movetime * 4 + 2000); + const ctrl = new AbortController(); + const timer = setTimeout( + () => ctrl.abort(new Error("引擎请求超时")), + timeoutMs, + ); + const onAbort = () => ctrl.abort(signal.reason); + if (signal) { + if (signal.aborted) ctrl.abort(signal.reason); + else signal.addEventListener("abort", onAbort, { once: true }); + } try { - const avgy = cls => { - const els = [...document.querySelectorAll('img[alt]')].filter(i => { - if (i.closest('#pf-panel')) return false; - const a = i.alt || ''; - return a.includes(cls) && /pawn|rook|cannon|horse|elephant|advisor|king/i.test(a); - }); - if (!els.length) return null; - return els.reduce((s, p) => s + p.getBoundingClientRect().y, 0) / els.length; - }; - const redY = avgy('red'); - const blkY = avgy('brown') ?? avgy('black'); - if (redY == null || blkY == null) return null; - G.mySide = redY > blkY ? 'red' : 'black'; - if (ui.sideHint) ui.sideHint.textContent = `我方: ${G.mySide === 'red' ? '红方' : '黑方'}`; - return G.mySide; - } catch (e) { return null; } + const resp = await fetch(ENGINE_URL + "/bestmove", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + moves, + fen: fen || undefined, + movetime, + depth: depth > 0 ? depth : undefined, + }), + signal: ctrl.signal, + }); + const text = await resp.text(); + let out = null; + try { + out = text ? JSON.parse(text) : null; + } catch (_) {} + if (!resp.ok) + throw new Error((out && out.error) || `HTTP ${resp.status}`); + if ( + !out || + typeof out !== "object" || + !out.ok || + typeof out.move !== "string" || + !out.move + ) { + throw new Error((out && out.error) || "引擎返回异常"); + } + return out; + } finally { + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + } } - // ---------- 引擎调用 ---------- - // 红方首步延迟: 实际等待 = 设置秒数 + 5 秒 - const FIRST_MOVE_EXTRA_SEC = 5; + function sendMove(move, thinkMs) { + if (!liveWS || liveWS.readyState !== WebSocket.OPEN) return false; + const frame = + NS + + `["g_v2.move",${JSON.stringify({ + game_id: G.id, + move, + thinking_time: Math.max(0, Math.round(thinkMs || 0)), + })}]`; + liveWS.send(frame); + return true; + } - async function maybeMove(force) { - if (!cfg.enabled && !force) return; - if (G.over || G.thinking || !G.id) return; + function scheduleTurn(delay = 0) { + if (G.turnTimer) return; + G.turnTimer = setTimeout( + () => { + G.turnTimer = 0; + maybeMove(); + }, + Math.max(0, delay), + ); + } + + async function maybeMove(force = false) { + if ((!cfg.enabled && !force) || G.over || G.thinking || !G.id) return; if (!G.mySide) detectSideFromBoard(); if (!G.mySide) return; - if (!force && !isMyTurn()) return; - if (force && !isMyTurn()) { status('现在不是你走棋'); return; } + if (!isMyTurn()) { + if (force) setStatus("现在不是你走棋"); + return; + } - // 计时起点包含首步延迟等待, 上报的 thinking_time 才真实 const t0 = Date.now(); - - // 整局第一步且我执红: 延迟出招(手动"立即走子"不受限) - if (G.moves.length === 0 && G.mySide === 'red' && !force) { + if (G.moves.length === 0 && G.mySide === "red" && !force) { const totalMs = (cfg.firstDelay + FIRST_MOVE_EXTRA_SEC) * 1000; if (!G.firstMoveDeadline) G.firstMoveDeadline = Date.now() + totalMs; const remain = G.firstMoveDeadline - Date.now(); if (remain > 0) { - status(`红方首步延迟中: ${Math.ceil(remain / 1000)}s`); + setStatus(`红方首步延迟中: ${Math.ceil(remain / 1000)}s`); + scheduleTurn(Math.min(remain, 1000)); return; } } + const reqId = ++G.reqId; G.thinking = true; - status('皮卡鱼思考中…', true); + setStatus("皮卡鱼思考中…", true); try { - const resp = await fetch(ENGINE_URL + '/bestmove', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - moves: G.moves, - fen: G.fen, - movetime: cfg.movetime, - depth: cfg.depth > 0 ? cfg.depth : undefined, - }), + const out = await engineBestmove({ + moves: G.moves.slice(), + fen: G.fen, + movetime: cfg.movetime, + depth: cfg.depth, }); - const out = await resp.json(); - if (!out.ok) throw new Error(out.error || ('HTTP ' + resp.status)); - if (G.over) throw new Error('对局已结束'); - const move = out.move; // 站点坐标 - if (G.moves[G.moves.length - 1] === move) throw new Error('引擎重复走子'); - if (!isMyTurn()) { status('轮次已变化, 放弃本次走子'); G.thinking = false; return; } + if (reqId !== G.reqId) return; + if (G.over) throw new Error("对局已结束"); + const move = out.move.trim(); + // 列 a-i, 行 1-10 (两位行号合法) + if (!/^[a-i](?:10|[1-9])[a-i](?:10|[1-9])$/i.test(move)) + throw new Error(`非法走法: ${move}`); + if (G.moves[G.moves.length - 1] === move) throw new Error("引擎重复走子"); + if (!isMyTurn()) { + setStatus("轮次已变化, 放弃本次走子"); + return; + } + if (!sendMove(move, Date.now() - t0)) + throw new Error("未找到 socket 连接"); G.moves.push(move); - sendMove(move, Date.now() - t0); - ui.score.textContent = `${out.source === 'cloud' ? '☁ 云库' : '♞ 引擎'} · 评分 ${out.score || '?'} · ${out.ms}ms`; - status(`已走: ${move}`); + setScore( + `${out.source === "cloud" ? "☁ 云库" : "♞ 引擎"} · 评分 ${out.score ?? "?"} · ${out.ms ?? "?"}ms`, + ); + setStatus(`已走: ${move}`); } catch (e) { - status('引擎错误: ' + e.message, true); - G.thinking = false; + if (reqId === G.reqId) + setStatus(`引擎错误: ${e && e.message ? e.message : e}`, true); + } finally { + if (reqId === G.reqId) G.thinking = false; } } - let liveWS = null; - function sendMove(move, thinkMs) { - if (!liveWS) { status('未找到 socket 连接'); return; } - const frame = NS + `["g_v2.move",${JSON.stringify({ - game_id: G.id, move, thinking_time: thinkMs, - })}]`; - liveWS.send(frame); + function resetGame() { + G.over = false; G.thinking = false; + G.firstMoveDeadline = 0; + G.sideCache = { at: 0, side: null }; + } + + function pushMoveIfNew(mv) { + if (mv && G.moves[G.moves.length - 1] !== mv) { + G.moves.push(mv); + return true; + } + return false; + } + + function onGameEvent(name, data) { + const d = (data && data.data) || {}; + if (name === "game.get.success") { + G.id = d.id || G.id; + G.moves = Array.isArray(d.move_history) + ? d.move_history.map((m) => String((m && m.move) || m).split(" ")[0]) + : []; + G.fen = d.fen || null; + resetGame(); + detectSide(d); + setStatus( + `已同步对局 ${G.id}(${G.moves.length} 步, 我方${sideName(G.mySide)})`, + ); + scheduleTurn(50); + } else if (name === "game.start.success") { + resetGame(); + scheduleTurn(600); + scheduleTurn(1800); + } else if (name === "game.move.opponent") { + const mv = String(d.move || "").split(" ")[0]; + if (d.fen) G.fen = d.fen; + if (mv && pushMoveIfNew(mv)) { + setStatus(`对手: ${mv}`); + scheduleTurn(80); + } + } else if (name === "game.move.success") { + const mv = String(d.move || "").split(" ")[0]; + if (d.fen) G.fen = d.fen; + if (mv && pushMoveIfNew(mv)) setStatus(`已走: ${mv}`); + G.thinking = false; + } else if (name === "game.server.end") { + G.over = true; + G.thinking = false; + setStatus(`对局结束(end_reason=${d.end_reason})`); + } } - // ---------- WebSocket 钩子 (原型级补丁, 对已存在/未来的连接都生效) ---------- function onInFrame(raw, ws) { liveWS = ws; const ev = parseFrame(raw); - if (ev) { - G.evCount = (G.evCount || 0) + 1; - try { onGameEvent(ev.name, ev.data); } catch (e) { console.error('[pf]', e); } + if (!ev) return; + G.evCount++; + try { + onGameEvent(ev.name, ev.data); + } catch (e) { + console.error("[pf]", e); } } function patchWebSocket() { const SP = uW.WebSocket && uW.WebSocket.prototype; - if (!SP || SP.__pfPatched) return; + if (!SP || SP[PATCH_MARK]) return; + const wrappedMap = new WeakMap(); const origSend = SP.send; SP.send = function (d) { - if (typeof d === 'string' && d.startsWith(NS)) liveWS = this; + if (typeof d === "string" && d.startsWith(NS)) liveWS = this; return origSend.apply(this, arguments); }; - // 包一层 addEventListener('message') 以捕获收帧 - const wrappedMap = new WeakMap(); const origAEL = SP.addEventListener; SP.addEventListener = function (type, fn, opts) { - if (type === 'message' && typeof fn === 'function' && !wrappedMap.has(fn)) { - const wrapped = function (ev) { onInFrame(String(ev.data), this); }; - wrappedMap.set(fn, wrapped); + if (type === "message" && typeof fn === "function") { + let wrapped = wrappedMap.get(fn); + if (!wrapped) { + wrapped = function (ev) { + onInFrame(String(ev.data), this); + fn.call(this, ev); + }; + wrappedMap.set(fn, wrapped); + } return origAEL.call(this, type, wrapped, opts); } - if (type === 'message' && typeof fn === 'function' && wrappedMap.has(fn)) { - return origAEL.call(this, type, wrappedMap.get(fn), opts); - } return origAEL.apply(this, arguments); }; - // 兼容 onmessage 属性赋值方式 - const omDesc = Object.getOwnPropertyDescriptor(SP, 'onmessage'); + const origREL = SP.removeEventListener; + SP.removeEventListener = function (type, fn, opts) { + if ( + type === "message" && + typeof fn === "function" && + wrappedMap.has(fn) + ) { + const wrapped = wrappedMap.get(fn); + wrappedMap.delete(fn); + return origREL.call(this, type, wrapped, opts); + } + return origREL.apply(this, arguments); + }; + + const omDesc = Object.getOwnPropertyDescriptor(SP, "onmessage"); if (omDesc && omDesc.set) { - Object.defineProperty(SP, 'onmessage', { + Object.defineProperty(SP, "onmessage", { configurable: true, - get() { return this.__pfOrigOM || null; }, + get() { + return this.__pfOrigOM || null; + }, set(fn) { - this.__pfOrigOM = fn; - const wrapped = function (ev) { - onInFrame(String(ev.data), this); - if (typeof fn === 'function') fn.call(this, ev); - }; + this.__pfOrigOM = typeof fn === "function" ? fn : null; + const wrapped = + typeof fn === "function" + ? function (ev) { + onInFrame(String(ev.data), this); + fn.call(this, ev); + } + : null; omDesc.set.call(this, wrapped); }, }); } - SP.__pfPatched = true; + SP[PATCH_MARK] = true; } + patchWebSocket(); - // 常规兜底轮询: 每秒检查是否轮到我方(覆盖事件时序抖动) - setInterval(() => { if (cfg.enabled) maybeMove(); }, 1000); + setInterval(() => { + if (cfg.enabled && !document.hidden) scheduleTurn(0); + }, 1000); - // 调试/测试句柄 - try { uW.__pf = { G, cfg, maybeMove, detectSide, detectSideFromBoard }; } catch (e) { /* ignore */ } + try { + uW.__pf = { + version: VERSION, + G, + cfg, + maybeMove, + detectSide, + detectSideFromBoard, + saveCfg, + }; + } catch (_) {} - // ---------- 悬浮面板 ---------- - const ui = {}; function buildPanel() { - const panel = document.createElement('div'); - panel.id = 'pf-panel'; + if (ui.panel) return; + const panel = document.createElement("div"); + panel.id = "pf-panel"; panel.innerHTML = `