From d1b7d346bdbe3ec93d94dcd852513a6b14818b34 Mon Sep 17 00:00:00 2001 From: EterUltimate <1831303476@qq.com> Date: Thu, 11 Jun 2026 15:04:38 +0800 Subject: [PATCH 1/2] fix: reduce disabled realtime learning calls --- _conf_schema.json | 12 ++ config.py | 11 ++ services/hooks/llm_hook_handler.py | 4 + services/learning/message_pipeline.py | 2 +- services/learning/realtime_processor.py | 23 +++ tests/integration/test_auth_blueprint.py | 79 ++++++++ tests/unit/test_auth_service.py | 58 +++++- tests/unit/test_config.py | 15 ++ tests/unit/test_config_service.py | 22 +++ tests/unit/test_feature_delegation.py | 36 +++- tests/unit/test_learning_chain_regressions.py | 120 +++++++++++- web_res/static/html/change_password.html | 172 +++++++++++++++++- web_res/static/html/login.html | 147 ++++++++++++++- webui/blueprints/auth.py | 99 ++++++++-- webui/middleware/auth.py | 30 ++- webui/services/auth_service.py | 142 +++++++++++++-- webui/services/config_service.py | 12 ++ 17 files changed, 936 insertions(+), 48 deletions(-) diff --git a/_conf_schema.json b/_conf_schema.json index ca2e719b..96343513 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -62,6 +62,12 @@ "hint": "是否启用Web界面用于查看和管理学习数据", "default": true }, + "enable_webui_password": { + "description": "启用WebUI登录密码", + "type": "bool", + "hint": "默认关闭,WebUI 免密访问;开启后访问 WebUI 需要登录密码。首次开启使用默认密码 self_learning_pwd 登录并按提示修改", + "default": false + }, "web_interface_port": { "description": "Web界面端口", "type": "int", @@ -164,6 +170,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..4ba808be 100644 --- a/config.py +++ b/config.py @@ -68,12 +68,14 @@ class PluginConfig(BaseModel): enable_jargon_learning: bool = True # 启用黑话学习 enable_style_learning: bool = True # 启用对话风格学习 enable_web_interface: bool = True + enable_webui_password: bool = False # 启用 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 +121,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 +158,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 +346,13 @@ 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), 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 +389,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 +490,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 +625,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..b5415548 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 DEFAULT_WEBUI_PASSWORD @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,71 @@ 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): + 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': DEFAULT_WEBUI_PASSWORD}, + ) + + 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): + get_container().plugin_config = SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + await client.post('/api/login', json={'password': DEFAULT_WEBUI_PASSWORD}) + + response = await client.post('/api/plugin_change_password', json={ + 'old_password': DEFAULT_WEBUI_PASSWORD, + '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..9d08b012 100644 --- a/tests/unit/test_auth_service.py +++ b/tests/unit/test_auth_service.py @@ -1,8 +1,10 @@ -"""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 DEFAULT_WEBUI_PASSWORD, AuthService class TestAuthService: @@ -57,3 +59,55 @@ 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_uses_default_password_when_enabled(self, tmp_path): + container = SimpleNamespace( + plugin_config=SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + ) + service = AuthService(container) + + success, message, extra_data = await service.login( + DEFAULT_WEBUI_PASSWORD, + "127.0.0.2", + ) + + 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): + container = SimpleNamespace( + plugin_config=SimpleNamespace( + enable_webui_password=True, + data_dir=str(tmp_path), + ) + ) + service = AuthService(container) + + success, _, _ = await service.login(DEFAULT_WEBUI_PASSWORD, "127.0.0.3") + assert success is True + + success, message = await service.change_password( + DEFAULT_WEBUI_PASSWORD, + "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..957f853f 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -29,7 +29,11 @@ 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.web_interface_port == 7833 assert config.web_interface_host == "0.0.0.0" assert config.log_level == "info" @@ -43,6 +47,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 +121,7 @@ def test_create_from_basic_config(self): 'enable_message_capture': False, 'enable_auto_learning': False, 'enable_realtime_llm_filter': True, + 'enable_webui_password': True, 'web_interface_port': 8080, } } @@ -125,6 +131,7 @@ 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.web_interface_port == 8080 assert config.data_dir == "/tmp/test" @@ -270,6 +277,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 +293,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 +301,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 +322,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..94144143 100644 --- a/tests/unit/test_config_service.py +++ b/tests/unit/test_config_service.py @@ -157,6 +157,16 @@ 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 + + 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 +352,7 @@ 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, }, "enable_realtime_learning": False, "enable_realtime_llm_filter": False, @@ -457,6 +468,7 @@ 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, }, "Target_Settings": { "target_qq_list": ["10001", "group_20002"], @@ -465,6 +477,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 +498,13 @@ 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["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["Target_Settings"]["target_qq_list"] == [ "10001", "group_20002", @@ -492,6 +512,8 @@ 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 assert "enable_realtime_learning" not in container.astrbot_config 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/login.html b/web_res/static/html/login.html index b0a5ccec..cda63b21 100644 --- a/web_res/static/html/login.html +++ b/web_res/static/html/login.html @@ -2,13 +2,154 @@ - - SELF LEARNING 监控板 + SELF LEARNING 登录 + +
+

SELF LEARNING

+

WebUI 控制台

+
+
+ + + +
+
diff --git a/webui/blueprints/auth.py b/webui/blueprints/auth.py index 45617cac..a7f1be67 100644 --- a/webui/blueprints/auth.py +++ b/webui/blueprints/auth.py @@ -1,11 +1,13 @@ """ -认证蓝图 - WebUI 免密访问 +认证蓝图 - WebUI 免密访问,可选启用登录密码 """ import os -from quart import Blueprint, render_template, jsonify, redirect, url_for +from quart import Blueprint, render_template, jsonify, redirect, request, session, url_for from astrbot.api import logger -from ..middleware.auth import require_auth +from ..dependencies import get_container +from ..middleware.auth import is_authenticated, require_auth +from ..services.auth_service import AuthService from ..utils.response import error_response _TEMPLATE_DIR = os.path.abspath( @@ -16,52 +18,102 @@ @auth_bp.route("/") +@require_auth async def read_root(): - """根目录 - 免密渲染监控板。""" + """根目录 - 渲染监控板。""" return await render_template("dashboard.html") @auth_bp.route("/login", methods=["GET"]) async def login_page(): - """兼容旧入口:直接进入主界面。""" - return redirect(url_for('auth.read_root_index')) + """显示登录页面;免密模式下直接进入主界面。""" + auth_service = AuthService(get_container()) + if not auth_service.is_password_enabled() or is_authenticated(): + return redirect(url_for('auth.read_root_index')) + return await render_template("login.html") @auth_bp.route("/login", methods=["POST"]) async def login(): - """兼容旧接口:免密模式下直接返回成功。""" + """处理登录请求。""" try: - return jsonify({ - "message": "Passwordless WebUI access granted", - "must_change": False, - "redirect": "/api/index" - }), 200 + data = await request.get_json(silent=True) or {} + password = data.get("password", "") + client_ip = request.remote_addr or "unknown" + + auth_service = AuthService(get_container()) + success, message, extra_data = await auth_service.login(password, client_ip) + extra_data = extra_data or {} + + if success: + if auth_service.is_password_enabled(): + session["authenticated"] = True + session["must_change"] = bool(extra_data.get("must_change", False)) + session.permanent = True + return jsonify({ + "message": message, + "must_change": extra_data.get("must_change", False), + "redirect": extra_data.get("redirect", "/api/index"), + }), 200 + + response_data = {"error": message} + response_data.update(extra_data) + status_code = 429 if extra_data.get("locked") else 401 + return jsonify(response_data), status_code except Exception as e: logger.error(f"登录处理失败: {e}", exc_info=True) return error_response(f"登录失败: {str(e)}", 500) @auth_bp.route("/index") +@require_auth async def read_root_index(): - """主页面 - 免密渲染监控板。""" + """主页面 - 渲染监控板。""" + auth_service = AuthService(get_container()) + if auth_service.is_password_enabled() and auth_service.check_must_change_password(): + return redirect(url_for('auth.change_password_page')) return await render_template("dashboard.html") @auth_bp.route("/plugin_change_password", methods=["GET"]) +@require_auth async def change_password_page(): - """免密模式下不再提供修改密码页面。""" - return redirect(url_for('auth.read_root_index')) + """显示修改密码页面。""" + auth_service = AuthService(get_container()) + if not auth_service.is_password_enabled(): + return redirect(url_for('auth.read_root_index')) + return await render_template("change_password.html") @auth_bp.route("/plugin_change_password", methods=["POST"]) +@require_auth async def change_password(): - """免密模式下禁用修改密码接口。""" + """处理修改密码请求。""" try: + auth_service = AuthService(get_container()) + if not auth_service.is_password_enabled(): + return jsonify({ + "success": False, + "error": "WebUI 已启用免密访问,无需修改密码", + "redirect": "/api/index" + }), 410 + + data = await request.get_json(silent=True) or {} + success, message = await auth_service.change_password( + data.get("old_password", ""), + data.get("new_password", ""), + ) + if success: + session["must_change"] = False + return jsonify({ + "success": True, + "message": message, + "redirect": "/api/index", + }), 200 return jsonify({ "success": False, - "error": "WebUI 已启用免密访问,无需修改密码", - "redirect": "/api/index" - }), 410 + "error": message, + }), 400 except Exception as e: logger.error(f"修改密码失败: {e}", exc_info=True) return error_response(f"修改密码失败: {str(e)}", 500) @@ -70,8 +122,15 @@ async def change_password(): @auth_bp.route("/logout", methods=["POST"]) @require_auth async def logout(): - """免密模式下登出为兼容性 no-op。""" + """处理登出。""" try: + auth_service = AuthService(get_container()) + if auth_service.is_password_enabled(): + session.clear() + return jsonify({ + "message": "Logged out successfully", + "redirect": "/api/login" + }), 200 return jsonify({ "message": "Passwordless WebUI stays open", "redirect": "/api/index" diff --git a/webui/middleware/auth.py b/webui/middleware/auth.py index 054c28b5..687677a8 100644 --- a/webui/middleware/auth.py +++ b/webui/middleware/auth.py @@ -2,16 +2,38 @@ 认证中间件 """ from functools import wraps +from quart import jsonify, redirect, request, session, url_for + +from ..dependencies import get_container + + +def _webui_password_enabled() -> bool: + try: + config = get_container().plugin_config + except Exception: + return False + return getattr(config, "enable_webui_password", False) is True def require_auth(f): - """Pack 分支 WebUI 使用免密访问,认证装饰器直接放行。""" + """要求认证;未开启 WebUI 密码时保持免密兼容。""" @wraps(f) async def decorated_function(*args, **kwargs): - return await f(*args, **kwargs) + if not _webui_password_enabled(): + return await f(*args, **kwargs) + if session.get("authenticated"): + return await f(*args, **kwargs) + if request.method == "GET" and request.path in {"/api/", "/api/index"}: + return redirect(url_for("auth.login_page")) + return jsonify({ + "error": "未认证,请先登录", + "redirect": "/api/login", + }), 401 return decorated_function def is_authenticated() -> bool: - """Pack 分支 WebUI 始终视为已认证。""" - return True + """检查是否已认证;免密模式始终视为已认证。""" + if not _webui_password_enabled(): + return True + return bool(session.get("authenticated")) diff --git a/webui/services/auth_service.py b/webui/services/auth_service.py index c5cd04bd..5c97df6a 100644 --- a/webui/services/auth_service.py +++ b/webui/services/auth_service.py @@ -3,24 +3,34 @@ """ import os import json +import time from typing import Tuple, Dict, Any, Optional try: from ...utils.logging_utils import get_astrbot_logger from ...utils.security_utils import ( PasswordHasher, + login_attempt_tracker, SecurityValidator, + verify_password_with_migration, ) except ImportError: from utils.logging_utils import get_astrbot_logger from utils.security_utils import ( PasswordHasher, + login_attempt_tracker, SecurityValidator, + verify_password_with_migration, ) logger = get_astrbot_logger("self_learning.webui.auth") -DEFAULT_PASSWORD_CONFIG = {"must_change": False} +PASSWORDLESS_PASSWORD_CONFIG = {"must_change": False} +DEFAULT_WEBUI_PASSWORD = "self_learning_pwd" +DEFAULT_PASSWORD_CONFIG = { + "password": DEFAULT_WEBUI_PASSWORD, + "must_change": True, +} def hash_password_with_salt(password: str) -> Dict[str, Any]: @@ -52,6 +62,10 @@ def __init__(self, container): self.plugin_config = container.plugin_config self._password_config: Optional[Dict[str, Any]] = None + def is_password_enabled(self) -> bool: + """Return whether WebUI password auth is explicitly enabled.""" + return getattr(self.plugin_config, "enable_webui_password", False) is True + def get_password_file_path(self) -> str: """获取密码文件路径""" data_dir = getattr(self.plugin_config, 'data_dir', None) if self.plugin_config else None @@ -77,7 +91,7 @@ def load_password_config(self) -> Dict[str, Any]: return self._password_config config_attr = getattr(self.plugin_config, 'password_config', None) if self.plugin_config else None - if isinstance(config_attr, dict): + if isinstance(config_attr, dict) and (config_attr or not self.is_password_enabled()): self._password_config = config_attr return config_attr @@ -94,11 +108,25 @@ def load_password_config(self) -> Dict[str, Any]: self._password_config = config return config else: - logger.warning(f"密码配置文件不存在: {password_file},使用免密配置") - return DEFAULT_PASSWORD_CONFIG.copy() + if self.is_password_enabled(): + logger.warning( + f"密码配置文件不存在: {password_file},使用默认初始密码" + ) + config = DEFAULT_PASSWORD_CONFIG.copy() + else: + logger.debug(f"密码配置文件不存在: {password_file},使用免密配置") + config = PASSWORDLESS_PASSWORD_CONFIG.copy() + self._password_config = config + return config except Exception as e: logger.error(f"加载密码配置失败: {e}", exc_info=True) - return DEFAULT_PASSWORD_CONFIG.copy() + config = ( + DEFAULT_PASSWORD_CONFIG.copy() + if self.is_password_enabled() + else PASSWORDLESS_PASSWORD_CONFIG.copy() + ) + self._password_config = config + return config def save_password_config(self, config: Dict[str, Any]) -> bool: """ @@ -137,7 +165,7 @@ def save_password_config(self, config: Dict[str, Any]) -> bool: async def login(self, password: str, client_ip: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]: """ - 处理用户登录。pack 分支 WebUI 为免密访问,保留该方法仅兼容旧调用。 + 处理用户登录。默认免密;启用 WebUI 密码后执行校验。 Args: password: 用户输入的密码 @@ -146,11 +174,58 @@ async def login(self, password: str, client_ip: str) -> Tuple[bool, str, Optiona Returns: Tuple[bool, str, Optional[Dict]]: (是否成功, 消息, 额外数据) """ - logger.debug(f"WebUI免密登录放行: client_ip={client_ip}") - return True, "Passwordless WebUI access granted", { - "must_change": False, - "redirect": "/api/index", - } + if not self.is_password_enabled(): + logger.debug(f"WebUI免密登录放行: client_ip={client_ip}") + return True, "Passwordless WebUI access granted", { + "must_change": False, + "redirect": "/api/index", + } + + password = SecurityValidator.sanitize_input(password, max_length=128) + if not password: + return False, "密码不能为空", None + + is_locked, remaining_time = login_attempt_tracker.is_locked(client_ip) + if is_locked: + logger.warning(f"IP {client_ip} 被锁定,剩余 {remaining_time} 秒") + return False, f"登录尝试次数过多,请在 {remaining_time} 秒后重试", { + "locked": True, + "remaining_time": remaining_time, + } + + password_config = self.load_password_config() + is_valid, updated_config = verify_password_with_migration( + password, + password_config, + ) + + if is_valid: + if updated_config != password_config: + self.save_password_config(updated_config) + password_config = updated_config + + login_attempt_tracker.record_attempt(client_ip, success=True) + must_change = bool(password_config.get("must_change", False)) + redirect = "/api/plugin_change_password" if must_change else "/api/index" + message = ( + "Login successful, but password must be changed" + if must_change + else "Login successful" + ) + return True, message, { + "must_change": must_change, + "redirect": redirect, + } + + login_attempt_tracker.record_attempt(client_ip, success=False) + remaining_attempts = login_attempt_tracker.get_remaining_attempts(client_ip) + logger.warning(f"IP {client_ip} 登录失败,剩余尝试次数: {remaining_attempts}") + + error_msg = "密码错误" + if remaining_attempts <= 2: + error_msg = f"密码错误,还剩 {remaining_attempts} 次尝试机会" + + return False, error_msg, {"remaining_attempts": remaining_attempts} async def change_password( self, @@ -167,7 +242,46 @@ async def change_password( Returns: Tuple[bool, str]: (是否成功, 消息) """ - return False, "WebUI 已启用免密访问,无需修改密码" + if not self.is_password_enabled(): + return False, "WebUI 已启用免密访问,无需修改密码" + + old_password = SecurityValidator.sanitize_input(old_password, max_length=128) + new_password = SecurityValidator.sanitize_input(new_password, max_length=128) + + if not old_password or not new_password: + return False, "旧密码和新密码不能为空" + + password_config = self.load_password_config() + is_valid, updated_config = verify_password_with_migration( + old_password, + password_config, + ) + if not is_valid: + return False, "当前密码错误" + if updated_config != password_config: + self.save_password_config(updated_config) + + if old_password == new_password: + return False, "新密码不能与当前密码相同" + + strength_result = SecurityValidator.validate_password_strength(new_password) + if not strength_result["valid"]: + issues = "、".join(strength_result["issues"]) if strength_result["issues"] else "密码强度不足" + return False, issues + + password_hash, salt = PasswordHasher.hash_password(new_password) + new_config = { + "password_hash": password_hash, + "salt": salt, + "must_change": False, + "version": 2, + "last_changed": time.time(), + } + + if self.save_password_config(new_config): + logger.info("WebUI 密码已更新") + return True, "密码修改成功" + return False, "保存密码配置失败" def check_must_change_password(self) -> bool: """ @@ -176,4 +290,6 @@ def check_must_change_password(self) -> bool: Returns: bool: 是否需要强制修改 """ - return False + if not self.is_password_enabled(): + return False + return bool(self.load_password_config().get("must_change", False)) diff --git a/webui/services/config_service.py b/webui/services/config_service.py index c9fc2d23..b729f131 100644 --- a/webui/services/config_service.py +++ b/webui/services/config_service.py @@ -55,6 +55,12 @@ def _load_schema_definition() -> Dict[str, Any]: "hint": "学习并维护群聊中的表达模式和常见句式", "default": True, }, + "enable_realtime_expression_learning": { + "description": "实时表达方式学习", + "type": "bool", + "hint": "实时学习关闭时,是否仍按消息增量触发表达方式学习。默认关闭以避免旁听群聊时产生高频 LLM 调用和审查记录", + "default": False, + }, "enable_memory_graph": { "description": "启用记忆图系统", "type": "bool", @@ -143,6 +149,12 @@ def _load_schema_definition() -> Dict[str, Any]: {"value": "prompt", "label": "prompt"}, ], }, + "enable_llm_hooks": { + "description": "启用 LLM Hook 上下文注入", + "type": "bool", + "hint": "开启后每次回复前会并行拉取社交、记忆、黑话、few-shot 等上下文;默认关闭以避免高频模型调用", + "default": False, + }, "use_sqlalchemy": { "description": "强制使用 SQLAlchemy ORM", "type": "bool", From 63c3385788f1696a878e639c7b5496f3a71de2ae Mon Sep 17 00:00:00 2001 From: EterUltimate <1831303476@qq.com> Date: Thu, 11 Jun 2026 16:15:44 +0800 Subject: [PATCH 2/2] fix: require explicit webui password setup --- _conf_schema.json | 9 +- config.py | 2 + tests/integration/test_auth_blueprint.py | 14 +-- tests/unit/test_auth_service.py | 38 +++++-- tests/unit/test_config.py | 3 + tests/unit/test_config_service.py | 45 +++++++++ web_res/static/html/dashboard.html | 15 +++ webui/middleware/auth.py | 3 +- webui/services/auth_service.py | 120 ++++++++++++++++++----- webui/services/config_service.py | 71 +++++++++++++- 10 files changed, 280 insertions(+), 40 deletions(-) diff --git a/_conf_schema.json b/_conf_schema.json index 96343513..7a4fee1f 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -65,9 +65,16 @@ "enable_webui_password": { "description": "启用WebUI登录密码", "type": "bool", - "hint": "默认关闭,WebUI 免密访问;开启后访问 WebUI 需要登录密码。首次开启使用默认密码 self_learning_pwd 登录并按提示修改", + "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", diff --git a/config.py b/config.py index 4ba808be..14bd3474 100644 --- a/config.py +++ b/config.py @@ -69,6 +69,7 @@ class PluginConfig(BaseModel): 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 界面监听地址 @@ -347,6 +348,7 @@ def create_from_config(cls, config: dict, data_dir: Optional[str] = None) -> 'Pl 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'), diff --git a/tests/integration/test_auth_blueprint.py b/tests/integration/test_auth_blueprint.py index b5415548..c7913827 100644 --- a/tests/integration/test_auth_blueprint.py +++ b/tests/integration/test_auth_blueprint.py @@ -9,7 +9,7 @@ from quart import Quart from webui.blueprints.auth import auth_bp from webui.dependencies import get_container -from webui.services.auth_service import DEFAULT_WEBUI_PASSWORD +from webui.services.auth_service import INITIAL_WEBUI_PASSWORD_ENV_VAR @pytest.fixture @@ -186,7 +186,8 @@ async def test_index_redirects_to_login_when_password_enabled(self, client, tmp_ assert response.headers["Location"].endswith("/api/login") @pytest.mark.asyncio - async def test_login_post_checks_password_when_enabled(self, client, tmp_path): + 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), @@ -197,7 +198,7 @@ async def test_login_post_checks_password_when_enabled(self, client, tmp_path): response = await client.post( '/api/login', - json={'password': DEFAULT_WEBUI_PASSWORD}, + json={'password': 'InitialPass123!'}, ) assert response.status_code == 200 @@ -206,15 +207,16 @@ async def test_login_post_checks_password_when_enabled(self, client, tmp_path): assert data['redirect'] == '/api/plugin_change_password' @pytest.mark.asyncio - async def test_change_password_when_enabled(self, client, tmp_path): + 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': DEFAULT_WEBUI_PASSWORD}) + await client.post('/api/login', json={'password': 'InitialPass123!'}) response = await client.post('/api/plugin_change_password', json={ - 'old_password': DEFAULT_WEBUI_PASSWORD, + 'old_password': 'InitialPass123!', 'new_password': 'NewPass123!', }) diff --git a/tests/unit/test_auth_service.py b/tests/unit/test_auth_service.py index 9d08b012..1a201229 100644 --- a/tests/unit/test_auth_service.py +++ b/tests/unit/test_auth_service.py @@ -4,7 +4,10 @@ import pytest -from webui.services.auth_service import DEFAULT_WEBUI_PASSWORD, AuthService +from webui.services.auth_service import ( + INITIAL_WEBUI_PASSWORD_ENV_VAR, + AuthService, +) class TestAuthService: @@ -61,7 +64,8 @@ def test_save_password_config_keeps_legacy_compatibility(self, mock_container): assert mock_container.plugin_config.password_config == new_config @pytest.mark.asyncio - async def test_login_uses_default_password_when_enabled(self, tmp_path): + 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, @@ -71,10 +75,31 @@ async def test_login_uses_default_password_when_enabled(self, tmp_path): service = AuthService(container) success, message, extra_data = await service.login( - DEFAULT_WEBUI_PASSWORD, + "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 == { @@ -85,7 +110,8 @@ async def test_login_uses_default_password_when_enabled(self, tmp_path): 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): + 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, @@ -94,11 +120,11 @@ async def test_change_password_when_enabled_updates_login_secret(self, tmp_path) ) service = AuthService(container) - success, _, _ = await service.login(DEFAULT_WEBUI_PASSWORD, "127.0.0.3") + success, _, _ = await service.login("InitialPass123!", "127.0.0.3") assert success is True success, message = await service.change_password( - DEFAULT_WEBUI_PASSWORD, + "InitialPass123!", "NewPass123!", ) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 957f853f..ba1feddb 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -34,6 +34,7 @@ def test_create_default_instance(self): 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" @@ -122,6 +123,7 @@ def test_create_from_basic_config(self): 'enable_auto_learning': False, 'enable_realtime_llm_filter': True, 'enable_webui_password': True, + 'webui_initial_password': 'InitPass123!', 'web_interface_port': 8080, } } @@ -132,6 +134,7 @@ def test_create_from_basic_config(self): 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" diff --git a/tests/unit/test_config_service.py b/tests/unit/test_config_service.py index 94144143..48b8d8d6 100644 --- a/tests/unit/test_config_service.py +++ b/tests/unit/test_config_service.py @@ -163,6 +163,9 @@ async def test_get_config_schema_includes_full_settings(self, tmp_path): 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" @@ -353,6 +356,7 @@ async def test_config_schema_refresh_prefers_grouped_realtime_plugin_page_values "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, @@ -365,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 @@ -373,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): @@ -413,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) @@ -469,6 +504,7 @@ async def test_update_config_syncs_webui_changes_to_plugin_page_config_and_runti "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"], @@ -499,12 +535,14 @@ async def test_update_config_syncs_webui_changes_to_plugin_page_config_and_runti 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", @@ -516,6 +554,13 @@ async def test_update_config_syncs_webui_changes_to_plugin_page_config_and_runti 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/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 = `