diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f36e070..2ebe7f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 所有重要更改都将记录在此文件中。 +## [3.2.2] - 2026-06-08 + +### AstrBot WebUI + +- 将完整 WebUI 功能接入 AstrBot 官方内嵌插件页,新增 Dashboard、AI 巡检、监控、审查队列、黑话学习、表达方式学习、人格学习、学习内容、图谱、回复策略、功能融合和设置等独立子模块。 +- 新增内嵌插件页桥接 API,覆盖模块聚合数据、学习内容、图谱、监控、集成、设置和审查操作,避免内嵌页依赖独立 WebUI 端口。 +- 修复记忆图谱与知识图谱在内嵌页中被拉伸、节点挤到四角且无法拖拽的问题;图谱现在按容器尺寸同步画布、居中布局,并支持指针拖拽节点。 + +### 版本 + +- 将插件发布版本号提升至 `3.2.2`。 + ## [3.2.1] - 2026-06-07 ### AstrBot WebUI diff --git a/README.md b/README.md index 78d794ef..b78adeaa 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 让 AstrBot 在群聊中持续采集、学习、审查并注入上下文,使 Bot 逐步具备表达风格、群组黑话、社交关系、长期记忆和人格演化能力。 -[![Version](https://img.shields.io/badge/version-3.2.1-blue.svg)](https://github.com/NickCharlie/astrbot_plugin_self_learning) +[![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/) diff --git a/README_EN.md b/README_EN.md index dabddba3..103269d4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -14,7 +14,7 @@
-[![Version](https://img.shields.io/badge/version-3.2.1-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.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/) [Features](#what-we-can-do) · [Quick Start](#quick-start) · [Web UI](#visual-management-interface) · [Community](#community) · [Contributing](CONTRIBUTING.md) diff --git a/core/page_api.py b/core/page_api.py index 1f98371c..6bf82c79 100644 --- a/core/page_api.py +++ b/core/page_api.py @@ -1,15 +1,21 @@ """AstrBot official Plugin Page API adapter. -This module registers lightweight APIs for AstrBot's embedded plugin pages. -It intentionally reuses the existing runtime/WebUI service container instead -of proxying to the standalone WebUI server. +The embedded dashboard runs inside AstrBot's Plugin Page iframe and can only +call plugin-relative bridge endpoints such as ``page/jargon``. This module +keeps that bridge surface stable while reusing the existing WebUI service +container and services. The standalone Quart WebUI remains available, but the +embedded page does not proxy or iframe it. """ from __future__ import annotations +import asyncio +import os +import sys +import time from dataclasses import asdict, is_dataclass -from datetime import datetime -from typing import Any, Awaitable, Callable +from datetime import date, datetime +from typing import Any, Awaitable, Callable, Mapping, Optional from astrbot.api import logger @@ -18,39 +24,435 @@ class PluginPageApi: - """Official AstrBot Plugin Page API for self-learning dashboard.""" + """Official AstrBot Plugin Page API for the self-learning dashboard.""" def __init__(self, plugin: Any) -> None: self.plugin = plugin def register_routes(self) -> None: - """Register routes consumed by ``pages/dashboard``.""" + """Register all routes consumed by ``pages/dashboard``.""" register = self.plugin.context.register_web_api - register( - f"{PAGE_API_PREFIX}/overview", - self.get_overview, - ["GET"], - "Self Learning embedded dashboard overview", - ) + routes: list[tuple[str, Callable[..., Awaitable[Any]], list[str], str]] = [ + ("overview", self.get_overview, ["GET"], "Self Learning embedded overview"), + ("dashboard", self.get_dashboard, ["GET"], "Self Learning embedded dashboard aggregate"), + ("jargon", self.get_jargon, ["GET"], "Self Learning embedded jargon module"), + ("jargon/action", self.post_jargon_action, ["POST"], "Self Learning embedded jargon actions"), + ("style", self.get_style, ["GET"], "Self Learning embedded expression learning module"), + ("style/action", self.post_style_action, ["POST"], "Self Learning embedded expression actions"), + ("reviews", self.get_reviews, ["GET"], "Self Learning embedded review queues"), + ("reviews/action", self.post_reviews_action, ["POST"], "Self Learning embedded review actions"), + ("persona", self.get_persona, ["GET"], "Self Learning embedded persona module"), + ("persona/action", self.post_persona_action, ["POST"], "Self Learning embedded persona actions"), + ("content", self.get_content, ["GET"], "Self Learning embedded learning content module"), + ("content/action", self.post_content_action, ["POST"], "Self Learning embedded content actions"), + ("graphs", self.get_graphs, ["GET"], "Self Learning embedded graph module"), + ("metrics", self.get_metrics, ["GET"], "Self Learning embedded metrics module"), + ("monitoring", self.get_monitoring, ["GET"], "Self Learning embedded monitoring module"), + ("integrations", self.get_integrations, ["GET"], "Self Learning embedded integrations module"), + ("settings", self.get_settings, ["GET"], "Self Learning embedded settings module"), + ("settings/action", self.post_settings_action, ["POST"], "Self Learning embedded settings actions"), + ] + for endpoint, handler, methods, description in routes: + register(f"{PAGE_API_PREFIX}/{endpoint}", handler, methods, description) async def get_overview(self) -> dict[str, Any]: - """Return a compact, fault-tolerant dashboard snapshot.""" + return self._ok(await self._load_overview()) + + async def get_dashboard(self) -> dict[str, Any]: + errors: dict[str, str] = {} + overview = await self._safe_section( + "overview", self._load_overview, errors, default={} + ) + reviews = await self._safe_section( + "reviews", lambda: self._load_reviews(limit=8), errors, default={} + ) + content = await self._safe_section( + "content", lambda: self._load_content(page=1, page_size=6), errors, default={} + ) + metrics = await self._safe_section( + "metrics", self._load_metrics, errors, default={} + ) + monitoring = await self._safe_section( + "monitoring", self._load_monitoring, errors, default={} + ) + integrations = await self._safe_section( + "integrations", self._load_integrations, errors, default={} + ) + graphs = await self._safe_section( + "graphs", + lambda: self._load_graphs(graph_type="both", limit=60), + errors, + default={}, + ) + settings = await self._safe_section( + "settings", + lambda: self._load_settings(include_schema=False), + errors, + default={}, + ) + + merged_errors = dict(overview.get("errors", {}) if isinstance(overview, dict) else {}) + merged_errors.update(errors) + return self._ok( + { + "overview": overview, + "reviews": reviews, + "content": content, + "metrics": metrics, + "monitoring": monitoring, + "integrations": integrations, + "graphs": graphs, + "settings": settings, + "errors": merged_errors, + } + ) + + async def get_jargon(self) -> dict[str, Any]: + args = self._query() + payload = await self._load_jargon( + group_id=self._query_value(args, "group_id"), + keyword=self._query_value(args, "keyword", ""), + page=self._query_int(args, "page", 1), + page_size=self._query_int(args, "page_size", self._query_int(args, "limit", 20)), + confirmed=self._query_optional_bool(args, "confirmed"), + pending=self._query_bool(args, "pending", False), + filter_mode=self._query_value(args, "filter", ""), + ) + return self._ok(payload) + + async def post_jargon_action(self) -> dict[str, Any]: + body = await self._body() + action = str(body.get("action", "")).strip() try: - from ..webui.dependencies import get_container - from ..webui.services.jargon_service import JargonService - from ..webui.services.learning_service import LearningService - from ..webui.services.metrics_service import MetricsService - from ..webui.services.persona_backup_service import PersonaBackupService - from ..webui.services.persona_service import PersonaService - except ImportError: - from webui.dependencies import get_container - from webui.services.jargon_service import JargonService - from webui.services.learning_service import LearningService - from webui.services.metrics_service import MetricsService - from webui.services.persona_backup_service import PersonaBackupService - from webui.services.persona_service import PersonaService + container = self._container() + JargonService = self._imports().JargonService + service = JargonService(container) - container = get_container() + if action in {"approve", "reject"}: + success, message, item = await service.review_jargon( + self._body_int(body, "id"), + action, + meaning=body.get("meaning"), + ) + return self._operation(success, message, item=item) + if action == "update": + success, message, item = await service.update_jargon( + self._body_int(body, "id"), + content=body.get("content"), + meaning=body.get("meaning"), + ) + return self._operation(success, message, item=item) + if action == "toggle_global": + success, message, is_global = await service.toggle_jargon_global( + self._body_int(body, "id") + ) + return self._operation(success, message, is_global=is_global) + if action == "delete": + success, message = await service.delete_jargon(self._body_int(body, "id")) + return self._operation(success, message) + if action == "sync_global": + target_group_id = str(body.get("group_id") or "").strip() + if not target_group_id: + return self._operation(False, "群组ID不能为空") + success, message, count = await service.sync_global_to_group(target_group_id) + return self._operation(success, message, synced_count=count) + return self._operation(False, f"未知黑话操作: {action or '(empty)'}") + except Exception as exc: + logger.error(f"[PluginPageAPI] jargon action failed: {exc}", exc_info=True) + return self._operation(False, str(exc)) + + async def get_style(self) -> dict[str, Any]: + args = self._query() + limit = self._query_int(args, "limit", 50) + try: + container = self._container() + LearningService = self._imports().LearningService + service = LearningService(container) + results, reviews, patterns = await asyncio.gather( + service.get_style_learning_results(), + service.get_style_learning_reviews(limit=limit), + service.get_style_learning_patterns(), + ) + return self._ok( + { + "results": results, + "reviews": reviews, + "patterns": patterns, + } + ) + except Exception as exc: + logger.error(f"[PluginPageAPI] style module failed: {exc}", exc_info=True) + return self._ok( + { + "results": {"statistics": {}, "style_progress": []}, + "reviews": {"reviews": [], "total": 0}, + "patterns": { + "emotion_patterns": [], + "language_patterns": [], + "topic_patterns": [], + }, + "error": str(exc), + } + ) + + async def post_style_action(self) -> dict[str, Any]: + body = await self._body() + action = str(body.get("action", "")).strip() + try: + LearningService = self._imports().LearningService + service = LearningService(self._container()) + + if action == "approve": + success, message = await service.approve_style_learning_review( + self._body_int(body, "id") + ) + return self._operation(success, message) + if action == "reject": + success, message = await service.reject_style_learning_review( + self._body_int(body, "id") + ) + return self._operation(success, message) + return self._operation(False, f"未知表达学习操作: {action or '(empty)'}") + except Exception as exc: + logger.error(f"[PluginPageAPI] style action failed: {exc}", exc_info=True) + return self._operation(False, str(exc)) + + async def get_reviews(self) -> dict[str, Any]: + args = self._query() + return self._ok( + await self._load_reviews( + limit=self._query_int(args, "limit", 50), + offset=self._query_int(args, "offset", 0), + status_filter=self._query_value(args, "status"), + ) + ) + + async def post_reviews_action(self) -> dict[str, Any]: + body = await self._body() + action = str(body.get("action", "")).strip() + try: + imports = self._imports() + container = self._container() + review_service = imports.PersonaReviewService(container) + + if action == "review": + success, message = await review_service.review_persona_update( + str(body.get("id") or ""), + str(body.get("decision") or body.get("review_action") or "approve"), + str(body.get("comment") or ""), + body.get("modified_content"), + ) + return self._operation(success, message) + if action == "revert": + success, message = await review_service.revert_persona_update( + str(body.get("id") or ""), + str(body.get("reason") or "撤回审查决定"), + ) + return self._operation(success, message) + if action == "delete": + success, message = await review_service.delete_persona_update( + str(body.get("id") or "") + ) + return self._operation(success, message) + if action == "batch_review": + result = await review_service.batch_review_persona_updates( + self._body_list(body, "ids", fallback_key="update_ids"), + str(body.get("decision") or body.get("review_action") or "approve"), + str(body.get("comment") or ""), + ) + return self._operation( + bool(result.get("success")), + result.get("message") or result.get("error") or "批量审查完成", + result=result, + ) + if action == "batch_delete": + result = await review_service.batch_delete_persona_updates( + self._body_list(body, "ids", fallback_key="update_ids") + ) + return self._operation( + bool(result.get("success")), + result.get("message") or result.get("error") or "批量删除完成", + result=result, + ) + if action.startswith("style_"): + learning_service = imports.LearningService(container) + review_id = self._body_int(body, "id") + if action == "style_approve": + success, message = await learning_service.approve_style_learning_review(review_id) + elif action == "style_reject": + success, message = await learning_service.reject_style_learning_review(review_id) + else: + return self._operation(False, f"未知风格审查操作: {action}") + return self._operation(success, message) + if action.startswith("jargon_"): + jargon_service = imports.JargonService(container) + jargon_id = self._body_int(body, "id") + if action == "jargon_approve": + success, message, item = await jargon_service.review_jargon( + jargon_id, "approve", meaning=body.get("meaning") + ) + return self._operation(success, message, item=item) + if action == "jargon_reject": + success, message, item = await jargon_service.review_jargon( + jargon_id, "reject", meaning=body.get("meaning") + ) + return self._operation(success, message, item=item) + if action == "jargon_delete": + success, message = await jargon_service.delete_jargon(jargon_id) + return self._operation(success, message) + return self._operation(False, f"未知审查操作: {action or '(empty)'}") + except Exception as exc: + logger.error(f"[PluginPageAPI] review action failed: {exc}", exc_info=True) + return self._operation(False, str(exc)) + + async def get_persona(self) -> dict[str, Any]: + args = self._query() + group_id = self._query_value(args, "group_id", "default") or "default" + limit = self._query_int(args, "limit", 20) + return self._ok(await self._load_persona(group_id=group_id, limit=limit)) + + async def post_persona_action(self) -> dict[str, Any]: + body = await self._body() + action = str(body.get("action", "")).strip() + try: + imports = self._imports() + container = self._container() + persona_service = imports.PersonaService(container) + backup_service = imports.PersonaBackupService(container) + + if action == "create": + success, message, persona_id = await persona_service.create_persona( + dict(body.get("persona") or body.get("data") or {}) + ) + return self._operation(success, message, persona_id=persona_id) + if action == "update": + success, message = await persona_service.update_persona( + str(body.get("persona_id") or ""), + dict(body.get("persona") or body.get("data") or {}), + ) + return self._operation(success, message) + if action == "delete": + success, message = await persona_service.delete_persona( + str(body.get("persona_id") or "") + ) + return self._operation(success, message) + if action == "import": + success, message, persona_id = await persona_service.import_persona( + dict(body.get("persona") or body.get("data") or {}) + ) + return self._operation(success, message, persona_id=persona_id) + if action == "export": + data = await persona_service.export_persona(str(body.get("persona_id") or "")) + return self._operation(True, "人格导出成功", persona=data) + if action == "backup_detail": + data = await backup_service.get_backup( + self._body_int(body, "id"), + group_id=body.get("group_id"), + ) + return self._operation(True, "人格备份详情已读取", backup=data) + if action == "backup_restore": + success, message = await backup_service.restore_backup( + self._body_int(body, "id"), + group_id=body.get("group_id"), + ) + return self._operation(success, message) + if action == "backup_delete": + success, message = await backup_service.delete_backup( + self._body_int(body, "id"), + group_id=body.get("group_id"), + ) + return self._operation(success, message) + return self._operation(False, f"未知人格操作: {action or '(empty)'}") + except Exception as exc: + logger.error(f"[PluginPageAPI] persona action failed: {exc}", exc_info=True) + return self._operation(False, str(exc)) + + async def get_content(self) -> dict[str, Any]: + args = self._query() + return self._ok( + await self._load_content( + page=self._query_int(args, "page", 1), + page_size=self._query_int(args, "page_size", 20), + ) + ) + + async def post_content_action(self) -> dict[str, Any]: + body = await self._body() + action = str(body.get("action", "")).strip() + try: + if action == "delete_content": + success, message = await self._delete_content_item( + str(body.get("bucket") or ""), + self._body_int(body, "id"), + ) + return self._operation(success, message) + if action == "delete_batch": + success, message = await self._delete_learning_batch( + self._body_int(body, "id") + ) + return self._operation(success, message) + if action == "relearn": + result = await self._relearn(str(body.get("group_id") or "default")) + return self._operation( + bool(result.get("success")), + result.get("message") or result.get("error") or "重新学习已提交", + result=result, + ) + return self._operation(False, f"未知学习内容操作: {action or '(empty)'}") + except Exception as exc: + logger.error(f"[PluginPageAPI] content action failed: {exc}", exc_info=True) + return self._operation(False, str(exc)) + + async def get_graphs(self) -> dict[str, Any]: + args = self._query() + return self._ok( + await self._load_graphs( + graph_type=self._query_value(args, "type", "memory") or "memory", + group_id=self._query_value(args, "group_id"), + limit=self._query_int(args, "limit", 120), + ) + ) + + async def get_metrics(self) -> dict[str, Any]: + return self._ok(await self._load_metrics()) + + async def get_monitoring(self) -> dict[str, Any]: + return self._ok(await self._load_monitoring()) + + async def get_integrations(self) -> dict[str, Any]: + return self._ok(await self._load_integrations()) + + async def get_settings(self) -> dict[str, Any]: + args = self._query() + include_schema = self._query_bool(args, "schema", True) + return self._ok(await self._load_settings(include_schema=include_schema)) + + async def post_settings_action(self) -> dict[str, Any]: + body = await self._body() + action = str(body.get("action", "")).strip() + try: + if action in {"save", "update_config"}: + ConfigService = self._imports().ConfigService + service = ConfigService(self._container()) + success, message, config = await service.update_config( + dict(body.get("config") or body.get("settings") or body.get("data") or {}) + ) + return self._operation(success, message, config=config) + if action == "install_dependencies": + result = await self._install_dependencies(body) + return self._operation( + bool(result.get("success")), + result.get("message") or result.get("error") or "依赖安装完成", + result=result, + ) + return self._operation(False, f"未知设置操作: {action or '(empty)'}") + except Exception as exc: + logger.error(f"[PluginPageAPI] settings action failed: {exc}", exc_info=True) + return self._operation(False, str(exc)) + + async def _load_overview(self) -> dict[str, Any]: + imports = self._imports() + container = self._container() errors: dict[str, str] = {} plugin_config = getattr(self.plugin, "plugin_config", None) @@ -62,48 +464,33 @@ async def get_overview(self) -> dict[str, Any]: learning_stats = self._serialize_learning_stats( getattr(self.plugin, "learning_stats", None) ) - jargon_stats = await self._safe_section( "jargon", - lambda: JargonService(container).get_jargon_stats(), + lambda: imports.JargonService(container).get_jargon_stats(), errors, - default={ - "total_candidates": 0, - "confirmed_jargon": 0, - "completed_inference": 0, - "total_occurrences": 0, - "average_count": 0, - "active_groups": 0, - }, + default=self._empty_jargon_stats(), ) style_results = await self._safe_section( "style", - lambda: LearningService(container).get_style_learning_results(), + lambda: imports.LearningService(container).get_style_learning_results(), errors, default={"statistics": {}, "style_progress": []}, ) persona_state = await self._safe_section( "persona", - lambda: PersonaService(container).get_current_persona_state("default"), + lambda: imports.PersonaService(container).get_current_persona_state("default"), errors, - default={ - "group_id": "default", - "persona": {"persona_id": "default", "name": "默认人格"}, - "prompt_length": 0, - "begin_dialog_count": 0, - "tool_count": 0, - "degraded": True, - }, + default=self._empty_persona_state(), ) backups = await self._safe_section( "persona_backups", - lambda: PersonaBackupService(container).list_backups(limit=8), + lambda: imports.PersonaBackupService(container).list_backups(limit=8), errors, default={"backups": [], "total": 0, "available": False}, ) metrics = await self._safe_section( "metrics", - lambda: MetricsService(container).get_intelligence_metrics("default"), + lambda: imports.MetricsService(container).get_intelligence_metrics("default"), errors, default={"overall_score": 0, "dimensions": {}, "trends": []}, ) @@ -120,62 +507,1021 @@ async def get_overview(self) -> dict[str, Any]: metrics=metrics, ) - return self._ok( - { - "plugin": { - "name": PLUGIN_NAME, - "display_name": "Self Learning", - "version": self._config_value(plugin_config, "version", "3.2.0"), - "generated_at": datetime.now().isoformat(), - }, - "runtime": { - "database_ready": bool(db_manager), - "database_degraded": bool( - getattr(container, "database_degraded", False) + return { + "plugin": { + "name": PLUGIN_NAME, + "display_name": "Self Learning", + "version": self._metadata_version(), + "generated_at": datetime.now().isoformat(), + }, + "runtime": { + "database_ready": bool(db_manager), + "database_degraded": bool(getattr(container, "database_degraded", False)), + "database_error": getattr(container, "database_start_error", None), + "services": { + "plugin_config": bool(plugin_config), + "webui_config": bool(webui_config), + "database_manager": bool(db_manager), + "persona_manager": bool(getattr(container, "persona_manager", None)), + "persona_web_manager": bool(getattr(container, "persona_web_manager", None)), + "intelligence_metrics": bool( + getattr(container, "intelligence_metrics_service", None) + ), + "progressive_learning": bool( + getattr(container, "progressive_learning", None) ), - "database_error": getattr(container, "database_start_error", None), - "services": { - "plugin_config": bool(plugin_config), - "webui_config": bool(webui_config), - "database_manager": bool(db_manager), - "persona_manager": bool(getattr(container, "persona_manager", None)), - "persona_web_manager": bool( - getattr(container, "persona_web_manager", None) - ), - "intelligence_metrics": bool( - getattr(container, "intelligence_metrics_service", None) - ), - }, }, - "webui": self._build_webui_snapshot(plugin_config, webui_config), - "learning_stats": learning_stats, - "jargon": jargon_stats, - "style": style_results, - "persona": persona_state, - "persona_backups": backups, - "metrics": metrics, - "modules": modules, - "quick_links": self._build_quick_links(plugin_config, webui_config), - "errors": errors, + }, + "webui": self._build_webui_snapshot(plugin_config, webui_config), + "learning_stats": learning_stats, + "jargon": jargon_stats, + "style": style_results, + "persona": persona_state, + "persona_backups": backups, + "metrics": metrics, + "modules": modules, + "quick_links": self._build_quick_links(plugin_config, webui_config), + "errors": errors, + } + + async def _load_jargon( + self, + *, + group_id: Optional[str], + keyword: str, + page: int, + page_size: int, + confirmed: Optional[bool], + pending: bool, + filter_mode: str, + ) -> dict[str, Any]: + imports = self._imports() + service = imports.JargonService(self._container()) + errors: dict[str, str] = {} + global_only = filter_mode == "global" + local_only = filter_mode == "local" + + stats = await self._safe_section( + "stats", + lambda: service.get_jargon_stats(group_id=group_id), + errors, + default=self._empty_jargon_stats(), + ) + groups = await self._safe_section( + "groups", + service.get_jargon_groups, + errors, + default=[], + ) + if keyword: + items = await self._safe_section( + "list", + lambda: service.search_jargon( + keyword, + chat_id=group_id, + confirmed_only=confirmed is True, + unconfirmed_only=confirmed is False, + pending_only=pending, + global_only=global_only, + local_only=local_only, + ), + errors, + default=[], + ) + listing = { + "jargon_list": items, + "total": len(items) if isinstance(items, list) else 0, + "page": 1, + "page_size": len(items) if isinstance(items, list) else 0, + "total_pages": 1, + } + else: + listing = await self._safe_section( + "list", + lambda: service.get_jargon_list( + group_id=group_id, + confirmed=confirmed, + page=max(1, page), + page_size=max(1, min(page_size or 20, 100)), + pending_only=pending, + global_only=global_only, + local_only=local_only, + ), + errors, + default={ + "jargon_list": [], + "total": 0, + "page": max(1, page), + "page_size": max(1, min(page_size or 20, 100)), + "total_pages": 1, + }, + ) + + return { + "stats": stats, + "groups": groups, + "list": listing, + "filters": { + "group_id": group_id, + "keyword": keyword, + "confirmed": confirmed, + "pending": pending, + "filter": filter_mode, + }, + "errors": errors, + } + + async def _load_reviews( + self, + *, + limit: int = 50, + offset: int = 0, + status_filter: Optional[str] = None, + ) -> dict[str, Any]: + imports = self._imports() + container = self._container() + errors: dict[str, str] = {} + review_service = imports.PersonaReviewService(container) + learning_service = imports.LearningService(container) + jargon_service = imports.JargonService(container) + bounded_limit = max(1, min(limit or 50, 100)) + + pending, reviewed, style_reviews, pending_jargon = await asyncio.gather( + self._safe_section( + "persona_pending", + lambda: review_service.get_pending_persona_updates( + limit=bounded_limit, + offset=max(0, offset), + ), + errors, + default={"updates": [], "total": 0, "success": True}, + ), + self._safe_section( + "persona_reviewed", + lambda: review_service.get_reviewed_persona_updates( + bounded_limit, + max(0, offset), + status_filter, + ), + errors, + default={"updates": [], "total": 0, "success": True}, + ), + self._safe_section( + "style_reviews", + lambda: learning_service.get_style_learning_reviews(limit=bounded_limit), + errors, + default={"reviews": [], "total": 0}, + ), + self._safe_section( + "jargon_pending", + lambda: jargon_service.get_jargon_list( + page=1, + page_size=min(bounded_limit, 50), + confirmed=False, + pending_only=True, + ), + errors, + default={"jargon_list": [], "total": 0}, + ), + ) + + return { + "persona_pending": pending, + "persona_reviewed": reviewed, + "style_reviews": style_reviews, + "jargon_pending": pending_jargon, + "errors": errors, + } + + async def _load_persona(self, *, group_id: str, limit: int) -> dict[str, Any]: + imports = self._imports() + container = self._container() + errors: dict[str, str] = {} + persona_service = imports.PersonaService(container) + backup_service = imports.PersonaBackupService(container) + + personas, current, default_persona, backups = await asyncio.gather( + self._safe_section("list", persona_service.get_all_personas, errors, default=[]), + self._safe_section( + "current", + lambda: persona_service.get_current_persona_state(group_id), + errors, + default=self._empty_persona_state(group_id), + ), + self._safe_section( + "default", + lambda: persona_service.get_default_persona(group_id), + errors, + default={"persona_id": "default", "system_prompt": "", "begin_dialogs": []}, + ), + self._safe_section( + "backups", + lambda: backup_service.list_backups(group_id=group_id, limit=limit), + errors, + default={"backups": [], "total": 0, "available": False}, + ), + ) + return { + "group_id": group_id, + "personas": personas, + "current": current, + "default": default_persona, + "backups": backups, + "errors": errors, + } + + async def _load_content(self, *, page: int, page_size: int) -> dict[str, Any]: + errors: dict[str, str] = {} + content, batches = await asyncio.gather( + self._safe_section( + "content", + self._get_learning_content_text, + errors, + default={"dialogues": [], "analysis": [], "features": [], "history": []}, + ), + self._safe_section( + "batches", + lambda: self._get_learning_batches(page=page, page_size=page_size), + errors, + default={"batches": [], "total": 0, "page": page, "page_size": page_size}, + ), + ) + return {"content": content, "batches": batches, "errors": errors} + + async def _load_graphs( + self, + *, + graph_type: str, + group_id: Optional[str] = None, + limit: int = 120, + ) -> dict[str, Any]: + service = self._imports().GraphService(self._container()) + bounded_limit = max(10, min(limit or 120, 300)) + if graph_type == "both": + memory, knowledge = await asyncio.gather( + service.get_memory_graph(group_id=group_id, limit=bounded_limit), + service.get_knowledge_graph(group_id=group_id, limit=bounded_limit), + ) + return {"memory": memory, "knowledge": knowledge} + if graph_type == "knowledge": + return {"knowledge": await service.get_knowledge_graph(group_id=group_id, limit=bounded_limit)} + return {"memory": await service.get_memory_graph(group_id=group_id, limit=bounded_limit)} + + async def _load_metrics(self) -> dict[str, Any]: + imports = self._imports() + container = self._container() + db = getattr(container, "database_manager", None) + metrics_service = imports.MetricsService(container) + errors: dict[str, str] = {} + + intelligence, diversity, affection = await asyncio.gather( + self._safe_section( + "intelligence", + lambda: metrics_service.get_intelligence_metrics("default"), + errors, + default={"overall_score": 0, "dimensions": {}, "trends": []}, + ), + self._safe_section( + "diversity", + lambda: metrics_service.get_diversity_metrics("default"), + errors, + default={ + "vocabulary_diversity": 0, + "topic_diversity": 0, + "style_diversity": 0, + "total_score": 0, + }, + ), + self._safe_section( + "affection", + lambda: metrics_service.get_affection_metrics("default"), + errors, + default={ + "average_affection": 0, + "total_users": 0, + "high_affection_count": 0, + "low_affection_count": 0, + "distribution": [], + }, + ), + ) + + message_stats = await self._safe_section( + "messages", + lambda: db.get_messages_statistics() if db else self._async_value({}), + errors, + default={}, + ) + trends = await self._safe_section( + "trends", + lambda: db.get_trends_data() if db and hasattr(db, "get_trends_data") else self._async_value({}), + errors, + default={}, + ) + + llm_adapter = getattr(container, "llm_adapter", None) + llm_stats = {} + provider_info = {} + if llm_adapter and hasattr(llm_adapter, "get_call_statistics"): + try: + llm_stats = llm_adapter.get_call_statistics() or {} + except Exception as exc: + errors["llm_stats"] = str(exc) + if llm_adapter and hasattr(llm_adapter, "get_provider_info"): + try: + provider_info = llm_adapter.get_provider_info() or {} + except Exception as exc: + errors["provider_info"] = str(exc) + + return { + "intelligence": intelligence, + "diversity": diversity, + "affection": affection, + "messages": message_stats, + "trends": trends, + "llm": { + "call_statistics": llm_stats, + "provider_info": provider_info, + }, + "learning": { + "active_sessions": self._active_learning_sessions(container), + "learning_stats": self._serialize_learning_stats( + getattr(self.plugin, "learning_stats", None) + ), + }, + "system": self._system_metrics(), + "errors": errors, + } + + async def _load_monitoring(self) -> dict[str, Any]: + container = self._container() + errors: dict[str, str] = {} + health = await self._safe_section( + "health", + lambda: self._async_value(self._health_summary(container)), + errors, + default={"overall": "unknown", "checks": {}}, + ) + functions = await self._safe_section( + "functions", + lambda: self._async_value(self._function_metrics()), + errors, + default={"debug_mode": False, "functions": [], "timestamp": time.time()}, + ) + return { + "health": health, + "functions": functions, + "runtime": { + "database_degraded": bool(getattr(container, "database_degraded", False)), + "database_start_error": getattr(container, "database_start_error", None), + "perf_collector": bool(getattr(container, "perf_collector", None)), + "metric_collector": bool(getattr(container, "metric_collector", None)), + "health_checker": bool(getattr(container, "health_checker", None)), + }, + "errors": errors, + } + + async def _load_integrations(self) -> dict[str, Any]: + service = self._imports().IntegrationService(self._container()) + status = service.get_status() + return { + **status, + "embed_targets": { + "livingmemory": service.get_embed_target("livingmemory"), + "group_chat_plus": service.get_embed_target("group_chat_plus"), + }, + } + + async def _load_settings(self, *, include_schema: bool) -> dict[str, Any]: + imports = self._imports() + service = imports.ConfigService(self._container()) + config = await service.get_config() + payload: dict[str, Any] = { + "config": config, + "dependency_tiers": imports.DEPENDENCY_TIERS, + "pip_mirrors": imports.PIP_MIRROR_SOURCES, + "manual_dependency_source": imports.MANUAL_DEPENDENCY_INSTALL_SOURCE, + } + if include_schema: + payload["schema"] = await service.get_config_schema() + else: + payload["schema_summary"] = { + "groups": len((await service.get_config_schema()).get("groups", [])) + } + return payload + + async def _get_learning_content_text(self) -> dict[str, list[dict[str, Any]]]: + container = self._container() + database_manager = getattr(container, "database_manager", None) + content_data: dict[str, list[dict[str, Any]]] = { + "dialogues": [], + "analysis": [], + "features": [], + "history": [], + } + if not database_manager or not hasattr(database_manager, "get_session"): + return content_data + + try: + from sqlalchemy import desc, select + + try: + from ..models.orm import ( + ExpressionPattern, + LearningBatch, + RawMessage, + StyleLearningReview, + ) + except ImportError: + from models.orm import ( + ExpressionPattern, + LearningBatch, + RawMessage, + StyleLearningReview, + ) + + async with database_manager.get_session() as session: + raw_messages = ( + await session.execute( + select(RawMessage).order_by(desc(RawMessage.timestamp)).limit(24) + ) + ).scalars().all() + style_reviews = ( + await session.execute( + select(StyleLearningReview) + .order_by(desc(StyleLearningReview.timestamp)) + .limit(24) + ) + ).scalars().all() + expression_patterns = ( + await session.execute( + select(ExpressionPattern) + .order_by(desc(ExpressionPattern.last_active_time)) + .limit(24) + ) + ).scalars().all() + batches = ( + await session.execute( + select(LearningBatch).order_by(desc(LearningBatch.start_time)).limit(24) + ) + ).scalars().all() + + for msg in raw_messages: + message_text = getattr(msg, "message", "") or "" + if len(message_text.strip()) < 2: + continue + sender = getattr(msg, "sender_name", None) or getattr(msg, "sender_id", None) or "未知发送者" + content_data["dialogues"].append( + { + "id": getattr(msg, "id", None), + "type": "dialogue", + "title": sender, + "timestamp": self._format_ts(getattr(msg, "timestamp", None)), + "text": f"{sender}: {message_text}", + "detail": message_text, + "metadata": f"群组: {getattr(msg, 'group_id', '')}, 平台: {getattr(msg, 'platform', '') or '未知'}", + "raw": { + "sender_id": getattr(msg, "sender_id", None), + "sender_name": getattr(msg, "sender_name", None), + "group_id": getattr(msg, "group_id", None), + "platform": getattr(msg, "platform", None), + "processed": bool(getattr(msg, "processed", False)), + }, + } + ) + + for review in style_reviews: + patterns = self._parse_jsonish(getattr(review, "learned_patterns", None), []) + description = getattr(review, "description", "") or "" + few_shots = getattr(review, "few_shots_content", "") or "" + content_data["analysis"].append( + { + "id": getattr(review, "id", None), + "type": getattr(review, "type", None) or "style_learning", + "title": description or f"风格学习 ({getattr(review, 'type', '') or 'style'})", + "timestamp": self._format_ts(getattr(review, "timestamp", None)), + "text": description or few_shots, + "detail": few_shots or description, + "status": getattr(review, "status", None), + "patterns": patterns, + "metadata": f"群组: {getattr(review, 'group_id', '')}, 状态: {getattr(review, 'status', '')}", + } + ) + + for pattern in expression_patterns: + weight = getattr(pattern, "weight", None) or 0 + situation = getattr(pattern, "situation", "") or "表达模式" + expression = getattr(pattern, "expression", "") or "" + content_data["features"].append( + { + "id": getattr(pattern, "id", None), + "type": "expression_pattern", + "title": situation, + "timestamp": self._format_ts(getattr(pattern, "last_active_time", None)), + "text": f"场景: {situation}\n表达: {expression}", + "detail": expression, + "metadata": f"群组: {getattr(pattern, 'group_id', '')}, 权重: {float(weight):.2f}", + } + ) + + max_batch_size = self._config_value( + getattr(container, "plugin_config", None), + "max_messages_per_batch", + 200, + ) + for batch in batches: + quality_score = self._effective_batch_quality(batch, max_batch_size) + content_data["history"].append( + { + "id": getattr(batch, "id", None), + "type": "learning_batch", + "title": getattr(batch, "batch_name", None) + or getattr(batch, "batch_id", None) + or "学习批次", + "timestamp": self._format_ts(getattr(batch, "start_time", None)), + "text": f"质量: {quality_score:.3f}", + "detail": getattr(batch, "error_message", None) + or f"状态: {getattr(batch, 'status', None) or 'unknown'}", + "status": getattr(batch, "status", None), + "metadata": ( + f"群组: {getattr(batch, 'group_id', '')}, " + f"消息: {getattr(batch, 'processed_messages', 0) or 0}, " + f"成功: {'是' if getattr(batch, 'success', False) else '否'}" + ), + } + ) + except Exception as exc: + logger.warning(f"[PluginPageAPI] learning content unavailable: {exc}", exc_info=True) + return content_data + + async def _get_learning_batches(self, *, page: int, page_size: int) -> dict[str, Any]: + database_manager = getattr(self._container(), "database_manager", None) + page = max(1, page) + page_size = max(1, min(page_size or 20, 100)) + if not database_manager or not hasattr(database_manager, "get_session"): + return {"batches": [], "total": 0, "page": page, "page_size": page_size, "total_pages": 1} + + try: + from sqlalchemy import desc, func, select + + try: + from ..models.orm import LearningBatch + except ImportError: + from models.orm import LearningBatch + + async with database_manager.get_session() as session: + total = (await session.execute(select(func.count()).select_from(LearningBatch))).scalar() or 0 + rows = ( + await session.execute( + select(LearningBatch) + .order_by(desc(LearningBatch.start_time)) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).scalars().all() + max_batch_size = self._config_value( + getattr(self._container(), "plugin_config", None), + "max_messages_per_batch", + 200, + ) + batches = [ + { + "id": getattr(batch, "id", None), + "batch_id": getattr(batch, "batch_id", None), + "batch_name": getattr(batch, "batch_name", None), + "group_id": getattr(batch, "group_id", None), + "start_time": getattr(batch, "start_time", None), + "end_time": getattr(batch, "end_time", None), + "quality_score": self._effective_batch_quality(batch, max_batch_size), + "raw_quality_score": getattr(batch, "quality_score", None), + "processed_messages": getattr(batch, "processed_messages", 0), + "message_count": getattr(batch, "message_count", 0), + "filtered_count": getattr(batch, "filtered_count", 0), + "success": bool(getattr(batch, "success", False)), + "status": getattr(batch, "status", None), + "error_message": getattr(batch, "error_message", None), + } + for batch in rows + ] + return { + "batches": batches, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": max(1, (total + page_size - 1) // page_size), } + except Exception as exc: + logger.warning(f"[PluginPageAPI] learning batches unavailable: {exc}", exc_info=True) + return {"batches": [], "total": 0, "page": page, "page_size": page_size, "total_pages": 1} + + async def _delete_content_item(self, bucket: str, item_id: int) -> tuple[bool, str]: + database_manager = getattr(self._container(), "database_manager", None) + if not database_manager or not hasattr(database_manager, "get_session"): + return False, "数据库管理器未初始化" + + bucket = bucket.strip() + try: + from sqlalchemy import delete as sql_delete + + try: + from ..models.orm import ( + ExpressionPattern, + LearningBatch, + RawMessage, + StyleLearningReview, + ) + except ImportError: + from models.orm import ( + ExpressionPattern, + LearningBatch, + RawMessage, + StyleLearningReview, + ) + + bucket_models = { + "dialogues": (RawMessage, "原始对话"), + "analysis": (StyleLearningReview, "分析结果"), + "features": (ExpressionPattern, "表达模式"), + "history": (LearningBatch, "学习批次"), + } + model_info = bucket_models.get(bucket) + if not model_info: + return False, f"不支持的学习内容类型: {bucket}" + + model, label = model_info + async with database_manager.get_session() as session: + result = await session.execute(sql_delete(model).where(model.id == item_id)) + await session.commit() + if result.rowcount > 0: + return True, f"{label} {item_id} 已删除" + return False, f"{label} {item_id} 不存在" + except Exception as exc: + logger.error(f"[PluginPageAPI] delete content failed: {exc}", exc_info=True) + return False, str(exc) + + async def _delete_learning_batch(self, batch_id: int) -> tuple[bool, str]: + return await self._delete_content_item("history", batch_id) + + async def _relearn(self, group_id: str) -> dict[str, Any]: + container = self._container() + database_manager = getattr(container, "database_manager", None) + group_id = (group_id or "default").strip() + if not group_id or group_id == "default": + detected = await self._detect_group_with_most_messages(database_manager) + if detected: + group_id = detected + + if not group_id: + return {"success": False, "error": "没有可用的群组数据"} + + total_messages = 0 + if database_manager: + try: + stats = await database_manager.get_messages_statistics() + if isinstance(stats, dict): + total_messages = int(stats.get("total_messages", 0) or 0) + except Exception: + pass + + progressive_learning = getattr(container, "progressive_learning", None) + if not progressive_learning: + return {"success": False, "error": "学习服务未初始化"} + + asyncio.create_task(progressive_learning.start_learning(group_id)) + return { + "success": True, + "message": f"重新学习已启动,群组: {group_id}", + "group_id": group_id, + "total_messages": total_messages, + } + + async def _detect_group_with_most_messages(self, database_manager: Any) -> Optional[str]: + if not database_manager or not hasattr(database_manager, "get_session"): + return None + try: + from sqlalchemy import and_, func, select + + try: + from ..models.orm import RawMessage + except ImportError: + from models.orm import RawMessage + + async with database_manager.get_session() as session: + result = await session.execute( + select(RawMessage.group_id, func.count().label("message_count")) + .where(and_(RawMessage.group_id.isnot(None), RawMessage.group_id != "")) + .group_by(RawMessage.group_id) + .order_by(func.count().desc()) + ) + row = result.first() + return str(row[0]) if row else None + except Exception as exc: + logger.warning(f"[PluginPageAPI] auto detect group failed: {exc}") + return None + + async def _install_dependencies(self, body: Mapping[str, Any]) -> dict[str, Any]: + imports = self._imports() + if body.get("manual_confirmed") is not True: + return {"success": False, "error": "依赖安装只能在设置界面手动确认后触发"} + if body.get("source") != imports.MANUAL_DEPENDENCY_INSTALL_SOURCE: + return {"success": False, "error": "缺少合法的依赖安装来源"} + + tier = str(body.get("tier") or "full").strip().lower() + tier_definition = imports.DEPENDENCY_TIERS.get(tier) + if not tier_definition: + return {"success": False, "error": "未知依赖安装档位"} + + mirror_key = str(body.get("pip_mirror") or "default").strip().lower() + mirror_definition = imports.PIP_MIRROR_SOURCES.get(mirror_key) + if not mirror_definition: + return {"success": False, "error": "未知 pip 镜像源"} + + pip_index_args: list[str] = [] + if mirror_definition["index_url"]: + pip_index_args = ["--index-url", mirror_definition["index_url"]] + + packages = list(tier_definition["packages"]) + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "--disable-pip-version-check", + "--no-input", + *pip_index_args, + *packages, + ] + process = await asyncio.create_subprocess_exec( + *cmd, + cwd=os.path.dirname(os.path.dirname(__file__)), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) + stdout, stderr = await process.communicate() + combined = ( + stdout.decode("utf-8", errors="replace") + + "\n" + + stderr.decode("utf-8", errors="replace") + ).strip() + return { + "success": process.returncode == 0, + "message": ( + f"{tier_definition['label']}安装完成" + if process.returncode == 0 + else f"{tier_definition['label']}安装失败,退出码: {process.returncode}" + ), + "tier": tier, + "tier_label": tier_definition["label"], + "pip_mirror": mirror_key, + "pip_mirror_label": mirror_definition["label"], + "pip_index_url": mirror_definition["index_url"], + "packages": packages, + "output": combined[-8000:], + } + + def _health_summary(self, container: Any) -> dict[str, Any]: + checker = getattr(container, "health_checker", None) + if checker is not None and hasattr(checker, "get_summary"): + return checker.get_summary() + try: + try: + from ..services.monitoring.health_checker import HealthChecker + except ImportError: + from services.monitoring.health_checker import HealthChecker + try: + from ..utils.cache_manager import get_cache_manager + except ImportError: + from utils.cache_manager import get_cache_manager + + registry = None + try: + if getattr(container, "factory_manager", None): + registry = ( + container.factory_manager.get_service_factory().get_service_registry() + ) + except Exception: + registry = None + return HealthChecker( + service_registry=registry, + cache_manager=get_cache_manager(), + llm_adapter=getattr(container, "llm_adapter", None), + ).get_summary() + except Exception as exc: + return {"overall": "degraded", "checks": {}, "error": str(exc), "timestamp": time.time()} + + def _function_metrics(self) -> dict[str, Any]: + try: + try: + from ..services.monitoring.instrumentation import ( + _func_counters, + _func_error_counters, + _func_histograms, + is_debug_mode, + ) + except ImportError: + from services.monitoring.instrumentation import ( + _func_counters, + _func_error_counters, + _func_histograms, + is_debug_mode, + ) + + functions = [] + for fqn, histogram in _func_histograms.items(): + calls = 0 + errors = 0 + duration_count = 0 + duration_sum = 0.0 + counter = _func_counters.get(fqn) + if counter: + for sample in counter.collect()[0].samples: + if sample.name.endswith("_total"): + calls = int(sample.value) + error_counter = _func_error_counters.get(fqn) + if error_counter: + for sample in error_counter.collect()[0].samples: + if sample.name.endswith("_total"): + errors = int(sample.value) + for sample in histogram.collect()[0].samples: + if sample.name.endswith("_count"): + duration_count = int(sample.value) + elif sample.name.endswith("_sum"): + duration_sum = float(sample.value) + avg = duration_sum / duration_count if duration_count > 0 else 0.0 + functions.append( + { + "name": fqn, + "calls": calls, + "errors": errors, + "error_rate": round(errors / calls, 4) if calls else 0, + "duration": { + "count": duration_count, + "sum": round(duration_sum, 4), + "avg": round(avg, 6), + }, + } + ) + functions.sort(key=lambda item: item["duration"]["avg"], reverse=True) + return { + "debug_mode": is_debug_mode(), + "functions": functions[:80], + "timestamp": time.time(), + } + except Exception as exc: + return {"debug_mode": False, "functions": [], "error": str(exc), "timestamp": time.time()} @staticmethod async def _safe_section( name: str, - loader: Callable[[], Awaitable[dict[str, Any]]], + loader: Callable[[], Awaitable[Any]], errors: dict[str, str], *, - default: dict[str, Any], - ) -> dict[str, Any]: + default: Any, + ) -> Any: try: data = await loader() - return data if isinstance(data, dict) else default + return default if data is None else data except Exception as exc: logger.warning(f"[PluginPageAPI] {name} section unavailable: {exc}", exc_info=True) errors[name] = str(exc) return default + @staticmethod + async def _async_value(value: Any) -> Any: + return value + + def _container(self) -> Any: + return self._imports().get_container() + + @staticmethod + def _imports() -> Any: + class Imports: + pass + + imports = Imports() + try: + from ..webui.blueprints.config import ( + DEPENDENCY_TIERS, + MANUAL_DEPENDENCY_INSTALL_SOURCE, + PIP_MIRROR_SOURCES, + ) + from ..webui.dependencies import get_container + from ..webui.services.config_service import ConfigService + from ..webui.services.graph_service import GraphService + from ..webui.services.integration_service import IntegrationService + from ..webui.services.jargon_service import JargonService + from ..webui.services.learning_service import LearningService + from ..webui.services.metrics_service import MetricsService + from ..webui.services.persona_backup_service import PersonaBackupService + from ..webui.services.persona_review_service import PersonaReviewService + from ..webui.services.persona_service import PersonaService + except ImportError: + from webui.blueprints.config import ( + DEPENDENCY_TIERS, + MANUAL_DEPENDENCY_INSTALL_SOURCE, + PIP_MIRROR_SOURCES, + ) + from webui.dependencies import get_container + from webui.services.config_service import ConfigService + from webui.services.graph_service import GraphService + from webui.services.integration_service import IntegrationService + from webui.services.jargon_service import JargonService + from webui.services.learning_service import LearningService + from webui.services.metrics_service import MetricsService + from webui.services.persona_backup_service import PersonaBackupService + from webui.services.persona_review_service import PersonaReviewService + from webui.services.persona_service import PersonaService + + imports.get_container = get_container + imports.ConfigService = ConfigService + imports.GraphService = GraphService + imports.IntegrationService = IntegrationService + imports.JargonService = JargonService + imports.LearningService = LearningService + imports.MetricsService = MetricsService + imports.PersonaBackupService = PersonaBackupService + imports.PersonaReviewService = PersonaReviewService + imports.PersonaService = PersonaService + imports.DEPENDENCY_TIERS = DEPENDENCY_TIERS + imports.MANUAL_DEPENDENCY_INSTALL_SOURCE = MANUAL_DEPENDENCY_INSTALL_SOURCE + imports.PIP_MIRROR_SOURCES = PIP_MIRROR_SOURCES + return imports + + @staticmethod + async def _body() -> dict[str, Any]: + try: + from quart import request + + data = await request.get_json(silent=True) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + @staticmethod + def _query() -> Any: + try: + from quart import request + + return request.args + except Exception: + return {} + + @staticmethod + def _query_value(args: Any, key: str, default: Optional[str] = None) -> Optional[str]: + try: + value = args.get(key, default) + except Exception: + value = default + if value is None: + return default + value = str(value) + return value if value != "" else default + + @classmethod + def _query_int(cls, args: Any, key: str, default: int) -> int: + try: + value = args.get(key, default) + except Exception: + value = default + return cls._as_int(value, default) + + @classmethod + def _query_bool(cls, args: Any, key: str, default: bool) -> bool: + value = cls._query_value(args, key, str(default).lower()) + if value is None: + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + @classmethod + def _query_optional_bool(cls, args: Any, key: str) -> Optional[bool]: + value = cls._query_value(args, key) + if value is None: + return None + normalized = str(value).strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return None + + @classmethod + def _body_int(cls, body: Mapping[str, Any], key: str, default: int = 0) -> int: + return cls._as_int(body.get(key), default) + + @staticmethod + def _body_list( + body: Mapping[str, Any], + key: str, + *, + fallback_key: Optional[str] = None, + ) -> list[str]: + value = body.get(key) + if value is None and fallback_key: + value = body.get(fallback_key) + if not isinstance(value, list): + return [] + return [str(item) for item in value if item is not None] + @staticmethod def _serialize_learning_stats(stats: Any) -> dict[str, Any]: if stats is None: @@ -190,9 +1536,7 @@ def _serialize_learning_stats(stats: Any) -> dict[str, Any]: if is_dataclass(stats): return asdict(stats) return { - "total_messages_collected": int( - getattr(stats, "total_messages_collected", 0) or 0 - ), + "total_messages_collected": int(getattr(stats, "total_messages_collected", 0) or 0), "filtered_messages": int(getattr(stats, "filtered_messages", 0) or 0), "style_updates": int(getattr(stats, "style_updates", 0) or 0), "persona_updates": int(getattr(stats, "persona_updates", 0) or 0), @@ -211,10 +1555,12 @@ def _build_modules( backups: dict[str, Any], metrics: dict[str, Any], ) -> list[dict[str, Any]]: - confirmed_jargon = cls._as_int(jargon_stats.get("confirmed_jargon")) - unique_styles = cls._as_int(style_stats.get("unique_styles")) - persona_prompt_len = cls._as_int(persona_state.get("prompt_length")) - backup_total = cls._as_int(backups.get("total")) + confirmed_jargon = cls._as_int(jargon_stats.get("confirmed_jargon"), 0) + unique_styles = cls._as_int( + style_stats.get("unique_styles") or style_stats.get("total_samples"), 0 + ) + persona_prompt_len = cls._as_int(persona_state.get("prompt_length"), 0) + backup_total = cls._as_int(backups.get("total"), 0) intelligence_score = cls._as_number(metrics.get("overall_score")) return [ @@ -225,8 +1571,8 @@ def _build_modules( "enabled": cls._config_bool(plugin_config, "enable_jargon_learning", True), "metric": confirmed_jargon, "metric_label": "已确认黑话", - "accent": "#14b8a6", - "target": "jargon", + "accent": "#0f9f8f", + "target": "jargon-learning", }, { "id": "style", @@ -235,20 +1581,18 @@ def _build_modules( "enabled": cls._config_bool(plugin_config, "enable_style_learning", True), "metric": unique_styles, "metric_label": "风格样本", - "accent": "#4f46e5", - "target": "style", + "accent": "#4169e1", + "target": "expression-learning", }, { "id": "persona", "title": "人格学习", "description": "人格演化、更新审查与备份恢复", - "enabled": cls._config_bool( - plugin_config, "enable_persona_evolution", True - ), + "enabled": cls._config_bool(plugin_config, "enable_persona_evolution", True), "metric": persona_prompt_len, "metric_label": "人格提示词字数", - "accent": "#f59e0b", - "target": "persona", + "accent": "#d97706", + "target": "persona-learning", }, { "id": "reviews", @@ -274,9 +1618,7 @@ def _build_modules( @staticmethod def _build_webui_snapshot(plugin_config: Any, webui_config: Any) -> dict[str, Any]: - enabled = PluginPageApi._config_bool( - plugin_config, "enable_web_interface", True - ) + enabled = PluginPageApi._config_bool(plugin_config, "enable_web_interface", True) host = getattr(webui_config, "host", None) or PluginPageApi._config_value( plugin_config, "web_interface_host", "127.0.0.1" ) @@ -299,9 +1641,15 @@ def _build_webui_snapshot(plugin_config: Any, webui_config: Any) -> dict[str, An def _build_quick_links(plugin_config: Any, webui_config: Any) -> list[dict[str, str]]: webui = PluginPageApi._build_webui_snapshot(plugin_config, webui_config) return [ + { + "id": "embedded_dashboard", + "label": "内嵌 WebUI", + "url": "#/plugin-page/astrbot_plugin_self_learning/dashboard", + "description": "AstrBot 官方插件页", + }, { "id": "full_dashboard", - "label": "完整 WebUI", + "label": "独立 WebUI", "url": webui["dashboard_url"], "description": "打开 7833 独立管理界面", }, @@ -313,6 +1661,29 @@ def _build_quick_links(plugin_config: Any, webui_config: Any) -> list[dict[str, }, ] + @staticmethod + def _empty_jargon_stats() -> dict[str, Any]: + return { + "total_candidates": 0, + "confirmed_jargon": 0, + "completed_inference": 0, + "total_occurrences": 0, + "average_count": 0, + "active_groups": 0, + } + + @staticmethod + def _empty_persona_state(group_id: str = "default") -> dict[str, Any]: + return { + "group_id": group_id, + "persona": {"persona_id": "default", "name": "默认人格"}, + "prompt_preview": "", + "prompt_length": 0, + "begin_dialog_count": 0, + "tool_count": 0, + "degraded": True, + } + @staticmethod def _config_value(config: Any, name: str, default: Any = None) -> Any: if config is None: @@ -329,19 +1700,136 @@ def _config_bool(config: Any, name: str, default: bool = False) -> bool: return bool(value) @staticmethod - def _as_int(value: Any) -> int: + def _as_int(value: Any, default: int = 0) -> int: try: - return int(value or 0) + return int(value if value is not None else default) except (TypeError, ValueError): + return default + + @staticmethod + def _as_number(value: Any, default: float = 0.0) -> float: + try: + return float(value if value is not None else default) + except (TypeError, ValueError): + return default + + @staticmethod + def _system_metrics() -> dict[str, Any]: + metrics = {"cpu_percent": 0, "memory_percent": 0, "disk_usage_percent": 0} + try: + import psutil + + metrics["cpu_percent"] = psutil.cpu_percent(interval=0) + memory = psutil.virtual_memory() + metrics["memory_percent"] = memory.percent + metrics["memory_used_gb"] = round(memory.used / (1024**3), 2) + metrics["memory_total_gb"] = round(memory.total / (1024**3), 2) + disk = psutil.disk_usage("/") + metrics["disk_usage_percent"] = round(disk.used / disk.total * 100, 2) + except Exception: + pass + return metrics + + @staticmethod + def _active_learning_sessions(container: Any) -> int: + progressive_learning = getattr(container, "progressive_learning", None) + if not progressive_learning: return 0 + try: + return sum(1 for active in progressive_learning.learning_active.values() if active) + except Exception: + return 0 + + @staticmethod + def _parse_jsonish(value: Any, fallback: Any) -> Any: + if value is None or value == "": + return fallback + if isinstance(value, (list, dict)): + return value + try: + import json + + return json.loads(value) + except Exception: + return fallback + + @classmethod + def _effective_batch_quality(cls, batch: Any, max_batch_size: int = 200) -> float: + stored = cls._optional_float(getattr(batch, "quality_score", None)) + if stored is not None and stored > 0: + return stored + if getattr(batch, "success", None) is False: + return stored if stored is not None else 0.0 + + processed = max( + cls._as_int(getattr(batch, "processed_messages", 0), 0), + cls._as_int(getattr(batch, "message_count", 0), 0), + ) + filtered = cls._as_int(getattr(batch, "filtered_count", 0), 0) + if processed <= 0 and filtered <= 0: + return stored if stored is not None else 0.0 + + batch_size = max(1, cls._as_int(max_batch_size, 200)) + volume_score = min(processed / batch_size, 1.0) + filtered_score = min(filtered / max(processed, filtered, 1), 1.0) if filtered else 0.0 + success_score = 0.10 if getattr(batch, "success", True) else 0.0 + return max(0.0, min(1.0, 0.25 + volume_score * 0.45 + filtered_score * 0.20 + success_score)) @staticmethod - def _as_number(value: Any) -> float: + def _optional_float(value: Any) -> Optional[float]: + if value is None: + return None try: - return float(value or 0) + return max(0.0, min(1.0, float(value))) except (TypeError, ValueError): - return 0.0 + return None @staticmethod - def _ok(data: dict[str, Any]) -> dict[str, Any]: - return {"status": "ok", "data": data} + def _format_ts(value: Any) -> str: + if not value: + return "" + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + try: + return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return str(value) + + @staticmethod + def _metadata_version() -> str: + metadata_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "metadata.yaml") + try: + with open(metadata_path, "r", encoding="utf-8") as handle: + for line in handle: + if line.strip().startswith("version:"): + return line.split(":", 1)[1].strip().strip('"').strip("'") + except OSError: + pass + return "3.2.1" + + @classmethod + def _to_plain(cls, value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, (datetime, date)): + return value.isoformat() + if is_dataclass(value): + return cls._to_plain(asdict(value)) + if isinstance(value, dict): + return {str(key): cls._to_plain(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [cls._to_plain(item) for item in value] + if hasattr(value, "model_dump"): + try: + return cls._to_plain(value.model_dump()) + except Exception: + return str(value) + return str(value) + + @classmethod + def _ok(cls, data: Any) -> dict[str, Any]: + return {"status": "ok", "success": True, "data": cls._to_plain(data)} + + @classmethod + def _operation(cls, success: bool, message: str, **data: Any) -> dict[str, Any]: + return cls._ok({"success": bool(success), "message": message, **data}) diff --git a/metadata.yaml b/metadata.yaml index b92a3810..dd915dfc 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.1" +version: "3.2.2" repo: "https://github.com/NickCharlie/astrbot_plugin_self_learning" tags: - "自学习" diff --git a/pages/dashboard/app.js b/pages/dashboard/app.js index 76105a8d..5e75ba01 100644 --- a/pages/dashboard/app.js +++ b/pages/dashboard/app.js @@ -1,286 +1,1043 @@ (() => { "use strict"; + const PAGE_META = { + home: ["Dashboard", "完整内嵌 WebUI"], + insights: ["Insights", "AI 巡检"], + monitoring: ["Monitoring", "运行监控"], + reviews: ["Reviews", "审查队列"], + "jargon-learning": ["Jargon", "黑话学习"], + "expression-learning": ["Expression", "表达方式学习"], + "persona-learning": ["Persona", "人格学习"], + content: ["Content", "学习内容"], + graphs: ["Graphs", "图谱"], + "reply-strategy": ["Reply", "回复策略"], + integrations: ["Integrations", "功能融合"], + settings: ["Settings", "设置"], + }; + const GRAPH_SAFE_PADDING = 34; + const state = { - data: null, - activeTarget: "overview", + page: "home", + ready: false, + dashboard: null, + overview: null, + pageData: {}, + contentType: "dialogues", + settingsGroup: null, + dirtySettings: new Map(), + graph: { + nodes: [], + links: [], + running: false, + dragged: null, + hovered: null, + type: "memory", + width: 0, + height: 0, + canvasBound: false, + }, }; - const els = {}; const physics = { - points: [], + particles: [], pointer: { x: 0, y: 0, active: false }, running: false, last: 0, }; - function $(id) { - return document.getElementById(id); - } - - function collectElements() { - [ - "runtime-status", - "runtime-summary", - "full-dashboard-link", - "refresh-button", - "stat-messages", - "stat-jargon", - "stat-style", - "stat-persona", - "module-list", - "module-chart", - "intelligence-ring", - "intelligence-score", - "metrics-summary", - "quick-entry-list", - "detail-kicker", - "detail-title", - "error-panel", - ].forEach((id) => { - els[id] = $(id); - }); - } + const $ = (id) => document.getElementById(id); + const qs = (selector, root = document) => root.querySelector(selector); + const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); - function buildEndpoint(path) { - return `page/${String(path).replace(/^\/+/, "").replace(/\/+/g, "/")}`; + function endpoint(path) { + return `page/${String(path || "").replace(/^\/+/, "").replace(/\/+/g, "/")}`; } - async function apiGet(path, params) { + async function bridgeReady() { const bridge = window.AstrBotPluginPage; if (!bridge) { throw new Error("AstrBot 插件页桥接 SDK 未加载"); } - await bridge.ready(); - return bridge.apiGet(buildEndpoint(path), params || {}); + const context = await bridge.ready(); + state.ready = true; + return context; + } + + async function apiGet(path, params) { + const bridge = window.AstrBotPluginPage; + await bridgeReady(); + return unwrap(await bridge.apiGet(endpoint(path), params || {})); + } + + async function apiPost(path, body) { + const bridge = window.AstrBotPluginPage; + await bridgeReady(); + return unwrap(await bridge.apiPost(endpoint(path), body || {})); } function unwrap(response) { - if (response && response.status === "ok") { - return response.data || {}; + const body = response && response.data && response.data.status ? response.data : response; + if (body && body.status === "ok") { + return body.data || {}; + } + if (body && body.status === "error") { + throw new Error(body.message || "请求失败"); } - if (response && response.status === "error") { - throw new Error(response.message || "请求失败"); + if (body && body.success === false) { + throw new Error(body.message || body.error || "请求失败"); } - return response || {}; + return body || {}; } - function fmt(value) { + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, "`"); + } + + function fmt(value, digits = 1) { const num = Number(value || 0); if (!Number.isFinite(num)) return "0"; - return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 1 }).format(num); + return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: digits }).format(num); + } + + function normalizeScore(value) { + const num = Number(value || 0); + if (!Number.isFinite(num)) return 0; + return Math.max(0, Math.min(100, num <= 1 ? num * 100 : num)); } function setText(id, value) { - if (els[id]) { - els[id].textContent = value; + const el = $(id); + if (el) el.textContent = value; + } + + function setHtml(id, html) { + const el = $(id); + if (el) el.innerHTML = html; + } + + function empty(text = "暂无数据") { + return `
${escapeHtml(text)}
`; + } + + function pill(text, tone = "") { + return `${escapeHtml(text)}`; + } + + function button(label, attrs = "", cls = "ghost-button") { + return ``; + } + + function setBusy(label = "加载中") { + setText("runtime-status", label); + setText("hero-status", label); + } + + function showToast(message, tone = "ok") { + const region = $("toast-region"); + if (!region) return; + const el = document.createElement("div"); + el.className = `toast ${tone}`; + el.textContent = message; + region.appendChild(el); + setTimeout(() => { + el.classList.add("leaving"); + setTimeout(() => el.remove(), 220); + }, 3200); + } + + function showErrors(errors) { + const panel = $("error-panel"); + if (!panel) return; + const entries = Object.entries(errors || {}); + panel.hidden = entries.length === 0; + panel.innerHTML = entries + .map(([key, value]) => `

${escapeHtml(key)}: ${escapeHtml(value)}

`) + .join(""); + } + + function showModal(title, html) { + const modal = $("detail-modal"); + setText("modal-title", title); + setHtml("modal-body", html); + if (modal && typeof modal.showModal === "function") { + modal.showModal(); } } - function setThemeFromBridge() { - try { - const bridge = window.AstrBotPluginPage; - if (!bridge) return; - const ctx = bridge.getContext && bridge.getContext(); - if (ctx && typeof ctx.isDark === "boolean") { - document.documentElement.setAttribute("data-theme", ctx.isDark ? "dark" : "light"); - } - if (bridge.onContext) { - bridge.onContext((next) => { - if (next && typeof next.isDark === "boolean") { - document.documentElement.setAttribute("data-theme", next.isDark ? "dark" : "light"); - } - }); - } - } catch (_) {} + function closeModal() { + const modal = $("detail-modal"); + if (modal && typeof modal.close === "function") modal.close(); + } + + function resolvePageFromHash() { + const raw = window.location.hash.replace(/^#\/?/, ""); + return PAGE_META[raw] ? raw : "home"; } - async function loadOverview() { - setStatus("加载中", "正在同步插件运行状态", false); + function navigateToPage(page, options = {}) { + const next = PAGE_META[page] ? page : "home"; + state.page = next; + if (!options.skipHash) { + window.location.hash = `#/${next}`; + } + qsa(".page").forEach((el) => el.classList.toggle("active", el.dataset.page === next)); + qsa(".nav-item").forEach((el) => el.classList.toggle("active", el.dataset.page === next)); + const meta = PAGE_META[next] || PAGE_META.home; + setText("page-kicker", meta[0]); + setText("page-title", meta[1]); + loadPageData(next, { force: !!options.force }); + } + + async function loadDashboard(force = false) { + if (state.dashboard && !force) { + renderDashboard(state.dashboard); + return state.dashboard; + } + setBusy("同步中"); try { - const data = unwrap(await apiGet("overview")); - state.data = data; - render(data); + const data = await apiGet("dashboard"); + state.dashboard = data; + state.overview = data.overview || data; + renderDashboard(data); + return data; } catch (error) { - setStatus("读取失败", error.message || String(error), true); - renderErrors({ bridge: error.message || String(error) }); + showToast(error.message || String(error), "error"); + showErrors({ bridge: error.message || String(error) }); + throw error; } } - function setStatus(label, summary, warn) { - const pill = els["runtime-status"]; - if (pill) { - pill.textContent = label; - pill.classList.toggle("warn", !!warn); + async function loadPageData(page, options = {}) { + const force = !!options.force; + try { + if (page === "home" || page === "insights") { + const data = await loadDashboard(force); + if (page === "insights") renderInsights(data); + return; + } + if (page === "monitoring") return renderMonitoring(await cached("monitoring", () => apiGet("monitoring"), force)); + if (page === "reviews") return renderReviews(await cached("reviews", () => apiGet("reviews", { limit: 50 }), force)); + if (page === "jargon-learning") return loadJargon(force); + if (page === "expression-learning") return renderStyle(await cached("style", () => apiGet("style", { limit: 50 }), force)); + if (page === "persona-learning") return renderPersona(await cached("persona", () => apiGet("persona", { group_id: "default", limit: 30 }), force)); + if (page === "content") return renderContent(await cached("content", () => apiGet("content", { page: 1, page_size: 20 }), force)); + if (page === "graphs") return loadGraphs(force); + if (page === "reply-strategy") return renderReplyStrategy(await cached("integrations", () => apiGet("integrations"), force)); + if (page === "integrations") return renderIntegrations(await cached("integrations", () => apiGet("integrations"), force)); + if (page === "settings") return renderSettings(await cached("settings", () => apiGet("settings", { schema: "true" }), force)); + } catch (error) { + showToast(error.message || String(error), "error"); } - setText("runtime-summary", summary); } - function render(data) { - const runtime = data.runtime || {}; - const webui = data.webui || {}; - const learning = data.learning_stats || {}; - const jargon = data.jargon || {}; - const styleStats = ((data.style || {}).statistics) || {}; - const persona = data.persona || {}; + async function cached(key, loader, force) { + if (!force && state.pageData[key]) return state.pageData[key]; + setBusy("加载中"); + const data = await loader(); + state.pageData[key] = data; + return data; + } - if (els["full-dashboard-link"] && webui.dashboard_url) { - els["full-dashboard-link"].href = webui.dashboard_url; - } + function renderDashboard(data) { + const overview = data.overview || data; + const runtime = overview.runtime || {}; + const webui = overview.webui || {}; + const learning = overview.learning_stats || {}; + const jargon = overview.jargon || {}; + const styleStats = ((overview.style || {}).statistics) || {}; + const persona = overview.persona || {}; + const errors = data.errors || overview.errors || {}; + const degraded = runtime.database_degraded || Object.keys(errors).length > 0; - const degraded = runtime.database_degraded || Object.keys(data.errors || {}).length > 0; - setStatus( - degraded ? "部分可用" : "运行正常", - degraded - ? "嵌入式页面已载入,部分服务处于降级状态,可查看下方错误提示。" - : `官方插件页已连接,完整 WebUI 入口为 ${webui.dashboard_url || "未配置"}。`, - degraded, - ); + const statusLabel = degraded ? "部分可用" : "运行正常"; + const summary = degraded + ? "嵌入式页面已载入,部分服务处于降级状态。" + : `已连接官方插件页 API,独立 WebUI: ${webui.dashboard_url || "未配置"}`; + setText("runtime-status", statusLabel); + setText("hero-status", statusLabel); + setText("runtime-summary", summary); + setText("hero-summary", summary); + $("runtime-status")?.classList.toggle("warn", degraded); + $("hero-status")?.classList.toggle("warn", degraded); + + const fullLink = $("full-dashboard-link"); + if (fullLink && webui.dashboard_url) fullLink.href = webui.dashboard_url; setText("stat-messages", fmt(learning.total_messages_collected)); setText("stat-jargon", fmt(jargon.confirmed_jargon)); setText("stat-style", fmt(styleStats.unique_styles || styleStats.total_samples)); setText("stat-persona", fmt(learning.persona_updates || persona.begin_dialog_count)); - renderModules(data.modules || []); - renderCharts(data.modules || []); - renderMetrics(data.metrics || {}); - renderQuickLinks(data.quick_links || []); - renderErrors(data.errors || {}); - activateTarget(state.activeTarget); + renderQuickActions(overview.quick_links || []); + renderModuleCards(overview.modules || []); + renderModuleChart(overview.modules || []); + renderIntelligence(overview.metrics || {}); + renderInsights(data); + showErrors(errors); } - function renderModules(modules) { - if (!els["module-list"]) return; - els["module-list"].innerHTML = modules.map((item) => ` -
-

${escapeHtml(item.title)}

+ function renderQuickActions(links) { + const html = links.map((link) => { + const external = String(link.url || "").startsWith("http"); + return ` + ${escapeHtml(link.label || "入口")} + ${escapeHtml(link.description || "")} + `; + }).join(""); + setHtml("quick-actions", html); + } + + function renderModuleCards(modules) { + const html = modules.map((item) => ` +
+
+

${escapeHtml(item.title)}

+ ${pill(item.enabled ? "启用" : "关闭", item.enabled ? "ok" : "warn")} +

${escapeHtml(item.description || "")}

-
`).join(""); - - els["module-list"].querySelectorAll(".module-card").forEach((card) => { - card.addEventListener("click", () => activateTarget(card.dataset.target || "overview")); - }); + setHtml("module-card-grid", html || empty()); } - function renderCharts(modules) { - if (!els["module-chart"]) return; + function renderModuleChart(modules) { const maxValue = Math.max(1, ...modules.map((item) => Number(item.metric || 0))); - els["module-chart"].innerHTML = modules.map((item) => { + const html = modules.map((item) => { const value = Math.max(4, Math.min(100, (Number(item.metric || 0) / maxValue) * 100)); - return ` -
- ${escapeHtml(item.title)} -
- ${escapeHtml(fmt(item.metric))} -
- `; + return `
+ ${escapeHtml(item.title)} +
+ ${escapeHtml(fmt(item.metric))} +
`; }).join(""); + setHtml("module-chart", html || empty()); } - function renderMetrics(metrics) { - const rawScore = Number(metrics.overall_score || 0); - const normalized = rawScore <= 1 ? rawScore * 100 : rawScore; - const score = Math.max(0, Math.min(100, normalized)); - if (els["intelligence-ring"]) { - els["intelligence-ring"].style.setProperty("--value", String(score)); - } + function renderIntelligence(metrics) { + const score = normalizeScore(metrics.overall_score); + $("intelligence-ring")?.style.setProperty("--value", String(score)); setText("intelligence-score", fmt(score)); - const dimensions = metrics.dimensions && typeof metrics.dimensions === "object" + const dimCount = metrics.dimensions && typeof metrics.dimensions === "object" ? Object.keys(metrics.dimensions).length : 0; - setText("metrics-summary", dimensions ? `已有 ${dimensions} 个维度参与评估。` : "智能指标服务暂未产生维度数据。"); - } - - function renderQuickLinks(links) { - if (!els["quick-entry-list"]) return; - els["quick-entry-list"].innerHTML = links.map((link) => ` - - - ${escapeHtml(link.label || "入口")} - ${escapeHtml(link.description || "")} - - - + setText("metrics-summary", dimCount ? `已有 ${dimCount} 个维度参与评估。` : "智能指标服务暂未产生维度数据。"); + } + + function buildInsights(data) { + const overview = data.overview || {}; + const reviews = data.reviews || {}; + const monitoring = data.monitoring || {}; + const integrations = data.integrations || {}; + const errors = data.errors || {}; + const items = []; + const push = (severity, title, detail, target) => items.push({ severity, title, detail, target }); + + if ((overview.runtime || {}).database_degraded) { + push("warn", "数据库处于降级状态", (overview.runtime || {}).database_error || "数据库服务未完整启动。", "monitoring"); + } + const pendingPersona = ((reviews.persona_pending || {}).updates || []).length; + const pendingStyle = ((reviews.style_reviews || {}).reviews || []).length; + const pendingJargon = (((reviews.jargon_pending || {}).jargon_list) || []).length; + const totalBacklog = pendingPersona + pendingStyle + pendingJargon; + if (totalBacklog > 0) { + push("action", "审查队列有积压", `当前有 ${totalBacklog} 条学习结果等待确认。`, "reviews"); + } + const score = normalizeScore(((overview.metrics || {}).overall_score)); + if (score > 0 && score < 60) { + push("warn", "智能评分偏低", `综合评分 ${fmt(score)},建议查看表达样本和学习批次。`, "metrics"); + } + const health = (monitoring.health || {}).overall; + if (health && health !== "healthy") { + push("warn", "健康检查提示异常", `当前健康状态为 ${health}。`, "monitoring"); + } + const delegation = integrations.delegation || {}; + if (delegation.memory_delegated || delegation.reply_delegated) { + push("ok", "伴随插件委托已启用", `记忆委托: ${delegation.memory_delegated ? "是" : "否"},回复委托: ${delegation.reply_delegated ? "是" : "否"}。`, "integrations"); + } + Object.entries(errors).forEach(([key, value]) => { + push("warn", `模块 ${key} 读取失败`, String(value), "monitoring"); + }); + if (!items.length) { + push("ok", "暂无高优先级问题", "核心学习、审查和监控模块均已返回可用数据。", "home"); + } + return items; + } + + function renderInsights(data) { + const insights = buildInsights(data || state.dashboard || {}); + const html = insights.map((item) => ` +
+ ${escapeHtml(item.severity === "ok" ? "OK" : item.severity === "action" ? "ACTION" : "WARN")} +

${escapeHtml(item.title)}

+

${escapeHtml(item.detail)}

+ ${button("前往", `data-route-card="${escapeAttr(item.target)}"`)} +
`).join(""); + setHtml("ai-insight-list", html); } - function renderErrors(errors) { - if (!els["error-panel"]) return; - const entries = Object.entries(errors || {}); - els["error-panel"].hidden = entries.length === 0; - els["error-panel"].innerHTML = entries.map(([key, value]) => `

${escapeHtml(key)}: ${escapeHtml(value)}

`).join(""); + function renderMonitoring(data) { + const health = data.health || {}; + const checks = health.checks || {}; + const healthHtml = Object.entries(checks).map(([key, item]) => ` +
+ ${escapeHtml(key)} + ${escapeHtml(item.status || "unknown")} + ${escapeHtml(summarizeObject(item.detail || {}))} +
+ `).join(""); + setHtml("health-grid", healthHtml || empty("暂无健康检查数据")); + + const functions = ((data.functions || {}).functions || []).slice(0, 20); + const fnHtml = functions.map((item) => ` +
+ ${escapeHtml(shortName(item.name))} + ${escapeHtml(fmt((item.duration || {}).avg || 0, 4))}s + ${escapeHtml(fmt(item.calls || 0, 0))} calls +
+ `).join(""); + setHtml("function-list", fnHtml || empty((data.functions || {}).debug_mode ? "暂无函数性能数据" : "debug_mode 未启用")); + showErrors(data.errors || {}); } - function activateTarget(target) { - state.activeTarget = target || "overview"; - document.querySelectorAll(".module-tab").forEach((tab) => { - tab.classList.toggle("active", tab.dataset.target === state.activeTarget); - }); - document.querySelectorAll(".module-card").forEach((card) => { - card.classList.toggle("active", card.dataset.target === state.activeTarget); - }); + async function loadJargon(force) { + const confirmed = $("jargon-confirmed")?.value || ""; + const filter = $("jargon-filter")?.value || ""; + const keyword = $("jargon-keyword")?.value || ""; + const params = { page: 1, page_size: 30 }; + if (confirmed) params.confirmed = confirmed; + if (filter) params.filter = filter; + if (keyword) params.keyword = keyword; + const data = await cached(`jargon:${JSON.stringify(params)}`, () => apiGet("jargon", params), force); + renderJargon(data); + } - const module = (state.data && (state.data.modules || []).find((item) => item.target === state.activeTarget)) || null; - setText("detail-kicker", state.activeTarget === "overview" ? "Overview" : state.activeTarget); - setText("detail-title", module ? module.title : "模块状态"); + function renderJargon(data) { + const stats = data.stats || {}; + setHtml("jargon-stat-grid", statCards([ + ["候选词", stats.total_candidates], + ["已确认", stats.confirmed_jargon], + ["推断完成", stats.completed_inference], + ["活跃群组", stats.active_groups], + ])); + const items = ((data.list || {}).jargon_list || []); + const html = items.map((item) => ` +
+
+ ${escapeHtml(item.term || item.content || `#${item.id}`)} + ${escapeHtml(item.meaning || item.definition || "暂无释义")} +
+ ${escapeHtml(item.group_id || "global")} + ${pill(item.is_confirmed ? "已确认" : "待确认", item.is_confirmed ? "ok" : "warn")} + ${pill(item.is_global ? "全局" : "本地")} +
+ ${button("编辑", `data-jargon-action="edit" data-id="${escapeAttr(item.id)}"`)} + ${button("确认", `data-jargon-action="approve" data-id="${escapeAttr(item.id)}"`)} + ${button("驳回", `data-jargon-action="reject" data-id="${escapeAttr(item.id)}"`)} + ${button(item.is_global ? "取消全局" : "设为全局", `data-jargon-action="toggle_global" data-id="${escapeAttr(item.id)}"`)} + ${button("删除", `data-jargon-action="delete" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+
+ `).join(""); + setHtml("jargon-list", html || empty("暂无黑话数据")); + state.pageData.lastJargonItems = items; + showErrors(data.errors || {}); } - function escapeHtml(value) { - return String(value ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + function renderStyle(data) { + const stats = ((data.results || {}).statistics) || {}; + setHtml("style-stat-grid", statCards([ + ["风格样本", stats.unique_styles || stats.total_samples], + ["平均置信度", stats.avg_confidence], + ["总样本", stats.total_samples], + ["最近更新", stats.latest_update ? "有" : "无"], + ])); + const patterns = data.patterns || {}; + const patternGroups = [ + ["情绪模式", patterns.emotion_patterns || []], + ["语言模式", patterns.language_patterns || []], + ["话题模式", patterns.topic_patterns || []], + ]; + setHtml("style-pattern-columns", patternGroups.map(([title, list]) => ` +
+

${escapeHtml(title)}

+ ${(list || []).slice(0, 12).map((item) => `${escapeHtml(item.name || item.pattern || item.text || "")}`).join("") || empty("暂无模式")} +
+ `).join("")); + const chartItems = patternGroups.map(([title, list]) => ({ title, metric: (list || []).length, accent: "#4169e1" })); + renderGenericBarChart("style-pattern-chart", chartItems); + const reviews = ((data.reviews || {}).reviews || []); + setHtml("expression-review-list", reviews.map((item) => styleReviewHtml(item)).join("") || empty("暂无表达审查")); } - function escapeAttr(value) { - return escapeHtml(value).replace(/`/g, "`"); + function renderReviews(data) { + const personaPending = ((data.persona_pending || {}).updates || []); + const personaReviewed = ((data.persona_reviewed || {}).updates || []); + const styleReviews = ((data.style_reviews || {}).reviews || []); + const pendingJargon = (((data.jargon_pending || {}).jargon_list) || []); + setText("persona-review-count", fmt(personaPending.length, 0)); + setText("style-review-count", fmt(styleReviews.length, 0)); + setText("jargon-review-count", fmt(pendingJargon.length, 0)); + setText("reviewed-count", fmt(personaReviewed.length, 0)); + setHtml("persona-review-list", personaPending.map((item) => personaReviewHtml(item)).join("") || empty("暂无人格更新")); + setHtml("style-review-list", styleReviews.map((item) => styleReviewHtml(item)).join("") || empty("暂无表达审查")); + setHtml("jargon-review-list", pendingJargon.map((item) => jargonReviewHtml(item)).join("") || empty("暂无黑话候选")); + setHtml("reviewed-persona-list", personaReviewed.slice(0, 12).map((item) => ` +
+ ${escapeHtml(item.id)} + ${escapeHtml(item.status || item.review_status || "reviewed")} + ${escapeHtml(item.reason || item.update_type || item.review_source || "")} +
+ `).join("") || empty("暂无已审查记录")); + showErrors(data.errors || {}); } - function bindEvents() { - if (els["refresh-button"]) { - els["refresh-button"].addEventListener("click", loadOverview); - } - document.querySelectorAll(".module-tab").forEach((tab) => { - tab.addEventListener("click", () => activateTarget(tab.dataset.target || "overview")); - }); - initSpringMotion(); + function personaReviewHtml(item) { + const id = item.id; + return `
+
+ ${escapeHtml(item.update_type || item.review_source || "人格更新")} + ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.reason || item.description || "")} +

${escapeHtml(item.proposed_content || item.new_content || item.incremental_content || "").slice(0, 220)}

+
+
+ ${button("详情", `data-review-action="detail" data-kind="persona" data-id="${escapeAttr(id)}"`)} + ${button("批准", `data-review-action="approve" data-kind="persona" data-id="${escapeAttr(id)}"`, "solid-button")} + ${button("拒绝", `data-review-action="reject" data-kind="persona" data-id="${escapeAttr(id)}"`)} + ${button("删除", `data-review-action="delete" data-kind="persona" data-id="${escapeAttr(id)}"`, "danger-button")} +
+
`; } - function initSpringMotion() { - const stage = document.querySelector(".spring-stage"); - if (!stage) return; + function styleReviewHtml(item) { + return `
+
+ ${escapeHtml(item.description || "表达方式学习")} + ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.status || "pending")} +

${escapeHtml(item.few_shots_content || item.learned_patterns || "").slice(0, 220)}

+
+
+ ${button("详情", `data-review-action="detail" data-kind="style" data-id="${escapeAttr(item.id)}"`)} + ${button("批准", `data-review-action="approve" data-kind="style" data-id="${escapeAttr(item.id)}"`, "solid-button")} + ${button("拒绝", `data-review-action="reject" data-kind="style" data-id="${escapeAttr(item.id)}"`)} +
+
`; + } + + function jargonReviewHtml(item) { + return `
+
+ ${escapeHtml(item.term || item.content || `#${item.id}`)} + ${escapeHtml(item.group_id || "global")} · ${escapeHtml(fmt(item.occurrences || item.count, 0))} 次 +

${escapeHtml(item.meaning || item.definition || item.review_detail || "暂无释义")}

+
+
+ ${button("确认", `data-review-action="approve" data-kind="jargon" data-id="${escapeAttr(item.id)}"`, "solid-button")} + ${button("驳回", `data-review-action="reject" data-kind="jargon" data-id="${escapeAttr(item.id)}"`)} + ${button("删除", `data-review-action="delete" data-kind="jargon" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+
`; + } + + function renderPersona(data) { + const current = data.current || {}; + const persona = current.persona || {}; + setHtml("persona-state-stats", statCards([ + ["提示词字数", current.prompt_length], + ["开场对话", current.begin_dialog_count], + ["工具数量", current.tool_count], + ["当前群组", current.group_id || "default"], + ])); + setText("persona-prompt-preview", current.prompt_preview || persona.system_prompt || persona.prompt || "暂无人格提示词"); + + const personas = data.personas || []; + setHtml("persona-list", personas.map((item) => { + const id = item.persona_id || item.id || item.name; + return `
+ ${escapeHtml(id)} + ${escapeHtml(item.name || id)} +
+ ${button("导出", `data-persona-action="export" data-persona-id="${escapeAttr(id)}"`)} + ${button("删除", `data-persona-action="delete" data-persona-id="${escapeAttr(id)}"`, "danger-button")} +
+
`; + }).join("") || empty("暂无人格列表")); + + const backups = ((data.backups || {}).backups || []); + setText("persona-backup-count", fmt(backups.length, 0)); + setHtml("persona-backup-list", backups.map((item) => ` +
+
+ ${escapeHtml(item.backup_name || `备份 ${item.id}`)} + ${escapeHtml(item.reason_short || item.reason || "无备注")} +
+ ${escapeHtml(item.group_id || "default")} + ${escapeHtml(item.timestamp || item.created_at || "")} +
+ ${button("查看", `data-persona-action="backup_detail" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`)} + ${button("恢复", `data-persona-action="backup_restore" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "solid-button")} + ${button("删除", `data-persona-action="backup_delete" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "danger-button")} +
+
+ `).join("") || empty("暂无人格备份")); + showErrors(data.errors || {}); + } + + function renderContent(data) { + const content = data.content || {}; + const items = content[state.contentType] || []; + qsa("#content-tabs button").forEach((btn) => btn.classList.toggle("active", btn.dataset.contentType === state.contentType)); + setHtml("learning-content-list", items.map((item) => ` +
+
+ ${escapeHtml(item.title || item.type || `#${item.id}`)} + ${escapeHtml(item.timestamp || "")} ${escapeHtml(item.metadata || "")} +

${escapeHtml(item.text || item.detail || "").slice(0, 360)}

+
+ ${button("删除", `data-content-action="delete_content" data-bucket="${escapeAttr(state.contentType)}" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+ `).join("") || empty("暂无学习内容")); + + const batches = ((data.batches || {}).batches || []); + setHtml("batch-list", batches.map((item) => ` +
+ ${escapeHtml(item.batch_name || item.batch_id || item.id)} + ${escapeHtml(item.status || (item.success ? "success" : "unknown"))} + ${escapeHtml(fmt(item.quality_score || 0, 3))} + ${button("删除", `data-content-action="delete_batch" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+ `).join("") || empty("暂无批次历史")); + showErrors(data.errors || {}); + } - physics.points = Array.from(stage.querySelectorAll(".spring-node:not(.node-core)")).map((el, index) => ({ - el, - x: 0, - y: 0, + async function loadGraphs(force) { + const type = $("graph-type")?.value || "memory"; + state.graph.type = type; + const data = await cached(`graphs:${type}`, () => apiGet("graphs", { type, limit: 140 }), force); + renderGraphs(data); + } + + function renderGraphs(data) { + const graph = data[state.graph.type] || data.memory || data.knowledge || {}; + const canvas = $("graph-canvas"); + const size = canvas + ? syncGraphCanvasSize(canvas, { force: true }) + : { width: 960, height: 520 }; + const rawNodes = Array.isArray(graph.nodes) ? graph.nodes : []; + state.graph.nodes = rawNodes.map((node, index) => + createGraphNode(node, index, rawNodes.length, size.width, size.height), + ); + state.graph.links = normalizeGraphLinks(graph.links || []); + state.graph.dragged = null; + state.graph.hovered = null; + setHtml("graph-stat-grid", statCards([ + ["节点", (graph.stats || {}).nodes || state.graph.nodes.length], + ["连线", (graph.stats || {}).links || state.graph.links.length], + ["群组", (graph.stats || {}).groups || (graph.groups || []).length], + ["来源", graph.data_source || "self_learning"], + ])); + setHtml("graph-node-list", state.graph.nodes.slice(0, 18).map((node) => ` +
+ ${escapeHtml(node.name || node.id)} + ${escapeHtml(node.category_name || node.category || "节点")} + ${escapeHtml(node.detail || "")} +
+ `).join("") || empty("暂无图谱节点")); + startGraphRender(); + } + + function createGraphNode(node, index, total, width, height) { + const id = graphValueKey(node.id ?? node.name ?? node.label ?? `${state.graph.type}-${index}`); + 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; + 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), vx: 0, vy: 0, - seed: index * 2.1, - })); - if (!physics.points.length) return; + pinned: false, + }; + } + + function normalizeGraphLinks(links) { + if (!Array.isArray(links)) return []; + return links.map((link) => ({ + ...link, + source: graphValueKey(link.source ?? link.from), + target: graphValueKey(link.target ?? link.to), + })).filter((link) => link.source && link.target); + } + + function renderReplyStrategy(data) { + const cards = (data.dashboards || []).filter((item) => item.id === "group_chat_plus"); + setHtml("reply-strategy-cards", cards.map(integrationCardHtml).join("") || empty("未检测到 Group Chat Plus")); + } + + function renderIntegrations(data) { + setHtml("integration-cards", (data.dashboards || []).map(integrationCardHtml).join("") || empty("暂无融合状态")); + const settings = data.settings || {}; + setHtml("integration-settings", Object.entries(settings).map(([key, value]) => ` +
+ ${escapeHtml(key)} + ${escapeHtml(value === true ? "开启" : value === false ? "关闭" : value ?? "未设置")} +
+ `).join("") || empty("暂无融合设置")); + } + + function integrationCardHtml(item) { + const dash = item.dashboard || {}; + const url = dash.external_url || dash.official_page_url || dash.url || "#"; + const disabled = !dash.available || !url; + return `
+
+ ${escapeHtml(item.role || "")} +

${escapeHtml(item.title || item.id)}

+

${escapeHtml(item.delegated ? "已委托" : item.active ? "可用" : "未启用")}

+
+ ${escapeHtml(dash.label || "打开")} + ${escapeHtml((item.dev_api || {}).mode || "")} +
`; + } + + function renderSettings(data) { + const schema = data.schema || {}; + const groups = schema.groups || []; + if (!state.settingsGroup && groups.length) state.settingsGroup = groups[0].key; + setHtml("settings-groups", groups.map((group) => ` + + `).join("") || empty("配置 schema 暂不可用")); + + const active = groups.find((group) => group.key === state.settingsGroup) || groups[0] || { fields: [] }; + setHtml("config-form", (active.fields || []).map(fieldHtml).join("") || empty("请选择配置分组")); + renderPipMirrors(data.pip_mirrors || {}); + } + + function fieldHtml(field) { + const value = state.dirtySettings.has(field.key) ? state.dirtySettings.get(field.key) : field.value; + const common = `data-config-field="${escapeAttr(field.key)}" data-config-type="${escapeAttr(field.type)}" ${field.editable ? "" : "disabled"}`; + let control = ""; + if (field.widget === "toggle") { + control = ``; + } else if (field.widget === "select" || field.widget === "provider") { + const options = field.options || []; + control = ``; + } else if (field.widget === "textarea" || field.type === "list") { + const textValue = Array.isArray(value) ? value.join("\n") : value ?? ""; + control = ``; + } else { + const inputType = field.widget === "number" || field.type === "int" || field.type === "float" ? "number" : "text"; + const step = field.type === "float" ? "0.01" : "1"; + control = ``; + } + return ``; + } + + function renderPipMirrors(mirrors) { + const select = $("pip-mirror-select"); + if (!select || select.childElementCount) return; + select.innerHTML = Object.entries(mirrors).map(([key, item]) => ``).join(""); + } + + function statCards(items) { + return items.map(([label, value]) => `
+ ${escapeHtml(label)} + ${escapeHtml(fmt(value, typeof value === "number" ? 1 : 0))} +
`).join(""); + } + + function renderGenericBarChart(id, items) { + const maxValue = Math.max(1, ...items.map((item) => Number(item.metric || 0))); + setHtml(id, items.map((item) => { + const value = Math.max(4, Math.min(100, Number(item.metric || 0) / maxValue * 100)); + return `
+ ${escapeHtml(item.title)} +
+ ${escapeHtml(fmt(item.metric, 0))} +
`; + }).join("") || empty()); + } + + function summarizeObject(obj) { + const entries = Object.entries(obj || {}).slice(0, 3); + return entries.map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`).join(" · "); + } + + function shortName(name) { + const text = String(name || ""); + return text.length > 58 ? `...${text.slice(-55)}` : text; + } + + function findReviewItem(kind, id) { + const reviews = state.pageData.reviews || {}; + const style = state.pageData.style || {}; + if (kind === "persona") return ((reviews.persona_pending || {}).updates || []).find((item) => String(item.id) === String(id)); + if (kind === "style") { + return ( + ((reviews.style_reviews || {}).reviews || []).find((item) => String(item.id) === String(id)) + || ((style.reviews || {}).reviews || []).find((item) => String(item.id) === String(id)) + ); + } + return (((reviews.jargon_pending || {}).jargon_list || []).find((item) => String(item.id) === String(id))); + } + + async function handleReviewAction(kind, id, action) { + if (action === "detail") { + showModal("审查详情", `
${escapeHtml(JSON.stringify(findReviewItem(kind, id) || {}, null, 2))}
`); + return; + } + let payload; + if (kind === "persona") { + payload = action === "delete" + ? { action: "delete", id } + : { action: "review", id, decision: action }; + } else if (kind === "style") { + payload = { action: `style_${action}`, id }; + } else { + payload = { action: `jargon_${action}`, id }; + } + const result = await apiPost("reviews/action", payload); + showToast(result.message || "操作完成", result.success ? "ok" : "error"); + state.pageData.reviews = null; + await loadPageData(state.page, { force: true }); + } + + async function handleJargonAction(action, id) { + if (action === "edit") { + const item = (state.pageData.lastJargonItems || []).find((entry) => String(entry.id) === String(id)) || {}; + showModal("编辑黑话", ` + + + + `); + return; + } + const result = await apiPost("jargon/action", { action, id }); + showToast(result.message || "操作完成", result.success ? "ok" : "error"); + state.pageData = {}; + await loadPageData(state.page, { force: true }); + } + + async function handlePersonaAction(buttonEl) { + const action = buttonEl.dataset.personaAction; + const body = { + action, + id: buttonEl.dataset.id, + group_id: buttonEl.dataset.groupId, + persona_id: buttonEl.dataset.personaId, + }; + const result = await apiPost("persona/action", body); + if (action === "backup_detail" || action === "export") { + showModal(action === "export" ? "人格导出" : "备份详情", `
${escapeHtml(JSON.stringify(result.persona || result.backup || result, null, 2))}
`); + return; + } + showToast(result.message || "操作完成", result.success ? "ok" : "error"); + state.pageData.persona = null; + await loadPageData("persona-learning", { force: true }); + } + + async function handleContentAction(buttonEl) { + const result = await apiPost("content/action", { + action: buttonEl.dataset.contentAction, + bucket: buttonEl.dataset.bucket, + id: buttonEl.dataset.id, + }); + showToast(result.message || "操作完成", result.success ? "ok" : "error"); + state.pageData.content = null; + await loadPageData("content", { force: true }); + } + + function collectConfigPayload() { + const payload = Object.fromEntries(state.dirtySettings.entries()); + qsa("[data-config-field]").forEach((field) => { + const key = field.dataset.configField; + const type = field.dataset.configType; + let value; + if (field.type === "checkbox") value = field.checked; + else if (type === "int") value = Number.parseInt(field.value || "0", 10); + else if (type === "float") value = Number.parseFloat(field.value || "0"); + else if (type === "list") { + const raw = field.value.trim(); + try { + value = raw.startsWith("[") ? JSON.parse(raw) : raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); + } catch (_) { + value = raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); + } + } else value = field.value; + payload[key] = value; + }); + return payload; + } + function bindEvents() { + $("refresh-button")?.addEventListener("click", () => loadPageData(state.page, { force: true })); + $("modal-close")?.addEventListener("click", closeModal); + $("jargon-search-button")?.addEventListener("click", () => { + Object.keys(state.pageData).filter((key) => key.startsWith("jargon:")).forEach((key) => delete state.pageData[key]); + loadJargon(true); + }); + $("copy-insight-context")?.addEventListener("click", async () => { + const text = JSON.stringify(state.dashboard || {}, null, 2); + try { + await navigator.clipboard.writeText(text); + showToast("巡检上下文已复制"); + } catch (_) { + showModal("巡检上下文", `
${escapeHtml(text)}
`); + } + }); + $("relearn-button")?.addEventListener("click", async () => { + const result = await apiPost("content/action", { action: "relearn", group_id: "default" }); + showToast(result.message || "重新学习已提交", result.success ? "ok" : "error"); + }); + $("graph-type")?.addEventListener("change", () => loadGraphs(true)); + $("config-save-button")?.addEventListener("click", async () => { + const result = await apiPost("settings/action", { action: "save", config: collectConfigPayload() }); + showToast(result.message || "设置已保存", result.success ? "ok" : "error"); + state.pageData.settings = null; + 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"); + }); + + document.addEventListener("click", async (event) => { + const target = event.target.closest("[data-route-card],[data-refresh-page],[data-review-action],[data-jargon-action],[data-persona-action],[data-content-action],[data-settings-group]"); + if (!target) return; + if (target.dataset.routeCard) navigateToPage(target.dataset.routeCard); + if (target.dataset.refreshPage) loadPageData(target.dataset.refreshPage, { force: true }); + if (target.dataset.reviewAction) await handleReviewAction(target.dataset.kind, target.dataset.id, target.dataset.reviewAction); + if (target.dataset.jargonAction) await handleJargonAction(target.dataset.jargonAction, target.dataset.id); + if (target.dataset.personaAction) await handlePersonaAction(target); + if (target.dataset.contentAction) await handleContentAction(target); + if (target.dataset.settingsGroup) { + state.settingsGroup = target.dataset.settingsGroup; + renderSettings(state.pageData.settings || {}); + } + }); + + document.addEventListener("change", (event) => { + const field = event.target.closest("[data-config-field]"); + if (!field) return; + state.dirtySettings.set(field.dataset.configField, field.type === "checkbox" ? field.checked : field.value); + }); + + document.addEventListener("click", async (event) => { + const save = event.target.closest("#modal-jargon-save"); + if (!save) return; + const result = await apiPost("jargon/action", { + action: "update", + id: save.dataset.id, + content: $("modal-jargon-content")?.value, + meaning: $("modal-jargon-meaning")?.value, + }); + closeModal(); + showToast(result.message || "黑话已更新", result.success ? "ok" : "error"); + state.pageData = {}; + await loadPageData("jargon-learning", { force: true }); + }); + + qsa(".nav-item").forEach((item) => { + item.addEventListener("click", (event) => { + event.preventDefault(); + navigateToPage(item.dataset.page || "home"); + }); + }); + qsa("#content-tabs button").forEach((buttonEl) => { + buttonEl.addEventListener("click", () => { + state.contentType = buttonEl.dataset.contentType || "dialogues"; + renderContent(state.pageData.content || {}); + }); + }); + window.addEventListener("hashchange", () => navigateToPage(resolvePageFromHash(), { skipHash: true })); + } + + function setThemeFromBridge() { + try { + const bridge = window.AstrBotPluginPage; + const apply = (ctx) => { + if (ctx && typeof ctx.isDark === "boolean") { + document.documentElement.setAttribute("data-theme", ctx.isDark ? "dark" : "light"); + } + }; + apply(bridge && bridge.getContext && bridge.getContext()); + if (bridge && bridge.onContextChange) bridge.onContextChange(apply); + if (bridge && bridge.onContext) bridge.onContext(apply); + } catch (_) {} + } + + function initSpringMotion() { + const stage = qs(".spring-stage"); + const canvas = $("physics-canvas"); + if (!stage || !canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const resize = () => { + const rect = stage.getBoundingClientRect(); + canvas.width = Math.max(1, Math.floor(rect.width * devicePixelRatio)); + canvas.height = Math.max(1, Math.floor(rect.height * devicePixelRatio)); + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + }; + resize(); + window.addEventListener("resize", resize); + physics.particles = qsa(".spring-node:not(.node-core)", stage).map((el, index) => ({ + el, x: 0, y: 0, vx: 0, vy: 0, seed: index * 2.3, + })); stage.addEventListener("pointermove", (event) => { const rect = stage.getBoundingClientRect(); physics.pointer.x = event.clientX - rect.left; physics.pointer.y = event.clientY - rect.top; physics.pointer.active = true; }); - stage.addEventListener("pointerleave", () => { - physics.pointer.active = false; - }); - + stage.addEventListener("pointerleave", () => { physics.pointer.active = false; }); if (!physics.running) { physics.running = true; physics.last = performance.now(); @@ -289,50 +1046,289 @@ } function tickSpringMotion(now) { - if (!physics.running) return; + const stage = qs(".spring-stage"); + const canvas = $("physics-canvas"); + if (!stage || !canvas) return; + const ctx = canvas.getContext("2d"); + const rect = stage.getBoundingClientRect(); 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; - physics.points.forEach((point) => { - const rect = point.el.parentElement.getBoundingClientRect(); + const core = { x: rect.width / 2, y: rect.height / 2 }; + physics.particles.forEach((point) => { const own = point.el.getBoundingClientRect(); - const breatheX = Math.sin(now / 1200 + point.seed) * 14; - const breatheY = Math.cos(now / 1350 + point.seed) * 12; - let targetX = breatheX; - let targetY = breatheY; - + 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; if (physics.pointer.active) { - const cx = own.left - rect.left + own.width / 2 + point.x; - const cy = own.top - rect.top + own.height / 2 + point.y; + 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.sqrt(dx * dx + dy * dy)); - const force = Math.max(0, 110 - dist) / 110; - targetX += (dx / dist) * force * 46; - targetY += (dy / dist) * force * 46; + 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 spring = 46; - const damping = 12; - point.vx += (targetX - point.x) * spring * dt; - point.vy += (targetY - point.y) * spring * dt; - point.vx *= Math.max(0, 1 - damping * dt); - point.vy *= Math.max(0, 1 - damping * dt); + 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.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.stroke(); + point.el.style.transform = `translate3d(${point.x.toFixed(2)}px, ${point.y.toFixed(2)}px, 0)`; + }); + requestAnimationFrame(tickSpringMotion); + } + + function startGraphRender() { + const canvas = $("graph-canvas"); + if (!canvas) return; + bindGraphCanvas(canvas); + syncGraphCanvasSize(canvas, { force: true }); + if (!state.graph.running) { + state.graph.running = true; + requestAnimationFrame(tickGraph); + } + } + + function bindGraphCanvas(canvas) { + if (state.graph.canvasBound) return; + state.graph.canvasBound = true; - const scale = 1 + Math.min(0.12, Math.hypot(point.vx, point.vy) / 900); - point.el.style.transform = `translate3d(${point.x.toFixed(2)}px, ${point.y.toFixed(2)}px, 0) scale(${scale.toFixed(3)})`; + canvas.addEventListener("pointerdown", (event) => { + const point = graphPointer(event, canvas); + const node = hitGraphNode(point.x, point.y); + if (!node) return; + event.preventDefault(); + canvas.setPointerCapture?.(event.pointerId); + node.pinned = true; + node.vx = 0; + node.vy = 0; + state.graph.dragged = { + node, + pointerId: event.pointerId, + offsetX: node.x - point.x, + offsetY: node.y - point.y, + }; + canvas.classList.add("is-dragging"); }); - requestAnimationFrame(tickSpringMotion); + canvas.addEventListener("pointermove", (event) => { + const point = graphPointer(event, canvas); + const drag = state.graph.dragged; + if (drag && drag.pointerId === event.pointerId) { + const min = drag.node.radius + GRAPH_SAFE_PADDING; + 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; + drag.node.homeY = drag.node.y; + drag.node.vx = 0; + drag.node.vy = 0; + event.preventDefault(); + return; + } + state.graph.hovered = hitGraphNode(point.x, point.y); + canvas.classList.toggle("has-hover", Boolean(state.graph.hovered)); + }); + + const releaseDrag = (event) => { + const drag = state.graph.dragged; + if (drag && drag.pointerId === event.pointerId) { + drag.node.vx = 0; + drag.node.vy = 0; + state.graph.dragged = null; + canvas.classList.remove("is-dragging"); + canvas.releasePointerCapture?.(event.pointerId); + } + }; + canvas.addEventListener("pointerup", releaseDrag); + canvas.addEventListener("pointercancel", releaseDrag); + canvas.addEventListener("pointerleave", () => { + state.graph.hovered = null; + canvas.classList.remove("has-hover"); + }); + + window.addEventListener("resize", () => { + syncGraphCanvasSize(canvas, { force: true }); + }); + } + + function tickGraph() { + const canvas = $("graph-canvas"); + if (!canvas) { + state.graph.running = false; + return; + } + const ctx = canvas.getContext("2d"); + const { width, height, ratio } = syncGraphCanvasSize(canvas); + const nodes = state.graph.nodes; + const links = state.graph.links; + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + ctx.clearRect(0, 0, width, height); + const byId = new Map(nodes.map((node) => [String(node.id), node])); + + links.slice(0, 260).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(98, Math.min(168, width * 0.18)); + const force = (dist - desired) * 0.00032; + if (!source.pinned) { + source.vx += dx * force; + source.vy += dy * force; + } + if (!target.pinned) { + target.vx -= dx * force; + target.vy -= dy * force; + } + ctx.strokeStyle = "rgba(100, 116, 139, 0.28)"; + ctx.lineWidth = Math.max(1, Math.min(4, Number(link.value || 1))); + ctx.beginPath(); + ctx.moveTo(source.x, source.y); + ctx.lineTo(target.x, target.y); + ctx.stroke(); + }); + + 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; + } + } + } + + nodes.forEach((node, index) => { + 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.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); + const isHovered = state.graph.hovered === node || state.graph.dragged?.node === node; + if (isHovered) { + ctx.fillStyle = "rgba(15, 159, 143, 0.14)"; + ctx.beginPath(); + ctx.arc(node.x, node.y, radius + 10, 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = node.source === "livingmemory" ? "#0f9f8f" : index % 3 === 0 ? "#4169e1" : index % 3 === 1 ? "#d97706" : "#e11d48"; + ctx.beginPath(); + ctx.arc(node.x, node.y, isHovered ? radius + 2 : radius, 0, Math.PI * 2); + 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); + }); + requestAnimationFrame(tickGraph); + } + + function syncGraphCanvasSize(canvas, options = {}) { + const rect = canvas.getBoundingClientRect(); + const width = Math.max(320, Math.floor(rect.width || canvas.clientWidth || state.graph.width || 960)); + const height = Math.max(320, Math.floor(rect.height || canvas.clientHeight || state.graph.height || 520)); + const ratio = Math.max(1, Math.min(2, window.devicePixelRatio || 1)); + const nextWidth = Math.floor(width * ratio); + const nextHeight = Math.floor(height * ratio); + const resized = canvas.width !== nextWidth || canvas.height !== nextHeight; + if (resized || options.force) { + const oldWidth = state.graph.width || width; + const oldHeight = state.graph.height || height; + canvas.width = nextWidth; + canvas.height = nextHeight; + state.graph.nodes.forEach((node) => { + const min = node.radius + GRAPH_SAFE_PADDING; + 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); + }); + } + state.graph.width = width; + state.graph.height = height; + return { width, height, ratio }; + } + + function graphPointer(event, canvas) { + const rect = canvas.getBoundingClientRect(); + return { + x: clamp(event.clientX - rect.left, 0, state.graph.width || rect.width), + y: clamp(event.clientY - rect.top, 0, state.graph.height || rect.height), + }; + } + + function hitGraphNode(x, y) { + for (let index = state.graph.nodes.length - 1; index >= 0; index -= 1) { + const node = state.graph.nodes[index]; + const radius = (node.radius || graphNodeRadius(node)) + 8; + if (Math.hypot(node.x - x, node.y - y) <= radius) { + return node; + } + } + return null; + } + + function graphNodeRadius(node) { + const raw = Number(node.symbolSize || node.value || node.weight || 12); + return Math.max(9, Math.min(24, Number.isFinite(raw) ? raw : 12)); + } + + function graphValueKey(value) { + if (value && typeof value === "object") { + return String(value.id ?? value.name ?? value.label ?? ""); + } + return String(value ?? ""); + } + + function clamp(value, min, max) { + if (max < min) return min; + return Math.max(min, Math.min(max, value)); } async function init() { - collectElements(); setThemeFromBridge(); bindEvents(); - await loadOverview(); + initSpringMotion(); + try { + await bridgeReady(); + navigateToPage(resolvePageFromHash(), { skipHash: true, force: true }); + } catch (error) { + showToast(error.message || String(error), "error"); + setText("runtime-status", "桥接失败"); + setText("runtime-summary", error.message || String(error)); + } } if (document.readyState === "loading") { diff --git a/pages/dashboard/index.html b/pages/dashboard/index.html index d06070c2..817008d1 100644 --- a/pages/dashboard/index.html +++ b/pages/dashboard/index.html @@ -8,93 +8,388 @@ -
-
-
-

AstrBot Embedded WebUI

-

Self Learning

+
+
+ -
-
-
+ + +
-
-
- 采集消息 - 0 +
+
+ +
+ 同步中 +

学习模块总览

+

正在读取黑话、表达、人设、图谱和监控数据。

+
+
-
- 已确认黑话 - 0 + +
+
+ 采集消息 + 0 +
+
+ 已确认黑话 + 0 +
+
+ 表达样本 + 0 +
+
+ 人格更新 + 0 +
+
+ +
+
+
+

学习子模块

+ +
+
+
+
+
+

模块指标

+ +
+
+
+
+ 0 +
+

等待智能指标服务返回数据。

+
+
+
+
+ +
+
+
+

AI 巡检

+

从学习积压、运行健康和配置状态生成可行动提示。

+
+
-
- 表达样本 - 0 +
+
+ +
+
+
+

运行监控

+

健康检查、服务可用性和函数级性能。

+
+
-
- 人格更新 - 0 +
+
+

健康状态

+
+
+
+

函数性能

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

最近已审查

+ 0 +
+
+
+
+ +
+
+
+

黑话学习

+

管理群内黑话候选、全局共享和语义释义。

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

表达方式学习

+

查看风格统计、学习模式和待确认 few-shot。

+
+ +
+
+
+

表达统计

+
+
+
+
+

待审查表达

+
+
+
+
+

学习模式

+
+
+
+ +
+
+
+

人格学习

+

当前人格状态、人格列表和备份恢复。

+
+ +
+
+
+

当前人格状态

+
+

+            
+
+

人格列表

+
+
+
+
+
+

人格备份

+ 0 +
+
+
+
+ +
+
+
+

学习内容

+

浏览原始对话、分析结果、表达模式和学习批次。

+
+
+ + +
+
+
+ + + + +
+
+
+
+
+

批次历史

+
+
+
+ +
+
+
+

图谱

+

记忆图谱与知识图谱会在内嵌页直接绘制。

+
+
+ + +
+
+
+
+ +
+
+

图谱详情

+
+
+
+
+
+ +
+
+
+

回复策略

+

查看 Group Chat Plus 委托状态和面板入口。

+
+ +
+
+
+
+
+ +
+
+
+

功能融合

+

Self Learning、LivingMemory 与 Group Chat Plus 的协作状态。

+
+ +
+
+
+

融合设置

+
+
+
+ +
+
+
+

设置

+

完整配置 schema、Provider 选项和可选依赖安装。

+
+
+ + +
+
+
+
+
+
+
+
+
+
+

可选依赖

+
+ + + +
+
+

+          
+
+ + +
+ + + + diff --git a/pages/dashboard/styles.css b/pages/dashboard/styles.css index 0d40dc70..7cf2a3da 100644 --- a/pages/dashboard/styles.css +++ b/pages/dashboard/styles.css @@ -1,151 +1,331 @@ :root { - --bg: #f7f8fb; - --surface: rgba(255, 255, 255, 0.88); + --bg: #f5f7fb; + --surface: rgba(255, 255, 255, 0.86); --surface-strong: #ffffff; - --border: rgba(15, 23, 42, 0.1); + --surface-muted: #eef2f7; + --border: rgba(15, 23, 42, 0.11); --text: #162033; - --muted: #657187; - --primary: #2563eb; - --teal: #14b8a6; - --amber: #f59e0b; + --muted: #687386; + --primary: #4169e1; + --teal: #0f9f8f; + --amber: #d97706; --rose: #e11d48; - --shadow: 0 18px 45px rgba(15, 23, 42, 0.1); + --green: #16a34a; + --shadow: 0 18px 44px rgba(15, 23, 42, 0.09); + --spring: cubic-bezier(0.2, 0.82, 0.18, 1); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } -html[data-theme="dark"], -html[data-theme="dark"] :root { - --bg: #111827; - --surface: rgba(31, 41, 55, 0.86); - --surface-strong: #1f2937; - --border: rgba(255, 255, 255, 0.11); +html[data-theme="dark"] { + --bg: #121720; + --surface: rgba(27, 34, 47, 0.86); + --surface-strong: #202939; + --surface-muted: #16202d; + --border: rgba(255, 255, 255, 0.12); --text: #f8fafc; - --muted: #a7b0c1; - --shadow: 0 18px 45px rgba(0, 0, 0, 0.25); + --muted: #a8b2c4; + --shadow: 0 18px 44px rgba(0, 0, 0, 0.24); } * { box-sizing: border-box; } +html { + min-height: 100%; + background: var(--bg); +} + body { margin: 0; min-height: 100vh; color: var(--text); background: - linear-gradient(120deg, rgba(20, 184, 166, 0.12), transparent 38%), - linear-gradient(135deg, var(--bg), color-mix(in srgb, var(--bg), #dbeafe 30%)); + linear-gradient(130deg, rgba(15, 159, 143, 0.11), transparent 32%), + linear-gradient(40deg, rgba(217, 119, 6, 0.08), transparent 36%), + var(--bg); } button, +input, +select, +textarea, a { font: inherit; } -.shell { - width: min(1180px, calc(100vw - 32px)); - margin: 0 auto; - padding: 24px 0 32px; +button { + color: inherit; } -.topbar { +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 268px minmax(0, 1fr); +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; display: flex; + flex-direction: column; + gap: 18px; + padding: 18px; + border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--surface), var(--bg) 20%); + backdrop-filter: blur(22px); +} + +.brand-block { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 12px; align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 18px; +} + +.brand-mark { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 8px; + color: #ffffff; + background: linear-gradient(145deg, var(--primary), var(--teal)); + font-weight: 850; + box-shadow: 0 12px 24px rgba(65, 105, 225, 0.24); } .eyebrow { - margin: 0 0 4px; + margin: 0 0 3px; color: var(--muted); - font-size: 12px; - font-weight: 700; + font-size: 11px; + font-weight: 800; letter-spacing: 0; text-transform: uppercase; } h1, h2, +h3, +h4, p { margin-top: 0; } h1 { margin-bottom: 0; - font-size: 42px; - line-height: 1; + font-size: 20px; + line-height: 1.1; letter-spacing: 0; } +.nav-list { + display: grid; + gap: 5px; + overflow-y: auto; + padding-right: 2px; +} + +.nav-item { + min-height: 38px; + display: flex; + align-items: center; + padding: 0 12px; + border: 1px solid transparent; + border-radius: 8px; + color: var(--muted); + text-decoration: none; + transition: transform 180ms var(--spring), background 180ms ease, color 180ms ease; +} + +.nav-item:hover { + color: var(--text); + background: color-mix(in srgb, var(--surface-strong), transparent 18%); +} + +.nav-item.active { + color: #ffffff; + background: var(--primary); + box-shadow: 0 12px 24px rgba(65, 105, 225, 0.22); +} + +.sidebar-footer { + margin-top: auto; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); +} + +.sidebar-footer p { + margin: 9px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.workspace { + min-width: 0; + width: min(1280px, calc(100vw - 268px)); + margin: 0 auto; + padding: 22px 24px 36px; +} + +.topbar, +.page-titlebar, +.panel-heading, +.inline-actions, .top-actions { display: flex; - gap: 8px; + align-items: center; + justify-content: space-between; + gap: 12px; } -.icon-button { - width: 40px; - height: 40px; - display: inline-grid; - place-items: center; +.topbar { + margin-bottom: 16px; +} + +.topbar h2 { + margin-bottom: 0; + font-size: 30px; + line-height: 1.15; + letter-spacing: 0; +} + +.top-actions, +.inline-actions { + flex-wrap: wrap; +} + +.icon-button, +.ghost-button, +.solid-button, +.danger-button, +.quick-entry, +.settings-group { + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; border: 1px solid var(--border); border-radius: 8px; - color: var(--text); background: var(--surface); + color: var(--text); text-decoration: none; cursor: pointer; - box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08); + transition: transform 180ms var(--spring), border-color 180ms ease, background 180ms ease; +} + +.icon-button { + width: 40px; + padding: 0; + font-size: 19px; +} + +.ghost-button, +.solid-button, +.danger-button { + padding: 0 12px; + white-space: nowrap; +} + +.solid-button { + color: #ffffff; + border-color: var(--primary); + background: var(--primary); } -.icon-button:hover { +.danger-button { + color: #ffffff; + border-color: var(--rose); + background: var(--rose); +} + +.disabled { + pointer-events: none; + opacity: 0.55; +} + +.icon-button:hover, +.ghost-button:hover, +.solid-button:hover, +.danger-button:hover, +.quick-entry:hover, +.settings-group:hover { transform: translateY(-1px); } -.dashboard-grid { - display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.7fr); - gap: 14px; +.status-pill, +.mini-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 3px 9px; + border-radius: 999px; + color: #075985; + background: rgba(14, 165, 233, 0.13); + font-size: 12px; + font-weight: 800; } -.status-panel, -.stat-strip, -.detail-panel, -.errors { - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); - box-shadow: var(--shadow); +.status-pill.warn, +.mini-badge.warn { + color: #9a3412; + background: rgba(217, 119, 6, 0.15); } -.status-panel { - min-height: 260px; - position: relative; - overflow: hidden; +.mini-badge.ok { + color: #166534; + background: rgba(22, 163, 74, 0.13); +} + +.page { + display: none; + animation: pageIn 260ms var(--spring); +} + +.page.active { + display: block; +} + +@keyframes pageIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dashboard-hero { display: grid; - grid-template-columns: minmax(240px, 0.9fr) minmax(240px, 1fr); - align-items: center; - padding: 24px; + grid-template-columns: minmax(320px, 1.05fr) minmax(280px, 0.95fr); + gap: 14px; + min-height: 292px; + margin-bottom: 14px; } .spring-stage { position: relative; - height: 216px; - border-radius: 8px; + min-height: 292px; overflow: hidden; - background: - linear-gradient(90deg, rgba(37, 99, 235, 0.14), transparent), - color-mix(in srgb, var(--surface-strong), transparent 18%); border: 1px solid var(--border); + border-radius: 8px; + background: + linear-gradient(90deg, rgba(65, 105, 225, 0.13), transparent), + linear-gradient(180deg, color-mix(in srgb, var(--surface-strong), transparent 6%), color-mix(in srgb, var(--surface-muted), transparent 12%)); + box-shadow: var(--shadow); } -.field-grid { +#physics-canvas { position: absolute; inset: 0; - opacity: 0.32; - background-image: - linear-gradient(var(--border) 1px, transparent 1px), - linear-gradient(90deg, var(--border) 1px, transparent 1px); - background-size: 34px 34px; } .spring-node { @@ -154,34 +334,28 @@ h1 { height: 32px; border-radius: 8px; background: var(--primary); - box-shadow: 0 10px 30px rgba(37, 99, 235, 0.35); - transform: translate3d(0, 0, 0); + box-shadow: 0 16px 32px rgba(65, 105, 225, 0.28); + will-change: transform; } .node-core { left: 50%; top: 50%; - width: 68px; - height: 68px; - margin: -34px 0 0 -34px; + width: 72px; + height: 72px; + margin: -36px 0 0 -36px; background: linear-gradient(145deg, var(--primary), var(--teal)); } -.node-a, -.node-b, -.node-c { - will-change: transform; -} - .node-a { - left: 24%; - top: 30%; + left: 22%; + top: 28%; background: var(--teal); } .node-b { - left: 66%; - top: 24%; + left: 68%; + top: 22%; background: var(--amber); } @@ -191,173 +365,230 @@ h1 { background: var(--rose); } -.status-copy { - padding-left: 24px; +.hero-copy, +.panel, +.stat-card, +.module-card, +.insight-card, +.integration-card, +.errors { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + box-shadow: var(--shadow); } -.status-pill { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 28px; - padding: 4px 10px; - border-radius: 999px; - color: #075985; - background: rgba(14, 165, 233, 0.13); - font-size: 12px; - font-weight: 700; +.hero-copy { + display: flex; + flex-direction: column; + justify-content: center; + padding: 24px; } -.status-pill.warn { - color: #9a3412; - background: rgba(245, 158, 11, 0.18); +.hero-copy h3 { + margin: 14px 0 9px; + font-size: 28px; + letter-spacing: 0; } -.status-copy h2, -.detail-panel h2 { - margin: 14px 0 8px; - font-size: 24px; - letter-spacing: 0; +.hero-copy p, +.page-titlebar p, +.module-card p, +.insight-card p, +.integration-card p, +.review-main p { + color: var(--muted); + line-height: 1.55; } -.status-copy p, -.mini-copy { - margin-bottom: 0; +.quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} + +.quick-entry { + flex-direction: column; + align-items: flex-start; + padding: 10px 12px; +} + +.quick-entry small { color: var(--muted); - line-height: 1.6; + font-size: 12px; } -.stat-strip { +.stat-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1px; - padding: 12px; - background: color-mix(in srgb, var(--surface), var(--border) 30%); + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; } -.stat-item { - min-height: 112px; - padding: 16px; - border-radius: 8px; +.stat-grid.compact { + grid-template-columns: repeat(4, minmax(120px, 1fr)); +} + +.stat-card { + min-height: 96px; + padding: 14px; background: var(--surface-strong); } -.stat-label, -.mini-label { - display: block; - margin-bottom: 8px; +.stat-card.small { + min-height: 78px; + box-shadow: none; +} + +.stat-card span, +.mini-label, +.table-row small, +.content-item small, +.review-main small, +.integration-card span, +.config-field small { color: var(--muted); font-size: 12px; font-weight: 700; } -.stat-item strong { - font-size: 30px; +.stat-card strong { + display: block; + margin-top: 8px; + font-size: 27px; letter-spacing: 0; } -.module-tabs { - display: flex; - gap: 8px; - padding: 18px 0 12px; - overflow-x: auto; +.home-grid, +.content-grid, +.graph-grid, +.settings-grid { + display: grid; + gap: 14px; } -.module-tab { - min-height: 36px; - padding: 0 14px; - border: 1px solid var(--border); - border-radius: 999px; - color: var(--muted); - background: var(--surface); - white-space: nowrap; - cursor: pointer; +.home-grid { + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); } -.module-tab.active { - color: #ffffff; - background: var(--primary); - border-color: var(--primary); +.content-grid.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); } -.content-grid { - display: grid; +.graph-grid { grid-template-columns: minmax(0, 1fr) 360px; - gap: 14px; } -.module-list { +.settings-grid { + grid-template-columns: 300px minmax(0, 1fr); + margin-bottom: 14px; +} + +.panel { + padding: 16px; +} + +.panel h3, +.page-titlebar h3 { + margin-bottom: 0; + font-size: 19px; + letter-spacing: 0; +} + +.page-titlebar { + margin-bottom: 14px; +} + +.page-titlebar p { + margin: 5px 0 0; +} + +.module-card-grid, +.insight-grid, +.review-layout, +.integration-cards, +.pattern-columns { display: grid; + gap: 10px; +} + +.module-card-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; } -.module-card { - min-height: 156px; - padding: 16px; - border: 1px solid var(--border); - border-radius: 8px; +.review-layout { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 14px; +} + +.integration-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 14px; +} + +.insight-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.pattern-columns { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.module-card, +.insight-card, +.integration-card { + min-height: 148px; + padding: 15px; background: var(--surface-strong); +} + +.module-card { cursor: pointer; - transition: transform 180ms ease, border-color 180ms ease; + border-left: 4px solid var(--accent, var(--primary)); + transition: transform 180ms var(--spring), border-color 180ms ease; } -.module-card:hover, -.module-card.active { +.module-card:hover { transform: translateY(-2px); - border-color: var(--accent, var(--primary)); } -.module-card h3 { - margin: 0 0 8px; - font-size: 18px; +.module-card-head { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.module-card h3, +.insight-card h3, +.integration-card h3 { + margin: 0 0 7px; + font-size: 17px; letter-spacing: 0; } .module-card p { - min-height: 44px; - margin-bottom: 16px; - color: var(--muted); - line-height: 1.5; + min-height: 46px; + margin-bottom: 12px; } -.module-card-footer { +.metric-line { display: flex; align-items: end; justify-content: space-between; gap: 12px; } -.module-metric { - font-size: 26px; - font-weight: 800; +.metric-line strong { + font-size: 25px; } -.module-state { - padding: 4px 8px; - border-radius: 999px; - color: var(--accent, var(--primary)); - background: color-mix(in srgb, var(--accent, var(--primary)), transparent 86%); +.metric-line span { + color: var(--muted); font-size: 12px; font-weight: 700; } -.detail-panel { - padding: 18px; -} - -.panel-heading span { - color: var(--primary); - font-size: 12px; - font-weight: 800; - text-transform: uppercase; -} - -.chart-stack { - display: grid; - gap: 18px; -} - .bar-chart { display: grid; gap: 10px; @@ -365,9 +596,9 @@ h1 { .bar-row { display: grid; - grid-template-columns: 72px minmax(0, 1fr) 42px; - gap: 10px; + grid-template-columns: 84px minmax(0, 1fr) 48px; align-items: center; + gap: 10px; color: var(--muted); font-size: 12px; } @@ -380,115 +611,470 @@ h1 { } .bar-fill { - height: 100%; width: calc(var(--value, 0) * 1%); + height: 100%; border-radius: inherit; background: var(--accent, var(--primary)); - transition: width 420ms cubic-bezier(0.2, 0.8, 0.2, 1); + transition: width 520ms var(--spring); } .ring-row { display: flex; - gap: 16px; align-items: center; - padding: 14px; + gap: 14px; + margin-top: 18px; + padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-strong); } .ring-chart { - width: 92px; - height: 92px; + width: 86px; + height: 86px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: - radial-gradient(circle at center, var(--surface-strong) 58%, transparent 59%), - conic-gradient(var(--primary) calc(var(--value, 0) * 1%), color-mix(in srgb, var(--muted), transparent 82%) 0); + radial-gradient(circle at center, var(--surface-strong) 57%, transparent 58%), + conic-gradient(var(--primary) calc(var(--value, 0) * 1%), color-mix(in srgb, var(--muted), transparent 84%) 0); } .ring-chart span { - font-weight: 800; + font-weight: 850; +} + +.insight-card.ok { + border-left: 4px solid var(--green); +} + +.insight-card.warn { + border-left: 4px solid var(--amber); +} + +.insight-card.action { + border-left: 4px solid var(--primary); } -.quick-entry-list { +.health-grid { display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; - margin-top: 18px; } -.quick-entry { - display: flex; - justify-content: space-between; - gap: 12px; +.health-card { + min-height: 98px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; + background: var(--surface-strong); +} + +.health-card strong { + display: block; + margin: 8px 0 6px; +} + +.health-card.healthy strong { + color: var(--green); +} + +.health-card.degraded strong, +.health-card.unhealthy strong { + color: var(--amber); +} + +.review-list, +.function-list, +.compact-table, +.content-list, +.config-form { + display: grid; + gap: 8px; +} + +.review-item, +.content-item, +.table-row, +.config-field, +.pattern-column { + display: grid; + gap: 9px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); +} + +.review-item, +.content-item { + align-items: start; +} + +.review-main p, +.content-item p { + margin-bottom: 0; + word-break: break-word; +} + +.table-row { + grid-template-columns: minmax(120px, 1fr) auto minmax(72px, auto) auto; + align-items: center; +} + +.rich-row { + grid-template-columns: minmax(180px, 1fr) auto auto auto; +} + +.row-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.toolbar-panel { + display: grid; + grid-template-columns: minmax(180px, 1fr) 150px 150px auto; + gap: 8px; + margin-bottom: 14px; +} + +input, +select, +textarea { + width: 100%; + min-height: 36px; + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; color: var(--text); background: var(--surface-strong); - text-decoration: none; } -.quick-entry small { +textarea { + resize: vertical; +} + +.segment-control { + display: inline-flex; + gap: 4px; + padding: 4px; + margin-bottom: 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.segment-control button { + min-height: 32px; + padding: 0 12px; + border: 0; + border-radius: 7px; + color: var(--muted); + background: transparent; + cursor: pointer; +} + +.segment-control button.active { + color: #ffffff; + background: var(--primary); +} + +.code-preview { + max-height: 360px; + overflow: auto; + margin: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + background: var(--surface-muted); + white-space: pre-wrap; + word-break: break-word; +} + +.graph-panel { + padding: 10px; +} + +#graph-canvas { + width: 100%; + height: clamp(360px, 56vw, 560px); + max-height: 62vh; + min-height: 360px; display: block; - margin-top: 4px; + touch-action: none; + border-radius: 8px; + cursor: grab; + background: + linear-gradient(var(--border) 1px, transparent 1px), + linear-gradient(90deg, var(--border) 1px, transparent 1px), + color-mix(in srgb, var(--surface-strong), transparent 4%); + background-size: 34px 34px; +} + +#graph-canvas.has-hover { + cursor: grab; +} + +#graph-canvas.is-dragging { + cursor: grabbing; +} + +.settings-sidebar { + align-content: start; + display: grid; + gap: 8px; +} + +.settings-group { + width: 100%; + min-height: 54px; + justify-content: flex-start; + flex-direction: column; + align-items: flex-start; + padding: 10px 12px; +} + +.settings-group.active { + color: #ffffff; + border-color: var(--primary); + background: var(--primary); +} + +.settings-group.active small { + color: rgba(255, 255, 255, 0.78); +} + +.config-field { + grid-template-columns: minmax(220px, 0.8fr) minmax(220px, 1fr); + align-items: center; +} + +.switch { + position: relative; + width: 54px; + height: 30px; + justify-self: start; +} + +.switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.switch span { + position: absolute; + inset: 0; + border-radius: 999px; + background: color-mix(in srgb, var(--muted), transparent 74%); + transition: background 180ms ease; +} + +.switch span::after { + content: ""; + position: absolute; + left: 3px; + top: 3px; + width: 24px; + height: 24px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.22); + transition: transform 220ms var(--spring); +} + +.switch input:checked + span { + background: var(--green); +} + +.switch input:checked + span::after { + transform: translateX(24px); +} + +.dependency-panel { + margin-top: 14px; +} + +.dependency-panel .code-preview { + margin-top: 12px; + min-height: 80px; +} + +.empty-state { + min-height: 54px; + display: grid; + place-items: center; + padding: 14px; + border: 1px dashed var(--border); + border-radius: 8px; color: var(--muted); + background: color-mix(in srgb, var(--surface-strong), transparent 28%); } .errors { margin-top: 14px; padding: 14px; color: #9a3412; - background: rgba(245, 158, 11, 0.12); + background: rgba(217, 119, 6, 0.12); +} + +.toast-region { + position: fixed; + right: 18px; + top: 18px; + z-index: 20; + display: grid; + gap: 8px; +} + +.toast { + max-width: min(360px, calc(100vw - 36px)); + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + background: var(--surface-strong); + box-shadow: var(--shadow); + animation: toastIn 180ms var(--spring); +} + +.toast.error { + color: #991b1b; +} + +.toast.leaving { + opacity: 0; + transform: translateY(-4px); +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateY(-6px); + } +} + +.modal { + width: min(760px, calc(100vw - 28px)); + padding: 0; + border: 0; + background: transparent; +} + +.modal::backdrop { + background: rgba(15, 23, 42, 0.46); + backdrop-filter: blur(5px); +} + +.modal-panel { + padding: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); + box-shadow: var(--shadow); } -@media (max-width: 900px) { - .dashboard-grid, - .content-grid, - .status-panel { +@media (max-width: 1120px) { + .app-shell { grid-template-columns: 1fr; } - .status-copy { - padding: 16px 0 0; + .sidebar { + position: relative; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--border); } - .content-grid { - display: flex; - flex-direction: column; + .nav-list { + grid-auto-flow: column; + grid-auto-columns: max-content; + overflow-x: auto; + overflow-y: hidden; } - .detail-panel { - order: -1; + .workspace { + width: min(100vw, 1280px); + } + + .sidebar-footer { + display: none; + } + + .review-layout, + .integration-cards, + .settings-grid, + .graph-grid { + grid-template-columns: 1fr; } } -@media (max-width: 640px) { - .shell { - width: min(100vw - 20px, 1180px); - padding-top: 14px; +@media (max-width: 860px) { + .workspace { + padding: 16px 14px 28px; } - .topbar { - align-items: flex-start; + .dashboard-hero, + .home-grid, + .content-grid.two-col { + grid-template-columns: 1fr; } - h1 { - font-size: 32px; + .stat-grid, + .stat-grid.compact, + .module-card-grid, + .insight-grid, + .pattern-columns, + .health-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .stat-strip, - .module-list { + .toolbar-panel { + grid-template-columns: 1fr 1fr; + } + + .table-row, + .rich-row, + .config-field { grid-template-columns: 1fr; } - .status-panel { - padding: 12px; + .row-actions { + justify-content: flex-start; + } +} + +@media (max-width: 560px) { + .topbar, + .page-titlebar, + .panel-heading { + align-items: flex-start; + flex-direction: column; + } + + .topbar h2 { + font-size: 24px; + } + + .stat-grid, + .stat-grid.compact, + .module-card-grid, + .insight-grid, + .pattern-columns, + .health-grid, + .toolbar-panel { + grid-template-columns: 1fr; } .spring-stage { - height: 180px; + min-height: 220px; + } + + .hero-copy h3 { + font-size: 24px; } } diff --git a/tests/integration/test_package_imports.py b/tests/integration/test_package_imports.py index c13a18bd..a3fbd9a0 100644 --- a/tests/integration/test_package_imports.py +++ b/tests/integration/test_package_imports.py @@ -68,7 +68,7 @@ def test_webui_manager_uses_deferred_annotations_for_lazy_server_import(): assert "from __future__ import annotations" in manager_source -def test_official_plugin_page_api_registers_dashboard_overview_route(): +def test_official_plugin_page_api_registers_embedded_dashboard_routes(): alias = "data.plugins.astrbot_plugin_self_learning_pageapi_pkgtest" _cleanup_alias(alias) @@ -91,14 +91,33 @@ def __init__(self): api.register_routes() assert module.PAGE_API_PREFIX == "/astrbot_plugin_self_learning/page" - assert plugin.context.routes == [ - ( - "/astrbot_plugin_self_learning/page/overview", - api.get_overview, - ["GET"], - "Self Learning embedded dashboard overview", - ) - ] + routes = {route: (handler, methods, desc) for route, handler, methods, desc in plugin.context.routes} + expected_methods = { + "/astrbot_plugin_self_learning/page/overview": ["GET"], + "/astrbot_plugin_self_learning/page/dashboard": ["GET"], + "/astrbot_plugin_self_learning/page/jargon": ["GET"], + "/astrbot_plugin_self_learning/page/jargon/action": ["POST"], + "/astrbot_plugin_self_learning/page/style": ["GET"], + "/astrbot_plugin_self_learning/page/style/action": ["POST"], + "/astrbot_plugin_self_learning/page/reviews": ["GET"], + "/astrbot_plugin_self_learning/page/reviews/action": ["POST"], + "/astrbot_plugin_self_learning/page/persona": ["GET"], + "/astrbot_plugin_self_learning/page/persona/action": ["POST"], + "/astrbot_plugin_self_learning/page/content": ["GET"], + "/astrbot_plugin_self_learning/page/content/action": ["POST"], + "/astrbot_plugin_self_learning/page/graphs": ["GET"], + "/astrbot_plugin_self_learning/page/metrics": ["GET"], + "/astrbot_plugin_self_learning/page/monitoring": ["GET"], + "/astrbot_plugin_self_learning/page/integrations": ["GET"], + "/astrbot_plugin_self_learning/page/settings": ["GET"], + "/astrbot_plugin_self_learning/page/settings/action": ["POST"], + } + assert set(expected_methods).issubset(routes) + for route, methods in expected_methods.items(): + assert routes[route][1] == methods + + 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 finally: _cleanup_alias(alias) diff --git a/tests/integration/test_webui_static_assets.py b/tests/integration/test_webui_static_assets.py index af071971..cac8f4ef 100644 --- a/tests/integration/test_webui_static_assets.py +++ b/tests/integration/test_webui_static_assets.py @@ -49,16 +49,57 @@ def test_embedded_plugin_page_uses_astrbot_bridge_and_module_dashboard(): styles = (PLUGIN_ROOT / "pages" / "dashboard" / "styles.css").read_text(encoding="utf-8") assert "AstrBot Embedded WebUI" in index - assert "黑话学习" in index - assert "表达方式学习" in index - assert "人格学习" in index + for label in [ + "Dashboard", + "AI 巡检", + "监控", + "审查队列", + "黑话学习", + "表达方式学习", + "人格学习", + "学习内容", + "图谱", + "回复策略", + "功能融合", + "设置", + ]: + assert label in index + for page in [ + "home", + "insights", + "monitoring", + "reviews", + "jargon-learning", + "expression-learning", + "persona-learning", + "content", + "graphs", + "reply-strategy", + "integrations", + "settings", + ]: + assert f'data-page="{page}"' in index assert "window.AstrBotPluginPage" in script - assert 'apiGet(buildEndpoint(path)' in script - assert 'apiGet("overview")' in script - assert 'buildEndpoint(path)' in script + assert 'apiGet("dashboard")' in script + assert 'apiGet("jargon"' in script + assert 'apiGet("style"' in script + assert 'apiGet("persona"' in script + assert 'apiGet("graphs"' in script + assert 'apiPost("reviews/action"' in script + assert 'apiPost("settings/action"' in script + assert 'return `page/${String(path || "")' in script assert "initSpringMotion" in script + assert "startGraphRender" in script + assert "syncGraphCanvasSize" in script + assert "hitGraphNode" in script + assert 'id="physics-canvas"' in index + assert 'id="graph-canvas"' in index + assert 'id="graph-canvas" width=' not in index assert ".module-card" in styles assert ".ring-chart" in styles + assert ".sidebar" in styles + assert ".graph-panel" in styles + assert "touch-action: none" in styles def test_webui_frontend_vendor_assets_exist():