${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