From 833304afd612fa763f76ac2f733d2cf6d9598230 Mon Sep 17 00:00:00 2001 From: YumemiDream Date: Tue, 16 Jun 2026 19:16:42 +0800 Subject: [PATCH 1/5] fix: replace window.confirm with custom modal in embedded page The embedded Plugin Page runs inside a sandboxed iframe without 'allow-modals', so window.confirm() calls are silently blocked by the browser. This caused all batch operations (approve/reject/delete) on the reviews, jargon, and expression-learning pages to appear unresponsive. Replace all window.confirm() calls with a custom showConfirm() function that renders a confirmation dialog using the existing element, which works within sandbox restrictions. --- pages/dashboard/app.js | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/pages/dashboard/app.js b/pages/dashboard/app.js index b2a089b9..16a4d441 100644 --- a/pages/dashboard/app.js +++ b/pages/dashboard/app.js @@ -408,6 +408,34 @@ else modal.removeAttribute("open"); } + function showConfirm(title, message, confirmText) { + return new Promise((resolve) => { + const modal = $("detail-modal"); + if (!modal) { resolve(window.confirm(message)); return; } + setText("modal-title", title); + setHtml("modal-body", ` +

${escapeHtml(message)}

+
+ + +
+ `); + const done = (result) => { + if (typeof modal.close === "function") modal.close(); + else modal.removeAttribute("open"); + resolve(result); + }; + $("confirm-ok").addEventListener("click", () => done(true), { once: true }); + $("confirm-cancel").addEventListener("click", () => done(false), { once: true }); + $("modal-close").addEventListener("click", () => done(false), { once: true }); + modal.addEventListener("cancel", (e) => { e.preventDefault(); done(false); }, { once: true }); + if (typeof modal.showModal === "function") { + try { modal.showModal(); return; } catch (_) {} + } + modal.setAttribute("open", ""); + }); + } + function resolvePageFromHash() { const raw = window.location.hash.replace(/^#\/?/, ""); return PAGE_META[raw] ? raw : "home"; @@ -1095,11 +1123,11 @@ const typeText = { persona: t("reviews.personaUpdates", "人格更新"), style: t("reviews.expressionReviews", "表达审查"), jargon: t("reviews.jargonCandidates", "黑话候选") }[kind] || t("reviews.items", "审查项"); const actionText = action === "approve" ? t("actions.pass", "通过") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); const scopeText = selectedReviewIds(kind).length ? t("selection.selected", "选中") : t("selection.currentPage", "当前页"); - if (!window.confirm(t("reviews.confirmBatch", "确定批量{action}{scope} {count} 条{type}?") + if (!await showConfirm(t("reviews.batchConfirmTitle", "批量操作确认"), t("reviews.confirmBatch", "确定批量{action}{scope} {count} 条{type}?") .replace("{action}", actionText) .replace("{scope}", scopeText) .replace("{count}", fmt(ids.length, 0)) - .replace("{type}", typeText))) return; + .replace("{type}", typeText), actionText)) return; const payload = { action: action === "delete" @@ -1305,7 +1333,7 @@ return; } const actionText = action === "approve" ? t("actions.confirm", "确认") : action === "reject" ? t("actions.rejectBack", "驳回") : t("actions.delete", "删除"); - if (!window.confirm(t("jargon.confirmBatch", "确定批量{action}选中的 {count} 条黑话?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)))) return; + if (!await showConfirm(t("jargon.batchConfirmTitle", "批量操作确认"), t("jargon.confirmBatch", "确定批量{action}选中的 {count} 条黑话?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return; const result = await apiPost("jargon/action", { action: action === "delete" ? "batch_delete" : "batch_review", @@ -1367,7 +1395,7 @@ return; } const actionText = action === "approve" ? t("actions.approve", "批准") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); - if (!window.confirm(t("style.confirmBatch", "确定批量{action}选中的 {count} 条表达审查?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)))) return; + if (!await showConfirm(t("style.batchConfirmTitle", "批量操作确认"), t("style.confirmBatch", "确定批量{action}选中的 {count} 条表达审查?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return; const result = await apiPost("style/action", { action: action === "delete" ? "batch_delete" : "batch_review", From 824ac4a01ccd1490685158ab8bf17c7c891c4532 Mon Sep 17 00:00:00 2001 From: YumemiAI <71859504+YumemiDream@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:41:24 +0800 Subject: [PATCH 2/5] fix: skip re-learning jargon that has been manually edited or fully inferred When the v2 learning system encounters a jargon term that already exists and is_complete=True (manually edited or fully inferred), skip the save to prevent overwriting the user's edits. Also preserve the existing count when updating an existing record, instead of resetting it to 1. Fixes: manually edited jargon meanings get overwritten by re-learning. --- .../core_learning/v2_learning_integration.py | 1887 +++++++++-------- 1 file changed, 951 insertions(+), 936 deletions(-) diff --git a/services/core_learning/v2_learning_integration.py b/services/core_learning/v2_learning_integration.py index 5f0a1c85..4f90397c 100644 --- a/services/core_learning/v2_learning_integration.py +++ b/services/core_learning/v2_learning_integration.py @@ -1,936 +1,951 @@ -""" -V2 learning integration layer. - -Wires together the v2-architecture modules and provides a unified -interface for the ``MaiBotEnhancedLearningManager`` to delegate to. -When v2 features are enabled in ``PluginConfig`` the learning manager -instantiates this class and calls its ``process_message`` and -``get_enhanced_context`` methods alongside (or instead of) the legacy -code paths. - -Modules orchestrated: - * ``TieredLearningTrigger`` — per-message / batch operation scheduling - * ``LightRAGKnowledgeManager`` — knowledge graph (replaces legacy) - * ``Mem0MemoryManager`` — memory management (replaces legacy) - * ``ExemplarLibrary`` — few-shot style exemplar retrieval - * ``SocialGraphAnalyzer`` — community detection / influence ranking - * ``JargonStatisticalFilter`` — statistical jargon pre-filter - * ``IRerankProvider`` — cross-source context reranking - -Design notes: - - All module construction is guarded by the relevant config flags so - that unused modules are never instantiated. - - ``start()`` / ``stop()`` manage the full lifecycle of every active - v2 module. - - Each module that can fail during construction logs a warning and - falls back gracefully (the integration layer keeps working with - the remaining modules). - - Thread-safe for single-event-loop asyncio usage. -""" - -import asyncio -import hashlib -import time -from collections import defaultdict -from typing import Any, Dict, List, Optional, Tuple - -from astrbot.api import logger - -from ...config import PluginConfig -from ...core.interfaces import MessageData -from ...utils.cache_manager import get_cache_manager -from ..monitoring.instrumentation import monitored -from ..quality import ( - BatchTriggerPolicy, - TieredLearningTrigger, - TriggerResult, -) - -# Minimum message length to consider for LLM-heavy ingestion operations. -_MIN_INGESTION_LENGTH = 15 - -# Maximum buffered messages per group before force-flushing. -_INGESTION_BUFFER_MAX = 10 - - -class V2LearningIntegration: - """Facade that initialises, wires, and exposes v2 learning modules. - - Usage:: - - v2 = V2LearningIntegration(config, llm_adapter, db_manager, context) - await v2.start() - result = await v2.process_message(message, group_id) - context = await v2.get_enhanced_context("query", group_id) - await v2.stop() - """ - - def __init__( - self, - config: PluginConfig, - llm_adapter: Optional[Any] = None, - db_manager: Optional[Any] = None, - context: Optional[Any] = None, - feature_delegation: Optional[Any] = None, - ) -> None: - self._config = config - self._llm = llm_adapter - self._db = db_manager - self._context = context - self._feature_delegation = feature_delegation - self._started = False - self._provider_retry_lock = asyncio.Lock() - self._last_provider_retry: float = 0.0 - self._provider_retry_interval: float = max( - 0.1, - float(getattr(config, "provider_retry_interval_seconds", 10.0) or 10.0), - ) - self._knowledge_manager_retryable = True - self._memory_manager_retryable = True - - # --- Resolve framework providers via factories --------------- - self._embedding_provider = self._create_embedding_provider() - self._rerank_provider = self._create_rerank_provider() - - # --- Instantiate v2 modules ---------------------------------- - self._knowledge_manager = self._create_knowledge_manager() - self._memory_manager = self._create_memory_manager() - self._exemplar_library = self._create_exemplar_library() - self._social_analyzer = self._create_social_analyzer() - self._jargon_filter = self._create_jargon_filter() - - # --- Query result cache via CacheManager ---------------------- - self._cache = get_cache_manager() - - # --- Message buffer for batch ingestion ----------------------- - # Knowledge and memory ingestion are LLM-heavy operations and - # must not run per-message. Instead, messages are buffered here - # and flushed as a batch in a Tier 2 operation. - self._ingestion_buffer: Dict[str, List[MessageData]] = defaultdict(list) - - # --- Tiered trigger ------------------------------------------ - self._trigger = TieredLearningTrigger() - self._register_trigger_operations() - - logger.info( - "[V2Integration] Initialised — " - f"knowledge={self._config.knowledge_engine}, " - f"memory={self._config.memory_engine}, " - f"memory_delegated={self._memory_delegated()}, " - f"embedding={'yes' if self._embedding_provider else 'no'}, " - f"reranker={'yes' if self._rerank_provider else 'no'}" - ) - - # Lifecycle - - async def start(self) -> None: - """Start all active v2 modules that expose a ``start`` method.""" - await self.refresh_provider_bindings(force=True) - - await asyncio.gather(*( - self._start_one(name, module) - for name, module in self._active_modules() - if module and hasattr(module, "start") - )) - self._started = True - logger.info("[V2Integration] All modules started") - - async def refresh_provider_bindings(self, *, force: bool = False) -> bool: - """Retry framework provider binding and create dependent modules. - - AstrBot can load plugins before provider registries are populated. This - lets startup, warmup, and first-use paths bind providers later without a - manual plugin reload. - """ - if not self._needs_provider_or_module_retry(): - return False - - if not force and not self._provider_retry_due(): - return False - - async with self._provider_retry_lock: - if not self._needs_provider_or_module_retry(): - return False - - if not force and not self._provider_retry_due(): - return False - self._last_provider_retry = time.monotonic() - - changed = False - modules_to_start: List[Tuple[str, Any]] = [] - - if not self._embedding_provider and self._embedding_provider_configured(): - provider = self._create_embedding_provider() - if provider: - self._embedding_provider = provider - changed = True - - if not self._rerank_provider and self._rerank_provider_configured(): - provider = self._create_rerank_provider() - if provider: - self._rerank_provider = provider - changed = True - - if self._embedding_provider: - if self._knowledge_manager is None: - self._knowledge_manager = self._create_knowledge_manager() - if self._knowledge_manager: - changed = True - modules_to_start.append(( - "knowledge_manager", - self._knowledge_manager, - )) - - if self._memory_manager is None: - self._memory_manager = self._create_memory_manager() - if self._memory_manager: - changed = True - modules_to_start.append(( - "memory_manager", - self._memory_manager, - )) - - if self._exemplar_library_needs_embedding_refresh(): - self._exemplar_library = self._create_exemplar_library() - changed = True - - if changed: - self._register_trigger_operations() - if self._started and modules_to_start: - await asyncio.gather(*( - self._start_one(name, module) - for name, module in modules_to_start - if module and hasattr(module, "start") - )) - logger.info( - "[V2Integration] Provider bindings refreshed — " - f"embedding={'yes' if self._embedding_provider else 'no'}, " - f"reranker={'yes' if self._rerank_provider else 'no'}" - ) - return changed - - def _provider_retry_due(self) -> bool: - return ( - time.monotonic() - self._last_provider_retry - >= self._provider_retry_interval - ) - - async def _start_one(self, name: str, module: Any) -> None: - try: - await module.start() - except Exception as exc: - logger.warning( - f"[V2Integration] {name} start failed: {exc}" - ) - - def _active_modules(self) -> List[Tuple[str, Any]]: - return [ - ("knowledge_manager", self._knowledge_manager), - ("memory_manager", self._memory_manager), - ("exemplar_library", self._exemplar_library), - ("social_analyzer", self._social_analyzer), - ("jargon_filter", self._jargon_filter), - ] - - def _needs_provider_or_module_retry(self) -> bool: - if self._embedding_provider_configured() and not self._embedding_provider: - return True - if self._rerank_provider_configured() and not self._rerank_provider: - return True - if self._embedding_provider and self._knowledge_manager is None: - return ( - self._config.knowledge_engine == "lightrag" - and self._knowledge_manager_retryable - ) - if self._embedding_provider and self._memory_manager is None: - return ( - self._config.memory_engine == "mem0" - and not self._memory_delegated() - and self._memory_manager_retryable - ) - return self._exemplar_library_needs_embedding_refresh() - - def _embedding_provider_configured(self) -> bool: - return bool( - str(getattr(self._config, "embedding_provider_id", "") or "").strip() - ) - - def _rerank_provider_configured(self) -> bool: - return bool( - str(getattr(self._config, "rerank_provider_id", "") or "").strip() - ) - - def _exemplar_library_needs_embedding_refresh(self) -> bool: - if not (self._db and self._embedding_provider and self._exemplar_library): - return False - return getattr(self._exemplar_library, "_embedding", None) is None - - async def warmup(self, group_ids: List[str]) -> None: - """Pre-warm heavyweight module instances for *group_ids*. - - Should be called shortly after ``start()`` once active group IDs - are known. Currently only LightRAG benefits from pre-warming - (each cold-start avoids a 12-15s initialisation penalty on the - first user query). - """ - await self.refresh_provider_bindings() - if ( - self._knowledge_manager - and hasattr(self._knowledge_manager, "warmup_instances") - ): - try: - await self._knowledge_manager.warmup_instances(group_ids) - except Exception as exc: - logger.debug( - f"[V2Integration] Knowledge warmup failed: {exc}" - ) - - async def stop(self) -> None: - """Stop all active v2 modules and release resources. - - Attempts to flush remaining buffered messages with a per-group - timeout. Timed-out buffers are discarded to avoid blocking - the shutdown sequence. - """ - _flush_timeout = self._config.task_cancel_timeout - - for group_id in list(self._ingestion_buffer.keys()): - try: - await asyncio.wait_for( - self._flush_ingestion_buffer(group_id), - timeout=_flush_timeout, - ) - except asyncio.TimeoutError: - dropped = len(self._ingestion_buffer.pop(group_id, [])) - logger.warning( - f"[V2Integration] Buffer flush timeout for group " - f"{group_id}, dropped {dropped} messages" - ) - except Exception as exc: - logger.warning( - f"[V2Integration] Buffer flush failed on stop " - f"for group {group_id}: {exc}" - ) - - modules: List[Tuple[str, Any]] = [ - ("knowledge_manager", self._knowledge_manager), - ("memory_manager", self._memory_manager), - ("exemplar_library", self._exemplar_library), - ("social_analyzer", self._social_analyzer), - ("jargon_filter", self._jargon_filter), - ] - - async def _stop_one(name: str, module: Any) -> None: - try: - await module.stop() - except Exception as exc: - logger.warning( - f"[V2Integration] {name} stop failed: {exc}" - ) - - async def _close_reranker() -> None: - try: - await self._rerank_provider.close() - except Exception as exc: - logger.warning(f"[V2Integration] Reranker close failed: {exc}") - - tasks = [ - _stop_one(name, module) - for name, module in modules - if module and hasattr(module, "stop") - ] - if self._rerank_provider and hasattr(self._rerank_provider, "close"): - tasks.append(_close_reranker()) - - await asyncio.gather(*tasks) - logger.info("[V2Integration] All modules stopped") - - # Public API - - @monitored - async def process_message( - self, message: MessageData, group_id: str - ) -> TriggerResult: - """Process an incoming message through the tiered trigger. - - Tier 1 operations run concurrently on every message. Tier 2 - operations fire when their policies are satisfied. - """ - await self.refresh_provider_bindings() - return await self._trigger.process_message(message, group_id) - - @monitored - async def get_enhanced_context( - self, - query: str, - group_id: str, - top_k: int = 5, - ) -> Dict[str, Any]: - """Retrieve v2 enhanced context for response generation. - - Returns a dict with optional keys: - * ``knowledge_context`` (str): Retrieved knowledge graph context. - * ``related_memories`` (List[str]): Semantically related memories. - * ``few_shot_examples`` (List[str]): Style exemplar texts - (not reranked; returned as-is). - * ``graph_stats`` (dict): Social graph summary statistics. - - When a reranker is available, knowledge and memory candidates are - reranked by relevance and only the top-k are returned. Few-shot - exemplars and graph stats are returned unmodified. - - Results are cached per (group_id, query_hash) with a configurable - TTL to avoid redundant retrieval on repeated or similar queries. - - All retrieval tasks run concurrently via ``asyncio.gather`` to - minimise total latency. - """ - await self.refresh_provider_bindings() - - # --- Check query result cache --- - cache_key = self._make_cache_key(query, group_id) - cached_result = self._cache.get("context", cache_key) - if cached_result is not None: - logger.debug( - f"[V2Integration] Context cache hit (group={group_id})" - ) - return cached_result - - context: Dict[str, Any] = {} - - # --- Build concurrent retrieval tasks --- - - async def _fetch_knowledge() -> None: - if not self._knowledge_manager: - return - try: - if hasattr(self._knowledge_manager, "query_knowledge"): - ctx = await self._knowledge_manager.query_knowledge( - query, group_id, - mode=self._config.lightrag_query_mode, - ) - elif hasattr( - self._knowledge_manager, - "answer_question_with_knowledge_graph", - ): - ctx = ( - await self._knowledge_manager - .answer_question_with_knowledge_graph(query, group_id) - ) - else: - ctx = "" - if ctx: - context["knowledge_context"] = ctx - except Exception as exc: - logger.debug( - f"[V2Integration] Knowledge retrieval failed: {exc}" - ) - - async def _fetch_memories() -> None: - if self._memory_delegated(): - logger.debug("[V2Integration] Memory retrieval delegated to LivingMemory") - return - if not self._memory_manager: - return - try: - memories = await self._memory_manager.get_related_memories( - query, group_id - ) - if memories: - context["related_memories"] = memories - except Exception as exc: - logger.debug( - f"[V2Integration] Memory retrieval failed: {exc}" - ) - - async def _fetch_exemplars() -> None: - if not self._exemplar_library: - return - try: - examples = await self._exemplar_library.get_few_shot_examples( - query, group_id, k=top_k - ) - if examples: - context["few_shot_examples"] = examples - except Exception as exc: - logger.debug( - f"[V2Integration] Exemplar retrieval failed: {exc}" - ) - - async def _fetch_graph_stats() -> None: - if not self._social_analyzer: - return - try: - stats = await self._social_analyzer.get_graph_statistics( - group_id - ) - if stats and stats.get("node_count", 0) > 0: - context["graph_stats"] = stats - except Exception as exc: - logger.debug( - f"[V2Integration] Social graph stats failed: {exc}" - ) - - # --- Run all retrievals concurrently --- - await asyncio.gather( - _fetch_knowledge(), - _fetch_memories(), - _fetch_exemplars(), - _fetch_graph_stats(), - ) - - # --- Conditional reranking --- - # Only invoke the reranker when there are enough candidates to - # justify the additional API round-trip latency. - rerank_candidates = len(context.get("related_memories", [])) - if "knowledge_context" in context: - rerank_candidates += 1 - min_candidates = getattr( - self._config, "rerank_min_candidates", 3 - ) - if self._rerank_provider and rerank_candidates >= min_candidates: - context = await self._rerank_context(query, context, top_k) - - # --- Store result in cache --- - self._cache.set("context", cache_key, context) - - return context - - # Cache helpers - - @staticmethod - def _make_cache_key(query: str, group_id: str) -> str: - """Generate a compact cache key from query text and group ID.""" - query_hash = hashlib.md5(query.encode("utf-8")).hexdigest()[:12] - return f"{group_id}:{query_hash}" - - def get_trigger_stats(self, group_id: str) -> Dict[str, Any]: - """Return tiered trigger statistics for a group.""" - return self._trigger.get_group_stats(group_id) - - # Module factories - - def _create_embedding_provider(self) -> Optional[Any]: - """Resolve embedding provider from the framework.""" - try: - from ..embedding.factory import EmbeddingProviderFactory - return EmbeddingProviderFactory.create(self._config, self._context) - except Exception as exc: - logger.debug( - f"[V2Integration] Embedding provider unavailable: {exc}" - ) - return None - - def _create_rerank_provider(self) -> Optional[Any]: - """Resolve reranker provider from the framework.""" - try: - from ..reranker.factory import RerankProviderFactory - return RerankProviderFactory.create(self._config, self._context) - except Exception as exc: - logger.debug(f"[V2Integration] Reranker unavailable: {exc}") - return None - - def _create_knowledge_manager(self) -> Optional[Any]: - """Create knowledge manager based on configured engine.""" - if self._config.knowledge_engine == "lightrag": - if not self._embedding_provider: - if self._embedding_provider_configured(): - logger.info( - "[V2Integration] LightRAG is waiting for the " - "embedding provider registry to become ready" - ) - else: - logger.warning( - "[V2Integration] LightRAG requires an embedding " - "provider; configure embedding_provider_id or use " - "the legacy knowledge engine" - ) - return None - try: - from ..integration import LightRAGKnowledgeManager - return LightRAGKnowledgeManager( - self._config, self._llm, self._embedding_provider - ) - except ImportError: - self._knowledge_manager_retryable = False - logger.warning( - "[V2Integration] lightrag-hku not installed, " - "falling back to legacy knowledge engine" - ) - except Exception as exc: - logger.warning( - f"[V2Integration] LightRAG init failed: {exc}" - ) - logger.debug( - "[V2Integration] LightRAG traceback:", exc_info=True - ) - return None - - def _create_memory_manager(self) -> Optional[Any]: - """Create memory manager based on configured engine.""" - if self._memory_delegated(): - logger.info("[V2Integration] Memory engine skipped: delegated to LivingMemory") - return None - if self._config.memory_engine == "mem0": - if not self._embedding_provider: - if self._embedding_provider_configured(): - logger.info( - "[V2Integration] Mem0 is waiting for the embedding " - "provider registry to become ready" - ) - else: - logger.warning( - "[V2Integration] Mem0 requires an embedding provider; " - "configure embedding_provider_id or use the legacy " - "memory engine" - ) - return None - try: - from ..integration import Mem0MemoryManager - return Mem0MemoryManager( - self._config, self._llm, self._embedding_provider - ) - except ImportError: - self._memory_manager_retryable = False - logger.warning( - "[V2Integration] mem0ai not installed, " - "falling back to legacy memory engine" - ) - except Exception as exc: - logger.warning( - f"[V2Integration] Mem0 init failed: {exc}" - ) - logger.debug( - "[V2Integration] Mem0 traceback:", exc_info=True - ) - return None - - def _create_exemplar_library(self) -> Optional[Any]: - """Create exemplar library if DB and embedding are available.""" - if not self._db: - return None - try: - from ..integration import ExemplarLibrary - return ExemplarLibrary(self._db, self._embedding_provider) - except Exception as exc: - logger.debug( - f"[V2Integration] ExemplarLibrary init failed: {exc}" - ) - return None - - def _create_social_analyzer(self) -> Optional[Any]: - """Create social graph analyzer.""" - try: - from ..social import SocialGraphAnalyzer - return SocialGraphAnalyzer(self._llm, self._db) - except Exception as exc: - logger.debug( - f"[V2Integration] SocialGraphAnalyzer init failed: {exc}" - ) - return None - - def _create_jargon_filter(self) -> Optional[Any]: - """Create jargon statistical filter.""" - try: - from ..jargon import JargonStatisticalFilter - return JargonStatisticalFilter() - except Exception as exc: - logger.debug( - f"[V2Integration] JargonStatisticalFilter init failed: {exc}" - ) - return None - - # Trigger wiring - - def _register_trigger_operations(self) -> None: - """Register all available modules with the tiered trigger. - - Architecture: - Tier 1 (per-message, sub-millisecond): - - jargon_stats: in-memory statistical counters - - ingestion_buffer: append message to buffer (no I/O) - - exemplar: embedding + DB insert (< 1s) - - Tier 2 (batch, LLM-gated, cooldown-protected): - - ingestion_flush: batch-process buffered messages through - LightRAG and Mem0, amortising LLM overhead across - multiple messages - - jargon: LLM-based jargon meaning inference - - social: community detection and influence ranking - - Knowledge graph ingestion (LightRAG) and memory ingestion (Mem0) - are intentionally registered as Tier 2 batch operations rather - than Tier 1 per-message callbacks because they each invoke one - or more LLM round-trips (entity extraction, fact extraction) - that take 3-10 seconds per message. Running them per-message - would dominate the event loop and block subsequent processing. - """ - - # ---- Tier 1: per-message lightweight operations ---- - - if self._jargon_filter: - jf = self._jargon_filter - - async def _jargon_update( - message: MessageData, group_id: str - ) -> None: - jf.update_from_message(message.message, group_id, message.sender_id) - - self._trigger.register_tier1("jargon_stats", _jargon_update) - - # Buffer messages for batch ingestion (knowledge + memory). - # This replaces the previous per-message LightRAG/Mem0 callbacks - # with a sub-millisecond append operation. - if self._knowledge_manager or self._memory_manager: - buf = self._ingestion_buffer - - async def _buffer_message( - message: MessageData, group_id: str - ) -> None: - if ( - message.message - and len(message.message.strip()) >= _MIN_INGESTION_LENGTH - ): - buf[group_id].append(message) - - self._trigger.register_tier1("ingestion_buffer", _buffer_message) - - if self._exemplar_library: - lib = self._exemplar_library - - async def _exemplar_add( - message: MessageData, group_id: str - ) -> None: - await lib.add_exemplar( - message.message, group_id, message.sender_id - ) - - self._trigger.register_tier1("exemplar", _exemplar_add) - - # ---- Tier 2: batch operations (LLM-heavy) ---- - - # Batch ingestion: flush buffered messages through LightRAG - # and Mem0. Fires every 5 messages or 60 seconds, whichever - # comes first. This amortises the per-message LLM overhead - # and reduces total API calls. - if self._knowledge_manager or self._memory_manager: - self._trigger.register_tier2( - "ingestion_flush", - self._flush_ingestion_buffer, - BatchTriggerPolicy( - message_threshold=5, cooldown_seconds=60 - ), - ) - - if self._jargon_filter: - jf2 = self._jargon_filter - llm = self._llm - db = self._db - - async def _jargon_batch(group_id: str) -> None: - candidates = jf2.get_jargon_candidates(group_id, top_k=20) - if not candidates or not llm: - return - for candidate in candidates[:10]: - try: - meaning = await llm.generate_response( - f"Explain the slang/jargon term " - f"'{candidate['term']}' in the context of an " - f"online chat group. Return a concise definition.", - model_type="filter", - ) - if ( - meaning - and db - and hasattr(db, "save_or_update_jargon") - ): - await db.save_or_update_jargon( - group_id, - candidate["term"], - { - "meaning": meaning, - "raw_content": "[]", - "is_jargon": True, - "count": 1, - "is_complete": True, - }, - ) - except Exception as exc: - logger.debug( - f"[V2Integration] Jargon inference failed " - f"for '{candidate['term']}': {exc}" - ) - - self._trigger.register_tier2( - "jargon", - _jargon_batch, - BatchTriggerPolicy( - message_threshold=20, cooldown_seconds=180 - ), - ) - - if self._social_analyzer: - sa = self._social_analyzer - - async def _social_batch(group_id: str) -> None: - # Execute independently so one failure does not skip the other. - try: - await sa.detect_communities(group_id) - except Exception as exc: - logger.debug( - f"[V2Integration] detect_communities failed: {exc}" - ) - try: - await sa.get_influence_ranking(group_id) - except Exception as exc: - logger.debug( - f"[V2Integration] get_influence_ranking failed: {exc}" - ) - - self._trigger.register_tier2( - "social", - _social_batch, - BatchTriggerPolicy( - message_threshold=50, cooldown_seconds=600 - ), - ) - - # Batch ingestion - - async def _flush_ingestion_buffer(self, group_id: str) -> None: - """Flush buffered messages for a group through knowledge and memory. - - Processes all buffered messages concurrently through LightRAG and - Mem0 in a single batch operation, then clears the buffer. Messages - within each engine are processed sequentially to avoid overwhelming - the underlying LLM providers with concurrent requests. - """ - messages = self._ingestion_buffer.pop(group_id, []) - if not messages: - return - - logger.debug( - f"[V2Integration] Flushing ingestion buffer: " - f"group={group_id}, count={len(messages)}" - ) - - async def _ingest_knowledge() -> None: - if not self._knowledge_manager: - return - method = None - if hasattr( - self._knowledge_manager, - "process_message_for_knowledge_graph", - ): - method = self._knowledge_manager.process_message_for_knowledge_graph - elif hasattr( - self._knowledge_manager, "process_message_for_knowledge" - ): - method = self._knowledge_manager.process_message_for_knowledge - if not method: - return - for msg in messages: - try: - await method(msg, group_id) - except Exception as exc: - logger.debug( - f"[V2Integration] Knowledge ingestion failed: {exc}" - ) - - async def _ingest_memory() -> None: - if self._memory_delegated(): - logger.debug("[V2Integration] Memory ingestion delegated to LivingMemory") - return - if not self._memory_manager: - return - for msg in messages: - try: - await self._memory_manager.add_memory_from_message( - msg, group_id - ) - except Exception as exc: - logger.debug( - f"[V2Integration] Memory ingestion failed: {exc}" - ) - - # Run knowledge and memory ingestion concurrently across engines, - # but sequentially within each engine to avoid provider overload. - await asyncio.gather( - _ingest_knowledge(), - _ingest_memory(), - ) - - def _memory_delegated(self) -> bool: - delegation = self._feature_delegation - if not delegation or not hasattr(delegation, "should_delegate_memory"): - return False - try: - return bool(delegation.should_delegate_memory()) - except Exception: - return False - - # Reranking - - @monitored - async def _rerank_context( - self, - query: str, - context: Dict[str, Any], - top_k: int, - ) -> Dict[str, Any]: - """Rerank knowledge and memory candidates by relevance. - - Few-shot exemplars and graph stats are returned unmodified. - """ - try: - documents: List[str] = [] - sources: List[str] = [] - - if "knowledge_context" in context: - documents.append(context["knowledge_context"]) - sources.append("knowledge") - - for mem in context.get("related_memories", []): - documents.append(mem) - sources.append("memory") - - if not documents: - return context - - results = await self._rerank_provider.rerank( - query, documents, top_n=top_k - ) - - # Rebuild context with reranked order. - reranked_memories: List[str] = [] - reranked_knowledge = "" - for r in results: - if r.index >= len(documents): - logger.debug( - f"[V2Integration] Reranker returned out-of-range " - f"index {r.index} (len={len(documents)}); skipping" - ) - continue - src = sources[r.index] - doc = documents[r.index] - if src == "knowledge": - reranked_knowledge = doc - elif src == "memory": - reranked_memories.append(doc) - - if reranked_knowledge: - context["knowledge_context"] = reranked_knowledge - elif "knowledge_context" in context: - del context["knowledge_context"] - - if reranked_memories: - context["related_memories"] = reranked_memories - elif "related_memories" in context: - del context["related_memories"] - - except Exception as exc: - logger.debug( - f"[V2Integration] Reranking failed, using unranked: {exc}" - ) - - return context +""" +V2 learning integration layer. + +Wires together the v2-architecture modules and provides a unified +interface for the ``MaiBotEnhancedLearningManager`` to delegate to. +When v2 features are enabled in ``PluginConfig`` the learning manager +instantiates this class and calls its ``process_message`` and +``get_enhanced_context`` methods alongside (or instead of) the legacy +code paths. + +Modules orchestrated: + * ``TieredLearningTrigger`` — per-message / batch operation scheduling + * ``LightRAGKnowledgeManager`` — knowledge graph (replaces legacy) + * ``Mem0MemoryManager`` — memory management (replaces legacy) + * ``ExemplarLibrary`` — few-shot style exemplar retrieval + * ``SocialGraphAnalyzer`` — community detection / influence ranking + * ``JargonStatisticalFilter`` — statistical jargon pre-filter + * ``IRerankProvider`` — cross-source context reranking + +Design notes: + - All module construction is guarded by the relevant config flags so + that unused modules are never instantiated. + - ``start()`` / ``stop()`` manage the full lifecycle of every active + v2 module. + - Each module that can fail during construction logs a warning and + falls back gracefully (the integration layer keeps working with + the remaining modules). + - Thread-safe for single-event-loop asyncio usage. +""" + +import asyncio +import hashlib +import time +from collections import defaultdict +from typing import Any, Dict, List, Optional, Tuple + +from astrbot.api import logger + +from ...config import PluginConfig +from ...core.interfaces import MessageData +from ...utils.cache_manager import get_cache_manager +from ..monitoring.instrumentation import monitored +from ..quality import ( + BatchTriggerPolicy, + TieredLearningTrigger, + TriggerResult, +) + +# Minimum message length to consider for LLM-heavy ingestion operations. +_MIN_INGESTION_LENGTH = 15 + +# Maximum buffered messages per group before force-flushing. +_INGESTION_BUFFER_MAX = 10 + + +class V2LearningIntegration: + """Facade that initialises, wires, and exposes v2 learning modules. + + Usage:: + + v2 = V2LearningIntegration(config, llm_adapter, db_manager, context) + await v2.start() + result = await v2.process_message(message, group_id) + context = await v2.get_enhanced_context("query", group_id) + await v2.stop() + """ + + def __init__( + self, + config: PluginConfig, + llm_adapter: Optional[Any] = None, + db_manager: Optional[Any] = None, + context: Optional[Any] = None, + feature_delegation: Optional[Any] = None, + ) -> None: + self._config = config + self._llm = llm_adapter + self._db = db_manager + self._context = context + self._feature_delegation = feature_delegation + self._started = False + self._provider_retry_lock = asyncio.Lock() + self._last_provider_retry: float = 0.0 + self._provider_retry_interval: float = max( + 0.1, + float(getattr(config, "provider_retry_interval_seconds", 10.0) or 10.0), + ) + self._knowledge_manager_retryable = True + self._memory_manager_retryable = True + + # --- Resolve framework providers via factories --------------- + self._embedding_provider = self._create_embedding_provider() + self._rerank_provider = self._create_rerank_provider() + + # --- Instantiate v2 modules ---------------------------------- + self._knowledge_manager = self._create_knowledge_manager() + self._memory_manager = self._create_memory_manager() + self._exemplar_library = self._create_exemplar_library() + self._social_analyzer = self._create_social_analyzer() + self._jargon_filter = self._create_jargon_filter() + + # --- Query result cache via CacheManager ---------------------- + self._cache = get_cache_manager() + + # --- Message buffer for batch ingestion ----------------------- + # Knowledge and memory ingestion are LLM-heavy operations and + # must not run per-message. Instead, messages are buffered here + # and flushed as a batch in a Tier 2 operation. + self._ingestion_buffer: Dict[str, List[MessageData]] = defaultdict(list) + + # --- Tiered trigger ------------------------------------------ + self._trigger = TieredLearningTrigger() + self._register_trigger_operations() + + logger.info( + "[V2Integration] Initialised — " + f"knowledge={self._config.knowledge_engine}, " + f"memory={self._config.memory_engine}, " + f"memory_delegated={self._memory_delegated()}, " + f"embedding={'yes' if self._embedding_provider else 'no'}, " + f"reranker={'yes' if self._rerank_provider else 'no'}" + ) + + # Lifecycle + + async def start(self) -> None: + """Start all active v2 modules that expose a ``start`` method.""" + await self.refresh_provider_bindings(force=True) + + await asyncio.gather(*( + self._start_one(name, module) + for name, module in self._active_modules() + if module and hasattr(module, "start") + )) + self._started = True + logger.info("[V2Integration] All modules started") + + async def refresh_provider_bindings(self, *, force: bool = False) -> bool: + """Retry framework provider binding and create dependent modules. + + AstrBot can load plugins before provider registries are populated. This + lets startup, warmup, and first-use paths bind providers later without a + manual plugin reload. + """ + if not self._needs_provider_or_module_retry(): + return False + + if not force and not self._provider_retry_due(): + return False + + async with self._provider_retry_lock: + if not self._needs_provider_or_module_retry(): + return False + + if not force and not self._provider_retry_due(): + return False + self._last_provider_retry = time.monotonic() + + changed = False + modules_to_start: List[Tuple[str, Any]] = [] + + if not self._embedding_provider and self._embedding_provider_configured(): + provider = self._create_embedding_provider() + if provider: + self._embedding_provider = provider + changed = True + + if not self._rerank_provider and self._rerank_provider_configured(): + provider = self._create_rerank_provider() + if provider: + self._rerank_provider = provider + changed = True + + if self._embedding_provider: + if self._knowledge_manager is None: + self._knowledge_manager = self._create_knowledge_manager() + if self._knowledge_manager: + changed = True + modules_to_start.append(( + "knowledge_manager", + self._knowledge_manager, + )) + + if self._memory_manager is None: + self._memory_manager = self._create_memory_manager() + if self._memory_manager: + changed = True + modules_to_start.append(( + "memory_manager", + self._memory_manager, + )) + + if self._exemplar_library_needs_embedding_refresh(): + self._exemplar_library = self._create_exemplar_library() + changed = True + + if changed: + self._register_trigger_operations() + if self._started and modules_to_start: + await asyncio.gather(*( + self._start_one(name, module) + for name, module in modules_to_start + if module and hasattr(module, "start") + )) + logger.info( + "[V2Integration] Provider bindings refreshed — " + f"embedding={'yes' if self._embedding_provider else 'no'}, " + f"reranker={'yes' if self._rerank_provider else 'no'}" + ) + return changed + + def _provider_retry_due(self) -> bool: + return ( + time.monotonic() - self._last_provider_retry + >= self._provider_retry_interval + ) + + async def _start_one(self, name: str, module: Any) -> None: + try: + await module.start() + except Exception as exc: + logger.warning( + f"[V2Integration] {name} start failed: {exc}" + ) + + def _active_modules(self) -> List[Tuple[str, Any]]: + return [ + ("knowledge_manager", self._knowledge_manager), + ("memory_manager", self._memory_manager), + ("exemplar_library", self._exemplar_library), + ("social_analyzer", self._social_analyzer), + ("jargon_filter", self._jargon_filter), + ] + + def _needs_provider_or_module_retry(self) -> bool: + if self._embedding_provider_configured() and not self._embedding_provider: + return True + if self._rerank_provider_configured() and not self._rerank_provider: + return True + if self._embedding_provider and self._knowledge_manager is None: + return ( + self._config.knowledge_engine == "lightrag" + and self._knowledge_manager_retryable + ) + if self._embedding_provider and self._memory_manager is None: + return ( + self._config.memory_engine == "mem0" + and not self._memory_delegated() + and self._memory_manager_retryable + ) + return self._exemplar_library_needs_embedding_refresh() + + def _embedding_provider_configured(self) -> bool: + return bool( + str(getattr(self._config, "embedding_provider_id", "") or "").strip() + ) + + def _rerank_provider_configured(self) -> bool: + return bool( + str(getattr(self._config, "rerank_provider_id", "") or "").strip() + ) + + def _exemplar_library_needs_embedding_refresh(self) -> bool: + if not (self._db and self._embedding_provider and self._exemplar_library): + return False + return getattr(self._exemplar_library, "_embedding", None) is None + + async def warmup(self, group_ids: List[str]) -> None: + """Pre-warm heavyweight module instances for *group_ids*. + + Should be called shortly after ``start()`` once active group IDs + are known. Currently only LightRAG benefits from pre-warming + (each cold-start avoids a 12-15s initialisation penalty on the + first user query). + """ + await self.refresh_provider_bindings() + if ( + self._knowledge_manager + and hasattr(self._knowledge_manager, "warmup_instances") + ): + try: + await self._knowledge_manager.warmup_instances(group_ids) + except Exception as exc: + logger.debug( + f"[V2Integration] Knowledge warmup failed: {exc}" + ) + + async def stop(self) -> None: + """Stop all active v2 modules and release resources. + + Attempts to flush remaining buffered messages with a per-group + timeout. Timed-out buffers are discarded to avoid blocking + the shutdown sequence. + """ + _flush_timeout = self._config.task_cancel_timeout + + for group_id in list(self._ingestion_buffer.keys()): + try: + await asyncio.wait_for( + self._flush_ingestion_buffer(group_id), + timeout=_flush_timeout, + ) + except asyncio.TimeoutError: + dropped = len(self._ingestion_buffer.pop(group_id, [])) + logger.warning( + f"[V2Integration] Buffer flush timeout for group " + f"{group_id}, dropped {dropped} messages" + ) + except Exception as exc: + logger.warning( + f"[V2Integration] Buffer flush failed on stop " + f"for group {group_id}: {exc}" + ) + + modules: List[Tuple[str, Any]] = [ + ("knowledge_manager", self._knowledge_manager), + ("memory_manager", self._memory_manager), + ("exemplar_library", self._exemplar_library), + ("social_analyzer", self._social_analyzer), + ("jargon_filter", self._jargon_filter), + ] + + async def _stop_one(name: str, module: Any) -> None: + try: + await module.stop() + except Exception as exc: + logger.warning( + f"[V2Integration] {name} stop failed: {exc}" + ) + + async def _close_reranker() -> None: + try: + await self._rerank_provider.close() + except Exception as exc: + logger.warning(f"[V2Integration] Reranker close failed: {exc}") + + tasks = [ + _stop_one(name, module) + for name, module in modules + if module and hasattr(module, "stop") + ] + if self._rerank_provider and hasattr(self._rerank_provider, "close"): + tasks.append(_close_reranker()) + + await asyncio.gather(*tasks) + logger.info("[V2Integration] All modules stopped") + + # Public API + + @monitored + async def process_message( + self, message: MessageData, group_id: str + ) -> TriggerResult: + """Process an incoming message through the tiered trigger. + + Tier 1 operations run concurrently on every message. Tier 2 + operations fire when their policies are satisfied. + """ + await self.refresh_provider_bindings() + return await self._trigger.process_message(message, group_id) + + @monitored + async def get_enhanced_context( + self, + query: str, + group_id: str, + top_k: int = 5, + ) -> Dict[str, Any]: + """Retrieve v2 enhanced context for response generation. + + Returns a dict with optional keys: + * ``knowledge_context`` (str): Retrieved knowledge graph context. + * ``related_memories`` (List[str]): Semantically related memories. + * ``few_shot_examples`` (List[str]): Style exemplar texts + (not reranked; returned as-is). + * ``graph_stats`` (dict): Social graph summary statistics. + + When a reranker is available, knowledge and memory candidates are + reranked by relevance and only the top-k are returned. Few-shot + exemplars and graph stats are returned unmodified. + + Results are cached per (group_id, query_hash) with a configurable + TTL to avoid redundant retrieval on repeated or similar queries. + + All retrieval tasks run concurrently via ``asyncio.gather`` to + minimise total latency. + """ + await self.refresh_provider_bindings() + + # --- Check query result cache --- + cache_key = self._make_cache_key(query, group_id) + cached_result = self._cache.get("context", cache_key) + if cached_result is not None: + logger.debug( + f"[V2Integration] Context cache hit (group={group_id})" + ) + return cached_result + + context: Dict[str, Any] = {} + + # --- Build concurrent retrieval tasks --- + + async def _fetch_knowledge() -> None: + if not self._knowledge_manager: + return + try: + if hasattr(self._knowledge_manager, "query_knowledge"): + ctx = await self._knowledge_manager.query_knowledge( + query, group_id, + mode=self._config.lightrag_query_mode, + ) + elif hasattr( + self._knowledge_manager, + "answer_question_with_knowledge_graph", + ): + ctx = ( + await self._knowledge_manager + .answer_question_with_knowledge_graph(query, group_id) + ) + else: + ctx = "" + if ctx: + context["knowledge_context"] = ctx + except Exception as exc: + logger.debug( + f"[V2Integration] Knowledge retrieval failed: {exc}" + ) + + async def _fetch_memories() -> None: + if self._memory_delegated(): + logger.debug("[V2Integration] Memory retrieval delegated to LivingMemory") + return + if not self._memory_manager: + return + try: + memories = await self._memory_manager.get_related_memories( + query, group_id + ) + if memories: + context["related_memories"] = memories + except Exception as exc: + logger.debug( + f"[V2Integration] Memory retrieval failed: {exc}" + ) + + async def _fetch_exemplars() -> None: + if not self._exemplar_library: + return + try: + examples = await self._exemplar_library.get_few_shot_examples( + query, group_id, k=top_k + ) + if examples: + context["few_shot_examples"] = examples + except Exception as exc: + logger.debug( + f"[V2Integration] Exemplar retrieval failed: {exc}" + ) + + async def _fetch_graph_stats() -> None: + if not self._social_analyzer: + return + try: + stats = await self._social_analyzer.get_graph_statistics( + group_id + ) + if stats and stats.get("node_count", 0) > 0: + context["graph_stats"] = stats + except Exception as exc: + logger.debug( + f"[V2Integration] Social graph stats failed: {exc}" + ) + + # --- Run all retrievals concurrently --- + await asyncio.gather( + _fetch_knowledge(), + _fetch_memories(), + _fetch_exemplars(), + _fetch_graph_stats(), + ) + + # --- Conditional reranking --- + # Only invoke the reranker when there are enough candidates to + # justify the additional API round-trip latency. + rerank_candidates = len(context.get("related_memories", [])) + if "knowledge_context" in context: + rerank_candidates += 1 + min_candidates = getattr( + self._config, "rerank_min_candidates", 3 + ) + if self._rerank_provider and rerank_candidates >= min_candidates: + context = await self._rerank_context(query, context, top_k) + + # --- Store result in cache --- + self._cache.set("context", cache_key, context) + + return context + + # Cache helpers + + @staticmethod + def _make_cache_key(query: str, group_id: str) -> str: + """Generate a compact cache key from query text and group ID.""" + query_hash = hashlib.md5(query.encode("utf-8")).hexdigest()[:12] + return f"{group_id}:{query_hash}" + + def get_trigger_stats(self, group_id: str) -> Dict[str, Any]: + """Return tiered trigger statistics for a group.""" + return self._trigger.get_group_stats(group_id) + + # Module factories + + def _create_embedding_provider(self) -> Optional[Any]: + """Resolve embedding provider from the framework.""" + try: + from ..embedding.factory import EmbeddingProviderFactory + return EmbeddingProviderFactory.create(self._config, self._context) + except Exception as exc: + logger.debug( + f"[V2Integration] Embedding provider unavailable: {exc}" + ) + return None + + def _create_rerank_provider(self) -> Optional[Any]: + """Resolve reranker provider from the framework.""" + try: + from ..reranker.factory import RerankProviderFactory + return RerankProviderFactory.create(self._config, self._context) + except Exception as exc: + logger.debug(f"[V2Integration] Reranker unavailable: {exc}") + return None + + def _create_knowledge_manager(self) -> Optional[Any]: + """Create knowledge manager based on configured engine.""" + if self._config.knowledge_engine == "lightrag": + if not self._embedding_provider: + if self._embedding_provider_configured(): + logger.info( + "[V2Integration] LightRAG is waiting for the " + "embedding provider registry to become ready" + ) + else: + logger.warning( + "[V2Integration] LightRAG requires an embedding " + "provider; configure embedding_provider_id or use " + "the legacy knowledge engine" + ) + return None + try: + from ..integration import LightRAGKnowledgeManager + return LightRAGKnowledgeManager( + self._config, self._llm, self._embedding_provider + ) + except ImportError: + self._knowledge_manager_retryable = False + logger.warning( + "[V2Integration] lightrag-hku not installed, " + "falling back to legacy knowledge engine" + ) + except Exception as exc: + logger.warning( + f"[V2Integration] LightRAG init failed: {exc}" + ) + logger.debug( + "[V2Integration] LightRAG traceback:", exc_info=True + ) + return None + + def _create_memory_manager(self) -> Optional[Any]: + """Create memory manager based on configured engine.""" + if self._memory_delegated(): + logger.info("[V2Integration] Memory engine skipped: delegated to LivingMemory") + return None + if self._config.memory_engine == "mem0": + if not self._embedding_provider: + if self._embedding_provider_configured(): + logger.info( + "[V2Integration] Mem0 is waiting for the embedding " + "provider registry to become ready" + ) + else: + logger.warning( + "[V2Integration] Mem0 requires an embedding provider; " + "configure embedding_provider_id or use the legacy " + "memory engine" + ) + return None + try: + from ..integration import Mem0MemoryManager + return Mem0MemoryManager( + self._config, self._llm, self._embedding_provider + ) + except ImportError: + self._memory_manager_retryable = False + logger.warning( + "[V2Integration] mem0ai not installed, " + "falling back to legacy memory engine" + ) + except Exception as exc: + logger.warning( + f"[V2Integration] Mem0 init failed: {exc}" + ) + logger.debug( + "[V2Integration] Mem0 traceback:", exc_info=True + ) + return None + + def _create_exemplar_library(self) -> Optional[Any]: + """Create exemplar library if DB and embedding are available.""" + if not self._db: + return None + try: + from ..integration import ExemplarLibrary + return ExemplarLibrary(self._db, self._embedding_provider) + except Exception as exc: + logger.debug( + f"[V2Integration] ExemplarLibrary init failed: {exc}" + ) + return None + + def _create_social_analyzer(self) -> Optional[Any]: + """Create social graph analyzer.""" + try: + from ..social import SocialGraphAnalyzer + return SocialGraphAnalyzer(self._llm, self._db) + except Exception as exc: + logger.debug( + f"[V2Integration] SocialGraphAnalyzer init failed: {exc}" + ) + return None + + def _create_jargon_filter(self) -> Optional[Any]: + """Create jargon statistical filter.""" + try: + from ..jargon import JargonStatisticalFilter + return JargonStatisticalFilter() + except Exception as exc: + logger.debug( + f"[V2Integration] JargonStatisticalFilter init failed: {exc}" + ) + return None + + # Trigger wiring + + def _register_trigger_operations(self) -> None: + """Register all available modules with the tiered trigger. + + Architecture: + Tier 1 (per-message, sub-millisecond): + - jargon_stats: in-memory statistical counters + - ingestion_buffer: append message to buffer (no I/O) + - exemplar: embedding + DB insert (< 1s) + + Tier 2 (batch, LLM-gated, cooldown-protected): + - ingestion_flush: batch-process buffered messages through + LightRAG and Mem0, amortising LLM overhead across + multiple messages + - jargon: LLM-based jargon meaning inference + - social: community detection and influence ranking + + Knowledge graph ingestion (LightRAG) and memory ingestion (Mem0) + are intentionally registered as Tier 2 batch operations rather + than Tier 1 per-message callbacks because they each invoke one + or more LLM round-trips (entity extraction, fact extraction) + that take 3-10 seconds per message. Running them per-message + would dominate the event loop and block subsequent processing. + """ + + # ---- Tier 1: per-message lightweight operations ---- + + if self._jargon_filter: + jf = self._jargon_filter + + async def _jargon_update( + message: MessageData, group_id: str + ) -> None: + jf.update_from_message(message.message, group_id, message.sender_id) + + self._trigger.register_tier1("jargon_stats", _jargon_update) + + # Buffer messages for batch ingestion (knowledge + memory). + # This replaces the previous per-message LightRAG/Mem0 callbacks + # with a sub-millisecond append operation. + if self._knowledge_manager or self._memory_manager: + buf = self._ingestion_buffer + + async def _buffer_message( + message: MessageData, group_id: str + ) -> None: + if ( + message.message + and len(message.message.strip()) >= _MIN_INGESTION_LENGTH + ): + buf[group_id].append(message) + + self._trigger.register_tier1("ingestion_buffer", _buffer_message) + + if self._exemplar_library: + lib = self._exemplar_library + + async def _exemplar_add( + message: MessageData, group_id: str + ) -> None: + await lib.add_exemplar( + message.message, group_id, message.sender_id + ) + + self._trigger.register_tier1("exemplar", _exemplar_add) + + # ---- Tier 2: batch operations (LLM-heavy) ---- + + # Batch ingestion: flush buffered messages through LightRAG + # and Mem0. Fires every 5 messages or 60 seconds, whichever + # comes first. This amortises the per-message LLM overhead + # and reduces total API calls. + if self._knowledge_manager or self._memory_manager: + self._trigger.register_tier2( + "ingestion_flush", + self._flush_ingestion_buffer, + BatchTriggerPolicy( + message_threshold=5, cooldown_seconds=60 + ), + ) + + if self._jargon_filter: + jf2 = self._jargon_filter + llm = self._llm + db = self._db + + async def _jargon_batch(group_id: str) -> None: + candidates = jf2.get_jargon_candidates(group_id, top_k=20) + if not candidates or not llm: + return + for candidate in candidates[:10]: + try: + # 跳过已被手动编辑或已完成推断的黑话,避免覆盖用户修改 + if db and hasattr(db, "get_jargon"): + existing = await db.get_jargon(group_id, candidate["term"]) + if existing and existing.get("is_complete"): + logger.debug( + f"[V2Integration] 跳过已完成的黑话: " + f"'{candidate['term']}'" + ) + continue + + meaning = await llm.generate_response( + f"Explain the slang/jargon term " + f"'{candidate['term']}' in the context of an " + f"online chat group. Return a concise definition.", + model_type="filter", + ) + if ( + meaning + and db + and hasattr(db, "save_or_update_jargon") + ): + jargon_data = { + "meaning": meaning, + "raw_content": "[]", + "is_jargon": True, + "is_complete": True, + } + # 已有记录时保留原始计数,不重置为1 + if existing: + jargon_data["count"] = existing.get("count", 1) + else: + jargon_data["count"] = 1 + await db.save_or_update_jargon( + group_id, + candidate["term"], + jargon_data, + ) + except Exception as exc: + logger.debug( + f"[V2Integration] Jargon inference failed " + f"for '{candidate['term']}': {exc}" + ) + + self._trigger.register_tier2( + "jargon", + _jargon_batch, + BatchTriggerPolicy( + message_threshold=20, cooldown_seconds=180 + ), + ) + + if self._social_analyzer: + sa = self._social_analyzer + + async def _social_batch(group_id: str) -> None: + # Execute independently so one failure does not skip the other. + try: + await sa.detect_communities(group_id) + except Exception as exc: + logger.debug( + f"[V2Integration] detect_communities failed: {exc}" + ) + try: + await sa.get_influence_ranking(group_id) + except Exception as exc: + logger.debug( + f"[V2Integration] get_influence_ranking failed: {exc}" + ) + + self._trigger.register_tier2( + "social", + _social_batch, + BatchTriggerPolicy( + message_threshold=50, cooldown_seconds=600 + ), + ) + + # Batch ingestion + + async def _flush_ingestion_buffer(self, group_id: str) -> None: + """Flush buffered messages for a group through knowledge and memory. + + Processes all buffered messages concurrently through LightRAG and + Mem0 in a single batch operation, then clears the buffer. Messages + within each engine are processed sequentially to avoid overwhelming + the underlying LLM providers with concurrent requests. + """ + messages = self._ingestion_buffer.pop(group_id, []) + if not messages: + return + + logger.debug( + f"[V2Integration] Flushing ingestion buffer: " + f"group={group_id}, count={len(messages)}" + ) + + async def _ingest_knowledge() -> None: + if not self._knowledge_manager: + return + method = None + if hasattr( + self._knowledge_manager, + "process_message_for_knowledge_graph", + ): + method = self._knowledge_manager.process_message_for_knowledge_graph + elif hasattr( + self._knowledge_manager, "process_message_for_knowledge" + ): + method = self._knowledge_manager.process_message_for_knowledge + if not method: + return + for msg in messages: + try: + await method(msg, group_id) + except Exception as exc: + logger.debug( + f"[V2Integration] Knowledge ingestion failed: {exc}" + ) + + async def _ingest_memory() -> None: + if self._memory_delegated(): + logger.debug("[V2Integration] Memory ingestion delegated to LivingMemory") + return + if not self._memory_manager: + return + for msg in messages: + try: + await self._memory_manager.add_memory_from_message( + msg, group_id + ) + except Exception as exc: + logger.debug( + f"[V2Integration] Memory ingestion failed: {exc}" + ) + + # Run knowledge and memory ingestion concurrently across engines, + # but sequentially within each engine to avoid provider overload. + await asyncio.gather( + _ingest_knowledge(), + _ingest_memory(), + ) + + def _memory_delegated(self) -> bool: + delegation = self._feature_delegation + if not delegation or not hasattr(delegation, "should_delegate_memory"): + return False + try: + return bool(delegation.should_delegate_memory()) + except Exception: + return False + + # Reranking + + @monitored + async def _rerank_context( + self, + query: str, + context: Dict[str, Any], + top_k: int, + ) -> Dict[str, Any]: + """Rerank knowledge and memory candidates by relevance. + + Few-shot exemplars and graph stats are returned unmodified. + """ + try: + documents: List[str] = [] + sources: List[str] = [] + + if "knowledge_context" in context: + documents.append(context["knowledge_context"]) + sources.append("knowledge") + + for mem in context.get("related_memories", []): + documents.append(mem) + sources.append("memory") + + if not documents: + return context + + results = await self._rerank_provider.rerank( + query, documents, top_n=top_k + ) + + # Rebuild context with reranked order. + reranked_memories: List[str] = [] + reranked_knowledge = "" + for r in results: + if r.index >= len(documents): + logger.debug( + f"[V2Integration] Reranker returned out-of-range " + f"index {r.index} (len={len(documents)}); skipping" + ) + continue + src = sources[r.index] + doc = documents[r.index] + if src == "knowledge": + reranked_knowledge = doc + elif src == "memory": + reranked_memories.append(doc) + + if reranked_knowledge: + context["knowledge_context"] = reranked_knowledge + elif "knowledge_context" in context: + del context["knowledge_context"] + + if reranked_memories: + context["related_memories"] = reranked_memories + elif "related_memories" in context: + del context["related_memories"] + + except Exception as exc: + logger.debug( + f"[V2Integration] Reranking failed, using unranked: {exc}" + ) + + return context From 14a4a2a55daab7e3a7dfa17e8fc3d0c32516164e Mon Sep 17 00:00:00 2001 From: YumemiAI <71859504+YumemiDream@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:51:26 +0800 Subject: [PATCH 3/5] fix: hide approve/reject buttons for already confirmed jargon The jargon list showed confirm and reject buttons for all items regardless of status. For already confirmed jargon, these buttons were no-ops and confusing. Now only unconfirmed items show the approve/reject buttons. --- pages/dashboard/app.js | 4126 ++++++++++++++++++++-------------------- 1 file changed, 2063 insertions(+), 2063 deletions(-) diff --git a/pages/dashboard/app.js b/pages/dashboard/app.js index 16a4d441..9742320c 100644 --- a/pages/dashboard/app.js +++ b/pages/dashboard/app.js @@ -1,2063 +1,2063 @@ -(() => { - "use strict"; - - const PAGE_META = { - home: ["page.home.kicker", "page.home.title", "Dashboard", "完整内嵌 WebUI"], - insights: ["page.insights.kicker", "page.insights.title", "Insights", "AI 巡检"], - monitoring: ["page.monitoring.kicker", "page.monitoring.title", "Monitoring", "运行监控"], - reviews: ["page.reviews.kicker", "page.reviews.title", "Reviews", "审查队列"], - "jargon-learning": ["page.jargon.kicker", "page.jargon.title", "Jargon", "黑话学习"], - "expression-learning": ["page.style.kicker", "page.style.title", "Expression", "表达方式学习"], - "persona-learning": ["page.persona.kicker", "page.persona.title", "Persona", "人格学习"], - content: ["page.content.kicker", "page.content.title", "Content", "学习内容"], - graphs: ["page.graphs.kicker", "page.graphs.title", "Graphs", "图谱"], - "reply-strategy": ["page.reply.kicker", "page.reply.title", "Reply", "回复策略"], - integrations: ["page.integrations.kicker", "page.integrations.title", "Integrations", "功能融合"], - settings: ["page.settings.kicker", "page.settings.title", "Settings", "设置"], - }; - const I18N_ROOT = "pages.dashboard"; - const GRAPH_SAFE_PADDING = 34; - const GRAPH_HOME_STRENGTH = 0.0064; - const GRAPH_CENTER_STRENGTH = 0.00016; - const GRAPH_LINK_STRENGTH = 0.000035; - - const state = { - page: "home", - ready: false, - dashboard: null, - overview: null, - pageData: {}, - selectedReviews: { - persona: new Set(), - style: new Set(), - jargon: new Set(), - }, - selectedJargon: new Set(), - contentType: "dialogues", - settingsGroup: null, - dirtySettings: new Map(), - graph: { - nodes: [], - links: [], - running: false, - dragged: null, - hovered: null, - type: "memory", - width: 0, - height: 0, - canvasBound: false, - }, - toastTimer: null, - }; - - const physics = { - particles: [], - pointer: { x: 0, y: 0, active: false }, - running: false, - last: 0, - }; - - const $ = (id) => document.getElementById(id); - const qs = (selector, root = document) => root.querySelector(selector); - const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); - - function locale() { - try { - const bridge = window.AstrBotPluginPage; - return bridge?.getLocale?.() || bridge?.getContext?.()?.locale || document.documentElement.lang || "zh-CN"; - } catch (_) { - return document.documentElement.lang || "zh-CN"; - } - } - - function t(key, fallback = "") { - const fullKey = key.startsWith("pages.") || key.startsWith("metadata.") || key.startsWith("config.") - ? key - : `${I18N_ROOT}.${key}`; - try { - const bridge = window.AstrBotPluginPage; - const value = bridge?.t?.(fullKey, fallback); - if (value !== undefined && value !== null && value !== fullKey) return String(value); - } catch (_) {} - return String(fallback || ""); - } - - function configT(path, fallback = "") { - return t(`config.${path}`, fallback); - } - - function applyStaticI18n(root = document) { - document.documentElement.lang = locale(); - qsa("[data-i18n]", root).forEach((el) => { - el.textContent = t(el.dataset.i18n, el.textContent); - }); - qsa("[data-i18n-title]", root).forEach((el) => { - el.setAttribute("title", t(el.dataset.i18nTitle, el.getAttribute("title") || "")); - }); - qsa("[data-i18n-aria-label]", root).forEach((el) => { - el.setAttribute("aria-label", t(el.dataset.i18nAriaLabel, el.getAttribute("aria-label") || "")); - }); - qsa("[data-i18n-placeholder]", root).forEach((el) => { - el.setAttribute("placeholder", t(el.dataset.i18nPlaceholder, el.getAttribute("placeholder") || "")); - }); - } - - function endpoint(path) { - return `page/${String(path || "").replace(/^\/+/, "").replace(/\/+/g, "/")}`; - } - - async function bridgeReady() { - const bridge = window.AstrBotPluginPage; - if (!bridge) { - throw new Error(t("errors.bridgeMissing", "AstrBot 插件页桥接 SDK 未加载")); - } - 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) { - 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 || t("errors.requestFailed", "请求失败")); - } - if (body && body.success === false) { - throw new Error(body.message || body.error || t("errors.requestFailed", "请求失败")); - } - return body || {}; - } - - function escapeHtml(value) { - return String(value ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - function escapeAttr(value) { - return escapeHtml(value).replace(/`/g, "`"); - } - - function localNavigationHost(hostname) { - const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1").toLowerCase(); - if (!host) return true; - return host === "localhost" - || host === "0.0.0.0" - || host === "::" - || host === "::1" - || host === "0:0:0:0:0:0:0:0" - || host === "0:0:0:0:0:0:0:1" - || /^127(?:\.\d{1,3}){3}$/.test(host); - } - - function hostForUrl(hostname) { - const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1"); - return host.includes(":") ? `[${host}]` : host; - } - - function resolveHostUrl(value) { - const raw = String(value || "").trim(); - if (!raw || raw === "#") return raw || "#"; - if (raw.startsWith("#")) return raw; - - let parsed; - try { - parsed = new URL(raw, window.location.href); - } catch (_) { - return raw; - } - - if (!/^https?:$/.test(parsed.protocol) || !localNavigationHost(parsed.hostname)) { - return raw; - } - - const browserHost = window.location.hostname; - if (!browserHost) return raw; - const replacementHost = hostForUrl(browserHost); - parsed.host = parsed.port ? `${replacementHost}:${parsed.port}` : replacementHost; - return parsed.href; - } - - function fmt(value, digits = 1) { - const num = Number(value || 0); - if (!Number.isFinite(num)) return "0"; - return new Intl.NumberFormat(locale(), { 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) { - const el = $(id); - if (el) el.textContent = value; - } - - function setHtml(id, html) { - const el = $(id); - if (el) el.innerHTML = html; - } - - function empty(text = t("empty.default", "暂无数据")) { - return `
${escapeHtml(text)}
`; - } - - function pill(text, tone = "") { - return `${escapeHtml(text)}`; - } - - function button(label, attrs = "", cls = "ghost-button") { - return ``; - } - - function normalizeId(id) { - return String(id ?? "").trim(); - } - - function selectedReviewSet(kind) { - if (!state.selectedReviews[kind]) state.selectedReviews[kind] = new Set(); - return state.selectedReviews[kind]; - } - - function selectedReviewIds(kind) { - return Array.from(selectedReviewSet(kind)).filter(Boolean); - } - - function isReviewSelected(kind, id) { - return selectedReviewSet(kind).has(normalizeId(id)); - } - - function isJargonSelected(id) { - return state.selectedJargon.has(normalizeId(id)); - } - - function pruneSelection(selection, validIds) { - const valid = new Set((validIds || []).map(normalizeId).filter(Boolean)); - Array.from(selection).forEach((id) => { - if (!valid.has(id)) selection.delete(id); - }); - } - - function reviewCheckbox(kind, id) { - const safeId = normalizeId(id); - return ``; - } - - function jargonCheckbox(id) { - const safeId = normalizeId(id); - return ``; - } - - function reviewCountText(kind, total) { - const selected = selectedReviewIds(kind).length; - return selected ? `${fmt(selected, 0)}/${fmt(total, 0)}` : fmt(total, 0); - } - - function visibleReviewIds(kind) { - const reviews = state.pageData.reviews || {}; - if (kind === "persona") { - return ((reviews.persona_pending || {}).updates || []) - .filter((item) => item && item.review_source !== "style_learning") - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - if (kind === "style") { - return ((reviews.style_reviews || {}).reviews || []) - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - if (kind === "jargon") { - return (((reviews.jargon_pending || {}).jargon_list) || []) - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - return []; - } - - function stylePageReviewIds() { - const style = state.pageData.style || {}; - return (((style.reviews || {}).reviews) || []) - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - - function jargonPageIds() { - return (state.pageData.lastJargonItems || []) - .map((item) => normalizeId(item.id)) - .filter(Boolean); - } - - function refreshSelectionLabels() { - ["persona", "style", "jargon"].forEach((kind) => { - const ids = visibleReviewIds(kind); - setText(`${kind}-review-count`, reviewCountText(kind, ids.length)); - }); - setText("expression-review-count", t("selection.selectedCount", "已选 {count}").replace("{count}", fmt(selectedReviewIds("style").length, 0))); - const selectedJargon = Array.from(state.selectedJargon).filter(Boolean).length; - const visibleJargon = jargonPageIds().length; - setText("jargon-selection-count", t("selection.selectedOfTotal", "已选 {selected}/{total}") - .replace("{selected}", fmt(selectedJargon, 0)) - .replace("{total}", fmt(visibleJargon, 0))); - } - - function setBusy(label = t("status.loading", "加载中")) { - setText("runtime-status", label); - setText("hero-status", label); - } - - function showToast(message, tone = "ok") { - const region = $("toast-region"); - if (!region) return; - if (state.toastTimer) { - clearTimeout(state.toastTimer); - state.toastTimer = null; - } - region.replaceChildren(); - const el = document.createElement("div"); - el.className = `toast ${tone}`; - const text = document.createElement("span"); - text.textContent = message; - const close = document.createElement("button"); - close.className = "toast-close"; - close.type = "button"; - close.setAttribute("aria-label", t("actions.closeToast", "关闭提示")); - close.textContent = "×"; - close.addEventListener("click", () => { - if (state.toastTimer) clearTimeout(state.toastTimer); - el.remove(); - }); - el.append(text, close); - region.appendChild(el); - state.toastTimer = setTimeout(() => { - el.classList.add("leaving"); - setTimeout(() => { - el.remove(); - if (state.toastTimer) state.toastTimer = null; - }, 220); - }, 3200); - } - - function warningListHtml(warnings) { - const items = Array.isArray(warnings) - ? warnings - .map((message) => String(message).trim()) - .filter(Boolean) - : []; - return items.map((message) => ` -
- 成本提醒 - ${escapeHtml(message)} -
- `).join(""); - } - - 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) return; - if (modal.open && typeof modal.close === "function") { - modal.close(); - } - if (typeof modal.showModal === "function") { - try { - modal.showModal(); - return; - } catch (_) {} - } - modal.setAttribute("open", ""); - } - - function closeModal() { - const modal = $("detail-modal"); - if (!modal) return; - if (typeof modal.close === "function") modal.close(); - else modal.removeAttribute("open"); - } - - function showConfirm(title, message, confirmText) { - return new Promise((resolve) => { - const modal = $("detail-modal"); - if (!modal) { resolve(window.confirm(message)); return; } - setText("modal-title", title); - setHtml("modal-body", ` -

${escapeHtml(message)}

-
- - -
- `); - const done = (result) => { - if (typeof modal.close === "function") modal.close(); - else modal.removeAttribute("open"); - resolve(result); - }; - $("confirm-ok").addEventListener("click", () => done(true), { once: true }); - $("confirm-cancel").addEventListener("click", () => done(false), { once: true }); - $("modal-close").addEventListener("click", () => done(false), { once: true }); - modal.addEventListener("cancel", (e) => { e.preventDefault(); done(false); }, { once: true }); - if (typeof modal.showModal === "function") { - try { modal.showModal(); return; } catch (_) {} - } - modal.setAttribute("open", ""); - }); - } - - function resolvePageFromHash() { - const raw = window.location.hash.replace(/^#\/?/, ""); - return PAGE_META[raw] ? raw : "home"; - } - - 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", t(meta[0], meta[2])); - setText("page-title", t(meta[1], meta[3])); - loadPageData(next, { force: !!options.force }); - } - - async function loadDashboard(force = false) { - if (state.dashboard && !force) { - renderDashboard(state.dashboard); - return state.dashboard; - } - setBusy(t("status.syncing", "同步中")); - try { - const data = await apiGet("dashboard"); - state.dashboard = data; - state.overview = data.overview || data; - renderDashboard(data); - return data; - } catch (error) { - showToast(error.message || String(error), "error"); - showErrors({ bridge: error.message || String(error) }); - throw error; - } - } - - 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"); - } - } - - async function cached(key, loader, force) { - if (!force && state.pageData[key]) return state.pageData[key]; - setBusy(t("status.loading", "加载中")); - const data = await loader(); - state.pageData[key] = data; - return data; - } - - 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 statusLabel = degraded ? t("status.partial", "部分可用") : t("status.healthy", "运行正常"); - const resolvedDashboardUrl = resolveHostUrl(webui.dashboard_url || ""); - const summary = degraded - ? t("status.degradedSummary", "嵌入式页面已载入,部分服务处于降级状态。") - : t("status.connectedSummary", "已连接官方插件页 API,独立 WebUI: {url}") - .replace("{url}", resolvedDashboardUrl || t("status.notConfigured", "未配置")); - 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 && resolvedDashboardUrl) fullLink.href = resolvedDashboardUrl; - - 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)); - - renderQuickActions(overview.quick_links || []); - renderModuleCards(overview.modules || []); - renderModuleChart(overview.modules || []); - renderIntelligence(overview.metrics || {}); - renderInsights(data); - showErrors(errors); - } - - function renderQuickActions(links) { - const html = links.map((link) => { - const url = resolveHostUrl(link.url || "#"); - const external = /^https?:\/\//.test(String(url || "")); - return ` - ${escapeHtml(link.label || t("actions.entry", "入口"))} - ${escapeHtml(link.description || "")} - `; - }).join(""); - setHtml("quick-actions", html); - } - - function renderModuleCards(modules) { - const html = modules.map((item) => ` -
-
-

${escapeHtml(item.title)}

- ${pill(item.enabled ? t("state.enabled", "启用") : t("state.closed", "关闭"), item.enabled ? "ok" : "warn")} -
-

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

-
- ${escapeHtml(fmt(item.metric))} - ${escapeHtml(item.metric_label || "")} -
-
- `).join(""); - setHtml("module-card-grid", html || empty()); - } - - function renderModuleChart(modules) { - const maxValue = Math.max(1, ...modules.map((item) => Number(item.metric || 0))); - 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))} -
`; - }).join(""); - setHtml("module-chart", html || empty()); - } - - function renderIntelligence(metrics) { - const score = normalizeScore(metrics.overall_score); - $("intelligence-ring")?.style.setProperty("--value", String(score)); - setText("intelligence-score", fmt(score)); - const dimCount = metrics.dimensions && typeof metrics.dimensions === "object" - ? Object.keys(metrics.dimensions).length - : 0; - setText("metrics-summary", dimCount - ? t("home.metricsSummary", "已有 {count} 个维度参与评估。").replace("{count}", fmt(dimCount, 0)) - : t("home.noMetrics", "智能指标服务暂未产生维度数据。")); - } - - 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", t("insights.databaseDegraded", "数据库处于降级状态"), (overview.runtime || {}).database_error || t("insights.databaseNotReady", "数据库服务未完整启动。"), "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", t("insights.reviewBacklog", "审查队列有积压"), t("insights.reviewBacklogDetail", "当前有 {count} 条学习结果等待确认。").replace("{count}", fmt(totalBacklog, 0)), "reviews"); - } - const score = normalizeScore(((overview.metrics || {}).overall_score)); - if (score > 0 && score < 60) { - push("warn", t("insights.lowScore", "智能评分偏低"), t("insights.lowScoreDetail", "综合评分 {score},建议查看表达样本和学习批次。").replace("{score}", fmt(score)), "metrics"); - } - const health = (monitoring.health || {}).overall; - if (health && health !== "healthy") { - push("warn", t("insights.healthWarn", "健康检查提示异常"), t("insights.healthWarnDetail", "当前健康状态为 {status}。").replace("{status}", health), "monitoring"); - } - const delegation = integrations.delegation || {}; - if (delegation.memory_delegated || delegation.reply_delegated) { - push("ok", t("insights.delegationEnabled", "伴随插件委托已启用"), t("insights.delegationDetail", "记忆委托: {memory},回复委托: {reply}。") - .replace("{memory}", delegation.memory_delegated ? t("state.yes", "是") : t("state.no", "否")) - .replace("{reply}", delegation.reply_delegated ? t("state.yes", "是") : t("state.no", "否")), "integrations"); - } - Object.entries(errors).forEach(([key, value]) => { - push("warn", t("insights.moduleFailed", "模块 {name} 读取失败").replace("{name}", key), String(value), "monitoring"); - }); - if (!items.length) { - push("ok", t("insights.noIssues", "暂无高优先级问题"), t("insights.noIssuesDetail", "核心学习、审查和监控模块均已返回可用数据。"), "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(t("actions.go", "前往"), `data-route-card="${escapeAttr(item.target)}"`)} -
- `).join(""); - setHtml("ai-insight-list", html); - } - - 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(t("empty.healthChecks", "暂无健康检查数据"))); - - 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 ? t("empty.functionStats", "暂无函数性能数据") : t("empty.debugDisabled", "debug_mode 未启用"))); - showErrors(data.errors || {}); - } - - 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); - } - - function renderJargon(data) { - const stats = data.stats || {}; - setHtml("jargon-stat-grid", statCards([ - [t("jargon.stats.candidates", "候选词"), stats.total_candidates], - [t("jargon.stats.confirmed", "已确认"), stats.confirmed_jargon], - [t("jargon.stats.inferred", "推断完成"), stats.completed_inference], - [t("jargon.stats.activeGroups", "活跃群组"), stats.active_groups], - ])); - const items = ((data.list || {}).jargon_list || []); - pruneSelection(state.selectedJargon, items.map((item) => item.id)); - const html = items.map((item) => ` -
- ${jargonCheckbox(item.id)} -
- ${escapeHtml(item.term || item.content || `#${item.id}`)} - ${escapeHtml(item.meaning || item.definition || t("empty.definition", "暂无释义"))} -
- ${escapeHtml(item.group_id || "global")} - ${pill(item.is_confirmed ? t("jargon.confirmed", "已确认") : t("jargon.pending", "待确认"), item.is_confirmed ? "ok" : "warn")} - ${pill(item.is_global ? t("jargon.global", "全局") : t("jargon.local", "本地"))} -
- ${button(t("actions.edit", "编辑"), `data-jargon-action="edit" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.confirm", "确认"), `data-jargon-action="approve" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.reject", "驳回"), `data-jargon-action="reject" data-id="${escapeAttr(item.id)}"`)} - ${button(item.is_global ? t("actions.unsetGlobal", "取消全局") : t("actions.setGlobal", "设为全局"), `data-jargon-action="toggle_global" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.delete", "删除"), `data-jargon-action="delete" data-id="${escapeAttr(item.id)}"`, "danger-button")} -
-
- `).join(""); - setHtml("jargon-list", html || empty(t("empty.jargon", "暂无黑话数据"))); - state.pageData.lastJargonItems = items; - state.pageData.currentJargonData = data; - refreshSelectionLabels(); - showErrors(data.errors || {}); - } - - function renderStyle(data) { - const stats = ((data.results || {}).statistics) || {}; - setHtml("style-stat-grid", statCards([ - [t("style.stats.samples", "风格样本"), stats.unique_styles || stats.total_samples], - [t("style.stats.avgConfidence", "平均置信度"), stats.avg_confidence], - [t("style.stats.totalSamples", "总样本"), stats.total_samples], - [t("style.stats.latestUpdate", "最近更新"), stats.latest_update ? t("state.yes", "有") : t("state.none", "无")], - ])); - const patterns = data.patterns || {}; - const patternGroups = [ - [t("style.patterns.emotion", "情绪模式"), patterns.emotion_patterns || []], - [t("style.patterns.language", "语言模式"), patterns.language_patterns || []], - [t("style.patterns.topic", "话题模式"), 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(t("empty.patterns", "暂无模式"))} -
- `).join("")); - const chartItems = patternGroups.map(([title, list]) => ({ title, metric: (list || []).length, accent: "#4169e1" })); - renderGenericBarChart("style-pattern-chart", chartItems); - const reviews = ((data.reviews || {}).reviews || []); - pruneSelection(selectedReviewSet("style"), reviews.map((item) => item.id)); - setHtml("expression-review-list", reviews.map((item) => styleReviewHtml(item)).join("") || empty(t("empty.styleReviews", "暂无表达审查"))); - state.pageData.lastStyleItems = reviews; - refreshSelectionLabels(); - } - - function renderReviews(data) { - const personaPending = ((data.persona_pending || {}).updates || []) - .filter((item) => item && item.review_source !== "style_learning"); - const personaReviewed = ((data.persona_reviewed || {}).updates || []); - const styleReviews = ((data.style_reviews || {}).reviews || []); - const pendingJargon = (((data.jargon_pending || {}).jargon_list) || []); - pruneSelection(selectedReviewSet("persona"), personaPending.map((item) => item.id)); - pruneSelection(selectedReviewSet("style"), styleReviews.map((item) => item.id)); - pruneSelection(selectedReviewSet("jargon"), pendingJargon.map((item) => item.id)); - setText("persona-review-count", reviewCountText("persona", personaPending.length)); - setText("style-review-count", reviewCountText("style", styleReviews.length)); - setText("jargon-review-count", reviewCountText("jargon", pendingJargon.length)); - setText("reviewed-count", fmt(personaReviewed.length, 0)); - setHtml("persona-review-list", personaPending.map((item) => personaReviewHtml(item)).join("") || empty(t("empty.personaUpdates", "暂无人格更新"))); - setHtml("style-review-list", styleReviews.map((item) => styleReviewHtml(item)).join("") || empty(t("empty.styleReviews", "暂无表达审查"))); - setHtml("jargon-review-list", pendingJargon.map((item) => jargonReviewHtml(item)).join("") || empty(t("empty.jargonCandidates", "暂无黑话候选"))); - state.pageData.lastStyleItems = styleReviews; - 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(t("empty.reviewed", "暂无已审查记录"))); - showErrors(data.errors || {}); - } - - function personaReviewHtml(item) { - const id = item.id; - return `
- ${reviewCheckbox("persona", id)} -
- ${escapeHtml(item.update_type || item.review_source || t("reviews.personaUpdates", "人格更新"))} - ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.reason || item.description || "")} -

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

-
-
- ${button(t("actions.details", "详情"), `data-review-action="detail" data-kind="persona" data-id="${escapeAttr(id)}"`)} - ${button(t("actions.approve", "批准"), `data-review-action="approve" data-kind="persona" data-id="${escapeAttr(id)}"`, "solid-button")} - ${button(t("actions.reject", "拒绝"), `data-review-action="reject" data-kind="persona" data-id="${escapeAttr(id)}"`)} - ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="persona" data-id="${escapeAttr(id)}"`, "danger-button")} -
-
`; - } - - function styleReviewHtml(item) { - return `
- ${reviewCheckbox("style", item.id)} -
- ${escapeHtml(item.description || t("style.title", "表达方式学习"))} - ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.status || "pending")} -

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

-
-
- ${button(t("actions.edit", "编辑"), `data-style-action="edit" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.details", "详情"), `data-review-action="detail" data-kind="style" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.approve", "批准"), `data-review-action="approve" data-kind="style" data-id="${escapeAttr(item.id)}"`, "solid-button")} - ${button(t("actions.reject", "拒绝"), `data-review-action="reject" data-kind="style" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="style" data-id="${escapeAttr(item.id)}"`, "danger-button")} -
-
`; - } - - function jargonReviewHtml(item) { - return `
- ${reviewCheckbox("jargon", item.id)} -
- ${escapeHtml(item.term || item.content || `#${item.id}`)} - ${escapeHtml(item.group_id || "global")} · ${escapeHtml(t("units.times", "{count} 次").replace("{count}", fmt(item.occurrences || item.count, 0)))} -

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

-
-
- ${button(t("actions.confirm", "确认"), `data-review-action="approve" data-kind="jargon" data-id="${escapeAttr(item.id)}"`, "solid-button")} - ${button(t("actions.rejectBack", "驳回"), `data-review-action="reject" data-kind="jargon" data-id="${escapeAttr(item.id)}"`)} - ${button(t("actions.delete", "删除"), `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([ - [t("persona.stats.promptLength", "提示词字数"), current.prompt_length], - [t("persona.stats.beginDialogs", "开场对话"), current.begin_dialog_count], - [t("persona.stats.toolCount", "工具数量"), current.tool_count], - [t("persona.stats.currentGroup", "当前群组"), current.group_id || "default"], - ])); - setText("persona-prompt-preview", current.prompt_preview || persona.system_prompt || persona.prompt || t("empty.personaPrompt", "暂无人格提示词")); - - 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(t("actions.edit", "编辑"), `data-persona-action="edit" data-persona-id="${escapeAttr(id)}"`)} - ${button(t("actions.export", "导出"), `data-persona-action="export" data-persona-id="${escapeAttr(id)}"`)} - ${button(t("actions.delete", "删除"), `data-persona-action="delete" data-persona-id="${escapeAttr(id)}"`, "danger-button")} -
-
`; - }).join("") || empty(t("empty.personaList", "暂无人格列表"))); - state.pageData.lastPersonaItems = personas; - - const backups = ((data.backups || {}).backups || []); - setText("persona-backup-count", fmt(backups.length, 0)); - setHtml("persona-backup-list", backups.map((item) => ` -
-
- ${escapeHtml(item.backup_name || t("persona.backupName", "备份 {id}").replace("{id}", item.id))} - ${escapeHtml(item.reason_short || item.reason || t("empty.noRemark", "无备注"))} -
- ${escapeHtml(item.group_id || "default")} - ${escapeHtml(item.timestamp || item.created_at || "")} -
- ${button(t("actions.view", "查看"), `data-persona-action="backup_detail" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`)} - ${button(t("actions.restore", "恢复"), `data-persona-action="backup_restore" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "solid-button")} - ${button(t("actions.delete", "删除"), `data-persona-action="backup_delete" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "danger-button")} -
-
- `).join("") || empty(t("empty.personaBackups", "暂无人格备份"))); - 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(t("actions.delete", "删除"), `data-content-action="delete_content" data-bucket="${escapeAttr(state.contentType)}" data-id="${escapeAttr(item.id)}"`, "danger-button")} -
- `).join("") || empty(t("empty.learningContent", "暂无学习内容"))); - - 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(t("actions.delete", "删除"), `data-content-action="delete_batch" data-id="${escapeAttr(item.id)}"`, "danger-button")} -
- `).join("") || empty(t("empty.batchHistory", "暂无批次历史"))); - showErrors(data.errors || {}); - } - - 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 || []); - settleGraphLayout(state.graph.nodes, state.graph.links, size.width, size.height); - state.graph.dragged = null; - state.graph.hovered = null; - setHtml("graph-stat-grid", statCards([ - [t("graphs.stats.nodes", "节点"), (graph.stats || {}).nodes || state.graph.nodes.length], - [t("graphs.stats.links", "连线"), (graph.stats || {}).links || state.graph.links.length], - [t("graphs.stats.groups", "群组"), (graph.stats || {}).groups || (graph.groups || []).length], - [t("graphs.stats.source", "来源"), 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 || t("graphs.node", "节点"))} - ${escapeHtml(node.detail || "")} -
- `).join("") || empty(t("empty.graphNodes", "暂无图谱节点"))); - 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 home = graphHomePosition(id, index, total, safeWidth, safeHeight, radius); - return { - ...node, - id, - label: node.name || node.label || id, - radius, - x: home.x, - y: home.y, - homeX: home.x, - homeY: home.y, - vx: 0, - vy: 0, - pinned: false, - }; - } - - function graphHomePosition(id, index, total, width, height, radius) { - const margin = graphNodeMargin(radius); - const centerX = width / 2; - const centerY = height / 2; - const seed = graphStableSeed(id); - const angleOffset = state.graph.type === "knowledge" ? 0.72 : 0; - const angle = index * 2.399963229728653 + angleOffset + seed * 0.0007; - const ring = Math.sqrt((index + 0.5) / Math.max(1, total)); - const spreadX = Math.max(86, (width - margin * 2) * 0.36); - const spreadY = Math.max(72, (height - margin * 2) * 0.34); - return { - x: clamp(centerX + Math.cos(angle) * spreadX * ring, margin, width - margin), - y: clamp(centerY + Math.sin(angle) * spreadY * ring, margin, height - margin), - }; - } - - function settleGraphLayout(nodes, links, width, height) { - if (!nodes.length) return; - const byId = new Map(nodes.map((node) => [String(node.id), node])); - for (let iteration = 0; iteration < 18; iteration += 1) { - links.slice(0, 220).forEach((link) => { - const source = byId.get(String(link.source)); - const target = byId.get(String(link.target)); - if (!source || !target) return; - const dx = target.x - source.x; - const dy = target.y - source.y; - const dist = Math.max(1, Math.hypot(dx, dy)); - const desired = Math.max(78, Math.min(132, Math.min(width, height) * 0.23)); - const adjust = (dist - desired) * 0.0035; - const nx = dx / dist; - const ny = dy / dist; - if (!source.pinned) { - source.x += nx * adjust; - source.y += ny * adjust; - } - if (!target.pinned) { - target.x -= nx * adjust; - target.y -= ny * adjust; - } - }); - - for (let i = 0; i < nodes.length; i += 1) { - for (let j = i + 1; j < Math.min(nodes.length, i + 42); j += 1) { - separateGraphNodes(nodes[i], nodes[j], 0.45); - } - } - - nodes.forEach((node) => { - if (!node.pinned) { - node.x += (node.homeX - node.x) * 0.12; - node.y += (node.homeY - node.y) * 0.12; - } - clampGraphNode(node, width, height); - }); - } - } - - function normalizeGraphLinks(links) { - if (!Array.isArray(links)) return []; - return links.map((link) => ({ - ...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(t("empty.groupChatPlus", "未检测到 Group Chat Plus"))); - } - - function renderIntegrations(data) { - setHtml("integration-cards", (data.dashboards || []).map(integrationCardHtml).join("") || empty(t("empty.integrations", "暂无融合状态"))); - setHtml("integration-warnings", warningListHtml(data.warnings || [])); - const settings = data.settings || {}; - setHtml("integration-settings", Object.entries(settings).map(([key, value]) => ` -
- ${escapeHtml(key)} - ${escapeHtml(value === true ? t("state.on", "开启") : value === false ? t("state.off", "关闭") : value ?? t("state.unset", "未设置"))} -
- `).join("") || empty(t("empty.integrationSettings", "暂无融合设置"))); - renderMaiBotImportPreview(data.maibot_learning || null); - } - - function integrationCardHtml(item) { - const dash = item.dashboard || {}; - const url = resolveHostUrl(dash.external_url || dash.official_page_url || dash.url || "#"); - const disabled = !dash.available || !url || url === "#"; - return `
-
- ${escapeHtml(item.role || "")} -

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

-

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

-
- ${escapeHtml(dash.label || t("actions.open", "打开"))} - ${escapeHtml((item.dev_api || {}).mode || "")} -
`; - } - - function collectMaiBotPayload() { - const payload = { - maibot_root: $("maibot-root-input")?.value?.trim() || "", - db_path: $("maibot-db-input")?.value?.trim() || "", - memorix_db_path: $("maibot-memorix-input")?.value?.trim() || "", - default_group_id: $("maibot-default-group-input")?.value?.trim() || "global", - import_expressions: Boolean($("maibot-import-expressions")?.checked), - import_jargons: Boolean($("maibot-import-jargons")?.checked), - import_memories: Boolean($("maibot-import-memories")?.checked), - approve_checked_expressions: Boolean($("maibot-approve-checked")?.checked), - }; - if (!payload.maibot_root && !payload.db_path) { - throw new Error(t("maibot.missingPath", "请填写 MaiBot 项目目录或主数据库路径")); - } - return payload; - } - - function renderMaiBotImportPreview(summary) { - const output = $("maibot-import-output"); - if (!output || !summary) return; - const counts = summary.counts || {}; - const breakdown = summary.review_breakdown || {}; - const destinations = summary.destinations || {}; - const lines = []; - if (Object.keys(counts).length) { - lines.push(t("maibot.previewCounts", "预览: 表达 {expressions} · 黑话 {jargons} · 记忆 {memories}") - .replace("{expressions}", fmt(counts.expressions, 0)) - .replace("{jargons}", fmt(counts.jargons, 0)) - .replace("{memories}", fmt(counts.memories, 0))); - } - if (Object.keys(breakdown).length) { - lines.push(t("maibot.importCounts", "导入: 表达审查 {style} · 黑话候选 {jargon} · 记忆审查 {memory}") - .replace("{style}", fmt(breakdown.style_learning_reviews, 0)) - .replace("{jargon}", fmt(breakdown.jargon_candidates, 0)) - .replace("{memory}", fmt(breakdown.persona_memory_reviews, 0))); - } - if (Object.keys(destinations).length) { - lines.push(t("maibot.destinations", "分类去向: 表达 -> {expressions}; 黑话 -> {jargons}; 记忆 -> {memories}") - .replace("{expressions}", destinations.expressions) - .replace("{jargons}", destinations.jargons) - .replace("{memories}", destinations.memories)); - } - output.textContent = `${lines.join("\n")}${lines.length ? "\n\n" : ""}${JSON.stringify(summary, null, 2)}`; - } - - function currentBatchReviewIds(kind) { - const selected = selectedReviewIds(kind); - return selected.length ? selected : visibleReviewIds(kind); - } - - async function handleBatchReviewAction(kind, action) { - const ids = currentBatchReviewIds(kind); - if (!ids.length) { - showToast(t("reviews.noBatchItems", "当前页没有可批量处理的审查项"), "error"); - return; - } - const typeText = { persona: t("reviews.personaUpdates", "人格更新"), style: t("reviews.expressionReviews", "表达审查"), jargon: t("reviews.jargonCandidates", "黑话候选") }[kind] || t("reviews.items", "审查项"); - const actionText = action === "approve" ? t("actions.pass", "通过") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); - const scopeText = selectedReviewIds(kind).length ? t("selection.selected", "选中") : t("selection.currentPage", "当前页"); - if (!await showConfirm(t("reviews.batchConfirmTitle", "批量操作确认"), t("reviews.confirmBatch", "确定批量{action}{scope} {count} 条{type}?") - .replace("{action}", actionText) - .replace("{scope}", scopeText) - .replace("{count}", fmt(ids.length, 0)) - .replace("{type}", typeText), actionText)) return; - - const payload = { - action: action === "delete" - ? kind === "persona" ? "batch_delete" : kind === "style" ? "batch_delete_style" : "batch_delete_jargon" - : kind === "persona" ? "batch_review" : kind === "style" ? "batch_review_style" : "batch_review_jargon", - ids, - decision: action, - }; - const result = await apiPost("reviews/action", payload); - showToast(result.message || t("messages.batchDone", "批量操作完成"), result.success ? "ok" : "error"); - selectedReviewSet(kind).clear(); - state.pageData.reviews = null; - await loadPageData(state.page, { force: true }); - } - - async function runMaiBotImportAction(action) { - const buttonEl = action === "maibot_import" ? $("maibot-import-button") : $("maibot-preview-button"); - const originalLabel = buttonEl?.textContent || ""; - try { - const payload = collectMaiBotPayload(); - if (buttonEl) { - buttonEl.disabled = true; - buttonEl.classList.add("is-busy"); - buttonEl.textContent = action === "maibot_import" ? t("actions.importing", "导入中") : t("actions.previewing", "预览中"); - } - setText("maibot-import-output", t("maibot.reading", "正在读取 MaiBot 学习数据...")); - const result = await apiPost("integrations/action", { action, ...payload }); - const detail = result.preview || result.result || result.payload || result; - renderMaiBotImportPreview(detail); - showToast(result.message || t("maibot.done", "MaiBot 学习数据操作完成"), result.success !== false ? "ok" : "error"); - if (action === "maibot_import") { - state.pageData = {}; - await loadDashboard(true); - } - } catch (error) { - const message = error.message || String(error); - setText("maibot-import-output", message); - showToast(message, "error"); - } finally { - if (buttonEl) { - buttonEl.disabled = false; - buttonEl.classList.remove("is-busy"); - buttonEl.textContent = originalLabel; - } - } - } - - function renderSettings(data) { - const schema = data.schema || {}; - setHtml("settings-warnings", warningListHtml(schema.warnings || data.warnings || [])); - const groups = schema.groups || []; - if (!state.settingsGroup && groups.length) state.settingsGroup = groups[0].key; - setHtml("settings-groups", groups.map((group) => ` - - `).join("") || empty(t("empty.configSchema", "配置 schema 暂不可用"))); - - const active = groups.find((group) => group.key === state.settingsGroup) || groups[0] || { fields: [] }; - setHtml("config-form", (active.fields || []).map(fieldHtml).join("") || empty(t("empty.selectConfigGroup", "请选择配置分组"))); - 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"}`; - const groupKey = field.group_key || activeSettingsGroupKeyForField(field.key); - const label = configT(`${groupKey}.${field.key}.description`, field.label || field.key); - const hint = configT(`${groupKey}.${field.key}.hint`, field.hint || ""); - 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 activeSettingsGroupKeyForField(fieldKey) { - const groups = ((state.pageData.settings || {}).schema || {}).groups || []; - const group = groups.find((item) => (item.fields || []).some((field) => field.key === fieldKey)); - return group?.key || state.settingsGroup || ""; - } - - 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(t("modal.reviewDetails", "审查详情"), `
${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 || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); - selectedReviewSet(kind).delete(normalizeId(id)); - 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(t("modal.editJargon", "编辑黑话"), ` - - - - `); - return; - } - const result = await apiPost("jargon/action", { action, id }); - showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); - state.selectedJargon.delete(normalizeId(id)); - state.pageData = {}; - await loadPageData(state.page, { force: true }); - } - - async function handleJargonBatchAction(action) { - const visibleIds = jargonPageIds(); - if (action === "select_all") { - visibleIds.forEach((id) => state.selectedJargon.add(id)); - renderJargon(state.pageData.currentJargonData || {}); - return; - } - if (action === "clear") { - state.selectedJargon.clear(); - renderJargon(state.pageData.currentJargonData || {}); - return; - } - - const ids = Array.from(state.selectedJargon).filter(Boolean); - if (!ids.length) { - showToast(t("jargon.selectFirst", "请先选择黑话条目"), "error"); - return; - } - const actionText = action === "approve" ? t("actions.confirm", "确认") : action === "reject" ? t("actions.rejectBack", "驳回") : t("actions.delete", "删除"); - if (!await showConfirm(t("jargon.batchConfirmTitle", "批量操作确认"), t("jargon.confirmBatch", "确定批量{action}选中的 {count} 条黑话?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return; - - const result = await apiPost("jargon/action", { - action: action === "delete" ? "batch_delete" : "batch_review", - ids, - decision: action, - }); - showToast(result.message || t("jargon.batchDone", "批量黑话操作完成"), result.success ? "ok" : "error"); - state.selectedJargon.clear(); - state.pageData = {}; - await loadPageData("jargon-learning", { force: true }); - } - - function modalFieldValue(id) { - return $(id)?.value ?? ""; - } - - function parseModalJson(id, fallback) { - const raw = modalFieldValue(id).trim(); - if (!raw) return fallback; - try { - return JSON.parse(raw); - } catch (_) { - return raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); - } - } - - async function handleStyleAction(action, id) { - if (action === "edit") { - const item = (state.pageData.lastStyleItems || []).find((entry) => String(entry.id) === String(id)) || {}; - const patterns = typeof item.learned_patterns === "string" - ? item.learned_patterns - : JSON.stringify(item.learned_patterns || [], null, 2); - showModal(t("modal.editStyle", "编辑表达方式"), ` - - - - - `); - } - } - - async function handleStyleBatchAction(action) { - const visibleIds = stylePageReviewIds(); - const selected = selectedReviewSet("style"); - if (action === "select_all") { - visibleIds.forEach((id) => selected.add(id)); - renderStyle(state.pageData.style || {}); - return; - } - if (action === "clear") { - selected.clear(); - renderStyle(state.pageData.style || {}); - return; - } - - const ids = selectedReviewIds("style"); - if (!ids.length) { - showToast(t("style.selectFirst", "请先选择表达审查项"), "error"); - return; - } - const actionText = action === "approve" ? t("actions.approve", "批准") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); - if (!await showConfirm(t("style.batchConfirmTitle", "批量操作确认"), t("style.confirmBatch", "确定批量{action}选中的 {count} 条表达审查?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return; - - const result = await apiPost("style/action", { - action: action === "delete" ? "batch_delete" : "batch_review", - ids, - decision: action, - }); - showToast(result.message || t("style.batchDone", "批量表达审查完成"), result.success ? "ok" : "error"); - selected.clear(); - state.pageData.style = null; - state.pageData.lastStyleItems = []; - await loadPageData("expression-learning", { force: true }); - } - - async function handlePersonaAction(buttonEl) { - const action = buttonEl.dataset.personaAction; - if (action === "edit") { - const personaId = buttonEl.dataset.personaId; - const item = (state.pageData.lastPersonaItems || []).find((entry) => String(entry.persona_id || entry.id || entry.name) === String(personaId)) || {}; - const beginDialogs = JSON.stringify(item.begin_dialogs || [], null, 2); - showModal(t("modal.editPersona", "编辑人格"), ` - - - - - - `); - return; - } - 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" ? t("modal.personaExport", "人格导出") : t("modal.backupDetails", "备份详情"), `
${escapeHtml(JSON.stringify(result.persona || result.backup || result, null, 2))}
`); - return; - } - showToast(result.message || t("messages.actionDone", "操作完成"), 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 || t("messages.actionDone", "操作完成"), 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(t("messages.contextCopied", "巡检上下文已复制")); - } catch (_) { - showModal(t("modal.insightContext", "巡检上下文"), `
${escapeHtml(text)}
`); - } - }); - $("relearn-button")?.addEventListener("click", async () => { - const result = await apiPost("content/action", { action: "relearn", group_id: "default" }); - showToast(result.message || t("messages.relearnSubmitted", "重新学习已提交"), 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 || t("messages.settingsSaved", "设置已保存"), result.success ? "ok" : "error"); - state.pageData.settings = null; - await loadPageData("settings", { force: true }); - }); - $("dependency-install-button")?.addEventListener("click", async () => { - const installButton = $("dependency-install-button"); - const originalLabel = installButton?.textContent || t("actions.manualInstall", "手动安装"); - const settings = state.pageData.settings || {}; - if (installButton) { - installButton.disabled = true; - installButton.classList.add("is-busy"); - installButton.textContent = t("actions.installing", "安装中"); - } - setText("dependency-output", t("settings.installingDeps", "正在调用 pip 安装依赖,请等待命令输出...")); - try { - const result = await apiPost("settings/action", { - action: "install_dependencies", - manual_confirmed: true, - source: settings.manual_dependency_source || "system_settings", - tier: $("dependency-tier")?.value || "full", - pip_mirror: $("pip-mirror-select")?.value || "default", - }); - const detail = result.result || result; - setText("dependency-output", detail.output || detail.message || result.message || t("settings.installDone", "依赖安装任务结束")); - showToast(result.message || detail.message || t("settings.installDone", "依赖安装任务结束"), result.success !== false ? "ok" : "error"); - } catch (error) { - const message = error.message || String(error); - setText("dependency-output", message); - showToast(message, "error"); - } finally { - if (installButton) { - installButton.disabled = false; - installButton.classList.remove("is-busy"); - installButton.textContent = originalLabel; - } - } - }); - $("maibot-preview-button")?.addEventListener("click", () => runMaiBotImportAction("maibot_preview")); - $("maibot-import-button")?.addEventListener("click", () => runMaiBotImportAction("maibot_import")); - - document.addEventListener("click", async (event) => { - const target = event.target.closest("[data-route-card],[data-refresh-page],[data-review-action],[data-batch-review-kind],[data-jargon-action],[data-jargon-batch-action],[data-style-action],[data-style-batch-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.batchReviewKind) await handleBatchReviewAction(target.dataset.batchReviewKind, target.dataset.batchReviewAction || "approve"); - if (target.dataset.jargonAction) await handleJargonAction(target.dataset.jargonAction, target.dataset.id); - if (target.dataset.jargonBatchAction) await handleJargonBatchAction(target.dataset.jargonBatchAction); - if (target.dataset.styleAction) await handleStyleAction(target.dataset.styleAction, target.dataset.id); - if (target.dataset.styleBatchAction) await handleStyleBatchAction(target.dataset.styleBatchAction); - 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 reviewSelect = event.target.closest("[data-review-select-kind]"); - if (reviewSelect) { - const selection = selectedReviewSet(reviewSelect.dataset.reviewSelectKind); - const id = normalizeId(reviewSelect.dataset.reviewSelectId); - if (reviewSelect.checked) selection.add(id); - else selection.delete(id); - refreshSelectionLabels(); - return; - } - const jargonSelect = event.target.closest("[data-jargon-select-id]"); - if (jargonSelect) { - const id = normalizeId(jargonSelect.dataset.jargonSelectId); - if (jargonSelect.checked) state.selectedJargon.add(id); - else state.selectedJargon.delete(id); - refreshSelectionLabels(); - return; - } - 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 || t("messages.jargonUpdated", "黑话已更新"), result.success ? "ok" : "error"); - state.pageData = {}; - await loadPageData("jargon-learning", { force: true }); - }); - - document.addEventListener("click", async (event) => { - const save = event.target.closest("#modal-style-save"); - if (!save) return; - const result = await apiPost("style/action", { - action: "update", - id: save.dataset.id, - description: modalFieldValue("modal-style-description"), - few_shots_content: modalFieldValue("modal-style-few-shots"), - learned_patterns: parseModalJson("modal-style-patterns", []), - }); - closeModal(); - showToast(result.message || t("messages.styleUpdated", "表达方式已更新"), result.success ? "ok" : "error"); - state.pageData.style = null; - state.pageData.lastStyleItems = []; - await loadPageData("expression-learning", { force: true }); - }); - - document.addEventListener("click", async (event) => { - const save = event.target.closest("#modal-persona-save"); - if (!save) return; - const personaId = save.dataset.personaId; - const result = await apiPost("persona/action", { - action: "update", - persona_id: personaId, - persona: { - persona_id: personaId, - name: modalFieldValue("modal-persona-name"), - system_prompt: modalFieldValue("modal-persona-prompt"), - prompt: modalFieldValue("modal-persona-prompt"), - begin_dialogs: parseModalJson("modal-persona-dialogs", []), - }, - }); - closeModal(); - showToast(result.message || t("messages.personaUpdated", "人格已更新"), result.success ? "ok" : "error"); - state.pageData.persona = null; - state.pageData.lastPersonaItems = []; - await loadPageData("persona-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 setI18nFromBridge() { - const rerender = () => { - applyStaticI18n(); - const meta = PAGE_META[state.page] || PAGE_META.home; - setText("page-kicker", t(meta[0], meta[2])); - setText("page-title", t(meta[1], meta[3])); - if (state.dashboard) renderDashboard(state.dashboard); - const data = state.pageData[state.page] || state.pageData[state.page === "expression-learning" ? "style" : state.page]; - if (state.page === "monitoring" && state.pageData.monitoring) renderMonitoring(state.pageData.monitoring); - else if (state.page === "reviews" && state.pageData.reviews) renderReviews(state.pageData.reviews); - else if (state.page === "jargon-learning" && state.pageData.currentJargonData) renderJargon(state.pageData.currentJargonData); - else if (state.page === "expression-learning" && state.pageData.style) renderStyle(state.pageData.style); - else if (state.page === "persona-learning" && state.pageData.persona) renderPersona(state.pageData.persona); - else if (state.page === "content" && state.pageData.content) renderContent(state.pageData.content); - else if (state.page === "graphs" && data) renderGraphs(data); - else if ((state.page === "reply-strategy" || state.page === "integrations") && state.pageData.integrations) { - if (state.page === "reply-strategy") renderReplyStrategy(state.pageData.integrations); - else renderIntegrations(state.pageData.integrations); - } else if (state.page === "settings" && state.pageData.settings) renderSettings(state.pageData.settings); - }; - try { - const bridge = window.AstrBotPluginPage; - if (bridge && bridge.onContextChange) bridge.onContextChange(rerender); - if (bridge && bridge.onContext) bridge.onContext(rerender); - } catch (_) {} - rerender(); - } - - function initSpringMotion() { - const stage = qs(".spring-stage"); - const canvas = $("physics-canvas"); - if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; - if (!stage || !canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - 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; }); - if (!physics.running) { - physics.running = true; - physics.last = performance.now(); - requestAnimationFrame(tickSpringMotion); - } - } - - function tickSpringMotion(now) { - 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.14)"; - ctx.lineWidth = 1.2; - - const core = { x: rect.width / 2, y: rect.height / 2 }; - physics.particles.forEach((point) => { - const own = point.el.getBoundingClientRect(); - const baseX = own.left - rect.left + own.width / 2 - point.x; - const baseY = own.top - rect.top + own.height / 2 - point.y; - let targetX = Math.sin(now / 1350 + point.seed) * 6; - let targetY = Math.cos(now / 1500 + point.seed) * 5; - if (physics.pointer.active) { - const cx = baseX + point.x; - const cy = baseY + point.y; - const dx = cx - physics.pointer.x; - const dy = cy - physics.pointer.y; - const dist = Math.max(1, Math.hypot(dx, dy)); - const force = Math.max(0, 96 - dist) / 96; - targetX += dx / dist * force * 18; - targetY += dy / dist * force * 18; - } - point.vx += (targetX - point.x) * 28 * dt; - point.vy += (targetY - point.y) * 28 * dt; - point.vx *= Math.max(0, 1 - 14 * dt); - point.vy *= Math.max(0, 1 - 14 * dt); - point.x += point.vx * dt * 60; - point.y += point.vy * dt * 60; - const px = baseX + point.x; - const py = baseY + point.y; - ctx.beginPath(); - ctx.moveTo(core.x, core.y); - ctx.quadraticCurveTo((core.x + px) / 2, (core.y + py) / 2 - 8, 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; - - 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"); - }); - - canvas.addEventListener("pointermove", (event) => { - const point = graphPointer(event, canvas); - const drag = state.graph.dragged; - if (drag && drag.pointerId === event.pointerId) { - const min = graphNodeMargin(drag.node.radius || graphNodeRadius(drag.node)); - drag.node.x = clamp(point.x + drag.offsetX, min, state.graph.width - min); - drag.node.y = clamp(point.y + drag.offsetY, min, state.graph.height - min); - drag.node.homeX = drag.node.x; - 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(78, Math.min(132, Math.min(width, height) * 0.23)); - const force = (dist - desired) * GRAPH_LINK_STRENGTH; - if (!source.pinned) { - source.vx += (dx / dist) * force; - source.vy += (dy / dist) * force; - } - if (!target.pinned) { - target.vx -= (dx / dist) * force; - target.vy -= (dy / dist) * force; - } - ctx.strokeStyle = "rgba(100, 116, 139, 0.28)"; - ctx.lineWidth = Math.max(1, Math.min(4, Number(link.value || 1))); - 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) { - separateGraphNodes(nodes[i], nodes[j], 0.022); - } - } - - 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) * GRAPH_HOME_STRENGTH + (cx - node.x) * GRAPH_CENTER_STRENGTH; - node.vy += ((node.homeY || cy) - node.y) * GRAPH_HOME_STRENGTH + (cy - node.y) * GRAPH_CENTER_STRENGTH; - node.vx *= 0.74; - node.vy *= 0.74; - node.x += node.vx; - node.y += node.vy; - } - const radius = node.radius || graphNodeRadius(node); - if (clampGraphNode(node, width, height) && !node.pinned) { - node.vx *= 0.12; - node.vy *= 0.12; - } - const isHovered = state.graph.hovered === node || state.graph.dragged?.node === node; - if (isHovered) { - ctx.fillStyle = "rgba(15, 159, 143, 0.14)"; - 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"; - const label = String(node.name || node.label || "").slice(0, 12); - const labelWidth = ctx.measureText(label).width; - const labelX = clamp(node.x + radius + 4, 6, width - labelWidth - 6); - const labelY = clamp(node.y + 4, 14, height - 6); - ctx.fillText(label, labelX, labelY); - }); - requestAnimationFrame(tickGraph); - } - - 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, index) => { - const radius = node.radius || graphNodeRadius(node); - const min = graphNodeMargin(radius); - const home = graphHomePosition(node.id, index, state.graph.nodes.length, width, height, radius); - node.x = clamp((node.x / oldWidth) * width, min, width - min); - node.y = clamp((node.y / oldHeight) * height, min, height - min); - node.homeX = home.x; - node.homeY = home.y; - if (options.force && !node.pinned) { - node.x = node.x * 0.55 + home.x * 0.45; - node.y = node.y * 0.55 + home.y * 0.45; - } - }); - } - state.graph.width = width; - 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 graphNodeMargin(radius) { - return Math.max(52, radius + GRAPH_SAFE_PADDING); - } - - function clampGraphNode(node, width, height) { - const radius = node.radius || graphNodeRadius(node); - const min = graphNodeMargin(radius); - const nextX = clamp(node.x, min, width - min); - const nextY = clamp(node.y, min, height - min); - const clamped = nextX !== node.x || nextY !== node.y; - node.x = nextX; - node.y = nextY; - return clamped; - } - - function separateGraphNodes(a, b, strength) { - const dx = b.x - a.x; - const dy = b.y - a.y; - const dist = Math.max(1, Math.hypot(dx, dy)); - const minDist = (a.radius || graphNodeRadius(a)) + (b.radius || graphNodeRadius(b)) + 20; - if (dist >= minDist) return; - const shift = (minDist - dist) / minDist * strength; - const nx = dx / dist; - const ny = dy / dist; - if (!a.pinned) { - a.vx -= nx * shift; - a.vy -= ny * shift; - a.x -= nx * shift * 6; - a.y -= ny * shift * 6; - } - if (!b.pinned) { - b.vx += nx * shift; - b.vy += ny * shift; - b.x += nx * shift * 6; - b.y += ny * shift * 6; - } - } - - function graphStableSeed(value) { - let hash = 0; - const text = String(value || ""); - for (let index = 0; index < text.length; index += 1) { - hash = (hash * 31 + text.charCodeAt(index)) >>> 0; - } - return hash % 997; - } - - function graphValueKey(value) { - if (value && typeof value === "object") { - return String(value.id ?? value.name ?? value.label ?? ""); - } - return String(value ?? ""); - } - - function clamp(value, min, max) { - if (max < min) return min; - return Math.max(min, Math.min(max, value)); - } - - async function init() { - setThemeFromBridge(); - bindEvents(); - initSpringMotion(); - try { - await bridgeReady(); - setI18nFromBridge(); - navigateToPage(resolvePageFromHash(), { skipHash: true, force: true }); - } catch (error) { - showToast(error.message || String(error), "error"); - setText("runtime-status", t("status.bridgeFailed", "桥接失败")); - setText("runtime-summary", error.message || String(error)); - } - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); - } else { - init(); - } -})(); +(() => { + "use strict"; + + const PAGE_META = { + home: ["page.home.kicker", "page.home.title", "Dashboard", "完整内嵌 WebUI"], + insights: ["page.insights.kicker", "page.insights.title", "Insights", "AI 巡检"], + monitoring: ["page.monitoring.kicker", "page.monitoring.title", "Monitoring", "运行监控"], + reviews: ["page.reviews.kicker", "page.reviews.title", "Reviews", "审查队列"], + "jargon-learning": ["page.jargon.kicker", "page.jargon.title", "Jargon", "黑话学习"], + "expression-learning": ["page.style.kicker", "page.style.title", "Expression", "表达方式学习"], + "persona-learning": ["page.persona.kicker", "page.persona.title", "Persona", "人格学习"], + content: ["page.content.kicker", "page.content.title", "Content", "学习内容"], + graphs: ["page.graphs.kicker", "page.graphs.title", "Graphs", "图谱"], + "reply-strategy": ["page.reply.kicker", "page.reply.title", "Reply", "回复策略"], + integrations: ["page.integrations.kicker", "page.integrations.title", "Integrations", "功能融合"], + settings: ["page.settings.kicker", "page.settings.title", "Settings", "设置"], + }; + const I18N_ROOT = "pages.dashboard"; + const GRAPH_SAFE_PADDING = 34; + const GRAPH_HOME_STRENGTH = 0.0064; + const GRAPH_CENTER_STRENGTH = 0.00016; + const GRAPH_LINK_STRENGTH = 0.000035; + + const state = { + page: "home", + ready: false, + dashboard: null, + overview: null, + pageData: {}, + selectedReviews: { + persona: new Set(), + style: new Set(), + jargon: new Set(), + }, + selectedJargon: new Set(), + contentType: "dialogues", + settingsGroup: null, + dirtySettings: new Map(), + graph: { + nodes: [], + links: [], + running: false, + dragged: null, + hovered: null, + type: "memory", + width: 0, + height: 0, + canvasBound: false, + }, + toastTimer: null, + }; + + const physics = { + particles: [], + pointer: { x: 0, y: 0, active: false }, + running: false, + last: 0, + }; + + const $ = (id) => document.getElementById(id); + const qs = (selector, root = document) => root.querySelector(selector); + const qsa = (selector, root = document) => Array.from(root.querySelectorAll(selector)); + + function locale() { + try { + const bridge = window.AstrBotPluginPage; + return bridge?.getLocale?.() || bridge?.getContext?.()?.locale || document.documentElement.lang || "zh-CN"; + } catch (_) { + return document.documentElement.lang || "zh-CN"; + } + } + + function t(key, fallback = "") { + const fullKey = key.startsWith("pages.") || key.startsWith("metadata.") || key.startsWith("config.") + ? key + : `${I18N_ROOT}.${key}`; + try { + const bridge = window.AstrBotPluginPage; + const value = bridge?.t?.(fullKey, fallback); + if (value !== undefined && value !== null && value !== fullKey) return String(value); + } catch (_) {} + return String(fallback || ""); + } + + function configT(path, fallback = "") { + return t(`config.${path}`, fallback); + } + + function applyStaticI18n(root = document) { + document.documentElement.lang = locale(); + qsa("[data-i18n]", root).forEach((el) => { + el.textContent = t(el.dataset.i18n, el.textContent); + }); + qsa("[data-i18n-title]", root).forEach((el) => { + el.setAttribute("title", t(el.dataset.i18nTitle, el.getAttribute("title") || "")); + }); + qsa("[data-i18n-aria-label]", root).forEach((el) => { + el.setAttribute("aria-label", t(el.dataset.i18nAriaLabel, el.getAttribute("aria-label") || "")); + }); + qsa("[data-i18n-placeholder]", root).forEach((el) => { + el.setAttribute("placeholder", t(el.dataset.i18nPlaceholder, el.getAttribute("placeholder") || "")); + }); + } + + function endpoint(path) { + return `page/${String(path || "").replace(/^\/+/, "").replace(/\/+/g, "/")}`; + } + + async function bridgeReady() { + const bridge = window.AstrBotPluginPage; + if (!bridge) { + throw new Error(t("errors.bridgeMissing", "AstrBot 插件页桥接 SDK 未加载")); + } + 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) { + 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 || t("errors.requestFailed", "请求失败")); + } + if (body && body.success === false) { + throw new Error(body.message || body.error || t("errors.requestFailed", "请求失败")); + } + return body || {}; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function escapeAttr(value) { + return escapeHtml(value).replace(/`/g, "`"); + } + + function localNavigationHost(hostname) { + const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1").toLowerCase(); + if (!host) return true; + return host === "localhost" + || host === "0.0.0.0" + || host === "::" + || host === "::1" + || host === "0:0:0:0:0:0:0:0" + || host === "0:0:0:0:0:0:0:1" + || /^127(?:\.\d{1,3}){3}$/.test(host); + } + + function hostForUrl(hostname) { + const host = String(hostname || "").trim().replace(/^\[(.*)\]$/, "$1"); + return host.includes(":") ? `[${host}]` : host; + } + + function resolveHostUrl(value) { + const raw = String(value || "").trim(); + if (!raw || raw === "#") return raw || "#"; + if (raw.startsWith("#")) return raw; + + let parsed; + try { + parsed = new URL(raw, window.location.href); + } catch (_) { + return raw; + } + + if (!/^https?:$/.test(parsed.protocol) || !localNavigationHost(parsed.hostname)) { + return raw; + } + + const browserHost = window.location.hostname; + if (!browserHost) return raw; + const replacementHost = hostForUrl(browserHost); + parsed.host = parsed.port ? `${replacementHost}:${parsed.port}` : replacementHost; + return parsed.href; + } + + function fmt(value, digits = 1) { + const num = Number(value || 0); + if (!Number.isFinite(num)) return "0"; + return new Intl.NumberFormat(locale(), { 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) { + const el = $(id); + if (el) el.textContent = value; + } + + function setHtml(id, html) { + const el = $(id); + if (el) el.innerHTML = html; + } + + function empty(text = t("empty.default", "暂无数据")) { + return `
${escapeHtml(text)}
`; + } + + function pill(text, tone = "") { + return `${escapeHtml(text)}`; + } + + function button(label, attrs = "", cls = "ghost-button") { + return ``; + } + + function normalizeId(id) { + return String(id ?? "").trim(); + } + + function selectedReviewSet(kind) { + if (!state.selectedReviews[kind]) state.selectedReviews[kind] = new Set(); + return state.selectedReviews[kind]; + } + + function selectedReviewIds(kind) { + return Array.from(selectedReviewSet(kind)).filter(Boolean); + } + + function isReviewSelected(kind, id) { + return selectedReviewSet(kind).has(normalizeId(id)); + } + + function isJargonSelected(id) { + return state.selectedJargon.has(normalizeId(id)); + } + + function pruneSelection(selection, validIds) { + const valid = new Set((validIds || []).map(normalizeId).filter(Boolean)); + Array.from(selection).forEach((id) => { + if (!valid.has(id)) selection.delete(id); + }); + } + + function reviewCheckbox(kind, id) { + const safeId = normalizeId(id); + return ``; + } + + function jargonCheckbox(id) { + const safeId = normalizeId(id); + return ``; + } + + function reviewCountText(kind, total) { + const selected = selectedReviewIds(kind).length; + return selected ? `${fmt(selected, 0)}/${fmt(total, 0)}` : fmt(total, 0); + } + + function visibleReviewIds(kind) { + const reviews = state.pageData.reviews || {}; + if (kind === "persona") { + return ((reviews.persona_pending || {}).updates || []) + .filter((item) => item && item.review_source !== "style_learning") + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + if (kind === "style") { + return ((reviews.style_reviews || {}).reviews || []) + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + if (kind === "jargon") { + return (((reviews.jargon_pending || {}).jargon_list) || []) + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + return []; + } + + function stylePageReviewIds() { + const style = state.pageData.style || {}; + return (((style.reviews || {}).reviews) || []) + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + + function jargonPageIds() { + return (state.pageData.lastJargonItems || []) + .map((item) => normalizeId(item.id)) + .filter(Boolean); + } + + function refreshSelectionLabels() { + ["persona", "style", "jargon"].forEach((kind) => { + const ids = visibleReviewIds(kind); + setText(`${kind}-review-count`, reviewCountText(kind, ids.length)); + }); + setText("expression-review-count", t("selection.selectedCount", "已选 {count}").replace("{count}", fmt(selectedReviewIds("style").length, 0))); + const selectedJargon = Array.from(state.selectedJargon).filter(Boolean).length; + const visibleJargon = jargonPageIds().length; + setText("jargon-selection-count", t("selection.selectedOfTotal", "已选 {selected}/{total}") + .replace("{selected}", fmt(selectedJargon, 0)) + .replace("{total}", fmt(visibleJargon, 0))); + } + + function setBusy(label = t("status.loading", "加载中")) { + setText("runtime-status", label); + setText("hero-status", label); + } + + function showToast(message, tone = "ok") { + const region = $("toast-region"); + if (!region) return; + if (state.toastTimer) { + clearTimeout(state.toastTimer); + state.toastTimer = null; + } + region.replaceChildren(); + const el = document.createElement("div"); + el.className = `toast ${tone}`; + const text = document.createElement("span"); + text.textContent = message; + const close = document.createElement("button"); + close.className = "toast-close"; + close.type = "button"; + close.setAttribute("aria-label", t("actions.closeToast", "关闭提示")); + close.textContent = "×"; + close.addEventListener("click", () => { + if (state.toastTimer) clearTimeout(state.toastTimer); + el.remove(); + }); + el.append(text, close); + region.appendChild(el); + state.toastTimer = setTimeout(() => { + el.classList.add("leaving"); + setTimeout(() => { + el.remove(); + if (state.toastTimer) state.toastTimer = null; + }, 220); + }, 3200); + } + + function warningListHtml(warnings) { + const items = Array.isArray(warnings) + ? warnings + .map((message) => String(message).trim()) + .filter(Boolean) + : []; + return items.map((message) => ` +
+ 成本提醒 + ${escapeHtml(message)} +
+ `).join(""); + } + + 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) return; + if (modal.open && typeof modal.close === "function") { + modal.close(); + } + if (typeof modal.showModal === "function") { + try { + modal.showModal(); + return; + } catch (_) {} + } + modal.setAttribute("open", ""); + } + + function closeModal() { + const modal = $("detail-modal"); + if (!modal) return; + if (typeof modal.close === "function") modal.close(); + else modal.removeAttribute("open"); + } + + function showConfirm(title, message, confirmText) { + return new Promise((resolve) => { + const modal = $("detail-modal"); + if (!modal) { resolve(window.confirm(message)); return; } + setText("modal-title", title); + setHtml("modal-body", ` +

${escapeHtml(message)}

+
+ + +
+ `); + const done = (result) => { + if (typeof modal.close === "function") modal.close(); + else modal.removeAttribute("open"); + resolve(result); + }; + $("confirm-ok").addEventListener("click", () => done(true), { once: true }); + $("confirm-cancel").addEventListener("click", () => done(false), { once: true }); + $("modal-close").addEventListener("click", () => done(false), { once: true }); + modal.addEventListener("cancel", (e) => { e.preventDefault(); done(false); }, { once: true }); + if (typeof modal.showModal === "function") { + try { modal.showModal(); return; } catch (_) {} + } + modal.setAttribute("open", ""); + }); + } + + function resolvePageFromHash() { + const raw = window.location.hash.replace(/^#\/?/, ""); + return PAGE_META[raw] ? raw : "home"; + } + + 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", t(meta[0], meta[2])); + setText("page-title", t(meta[1], meta[3])); + loadPageData(next, { force: !!options.force }); + } + + async function loadDashboard(force = false) { + if (state.dashboard && !force) { + renderDashboard(state.dashboard); + return state.dashboard; + } + setBusy(t("status.syncing", "同步中")); + try { + const data = await apiGet("dashboard"); + state.dashboard = data; + state.overview = data.overview || data; + renderDashboard(data); + return data; + } catch (error) { + showToast(error.message || String(error), "error"); + showErrors({ bridge: error.message || String(error) }); + throw error; + } + } + + 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"); + } + } + + async function cached(key, loader, force) { + if (!force && state.pageData[key]) return state.pageData[key]; + setBusy(t("status.loading", "加载中")); + const data = await loader(); + state.pageData[key] = data; + return data; + } + + 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 statusLabel = degraded ? t("status.partial", "部分可用") : t("status.healthy", "运行正常"); + const resolvedDashboardUrl = resolveHostUrl(webui.dashboard_url || ""); + const summary = degraded + ? t("status.degradedSummary", "嵌入式页面已载入,部分服务处于降级状态。") + : t("status.connectedSummary", "已连接官方插件页 API,独立 WebUI: {url}") + .replace("{url}", resolvedDashboardUrl || t("status.notConfigured", "未配置")); + 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 && resolvedDashboardUrl) fullLink.href = resolvedDashboardUrl; + + 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)); + + renderQuickActions(overview.quick_links || []); + renderModuleCards(overview.modules || []); + renderModuleChart(overview.modules || []); + renderIntelligence(overview.metrics || {}); + renderInsights(data); + showErrors(errors); + } + + function renderQuickActions(links) { + const html = links.map((link) => { + const url = resolveHostUrl(link.url || "#"); + const external = /^https?:\/\//.test(String(url || "")); + return ` + ${escapeHtml(link.label || t("actions.entry", "入口"))} + ${escapeHtml(link.description || "")} + `; + }).join(""); + setHtml("quick-actions", html); + } + + function renderModuleCards(modules) { + const html = modules.map((item) => ` +
+
+

${escapeHtml(item.title)}

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

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

+
+ ${escapeHtml(fmt(item.metric))} + ${escapeHtml(item.metric_label || "")} +
+
+ `).join(""); + setHtml("module-card-grid", html || empty()); + } + + function renderModuleChart(modules) { + const maxValue = Math.max(1, ...modules.map((item) => Number(item.metric || 0))); + 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))} +
`; + }).join(""); + setHtml("module-chart", html || empty()); + } + + function renderIntelligence(metrics) { + const score = normalizeScore(metrics.overall_score); + $("intelligence-ring")?.style.setProperty("--value", String(score)); + setText("intelligence-score", fmt(score)); + const dimCount = metrics.dimensions && typeof metrics.dimensions === "object" + ? Object.keys(metrics.dimensions).length + : 0; + setText("metrics-summary", dimCount + ? t("home.metricsSummary", "已有 {count} 个维度参与评估。").replace("{count}", fmt(dimCount, 0)) + : t("home.noMetrics", "智能指标服务暂未产生维度数据。")); + } + + 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", t("insights.databaseDegraded", "数据库处于降级状态"), (overview.runtime || {}).database_error || t("insights.databaseNotReady", "数据库服务未完整启动。"), "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", t("insights.reviewBacklog", "审查队列有积压"), t("insights.reviewBacklogDetail", "当前有 {count} 条学习结果等待确认。").replace("{count}", fmt(totalBacklog, 0)), "reviews"); + } + const score = normalizeScore(((overview.metrics || {}).overall_score)); + if (score > 0 && score < 60) { + push("warn", t("insights.lowScore", "智能评分偏低"), t("insights.lowScoreDetail", "综合评分 {score},建议查看表达样本和学习批次。").replace("{score}", fmt(score)), "metrics"); + } + const health = (monitoring.health || {}).overall; + if (health && health !== "healthy") { + push("warn", t("insights.healthWarn", "健康检查提示异常"), t("insights.healthWarnDetail", "当前健康状态为 {status}。").replace("{status}", health), "monitoring"); + } + const delegation = integrations.delegation || {}; + if (delegation.memory_delegated || delegation.reply_delegated) { + push("ok", t("insights.delegationEnabled", "伴随插件委托已启用"), t("insights.delegationDetail", "记忆委托: {memory},回复委托: {reply}。") + .replace("{memory}", delegation.memory_delegated ? t("state.yes", "是") : t("state.no", "否")) + .replace("{reply}", delegation.reply_delegated ? t("state.yes", "是") : t("state.no", "否")), "integrations"); + } + Object.entries(errors).forEach(([key, value]) => { + push("warn", t("insights.moduleFailed", "模块 {name} 读取失败").replace("{name}", key), String(value), "monitoring"); + }); + if (!items.length) { + push("ok", t("insights.noIssues", "暂无高优先级问题"), t("insights.noIssuesDetail", "核心学习、审查和监控模块均已返回可用数据。"), "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(t("actions.go", "前往"), `data-route-card="${escapeAttr(item.target)}"`)} +
+ `).join(""); + setHtml("ai-insight-list", html); + } + + 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(t("empty.healthChecks", "暂无健康检查数据"))); + + 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 ? t("empty.functionStats", "暂无函数性能数据") : t("empty.debugDisabled", "debug_mode 未启用"))); + showErrors(data.errors || {}); + } + + 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); + } + + function renderJargon(data) { + const stats = data.stats || {}; + setHtml("jargon-stat-grid", statCards([ + [t("jargon.stats.candidates", "候选词"), stats.total_candidates], + [t("jargon.stats.confirmed", "已确认"), stats.confirmed_jargon], + [t("jargon.stats.inferred", "推断完成"), stats.completed_inference], + [t("jargon.stats.activeGroups", "活跃群组"), stats.active_groups], + ])); + const items = ((data.list || {}).jargon_list || []); + pruneSelection(state.selectedJargon, items.map((item) => item.id)); + const html = items.map((item) => ` +
+ ${jargonCheckbox(item.id)} +
+ ${escapeHtml(item.term || item.content || `#${item.id}`)} + ${escapeHtml(item.meaning || item.definition || t("empty.definition", "暂无释义"))} +
+ ${escapeHtml(item.group_id || "global")} + ${pill(item.is_confirmed ? t("jargon.confirmed", "已确认") : t("jargon.pending", "待确认"), item.is_confirmed ? "ok" : "warn")} + ${pill(item.is_global ? t("jargon.global", "全局") : t("jargon.local", "本地"))} +
+ ${button(t("actions.edit", "编辑"), `data-jargon-action="edit" data-id="${escapeAttr(item.id)}"`)} + ${item.is_confirmed ? "" : button(t("actions.confirm", "确认"), `data-jargon-action="approve" data-id="${escapeAttr(item.id)}"`, "solid-button")} + ${item.is_confirmed ? "" : button(t("actions.reject", "驳回"), `data-jargon-action="reject" data-id="${escapeAttr(item.id)}"`)} + ${button(item.is_global ? t("actions.unsetGlobal", "取消全局") : t("actions.setGlobal", "设为全局"), `data-jargon-action="toggle_global" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.delete", "删除"), `data-jargon-action="delete" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+
+ `).join(""); + setHtml("jargon-list", html || empty(t("empty.jargon", "暂无黑话数据"))); + state.pageData.lastJargonItems = items; + state.pageData.currentJargonData = data; + refreshSelectionLabels(); + showErrors(data.errors || {}); + } + + function renderStyle(data) { + const stats = ((data.results || {}).statistics) || {}; + setHtml("style-stat-grid", statCards([ + [t("style.stats.samples", "风格样本"), stats.unique_styles || stats.total_samples], + [t("style.stats.avgConfidence", "平均置信度"), stats.avg_confidence], + [t("style.stats.totalSamples", "总样本"), stats.total_samples], + [t("style.stats.latestUpdate", "最近更新"), stats.latest_update ? t("state.yes", "有") : t("state.none", "无")], + ])); + const patterns = data.patterns || {}; + const patternGroups = [ + [t("style.patterns.emotion", "情绪模式"), patterns.emotion_patterns || []], + [t("style.patterns.language", "语言模式"), patterns.language_patterns || []], + [t("style.patterns.topic", "话题模式"), 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(t("empty.patterns", "暂无模式"))} +
+ `).join("")); + const chartItems = patternGroups.map(([title, list]) => ({ title, metric: (list || []).length, accent: "#4169e1" })); + renderGenericBarChart("style-pattern-chart", chartItems); + const reviews = ((data.reviews || {}).reviews || []); + pruneSelection(selectedReviewSet("style"), reviews.map((item) => item.id)); + setHtml("expression-review-list", reviews.map((item) => styleReviewHtml(item)).join("") || empty(t("empty.styleReviews", "暂无表达审查"))); + state.pageData.lastStyleItems = reviews; + refreshSelectionLabels(); + } + + function renderReviews(data) { + const personaPending = ((data.persona_pending || {}).updates || []) + .filter((item) => item && item.review_source !== "style_learning"); + const personaReviewed = ((data.persona_reviewed || {}).updates || []); + const styleReviews = ((data.style_reviews || {}).reviews || []); + const pendingJargon = (((data.jargon_pending || {}).jargon_list) || []); + pruneSelection(selectedReviewSet("persona"), personaPending.map((item) => item.id)); + pruneSelection(selectedReviewSet("style"), styleReviews.map((item) => item.id)); + pruneSelection(selectedReviewSet("jargon"), pendingJargon.map((item) => item.id)); + setText("persona-review-count", reviewCountText("persona", personaPending.length)); + setText("style-review-count", reviewCountText("style", styleReviews.length)); + setText("jargon-review-count", reviewCountText("jargon", pendingJargon.length)); + setText("reviewed-count", fmt(personaReviewed.length, 0)); + setHtml("persona-review-list", personaPending.map((item) => personaReviewHtml(item)).join("") || empty(t("empty.personaUpdates", "暂无人格更新"))); + setHtml("style-review-list", styleReviews.map((item) => styleReviewHtml(item)).join("") || empty(t("empty.styleReviews", "暂无表达审查"))); + setHtml("jargon-review-list", pendingJargon.map((item) => jargonReviewHtml(item)).join("") || empty(t("empty.jargonCandidates", "暂无黑话候选"))); + state.pageData.lastStyleItems = styleReviews; + 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(t("empty.reviewed", "暂无已审查记录"))); + showErrors(data.errors || {}); + } + + function personaReviewHtml(item) { + const id = item.id; + return `
+ ${reviewCheckbox("persona", id)} +
+ ${escapeHtml(item.update_type || item.review_source || t("reviews.personaUpdates", "人格更新"))} + ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.reason || item.description || "")} +

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

+
+
+ ${button(t("actions.details", "详情"), `data-review-action="detail" data-kind="persona" data-id="${escapeAttr(id)}"`)} + ${button(t("actions.approve", "批准"), `data-review-action="approve" data-kind="persona" data-id="${escapeAttr(id)}"`, "solid-button")} + ${button(t("actions.reject", "拒绝"), `data-review-action="reject" data-kind="persona" data-id="${escapeAttr(id)}"`)} + ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="persona" data-id="${escapeAttr(id)}"`, "danger-button")} +
+
`; + } + + function styleReviewHtml(item) { + return `
+ ${reviewCheckbox("style", item.id)} +
+ ${escapeHtml(item.description || t("style.title", "表达方式学习"))} + ${escapeHtml(item.group_id || "default")} · ${escapeHtml(item.status || "pending")} +

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

+
+
+ ${button(t("actions.edit", "编辑"), `data-style-action="edit" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.details", "详情"), `data-review-action="detail" data-kind="style" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.approve", "批准"), `data-review-action="approve" data-kind="style" data-id="${escapeAttr(item.id)}"`, "solid-button")} + ${button(t("actions.reject", "拒绝"), `data-review-action="reject" data-kind="style" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.delete", "删除"), `data-review-action="delete" data-kind="style" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+
`; + } + + function jargonReviewHtml(item) { + return `
+ ${reviewCheckbox("jargon", item.id)} +
+ ${escapeHtml(item.term || item.content || `#${item.id}`)} + ${escapeHtml(item.group_id || "global")} · ${escapeHtml(t("units.times", "{count} 次").replace("{count}", fmt(item.occurrences || item.count, 0)))} +

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

+
+
+ ${button(t("actions.confirm", "确认"), `data-review-action="approve" data-kind="jargon" data-id="${escapeAttr(item.id)}"`, "solid-button")} + ${button(t("actions.rejectBack", "驳回"), `data-review-action="reject" data-kind="jargon" data-id="${escapeAttr(item.id)}"`)} + ${button(t("actions.delete", "删除"), `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([ + [t("persona.stats.promptLength", "提示词字数"), current.prompt_length], + [t("persona.stats.beginDialogs", "开场对话"), current.begin_dialog_count], + [t("persona.stats.toolCount", "工具数量"), current.tool_count], + [t("persona.stats.currentGroup", "当前群组"), current.group_id || "default"], + ])); + setText("persona-prompt-preview", current.prompt_preview || persona.system_prompt || persona.prompt || t("empty.personaPrompt", "暂无人格提示词")); + + 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(t("actions.edit", "编辑"), `data-persona-action="edit" data-persona-id="${escapeAttr(id)}"`)} + ${button(t("actions.export", "导出"), `data-persona-action="export" data-persona-id="${escapeAttr(id)}"`)} + ${button(t("actions.delete", "删除"), `data-persona-action="delete" data-persona-id="${escapeAttr(id)}"`, "danger-button")} +
+
`; + }).join("") || empty(t("empty.personaList", "暂无人格列表"))); + state.pageData.lastPersonaItems = personas; + + const backups = ((data.backups || {}).backups || []); + setText("persona-backup-count", fmt(backups.length, 0)); + setHtml("persona-backup-list", backups.map((item) => ` +
+
+ ${escapeHtml(item.backup_name || t("persona.backupName", "备份 {id}").replace("{id}", item.id))} + ${escapeHtml(item.reason_short || item.reason || t("empty.noRemark", "无备注"))} +
+ ${escapeHtml(item.group_id || "default")} + ${escapeHtml(item.timestamp || item.created_at || "")} +
+ ${button(t("actions.view", "查看"), `data-persona-action="backup_detail" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`)} + ${button(t("actions.restore", "恢复"), `data-persona-action="backup_restore" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "solid-button")} + ${button(t("actions.delete", "删除"), `data-persona-action="backup_delete" data-id="${escapeAttr(item.id)}" data-group-id="${escapeAttr(item.group_id || "")}"`, "danger-button")} +
+
+ `).join("") || empty(t("empty.personaBackups", "暂无人格备份"))); + 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(t("actions.delete", "删除"), `data-content-action="delete_content" data-bucket="${escapeAttr(state.contentType)}" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+ `).join("") || empty(t("empty.learningContent", "暂无学习内容"))); + + 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(t("actions.delete", "删除"), `data-content-action="delete_batch" data-id="${escapeAttr(item.id)}"`, "danger-button")} +
+ `).join("") || empty(t("empty.batchHistory", "暂无批次历史"))); + showErrors(data.errors || {}); + } + + 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 || []); + settleGraphLayout(state.graph.nodes, state.graph.links, size.width, size.height); + state.graph.dragged = null; + state.graph.hovered = null; + setHtml("graph-stat-grid", statCards([ + [t("graphs.stats.nodes", "节点"), (graph.stats || {}).nodes || state.graph.nodes.length], + [t("graphs.stats.links", "连线"), (graph.stats || {}).links || state.graph.links.length], + [t("graphs.stats.groups", "群组"), (graph.stats || {}).groups || (graph.groups || []).length], + [t("graphs.stats.source", "来源"), 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 || t("graphs.node", "节点"))} + ${escapeHtml(node.detail || "")} +
+ `).join("") || empty(t("empty.graphNodes", "暂无图谱节点"))); + 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 home = graphHomePosition(id, index, total, safeWidth, safeHeight, radius); + return { + ...node, + id, + label: node.name || node.label || id, + radius, + x: home.x, + y: home.y, + homeX: home.x, + homeY: home.y, + vx: 0, + vy: 0, + pinned: false, + }; + } + + function graphHomePosition(id, index, total, width, height, radius) { + const margin = graphNodeMargin(radius); + const centerX = width / 2; + const centerY = height / 2; + const seed = graphStableSeed(id); + const angleOffset = state.graph.type === "knowledge" ? 0.72 : 0; + const angle = index * 2.399963229728653 + angleOffset + seed * 0.0007; + const ring = Math.sqrt((index + 0.5) / Math.max(1, total)); + const spreadX = Math.max(86, (width - margin * 2) * 0.36); + const spreadY = Math.max(72, (height - margin * 2) * 0.34); + return { + x: clamp(centerX + Math.cos(angle) * spreadX * ring, margin, width - margin), + y: clamp(centerY + Math.sin(angle) * spreadY * ring, margin, height - margin), + }; + } + + function settleGraphLayout(nodes, links, width, height) { + if (!nodes.length) return; + const byId = new Map(nodes.map((node) => [String(node.id), node])); + for (let iteration = 0; iteration < 18; iteration += 1) { + links.slice(0, 220).forEach((link) => { + const source = byId.get(String(link.source)); + const target = byId.get(String(link.target)); + if (!source || !target) return; + const dx = target.x - source.x; + const dy = target.y - source.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + const desired = Math.max(78, Math.min(132, Math.min(width, height) * 0.23)); + const adjust = (dist - desired) * 0.0035; + const nx = dx / dist; + const ny = dy / dist; + if (!source.pinned) { + source.x += nx * adjust; + source.y += ny * adjust; + } + if (!target.pinned) { + target.x -= nx * adjust; + target.y -= ny * adjust; + } + }); + + for (let i = 0; i < nodes.length; i += 1) { + for (let j = i + 1; j < Math.min(nodes.length, i + 42); j += 1) { + separateGraphNodes(nodes[i], nodes[j], 0.45); + } + } + + nodes.forEach((node) => { + if (!node.pinned) { + node.x += (node.homeX - node.x) * 0.12; + node.y += (node.homeY - node.y) * 0.12; + } + clampGraphNode(node, width, height); + }); + } + } + + function normalizeGraphLinks(links) { + if (!Array.isArray(links)) return []; + return links.map((link) => ({ + ...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(t("empty.groupChatPlus", "未检测到 Group Chat Plus"))); + } + + function renderIntegrations(data) { + setHtml("integration-cards", (data.dashboards || []).map(integrationCardHtml).join("") || empty(t("empty.integrations", "暂无融合状态"))); + setHtml("integration-warnings", warningListHtml(data.warnings || [])); + const settings = data.settings || {}; + setHtml("integration-settings", Object.entries(settings).map(([key, value]) => ` +
+ ${escapeHtml(key)} + ${escapeHtml(value === true ? t("state.on", "开启") : value === false ? t("state.off", "关闭") : value ?? t("state.unset", "未设置"))} +
+ `).join("") || empty(t("empty.integrationSettings", "暂无融合设置"))); + renderMaiBotImportPreview(data.maibot_learning || null); + } + + function integrationCardHtml(item) { + const dash = item.dashboard || {}; + const url = resolveHostUrl(dash.external_url || dash.official_page_url || dash.url || "#"); + const disabled = !dash.available || !url || url === "#"; + return `
+
+ ${escapeHtml(item.role || "")} +

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

+

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

+
+ ${escapeHtml(dash.label || t("actions.open", "打开"))} + ${escapeHtml((item.dev_api || {}).mode || "")} +
`; + } + + function collectMaiBotPayload() { + const payload = { + maibot_root: $("maibot-root-input")?.value?.trim() || "", + db_path: $("maibot-db-input")?.value?.trim() || "", + memorix_db_path: $("maibot-memorix-input")?.value?.trim() || "", + default_group_id: $("maibot-default-group-input")?.value?.trim() || "global", + import_expressions: Boolean($("maibot-import-expressions")?.checked), + import_jargons: Boolean($("maibot-import-jargons")?.checked), + import_memories: Boolean($("maibot-import-memories")?.checked), + approve_checked_expressions: Boolean($("maibot-approve-checked")?.checked), + }; + if (!payload.maibot_root && !payload.db_path) { + throw new Error(t("maibot.missingPath", "请填写 MaiBot 项目目录或主数据库路径")); + } + return payload; + } + + function renderMaiBotImportPreview(summary) { + const output = $("maibot-import-output"); + if (!output || !summary) return; + const counts = summary.counts || {}; + const breakdown = summary.review_breakdown || {}; + const destinations = summary.destinations || {}; + const lines = []; + if (Object.keys(counts).length) { + lines.push(t("maibot.previewCounts", "预览: 表达 {expressions} · 黑话 {jargons} · 记忆 {memories}") + .replace("{expressions}", fmt(counts.expressions, 0)) + .replace("{jargons}", fmt(counts.jargons, 0)) + .replace("{memories}", fmt(counts.memories, 0))); + } + if (Object.keys(breakdown).length) { + lines.push(t("maibot.importCounts", "导入: 表达审查 {style} · 黑话候选 {jargon} · 记忆审查 {memory}") + .replace("{style}", fmt(breakdown.style_learning_reviews, 0)) + .replace("{jargon}", fmt(breakdown.jargon_candidates, 0)) + .replace("{memory}", fmt(breakdown.persona_memory_reviews, 0))); + } + if (Object.keys(destinations).length) { + lines.push(t("maibot.destinations", "分类去向: 表达 -> {expressions}; 黑话 -> {jargons}; 记忆 -> {memories}") + .replace("{expressions}", destinations.expressions) + .replace("{jargons}", destinations.jargons) + .replace("{memories}", destinations.memories)); + } + output.textContent = `${lines.join("\n")}${lines.length ? "\n\n" : ""}${JSON.stringify(summary, null, 2)}`; + } + + function currentBatchReviewIds(kind) { + const selected = selectedReviewIds(kind); + return selected.length ? selected : visibleReviewIds(kind); + } + + async function handleBatchReviewAction(kind, action) { + const ids = currentBatchReviewIds(kind); + if (!ids.length) { + showToast(t("reviews.noBatchItems", "当前页没有可批量处理的审查项"), "error"); + return; + } + const typeText = { persona: t("reviews.personaUpdates", "人格更新"), style: t("reviews.expressionReviews", "表达审查"), jargon: t("reviews.jargonCandidates", "黑话候选") }[kind] || t("reviews.items", "审查项"); + const actionText = action === "approve" ? t("actions.pass", "通过") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); + const scopeText = selectedReviewIds(kind).length ? t("selection.selected", "选中") : t("selection.currentPage", "当前页"); + if (!await showConfirm(t("reviews.batchConfirmTitle", "批量操作确认"), t("reviews.confirmBatch", "确定批量{action}{scope} {count} 条{type}?") + .replace("{action}", actionText) + .replace("{scope}", scopeText) + .replace("{count}", fmt(ids.length, 0)) + .replace("{type}", typeText), actionText)) return; + + const payload = { + action: action === "delete" + ? kind === "persona" ? "batch_delete" : kind === "style" ? "batch_delete_style" : "batch_delete_jargon" + : kind === "persona" ? "batch_review" : kind === "style" ? "batch_review_style" : "batch_review_jargon", + ids, + decision: action, + }; + const result = await apiPost("reviews/action", payload); + showToast(result.message || t("messages.batchDone", "批量操作完成"), result.success ? "ok" : "error"); + selectedReviewSet(kind).clear(); + state.pageData.reviews = null; + await loadPageData(state.page, { force: true }); + } + + async function runMaiBotImportAction(action) { + const buttonEl = action === "maibot_import" ? $("maibot-import-button") : $("maibot-preview-button"); + const originalLabel = buttonEl?.textContent || ""; + try { + const payload = collectMaiBotPayload(); + if (buttonEl) { + buttonEl.disabled = true; + buttonEl.classList.add("is-busy"); + buttonEl.textContent = action === "maibot_import" ? t("actions.importing", "导入中") : t("actions.previewing", "预览中"); + } + setText("maibot-import-output", t("maibot.reading", "正在读取 MaiBot 学习数据...")); + const result = await apiPost("integrations/action", { action, ...payload }); + const detail = result.preview || result.result || result.payload || result; + renderMaiBotImportPreview(detail); + showToast(result.message || t("maibot.done", "MaiBot 学习数据操作完成"), result.success !== false ? "ok" : "error"); + if (action === "maibot_import") { + state.pageData = {}; + await loadDashboard(true); + } + } catch (error) { + const message = error.message || String(error); + setText("maibot-import-output", message); + showToast(message, "error"); + } finally { + if (buttonEl) { + buttonEl.disabled = false; + buttonEl.classList.remove("is-busy"); + buttonEl.textContent = originalLabel; + } + } + } + + function renderSettings(data) { + const schema = data.schema || {}; + setHtml("settings-warnings", warningListHtml(schema.warnings || data.warnings || [])); + const groups = schema.groups || []; + if (!state.settingsGroup && groups.length) state.settingsGroup = groups[0].key; + setHtml("settings-groups", groups.map((group) => ` + + `).join("") || empty(t("empty.configSchema", "配置 schema 暂不可用"))); + + const active = groups.find((group) => group.key === state.settingsGroup) || groups[0] || { fields: [] }; + setHtml("config-form", (active.fields || []).map(fieldHtml).join("") || empty(t("empty.selectConfigGroup", "请选择配置分组"))); + 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"}`; + const groupKey = field.group_key || activeSettingsGroupKeyForField(field.key); + const label = configT(`${groupKey}.${field.key}.description`, field.label || field.key); + const hint = configT(`${groupKey}.${field.key}.hint`, field.hint || ""); + 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 activeSettingsGroupKeyForField(fieldKey) { + const groups = ((state.pageData.settings || {}).schema || {}).groups || []; + const group = groups.find((item) => (item.fields || []).some((field) => field.key === fieldKey)); + return group?.key || state.settingsGroup || ""; + } + + 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(t("modal.reviewDetails", "审查详情"), `
${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 || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); + selectedReviewSet(kind).delete(normalizeId(id)); + 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(t("modal.editJargon", "编辑黑话"), ` + + + + `); + return; + } + const result = await apiPost("jargon/action", { action, id }); + showToast(result.message || t("messages.actionDone", "操作完成"), result.success ? "ok" : "error"); + state.selectedJargon.delete(normalizeId(id)); + state.pageData = {}; + await loadPageData(state.page, { force: true }); + } + + async function handleJargonBatchAction(action) { + const visibleIds = jargonPageIds(); + if (action === "select_all") { + visibleIds.forEach((id) => state.selectedJargon.add(id)); + renderJargon(state.pageData.currentJargonData || {}); + return; + } + if (action === "clear") { + state.selectedJargon.clear(); + renderJargon(state.pageData.currentJargonData || {}); + return; + } + + const ids = Array.from(state.selectedJargon).filter(Boolean); + if (!ids.length) { + showToast(t("jargon.selectFirst", "请先选择黑话条目"), "error"); + return; + } + const actionText = action === "approve" ? t("actions.confirm", "确认") : action === "reject" ? t("actions.rejectBack", "驳回") : t("actions.delete", "删除"); + if (!await showConfirm(t("jargon.batchConfirmTitle", "批量操作确认"), t("jargon.confirmBatch", "确定批量{action}选中的 {count} 条黑话?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return; + + const result = await apiPost("jargon/action", { + action: action === "delete" ? "batch_delete" : "batch_review", + ids, + decision: action, + }); + showToast(result.message || t("jargon.batchDone", "批量黑话操作完成"), result.success ? "ok" : "error"); + state.selectedJargon.clear(); + state.pageData = {}; + await loadPageData("jargon-learning", { force: true }); + } + + function modalFieldValue(id) { + return $(id)?.value ?? ""; + } + + function parseModalJson(id, fallback) { + const raw = modalFieldValue(id).trim(); + if (!raw) return fallback; + try { + return JSON.parse(raw); + } catch (_) { + return raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); + } + } + + async function handleStyleAction(action, id) { + if (action === "edit") { + const item = (state.pageData.lastStyleItems || []).find((entry) => String(entry.id) === String(id)) || {}; + const patterns = typeof item.learned_patterns === "string" + ? item.learned_patterns + : JSON.stringify(item.learned_patterns || [], null, 2); + showModal(t("modal.editStyle", "编辑表达方式"), ` + + + + + `); + } + } + + async function handleStyleBatchAction(action) { + const visibleIds = stylePageReviewIds(); + const selected = selectedReviewSet("style"); + if (action === "select_all") { + visibleIds.forEach((id) => selected.add(id)); + renderStyle(state.pageData.style || {}); + return; + } + if (action === "clear") { + selected.clear(); + renderStyle(state.pageData.style || {}); + return; + } + + const ids = selectedReviewIds("style"); + if (!ids.length) { + showToast(t("style.selectFirst", "请先选择表达审查项"), "error"); + return; + } + const actionText = action === "approve" ? t("actions.approve", "批准") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除"); + if (!await showConfirm(t("style.batchConfirmTitle", "批量操作确认"), t("style.confirmBatch", "确定批量{action}选中的 {count} 条表达审查?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return; + + const result = await apiPost("style/action", { + action: action === "delete" ? "batch_delete" : "batch_review", + ids, + decision: action, + }); + showToast(result.message || t("style.batchDone", "批量表达审查完成"), result.success ? "ok" : "error"); + selected.clear(); + state.pageData.style = null; + state.pageData.lastStyleItems = []; + await loadPageData("expression-learning", { force: true }); + } + + async function handlePersonaAction(buttonEl) { + const action = buttonEl.dataset.personaAction; + if (action === "edit") { + const personaId = buttonEl.dataset.personaId; + const item = (state.pageData.lastPersonaItems || []).find((entry) => String(entry.persona_id || entry.id || entry.name) === String(personaId)) || {}; + const beginDialogs = JSON.stringify(item.begin_dialogs || [], null, 2); + showModal(t("modal.editPersona", "编辑人格"), ` + + + + + + `); + return; + } + 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" ? t("modal.personaExport", "人格导出") : t("modal.backupDetails", "备份详情"), `
${escapeHtml(JSON.stringify(result.persona || result.backup || result, null, 2))}
`); + return; + } + showToast(result.message || t("messages.actionDone", "操作完成"), 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 || t("messages.actionDone", "操作完成"), 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(t("messages.contextCopied", "巡检上下文已复制")); + } catch (_) { + showModal(t("modal.insightContext", "巡检上下文"), `
${escapeHtml(text)}
`); + } + }); + $("relearn-button")?.addEventListener("click", async () => { + const result = await apiPost("content/action", { action: "relearn", group_id: "default" }); + showToast(result.message || t("messages.relearnSubmitted", "重新学习已提交"), 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 || t("messages.settingsSaved", "设置已保存"), result.success ? "ok" : "error"); + state.pageData.settings = null; + await loadPageData("settings", { force: true }); + }); + $("dependency-install-button")?.addEventListener("click", async () => { + const installButton = $("dependency-install-button"); + const originalLabel = installButton?.textContent || t("actions.manualInstall", "手动安装"); + const settings = state.pageData.settings || {}; + if (installButton) { + installButton.disabled = true; + installButton.classList.add("is-busy"); + installButton.textContent = t("actions.installing", "安装中"); + } + setText("dependency-output", t("settings.installingDeps", "正在调用 pip 安装依赖,请等待命令输出...")); + try { + const result = await apiPost("settings/action", { + action: "install_dependencies", + manual_confirmed: true, + source: settings.manual_dependency_source || "system_settings", + tier: $("dependency-tier")?.value || "full", + pip_mirror: $("pip-mirror-select")?.value || "default", + }); + const detail = result.result || result; + setText("dependency-output", detail.output || detail.message || result.message || t("settings.installDone", "依赖安装任务结束")); + showToast(result.message || detail.message || t("settings.installDone", "依赖安装任务结束"), result.success !== false ? "ok" : "error"); + } catch (error) { + const message = error.message || String(error); + setText("dependency-output", message); + showToast(message, "error"); + } finally { + if (installButton) { + installButton.disabled = false; + installButton.classList.remove("is-busy"); + installButton.textContent = originalLabel; + } + } + }); + $("maibot-preview-button")?.addEventListener("click", () => runMaiBotImportAction("maibot_preview")); + $("maibot-import-button")?.addEventListener("click", () => runMaiBotImportAction("maibot_import")); + + document.addEventListener("click", async (event) => { + const target = event.target.closest("[data-route-card],[data-refresh-page],[data-review-action],[data-batch-review-kind],[data-jargon-action],[data-jargon-batch-action],[data-style-action],[data-style-batch-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.batchReviewKind) await handleBatchReviewAction(target.dataset.batchReviewKind, target.dataset.batchReviewAction || "approve"); + if (target.dataset.jargonAction) await handleJargonAction(target.dataset.jargonAction, target.dataset.id); + if (target.dataset.jargonBatchAction) await handleJargonBatchAction(target.dataset.jargonBatchAction); + if (target.dataset.styleAction) await handleStyleAction(target.dataset.styleAction, target.dataset.id); + if (target.dataset.styleBatchAction) await handleStyleBatchAction(target.dataset.styleBatchAction); + 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 reviewSelect = event.target.closest("[data-review-select-kind]"); + if (reviewSelect) { + const selection = selectedReviewSet(reviewSelect.dataset.reviewSelectKind); + const id = normalizeId(reviewSelect.dataset.reviewSelectId); + if (reviewSelect.checked) selection.add(id); + else selection.delete(id); + refreshSelectionLabels(); + return; + } + const jargonSelect = event.target.closest("[data-jargon-select-id]"); + if (jargonSelect) { + const id = normalizeId(jargonSelect.dataset.jargonSelectId); + if (jargonSelect.checked) state.selectedJargon.add(id); + else state.selectedJargon.delete(id); + refreshSelectionLabels(); + return; + } + 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 || t("messages.jargonUpdated", "黑话已更新"), result.success ? "ok" : "error"); + state.pageData = {}; + await loadPageData("jargon-learning", { force: true }); + }); + + document.addEventListener("click", async (event) => { + const save = event.target.closest("#modal-style-save"); + if (!save) return; + const result = await apiPost("style/action", { + action: "update", + id: save.dataset.id, + description: modalFieldValue("modal-style-description"), + few_shots_content: modalFieldValue("modal-style-few-shots"), + learned_patterns: parseModalJson("modal-style-patterns", []), + }); + closeModal(); + showToast(result.message || t("messages.styleUpdated", "表达方式已更新"), result.success ? "ok" : "error"); + state.pageData.style = null; + state.pageData.lastStyleItems = []; + await loadPageData("expression-learning", { force: true }); + }); + + document.addEventListener("click", async (event) => { + const save = event.target.closest("#modal-persona-save"); + if (!save) return; + const personaId = save.dataset.personaId; + const result = await apiPost("persona/action", { + action: "update", + persona_id: personaId, + persona: { + persona_id: personaId, + name: modalFieldValue("modal-persona-name"), + system_prompt: modalFieldValue("modal-persona-prompt"), + prompt: modalFieldValue("modal-persona-prompt"), + begin_dialogs: parseModalJson("modal-persona-dialogs", []), + }, + }); + closeModal(); + showToast(result.message || t("messages.personaUpdated", "人格已更新"), result.success ? "ok" : "error"); + state.pageData.persona = null; + state.pageData.lastPersonaItems = []; + await loadPageData("persona-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 setI18nFromBridge() { + const rerender = () => { + applyStaticI18n(); + const meta = PAGE_META[state.page] || PAGE_META.home; + setText("page-kicker", t(meta[0], meta[2])); + setText("page-title", t(meta[1], meta[3])); + if (state.dashboard) renderDashboard(state.dashboard); + const data = state.pageData[state.page] || state.pageData[state.page === "expression-learning" ? "style" : state.page]; + if (state.page === "monitoring" && state.pageData.monitoring) renderMonitoring(state.pageData.monitoring); + else if (state.page === "reviews" && state.pageData.reviews) renderReviews(state.pageData.reviews); + else if (state.page === "jargon-learning" && state.pageData.currentJargonData) renderJargon(state.pageData.currentJargonData); + else if (state.page === "expression-learning" && state.pageData.style) renderStyle(state.pageData.style); + else if (state.page === "persona-learning" && state.pageData.persona) renderPersona(state.pageData.persona); + else if (state.page === "content" && state.pageData.content) renderContent(state.pageData.content); + else if (state.page === "graphs" && data) renderGraphs(data); + else if ((state.page === "reply-strategy" || state.page === "integrations") && state.pageData.integrations) { + if (state.page === "reply-strategy") renderReplyStrategy(state.pageData.integrations); + else renderIntegrations(state.pageData.integrations); + } else if (state.page === "settings" && state.pageData.settings) renderSettings(state.pageData.settings); + }; + try { + const bridge = window.AstrBotPluginPage; + if (bridge && bridge.onContextChange) bridge.onContextChange(rerender); + if (bridge && bridge.onContext) bridge.onContext(rerender); + } catch (_) {} + rerender(); + } + + function initSpringMotion() { + const stage = qs(".spring-stage"); + const canvas = $("physics-canvas"); + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return; + if (!stage || !canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + 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; }); + if (!physics.running) { + physics.running = true; + physics.last = performance.now(); + requestAnimationFrame(tickSpringMotion); + } + } + + function tickSpringMotion(now) { + 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.14)"; + ctx.lineWidth = 1.2; + + const core = { x: rect.width / 2, y: rect.height / 2 }; + physics.particles.forEach((point) => { + const own = point.el.getBoundingClientRect(); + const baseX = own.left - rect.left + own.width / 2 - point.x; + const baseY = own.top - rect.top + own.height / 2 - point.y; + let targetX = Math.sin(now / 1350 + point.seed) * 6; + let targetY = Math.cos(now / 1500 + point.seed) * 5; + if (physics.pointer.active) { + const cx = baseX + point.x; + const cy = baseY + point.y; + const dx = cx - physics.pointer.x; + const dy = cy - physics.pointer.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + const force = Math.max(0, 96 - dist) / 96; + targetX += dx / dist * force * 18; + targetY += dy / dist * force * 18; + } + point.vx += (targetX - point.x) * 28 * dt; + point.vy += (targetY - point.y) * 28 * dt; + point.vx *= Math.max(0, 1 - 14 * dt); + point.vy *= Math.max(0, 1 - 14 * dt); + point.x += point.vx * dt * 60; + point.y += point.vy * dt * 60; + const px = baseX + point.x; + const py = baseY + point.y; + ctx.beginPath(); + ctx.moveTo(core.x, core.y); + ctx.quadraticCurveTo((core.x + px) / 2, (core.y + py) / 2 - 8, 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; + + 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"); + }); + + canvas.addEventListener("pointermove", (event) => { + const point = graphPointer(event, canvas); + const drag = state.graph.dragged; + if (drag && drag.pointerId === event.pointerId) { + const min = graphNodeMargin(drag.node.radius || graphNodeRadius(drag.node)); + drag.node.x = clamp(point.x + drag.offsetX, min, state.graph.width - min); + drag.node.y = clamp(point.y + drag.offsetY, min, state.graph.height - min); + drag.node.homeX = drag.node.x; + 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(78, Math.min(132, Math.min(width, height) * 0.23)); + const force = (dist - desired) * GRAPH_LINK_STRENGTH; + if (!source.pinned) { + source.vx += (dx / dist) * force; + source.vy += (dy / dist) * force; + } + if (!target.pinned) { + target.vx -= (dx / dist) * force; + target.vy -= (dy / dist) * force; + } + ctx.strokeStyle = "rgba(100, 116, 139, 0.28)"; + ctx.lineWidth = Math.max(1, Math.min(4, Number(link.value || 1))); + 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) { + separateGraphNodes(nodes[i], nodes[j], 0.022); + } + } + + 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) * GRAPH_HOME_STRENGTH + (cx - node.x) * GRAPH_CENTER_STRENGTH; + node.vy += ((node.homeY || cy) - node.y) * GRAPH_HOME_STRENGTH + (cy - node.y) * GRAPH_CENTER_STRENGTH; + node.vx *= 0.74; + node.vy *= 0.74; + node.x += node.vx; + node.y += node.vy; + } + const radius = node.radius || graphNodeRadius(node); + if (clampGraphNode(node, width, height) && !node.pinned) { + node.vx *= 0.12; + node.vy *= 0.12; + } + const isHovered = state.graph.hovered === node || state.graph.dragged?.node === node; + if (isHovered) { + ctx.fillStyle = "rgba(15, 159, 143, 0.14)"; + 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"; + const label = String(node.name || node.label || "").slice(0, 12); + const labelWidth = ctx.measureText(label).width; + const labelX = clamp(node.x + radius + 4, 6, width - labelWidth - 6); + const labelY = clamp(node.y + 4, 14, height - 6); + ctx.fillText(label, labelX, labelY); + }); + requestAnimationFrame(tickGraph); + } + + 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, index) => { + const radius = node.radius || graphNodeRadius(node); + const min = graphNodeMargin(radius); + const home = graphHomePosition(node.id, index, state.graph.nodes.length, width, height, radius); + node.x = clamp((node.x / oldWidth) * width, min, width - min); + node.y = clamp((node.y / oldHeight) * height, min, height - min); + node.homeX = home.x; + node.homeY = home.y; + if (options.force && !node.pinned) { + node.x = node.x * 0.55 + home.x * 0.45; + node.y = node.y * 0.55 + home.y * 0.45; + } + }); + } + state.graph.width = width; + 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 graphNodeMargin(radius) { + return Math.max(52, radius + GRAPH_SAFE_PADDING); + } + + function clampGraphNode(node, width, height) { + const radius = node.radius || graphNodeRadius(node); + const min = graphNodeMargin(radius); + const nextX = clamp(node.x, min, width - min); + const nextY = clamp(node.y, min, height - min); + const clamped = nextX !== node.x || nextY !== node.y; + node.x = nextX; + node.y = nextY; + return clamped; + } + + function separateGraphNodes(a, b, strength) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.max(1, Math.hypot(dx, dy)); + const minDist = (a.radius || graphNodeRadius(a)) + (b.radius || graphNodeRadius(b)) + 20; + if (dist >= minDist) return; + const shift = (minDist - dist) / minDist * strength; + const nx = dx / dist; + const ny = dy / dist; + if (!a.pinned) { + a.vx -= nx * shift; + a.vy -= ny * shift; + a.x -= nx * shift * 6; + a.y -= ny * shift * 6; + } + if (!b.pinned) { + b.vx += nx * shift; + b.vy += ny * shift; + b.x += nx * shift * 6; + b.y += ny * shift * 6; + } + } + + function graphStableSeed(value) { + let hash = 0; + const text = String(value || ""); + for (let index = 0; index < text.length; index += 1) { + hash = (hash * 31 + text.charCodeAt(index)) >>> 0; + } + return hash % 997; + } + + function graphValueKey(value) { + if (value && typeof value === "object") { + return String(value.id ?? value.name ?? value.label ?? ""); + } + return String(value ?? ""); + } + + function clamp(value, min, max) { + if (max < min) return min; + return Math.max(min, Math.min(max, value)); + } + + async function init() { + setThemeFromBridge(); + bindEvents(); + initSpringMotion(); + try { + await bridgeReady(); + setI18nFromBridge(); + navigateToPage(resolvePageFromHash(), { skipHash: true, force: true }); + } catch (error) { + showToast(error.message || String(error), "error"); + setText("runtime-status", t("status.bridgeFailed", "桥接失败")); + setText("runtime-summary", error.message || String(error)); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); From 3b7dcf7c8e424e0e2e3bafe4bae1ac3625f9ba7b Mon Sep 17 00:00:00 2001 From: YumemiAI <71859504+YumemiDream@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:55:37 +0800 Subject: [PATCH 4/5] fix: increase jargon edit textarea height from 4 to 10 rows --- pages/dashboard/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/dashboard/app.js b/pages/dashboard/app.js index 9742320c..3116c60a 100644 --- a/pages/dashboard/app.js +++ b/pages/dashboard/app.js @@ -1302,7 +1302,7 @@ const item = (state.pageData.lastJargonItems || []).find((entry) => String(entry.id) === String(id)) || {}; showModal(t("modal.editJargon", "编辑黑话"), ` - + `); return; From 69e7f2fb7cb79bb194cb465bc7da667a5e92fdfe Mon Sep 17 00:00:00 2001 From: YumemiAI <71859504+YumemiDream@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:58:53 +0800 Subject: [PATCH 5/5] fix: widen modal from 760px to 900px for better editing experience --- pages/dashboard/styles.css | 2620 ++++++++++++++++++------------------ 1 file changed, 1310 insertions(+), 1310 deletions(-) diff --git a/pages/dashboard/styles.css b/pages/dashboard/styles.css index 9d5a301e..e7b90af9 100644 --- a/pages/dashboard/styles.css +++ b/pages/dashboard/styles.css @@ -1,1310 +1,1310 @@ -:root { - --bg: #f5f7fb; - --surface: rgba(255, 255, 255, 0.86); - --surface-strong: #ffffff; - --surface-muted: #eef2f7; - --border: rgba(15, 23, 42, 0.11); - --text: #162033; - --muted: #687386; - --primary: #4169e1; - --teal: #0f9f8f; - --amber: #d97706; - --rose: #e11d48; - --green: #16a34a; - --shadow: 0 18px 44px rgba(15, 23, 42, 0.09); - --spring: cubic-bezier(0.22, 1, 0.36, 1); - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -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: #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(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; -} - -button { - color: inherit; -} - -.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; -} - -.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 3px; - color: var(--muted); - 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: 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; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.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; -} - -.compact-actions { - justify-content: flex-end; - gap: 6px; -} - -.compact-actions .ghost-button, -.compact-actions .danger-button { - min-height: 28px; - padding: 0 8px; - font-size: 12px; -} - -.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; - background: var(--surface); - color: var(--text); - text-decoration: none; - cursor: pointer; - transition: transform 160ms var(--spring), border-color 160ms ease, background 160ms ease, opacity 160ms 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); -} - -.danger-button { - color: #ffffff; - border-color: var(--rose); - background: var(--rose); -} - -.disabled { - pointer-events: none; - opacity: 0.55; -} - -button:disabled, -button.is-busy { - cursor: wait; - opacity: 0.64; - transform: none; -} - -.icon-button:hover, -.ghost-button:hover, -.solid-button:hover, -.danger-button:hover, -.quick-entry:hover, -.settings-group:hover { - transform: translateY(-1px); -} - -.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-pill.warn, -.mini-badge.warn { - color: #9a3412; - background: rgba(217, 119, 6, 0.15); -} - -.mini-badge.ok { - color: #166534; - background: rgba(22, 163, 74, 0.13); -} - -.page { - display: none; - animation: pageIn 190ms var(--spring); -} - -.page.active { - display: block; -} - -@keyframes pageIn { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.dashboard-hero { - display: grid; - grid-template-columns: minmax(320px, 1.05fr) minmax(280px, 0.95fr); - gap: 14px; - min-height: 292px; - margin-bottom: 14px; -} - -.spring-stage { - position: relative; - min-height: 292px; - overflow: hidden; - 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); -} - -#physics-canvas { - position: absolute; - inset: 0; -} - -.spring-node { - position: absolute; - width: 32px; - height: 32px; - border-radius: 8px; - background: var(--primary); - box-shadow: 0 16px 32px rgba(65, 105, 225, 0.28); - will-change: transform; -} - -.node-core { - left: 50%; - top: 50%; - width: 72px; - height: 72px; - margin: -36px 0 0 -36px; - background: linear-gradient(145deg, var(--primary), var(--teal)); -} - -.node-a { - left: 22%; - top: 28%; - background: var(--teal); -} - -.node-b { - left: 68%; - top: 22%; - background: var(--amber); -} - -.node-c { - left: 72%; - top: 70%; - background: var(--rose); -} - -.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); -} - -.hero-copy { - display: flex; - flex-direction: column; - justify-content: center; - padding: 24px; -} - -.hero-copy h3 { - margin: 14px 0 9px; - font-size: 28px; - 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; -} - -.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); - font-size: 12px; -} - -.stat-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 14px; -} - -.stat-grid.compact { - grid-template-columns: repeat(4, minmax(120px, 1fr)); -} - -.stat-card { - min-height: 96px; - padding: 14px; - background: var(--surface-strong); -} - -.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-card strong { - display: block; - margin-top: 8px; - font-size: 27px; - letter-spacing: 0; -} - -.home-grid, -.content-grid, -.graph-grid, -.settings-grid { - display: grid; - gap: 14px; -} - -.home-grid { - grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); -} - -.content-grid.two-col { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.graph-grid { - grid-template-columns: minmax(0, 1fr) 360px; -} - -.settings-grid { - grid-template-columns: 300px minmax(0, 1fr); - margin-bottom: 14px; -} - -.settings-warning-list { - display: grid; - gap: 8px; - margin: 0 0 14px; -} - -.settings-warning { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 10px; - align-items: start; - padding: 10px 12px; - border: 1px solid rgba(217, 119, 6, 0.28); - border-radius: 8px; - color: #92400e; - background: rgba(217, 119, 6, 0.10); -} - -.settings-warning strong { - white-space: nowrap; - font-size: 12px; -} - -.settings-warning span { - min-width: 0; - overflow-wrap: anywhere; - font-size: 12px; - line-height: 1.55; -} - -html[data-theme="dark"] .settings-warning { - color: #fbbf24; - background: rgba(217, 119, 6, 0.16); -} - -.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)); -} - -.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; - border-left: 4px solid var(--accent, var(--primary)); - transition: transform 180ms var(--spring), border-color 180ms ease; -} - -.module-card:hover { - transform: translateY(-1px); -} - -.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: 46px; - margin-bottom: 12px; -} - -.metric-line { - display: flex; - align-items: end; - justify-content: space-between; - gap: 12px; -} - -.metric-line strong { - font-size: 25px; -} - -.metric-line span { - color: var(--muted); - font-size: 12px; - font-weight: 700; -} - -.bar-chart { - display: grid; - gap: 10px; -} - -.bar-row { - display: grid; - grid-template-columns: 84px minmax(0, 1fr) 48px; - align-items: center; - gap: 10px; - color: var(--muted); - font-size: 12px; -} - -.bar-track { - height: 10px; - overflow: hidden; - border-radius: 999px; - background: color-mix(in srgb, var(--muted), transparent 84%); -} - -.bar-fill { - width: calc(var(--value, 0) * 1%); - height: 100%; - border-radius: inherit; - background: var(--accent, var(--primary)); - transition: width 520ms var(--spring); -} - -.ring-row { - display: flex; - align-items: center; - gap: 14px; - margin-top: 18px; - padding: 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-strong); -} - -.ring-chart { - 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) 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: 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); -} - -.health-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.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 { - min-width: 0; - display: grid; - gap: 8px; -} - -.review-item, -.content-item, -.table-row, -.config-field, -.pattern-column { - min-width: 0; - 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-item.selectable { - grid-template-columns: auto minmax(0, 1fr); - align-items: start; -} - -.review-item.selectable .row-actions { - grid-column: 2; -} - -.review-main p, -.content-item p { - margin-bottom: 0; - overflow-wrap: anywhere; -} - -.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 minmax(118px, auto); -} - -.table-row.selectable-row { - grid-template-columns: auto minmax(180px, 1fr) auto auto auto minmax(118px, auto); -} - -.table-row > *, -.rich-row > *, -.config-field > *, -.panel > * { - min-width: 0; -} - -.table-row span, -.table-row strong, -.table-row small, -.rich-row span, -.rich-row strong, -.rich-row small { - overflow-wrap: anywhere; -} - -.row-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; -} - -.select-cell { - width: 22px; - min-width: 22px; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.select-cell input { - width: 16px; - min-height: 16px; - padding: 0; - margin: 0; - cursor: pointer; -} - -.batch-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; -} - -.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); -} - -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; - overflow-wrap: anywhere; - word-break: break-word; -} - -.graph-panel { - container-type: inline-size; - padding: 10px; - overflow: hidden; -} - -#graph-canvas { - width: 100%; - height: clamp(320px, 56.25cqw, 520px); - aspect-ratio: 16 / 9; - max-height: 58vh; - min-height: 320px; - display: block; - 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; -} - -@supports not (height: 1cqw) { - #graph-canvas { - height: clamp(320px, 48vw, 520px); - } -} - -.persona-layout, -.persona-state-panel, -.persona-list-panel, -.persona-backup-panel { - min-width: 0; -} - -.persona-layout { - grid-template-columns: minmax(0, 1.08fr) minmax(260px, 0.92fr); -} - -.persona-state-panel .stat-grid.compact { - grid-template-columns: repeat(2, minmax(120px, 1fr)); -} - -.persona-state-panel .code-preview { - max-height: min(42vh, 420px); -} - -.persona-list-panel .table-row, -.persona-backup-panel .table-row { - grid-template-columns: minmax(0, 1fr); - align-items: start; -} - -.persona-list-panel .row-actions, -.persona-backup-panel .row-actions { - justify-content: flex-start; -} - -#graph-canvas.has-hover { - cursor: grab; -} - -#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; -} - -.maibot-import-panel { - margin-bottom: 14px; -} - -.maibot-import-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - margin-top: 12px; -} - -.maibot-import-panel .config-field { - align-items: center; -} - -.toggle-row { - display: flex; - flex-wrap: wrap; - gap: 10px; - margin: 12px 0; -} - -.toggle-row label { - min-height: 34px; - display: inline-flex; - align-items: center; - gap: 7px; - padding: 0 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-strong); - color: var(--muted); - font-size: 13px; - font-weight: 700; -} - -.maibot-import-panel .code-preview { - min-height: 136px; -} - -.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(217, 119, 6, 0.12); -} - -.toast-region { - position: fixed; - right: 18px; - top: 18px; - z-index: 20; - display: grid; - gap: 8px; -} - -.toast { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - 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-close { - width: 24px; - height: 24px; - padding: 0; - border: 0; - color: var(--muted); - background: transparent; - line-height: 1; - cursor: pointer; -} - -.toast-close:hover { - color: var(--text); -} - -.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: 1120px) { - .app-shell { - grid-template-columns: 1fr; - } - - .sidebar { - position: relative; - height: auto; - border-right: 0; - border-bottom: 1px solid var(--border); - } - - .nav-list { - grid-auto-flow: column; - grid-auto-columns: max-content; - overflow-x: auto; - overflow-y: hidden; - } - - .workspace { - width: min(100vw, 1280px); - } - - .sidebar-footer { - display: none; - } - - .review-layout, - .integration-cards, - .settings-grid, - .maibot-import-grid, - .graph-grid { - grid-template-columns: 1fr; - } -} - -@media (max-width: 860px) { - .workspace { - padding: 16px 14px 28px; - } - - .dashboard-hero, - .home-grid, - .content-grid.two-col { - grid-template-columns: 1fr; - } - - .stat-grid, - .stat-grid.compact, - .module-card-grid, - .insight-grid, - .pattern-columns, - .health-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .toolbar-panel { - grid-template-columns: 1fr 1fr; - } - - .table-row, - .rich-row, - .config-field { - grid-template-columns: 1fr; - } - - .row-actions { - justify-content: flex-start; - } - - #graph-canvas { - min-height: 300px; - max-height: none; - } -} - -@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 { - min-height: 220px; - } - - .hero-copy h3 { - font-size: 24px; - } -} - -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - animation-duration: 1ms !important; - animation-iteration-count: 1 !important; - transition-duration: 1ms !important; - } - - .spring-node { - will-change: auto; - transform: none !important; - } -} +:root { + --bg: #f5f7fb; + --surface: rgba(255, 255, 255, 0.86); + --surface-strong: #ffffff; + --surface-muted: #eef2f7; + --border: rgba(15, 23, 42, 0.11); + --text: #162033; + --muted: #687386; + --primary: #4169e1; + --teal: #0f9f8f; + --amber: #d97706; + --rose: #e11d48; + --green: #16a34a; + --shadow: 0 18px 44px rgba(15, 23, 42, 0.09); + --spring: cubic-bezier(0.22, 1, 0.36, 1); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +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: #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(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; +} + +button { + color: inherit; +} + +.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; +} + +.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 3px; + color: var(--muted); + 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: 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; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.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; +} + +.compact-actions { + justify-content: flex-end; + gap: 6px; +} + +.compact-actions .ghost-button, +.compact-actions .danger-button { + min-height: 28px; + padding: 0 8px; + font-size: 12px; +} + +.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; + background: var(--surface); + color: var(--text); + text-decoration: none; + cursor: pointer; + transition: transform 160ms var(--spring), border-color 160ms ease, background 160ms ease, opacity 160ms 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); +} + +.danger-button { + color: #ffffff; + border-color: var(--rose); + background: var(--rose); +} + +.disabled { + pointer-events: none; + opacity: 0.55; +} + +button:disabled, +button.is-busy { + cursor: wait; + opacity: 0.64; + transform: none; +} + +.icon-button:hover, +.ghost-button:hover, +.solid-button:hover, +.danger-button:hover, +.quick-entry:hover, +.settings-group:hover { + transform: translateY(-1px); +} + +.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-pill.warn, +.mini-badge.warn { + color: #9a3412; + background: rgba(217, 119, 6, 0.15); +} + +.mini-badge.ok { + color: #166534; + background: rgba(22, 163, 74, 0.13); +} + +.page { + display: none; + animation: pageIn 190ms var(--spring); +} + +.page.active { + display: block; +} + +@keyframes pageIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dashboard-hero { + display: grid; + grid-template-columns: minmax(320px, 1.05fr) minmax(280px, 0.95fr); + gap: 14px; + min-height: 292px; + margin-bottom: 14px; +} + +.spring-stage { + position: relative; + min-height: 292px; + overflow: hidden; + 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); +} + +#physics-canvas { + position: absolute; + inset: 0; +} + +.spring-node { + position: absolute; + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--primary); + box-shadow: 0 16px 32px rgba(65, 105, 225, 0.28); + will-change: transform; +} + +.node-core { + left: 50%; + top: 50%; + width: 72px; + height: 72px; + margin: -36px 0 0 -36px; + background: linear-gradient(145deg, var(--primary), var(--teal)); +} + +.node-a { + left: 22%; + top: 28%; + background: var(--teal); +} + +.node-b { + left: 68%; + top: 22%; + background: var(--amber); +} + +.node-c { + left: 72%; + top: 70%; + background: var(--rose); +} + +.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); +} + +.hero-copy { + display: flex; + flex-direction: column; + justify-content: center; + padding: 24px; +} + +.hero-copy h3 { + margin: 14px 0 9px; + font-size: 28px; + 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; +} + +.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); + font-size: 12px; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.stat-grid.compact { + grid-template-columns: repeat(4, minmax(120px, 1fr)); +} + +.stat-card { + min-height: 96px; + padding: 14px; + background: var(--surface-strong); +} + +.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-card strong { + display: block; + margin-top: 8px; + font-size: 27px; + letter-spacing: 0; +} + +.home-grid, +.content-grid, +.graph-grid, +.settings-grid { + display: grid; + gap: 14px; +} + +.home-grid { + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); +} + +.content-grid.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.graph-grid { + grid-template-columns: minmax(0, 1fr) 360px; +} + +.settings-grid { + grid-template-columns: 300px minmax(0, 1fr); + margin-bottom: 14px; +} + +.settings-warning-list { + display: grid; + gap: 8px; + margin: 0 0 14px; +} + +.settings-warning { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 10px 12px; + border: 1px solid rgba(217, 119, 6, 0.28); + border-radius: 8px; + color: #92400e; + background: rgba(217, 119, 6, 0.10); +} + +.settings-warning strong { + white-space: nowrap; + font-size: 12px; +} + +.settings-warning span { + min-width: 0; + overflow-wrap: anywhere; + font-size: 12px; + line-height: 1.55; +} + +html[data-theme="dark"] .settings-warning { + color: #fbbf24; + background: rgba(217, 119, 6, 0.16); +} + +.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)); +} + +.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; + border-left: 4px solid var(--accent, var(--primary)); + transition: transform 180ms var(--spring), border-color 180ms ease; +} + +.module-card:hover { + transform: translateY(-1px); +} + +.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: 46px; + margin-bottom: 12px; +} + +.metric-line { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; +} + +.metric-line strong { + font-size: 25px; +} + +.metric-line span { + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.bar-chart { + display: grid; + gap: 10px; +} + +.bar-row { + display: grid; + grid-template-columns: 84px minmax(0, 1fr) 48px; + align-items: center; + gap: 10px; + color: var(--muted); + font-size: 12px; +} + +.bar-track { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--muted), transparent 84%); +} + +.bar-fill { + width: calc(var(--value, 0) * 1%); + height: 100%; + border-radius: inherit; + background: var(--accent, var(--primary)); + transition: width 520ms var(--spring); +} + +.ring-row { + display: flex; + align-items: center; + gap: 14px; + margin-top: 18px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); +} + +.ring-chart { + 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) 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: 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); +} + +.health-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.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 { + min-width: 0; + display: grid; + gap: 8px; +} + +.review-item, +.content-item, +.table-row, +.config-field, +.pattern-column { + min-width: 0; + 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-item.selectable { + grid-template-columns: auto minmax(0, 1fr); + align-items: start; +} + +.review-item.selectable .row-actions { + grid-column: 2; +} + +.review-main p, +.content-item p { + margin-bottom: 0; + overflow-wrap: anywhere; +} + +.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 minmax(118px, auto); +} + +.table-row.selectable-row { + grid-template-columns: auto minmax(180px, 1fr) auto auto auto minmax(118px, auto); +} + +.table-row > *, +.rich-row > *, +.config-field > *, +.panel > * { + min-width: 0; +} + +.table-row span, +.table-row strong, +.table-row small, +.rich-row span, +.rich-row strong, +.rich-row small { + overflow-wrap: anywhere; +} + +.row-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.select-cell { + width: 22px; + min-width: 22px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.select-cell input { + width: 16px; + min-height: 16px; + padding: 0; + margin: 0; + cursor: pointer; +} + +.batch-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.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); +} + +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; + overflow-wrap: anywhere; + word-break: break-word; +} + +.graph-panel { + container-type: inline-size; + padding: 10px; + overflow: hidden; +} + +#graph-canvas { + width: 100%; + height: clamp(320px, 56.25cqw, 520px); + aspect-ratio: 16 / 9; + max-height: 58vh; + min-height: 320px; + display: block; + 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; +} + +@supports not (height: 1cqw) { + #graph-canvas { + height: clamp(320px, 48vw, 520px); + } +} + +.persona-layout, +.persona-state-panel, +.persona-list-panel, +.persona-backup-panel { + min-width: 0; +} + +.persona-layout { + grid-template-columns: minmax(0, 1.08fr) minmax(260px, 0.92fr); +} + +.persona-state-panel .stat-grid.compact { + grid-template-columns: repeat(2, minmax(120px, 1fr)); +} + +.persona-state-panel .code-preview { + max-height: min(42vh, 420px); +} + +.persona-list-panel .table-row, +.persona-backup-panel .table-row { + grid-template-columns: minmax(0, 1fr); + align-items: start; +} + +.persona-list-panel .row-actions, +.persona-backup-panel .row-actions { + justify-content: flex-start; +} + +#graph-canvas.has-hover { + cursor: grab; +} + +#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; +} + +.maibot-import-panel { + margin-bottom: 14px; +} + +.maibot-import-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.maibot-import-panel .config-field { + align-items: center; +} + +.toggle-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 12px 0; +} + +.toggle-row label { + min-height: 34px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-strong); + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.maibot-import-panel .code-preview { + min-height: 136px; +} + +.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(217, 119, 6, 0.12); +} + +.toast-region { + position: fixed; + right: 18px; + top: 18px; + z-index: 20; + display: grid; + gap: 8px; +} + +.toast { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + 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-close { + width: 24px; + height: 24px; + padding: 0; + border: 0; + color: var(--muted); + background: transparent; + line-height: 1; + cursor: pointer; +} + +.toast-close:hover { + color: var(--text); +} + +.toast.error { + color: #991b1b; +} + +.toast.leaving { + opacity: 0; + transform: translateY(-4px); +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateY(-6px); + } +} + +.modal { + width: min(900px, 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: 1120px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: relative; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .nav-list { + grid-auto-flow: column; + grid-auto-columns: max-content; + overflow-x: auto; + overflow-y: hidden; + } + + .workspace { + width: min(100vw, 1280px); + } + + .sidebar-footer { + display: none; + } + + .review-layout, + .integration-cards, + .settings-grid, + .maibot-import-grid, + .graph-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 860px) { + .workspace { + padding: 16px 14px 28px; + } + + .dashboard-hero, + .home-grid, + .content-grid.two-col { + grid-template-columns: 1fr; + } + + .stat-grid, + .stat-grid.compact, + .module-card-grid, + .insight-grid, + .pattern-columns, + .health-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .toolbar-panel { + grid-template-columns: 1fr 1fr; + } + + .table-row, + .rich-row, + .config-field { + grid-template-columns: 1fr; + } + + .row-actions { + justify-content: flex-start; + } + + #graph-canvas { + min-height: 300px; + max-height: none; + } +} + +@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 { + min-height: 220px; + } + + .hero-copy h3 { + font-size: 24px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 1ms !important; + animation-iteration-count: 1 !important; + transition-duration: 1ms !important; + } + + .spring-node { + will-change: auto; + transform: none !important; + } +}