diff --git a/astrbot/core/log.py b/astrbot/core/log.py index 3dd0719b11..a8566f3800 100644 --- a/astrbot/core/log.py +++ b/astrbot/core/log.py @@ -4,6 +4,7 @@ import logging import os import sys +import threading import time from asyncio import Queue from collections import deque @@ -173,6 +174,7 @@ class LogManager: _console_sink_id: int | None = None _file_sink_id: int | None = None _trace_sink_id: int | None = None + _reconfigure_lock = threading.RLock() _NOISY_LOGGER_LEVELS: dict[str, int] = { "aiosqlite": logging.WARNING, "filelock": logging.WARNING, @@ -337,40 +339,22 @@ def _add_file_sink( ) @classmethod - def configure_logger( + def _replace_file_sink( cls, + *, logger: logging.Logger, - config: dict | None, - override_level: str | None = None, + enable_file: bool, + file_path: str | None, + max_mb: int | None, ) -> None: - if not config: - return - - level = override_level or config.get("log_level") - if level: - try: - logger.setLevel(level) - except Exception: - logger.setLevel(logging.INFO) - - if "log_file" in config: - file_conf = config.get("log_file") or {} - enable_file = bool(file_conf.get("enable", False)) - file_path = file_conf.get("path") - max_mb = file_conf.get("max_mb") - else: - enable_file = bool(config.get("log_file_enable", False)) - file_path = config.get("log_file_path") - max_mb = config.get("log_file_max_mb") - - cls._remove_sink(cls._file_sink_id) - cls._file_sink_id = None - if not enable_file: + old_sink_id = cls._file_sink_id + cls._file_sink_id = None + cls._remove_sink(old_sink_id) return try: - cls._file_sink_id = cls._add_file_sink( + new_sink_id = cls._add_file_sink( file_path=cls._resolve_log_path(file_path), level=logger.level, max_mb=max_mb, @@ -379,39 +363,102 @@ def configure_logger( ) except Exception as e: logger.error(f"Failed to add file sink: {e}") - - @classmethod - def configure_trace_logger(cls, config: dict | None) -> None: - if not config: return - enable = bool( - config.get("trace_log_enable") - or (config.get("log_file", {}) or {}).get("trace_enable", False) - ) - path = config.get("trace_log_path") - max_mb = config.get("trace_log_max_mb") - if "log_file" in config: - legacy = config.get("log_file") or {} - path = path or legacy.get("trace_path") - max_mb = max_mb or legacy.get("trace_max_mb") - - trace_logger = logging.getLogger("astrbot.trace") - cls._ensure_logger_enricher_filter(trace_logger) - cls._ensure_logger_intercept_handler(trace_logger) - trace_logger.setLevel(logging.INFO) - trace_logger.propagate = False - - cls._remove_sink(cls._trace_sink_id) - cls._trace_sink_id = None + old_sink_id = cls._file_sink_id + cls._file_sink_id = new_sink_id + cls._remove_sink(old_sink_id) + @classmethod + def _replace_trace_sink( + cls, + *, + enable: bool, + path: str | None, + max_mb: int | None, + ) -> None: if not enable: + old_sink_id = cls._trace_sink_id + cls._trace_sink_id = None + cls._remove_sink(old_sink_id) return - cls._trace_sink_id = cls._add_file_sink( - file_path=cls._resolve_log_path(path or "logs/astrbot.trace.log"), - level=logging.INFO, - max_mb=max_mb, - backup_count=3, - trace=True, - ) + try: + new_sink_id = cls._add_file_sink( + file_path=cls._resolve_log_path(path or "logs/astrbot.trace.log"), + level=logging.INFO, + max_mb=max_mb, + backup_count=3, + trace=True, + ) + except Exception as e: + logging.getLogger("astrbot").error(f"Failed to add trace sink: {e}") + return + + old_sink_id = cls._trace_sink_id + cls._trace_sink_id = new_sink_id + cls._remove_sink(old_sink_id) + + @classmethod + def configure_logger( + cls, + logger: logging.Logger, + config: dict | None, + override_level: str | None = None, + ) -> None: + with cls._reconfigure_lock: + if not config: + return + + level = override_level or config.get("log_level") + if level: + try: + logger.setLevel(level) + except Exception: + logger.setLevel(logging.INFO) + + if "log_file" in config: + file_conf = config.get("log_file") or {} + enable_file = bool(file_conf.get("enable", False)) + file_path = file_conf.get("path") + max_mb = file_conf.get("max_mb") + else: + enable_file = bool(config.get("log_file_enable", False)) + file_path = config.get("log_file_path") + max_mb = config.get("log_file_max_mb") + + cls._replace_file_sink( + logger=logger, + enable_file=enable_file, + file_path=file_path, + max_mb=max_mb, + ) + + @classmethod + def configure_trace_logger(cls, config: dict | None) -> None: + with cls._reconfigure_lock: + if not config: + return + + enable = bool( + config.get("trace_log_enable") + or (config.get("log_file", {}) or {}).get("trace_enable", False) + ) + path = config.get("trace_log_path") + max_mb = config.get("trace_log_max_mb") + if "log_file" in config: + legacy = config.get("log_file") or {} + path = path or legacy.get("trace_path") + max_mb = max_mb or legacy.get("trace_max_mb") + + trace_logger = logging.getLogger("astrbot.trace") + cls._ensure_logger_enricher_filter(trace_logger) + cls._ensure_logger_intercept_handler(trace_logger) + trace_logger.setLevel(logging.INFO) + trace_logger.propagate = False + + cls._replace_trace_sink( + enable=enable, + path=path, + max_mb=max_mb, + ) diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 9ec24d254d..a0eeeb8553 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -8,7 +8,7 @@ from quart import request -from astrbot.core import astrbot_config, file_token_service, logger +from astrbot.core import LogManager, astrbot_config, file_token_service, logger from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.default import ( CONFIG_METADATA_2, @@ -29,6 +29,7 @@ from astrbot.core.utils.llm_metadata import LLM_METADATAS from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config +from .restart_control import mark_runtime_log_config_saved from .route import Response, Route, RouteContext from .util import ( config_key_to_folder, @@ -39,6 +40,120 @@ MAX_FILE_BYTES = 500 * 1024 * 1024 +_RUNTIME_LOG_KEYS = ( + "log_level", + "log_file_enable", + "log_file_path", + "log_file_max_mb", +) + +_RUNTIME_TRACE_LOG_KEYS = ( + "trace_log_enable", + "trace_log_path", + "trace_log_max_mb", +) + + +def _runtime_log_config(conf: dict) -> dict: + legacy = conf.get("log_file") or {} + return { + **{key: copy.deepcopy(conf.get(key)) for key in _RUNTIME_LOG_KEYS}, + "legacy_log_file": { + "enable": copy.deepcopy(legacy.get("enable")), + "path": copy.deepcopy(legacy.get("path")), + "max_mb": copy.deepcopy(legacy.get("max_mb")), + }, + } + + +def _runtime_trace_log_config(conf: dict) -> dict: + legacy = conf.get("log_file") or {} + return { + **{key: copy.deepcopy(conf.get(key)) for key in _RUNTIME_TRACE_LOG_KEYS}, + "legacy_log_file": { + "trace_enable": copy.deepcopy(legacy.get("trace_enable")), + "trace_path": copy.deepcopy(legacy.get("trace_path")), + "trace_max_mb": copy.deepcopy(legacy.get("trace_max_mb")), + }, + } + + +def _config_without_runtime_log_config(conf: dict) -> dict: + conf = copy.deepcopy(conf) + for key in (*_RUNTIME_LOG_KEYS, *_RUNTIME_TRACE_LOG_KEYS): + conf.pop(key, None) + + legacy = conf.get("log_file") + if isinstance(legacy, dict): + for key in ( + "enable", + "path", + "max_mb", + "trace_enable", + "trace_path", + "trace_max_mb", + ): + legacy.pop(key, None) + if not legacy: + conf.pop("log_file", None) + + return conf + + +def _runtime_log_config_changed(old_config: dict, new_config: dict) -> bool: + return _runtime_log_config(old_config) != _runtime_log_config( + new_config + ) or _runtime_trace_log_config(old_config) != _runtime_trace_log_config(new_config) + + +def _system_config_save_requires_restart(old_config: dict, new_config: dict) -> bool: + if old_config == new_config: + return False + + return _config_without_runtime_log_config( + old_config + ) != _config_without_runtime_log_config(new_config) + + +def _apply_runtime_log_config_if_changed( + old_config: dict, + new_config: dict, +) -> bool: + old_log_config = _runtime_log_config(old_config) + new_log_config = _runtime_log_config(new_config) + old_trace_config = _runtime_trace_log_config(old_config) + new_trace_config = _runtime_trace_log_config(new_config) + + if old_log_config == new_log_config and old_trace_config == new_trace_config: + return False + + updated = False + + if old_log_config != new_log_config: + try: + LogManager.configure_logger(logger, new_config) + updated = True + except Exception: + logger.error( + "Failed to update runtime logger:\n%s", + traceback.format_exc(), + ) + + if old_trace_config != new_trace_config: + try: + LogManager.configure_trace_logger(new_config) + updated = True + except Exception: + logger.error( + "Failed to update runtime trace logger:\n%s", + traceback.format_exc(), + ) + + if updated: + logger.info("Runtime log configuration updated.") + + return updated + def try_cast(value: Any, type_: str): if type_ == "int": @@ -300,10 +415,15 @@ async def _validate_neo_connectivity( def save_config( - post_config: dict, config: AstrBotConfig, is_core: bool = False -) -> None: + post_config: dict, + config: AstrBotConfig, + is_core: bool = False, + old_config_snapshot: dict | None = None, +) -> bool: """验证并保存配置""" errors = None + if is_core and old_config_snapshot is None: + old_config_snapshot = copy.deepcopy(dict(config)) # Snapshot old Computer config for change detection if is_core: @@ -329,6 +449,11 @@ def save_config( config.save_config(post_config) + if is_core and old_config_snapshot is not None: + return _apply_runtime_log_config_if_changed(old_config_snapshot, dict(config)) + + return False + class ConfigRoute(Route): def __init__( @@ -1021,14 +1146,15 @@ async def post_astrbot_configs(self): for key in no_update_keys: config[key] = self.acm.default_conf[key] - await self._save_astrbot_configs(config, conf_id) + save_result = await self._save_astrbot_configs(config, conf_id) await self.core_lifecycle.reload_pipeline_scheduler(conf_id) # Non-blocking Bay connectivity check warning = await _validate_neo_connectivity(config) + response_data = {"requires_restart": save_result["requires_restart"]} if warning: - return Response().ok(None, f"保存成功。{warning}").__dict__ - return Response().ok(None, "保存成功~").__dict__ + return Response().ok(response_data, f"保存成功。{warning}").__dict__ + return Response().ok(response_data, "保存成功~").__dict__ except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ @@ -1516,11 +1642,12 @@ async def _get_plugin_config(self, plugin_name: str): async def _save_astrbot_configs( self, post_configs: dict, conf_id: str | None = None - ) -> None: + ) -> dict: try: if conf_id not in self.acm.confs: raise ValueError(f"配置文件 {conf_id} 不存在") astrbot_config = self.acm.confs[conf_id] + old_config_snapshot = copy.deepcopy(dict(astrbot_config)) # 保留服务端的 t2i_active_template 值 if "t2i_active_template" in astrbot_config: @@ -1528,7 +1655,20 @@ async def _save_astrbot_configs( "t2i_active_template" ] - save_config(post_configs, astrbot_config, is_core=True) + runtime_log_config_updated = save_config( + post_configs, + astrbot_config, + is_core=True, + old_config_snapshot=old_config_snapshot, + ) + requires_restart = _system_config_save_requires_restart( + old_config_snapshot, + dict(astrbot_config), + ) + if runtime_log_config_updated and not requires_restart: + mark_runtime_log_config_saved() + + return {"requires_restart": requires_restart} except Exception as e: raise e diff --git a/astrbot/dashboard/routes/restart_control.py b/astrbot/dashboard/routes/restart_control.py new file mode 100644 index 0000000000..0d2bc31faa --- /dev/null +++ b/astrbot/dashboard/routes/restart_control.py @@ -0,0 +1,15 @@ +import time + +_RUNTIME_LOG_SAVE_RESTART_SKIP_SECONDS = 8 +_runtime_log_save_restart_skip_until = 0.0 + + +def mark_runtime_log_config_saved() -> None: + global _runtime_log_save_restart_skip_until + _runtime_log_save_restart_skip_until = ( + time.monotonic() + _RUNTIME_LOG_SAVE_RESTART_SKIP_SECONDS + ) + + +def should_skip_restart_after_runtime_log_config_save() -> bool: + return time.monotonic() < _runtime_log_save_restart_skip_until diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py index b02091d5d4..eb44c1ed80 100644 --- a/astrbot/dashboard/routes/stat.py +++ b/astrbot/dashboard/routes/stat.py @@ -25,6 +25,7 @@ from astrbot.core.utils.storage_cleaner import StorageCleaner from astrbot.core.utils.version_comparator import VersionComparator +from .restart_control import should_skip_restart_after_runtime_log_config_save from .route import Response, Route, RouteContext @@ -62,6 +63,12 @@ async def restart_core(self): .__dict__ ) + if should_skip_restart_after_runtime_log_config_save(): + logger.info( + "Skipped restart request after runtime log configuration update.", + ) + return Response().ok(message="日志配置已实时生效,无需重启。").__dict__ + await self.core_lifecycle.restart() return Response().ok().__dict__ diff --git a/dashboard/src/views/ConfigPage.vue b/dashboard/src/views/ConfigPage.vue index 0af226afbd..3879a630ad 100644 --- a/dashboard/src/views/ConfigPage.vue +++ b/dashboard/src/views/ConfigPage.vue @@ -221,6 +221,25 @@ import { import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue'; import { normalizeTextInput } from '@/utils/inputValue'; +const RUNTIME_LOG_CONFIG_KEYS = [ + 'log_level', + 'log_file_enable', + 'log_file_path', + 'log_file_max_mb', + 'trace_log_enable', + 'trace_log_path', + 'trace_log_max_mb' +]; + +const LEGACY_RUNTIME_LOG_FILE_KEYS = [ + 'enable', + 'path', + 'max_mb', + 'trace_enable', + 'trace_path', + 'trace_max_mb' +]; + export default { name: 'ConfigPage', components: { @@ -264,7 +283,12 @@ export default { } else if (confirmed) { const result = await this.updateConfig(); if (this.isSystemConfig) { - next(false); + if (result?.success && !result?.restarted) { + await new Promise(resolve => setTimeout(resolve, 800)); + next(); + } else { + next(false); + } } else { if (result?.success) { await new Promise(resolve => setTimeout(resolve, 800)); @@ -540,18 +564,30 @@ export default { postData.conf_id = this.selectedConfigID; } + const previousConfig = this.parseConfigSnapshot(this.lastSavedConfigSnapshot); + const localShouldRestart = this.isSystemConfig && this.shouldRestartAfterSystemConfigSave( + previousConfig, + postData.config + ); + return axios.post('/api/config/astrbot/update', postData).then((res) => { if (res.data.status === "ok") { + const responseData = res.data.data || {}; + const shouldRestart = this.isSystemConfig && ( + typeof responseData.requires_restart === 'boolean' + ? responseData.requires_restart + : localShouldRestart + ); this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data); this.save_message = res.data.message || this.messages.saveSuccess; this.save_message_snack = true; this.save_message_success = "success"; this.onConfigSaved(); - if (this.isSystemConfig) { + if (shouldRestart) { restartAstrBotRuntime(this.$refs.wfr).catch(() => {}) } - return { success: true }; + return { success: true, restarted: shouldRestart }; } else { this.save_message = res.data.message || this.messages.saveError; this.save_message_snack = true; @@ -880,6 +916,37 @@ export default { }, getConfigSnapshot(config) { return JSON.stringify(config ?? {}); + }, + parseConfigSnapshot(snapshot) { + if (!snapshot) { + return {}; + } + try { + return JSON.parse(snapshot); + } catch (_error) { + return {}; + } + }, + getConfigWithoutRuntimeLogConfig(config) { + const cloned = JSON.parse(JSON.stringify(config ?? {})); + for (const key of RUNTIME_LOG_CONFIG_KEYS) { + delete cloned[key]; + } + if (cloned.log_file && typeof cloned.log_file === 'object') { + for (const key of LEGACY_RUNTIME_LOG_FILE_KEYS) { + delete cloned.log_file[key]; + } + if (Object.keys(cloned.log_file).length === 0) { + delete cloned.log_file; + } + } + return cloned; + }, + shouldRestartAfterSystemConfigSave(previousConfig, nextConfig) { + const previousWithoutLog = this.getConfigWithoutRuntimeLogConfig(previousConfig); + const nextWithoutLog = this.getConfigWithoutRuntimeLogConfig(nextConfig); + + return this.getConfigSnapshot(previousWithoutLog) !== this.getConfigSnapshot(nextWithoutLog); } }, } diff --git a/tests/test_log_manager_runtime.py b/tests/test_log_manager_runtime.py new file mode 100644 index 0000000000..b2210606a7 --- /dev/null +++ b/tests/test_log_manager_runtime.py @@ -0,0 +1,93 @@ +import logging + +import pytest + +from astrbot.core.log import LogManager + + +@pytest.fixture(autouse=True) +def restore_log_manager_sinks(): + old_file_sink_id = LogManager._file_sink_id + old_trace_sink_id = LogManager._trace_sink_id + try: + yield + finally: + LogManager._file_sink_id = old_file_sink_id + LogManager._trace_sink_id = old_trace_sink_id + + +def test_invalid_new_file_sink_keeps_existing_sink(monkeypatch): + removed_sink_ids = [] + test_logger = logging.getLogger("astrbot.test.runtime") + LogManager._file_sink_id = 10 + + def fail_add_file_sink(**kwargs): + raise OSError("path is not writable") + + monkeypatch.setattr(LogManager, "_add_file_sink", fail_add_file_sink) + monkeypatch.setattr(LogManager, "_remove_sink", removed_sink_ids.append) + + LogManager._replace_file_sink( + logger=test_logger, + enable_file=True, + file_path="bad/path/astrbot.log", + max_mb=20, + ) + + assert LogManager._file_sink_id == 10 + assert removed_sink_ids == [] + + +def test_successful_file_sink_replace_removes_old_sink(monkeypatch): + removed_sink_ids = [] + test_logger = logging.getLogger("astrbot.test.runtime") + LogManager._file_sink_id = 10 + + monkeypatch.setattr(LogManager, "_add_file_sink", lambda **kwargs: 11) + monkeypatch.setattr(LogManager, "_remove_sink", removed_sink_ids.append) + + LogManager._replace_file_sink( + logger=test_logger, + enable_file=True, + file_path="logs/astrbot.log", + max_mb=20, + ) + + assert LogManager._file_sink_id == 11 + assert removed_sink_ids == [10] + + +def test_invalid_new_trace_sink_keeps_existing_sink(monkeypatch): + removed_sink_ids = [] + LogManager._trace_sink_id = 20 + + def fail_add_trace_sink(**kwargs): + raise OSError("path is not writable") + + monkeypatch.setattr(LogManager, "_add_file_sink", fail_add_trace_sink) + monkeypatch.setattr(LogManager, "_remove_sink", removed_sink_ids.append) + + LogManager._replace_trace_sink( + enable=True, + path="bad/path/astrbot.trace.log", + max_mb=20, + ) + + assert LogManager._trace_sink_id == 20 + assert removed_sink_ids == [] + + +def test_disabling_trace_sink_removes_existing_sink(monkeypatch): + removed_sink_ids = [] + LogManager._trace_sink_id = 20 + + monkeypatch.setattr(LogManager, "_remove_sink", removed_sink_ids.append) + + LogManager._replace_trace_sink( + enable=False, + path="logs/astrbot.trace.log", + max_mb=20, + ) + + assert LogManager._trace_sink_id is None + assert removed_sink_ids == [20] diff --git a/tests/test_runtime_log_config_restart.py b/tests/test_runtime_log_config_restart.py new file mode 100644 index 0000000000..eb45645f9d --- /dev/null +++ b/tests/test_runtime_log_config_restart.py @@ -0,0 +1,41 @@ +from astrbot.dashboard.routes.config import ( + _runtime_log_config_changed, + _system_config_save_requires_restart, +) + + +def test_log_level_change_does_not_require_restart(): + old_config = {"log_level": "INFO", "timezone": "Asia/Shanghai"} + new_config = {"log_level": "DEBUG", "timezone": "Asia/Shanghai"} + + assert _runtime_log_config_changed(old_config, new_config) + assert not _system_config_save_requires_restart(old_config, new_config) + + +def test_legacy_log_file_change_does_not_require_restart(): + old_config = { + "timezone": "Asia/Shanghai", + "log_file": {"enable": False, "path": "logs/astrbot.log"}, + } + new_config = { + "timezone": "Asia/Shanghai", + "log_file": {"enable": True, "path": "logs/astrbot.log"}, + } + + assert _runtime_log_config_changed(old_config, new_config) + assert not _system_config_save_requires_restart(old_config, new_config) + + +def test_non_log_config_change_requires_restart(): + old_config = {"log_level": "INFO", "timezone": "Asia/Shanghai"} + new_config = {"log_level": "INFO", "timezone": "UTC"} + + assert not _runtime_log_config_changed(old_config, new_config) + assert _system_config_save_requires_restart(old_config, new_config) + + +def test_no_config_change_does_not_require_restart(): + config = {"log_level": "INFO", "timezone": "Asia/Shanghai"} + + assert not _runtime_log_config_changed(config, config) + assert not _system_config_save_requires_restart(config, config)