From cc5c7cdc85ba1823daf7f214a18a5f6408283e13 Mon Sep 17 00:00:00 2001 From: EterUltimate <1831303476@qq.com> Date: Mon, 8 Jun 2026 17:56:06 +0800 Subject: [PATCH] fix: polish embedded WebUI layout --- CHANGELOG.md | 13 + README.md | 2 +- README_EN.md | 2 +- __init__.py | 2 +- core/page_api.py | 20 +- metadata.yaml | 2 +- pages/dashboard/app.js | 310 +++++++++++++----- pages/dashboard/index.html | 10 +- pages/dashboard/styles.css | 106 +++++- tests/integration/test_package_imports.py | 64 ++++ tests/integration/test_webui_static_assets.py | 21 ++ 11 files changed, 457 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ebe7f6c..813c4516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ 所有重要更改都将记录在此文件中。 +## [3.2.3] - 2026-06-08 + +### AstrBot WebUI + +- 修复内嵌插件页人格学习模块在长提示词、长备份名和窄屏布局下的横向溢出,提示词预览现在会在面板内滚动。 +- 优化记忆图谱与知识图谱的内嵌页绘制逻辑,改为稳定中心布局、温和归位和固定比例画布,避免节点挤到四角或图表被拉伸。 +- 收敛 Dashboard 物理动效强度,并支持 `prefers-reduced-motion`,使内嵌页动画更简洁温和。 +- 修复内嵌设置页依赖安装按钮反馈不明显的问题,并兼容旧版插件页确认字段,确保手动安装请求能正确触发并显示输出。 + +### 版本 + +- 将插件发布版本号提升至 `3.2.3`。 + ## [3.2.2] - 2026-06-08 ### AstrBot WebUI diff --git a/README.md b/README.md index b78adeaa..5b4f5585 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 让 AstrBot 在群聊中持续采集、学习、审查并注入上下文,使 Bot 逐步具备表达风格、群组黑话、社交关系、长期记忆和人格演化能力。 -[![Version](https://img.shields.io/badge/version-3.2.2-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) +[![Version](https://img.shields.io/badge/version-3.2.3-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-AGPL--3.0-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) diff --git a/README_EN.md b/README_EN.md index 103269d4..63af449d 100644 --- a/README_EN.md +++ b/README_EN.md @@ -14,7 +14,7 @@
-[![Version](https://img.shields.io/badge/version-3.2.2-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-AGPL--3.0-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) +[![Version](https://img.shields.io/badge/version-3.2.3-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) [![License](https://img.shields.io/badge/license-AGPL--3.0-green.svg)](LICENSE) [![AstrBot](https://img.shields.io/badge/AstrBot-%3E%3D4.11.4-orange.svg)](https://github.com/Soulter/AstrBot) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/) [Features](#what-we-can-do) · [Quick Start](#quick-start) · [Web UI](#visual-management-interface) · [Community](#community) · [Contributing](CONTRIBUTING.md) diff --git a/__init__.py b/__init__.py index 572ef848..897d2a1c 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,5 @@ # AstrBot 自学习插件 -__version__ = "3.2.0" +__version__ = "3.2.3" # Ensure parent namespace packages ("data", "data.plugins") are # durably registered in sys.modules. AstrBot loads plugins via diff --git a/core/page_api.py b/core/page_api.py index 6bf82c79..2fe6dd41 100644 --- a/core/page_api.py +++ b/core/page_api.py @@ -1221,9 +1221,13 @@ async def _detect_group_with_most_messages(self, database_manager: Any) -> Optio async def _install_dependencies(self, body: Mapping[str, Any]) -> dict[str, Any]: imports = self._imports() - if body.get("manual_confirmed") is not True: + manual_confirmed = body.get("manual_confirmed") is True or ( + body.get("manual_confirm") is True and body.get("user_confirmed") is True + ) + if not manual_confirmed: return {"success": False, "error": "依赖安装只能在设置界面手动确认后触发"} - if body.get("source") != imports.MANUAL_DEPENDENCY_INSTALL_SOURCE: + source = body.get("source") + if source != imports.MANUAL_DEPENDENCY_INSTALL_SOURCE and body.get("mode") != "plugin_page": return {"success": False, "error": "缺少合法的依赖安装来源"} tier = str(body.get("tier") or "full").strip().lower() @@ -1633,8 +1637,20 @@ def _build_webui_snapshot(plugin_config: Any, webui_config: Any) -> dict[str, An return { "enabled": enabled, "host": host, + "bind_host": host, + "display_host": display_host, "port": port, "dashboard_url": f"http://{display_host}:{port}", + "public_url_strategy": "browser_host_for_local_bind", + "client_rewrite_hosts": [ + "localhost", + "127.0.0.1", + "0.0.0.0", + "::", + "::1", + "[::]", + "[::1]", + ], } @staticmethod diff --git a/metadata.yaml b/metadata.yaml index dd915dfc..ade3ffa4 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -2,7 +2,7 @@ name: "astrbot_plugin_self_learning" author: "NickMo, EterUltimate" display_name: "self-learning" description: "SELF LEARNING 自主学习插件 — 让 AI 聊天机器人自主学习对话风格、理解群组黑话、管理社交关系与好感度、自适应人格演化,像真人一样自然对话。(使用前必须手动备份人格数据)" -version: "3.2.2" +version: "3.2.3" repo: "https://github.com/NickCharlie/astrbot_plugin_self_learning" tags: - "自学习" diff --git a/pages/dashboard/app.js b/pages/dashboard/app.js index 5e75ba01..f8c3c4c6 100644 --- a/pages/dashboard/app.js +++ b/pages/dashboard/app.js @@ -16,6 +16,9 @@ settings: ["Settings", "设置"], }; const GRAPH_SAFE_PADDING = 34; + const GRAPH_HOME_STRENGTH = 0.0064; + const GRAPH_CENTER_STRENGTH = 0.00016; + const GRAPH_LINK_STRENGTH = 0.000035; const state = { page: "home", @@ -103,6 +106,46 @@ return escapeHtml(value).replace(/`/g, "`"); } + function localNavigationHost(hostname) { + const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1").toLowerCase(); + if (!host) return true; + return host === "localhost" + || host === "0.0.0.0" + || host === "::" + || host === "::1" + || host === "0:0:0:0:0:0:0:0" + || host === "0:0:0:0:0:0:0:1" + || /^127(?:\.\d{1,3}){3}$/.test(host); + } + + function hostForUrl(hostname) { + const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1"); + return host.includes(":") ? `[${host}]` : host; + } + + function resolveHostUrl(value) { + const raw = String(value || "").trim(); + if (!raw || raw === "#") return raw || "#"; + if (raw.startsWith("#")) return raw; + + let parsed; + try { + parsed = new URL(raw, window.location.href); + } catch (_) { + return raw; + } + + if (!/^https?:$/.test(parsed.protocol) || !localNavigationHost(parsed.hostname)) { + return raw; + } + + const browserHost = window.location.hostname; + if (!browserHost) return raw; + const replacementHost = hostForUrl(browserHost); + parsed.host = parsed.port ? `${replacementHost}:${parsed.port}` : replacementHost; + return parsed.href; + } + function fmt(value, digits = 1) { const num = Number(value || 0); if (!Number.isFinite(num)) return "0"; @@ -260,9 +303,10 @@ const degraded = runtime.database_degraded || Object.keys(errors).length > 0; const statusLabel = degraded ? "部分可用" : "运行正常"; + const resolvedDashboardUrl = resolveHostUrl(webui.dashboard_url || ""); const summary = degraded ? "嵌入式页面已载入,部分服务处于降级状态。" - : `已连接官方插件页 API,独立 WebUI: ${webui.dashboard_url || "未配置"}`; + : `已连接官方插件页 API,独立 WebUI: ${resolvedDashboardUrl || "未配置"}`; setText("runtime-status", statusLabel); setText("hero-status", statusLabel); setText("runtime-summary", summary); @@ -271,7 +315,7 @@ $("hero-status")?.classList.toggle("warn", degraded); const fullLink = $("full-dashboard-link"); - if (fullLink && webui.dashboard_url) fullLink.href = webui.dashboard_url; + if (fullLink && resolvedDashboardUrl) fullLink.href = resolvedDashboardUrl; setText("stat-messages", fmt(learning.total_messages_collected)); setText("stat-jargon", fmt(jargon.confirmed_jargon)); @@ -288,8 +332,9 @@ function renderQuickActions(links) { const html = links.map((link) => { - const external = String(link.url || "").startsWith("http"); - return ` + const url = resolveHostUrl(link.url || "#"); + const external = /^https?:\/\//.test(String(url || "")); + return ` ${escapeHtml(link.label || "入口")} ${escapeHtml(link.description || "")} `; @@ -642,6 +687,7 @@ createGraphNode(node, index, rawNodes.length, size.width, size.height), ); state.graph.links = normalizeGraphLinks(graph.links || []); + settleGraphLayout(state.graph.nodes, state.graph.links, size.width, size.height); state.graph.dragged = null; state.graph.hovered = null; setHtml("graph-stat-grid", statCards([ @@ -665,31 +711,79 @@ const radius = graphNodeRadius(node); const safeWidth = Math.max(320, width || 960); const safeHeight = Math.max(320, height || 520); - const centerX = safeWidth / 2; - const centerY = safeHeight / 2; - const spread = Math.max(64, Math.min(safeWidth, safeHeight) * 0.38); - const angle = index * 2.399963229728653 + (state.graph.type === "knowledge" ? 0.72 : 0); - const ring = Math.sqrt((index + 0.5) / Math.max(1, total)); - const x = centerX + Math.cos(angle) * spread * ring; - const y = centerY + Math.sin(angle) * spread * ring; - const min = radius + GRAPH_SAFE_PADDING; - const maxX = safeWidth - radius - GRAPH_SAFE_PADDING; - const maxY = safeHeight - radius - GRAPH_SAFE_PADDING; + const home = graphHomePosition(id, index, total, safeWidth, safeHeight, radius); return { ...node, id, label: node.name || node.label || id, radius, - x: clamp(x, min, maxX), - y: clamp(y, min, maxY), - homeX: clamp(x, min, maxX), - homeY: clamp(y, min, maxY), + x: home.x, + y: home.y, + homeX: home.x, + homeY: home.y, vx: 0, vy: 0, pinned: false, }; } + function graphHomePosition(id, index, total, width, height, radius) { + const margin = graphNodeMargin(radius); + const centerX = width / 2; + const centerY = height / 2; + const seed = graphStableSeed(id); + const angleOffset = state.graph.type === "knowledge" ? 0.72 : 0; + const angle = index * 2.399963229728653 + angleOffset + seed * 0.0007; + const ring = Math.sqrt((index + 0.5) / Math.max(1, total)); + const spreadX = Math.max(86, (width - margin * 2) * 0.36); + const spreadY = Math.max(72, (height - margin * 2) * 0.34); + return { + x: clamp(centerX + Math.cos(angle) * spreadX * ring, margin, width - margin), + y: clamp(centerY + Math.sin(angle) * spreadY * ring, margin, height - margin), + }; + } + + function settleGraphLayout(nodes, links, width, height) { + if (!nodes.length) return; + const byId = new Map(nodes.map((node) => [String(node.id), node])); + for (let iteration = 0; iteration < 18; iteration += 1) { + links.slice(0, 220).forEach((link) => { + const source = byId.get(String(link.source)); + const target = byId.get(String(link.target)); + if (!source || !target) return; + const dx = target.x - source.x; + const dy = target.y - source.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + const desired = Math.max(78, Math.min(132, Math.min(width, height) * 0.23)); + const adjust = (dist - desired) * 0.0035; + const nx = dx / dist; + const ny = dy / dist; + if (!source.pinned) { + source.x += nx * adjust; + source.y += ny * adjust; + } + if (!target.pinned) { + target.x -= nx * adjust; + target.y -= ny * adjust; + } + }); + + for (let i = 0; i < nodes.length; i += 1) { + for (let j = i + 1; j < Math.min(nodes.length, i + 42); j += 1) { + separateGraphNodes(nodes[i], nodes[j], 0.45); + } + } + + nodes.forEach((node) => { + if (!node.pinned) { + node.x += (node.homeX - node.x) * 0.12; + node.y += (node.homeY - node.y) * 0.12; + } + clampGraphNode(node, width, height); + }); + } + } + function normalizeGraphLinks(links) { if (!Array.isArray(links)) return []; return links.map((link) => ({ @@ -717,8 +811,8 @@ function integrationCardHtml(item) { const dash = item.dashboard || {}; - const url = dash.external_url || dash.official_page_url || dash.url || "#"; - const disabled = !dash.available || !url; + const url = resolveHostUrl(dash.external_url || dash.official_page_url || dash.url || "#"); + const disabled = !dash.available || !url || url === "#"; return `
${escapeHtml(item.role || "")} @@ -936,15 +1030,37 @@ await loadPageData("settings", { force: true }); }); $("dependency-install-button")?.addEventListener("click", async () => { - const result = await apiPost("settings/action", { - action: "install_dependencies", - manual_confirmed: true, - source: "system_settings", - tier: $("dependency-tier")?.value || "full", - pip_mirror: $("pip-mirror-select")?.value || "default", - }); - setText("dependency-output", (result.result || result).output || result.message || ""); - showToast(result.message || "依赖安装任务结束", result.success ? "ok" : "error"); + const installButton = $("dependency-install-button"); + const originalLabel = installButton?.textContent || "手动安装"; + const settings = state.pageData.settings || {}; + if (installButton) { + installButton.disabled = true; + installButton.classList.add("is-busy"); + installButton.textContent = "安装中"; + } + setText("dependency-output", "正在调用 pip 安装依赖,请等待命令输出..."); + try { + const result = await apiPost("settings/action", { + action: "install_dependencies", + manual_confirmed: true, + source: settings.manual_dependency_source || "system_settings", + tier: $("dependency-tier")?.value || "full", + pip_mirror: $("pip-mirror-select")?.value || "default", + }); + const detail = result.result || result; + setText("dependency-output", detail.output || detail.message || result.message || "依赖安装任务结束"); + showToast(result.message || detail.message || "依赖安装任务结束", result.success !== false ? "ok" : "error"); + } catch (error) { + const message = error.message || String(error); + setText("dependency-output", message); + showToast(message, "error"); + } finally { + if (installButton) { + installButton.disabled = false; + installButton.classList.remove("is-busy"); + installButton.textContent = originalLabel; + } + } }); document.addEventListener("click", async (event) => { @@ -1015,6 +1131,7 @@ function initSpringMotion() { const stage = qs(".spring-stage"); const canvas = $("physics-canvas"); + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; if (!stage || !canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; @@ -1054,37 +1171,37 @@ const dt = Math.min(0.033, Math.max(0.001, (now - physics.last) / 1000)); physics.last = now; ctx.clearRect(0, 0, rect.width, rect.height); - ctx.strokeStyle = "rgba(65, 105, 225, 0.22)"; - ctx.lineWidth = 1.5; + ctx.strokeStyle = "rgba(65, 105, 225, 0.14)"; + ctx.lineWidth = 1.2; const core = { x: rect.width / 2, y: rect.height / 2 }; physics.particles.forEach((point) => { const own = point.el.getBoundingClientRect(); const baseX = own.left - rect.left + own.width / 2 - point.x; const baseY = own.top - rect.top + own.height / 2 - point.y; - let targetX = Math.sin(now / 1100 + point.seed) * 16; - let targetY = Math.cos(now / 1250 + point.seed) * 14; + let targetX = Math.sin(now / 1350 + point.seed) * 6; + let targetY = Math.cos(now / 1500 + point.seed) * 5; if (physics.pointer.active) { const cx = baseX + point.x; const cy = baseY + point.y; const dx = cx - physics.pointer.x; const dy = cy - physics.pointer.y; const dist = Math.max(1, Math.hypot(dx, dy)); - const force = Math.max(0, 120 - dist) / 120; - targetX += dx / dist * force * 52; - targetY += dy / dist * force * 52; + const force = Math.max(0, 96 - dist) / 96; + targetX += dx / dist * force * 18; + targetY += dy / dist * force * 18; } - point.vx += (targetX - point.x) * 42 * dt; - point.vy += (targetY - point.y) * 42 * dt; - point.vx *= Math.max(0, 1 - 12 * dt); - point.vy *= Math.max(0, 1 - 12 * dt); + point.vx += (targetX - point.x) * 28 * dt; + point.vy += (targetY - point.y) * 28 * dt; + point.vx *= Math.max(0, 1 - 14 * dt); + point.vy *= Math.max(0, 1 - 14 * dt); point.x += point.vx * dt * 60; point.y += point.vy * dt * 60; const px = baseX + point.x; const py = baseY + point.y; ctx.beginPath(); ctx.moveTo(core.x, core.y); - ctx.quadraticCurveTo((core.x + px) / 2, (core.y + py) / 2 - 16, px, py); + ctx.quadraticCurveTo((core.x + px) / 2, (core.y + py) / 2 - 8, px, py); ctx.stroke(); point.el.style.transform = `translate3d(${point.x.toFixed(2)}px, ${point.y.toFixed(2)}px, 0)`; }); @@ -1128,7 +1245,7 @@ const point = graphPointer(event, canvas); const drag = state.graph.dragged; if (drag && drag.pointerId === event.pointerId) { - const min = drag.node.radius + GRAPH_SAFE_PADDING; + const min = graphNodeMargin(drag.node.radius || graphNodeRadius(drag.node)); drag.node.x = clamp(point.x + drag.offsetX, min, state.graph.width - min); drag.node.y = clamp(point.y + drag.offsetY, min, state.graph.height - min); drag.node.homeX = drag.node.x; @@ -1185,15 +1302,15 @@ const dx = target.x - source.x; const dy = target.y - source.y; const dist = Math.max(1, Math.hypot(dx, dy)); - const desired = Math.max(98, Math.min(168, width * 0.18)); - const force = (dist - desired) * 0.00032; + const desired = Math.max(78, Math.min(132, Math.min(width, height) * 0.23)); + const force = (dist - desired) * GRAPH_LINK_STRENGTH; if (!source.pinned) { - source.vx += dx * force; - source.vy += dy * force; + source.vx += (dx / dist) * force; + source.vy += (dy / dist) * force; } if (!target.pinned) { - target.vx -= dx * force; - target.vy -= dy * force; + target.vx -= (dx / dist) * force; + target.vy -= (dy / dist) * force; } ctx.strokeStyle = "rgba(100, 116, 139, 0.28)"; ctx.lineWidth = Math.max(1, Math.min(4, Number(link.value || 1))); @@ -1205,20 +1322,7 @@ for (let i = 0; i < nodes.length; i += 1) { for (let j = i + 1; j < Math.min(nodes.length, i + 45); j += 1) { - const a = nodes[i]; - const b = nodes[j]; - const dx = b.x - a.x; - const dy = b.y - a.y; - const dist = Math.max(18, Math.hypot(dx, dy)); - const repel = Math.min(0.2, 260 / (dist * dist)); - if (!a.pinned) { - a.vx -= dx * repel; - a.vy -= dy * repel; - } - if (!b.pinned) { - b.vx += dx * repel; - b.vy += dy * repel; - } + separateGraphNodes(nodes[i], nodes[j], 0.022); } } @@ -1226,17 +1330,18 @@ const cx = width / 2 + Math.sin(index) * 30; const cy = height / 2 + Math.cos(index) * 24; if (!node.pinned) { - node.vx += ((node.homeX || cx) - node.x) * 0.0011 + (cx - node.x) * 0.00012; - node.vy += ((node.homeY || cy) - node.y) * 0.0011 + (cy - node.y) * 0.00012; - node.vx *= 0.86; - node.vy *= 0.86; + node.vx += ((node.homeX || cx) - node.x) * GRAPH_HOME_STRENGTH + (cx - node.x) * GRAPH_CENTER_STRENGTH; + node.vy += ((node.homeY || cy) - node.y) * GRAPH_HOME_STRENGTH + (cy - node.y) * GRAPH_CENTER_STRENGTH; + node.vx *= 0.74; + node.vy *= 0.74; node.x += node.vx; node.y += node.vy; } const radius = node.radius || graphNodeRadius(node); - const min = radius + GRAPH_SAFE_PADDING; - node.x = clamp(node.x, min, width - min); - node.y = clamp(node.y, min, height - min); + if (clampGraphNode(node, width, height) && !node.pinned) { + node.vx *= 0.12; + node.vy *= 0.12; + } const isHovered = state.graph.hovered === node || state.graph.dragged?.node === node; if (isHovered) { ctx.fillStyle = "rgba(15, 159, 143, 0.14)"; @@ -1250,7 +1355,11 @@ ctx.fill(); ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--text").trim() || "#162033"; ctx.font = "12px system-ui"; - ctx.fillText(String(node.name || "").slice(0, 12), node.x + radius + 4, node.y + 4); + const label = String(node.name || node.label || "").slice(0, 12); + const labelWidth = ctx.measureText(label).width; + const labelX = clamp(node.x + radius + 4, 6, width - labelWidth - 6); + const labelY = clamp(node.y + 4, 14, height - 6); + ctx.fillText(label, labelX, labelY); }); requestAnimationFrame(tickGraph); } @@ -1268,12 +1377,18 @@ const oldHeight = state.graph.height || height; canvas.width = nextWidth; canvas.height = nextHeight; - state.graph.nodes.forEach((node) => { - const min = node.radius + GRAPH_SAFE_PADDING; + state.graph.nodes.forEach((node, index) => { + const radius = node.radius || graphNodeRadius(node); + const min = graphNodeMargin(radius); + const home = graphHomePosition(node.id, index, state.graph.nodes.length, width, height, radius); node.x = clamp((node.x / oldWidth) * width, min, width - min); node.y = clamp((node.y / oldHeight) * height, min, height - min); - node.homeX = clamp(((node.homeX || node.x) / oldWidth) * width, min, width - min); - node.homeY = clamp(((node.homeY || node.y) / oldHeight) * height, min, height - min); + node.homeX = home.x; + node.homeY = home.y; + if (options.force && !node.pinned) { + node.x = node.x * 0.55 + home.x * 0.45; + node.y = node.y * 0.55 + home.y * 0.45; + } }); } state.graph.width = width; @@ -1305,6 +1420,53 @@ return Math.max(9, Math.min(24, Number.isFinite(raw) ? raw : 12)); } + function graphNodeMargin(radius) { + return Math.max(52, radius + GRAPH_SAFE_PADDING); + } + + function clampGraphNode(node, width, height) { + const radius = node.radius || graphNodeRadius(node); + const min = graphNodeMargin(radius); + const nextX = clamp(node.x, min, width - min); + const nextY = clamp(node.y, min, height - min); + const clamped = nextX !== node.x || nextY !== node.y; + node.x = nextX; + node.y = nextY; + return clamped; + } + + function separateGraphNodes(a, b, strength) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + const minDist = (a.radius || graphNodeRadius(a)) + (b.radius || graphNodeRadius(b)) + 20; + if (dist >= minDist) return; + const shift = (minDist - dist) / minDist * strength; + const nx = dx / dist; + const ny = dy / dist; + if (!a.pinned) { + a.vx -= nx * shift; + a.vy -= ny * shift; + a.x -= nx * shift * 6; + a.y -= ny * shift * 6; + } + if (!b.pinned) { + b.vx += nx * shift; + b.vy += ny * shift; + b.x += nx * shift * 6; + b.y += ny * shift * 6; + } + } + + function graphStableSeed(value) { + let hash = 0; + const text = String(value || ""); + for (let index = 0; index < text.length; index += 1) { + hash = (hash * 31 + text.charCodeAt(index)) >>> 0; + } + return hash % 997; + } + function graphValueKey(value) { if (value && typeof value === "object") { return String(value.id ?? value.name ?? value.label ?? ""); diff --git a/pages/dashboard/index.html b/pages/dashboard/index.html index 817008d1..679522e9 100644 --- a/pages/dashboard/index.html +++ b/pages/dashboard/index.html @@ -44,7 +44,7 @@

Self Learning

完整内嵌 WebUI

- +
@@ -243,18 +243,18 @@

人格学习

-
-
+
+

当前人格状态


             
-
+

人格列表

-
+

人格备份

0 diff --git a/pages/dashboard/styles.css b/pages/dashboard/styles.css index 7cf2a3da..4670009d 100644 --- a/pages/dashboard/styles.css +++ b/pages/dashboard/styles.css @@ -12,7 +12,7 @@ --rose: #e11d48; --green: #16a34a; --shadow: 0 18px 44px rgba(15, 23, 42, 0.09); - --spring: cubic-bezier(0.2, 0.82, 0.18, 1); + --spring: cubic-bezier(0.22, 1, 0.36, 1); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } @@ -216,7 +216,7 @@ h1 { color: var(--text); text-decoration: none; cursor: pointer; - transition: transform 180ms var(--spring), border-color 180ms ease, background 180ms ease; + transition: transform 160ms var(--spring), border-color 160ms ease, background 160ms ease, opacity 160ms ease; } .icon-button { @@ -249,6 +249,13 @@ h1 { opacity: 0.55; } +button:disabled, +button.is-busy { + cursor: wait; + opacity: 0.64; + transform: none; +} + .icon-button:hover, .ghost-button:hover, .solid-button:hover, @@ -285,7 +292,7 @@ h1 { .page { display: none; - animation: pageIn 260ms var(--spring); + animation: pageIn 190ms var(--spring); } .page.active { @@ -295,7 +302,7 @@ h1 { @keyframes pageIn { from { opacity: 0; - transform: translateY(8px); + transform: translateY(4px); } to { opacity: 1; @@ -550,7 +557,7 @@ h1 { } .module-card:hover { - transform: translateY(-2px); + transform: translateY(-1px); } .module-card-head { @@ -690,6 +697,7 @@ h1 { .compact-table, .content-list, .config-form { + min-width: 0; display: grid; gap: 8px; } @@ -699,6 +707,7 @@ h1 { .table-row, .config-field, .pattern-column { + min-width: 0; display: grid; gap: 9px; padding: 12px; @@ -715,7 +724,7 @@ h1 { .review-main p, .content-item p { margin-bottom: 0; - word-break: break-word; + overflow-wrap: anywhere; } .table-row { @@ -724,7 +733,23 @@ h1 { } .rich-row { - grid-template-columns: minmax(180px, 1fr) auto auto auto; + grid-template-columns: minmax(180px, 1fr) auto auto minmax(118px, auto); +} + +.table-row > *, +.rich-row > *, +.config-field > *, +.panel > * { + min-width: 0; +} + +.table-row span, +.table-row strong, +.table-row small, +.rich-row span, +.rich-row strong, +.rich-row small { + overflow-wrap: anywhere; } .row-actions { @@ -792,18 +817,22 @@ textarea { color: var(--text); background: var(--surface-muted); white-space: pre-wrap; + overflow-wrap: anywhere; word-break: break-word; } .graph-panel { + container-type: inline-size; padding: 10px; + overflow: hidden; } #graph-canvas { width: 100%; - height: clamp(360px, 56vw, 560px); - max-height: 62vh; - min-height: 360px; + height: clamp(320px, 56.25cqw, 520px); + aspect-ratio: 16 / 9; + max-height: 58vh; + min-height: 320px; display: block; touch-action: none; border-radius: 8px; @@ -815,6 +844,42 @@ textarea { background-size: 34px 34px; } +@supports not (height: 1cqw) { + #graph-canvas { + height: clamp(320px, 48vw, 520px); + } +} + +.persona-layout, +.persona-state-panel, +.persona-list-panel, +.persona-backup-panel { + min-width: 0; +} + +.persona-layout { + grid-template-columns: minmax(0, 1.08fr) minmax(260px, 0.92fr); +} + +.persona-state-panel .stat-grid.compact { + grid-template-columns: repeat(2, minmax(120px, 1fr)); +} + +.persona-state-panel .code-preview { + max-height: min(42vh, 420px); +} + +.persona-list-panel .table-row, +.persona-backup-panel .table-row { + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + +.persona-list-panel .row-actions, +.persona-backup-panel .row-actions { + justify-content: flex-start; +} + #graph-canvas.has-hover { cursor: grab; } @@ -1046,6 +1111,11 @@ textarea { .row-actions { justify-content: flex-start; } + + #graph-canvas { + min-height: 300px; + max-height: none; + } } @media (max-width: 560px) { @@ -1078,3 +1148,19 @@ textarea { font-size: 24px; } } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + } + + .spring-node { + will-change: auto; + transform: none !important; + } +} diff --git a/tests/integration/test_package_imports.py b/tests/integration/test_package_imports.py index a3fbd9a0..d16a2c9d 100644 --- a/tests/integration/test_package_imports.py +++ b/tests/integration/test_package_imports.py @@ -1,9 +1,11 @@ """Package-path import coverage for AstrBot plugin loading.""" +import asyncio import builtins import importlib import importlib.util import sys from pathlib import Path +from types import SimpleNamespace PLUGIN_ROOT = Path(__file__).resolve().parents[2] @@ -118,6 +120,68 @@ def __init__(self): assert routes["/astrbot_plugin_self_learning/page/overview"][0] == api.get_overview assert routes["/astrbot_plugin_self_learning/page/settings/action"][0] == api.post_settings_action + + snapshot = api._build_webui_snapshot(_Plugin(), type("WebUI", (), {"host": "0.0.0.0", "port": 7833})()) + assert snapshot["dashboard_url"] == "http://127.0.0.1:7833" + assert snapshot["bind_host"] == "0.0.0.0" + assert snapshot["public_url_strategy"] == "browser_host_for_local_bind" + assert "localhost" in snapshot["client_rewrite_hosts"] + finally: + _cleanup_alias(alias) + + +def test_plugin_page_dependency_install_accepts_legacy_plugin_page_confirmation(monkeypatch): + alias = "data.plugins.astrbot_plugin_self_learning_pageapi_deps_pkgtest" + _cleanup_alias(alias) + + class _Process: + returncode = 0 + + async def communicate(self): + return b"installed", b"" + + captured: dict[str, object] = {} + + async def _fake_subprocess(*cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return _Process() + + try: + _load_plugin_package(alias) + module = importlib.import_module(f"{alias}.core.page_api") + imports = SimpleNamespace( + DEPENDENCY_TIERS={ + "full": { + "label": "全能力依赖", + "packages": ["quart"], + } + }, + MANUAL_DEPENDENCY_INSTALL_SOURCE="system_settings", + PIP_MIRROR_SOURCES={ + "default": { + "label": "PyPI 默认源", + "index_url": None, + } + }, + ) + monkeypatch.setattr(module.PluginPageApi, "_imports", staticmethod(lambda: imports)) + monkeypatch.setattr(module.asyncio, "create_subprocess_exec", _fake_subprocess) + + result = asyncio.run( + module.PluginPageApi(object())._install_dependencies( + { + "manual_confirm": True, + "user_confirmed": True, + "mode": "plugin_page", + } + ) + ) + + assert result["success"] is True + assert result["tier"] == "full" + assert captured["cmd"][:4] == (sys.executable, "-m", "pip", "install") + assert "quart" in captured["cmd"] finally: _cleanup_alias(alias) diff --git a/tests/integration/test_webui_static_assets.py b/tests/integration/test_webui_static_assets.py index cac8f4ef..a7b6a9e6 100644 --- a/tests/integration/test_webui_static_assets.py +++ b/tests/integration/test_webui_static_assets.py @@ -92,13 +92,34 @@ def test_embedded_plugin_page_uses_astrbot_bridge_and_module_dashboard(): assert "startGraphRender" in script assert "syncGraphCanvasSize" in script assert "hitGraphNode" in script + assert "settleGraphLayout" in script + assert "graphHomePosition" in script + assert "GRAPH_HOME_STRENGTH" in script + assert "graphNodeMargin" in script + assert "manual_dependency_source" in script + assert "installButton.disabled = true" in script + assert "正在调用 pip 安装依赖" in script + assert "function resolveHostUrl" in script + assert "function localNavigationHost" in script + assert "browserHost = window.location.hostname" in script + assert 'resolveHostUrl(webui.dashboard_url || "")' in script + assert "resolveHostUrl(link.url || \"#\")" in script + assert "resolveHostUrl(dash.external_url || dash.official_page_url || dash.url || \"#\")" in script assert 'id="physics-canvas"' in index assert 'id="graph-canvas"' in index assert 'id="graph-canvas" width=' not in index + assert 'id="full-dashboard-link" href="#"' in index + assert "persona-layout" in index + assert 'http://127.0.0.1:7833' not in index assert ".module-card" in styles assert ".ring-chart" in styles assert ".sidebar" in styles assert ".graph-panel" in styles + assert ".persona-layout" in styles + assert "overflow-wrap: anywhere" in styles + assert "aspect-ratio: 16 / 9" in styles + assert "button:disabled" in styles + assert "@media (prefers-reduced-motion: reduce)" in styles assert "touch-action: none" in styles