From 1bb2800b04d4ac92e9ff2ce66acc2bdf4b3da709 Mon Sep 17 00:00:00 2001 From: YumemiDream <1803068130@qq.com> Date: Sun, 7 Jun 2026 14:25:09 +0800 Subject: [PATCH] feat(webui): refactor dashboard and add plugin page --- .astrbot-plugin/i18n/en-US.json | 7 + .astrbot-plugin/i18n/zh-CN.json | 7 + pages/dashboard/index.html | 187 ++ tests/integration/test_webui_static_assets.py | 104 ++ tests/unit/test_persona_review_service.py | 58 + tests/unit/test_webui_manager.py | 52 + web_res/static/html/dashboard.html | 1503 ++++++++++++++--- webui/blueprints/persona_reviews.py | 3 +- webui/manager.py | 64 + webui/services/persona_review_service.py | 15 +- 10 files changed, 1721 insertions(+), 279 deletions(-) create mode 100644 .astrbot-plugin/i18n/en-US.json create mode 100644 .astrbot-plugin/i18n/zh-CN.json create mode 100644 pages/dashboard/index.html diff --git a/.astrbot-plugin/i18n/en-US.json b/.astrbot-plugin/i18n/en-US.json new file mode 100644 index 00000000..faf31bbc --- /dev/null +++ b/.astrbot-plugin/i18n/en-US.json @@ -0,0 +1,7 @@ +{ + "pages": { + "dashboard": { + "title": "Dashboard" + } + } +} diff --git a/.astrbot-plugin/i18n/zh-CN.json b/.astrbot-plugin/i18n/zh-CN.json new file mode 100644 index 00000000..faf31bbc --- /dev/null +++ b/.astrbot-plugin/i18n/zh-CN.json @@ -0,0 +1,7 @@ +{ + "pages": { + "dashboard": { + "title": "Dashboard" + } + } +} diff --git a/pages/dashboard/index.html b/pages/dashboard/index.html new file mode 100644 index 00000000..6e541460 --- /dev/null +++ b/pages/dashboard/index.html @@ -0,0 +1,187 @@ + + + + + + Self Learning Dashboard + + + + +
+
+ +
+
+

正在加载 Dashboard

+

正在通过 AstrBot Plugin Page bridge 读取 Self Learning WebUI 地址。

