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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions _conf_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 启用时间衰减机制
Expand Down Expand Up @@ -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 # 话题检测触发消息增量

# 筛选参数
Expand Down Expand Up @@ -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配置
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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")

Expand Down
4 changes: 4 additions & 0 deletions services/hooks/llm_hook_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion services/learning/message_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions services/learning/realtime_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down Expand Up @@ -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)

Expand All @@ -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}"
Expand Down
81 changes: 81 additions & 0 deletions tests/integration/test_auth_blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -20,6 +29,8 @@ async def app(mock_container):

yield app

container.plugin_config = previous_plugin_config


@pytest.fixture
async def client(app):
Expand Down Expand Up @@ -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"
84 changes: 82 additions & 2 deletions tests/unit/test_auth_service.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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",
}
Loading
Loading