Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .astrbot-plugin/i18n/en-US.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"pages": {
"dashboard": {
"title": "Dashboard"
}
}
}
7 changes: 7 additions & 0 deletions .astrbot-plugin/i18n/zh-CN.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"pages": {
"dashboard": {
"title": "Dashboard"
}
}
}
187 changes: 187 additions & 0 deletions pages/dashboard/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Self Learning Dashboard</title>
<script src="/api/plugin/page/bridge-sdk.js"></script>
<style>
:root {
color-scheme: light;
--bg: #eef3f8;
--panel: #ffffff;
--text: #102033;
--muted: #64748b;
--border: #d8e1ec;
--accent: #2563eb;
--accent-soft: rgba(37, 99, 235, 0.12);
--danger: #dc2626;
--shadow: 0 16px 40px rgba(15, 23, 42, 0.10);
}

[data-theme="dark"] {
color-scheme: dark;
--bg: #0b111c;
--panel: #121c2b;
--text: #e5edf7;
--muted: #9aa8bc;
--border: #273449;
--accent: #60a5fa;
--accent-soft: rgba(96, 165, 250, 0.16);
--danger: #f87171;
--shadow: 0 16px 40px rgba(0, 0, 0, 0.36);
}

* {
box-sizing: border-box;
}

html,
body {
margin: 0;
width: 100%;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
}

body {
overflow: hidden;
}

.shell {
width: 100%;
height: 100%;
min-height: 100vh;
}

.frame-wrap {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
}

iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: var(--bg);
}

.status {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: var(--bg);
}

.status-card {
width: min(520px, 100%);
padding: 24px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}

.status-title {
margin: 0;
font-size: 20px;
letter-spacing: 0;
line-height: 1.3;
}

.status-text {
margin: 10px 0 0;
color: var(--muted);
font-size: 14px;
line-height: 1.7;
}

.status.error .status-title {
color: var(--danger);
}

.hidden {
display: none;
}

</style>
</head>
<body>
<div class="shell">
<main class="frame-wrap">
<iframe id="dashboardFrame" title="Self Learning Dashboard"></iframe>
<section class="status" id="status">
<div class="status-card">
<h2 class="status-title" id="statusTitle">正在加载 Dashboard</h2>
<p class="status-text" id="statusText">正在通过 AstrBot Plugin Page bridge 读取 Self Learning WebUI 地址。</p>
</div>
</section>
</main>
</div>

<script>
(function () {
const statusEl = document.getElementById('status');
const statusTitleEl = document.getElementById('statusTitle');
const statusTextEl = document.getElementById('statusText');
const frameEl = document.getElementById('dashboardFrame');

function fallbackDashboardUrl() {
const host = window.location.hostname || '127.0.0.1';
const safeHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
return `http://${safeHost}:7833/static/html/dashboard.html`;
}

function setStatus(title, message, tone) {
statusTitleEl.textContent = title;
statusTextEl.textContent = message;
statusEl.className = `status${tone ? ` ${tone}` : ''}`;
}

function hideStatus() {
statusEl.classList.add('hidden');
}

function setDashboardUrl(url) {
frameEl.src = url;
}

frameEl.addEventListener('load', hideStatus);

if (!window.AstrBotPluginPage) {
setDashboardUrl(fallbackDashboardUrl());
return;
}

window.AstrBotPluginPage.ready().then(async () => {
try {
const payload = await window.AstrBotPluginPage.apiGet('dashboard_url');
if (!payload || !payload.url) {
throw new Error('dashboard_url 未返回 url');
}
setDashboardUrl(payload.url);
} catch (error) {
setStatus(
'无法加载 Dashboard',
`已进入插件 Page,但读取独立 WebUI 地址失败:${error.message || error}`,
'error',
);
}
}).catch((error) => {
setStatus(
'插件页面上下文读取失败',
error.message || String(error),
'error',
);
});
})();
</script>
</body>
</html>
104 changes: 104 additions & 0 deletions tests/integration/test_webui_static_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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")

Expand All @@ -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 '<div class="app-brand">Self Learning</div>' in text
assert "SELF LEARNING / DASHBOARD" not in text
assert '<div class="brand">' 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")

Expand Down
58 changes: 58 additions & 0 deletions tests/unit/test_persona_review_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Loading
Loading