+
+
+
+
+ + + + diff --git a/tests/integration/test_webui_static_assets.py b/tests/integration/test_webui_static_assets.py index 94b3ac9d..dc3c0e04 100644 --- a/tests/integration/test_webui_static_assets.py +++ b/tests/integration/test_webui_static_assets.py @@ -5,6 +5,7 @@ PLUGIN_ROOT = Path(__file__).resolve().parents[2] HTML_FILES = [ + PLUGIN_ROOT / "pages" / "dashboard" / "index.html", PLUGIN_ROOT / "web_res" / "static" / "html" / "change_password.html", PLUGIN_ROOT / "web_res" / "static" / "html" / "dashboard.html", PLUGIN_ROOT / "web_res" / "static" / "html" / "graph_share.html", @@ -41,6 +42,26 @@ def test_webui_frontend_vendor_assets_exist(): assert path.exists(), f"Missing vendored frontend asset: {path}" +def test_astrbot_plugin_pages_dashboard_entry_exists(): + text = (PLUGIN_ROOT / "pages" / "dashboard" / "index.html").read_text(encoding="utf-8") + zh_i18n = (PLUGIN_ROOT / ".astrbot-plugin" / "i18n" / "zh-CN.json").read_text(encoding="utf-8") + en_i18n = (PLUGIN_ROOT / ".astrbot-plugin" / "i18n" / "en-US.json").read_text(encoding="utf-8") + + assert "AstrBotPluginPage" in text + assert "/api/plugin/page/bridge-sdk.js" in text + assert "apiGet('dashboard_url')" in text + assert 'id="dashboardFrame"' in text + assert "setDashboardUrl(payload.url)" in text + assert "toolbar" not in text + assert "brand-title" not in text + assert "brand-meta" not in text + assert "pageLabel" not in text + assert "openDashboard" not in text + assert "reloadDashboard" not in text + assert '"dashboard"' in zh_i18n + assert '"dashboard"' in en_i18n + + def test_dashboard_exposes_learning_content_browser(): text = (PLUGIN_ROOT / "web_res" / "static" / "html" / "dashboard.html").read_text(encoding="utf-8") @@ -67,11 +88,94 @@ def test_dashboard_review_details_use_backend_structured_fields(): assert "after_begin_dialogs" in text assert "/api/persona_updates/reviewed?limit=5" in text assert "reviewedPersonaList" in text + assert "reviewedStyleList" in text + assert "styleReviewed" in text + assert "/api/persona_updates/reviewed?limit=5&source=persona" in text + assert "/api/persona_updates/reviewed?limit=5&source=style" in text + assert "renderReviewedStyleHistory" in text assert "追加到 begin_dialogs" in text assert "item.definition || item.meaning || item.review_detail" in text assert "renderContextExamples(item)" in text +def test_dashboard_review_queue_uses_sidebar_categories(): + text = (PLUGIN_ROOT / "web_res" / "static" / "html" / "dashboard.html").read_text(encoding="utf-8") + + assert "review-sidebar" in text + assert "review-workspace" in text + assert 'data-review-tab="persona"' in text + assert 'data-review-tab="style"' in text + assert 'data-review-tab="jargon"' in text + assert 'data-review-tab="persona-state"' in text + assert 'data-review-tab="reviewed"' in text + assert 'data-review-tab="batches"' in text + assert "setReviewTab" in text + assert "updateReviewNavCounts" in text + + +def test_dashboard_settings_uses_sidebar_categories(): + text = (PLUGIN_ROOT / "web_res" / "static" / "html" / "dashboard.html").read_text(encoding="utf-8") + + assert "settings-sidebar" in text + assert "settings-workspace" in text + assert "settingsSidebarSummary" in text + assert "settingsNav" in text + assert 'data-config-group="all"' in text + assert "dependencyInstallPanel" in text + assert "renderSettingsNav" in text + assert "setConfigGroup" in text + assert "configGroupIcon" in text + assert "SETTINGS_NAV_SECTIONS" in text + assert "常用入口" in text + assert "学习链路" in text + assert "人格与关系" in text + assert "数据与运行" in text + assert "settings-nav::-webkit-scrollbar" in text + assert "scrollbar-width: thin" in text + assert "Dependency_Install_Guide: 'extension'" in text + assert "deployed_code" not in text + + +def test_dashboard_settings_right_pane_uses_dense_controls(): + text = (PLUGIN_ROOT / "web_res" / "static" / "html" / "dashboard.html").read_text(encoding="utf-8") + + assert "grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));" in text + assert "function configFieldLayoutClasses" in text + assert "is-bool" in text + assert "is-list" in text + assert "is-wide" in text + assert "function renderConfigSwitch" in text + assert 'class="config-switch-input"' in text + assert "switch-track" in text + assert "switch-thumb" in text + assert "bool-state" in text + assert "field.type === 'bool'" in text + assert "renderConfigSwitch(field, fieldId, Boolean(displayValue))" in text + assert "function syncConfigSwitchState" in text + + +def test_dashboard_global_header_uses_compact_brand_copy(): + text = (PLUGIN_ROOT / "web_res" / "static" / "html" / "dashboard.html").read_text(encoding="utf-8") + + assert '
Self Learning
' in text + assert "SELF LEARNING / DASHBOARD" not in text + assert '
' not in text + assert 'id="summaryText"' not in text + assert 'id="refreshBtn"' in text + assert 'id="settingsJumpBtn"' in text + assert 'id="themeBtn"' in text + + +def test_dashboard_jargon_sort_does_not_expose_misleading_occurrences_order(): + text = (PLUGIN_ROOT / "web_res" / "static" / "html" / "dashboard.html").read_text(encoding="utf-8") + + assert 'value="occurrences"' not in text + assert "按出现次数" not in text + assert "sort === 'occurrences'" not in text + assert "样本 ${formatCount(item.occurrences)}" in text + assert "['样本', jargonStats.total_occurrences]" in text + + def test_dashboard_review_deletes_use_inline_confirmation(): text = (PLUGIN_ROOT / "web_res" / "static" / "html" / "dashboard.html").read_text(encoding="utf-8") diff --git a/tests/unit/test_persona_review_service.py b/tests/unit/test_persona_review_service.py index c90e17b2..adf827df 100644 --- a/tests/unit/test_persona_review_service.py +++ b/tests/unit/test_persona_review_service.py @@ -442,6 +442,64 @@ async def test_get_reviewed_persona_updates(self, mock_container): assert result['updates'][0]['persona_change_snapshot'] == snapshot assert mock_container.database_manager.get_persona_change_snapshot.await_count == 3 + @pytest.mark.asyncio + async def test_get_reviewed_persona_updates_filters_persona_sources(self, mock_container): + """Persona reviewed history should not be crowded out by style review records.""" + service = PersonaReviewService(mock_container) + + traditional = [{'id': 1, 'status': 'approved', 'review_time': 1000}] + persona_learning = [{'id': 2, 'status': 'approved', 'review_time': 2000}] + + mock_container.persona_updater.get_reviewed_persona_updates.return_value = traditional + mock_container.database_manager.get_reviewed_persona_learning_updates.return_value = persona_learning + mock_container.database_manager.get_reviewed_style_learning_updates.return_value = [ + {'id': 3, 'status': 'rejected', 'review_time': 3000} + ] + mock_container.database_manager.get_persona_change_snapshot.return_value = None + + result = await service.get_reviewed_persona_updates( + limit=50, + offset=0, + source_filter='persona', + ) + + assert result['success'] is True + assert result['total'] == 2 + assert [item['review_source'] for item in result['updates']] == [ + 'persona_learning', + 'traditional', + ] + mock_container.database_manager.get_reviewed_style_learning_updates.assert_not_called() + + @pytest.mark.asyncio + async def test_get_reviewed_persona_updates_filters_style_source(self, mock_container): + """Style reviewed history should stay separate from persona review records.""" + service = PersonaReviewService(mock_container) + + mock_container.persona_updater.get_reviewed_persona_updates.return_value = [ + {'id': 1, 'status': 'approved', 'review_time': 1000} + ] + mock_container.database_manager.get_reviewed_persona_learning_updates.return_value = [ + {'id': 2, 'status': 'approved', 'review_time': 2000} + ] + mock_container.database_manager.get_reviewed_style_learning_updates.return_value = [ + {'id': 3, 'status': 'rejected', 'review_time': 3000} + ] + mock_container.database_manager.get_persona_change_snapshot.return_value = None + + result = await service.get_reviewed_persona_updates( + limit=50, + offset=0, + source_filter='style', + ) + + assert result['success'] is True + assert result['total'] == 1 + assert result['updates'][0]['id'] == 'style_3' + assert result['updates'][0]['review_source'] == 'style_learning' + mock_container.persona_updater.get_reviewed_persona_updates.assert_not_called() + mock_container.database_manager.get_reviewed_persona_learning_updates.assert_not_called() + @pytest.mark.asyncio async def test_revert_persona_update_traditional(self, mock_container): """Test reverting traditional persona update""" diff --git a/tests/unit/test_webui_manager.py b/tests/unit/test_webui_manager.py index f8da42a1..fb996c2d 100644 --- a/tests/unit/test_webui_manager.py +++ b/tests/unit/test_webui_manager.py @@ -67,3 +67,55 @@ async def start(self): assert database_manager.start_calls == 1 assert manager._database_degraded is True assert manager._database_start_error == "connection was closed in the middle of operation" + + +def test_webui_manager_registers_astrbot_plugin_page_dashboard_url_api(): + calls = [] + context = SimpleNamespace( + register_web_api=lambda route, handler, methods, desc: calls.append( + (route, handler, methods, desc) + ) + ) + config = SimpleNamespace( + enable_web_interface=True, + web_interface_host="0.0.0.0", + web_interface_port=7833, + ) + + WebUIManager( + plugin_config=config, + context=context, + factory_manager=object(), + perf_tracker="perf", + group_id_to_unified_origin={}, + plugin_instance=SimpleNamespace(name="astrbot_plugin_self_learning"), + ) + + routes = {item[0] for item in calls} + assert "astrbot_plugin_self_learning/dashboard_url" in routes + assert any(item[2] == ["GET"] for item in calls) + + +def test_webui_manager_public_webui_url_uses_configured_host(): + manager = WebUIManager( + plugin_config=SimpleNamespace( + enable_web_interface=True, + web_interface_host="203.0.113.10", + web_interface_port=7833, + ), + context=SimpleNamespace(), + factory_manager=object(), + perf_tracker="perf", + group_id_to_unified_origin={}, + ) + + assert manager._public_webui_base_url() == "http://203.0.113.10:7833" + + +def test_webui_manager_dashboard_asset_version_uses_dashboard_file_mtime(): + manager = _manager() + + version = manager._dashboard_asset_version() + + assert version.isdigit() + assert int(version) > 0 diff --git a/web_res/static/html/dashboard.html b/web_res/static/html/dashboard.html index a602100c..15cce14e 100644 --- a/web_res/static/html/dashboard.html +++ b/web_res/static/html/dashboard.html @@ -191,40 +191,19 @@ .topbar { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; gap: 16px; - padding: 8px 0 20px; - border-bottom: 1px solid var(--border); - margin-bottom: 20px; + padding: 0 0 14px; + margin-bottom: 14px; } - .brand { + .app-brand { min-width: 0; - } - - .eyebrow { - color: var(--muted); - font-size: 12px; + color: var(--text); + font-size: 30px; + line-height: 1.1; letter-spacing: 0; - text-transform: uppercase; - } - - .brand h1 { - margin: 4px 0 0; - font-size: var(--text-2xl); - line-height: 1.15; - letter-spacing: -0.02em; font-weight: 800; - background: var(--grad-primary); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - .brand p { - margin: 8px 0 0; - color: var(--muted); - max-width: 880px; } .toolbar { @@ -769,6 +748,164 @@ gap: 16px; } + .review-workspace { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 16px; + align-items: start; + } + + .review-sidebar { + position: sticky; + top: 16px; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--panel); + box-shadow: var(--shadow-sm); + } + + .review-sidebar-head { + padding: 2px 4px 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 12px; + } + + .review-sidebar-kicker { + color: var(--muted); + font-size: 11px; + font-weight: 700; + } + + .review-sidebar-head h3 { + margin: 4px 0 2px; + font-size: var(--text-lg); + line-height: 1.25; + } + + .review-sidebar-head p { + margin: 0; + color: var(--muted); + font-size: 12px; + } + + .review-nav { + display: grid; + gap: 8px; + } + + .review-nav-group { + margin: 14px 4px 8px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + } + + .review-nav-btn { + width: 100%; + min-height: 58px; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text); + cursor: pointer; + text-align: left; + } + + .review-nav-btn:hover { + border-color: var(--border); + background: var(--surface-hover); + } + + .review-nav-btn.active { + border-color: var(--accent); + background: var(--selection); + box-shadow: inset 3px 0 0 var(--accent); + } + + .review-nav-icon { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-soft); + color: var(--accent); + } + + .review-nav-icon .material-icons { + font-size: 18px; + } + + .review-nav-text { + min-width: 0; + } + + .review-nav-title { + display: block; + font-size: 13px; + font-weight: 700; + line-height: 1.2; + } + + .review-nav-desc { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 12px; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .review-nav-badge { + min-width: 28px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: var(--panel-soft); + color: var(--muted); + font-size: 12px; + font-weight: 700; + } + + .review-nav-btn.active .review-nav-badge { + border-color: transparent; + background: var(--accent); + color: #fff; + } + + .review-main { + min-width: 0; + } + + .review-tab-panel { + display: none; + gap: 16px; + } + + .review-tab-panel.active { + display: grid; + } + + .review-split-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(300px, 0.85fr); + gap: 16px; + } + .panel { background: var(--panel); border: 1px solid var(--border); @@ -1533,6 +1670,172 @@ margin-top: 18px; } + .settings-workspace { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 16px; + align-items: start; + } + + .settings-sidebar { + position: sticky; + top: 16px; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--panel); + box-shadow: var(--shadow-sm); + } + + .settings-sidebar-head { + padding: 2px 4px 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 12px; + } + + .settings-sidebar-head h3 { + margin: 4px 0 2px; + font-size: var(--text-lg); + line-height: 1.25; + } + + .settings-sidebar-head p { + margin: 0; + color: var(--muted); + font-size: 12px; + } + + .settings-nav { + display: grid; + gap: 8px; + max-height: calc(100dvh - 220px); + overflow: auto; + padding-right: 4px; + scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: rgba(99, 102, 241, 0.42) transparent; + } + + .settings-nav::-webkit-scrollbar { + width: 8px; + } + + .settings-nav::-webkit-scrollbar-track { + background: transparent; + } + + .settings-nav::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background: rgba(99, 102, 241, 0.34); + background-clip: content-box; + } + + .settings-nav::-webkit-scrollbar-thumb:hover { + background: rgba(99, 102, 241, 0.56); + background-clip: content-box; + } + + .settings-nav-group { + margin: 14px 4px 8px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + } + + .settings-nav-btn { + width: 100%; + min-height: 56px; + display: grid; + grid-template-columns: 32px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text); + cursor: pointer; + text-align: left; + } + + .settings-nav-btn:hover { + border-color: var(--border); + background: var(--surface-hover); + } + + .settings-nav-btn.active { + border-color: var(--accent); + background: var(--selection); + box-shadow: inset 3px 0 0 var(--accent); + } + + .settings-nav-icon { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-soft); + color: var(--accent); + overflow: hidden; + flex: none; + } + + .settings-nav-icon .material-icons { + font-size: 18px; + line-height: 1; + } + + .settings-nav-text { + min-width: 0; + } + + .settings-nav-title { + display: block; + font-size: 13px; + font-weight: 700; + line-height: 1.2; + } + + .settings-nav-desc { + display: block; + margin-top: 3px; + color: var(--muted); + font-size: 12px; + line-height: 1.25; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .settings-nav-badge { + min-width: 28px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: var(--radius-full); + background: var(--panel-soft); + color: var(--muted); + font-size: 12px; + font-weight: 700; + } + + .settings-nav-btn.active .settings-nav-badge { + border-color: transparent; + background: var(--accent); + color: #fff; + } + + .settings-main { + min-width: 0; + } + .settings-head { display: flex; justify-content: space-between; @@ -1769,7 +2072,7 @@ .settings-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: 12px; } @@ -1825,20 +2128,38 @@ .config-fields { display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; padding: 0 16px 16px; + align-items: stretch; } .config-field { display: grid; + grid-template-rows: auto auto 1fr; gap: 8px; - padding-top: 12px; - border-top: 1px solid var(--border); + min-width: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel-soft); + box-sizing: border-box; } - .config-field:first-child { - padding-top: 0; - border-top: 0; + .config-field:hover { + border-color: var(--border-strong); + background: var(--panel); + } + + .config-field.is-wide, + .config-field.is-list, + .config-field.is-longtext { + grid-column: 1 / -1; + } + + .config-field.is-bool { + grid-template-rows: auto 1fr auto; + min-height: 132px; } .field-head { @@ -1846,6 +2167,7 @@ justify-content: space-between; gap: 10px; align-items: flex-start; + min-width: 0; } .field-label { @@ -1853,6 +2175,8 @@ font-size: 13px; line-height: 1.35; font-weight: 600; + min-width: 0; + overflow-wrap: anywhere; } .field-meta { @@ -1860,6 +2184,7 @@ flex-wrap: wrap; gap: 6px; justify-content: flex-end; + flex: none; } .field-pill { @@ -1890,34 +2215,108 @@ background: var(--input-bg); color: var(--text); padding: 10px 12px; + min-height: 40px; box-sizing: border-box; box-shadow: none; + font-size: 13px; } .config-control textarea { - min-height: 92px; + min-height: 112px; resize: vertical; } - .config-control input[type="checkbox"] { - width: 18px; - height: 18px; - margin: 0; - padding: 0; - accent-color: var(--accent); - } - .config-control .bool-row { - display: inline-flex; + position: relative; + width: 100%; + display: flex; align-items: center; - gap: 10px; - min-height: 36px; + justify-content: space-between; + gap: 12px; + min-height: 48px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--input-bg); color: var(--text); font-size: 13px; + box-sizing: border-box; + cursor: pointer; + } + + .config-control .bool-row.disabled { + cursor: not-allowed; + opacity: .72; + } + + .config-control .config-switch-input { + position: absolute; + width: 1px; + height: 1px; + min-height: 0; + margin: -1px; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + opacity: 0; + pointer-events: none; + } + + .switch-track { + position: relative; + width: 44px; + height: 24px; + flex: none; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel); + transition: background .18s ease, border-color .18s ease; + } + + .switch-thumb { + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: 999px; + background: var(--muted); + box-shadow: var(--shadow-sm); + transition: transform .18s ease, background .18s ease; + } + + .config-control .config-switch-input:checked + .switch-track { + border-color: var(--accent); + background: var(--accent); } - .config-control .bool-row span { + .config-control .config-switch-input:checked + .switch-track .switch-thumb { + transform: translateX(20px); + background: #fff; + } + + .config-control .config-switch-input:focus-visible + .switch-track { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + .config-control .config-switch-input:disabled + .switch-track { + filter: grayscale(.45); + } + + .bool-state { color: var(--muted); + font-weight: 700; + white-space: nowrap; + } + + .config-field.is-bool .config-control { + align-self: center; + } + + .config-field.is-bool .field-hint { + align-self: end; } .persona-picker { @@ -2007,6 +2406,17 @@ grid-template-columns: 1fr; } + .review-workspace, + .settings-workspace, + .review-split-grid { + grid-template-columns: 1fr; + } + + .review-sidebar, + .settings-sidebar { + position: static; + } + .settings-head { flex-direction: column; align-items: stretch; @@ -2031,7 +2441,18 @@ } .topbar { + align-items: flex-start; flex-direction: column; + gap: 10px; + } + + .topbar .toolbar { + width: 100%; + justify-content: flex-start; + } + + .app-brand { + font-size: 24px; } .page-titlebar { @@ -2046,12 +2467,18 @@ grid-template-columns: 1fr; } - .panel-head { - flex-direction: column; + .review-sidebar, + .settings-sidebar { + padding: 12px; } - .brand h1 { - font-size: 24px; + .review-nav-btn, + .settings-nav-btn { + min-height: 54px; + } + + .panel-head { + flex-direction: column; } .settings-grid { @@ -2105,12 +2532,8 @@
-
-
-
SELF LEARNING / DASHBOARD
-

监控板

-

正在连接学习链路。

-
+
+
Self Learning
schedule @@ -2428,163 +2851,250 @@

筛选模型调用

-
-
-
- -
-
-

学习维度

- -- -
-
-
-
-
-
- - - -
-
-
-

审查队列

-

处理待审人格、风格审查、黑话候选和最近学习批次。

-
- apps模块 -
- -
审查操作就绪。
- -
-
-
-

当前人格状态

- -- -
-
-
-
-
-
- -
-
-

人格备份

- -- -
-
-
-
-
-
- -
-
-
-

待审人格

- 0 -
-
-
-
-
- -
-
-

风格审查

- 0 +
+
-
-
+ +
+
+

学习维度

+ -- +
+
+
+
+
-
+
-
-
-
-

最近已审人格

- 0 -
-
-
+
+
+
+

审查队列

+

处理待审人格、风格审查、黑话候选和最近学习批次。

+ apps模块
-
-
-
-

黑话与批次

- -- -
-
- - - -
-
-
-
+
审查操作就绪。
-
-
+
+ + +
+
+
+
+

待审人格

+ 0 +
+
+
+
-
-
-
+
-
-
-
-

最近批次

- -- -
-
- -
-
-
-
+ + + + + + + + +
-
-
+
@@ -2806,37 +3316,60 @@

全量设置

-
-
-
DEPENDENCIES
-

手动安装依赖

-

基础能力依赖覆盖人格审查、黑话学习、表达方式学习与 WebUI。全能力依赖包含图谱、监控、外部数据库、LightRAG 与 Mem0。

-
-
- -
- - + + + +
+
+
+
DEPENDENCIES
+

手动安装依赖

+

基础能力依赖覆盖人格审查、黑话学习、表达方式学习与 WebUI。全能力依赖包含图谱、监控、外部数据库、LightRAG 与 Mem0。

+
+
+ +
+ + +
+
+
依赖安装需手动确认,不会在插件安装或启动时自动执行。
+
+
+
正在加载配置中。
-
依赖安装需手动确认,不会在插件安装或启动时自动执行。
-
-
-
正在加载配置中。
@@ -2850,10 +3383,26 @@

手动安装依赖

embedding: 'Embedding', rerank: 'Reranker', }; + function safeLocalStorageGet(key, fallback = null) { + try { + return localStorage.getItem(key) ?? fallback; + } catch { + return fallback; + } + } + + function safeLocalStorageSet(key, value) { + try { + localStorage.setItem(key, value); + } catch { + // Storage may be unavailable in sandboxed AstrBot Plugin Pages. + } + } + const state = { charts: {}, data: null, - theme: themeFromDocument || localStorage.getItem('sl-dashboard-theme') || 'light', + theme: themeFromDocument || safeLocalStorageGet('sl-dashboard-theme', 'light') || 'light', config: { schema: null, original: {}, @@ -2862,6 +3411,7 @@

手动安装依赖

loading: false, saving: false, search: '', + activeGroup: 'all', }, personas: { items: [], @@ -2870,9 +3420,12 @@

手动安装依赖

}, dependencyInstall: { busy: false, - mirror: localStorage.getItem('sl-pip-mirror') || 'default', + mirror: safeLocalStorageGet('sl-pip-mirror', 'default') || 'default', }, actionBusy: new Set(), + reviews: { + tab: 'persona', + }, personaState: { groupId: 'default', }, @@ -3872,6 +4425,15 @@

手动安装依赖

return map[target] || 'home'; } + function getReviewTabForInsightTarget(target) { + const map = { + reviews: 'persona', + jargon: 'jargon', + batches: 'batches', + }; + return map[target] || null; + } + function jumpToInsightTarget(target) { const targetId = getInsightTargetId(target); if (!targetId) { @@ -3879,6 +4441,10 @@

手动安装依赖

} navigateToPage(getInsightTargetPage(target), { keepScroll: true }); + const reviewTab = getReviewTabForInsightTarget(target); + if (reviewTab) { + setReviewTab(reviewTab); + } window.setTimeout(() => { const element = $(targetId); if (!element) { @@ -4083,6 +4649,76 @@

手动安装依赖

return `置信 ${formatPercent(score <= 1 ? score * 100 : score)}`; } + function setReviewTab(tab) { + const validTabs = ['persona', 'style', 'jargon', 'persona-state', 'reviewed', 'batches']; + const nextTab = validTabs.includes(tab) ? tab : 'persona'; + state.reviews.tab = nextTab; + + document.querySelectorAll('[data-review-tab]').forEach((button) => { + const active = button.dataset.reviewTab === nextTab; + button.classList.toggle('active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + + document.querySelectorAll('[data-review-panel]').forEach((panel) => { + const active = panel.dataset.reviewPanel === nextTab; + panel.classList.toggle('active', active); + panel.hidden = !active; + }); + } + + function updateReviewNavCounts(counts = {}) { + const setText = (id, value) => { + const target = $(id); + if (target) { + target.textContent = value; + } + }; + + const dashboardData = state.data || {}; + const persona = dashboardData.persona || {}; + const personaUpdates = safeArray(persona.updates); + const style = dashboardData.style || {}; + const personaReviewed = dashboardData.personaReviewed || {}; + const styleReviewed = dashboardData.styleReviewed || {}; + const personaBackups = dashboardData.personaBackups || {}; + const jargonStats = extractJargonStats(dashboardData.jargonStats); + const styleBacklogDefault = safeNumber(style.total); + const personaTotal = safeNumber(persona.total); + const personaIncludesStyle = personaUpdates.some((item) => item && item.review_source === 'style_learning'); + const personaBacklogDefault = personaIncludesStyle ? Math.max(0, personaTotal - styleBacklogDefault) : personaTotal; + const pendingJargonDefault = Math.max( + 0, + safeNumber(jargonStats.total_candidates ?? jargonStats.totalCandidates) + - safeNumber(jargonStats.confirmed_jargon ?? jargonStats.confirmedJargon), + ); + const backupTotalDefault = personaBackups.available === false + ? 0 + : safeNumber(personaBackups.total || safeArray(personaBackups.backups).length); + const valueOrDefault = (value, fallback) => ( + value === undefined || value === null ? fallback : safeNumber(value) + ); + + const personaBacklog = valueOrDefault(counts.personaBacklog, personaBacklogDefault); + const styleBacklog = valueOrDefault(counts.styleBacklog, styleBacklogDefault); + const pendingJargon = valueOrDefault(counts.pendingJargon, pendingJargonDefault); + const reviewedTotal = valueOrDefault( + counts.reviewedTotal, + safeNumber(personaReviewed.total) + safeNumber(styleReviewed.total), + ); + const backupTotal = valueOrDefault(counts.backupTotal, backupTotalDefault); + const batchTotal = valueOrDefault(counts.batchTotal, state.batch.total); + const pendingTotal = personaBacklog + styleBacklog + pendingJargon; + + setText('reviewSidebarSummary', `待处理 ${formatCount(pendingTotal)} 条`); + setText('reviewNavPersonaCount', formatCount(personaBacklog)); + setText('reviewNavStyleCount', formatCount(styleBacklog)); + setText('reviewNavJargonCount', formatCount(pendingJargon)); + setText('reviewNavPersonaStateCount', formatCount(backupTotal)); + setText('reviewNavReviewedCount', formatCount(reviewedTotal)); + setText('reviewNavBatchCount', formatCount(batchTotal)); + } + function actionButton(action, id, icon, label, tone = '') { if (id === undefined || id === null || id === '') { return ''; @@ -4238,7 +4874,7 @@

手动安装依赖

return rows.length ? rows.join('') : '
style

暂无风格审查记录

'; } - function renderReviewedPersonaHistory(updates) { + function renderReviewedHistory(updates, emptyText) { const rows = safeArray(updates).slice(0, 5).map((item) => { const sourceMap = { traditional: '传统', @@ -4269,7 +4905,15 @@

手动安装依赖

`; }); - return rows.length ? rows.join('') : '
暂无已审人格记录。
'; + return rows.length ? rows.join('') : `
${escapeHtml(emptyText)}
`; + } + + function renderReviewedPersonaHistory(updates) { + return renderReviewedHistory(updates, '暂无已审人格记录。'); + } + + function renderReviewedStyleHistory(updates) { + return renderReviewedHistory(updates, '暂无已审风格记录。'); } function renderPersonaStatePanel(currentState, backupsPayload) { @@ -4337,6 +4981,7 @@

手动安装依赖

const health = data.health || {}; const persona = data.persona || {}; const personaReviewed = data.personaReviewed || {}; + const styleReviewed = data.styleReviewed || {}; const personaState = data.personaState || {}; const personaBackups = data.personaBackups || {}; const style = data.style || {}; @@ -4359,18 +5004,24 @@

手动安装依赖

const personaTotal = safeNumber(persona.total); const personaPayloadIncludesStyle = personaUpdates.some((item) => item && item.review_source === 'style_learning'); const personaBacklog = personaPayloadIncludesStyle ? Math.max(0, personaTotal - styleBacklog) : personaTotal; + const totalJargonCandidates = safeNumber(jargonStats.total_candidates ?? jargonStats.totalCandidates); + const confirmedJargon = safeNumber(jargonStats.confirmed_jargon ?? jargonStats.confirmedJargon); + const pendingJargon = Math.max(0, totalJargonCandidates - confirmedJargon); const backlog = personaBacklog + styleBacklog; const overallHealth = textOrDash(health.overall || 'unknown'); $('heroHeadline').textContent = '自学习监控板'; $('heroSubline').textContent = `总消息 ${formatCount(totalMessages)} · 筛选率 ${formatPercent(filterRate)} · 待办 ${formatCount(backlog)} 条`; - $('summaryText').textContent = buildSummaryText({ - overallHealth: health.overall || 'unknown', - learningEfficiency, - backlog, - filteredMessages, - totalMessages, - }); + const summaryText = $('summaryText'); + if (summaryText) { + summaryText.textContent = buildSummaryText({ + overallHealth: health.overall || 'unknown', + learningEfficiency, + backlog, + filteredMessages, + totalMessages, + }); + } $('overallHealth').textContent = overallHealth; $('overallEfficiency').textContent = formatPercent(learningEfficiency); @@ -4468,11 +5119,24 @@

手动安装依赖

$('personaCount').textContent = `${formatCount(personaBacklog)} 条`; $('styleCount').textContent = `${formatCount(styleBacklog)} 条`; $('reviewedPersonaCount').textContent = `${formatCount(personaReviewed.total || 0)} 条`; + $('reviewedStyleCount').textContent = `${formatCount(styleReviewed.total || 0)} 条`; $('personaList').innerHTML = renderPersonaQueue(personaUpdates); const styleUpdatesFromPersona = personaUpdates.filter((item) => item && item.review_source === 'style_learning'); $('styleList').innerHTML = renderStyleQueue(styleUpdatesFromPersona.length ? styleUpdatesFromPersona : style.reviews); $('reviewedPersonaList').innerHTML = renderReviewedPersonaHistory(personaReviewed.updates); + $('reviewedStyleList').innerHTML = renderReviewedStyleHistory(styleReviewed.updates); renderPersonaStatePanel(personaState, personaBackups); + updateReviewNavCounts({ + personaBacklog, + styleBacklog, + pendingJargon, + reviewedTotal: safeNumber(personaReviewed.total) + safeNumber(styleReviewed.total), + backupTotal: personaBackups.available === false + ? 0 + : (personaBackups.total || safeArray(personaBackups.backups).length), + batchTotal: state.batch.total, + }); + setReviewTab(state.reviews.tab); $('trendHint').textContent = `最近 ${Object.keys(trends.daily_messages || {}).length || 7} 天`; @@ -4520,13 +5184,16 @@

手动安装依赖

const backlog = safeNumber(data.backlog); const filteredMessages = safeNumber(data.metrics && data.metrics.filtered_messages); const totalMessages = safeNumber(data.metrics && data.metrics.total_messages_collected); - $('summaryText').textContent = buildSummaryText({ - overallHealth, - learningEfficiency, - backlog, - filteredMessages, - totalMessages, - }); + const summaryText = $('summaryText'); + if (summaryText) { + summaryText.textContent = buildSummaryText({ + overallHealth, + learningEfficiency, + backlog, + filteredMessages, + totalMessages, + }); + } } function renderTrendChart(trends) { @@ -4820,6 +5487,202 @@

手动安装依赖

.map((field) => field.key); } + function configGroupIcon(groupKey) { + const icons = { + Dependency_Install_Guide: 'extension', + Self_Learning_Basic: 'psychology', + Target_Settings: 'gps_fixed', + Model_Configuration: 'smart_toy', + Learning_Parameters: 'school', + Filter_Parameters: 'filter_alt', + Style_Analysis: 'style', + Advanced_Settings: 'tune', + Machine_Learning_Settings: 'model_training', + Persona_Backup_Settings: 'backup', + Goal_Driven_Chat_Settings: 'flag', + Affection_System_Settings: 'favorite', + Mood_System_Settings: 'mood', + Social_Context_Settings: 'groups', + Storage_Settings: 'folder', + Database_Settings: 'storage', + API_Settings: 'api', + Repository_Settings: 'source', + Integration_Settings: 'hub', + V2_Architecture_Settings: 'account_tree', + }; + return icons[groupKey] || 'settings'; + } + + const SETTINGS_NAV_SECTIONS = [ + { + title: '常用入口', + keys: [ + 'Dependency_Install_Guide', + 'Self_Learning_Basic', + 'Target_Settings', + 'Model_Configuration', + 'Integration_Settings', + ], + }, + { + title: '学习链路', + keys: [ + 'Learning_Parameters', + 'Filter_Parameters', + 'Style_Analysis', + 'Machine_Learning_Settings', + 'Goal_Driven_Chat_Settings', + 'Persona_Evolution_Settings', + ], + }, + { + title: '人格与关系', + keys: [ + 'Persona_Backup_Settings', + 'Affection_System_Settings', + 'Mood_System_Settings', + 'Social_Context_Settings', + ], + }, + { + title: '数据与运行', + keys: [ + 'Storage_Settings', + 'Database_Settings', + 'API_Settings', + 'Repository_Settings', + 'V2_Architecture_Settings', + 'Advanced_Settings', + 'Runtime_Internal_Settings', + ], + }, + ]; + + function settingsNavGroupMeta(group) { + const key = group && group.key ? group.key : ''; + if (key === 'Dependency_Install_Guide') { + return { + title: '依赖安装', + desc: '基础 / 全能力依赖', + }; + } + return { + title: (group && group.title) || key || '未命名分组', + desc: (group && group.hint) || '', + }; + } + + function ensureValidConfigGroup(groups = safeArray(getConfigSchemaPayload().groups)) { + const active = state.config.activeGroup || 'all'; + if (active === 'all') { + return 'all'; + } + if (groups.some((group) => group && group.key === active)) { + return active; + } + state.config.activeGroup = 'all'; + return 'all'; + } + + function renderSettingsNav(groups = safeArray(getConfigSchemaPayload().groups)) { + const nav = $('settingsNav'); + if (!nav) { + return; + } + + const active = ensureValidConfigGroup(groups); + const dirtyKeys = state.config.dirtyKeys || new Set(); + const totalFields = groups.reduce((sum, group) => sum + safeArray(group && group.fields).length, 0); + const buttonMarkup = []; + + const allDirtyCount = dirtyKeys.size; + buttonMarkup.push('
总览
'); + buttonMarkup.push(` + + `); + + const groupsByKey = new Map(groups.filter((group) => group && group.key).map((group) => [group.key, group])); + const renderedKeys = new Set(); + + SETTINGS_NAV_SECTIONS.forEach((section) => { + const sectionGroups = section.keys + .map((key) => groupsByKey.get(key)) + .filter(Boolean); + if (!sectionGroups.length) { + return; + } + + buttonMarkup.push(`
${escapeHtml(section.title)}
`); + sectionGroups.forEach((group) => { + renderedKeys.add(group.key); + buttonMarkup.push(renderSettingsNavButton(group, active, dirtyKeys)); + }); + }); + + const otherGroups = groups.filter((group) => group && group.key && !renderedKeys.has(group.key)); + if (otherGroups.length) { + buttonMarkup.push('
其他
'); + otherGroups.forEach((group) => { + buttonMarkup.push(renderSettingsNavButton(group, active, dirtyKeys)); + }); + } + + nav.innerHTML = buttonMarkup.join(''); + + const summary = $('settingsSidebarSummary'); + if (summary) { + summary.textContent = allDirtyCount + ? `${formatCount(groups.length)} 类 · ${formatCount(allDirtyCount)} 项未保存` + : `${formatCount(groups.length)} 类 · ${formatCount(totalFields)} 项配置`; + } + } + + function renderSettingsNavButton(group, active, dirtyKeys) { + if (!group || !group.key) { + return ''; + } + const fields = safeArray(group.fields); + const editableCount = fields.filter((field) => field && field.editable).length; + const dirtyCount = fields.filter((field) => dirtyKeys.has(field.key)).length; + const isActive = active === group.key; + const meta = settingsNavGroupMeta(group); + const desc = dirtyCount + ? `${formatCount(dirtyCount)} 项已改` + : (meta.desc || `${formatCount(editableCount)} 项可改`); + return ` + + `; + } + + function setConfigGroup(groupKey, options = {}) { + const { scroll = false } = options; + state.config.activeGroup = groupKey || 'all'; + ensureValidConfigGroup(); + renderSettingsNav(); + applyConfigSearch(); + + if (scroll) { + const target = state.config.activeGroup === 'Dependency_Install_Guide' + ? $('dependencyInstallPanel') + : document.querySelector(`[data-group-key="${state.config.activeGroup}"]`); + (target || $('settingsSection'))?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + function getConfigFieldByKey(key) { const payload = getConfigSchemaPayload(); for (const group of safeArray(payload.groups)) { @@ -4963,6 +5826,68 @@

手动安装依赖

} } + function configFieldLayoutClasses(field) { + const type = String(field && field.type ? field.type : 'text').toLowerCase(); + const widget = String(field && field.widget ? field.widget : 'text').toLowerCase(); + const key = String(field && field.key ? field.key : '').toLowerCase(); + const classes = ['config-field']; + const safeType = type.replace(/[^a-z0-9_-]/g, '') || 'text'; + const safeWidget = widget.replace(/[^a-z0-9_-]/g, '') || 'text'; + + classes.push(`is-${safeType}`); + classes.push(`widget-${safeWidget}`); + if (type === 'bool') { + classes.push('is-bool'); + } + if (type === 'list') { + classes.push('is-list', 'is-wide'); + } + if ( + widget === 'textarea' + || key === 'current_persona_name' + || key.includes('path') + || key.includes('dir') + || key.includes('api_key') + || key.includes('password') + || key.includes('prompt') + || key.includes('template') + ) { + classes.push('is-wide'); + } + + return Array.from(new Set(classes)).join(' '); + } + + function renderConfigSwitch(field, fieldId, checked) { + const stateText = checked ? '已启用' : '已关闭'; + return ` + + `; + } + + function syncConfigSwitchState(target) { + if (!target || target.type !== 'checkbox') { + return; + } + const stateEl = target.closest('.bool-row')?.querySelector('.bool-state'); + if (stateEl) { + stateEl.textContent = target.checked ? '已启用' : '已关闭'; + } + } + function renderConfigField(field, idPrefix = 'config') { const fieldId = `${idPrefix}-${field.key.replace(/[^a-zA-Z0-9_-]/g, '-')}`; const value = Object.prototype.hasOwnProperty.call(state.config.draft, field.key) @@ -5036,21 +5961,8 @@

手动安装依赖

`; - } else if (field.widget === 'toggle') { - controlHtml = ` - - `; + } else if (field.type === 'bool') { + controlHtml = renderConfigSwitch(field, fieldId, Boolean(displayValue)); } else if (field.widget === 'select') { const options = safeArray(field.options || []); const currentValue = displayValue || ''; @@ -5174,7 +6086,7 @@

手动安装依赖

return `
@@ -5193,10 +6105,13 @@

手动安装依赖

function renderConfigPanel() { const payload = getConfigSchemaPayload(); const groups = safeArray(payload.groups); - const groupsMarkup = groups.map((group) => { + const activeGroup = ensureValidConfigGroup(groups); + const visibleGroups = groups.filter((group) => group && group.key !== 'Dependency_Install_Guide'); + const groupsMarkup = visibleGroups.map((group) => { const fields = safeArray(group.fields); const editableCount = fields.filter((field) => field && field.editable).length; const readonlyCount = fields.length - editableCount; + const shouldOpen = activeGroup === 'all' || activeGroup === group.key; const searchText = [ group.key, group.title, @@ -5205,7 +6120,7 @@

手动安装依赖

].filter(Boolean).join(' ').toLowerCase(); return ` -
+
${escapeHtml(group.title)} @@ -5370,6 +6285,7 @@

手动安装依赖

if (configSummary) { configSummary.innerHTML = summaryItems.join(''); } + renderSettingsNav(groups); if (state.data) { renderHomeModules(state.data); } @@ -5415,6 +6331,7 @@

手动安装依赖

let nextValue = null; if (field.type === 'bool') { nextValue = Boolean(target.checked); + syncConfigSwitchState(target); } else if (field.type === 'int' || field.type === 'float') { nextValue = normalizeFieldValue(field, target.value); if (nextValue === null && !field.nullable) { @@ -5444,22 +6361,36 @@

手动安装依赖

function applyConfigSearch() { const query = String(state.config.search || '').trim().toLowerCase(); + const activeGroup = ensureValidConfigGroup(); const groups = document.querySelectorAll('.config-group'); + const dependencyPanel = $('dependencyInstallPanel'); + const dependencyStatus = $('dependencyStatus'); + const dependencyVisible = Boolean(query) || activeGroup === 'all' || activeGroup === 'Dependency_Install_Guide'; + const dependencyMatches = !query || 'Dependency_Install_Guide 依赖安装 手动安装依赖 dependencies'.toLowerCase().includes(query); + + if (dependencyPanel) { + dependencyPanel.hidden = !(dependencyVisible && dependencyMatches); + } + if (dependencyStatus) { + dependencyStatus.hidden = !(dependencyVisible && dependencyMatches); + } groups.forEach((groupEl) => { + const groupKey = groupEl.dataset.groupKey || ''; + const activeMatch = Boolean(query) || activeGroup === 'all' || groupKey === activeGroup; const groupMatch = !query || (groupEl.dataset.search || '').includes(query); const fields = groupEl.querySelectorAll('.config-field'); let fieldMatch = false; fields.forEach((fieldEl) => { - const match = !query || groupMatch || (fieldEl.dataset.search || '').includes(query); + const match = activeMatch && (!query || groupMatch || (fieldEl.dataset.search || '').includes(query)); fieldEl.hidden = !match; if (match) { fieldMatch = true; } }); - const visible = !query || groupMatch || fieldMatch; + const visible = activeMatch && (!query || groupMatch || fieldMatch); groupEl.hidden = !visible; if (visible) { groupEl.open = true; @@ -5800,10 +6731,10 @@

${escapeHtml(item.title)}

navigateToPage('settings'); const searchInput = $('configSearch'); if (searchInput) { - state.config.search = 'Integration_Settings'; + state.config.search = ''; searchInput.value = state.config.search; - applyConfigSearch(); } + setConfigGroup('Integration_Settings'); const groupEl = document.querySelector('[data-group-key="Integration_Settings"]'); if (groupEl) { groupEl.open = true; @@ -6719,7 +7650,7 @@

${escapeHtml(item.title)}

document.documentElement.setAttribute('data-theme', state.theme); document.documentElement.style.colorScheme = state.theme; document.body.setAttribute('data-theme', state.theme); - localStorage.setItem('sl-dashboard-theme', state.theme); + safeLocalStorageSet('sl-dashboard-theme', state.theme); updateThemeIcon(); if (state.data) { renderAll(state.data); @@ -6760,7 +7691,10 @@

${escapeHtml(item.title)}

} async function loadDashboard() { - $('summaryText').innerHTML = '正在刷新监控数据。'; + const summaryText = $('summaryText'); + if (summaryText) { + summaryText.innerHTML = '正在刷新监控数据。'; + } const [ metrics, @@ -6769,6 +7703,7 @@

${escapeHtml(item.title)}

functionsData, persona, personaReviewed, + styleReviewed, personaState, personaBackups, style, @@ -6780,7 +7715,8 @@

${escapeHtml(item.title)}

safeFetch('/api/monitoring/health'), safeFetch('/api/monitoring/functions'), safeFetch('/api/persona_updates?limit=10'), - safeFetch('/api/persona_updates/reviewed?limit=5'), + safeFetch('/api/persona_updates/reviewed?limit=5&source=persona'), + safeFetch('/api/persona_updates/reviewed?limit=5&source=style'), safeFetch('/api/persona_management/current?group_id=default'), safeFetch('/api/persona_backups/list?group_id=default&limit=8'), safeFetch('/api/style_learning/reviews?limit=5'), @@ -6803,6 +7739,7 @@

${escapeHtml(item.title)}

functions: safeArray(functionsData && functionsData.functions).slice(0, 5), persona: persona || { updates: [], total: 0 }, personaReviewed: personaReviewed || { updates: [], total: 0 }, + styleReviewed: styleReviewed || { updates: [], total: 0 }, personaState: personaState || {}, personaBackups: personaBackups || { backups: [], total: 0, available: false }, style: style || { reviews: [], total: 0 }, @@ -6856,8 +7793,6 @@

${escapeHtml(item.title)}

items.reverse(); } else if (state.jargon.sort === 'name') { items.sort((a, b) => (a.term || a.word || '').localeCompare(b.term || b.word || '')); - } else if (state.jargon.sort === 'occurrences') { - items.sort((a, b) => (b.occurrences || 0) - (a.occurrences || 0)); } state.jargon.items = items; @@ -6910,7 +7845,7 @@

${escapeHtml(item.title)}

['候选', jargonStats.total_candidates], ['确认', jargonStats.confirmed_jargon], ['完成', jargonStats.completed_inference], - ['出现', jargonStats.total_occurrences], + ['样本', jargonStats.total_occurrences], ].map(([label, value]) => `
${label}
@@ -6928,7 +7863,7 @@

${escapeHtml(item.title)}

const meta = [ item.group_id ? `群组 ${item.group_id}` : null, item.is_confirmed !== undefined ? (item.is_confirmed ? '已确认' : '待确认') : null, - item.occurrences !== undefined ? `出现 ${formatCount(item.occurrences)}` : null, + item.occurrences !== undefined ? `样本 ${formatCount(item.occurrences)}` : null, item.created_at ? `创建 ${formatTime(item.created_at)}` : null, item.updated_at ? `更新 ${formatTime(item.updated_at)}` : null, ].filter(Boolean).join(' · '); @@ -6986,6 +7921,13 @@

${escapeHtml(item.title)}

$('jargonNextBtn').disabled = page >= totalPages; $('jargonHint').textContent = `候选 ${formatCount((jargonStats.total_candidates ?? jargonStats.totalCandidates) || 0)} · 第 ${page}/${totalPages} 页`; + updateReviewNavCounts({ + pendingJargon: Math.max( + 0, + safeNumber(jargonStats.total_candidates ?? jargonStats.totalCandidates) + - safeNumber(jargonStats.confirmed_jargon ?? jargonStats.confirmedJargon), + ), + }); } function renderBatchPanel() { @@ -7023,6 +7965,7 @@

${escapeHtml(item.title)}

$('batchPrevBtn').disabled = page <= 1; $('batchNextBtn').disabled = page >= totalPages; $('batchHint').textContent = `共 ${formatCount(total)} 个批次`; + updateReviewNavCounts({ batchTotal: total }); } function bindEvents() { @@ -7054,6 +7997,12 @@

${escapeHtml(item.title)}

state.config.search = event.target.value || ''; applyConfigSearch(); }); + $('settingsNav').addEventListener('click', (event) => { + const target = event.target.closest('[data-config-group]'); + if (target) { + setConfigGroup(target.dataset.configGroup || 'all', { scroll: true }); + } + }); $('configGroups').addEventListener('input', (event) => { const target = event.target; if (target && target.dataset && target.dataset.configKey) { @@ -7097,7 +8046,7 @@

${escapeHtml(item.title)}

pipMirrorSelect.value = state.dependencyInstall.mirror; pipMirrorSelect.addEventListener('change', (event) => { state.dependencyInstall.mirror = event.target.value || 'default'; - localStorage.setItem('sl-pip-mirror', state.dependencyInstall.mirror); + safeLocalStorageSet('sl-pip-mirror', state.dependencyInstall.mirror); }); } @@ -7165,6 +8114,12 @@

${escapeHtml(item.title)}

return; } + const reviewTab = event.target.closest('[data-review-tab]'); + if (reviewTab) { + setReviewTab(reviewTab.dataset.reviewTab); + return; + } + const graphTab = event.target.closest('[data-graph-type]'); if (graphTab) { state.graph.type = graphTab.dataset.graphType === 'knowledge' ? 'knowledge' : 'memory'; diff --git a/webui/blueprints/persona_reviews.py b/webui/blueprints/persona_reviews.py index 70f45708..d5a77bc0 100644 --- a/webui/blueprints/persona_reviews.py +++ b/webui/blueprints/persona_reviews.py @@ -69,10 +69,11 @@ async def get_reviewed_persona_updates(): limit = int(request.args.get('limit', 50)) offset = int(request.args.get('offset', 0)) status_filter = request.args.get('status') + source_filter = request.args.get('source') container = get_container() review_service = PersonaReviewService(container) - result = await review_service.get_reviewed_persona_updates(limit, offset, status_filter) + result = await review_service.get_reviewed_persona_updates(limit, offset, status_filter, source_filter) return jsonify(result), 200 diff --git a/webui/manager.py b/webui/manager.py index c113d335..06204e0d 100644 --- a/webui/manager.py +++ b/webui/manager.py @@ -4,6 +4,7 @@ import asyncio import inspect import sys +from pathlib import Path from typing import Optional, Any, Dict, TYPE_CHECKING from astrbot.api import logger @@ -50,9 +51,72 @@ def __init__( self._database_degraded = False self._database_start_error: Optional[str] = None self._database_start_attempted = False + self._register_plugin_pages_api() # 创建 + def _public_webui_base_url(self) -> str: + host = str(getattr(self._config, "web_interface_host", "127.0.0.1") or "127.0.0.1") + port = int(getattr(self._config, "web_interface_port", 7833) or 7833) + if host in {"0.0.0.0", "::", "[::]"}: + try: + from quart import request + + host_header = request.host.split(":", 1)[0] + except (ImportError, RuntimeError): + host_header = "" + host = host_header or "127.0.0.1" + if ":" in host and not host.startswith("["): + host = f"[{host}]" + return f"http://{host}:{port}" + + def _dashboard_asset_version(self) -> str: + dashboard_path = Path(__file__).resolve().parents[1] / "web_res" / "static" / "html" / "dashboard.html" + try: + return str(dashboard_path.stat().st_mtime_ns) + except OSError: + return "0" + + def _register_plugin_pages_api(self) -> None: + register_web_api = getattr(self._context, "register_web_api", None) + if not callable(register_web_api): + logger.debug("[WebUI] AstrBot Plugin Pages API is not available") + return + + plugin_name = "astrbot_plugin_self_learning" + plugin_instance_name = getattr(self._plugin_instance, "name", None) + if isinstance(plugin_instance_name, str) and plugin_instance_name.strip(): + plugin_name = plugin_instance_name.strip() + + async def dashboard_url(): + try: + from quart import jsonify + except ImportError: + def jsonify(payload): + return payload + + base_url = self._public_webui_base_url() + dashboard_version = self._dashboard_asset_version() + return jsonify({ + "url": f"{base_url}/static/html/dashboard.html?v={dashboard_version}", + "base_url": base_url, + "version": dashboard_version, + }) + + for route in { + f"{plugin_name}/dashboard_url", + "astrbot_plugin_self_learning/dashboard_url", + }: + try: + register_web_api( + route, + dashboard_url, + ["GET"], + "Self Learning dashboard URL for AstrBot Plugin Pages", + ) + except Exception as exc: + logger.debug(f"[WebUI] register Plugin Pages API failed for {route}: {exc}") + def create_server(self) -> bool: """创建 Server 实例(不启动)。返回 True 表示需要立即启动。""" global _server_instance diff --git a/webui/services/persona_review_service.py b/webui/services/persona_review_service.py index f80db739..f4af5e96 100644 --- a/webui/services/persona_review_service.py +++ b/webui/services/persona_review_service.py @@ -1104,7 +1104,8 @@ async def get_reviewed_persona_updates( self, limit: int = 50, offset: int = 0, - status_filter: Optional[str] = None + status_filter: Optional[str] = None, + source_filter: Optional[str] = None, ) -> Dict[str, Any]: """ 获取已审查的人格更新列表 @@ -1118,9 +1119,15 @@ async def get_reviewed_persona_updates( Dict: 包含已审查更新的字典 """ reviewed_updates = [] + source = (source_filter or "all").strip().lower().replace("-", "_") + if source not in {"all", "persona", "traditional", "persona_learning", "style", "style_learning"}: + source = "all" + include_traditional = source in {"all", "persona", "traditional"} + include_persona_learning = source in {"all", "persona", "persona_learning"} + include_style_learning = source in {"all", "style", "style_learning"} # 从传统人格更新审查获取 - if self.persona_updater: + if include_traditional and self.persona_updater: try: traditional_updates = await self.persona_updater.get_reviewed_persona_updates(limit, offset, status_filter) if traditional_updates: @@ -1159,7 +1166,7 @@ async def get_reviewed_persona_updates( logger.warning(f"获取传统已审查人格更新失败: {e}") # 从人格学习审查获取 - if self.database_manager: + if include_persona_learning and self.database_manager: try: persona_learning_updates = await self.database_manager.get_reviewed_persona_learning_updates(limit, offset, status_filter) if persona_learning_updates: @@ -1186,7 +1193,7 @@ async def get_reviewed_persona_updates( logger.warning(f"获取已审查人格学习更新失败: {e}") # 从风格学习审查获取 - if self.database_manager: + if include_style_learning and self.database_manager: try: style_updates = await self.database_manager.get_reviewed_style_learning_updates(limit, offset, status_filter) if style_updates: