diff --git a/_conf_schema.json b/_conf_schema.json index ca2e719b..7a4fee1f 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -62,6 +62,19 @@ "hint": "是否启用Web界面用于查看和管理学习数据", "default": true }, + "enable_webui_password": { + "description": "启用WebUI登录密码", + "type": "bool", + "hint": "默认关闭,WebUI 免密访问;开启后访问 WebUI 需要登录密码。首次开启前请填写下方一次性初始密码,或设置环境变量 ASTRBOT_WEBUI_INITIAL_PASSWORD", + "default": false + }, + "webui_initial_password": { + "description": "WebUI一次性初始密码", + "type": "string", + "hint": "仅在首次启用 WebUI 登录密码且尚未生成 password.json 时使用。保存后会写入哈希密码并清空,不会回显明文", + "default": "", + "_secret": true + }, "web_interface_port": { "description": "Web界面端口", "type": "int", @@ -164,6 +177,12 @@ "hint": "每个群每新增多少条原始消息才触发表达方式学习。调大可降低筛选/分析模型调用频率", "default": 10 }, + "expression_learning_min_interval_seconds": { + "description": "表达方式学习最小间隔(秒)", + "type": "int", + "hint": "同一群两次表达方式学习之间的最小间隔。默认3600秒,避免实时模式下频繁生成审查/人格更新", + "default": 3600 + }, "topic_detection_interval_messages": { "description": "话题检测触发消息数", "type": "int", diff --git a/config.py b/config.py index 16f0de7a..14bd3474 100644 --- a/config.py +++ b/config.py @@ -68,12 +68,15 @@ class PluginConfig(BaseModel): enable_jargon_learning: bool = True # 启用黑话学习 enable_style_learning: bool = True # 启用对话风格学习 enable_web_interface: bool = True + enable_webui_password: bool = False # 启用 WebUI 登录密码,默认免密 + webui_initial_password: str = "" # WebUI 密码首次启用时的一次性初始密码 web_interface_port: int = 7833 # 新增 Web 界面端口配置 web_interface_host: str = "0.0.0.0" # Web 界面监听地址 # MaiBot增强功能(默认启用) enable_maibot_features: bool = True # 启用MaiBot增强功能 enable_expression_patterns: bool = True # 启用表达模式学习 + enable_realtime_expression_learning: bool = False # 实时学习关闭时是否仍按消息触发表达学习 enable_memory_graph: bool = True # 启用记忆图系统 enable_knowledge_graph: bool = True # 启用知识图谱 enable_time_decay: bool = True # 启用时间衰减机制 @@ -119,6 +122,7 @@ class PluginConfig(BaseModel): min_messages_for_learning: int = 50 # 最少消息数量才开始学习 max_messages_per_batch: int = 200 # 每批处理的最大消息数量 expression_learning_trigger_messages: int = 10 # 表达方式学习触发消息增量 + expression_learning_min_interval_seconds: int = 3600 # 表达方式学习最小触发间隔(秒) topic_detection_interval_messages: int = 10 # 话题检测触发消息增量 # 筛选参数 @@ -155,6 +159,7 @@ class PluginConfig(BaseModel): shutdown_step_timeout: int = 8 # 每个关停步骤的超时 task_cancel_timeout: int = 3 # 后台任务取消等待超时 service_stop_timeout: int = 5 # 单个服务停止超时 + enable_llm_hooks: bool = False # 启用 LLM Hook 上下文注入,默认关闭以避免高频调用 llm_hook_context_timeout: float = 3.0 # LLM Hook 单个上下文源超时(秒) # PersonaUpdater配置 @@ -342,11 +347,14 @@ def create_from_config(cls, config: dict, data_dir: Optional[str] = None) -> 'Pl enable_jargon_learning=basic_settings.get('enable_jargon_learning', True), enable_style_learning=basic_settings.get('enable_style_learning', True), enable_web_interface=basic_settings.get('enable_web_interface', True), + enable_webui_password=basic_settings.get('enable_webui_password', False), + webui_initial_password=basic_settings.get('webui_initial_password', ''), web_interface_port=basic_settings.get('web_interface_port', 7833), web_interface_host=basic_settings.get('web_interface_host', '0.0.0.0'), enable_maibot_features=maibot_enhancement.get('enable_maibot_features', True), enable_expression_patterns=maibot_enhancement.get('enable_expression_patterns', True), + enable_realtime_expression_learning=maibot_enhancement.get('enable_realtime_expression_learning', False), enable_memory_graph=maibot_enhancement.get('enable_memory_graph', True), enable_knowledge_graph=maibot_enhancement.get('enable_knowledge_graph', True), enable_time_decay=maibot_enhancement.get('enable_time_decay', True), @@ -383,6 +391,7 @@ def create_from_config(cls, config: dict, data_dir: Optional[str] = None) -> 'Pl min_messages_for_learning=learning_params.get('min_messages_for_learning', 50), max_messages_per_batch=learning_params.get('max_messages_per_batch', 200), expression_learning_trigger_messages=learning_params.get('expression_learning_trigger_messages', 10), + expression_learning_min_interval_seconds=learning_params.get('expression_learning_min_interval_seconds', 3600), topic_detection_interval_messages=learning_params.get('topic_detection_interval_messages', 10), message_min_length=filter_params.get('message_min_length', 5), @@ -483,6 +492,7 @@ def create_from_config(cls, config: dict, data_dir: Optional[str] = None) -> 'Pl shutdown_step_timeout=runtime_internal_settings.get('shutdown_step_timeout', 8), task_cancel_timeout=runtime_internal_settings.get('task_cancel_timeout', 3), service_stop_timeout=runtime_internal_settings.get('service_stop_timeout', 5), + enable_llm_hooks=runtime_internal_settings.get('enable_llm_hooks', False), llm_hook_context_timeout=float(runtime_internal_settings.get('llm_hook_context_timeout', 3.0)), llm_hook_injection_target=runtime_internal_settings.get( 'llm_hook_injection_target', @@ -617,6 +627,9 @@ def validate_config(self) -> List[str]: if self.expression_learning_trigger_messages <= 0: errors.append("表达方式学习触发消息数必须大于0") + if self.expression_learning_min_interval_seconds < 0: + errors.append("表达方式学习最小触发间隔不能小于0秒") + if self.topic_detection_interval_messages <= 0: errors.append("话题检测触发消息数必须大于0") diff --git a/services/hooks/llm_hook_handler.py b/services/hooks/llm_hook_handler.py index 35761938..dfd63f3e 100644 --- a/services/hooks/llm_hook_handler.py +++ b/services/hooks/llm_hook_handler.py @@ -76,6 +76,10 @@ async def handle(self, event: AstrMessageEvent, req: Any) -> None: logger.warning("[LLM Hook] req 参数为 None,跳过注入") return + if not getattr(self._config, "enable_llm_hooks", False): + logger.debug("[LLM Hook] 总开关未启用,跳过上下文注入") + return + if not self._diversity_manager: logger.debug("[LLM Hook] diversity_manager未初始化,跳过多样性注入") return diff --git a/services/learning/message_pipeline.py b/services/learning/message_pipeline.py index da695f03..3a22e5ca 100644 --- a/services/learning/message_pipeline.py +++ b/services/learning/message_pipeline.py @@ -158,7 +158,7 @@ async def process_learning( group_id, message_text, sender_id ) ) - elif self._config.enable_expression_patterns: + elif getattr(self._config, "enable_realtime_expression_learning", False): self._spawn( self._realtime_processor.process_expression_learning_background( group_id, message_text, sender_id diff --git a/services/learning/realtime_processor.py b/services/learning/realtime_processor.py index ab9e5ed1..f4fcb643 100644 --- a/services/learning/realtime_processor.py +++ b/services/learning/realtime_processor.py @@ -57,6 +57,7 @@ def __init__( self._db_manager = db_manager self._expression_learner = None # lazily resolved, cached self._last_expression_trigger_counts: Dict[str, int] = {} + self._last_expression_learning_times: Dict[str, float] = {} # Callback set by the plugin to trigger incremental prompt updates self.update_system_prompt_callback: Optional[ @@ -165,6 +166,27 @@ async def _process_expression_style_learning( ) -> None: """Learn expression styles directly from raw messages.""" try: + now = time.time() + min_interval = max( + 0, + int( + getattr( + self._config, + "expression_learning_min_interval_seconds", + 3600, + ) + or 0 + ), + ) + last_learning_time = self._last_expression_learning_times.get(group_id, 0) + if min_interval and last_learning_time and now - last_learning_time < min_interval: + remaining = int(min_interval - (now - last_learning_time)) + logger.debug( + f"群组 {group_id} 表达风格学习处于冷却中," + f"剩余约 {remaining} 秒" + ) + return + stats = await self._message_collector.get_statistics(group_id) raw_message_count = stats.get("raw_messages", 0) @@ -186,6 +208,7 @@ async def _process_expression_style_learning( ) return self._last_expression_trigger_counts[group_id] = raw_message_count + self._last_expression_learning_times[group_id] = now logger.info( f"群组 {group_id} 开始表达风格学习,当前消息数:{raw_message_count}" diff --git a/tests/integration/test_auth_blueprint.py b/tests/integration/test_auth_blueprint.py index 26e96717..c7913827 100644 --- a/tests/integration/test_auth_blueprint.py +++ b/tests/integration/test_auth_blueprint.py @@ -3,14 +3,23 @@ Tests the auth blueprint routes with mock dependencies """ +from types import SimpleNamespace + import pytest from quart import Quart from webui.blueprints.auth import auth_bp +from webui.dependencies import get_container +from webui.services.auth_service import INITIAL_WEBUI_PASSWORD_ENV_VAR @pytest.fixture async def app(mock_container): """Create test Quart application""" + container = get_container() + previous_plugin_config = container.plugin_config + mock_container.plugin_config.enable_webui_password = False + container.plugin_config = mock_container.plugin_config + app = Quart(__name__) app.config['TESTING'] = True app.secret_key = 'test-secret-key' @@ -20,6 +29,8 @@ async def app(mock_container): yield app + container.plugin_config = previous_plugin_config + @pytest.fixture async def client(app): @@ -143,3 +154,73 @@ async def test_require_auth_decorator_not_authenticated(self, client): response = await client.post('/api/logout') assert response.status_code == 200 + + +class TestPasswordEnabledAuthBlueprint: + """Integration tests for optional password mode.""" + + @pytest.mark.asyncio + async def test_login_page_renders_when_password_enabled(self, client, tmp_path): + get_container().plugin_config = SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + + response = await client.get('/api/login') + + assert response.status_code == 200 + body = await response.get_data(as_text=True) + assert "SELF LEARNING" in body + assert "登录密码" in body + + @pytest.mark.asyncio + async def test_index_redirects_to_login_when_password_enabled(self, client, tmp_path): + get_container().plugin_config = SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + + response = await client.get('/api/index') + + assert response.status_code in [302, 303, 307] + assert response.headers["Location"].endswith("/api/login") + + @pytest.mark.asyncio + async def test_login_post_checks_password_when_enabled(self, client, tmp_path, monkeypatch): + monkeypatch.setenv(INITIAL_WEBUI_PASSWORD_ENV_VAR, "InitialPass123!") + get_container().plugin_config = SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + + failed = await client.post('/api/login', json={'password': 'wrong_password'}) + assert failed.status_code == 401 + + response = await client.post( + '/api/login', + json={'password': 'InitialPass123!'}, + ) + + assert response.status_code == 200 + data = await response.get_json() + assert data['must_change'] is True + assert data['redirect'] == '/api/plugin_change_password' + + @pytest.mark.asyncio + async def test_change_password_when_enabled(self, client, tmp_path, monkeypatch): + monkeypatch.setenv(INITIAL_WEBUI_PASSWORD_ENV_VAR, "InitialPass123!") + get_container().plugin_config = SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + await client.post('/api/login', json={'password': 'InitialPass123!'}) + + response = await client.post('/api/plugin_change_password', json={ + 'old_password': 'InitialPass123!', + 'new_password': 'NewPass123!', + }) + + assert response.status_code == 200 + data = await response.get_json() + assert data["success"] is True + assert data["redirect"] == "/api/index" diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py index 39c7f7b1..1a201229 100644 --- a/tests/unit/test_auth_service.py +++ b/tests/unit/test_auth_service.py @@ -1,8 +1,13 @@ -"""Unit tests for pack-branch passwordless AuthService compatibility.""" +"""Unit tests for optional WebUI password authentication.""" + +from types import SimpleNamespace import pytest -from webui.services.auth_service import AuthService +from webui.services.auth_service import ( + INITIAL_WEBUI_PASSWORD_ENV_VAR, + AuthService, +) class TestAuthService: @@ -57,3 +62,78 @@ def test_save_password_config_keeps_legacy_compatibility(self, mock_container): assert service.save_password_config(new_config) is True assert service._password_config == new_config assert mock_container.plugin_config.password_config == new_config + + @pytest.mark.asyncio + async def test_login_requires_initial_password_when_enabled(self, tmp_path, monkeypatch): + monkeypatch.delenv(INITIAL_WEBUI_PASSWORD_ENV_VAR, raising=False) + container = SimpleNamespace( + plugin_config=SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + ) + service = AuthService(container) + + success, message, extra_data = await service.login( + "AnyPass123!", + "127.0.0.2", + ) + + assert success is False + assert "尚未配置初始密码" in message + assert extra_data == {"setup_required": True} + assert not (tmp_path / "password.json").exists() + + @pytest.mark.asyncio + async def test_login_uses_explicit_initial_password_when_enabled(self, tmp_path, monkeypatch): + monkeypatch.setenv(INITIAL_WEBUI_PASSWORD_ENV_VAR, "InitialPass123!") + container = SimpleNamespace( + plugin_config=SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + ) + service = AuthService(container) + + success, message, extra_data = await service.login( + "InitialPass123!", + "127.0.0.4", + ) + + assert success is True + assert "password must be changed" in message + assert extra_data == { + "must_change": True, + "redirect": "/api/plugin_change_password", + } + assert (tmp_path / "password.json").exists() + assert "password_hash" in container.plugin_config.password_config + + @pytest.mark.asyncio + async def test_change_password_when_enabled_updates_login_secret(self, tmp_path, monkeypatch): + monkeypatch.setenv(INITIAL_WEBUI_PASSWORD_ENV_VAR, "InitialPass123!") + container = SimpleNamespace( + plugin_config=SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + ) + service = AuthService(container) + + success, _, _ = await service.login("InitialPass123!", "127.0.0.3") + assert success is True + + success, message = await service.change_password( + "InitialPass123!", + "NewPass123!", + ) + + assert success is True + assert message == "密码修改成功" + + success, _, extra_data = await service.login("NewPass123!", "127.0.0.3") + assert success is True + assert extra_data == { + "must_change": False, + "redirect": "/api/index", + } diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index b7ad4608..ba1feddb 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -29,7 +29,12 @@ def test_create_default_instance(self): assert config.enable_message_capture is True assert config.enable_auto_learning is True assert config.enable_realtime_learning is False + assert config.enable_realtime_llm_filter is False + assert config.enable_realtime_expression_learning is False + assert config.enable_llm_hooks is False assert config.enable_web_interface is True + assert config.enable_webui_password is False + assert config.webui_initial_password == "" assert config.web_interface_port == 7833 assert config.web_interface_host == "0.0.0.0" assert config.log_level == "info" @@ -43,6 +48,7 @@ def test_create_default_classmethod(self): assert config.min_messages_for_learning == 50 assert config.max_messages_per_batch == 200 assert config.expression_learning_trigger_messages == 10 + assert config.expression_learning_min_interval_seconds == 3600 assert config.topic_detection_interval_messages == 10 def test_default_learning_parameters(self): @@ -116,6 +122,8 @@ def test_create_from_basic_config(self): 'enable_message_capture': False, 'enable_auto_learning': False, 'enable_realtime_llm_filter': True, + 'enable_webui_password': True, + 'webui_initial_password': 'InitPass123!', 'web_interface_port': 8080, } } @@ -125,6 +133,8 @@ def test_create_from_basic_config(self): assert config.enable_message_capture is False assert config.enable_auto_learning is False assert config.enable_realtime_llm_filter is True + assert config.enable_webui_password is True + assert config.webui_initial_password == 'InitPass123!' assert config.web_interface_port == 8080 assert config.data_dir == "/tmp/test" @@ -270,6 +280,7 @@ def test_create_from_config_with_webui_extra_setting_groups(self): 'MaiBot_Enhancement': { 'enable_maibot_features': False, 'enable_expression_patterns': False, + 'enable_realtime_expression_learning': True, 'enable_memory_graph': False, 'enable_knowledge_graph': False, 'enable_time_decay': False, @@ -285,6 +296,7 @@ def test_create_from_config_with_webui_extra_setting_groups(self): }, 'Runtime_Internal_Settings': { 'llm_hook_injection_target': 'prompt', + 'enable_llm_hooks': True, 'enable_memory_cleanup': False, 'memory_cleanup_days': 14, 'memory_importance_threshold': 0.45, @@ -292,12 +304,16 @@ def test_create_from_config_with_webui_extra_setting_groups(self): 'task_cancel_timeout': 6, 'service_stop_timeout': 7, }, + 'Learning_Parameters': { + 'expression_learning_min_interval_seconds': 120, + }, } config = PluginConfig.create_from_config(raw_config, data_dir="/tmp/test") assert config.enable_maibot_features is False assert config.enable_expression_patterns is False + assert config.enable_realtime_expression_learning is True assert config.enable_memory_graph is False assert config.enable_knowledge_graph is False assert config.enable_time_decay is False @@ -309,12 +325,14 @@ def test_create_from_config_with_webui_extra_setting_groups(self): assert config.auto_apply_persona_updates is False assert config.persona_update_backup_enabled is False assert config.llm_hook_injection_target == 'prompt' + assert config.enable_llm_hooks is True assert config.enable_memory_cleanup is False assert config.memory_cleanup_days == 14 assert config.memory_importance_threshold == 0.45 assert config.shutdown_step_timeout == 11 assert config.task_cancel_timeout == 6 assert config.service_stop_timeout == 7 + assert config.expression_learning_min_interval_seconds == 120 def test_create_from_empty_config(self): """Test config creation from empty dict uses all defaults.""" diff --git a/tests/unit/test_config_service.py b/tests/unit/test_config_service.py index 6cc01dd3..48b8d8d6 100644 --- a/tests/unit/test_config_service.py +++ b/tests/unit/test_config_service.py @@ -157,6 +157,19 @@ async def test_get_config_schema_includes_full_settings(self, tmp_path): runtime_fields = {field["key"]: field for field in groups["Runtime_Internal_Settings"]["fields"]} assert runtime_fields["messages_db_path"]["editable"] is False + assert runtime_fields["enable_llm_hooks"]["widget"] == "toggle" + assert runtime_fields["enable_llm_hooks"]["value"] is False + + basic_fields = {field["key"]: field for field in groups["Self_Learning_Basic"]["fields"]} + assert basic_fields["enable_webui_password"]["widget"] == "toggle" + assert basic_fields["enable_webui_password"]["value"] is False + assert basic_fields["webui_initial_password"]["widget"] == "password" + assert basic_fields["webui_initial_password"]["value"] == "" + assert basic_fields["webui_initial_password"]["secret"] is True + + maibot_fields = {field["key"]: field for field in groups["MaiBot_Enhancement"]["fields"]} + assert maibot_fields["enable_realtime_expression_learning"]["widget"] == "toggle" + assert maibot_fields["enable_realtime_expression_learning"]["value"] is False advanced_fields = {field["key"]: field for field in groups["Advanced_Settings"]["fields"]} assert advanced_fields["log_level"]["widget"] == "select" @@ -342,6 +355,8 @@ async def test_config_schema_refresh_prefers_grouped_realtime_plugin_page_values "Self_Learning_Basic": { "enable_realtime_learning": True, "enable_realtime_llm_filter": True, + "enable_webui_password": True, + "webui_initial_password": "InitPass123!", }, "enable_realtime_learning": False, "enable_realtime_llm_filter": False, @@ -354,6 +369,7 @@ async def test_config_schema_refresh_prefers_grouped_realtime_plugin_page_values assert schema["config"]["enable_realtime_learning"] is True assert schema["config"]["enable_realtime_llm_filter"] is True + assert schema["config"]["webui_initial_password"] == "" fields = { field["key"]: field @@ -362,10 +378,21 @@ async def test_config_schema_refresh_prefers_grouped_realtime_plugin_page_values } assert fields["enable_realtime_learning"]["value"] is True assert fields["enable_realtime_llm_filter"]["value"] is True + assert fields["webui_initial_password"]["value"] == "" saved = json.loads(config_file.read_text(encoding="utf-8")) assert saved["enable_realtime_learning"] is True assert saved["enable_realtime_llm_filter"] is True + assert saved["webui_initial_password"] == "" + assert container.astrbot_config["Self_Learning_Basic"]["webui_initial_password"] == "" + + password_config = json.loads( + (Path(container.plugin_config.data_dir) / "password.json").read_text( + encoding="utf-8", + ) + ) + assert "password_hash" in password_config + assert "password" not in password_config @pytest.mark.asyncio async def test_config_schema_refresh_pushes_newer_webui_config_to_plugin_page(self, tmp_path): @@ -402,6 +429,25 @@ async def test_config_schema_refresh_pushes_newer_webui_config_to_plugin_page(se @pytest.mark.unit class TestConfigServiceUpdate: + @pytest.mark.asyncio + async def test_update_config_rejects_password_mode_without_initial_secret(self, tmp_path, monkeypatch): + monkeypatch.delenv("ASTRBOT_WEBUI_INITIAL_PASSWORD", raising=False) + container = build_container(tmp_path) + service = ConfigService(container) + + success, message, updated = await service.update_config( + { + "Self_Learning_Basic": { + "enable_webui_password": True, + }, + } + ) + + assert success is False + assert "初始密码" in message + assert updated["enable_webui_password"] is False + assert not (Path(container.plugin_config.data_dir) / "password.json").exists() + @pytest.mark.asyncio async def test_update_config_persists_and_syncs_paths(self, tmp_path): container = build_container(tmp_path) @@ -457,6 +503,8 @@ async def test_update_config_syncs_webui_changes_to_plugin_page_config_and_runti "Self_Learning_Basic": { "enable_realtime_learning": True, "enable_realtime_llm_filter": True, + "enable_webui_password": True, + "webui_initial_password": "InitPass123!", }, "Target_Settings": { "target_qq_list": ["10001", "group_20002"], @@ -465,6 +513,10 @@ async def test_update_config_syncs_webui_changes_to_plugin_page_config_and_runti "Learning_Parameters": { "learning_interval_hours": 2, "max_messages_per_batch": 25, + "expression_learning_min_interval_seconds": 120, + }, + "Runtime_Internal_Settings": { + "enable_llm_hooks": True, }, "Style_Analysis": { "style_update_threshold": 0.72, @@ -482,9 +534,15 @@ async def test_update_config_syncs_webui_changes_to_plugin_page_config_and_runti assert updated["learning_interval_hours"] == 2 assert updated["enable_realtime_learning"] is True assert updated["enable_realtime_llm_filter"] is True + assert updated["enable_webui_password"] is True + assert updated["webui_initial_password"] == "" + assert updated["enable_llm_hooks"] is True + assert updated["expression_learning_min_interval_seconds"] == 120 assert container.astrbot_config["Self_Learning_Basic"]["enable_realtime_learning"] is True assert container.astrbot_config["Self_Learning_Basic"]["enable_realtime_llm_filter"] is True + assert container.astrbot_config["Self_Learning_Basic"]["enable_webui_password"] is True + assert container.astrbot_config["Self_Learning_Basic"]["webui_initial_password"] == "" assert container.astrbot_config["Target_Settings"]["target_qq_list"] == [ "10001", "group_20002", @@ -492,8 +550,17 @@ async def test_update_config_syncs_webui_changes_to_plugin_page_config_and_runti assert container.astrbot_config["Target_Settings"]["target_blacklist"] == ["blocked"] assert container.astrbot_config["Learning_Parameters"]["learning_interval_hours"] == 2 assert container.astrbot_config["Learning_Parameters"]["max_messages_per_batch"] == 25 + assert container.astrbot_config["Learning_Parameters"]["expression_learning_min_interval_seconds"] == 120 + assert container.astrbot_config["Runtime_Internal_Settings"]["enable_llm_hooks"] is True assert container.astrbot_config["Style_Analysis"]["style_update_threshold"] == 0.72 assert container.astrbot_config["Filter_Parameters"]["relevance_threshold"] == 0.68 + password_config = json.loads( + (Path(container.plugin_config.data_dir) / "password.json").read_text( + encoding="utf-8", + ) + ) + assert "password_hash" in password_config + assert "password" not in password_config assert "enable_realtime_learning" not in container.astrbot_config assert "enable_realtime_llm_filter" not in container.astrbot_config assert container.astrbot_config.save_calls == 1 diff --git a/tests/unit/test_feature_delegation.py b/tests/unit/test_feature_delegation.py index 01d9dae3..dc9180c0 100644 --- a/tests/unit/test_feature_delegation.py +++ b/tests/unit/test_feature_delegation.py @@ -1,7 +1,7 @@ import sys from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest @@ -83,6 +83,40 @@ def test_feature_delegation_keeps_local_fallback_when_companion_missing_or_disab assert delegation.should_delegate_memory() is False +@pytest.mark.asyncio +async def test_llm_hook_handle_returns_without_context_fetches_when_disabled(): + diversity = SimpleNamespace( + build_diversity_prompt_injection=AsyncMock(return_value="diversity") + ) + perf_tracker = Mock() + handler = LLMHookHandler( + plugin_config=SimpleNamespace(enable_llm_hooks=False), + diversity_manager=diversity, + social_context_injector=SimpleNamespace( + format_complete_context=AsyncMock(return_value="social") + ), + v2_integration=SimpleNamespace( + get_enhanced_context=AsyncMock(return_value={"knowledge_context": "k"}) + ), + jargon_query_service=SimpleNamespace( + check_and_explain_jargon=AsyncMock(return_value="jargon") + ), + temporary_persona_updater=SimpleNamespace(session_updates={"group-a": ["u"]}), + perf_tracker=perf_tracker, + group_id_to_unified_origin={}, + db_manager=SimpleNamespace( + get_approved_few_shots=AsyncMock(return_value=["few-shot"]) + ), + ) + req = SimpleNamespace(prompt="你好", system_prompt="", extra_user_content_parts=[]) + + await handler.handle(SimpleNamespace(), req) + + diversity.build_diversity_prompt_injection.assert_not_awaited() + perf_tracker.record.assert_not_called() + assert req.extra_user_content_parts == [] + + @pytest.mark.asyncio async def test_llm_hook_omits_local_v2_memories_when_livingmemory_delegated(): v2 = SimpleNamespace( diff --git a/tests/unit/test_learning_chain_regressions.py b/tests/unit/test_learning_chain_regressions.py index 1dd97c40..50260fac 100644 --- a/tests/unit/test_learning_chain_regressions.py +++ b/tests/unit/test_learning_chain_regressions.py @@ -472,7 +472,7 @@ def test_fresh_jargon_miner_first_trigger_is_not_blocked_by_cooldown(): @pytest.mark.unit @pytest.mark.asyncio -async def test_message_pipeline_runs_expression_learning_without_realtime_mode(): +async def test_message_pipeline_skips_expression_learning_without_realtime_mode_by_default(): config = SimpleNamespace( enable_jargon_learning=False, enable_expression_patterns=True, @@ -519,6 +519,60 @@ def spawn_now(coro): await pipeline.process_learning("group-a", "user-a", "这是表达学习消息", event) await asyncio.gather(*spawned) + realtime_processor.process_expression_learning_background.assert_not_called() + realtime_processor.process_realtime_background.assert_not_called() + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_message_pipeline_can_run_expression_learning_when_explicitly_enabled(): + config = SimpleNamespace( + enable_jargon_learning=False, + enable_expression_patterns=True, + enable_realtime_learning=False, + enable_realtime_expression_learning=True, + enable_style_learning=False, + enable_goal_driven_chat=False, + ) + collector = SimpleNamespace(collect_message=AsyncMock()) + enhanced_interaction = SimpleNamespace( + update_conversation_context=AsyncMock(), + ) + realtime_processor = SimpleNamespace( + process_expression_learning_background=AsyncMock(), + process_realtime_background=AsyncMock(), + ) + + pipeline = MessagePipeline( + plugin_config=config, + message_collector=collector, + enhanced_interaction=enhanced_interaction, + jargon_miner_manager=None, + jargon_statistical_filter=None, + v2_integration=None, + realtime_processor=realtime_processor, + group_orchestrator=SimpleNamespace(), + conversation_goal_manager=None, + affection_manager=SimpleNamespace(), + db_manager=SimpleNamespace(), + ) + + spawned = [] + + def spawn_now(coro): + task = asyncio.create_task(coro) + spawned.append(task) + return task + + pipeline._spawn = spawn_now + event = SimpleNamespace( + get_sender_name=lambda: "User A", + get_platform_name=lambda: "test", + ) + + await pipeline.process_learning("group-a", "user-a", "这是表达学习消息", event) + await asyncio.gather(*spawned) + realtime_processor.process_expression_learning_background.assert_awaited_once_with( "group-a", "这是表达学习消息", @@ -591,6 +645,7 @@ async def test_message_pipeline_collects_to_database_and_triggers_learning_paths enable_jargon_learning=True, enable_expression_patterns=True, enable_realtime_learning=False, + enable_realtime_expression_learning=True, enable_style_learning=False, enable_goal_driven_chat=False, ) @@ -867,6 +922,7 @@ async def test_realtime_expression_learning_is_batch_gated(): message_max_length=500, enable_expression_patterns=True, expression_learning_trigger_messages=10, + expression_learning_min_interval_seconds=0, ) collector = SimpleNamespace( get_statistics=AsyncMock( @@ -921,6 +977,67 @@ async def test_realtime_expression_learning_is_batch_gated(): assert learner.trigger_learning_for_group.await_count == 2 +@pytest.mark.unit +@pytest.mark.asyncio +async def test_realtime_expression_learning_respects_min_interval(): + config = SimpleNamespace( + message_min_length=1, + message_max_length=500, + enable_expression_patterns=True, + expression_learning_trigger_messages=1, + expression_learning_min_interval_seconds=3600, + ) + collector = SimpleNamespace( + get_statistics=AsyncMock(return_value={"raw_messages": 10}) + ) + learner = SimpleNamespace(trigger_learning_for_group=AsyncMock(return_value=False)) + factory_manager = SimpleNamespace( + get_component_factory=lambda: SimpleNamespace( + create_expression_pattern_learner=lambda: learner + ) + ) + db_manager = SimpleNamespace( + get_recent_raw_messages=AsyncMock( + return_value=[ + { + "id": idx, + "sender_id": f"user-{idx}", + "sender_name": f"User {idx}", + "message": f"这是第{idx}条足够长的表达学习消息", + "timestamp": time.time(), + "platform": "test", + } + for idx in range(1, 6) + ] + ) + ) + processor = RealtimeProcessor( + plugin_config=config, + message_collector=collector, + multidimensional_analyzer=SimpleNamespace(), + persona_manager=SimpleNamespace(), + temporary_persona_updater=SimpleNamespace(), + dialog_analyzer=SimpleNamespace(), + learning_stats=SimpleNamespace(style_updates=0), + factory_manager=factory_manager, + db_manager=db_manager, + ) + + await processor.process_expression_learning( + "group-a", + "这是用于表达学习频控的消息", + "user-a", + ) + await processor.process_expression_learning( + "group-a", + "这是用于表达学习频控的消息", + "user-a", + ) + + assert learner.trigger_learning_for_group.await_count == 1 + assert collector.get_statistics.await_count == 1 + + @pytest.mark.unit @pytest.mark.asyncio async def test_realtime_expression_learning_creates_review_without_persona_write(): @@ -929,6 +1046,7 @@ async def test_realtime_expression_learning_creates_review_without_persona_write message_max_length=500, enable_expression_patterns=True, expression_learning_trigger_messages=1, + expression_learning_min_interval_seconds=0, ) collector = SimpleNamespace( get_statistics=AsyncMock(return_value={"raw_messages": 10}) diff --git a/web_res/static/html/change_password.html b/web_res/static/html/change_password.html index b0a5ccec..c149688c 100644 --- a/web_res/static/html/change_password.html +++ b/web_res/static/html/change_password.html @@ -2,13 +2,179 @@ - - SELF LEARNING 监控板 + 修改 WebUI 密码 + +
+

修改 WebUI 密码

+

首次开启密码后需要完成一次密码更新。

+
+
+ + + + + + + +
+
diff --git a/web_res/static/html/dashboard.html b/web_res/static/html/dashboard.html index 04169f2d..6b499036 100644 --- a/web_res/static/html/dashboard.html +++ b/web_res/static/html/dashboard.html @@ -6527,6 +6527,21 @@

手动安装依赖

data-config-widget="${escapeHtml(field.widget)}" > `; + } else if (field.widget === 'password') { + controlHtml = ` + + `; } else if (field.type === 'list') { controlHtml = `