feat: improve log filtering and console workspace

This commit is contained in:
Soulter
2026-10-12 00:37:18 +08:00
parent 48ba71ad74
commit edbc84dbb9
21 changed files with 903 additions and 317 deletions
+7 -2
View File
@@ -69,15 +69,20 @@ class EventBus:
event (AstrMessageEvent): 事件对象
"""
log_extra = {
"category": "user_chat",
"platform_id": event.get_platform_id(),
"platform_type": event.get_platform_name(),
}
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
if event.get_sender_name():
logger.info(
f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}",
extra={"category": "user_chat"},
extra=log_extra,
)
# 没有发送者名称: [平台名] 发送者ID: 消息概要
else:
logger.info(
f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_id()}: {event.get_message_outline()}",
extra={"category": "user_chat"},
extra=log_extra,
)
+81 -4
View File
@@ -9,6 +9,10 @@ import tempfile
import time
from asyncio import Queue
from collections import deque
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
@@ -28,6 +32,54 @@ PLUGIN_LOGGER_PREFIX = "astrbot.plugin."
PLUGIN_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
"""Allowed per-plugin log levels."""
@dataclass
class LogContext:
"""Platform metadata attached to logs emitted inside an async operation.
Attributes:
platform_id: Unique configured platform instance ID.
platform_type: Platform adapter type, such as ``telegram``.
active: Whether copied async contexts may still use this metadata.
"""
platform_id: str | None
platform_type: str | None
active: bool = True
_log_context: ContextVar[LogContext | None] = ContextVar(
"astrbot_log_context",
default=None,
)
@contextmanager
def log_context(
platform_id: str | None,
platform_type: str | None,
) -> Iterator[None]:
"""Bind platform metadata to logs in the current execution context.
Copied contexts share the same mutable object. Marking it inactive on exit
prevents detached tasks from retaining stale event platform metadata.
Args:
platform_id: Unique configured platform instance ID.
platform_type: Platform adapter type.
Yields:
Control while the platform metadata is active.
"""
context = LogContext(platform_id=platform_id, platform_type=platform_type)
token = _log_context.set(context)
try:
yield
finally:
context.active = False
_log_context.reset(token)
if TYPE_CHECKING:
from loguru import Record
@@ -38,11 +90,15 @@ class _RecordEnricherFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
if record.name.startswith(PLUGIN_LOGGER_PREFIX):
# Records from a per-plugin logger are tagged with the plugin name.
record.plugin_tag = f"[{record.name[len(PLUGIN_LOGGER_PREFIX) :]}]"
plugin_name = record.name[len(PLUGIN_LOGGER_PREFIX) :]
record.plugin_tag = f"[{plugin_name}]"
record.source = "plugin"
record.plugin_name = plugin_name
else:
record.plugin_tag = (
"[Plug]" if _is_plugin_path(record.pathname) else "[Core]"
)
is_plugin = _is_plugin_path(record.pathname)
record.plugin_tag = "[Plug]" if is_plugin else "[Core]"
record.source = "plugin" if is_plugin else "core"
record.plugin_name = None
record.short_levelname = _get_short_level_name(record.levelname)
record.astrbot_version_tag = (
f" [v{VERSION}]" if record.levelno >= logging.WARNING else ""
@@ -51,6 +107,14 @@ class _RecordEnricherFilter(logging.Filter):
record.source_line = record.lineno
record.is_trace = record.name == "astrbot.trace"
record.category = getattr(record, "category", None) or "system"
context = _log_context.get()
context_is_active = context is not None and context.active
record.platform_id = getattr(record, "platform_id", None) or (
context.platform_id if context_is_active else None
)
record.platform_type = getattr(record, "platform_type", None) or (
context.platform_type if context_is_active else None
)
return True
@@ -108,6 +172,10 @@ def _patch_record(record: "Record") -> None:
extra.setdefault("source_line", record["line"])
extra.setdefault("is_trace", False)
extra.setdefault("category", "system")
extra.setdefault("source", "core")
extra.setdefault("plugin_name", None)
extra.setdefault("platform_id", None)
extra.setdefault("platform_type", None)
_loguru = _raw_loguru_logger.patch(_patch_record)
@@ -135,6 +203,11 @@ class _LoguruInterceptHandler(logging.Handler):
),
"source_line": getattr(record, "source_line", record.lineno),
"is_trace": getattr(record, "is_trace", record.name == "astrbot.trace"),
"category": getattr(record, "category", None) or "system",
"source": getattr(record, "source", "core"),
"plugin_name": getattr(record, "plugin_name", None),
"platform_id": getattr(record, "platform_id", None),
"platform_type": getattr(record, "platform_type", None),
}
_loguru.bind(**payload).opt(exception=record.exc_info).log(
@@ -182,6 +255,10 @@ class LogQueueHandler(logging.Handler):
"time": time.time(),
"data": log_entry,
"category": getattr(record, "category", None) or "system",
"source": getattr(record, "source", "core"),
"plugin_name": getattr(record, "plugin_name", None),
"platform_id": getattr(record, "platform_id", None),
"platform_type": getattr(record, "platform_type", None),
},
)
+11 -9
View File
@@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator
from typing import cast
from astrbot.core import logger
from astrbot.core.log import log_context
from astrbot.core.platform import AstrMessageEvent
from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEvent
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
@@ -87,14 +88,15 @@ class PipelineScheduler:
"""
active_event_registry.register(event)
try:
await self._process_stages(event)
with log_context(event.get_platform_id(), event.get_platform_name()):
try:
await self._process_stages(event)
# 发送一个空消息, 以便于后续的处理
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
# Send an empty message for platform-specific completion handling.
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
await event.send(None)
logger.debug("pipeline execution completed.")
finally:
event.cleanup_temporary_local_files()
active_event_registry.unregister(event)
logger.debug("pipeline execution completed.")
finally:
event.cleanup_temporary_local_files()
active_event_registry.unregister(event)
+42 -19
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from astrbot.core import logger
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.log import log_context
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
@@ -49,7 +50,10 @@ class PlatformManager:
return sanitized, sanitized != platform_id
def _start_platform_task(self, task_name: str, inst: Platform) -> None:
run_task = asyncio.create_task(inst.run(), name=task_name)
run_task = asyncio.create_task(
self._run_platform(inst),
name=task_name,
)
wrapper_task = asyncio.create_task(
self._task_wrapper(run_task, platform=inst),
name=f"{task_name}_wrapper",
@@ -59,6 +63,17 @@ class PlatformManager:
wrapper=wrapper_task,
)
async def _run_platform(self, inst: Platform) -> None:
"""Run an adapter with its platform metadata bound to emitted logs.
Args:
inst: Platform adapter instance to run.
"""
meta = inst.meta()
platform_id = meta.id or inst.config.get("id")
with log_context(platform_id, meta.name):
await inst.run()
async def _stop_platform_task(self, client_id: str) -> None:
tasks = self._platform_tasks.pop(client_id, None)
if not tasks:
@@ -130,6 +145,10 @@ class PlatformManager:
"Loading IM platform adapter %s(%s) ...",
platform_config["type"],
platform_config["id"],
extra={
"platform_id": platform_config["id"],
"platform_type": platform_config["type"],
},
)
match platform_config["type"]:
case "aiocqhttp":
@@ -240,26 +259,30 @@ class PlatformManager:
async def _task_wrapper(
self, task: asyncio.Task, platform: Platform | None = None
) -> None:
# 设置平台状态为运行中
if platform:
platform.status = PlatformStatus.RUNNING
try:
await task
except asyncio.CancelledError:
meta = platform.meta() if platform else None
platform_id = (meta.id or platform.config.get("id")) if meta else None
platform_type = meta.name if meta else None
with log_context(platform_id, platform_type):
# 设置平台状态为运行中
if platform:
platform.status = PlatformStatus.STOPPED
except Exception as e:
error_msg = str(e)
tb_str = traceback.format_exc()
logger.error(f"------- Task {task.get_name()} failed: {e}")
for line in tb_str.split("\n"):
logger.error(f"| {line}")
logger.error("-------")
platform.status = PlatformStatus.RUNNING
# 记录错误到平台实例
if platform:
platform.record_error(error_msg, tb_str)
try:
await task
except asyncio.CancelledError:
if platform:
platform.status = PlatformStatus.STOPPED
except Exception as e:
error_msg = str(e)
tb_str = traceback.format_exc()
logger.error(f"------- Task {task.get_name()} failed: {e}")
for line in tb_str.split("\n"):
logger.error(f"| {line}")
logger.error("-------")
# 记录错误到平台实例
if platform:
platform.record_error(error_msg, tb_str)
async def reload(self, platform_config: dict) -> None:
await self.terminate_platform(platform_config["id"])
+12 -10
View File
@@ -5,6 +5,7 @@ import string
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.log import log_context
from astrbot.core.platform import Platform
from astrbot.core.platform.sources.dingtalk.app_registration import (
poll_dingtalk_app_registration_once,
@@ -46,16 +47,17 @@ class PlatformService:
logger.warning(f"未找到 webhook_uuid 为 {webhook_uuid} 的平台")
raise PlatformServiceError("未找到对应平台", 404)
try:
return await platform_adapter.webhook_callback(request_obj)
except NotImplementedError as exc:
logger.error(
f"平台 {platform_adapter.meta().name} 未实现 webhook_callback 方法"
)
raise PlatformServiceError("平台未支持统一 Webhook 模式", 500) from exc
except Exception as exc:
logger.error(f"处理 webhook 回调时发生错误: {exc}", exc_info=True)
raise PlatformServiceError("处理回调失败", 500) from exc
meta = platform_adapter.meta()
platform_id = meta.id or platform_adapter.config.get("id")
with log_context(platform_id, meta.name):
try:
return await platform_adapter.webhook_callback(request_obj)
except NotImplementedError as exc:
logger.error(f"平台 {meta.name} 未实现 webhook_callback 方法")
raise PlatformServiceError("平台未支持统一 Webhook 模式", 500) from exc
except Exception as exc:
logger.error(f"处理 webhook 回调时发生错误: {exc}", exc_info=True)
raise PlatformServiceError("处理回调失败", 500) from exc
def find_platform_by_uuid(self, webhook_uuid: str) -> Platform | None:
for platform in self.platform_manager.platform_insts:
+8
View File
@@ -1 +1,9 @@
# Unreleased
## Logs
- Add structured source, plugin, platform, and category metadata for in-memory log filtering.
- Logs: top-row filters → the Filters sidebar, with combined level/source/plugin/platform/keyword filtering and an option to hide user-chat logs.
- Core source label → System; plugin selectors prefer display names and show IDs as subtitles.
- Auto-scroll switch → automatic bottom following, preserving the reading position when viewing older logs.
- Add 8–16px font controls (12px default, 2px steps) alongside fullscreen and Pip installation.
@@ -1,4 +1,4 @@
/* Auto-generated MDI subset – 266 icons */
/* Auto-generated MDI subset – 267 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -484,6 +484,10 @@
content: "\F0234";
}
.mdi-filter-variant::before {
content: "\F0236";
}
.mdi-folder-move::before {
content: "\F0252";
}
@@ -1,6 +1,5 @@
<script setup>
import { useCommonStore } from "@/stores/common";
import { logApi } from "@/api/v1";
import { logApi, pluginApi } from "@/api/v1";
import { useModuleI18n } from "@/i18n/composables";
import { normalizeTextInput } from "@/utils/inputValue";
import { EventSourcePolyfill } from "event-source-polyfill";
@@ -16,7 +15,7 @@ const { tm } = useModuleI18n("features/console");
>
<div class="filter-controls mb-2" v-if="showLevelBtns || showSearch">
<v-chip-group
v-if="showLevelBtns"
v-if="showLevelBtns && !workspaceMode"
v-model="selectedLevels"
class="log-level-filters"
column
@@ -37,6 +36,18 @@ const { tm } = useModuleI18n("features/console");
{{ level }}
</v-chip>
</v-chip-group>
<v-btn
v-if="workspaceMode"
class="filter-toggle-btn"
size="small"
:variant="filtersOpen || hasActiveFilters ? 'tonal' : 'text'"
:aria-expanded="filtersOpen"
@click="filtersOpen = !filtersOpen"
>
<v-icon icon="mdi-filter-variant" size="17" />
<span>{{ tm("filters.button") }}</span>
<span v-if="hasActiveFilters" class="filter-status-dot" />
</v-btn>
<v-text-field
v-if="showSearch"
:model-value="searchInput"
@@ -63,16 +74,126 @@ const { tm } = useModuleI18n("features/console");
></v-btn>
</div>
<div id="term" class="console-term"></div>
<div class="console-body">
<aside v-if="workspaceMode && filtersOpen" class="console-filter-panel">
<div class="console-filter-heading">
<span>{{ tm("filters.title") }}</span>
<v-btn
v-if="hasActiveFilters"
size="small"
variant="text"
:aria-label="tm('filters.reset')"
@click="resetFilters"
>
{{ tm("filters.reset") }}
</v-btn>
</div>
<div class="console-filter-block console-filter-block--first">
<div class="console-filter-label">{{ tm("filters.level") }}</div>
<label
v-for="(level, index) in logLevels"
:key="level"
class="console-filter-option"
>
<v-checkbox-btn
v-model="selectedLevels"
:value="index"
density="compact"
hide-details
/>
<span
class="level-indicator"
:style="{ backgroundColor: getLevelIndicatorColor(level) }"
/>
<span>{{ level }}</span>
</label>
</div>
<div class="console-filter-block">
<div class="console-filter-label">{{ tm("filters.source") }}</div>
<v-radio-group
v-model="selectedSource"
hide-details
density="compact"
>
<v-radio :label="tm('filters.allSources')" value="all" />
<v-radio :label="tm('filters.core')" value="core" />
<v-radio :label="tm('filters.plugins')" value="plugin" />
</v-radio-group>
</div>
<div class="console-filter-block">
<div class="console-filter-label">{{ tm("filters.plugin") }}</div>
<v-select
v-model="selectedPlugin"
:items="pluginOptions"
density="compact"
variant="solo-filled"
flat
hide-details
clearable
item-title="title"
item-value="value"
:disabled="selectedSource === 'core' || pluginOptions.length === 0"
:placeholder="tm('filters.allPlugins')"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps" :subtitle="item.raw.subtitle" />
</template>
</v-select>
</div>
<div class="console-filter-block">
<div class="console-filter-label">{{ tm("filters.platform") }}</div>
<v-select
v-model="selectedPlatform"
:items="platformOptions"
density="compact"
variant="solo-filled"
flat
hide-details
clearable
item-title="title"
item-value="value"
:placeholder="tm('filters.allPlatforms')"
/>
</div>
<div class="console-filter-block console-filter-block--last">
<v-switch
:model-value="hideUserChat"
:label="tm('hideUserChat.label')"
hide-details
density="compact"
inset
color="primary"
@update:model-value="$emit('update:hideUserChat', $event)"
/>
</div>
</aside>
<div
id="term"
class="console-term"
:style="{ '--console-log-font-size': `${fontSize}px` }"
></div>
</div>
</div>
</template>
<script>
import { useModuleI18n as useConsoleI18n } from "@/i18n/composables";
const { tm: translateConsole } = useConsoleI18n("features/console");
export default {
name: "ConsoleDisplayer",
emits: ["update:hideUserChat"],
data() {
return {
isFullscreen: false,
filtersOpen: false,
logColorAnsiMap: {
"\u001b[1;34m": "color: #6cb6d9; font-weight: bold;",
"\u001b[1;36m": "color: #72c4cc; font-weight: bold;",
@@ -93,6 +214,12 @@ export default {
CRITICAL: "purple",
},
localLogCache: [],
maxLogCacheLength: 1000,
nextLogId: 1,
pluginDisplayNames: {},
selectedSource: "all",
selectedPlugin: null,
selectedPlatform: null,
searchInput: "",
// Trimmed keyword; an empty string disables filtering.
searchKeyword: "",
@@ -106,8 +233,58 @@ export default {
};
},
computed: {
commonStore() {
return useCommonStore();
hasActiveFilters() {
return (
this.selectedLevels.length !== this.logLevels.length ||
this.selectedSource !== "all" ||
Boolean(this.selectedPlugin) ||
Boolean(this.selectedPlatform) ||
this.hideUserChat
);
},
pluginOptions() {
const pluginNames = Array.from(
new Set(
this.localLogCache
.map((log) => log.plugin_name)
.filter((name) => typeof name === "string" && name.length > 0),
),
);
return pluginNames
.map((value) => ({
title: this.pluginDisplayNames[value] || value,
subtitle: value,
value,
}))
.sort(
(a, b) =>
a.title.localeCompare(b.title) || a.value.localeCompare(b.value),
);
},
platformOptions() {
const platforms = new Map();
let hasUnscopedLogs = false;
this.localLogCache.forEach((log) => {
if (!log.platform_id) {
hasUnscopedLogs = true;
return;
}
const type = log.platform_type ? ` (${log.platform_type})` : "";
platforms.set(log.platform_id, {
title: `${log.platform_id}${type}`,
value: log.platform_id,
});
});
const options = Array.from(platforms.values()).sort((a, b) =>
a.title.localeCompare(b.title),
);
if (hasUnscopedLogs) {
options.unshift({
title: translateConsole("filters.unscopedPlatform"),
value: "__unscoped__",
});
}
return options;
},
},
props: {
@@ -127,9 +304,9 @@ export default {
type: Boolean,
default: false,
},
autoScroll: {
type: Boolean,
default: true,
fontSize: {
type: Number,
default: 12,
},
workspaceMode: {
type: Boolean,
@@ -146,6 +323,18 @@ export default {
hideUserChat() {
this.refreshDisplay();
},
selectedSource(value) {
if (value === "core") {
this.selectedPlugin = null;
}
this.refreshDisplay();
},
selectedPlugin() {
this.refreshDisplay();
},
selectedPlatform() {
this.refreshDisplay();
},
searchInput: {
handler(value) {
if (this.searchTimer) {
@@ -165,6 +354,9 @@ export default {
},
async mounted() {
await this.fetchLogHistory();
if (this.workspaceMode) {
await this.fetchPluginDisplayNames();
}
this.connectSSE();
document.addEventListener("fullscreenchange", this.handleFullscreenChange);
},
@@ -276,6 +468,12 @@ export default {
let hasUpdate = false;
const termElement = document.getElementById("term");
const shouldStickToBottom =
!termElement ||
termElement.scrollHeight -
termElement.scrollTop -
termElement.clientHeight <=
24;
// Batch the rendered log elements into a single fragment so that a large
// history payload only triggers one reflow instead of one per log line.
const fragment = termElement ? document.createDocumentFragment() : null;
@@ -289,16 +487,13 @@ export default {
);
if (!exists) {
log._renderId = this.nextLogId++;
this.localLogCache.push(log);
hasUpdate = true;
if (
this.isLevelSelected(log.level) &&
!this.isHiddenByCategory(log) &&
this.matchesKeyword(log)
) {
if (this.isLogVisible(log)) {
if (fragment) {
fragment.appendChild(this.buildLogElement(log.data));
fragment.appendChild(this.buildLogElement(log));
}
}
}
@@ -307,15 +502,22 @@ export default {
if (hasUpdate) {
this.localLogCache.sort((a, b) => a.time - b.time);
const maxSize = this.commonStore.log_cache_max_len || 200;
if (this.localLogCache.length > maxSize) {
this.localLogCache.splice(0, this.localLogCache.length - maxSize);
if (this.localLogCache.length > this.maxLogCacheLength) {
const removed = this.localLogCache.splice(
0,
this.localLogCache.length - this.maxLogCacheLength,
);
removed.forEach((log) => {
termElement
?.querySelector(`[data-log-id="${log._renderId}"]`)
?.remove();
});
}
}
if (fragment && fragment.childNodes.length > 0) {
termElement.appendChild(fragment);
if (this.autoScroll) {
if (shouldStickToBottom) {
termElement.scrollTop = termElement.scrollHeight;
}
}
@@ -332,10 +534,46 @@ export default {
}
},
async fetchPluginDisplayNames() {
try {
const res = await pluginApi.list();
const displayNames = {};
for (const plugin of res.data?.data || []) {
const name = String(plugin?.name || "").trim();
const displayName = String(plugin?.display_name || "").trim();
if (name && displayName) {
displayNames[name] = displayName;
}
}
this.pluginDisplayNames = displayNames;
} catch (err) {
console.warn("Failed to load plugin display names:", err);
}
},
getLevelColor(level) {
return this.levelColors[level] || "grey";
},
getLevelIndicatorColor(level) {
const colors = {
DEBUG: "#9aa0a6",
INFO: "#67b7dc",
WARNING: "#d4ad45",
ERROR: "#df6262",
CRITICAL: "#bb68d5",
};
return colors[level] || "#9aa0a6";
},
resetFilters() {
this.selectedLevels = this.logLevels.map((_, index) => index);
this.selectedSource = "all";
this.selectedPlugin = null;
this.selectedPlatform = null;
this.$emit("update:hideUserChat", false);
},
isLevelSelected(level) {
for (let i = 0; i < this.selectedLevels.length; ++i) {
let level_ = this.logLevels[this.selectedLevels[i]];
@@ -350,6 +588,38 @@ export default {
return this.hideUserChat && log && log.category === "user_chat";
},
matchesSource(log) {
if (this.selectedSource === "all") {
return true;
}
return (log.source || "core") === this.selectedSource;
},
matchesPlugin(log) {
return !this.selectedPlugin || log.plugin_name === this.selectedPlugin;
},
matchesPlatform(log) {
if (!this.selectedPlatform) {
return true;
}
if (this.selectedPlatform === "__unscoped__") {
return !log.platform_id;
}
return log.platform_id === this.selectedPlatform;
},
isLogVisible(log) {
return (
this.isLevelSelected(log.level) &&
!this.isHiddenByCategory(log) &&
this.matchesSource(log) &&
this.matchesPlugin(log) &&
this.matchesPlatform(log) &&
this.matchesKeyword(log)
);
},
matchesKeyword(log) {
if (!this.searchKeyword) {
return true;
@@ -400,22 +670,26 @@ export default {
const termElement = document.getElementById("term");
if (!termElement) return;
const shouldStickToBottom =
termElement.scrollHeight -
termElement.scrollTop -
termElement.clientHeight <=
24;
const previousScrollTop = termElement.scrollTop;
termElement.innerHTML = "";
if (!this.localLogCache || this.localLogCache.length === 0) return;
const fragment = document.createDocumentFragment();
this.localLogCache.forEach((logItem) => {
if (
this.isLevelSelected(logItem.level) &&
!this.isHiddenByCategory(logItem) &&
this.matchesKeyword(logItem)
) {
fragment.appendChild(this.buildLogElement(logItem.data));
if (this.isLogVisible(logItem)) {
fragment.appendChild(this.buildLogElement(logItem));
}
});
termElement.appendChild(fragment);
if (this.autoScroll) {
if (shouldStickToBottom) {
termElement.scrollTop = termElement.scrollHeight;
} else {
termElement.scrollTop = previousScrollTop;
}
},
@@ -468,8 +742,10 @@ export default {
element.appendChild(messageSpan);
},
buildLogElement(log) {
buildLogElement(logItem) {
const span = document.createElement("pre");
span.dataset.logId = String(logItem._renderId);
let log = logItem.data || "";
let style = this.logColorAnsiMap["default"];
for (const key in this.logColorAnsiMap) {
if (log.startsWith(key)) {
@@ -523,37 +799,148 @@ export default {
.filter-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: 8px;
margin-bottom: 8px;
}
.log-search-field {
flex: 0 1 260px;
max-width: 260px;
flex: 1 1 360px;
max-width: 520px;
min-width: 160px;
}
.filter-toggle-btn {
flex: 0 0 auto;
}
.filter-toggle-btn :deep(.v-btn__content) {
gap: 5px;
}
.filter-status-dot {
background: rgb(var(--v-theme-primary));
border-radius: 50%;
height: 6px;
width: 6px;
}
.console-body {
display: flex;
flex: 1 1 auto;
gap: 10px;
min-height: 0;
min-width: 0;
overflow: hidden;
position: relative;
}
.console-term {
background-color: #1e1e1e;
border-radius: 8px;
height: 100%;
overflow-y: auto;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow-x: auto;
overflow-y: auto;
padding: 16px;
}
.console-displayer-wrapper--workspace .filter-controls {
flex: 0 0 auto;
gap: 8px 12px;
gap: 8px;
margin-bottom: 10px !important;
min-height: 42px;
overflow-x: auto;
padding: 0 2px;
scrollbar-width: none;
}
.console-displayer-wrapper--workspace .log-level-filters {
flex: 0 1 auto;
min-width: 0;
.console-displayer-wrapper--workspace .filter-controls::-webkit-scrollbar {
display: none;
}
.console-filter-panel {
background: rgba(var(--v-theme-surface), 0.96);
border: 1px solid rgba(var(--v-theme-on-surface), 0.09);
border-radius: 12px;
flex: 0 0 238px;
overflow-y: auto;
padding: 14px;
width: 238px;
}
.console-filter-heading {
align-items: center;
display: flex;
font-size: 0.95rem;
font-weight: 650;
justify-content: space-between;
min-height: 32px;
}
.console-filter-heading .v-btn {
margin-inline-end: -8px;
}
.console-filter-block {
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.08);
margin-top: 12px;
padding-top: 12px;
}
.console-filter-block--first {
margin-top: 8px;
}
.console-filter-block--last {
padding-top: 8px;
}
.console-filter-label {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.72rem;
font-weight: 650;
letter-spacing: 0.02em;
margin-bottom: 5px;
}
.console-filter-option {
align-items: center;
cursor: pointer;
display: flex;
font-size: 0.78rem;
min-height: 30px;
}
.console-filter-option :deep(.v-selection-control) {
min-height: 30px;
}
.level-indicator {
border-radius: 50%;
flex: 0 0 auto;
height: 7px;
margin: 0 8px 0 2px;
width: 7px;
}
.console-filter-panel :deep(.v-radio-group .v-selection-control) {
min-height: 30px;
}
.console-filter-panel :deep(.v-label) {
font-size: 0.78rem;
opacity: 0.88;
}
.console-filter-panel :deep(.v-field) {
border-radius: 9px;
font-size: 0.78rem;
}
.console-filter-panel :deep(.v-switch .v-selection-control) {
min-height: 34px;
}
.console-displayer-wrapper--workspace .console-term {
@@ -580,10 +967,6 @@ export default {
background: rgba(255, 255, 255, 0.04);
}
.console-displayer-wrapper--workspace :deep(.console-log-prefix) {
opacity: 0.64;
}
.console-displayer-wrapper--workspace :deep(.console-log-level) {
font-weight: 700;
}
@@ -596,12 +979,19 @@ export default {
color: rgba(255, 255, 255, 0.7) !important;
}
#console-wrapper:fullscreen .console-filter-panel {
--v-theme-on-surface: 255, 255, 255;
--v-theme-surface: 38, 38, 38;
background: #262626;
border-color: rgba(255, 255, 255, 0.12);
}
:deep(.console-log-line) {
display: block;
margin: 0 0 2px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas,
var(--astrbot-font-cjk-mono), monospace;
font-size: 12px;
font-size: var(--console-log-font-size, 12px);
white-space: pre-wrap;
}
@@ -640,33 +1030,23 @@ export default {
}
.console-displayer-wrapper--workspace .filter-controls {
align-items: flex-start;
gap: 6px;
padding: 0;
}
.console-displayer-wrapper--workspace .filter-controls > .v-spacer {
display: none;
}
.console-displayer-wrapper--workspace .log-level-filters {
flex: 1 1 calc(100% - 38px);
order: 1;
}
.console-displayer-wrapper--workspace .log-search-field {
flex: 1 1 100%;
max-width: none;
order: 3;
flex: 1 1 220px;
min-width: 150px;
}
.console-displayer-wrapper--workspace :deep(.console-header-actions) {
flex: 1 1 100%;
order: 4;
}
.console-displayer-wrapper--workspace .fullscreen-btn {
order: 2;
.console-filter-panel {
bottom: 0;
box-shadow: 8px 0 24px rgba(0, 0, 0, 0.18);
left: 0;
max-width: 82%;
position: absolute;
top: 0;
z-index: 3;
}
.console-displayer-wrapper--workspace .console-term {
@@ -1,10 +1,5 @@
{
"title": "Console",
"autoScroll": {
"label": "Auto-scroll",
"enabled": "Auto-scroll enabled",
"disabled": "Auto-scroll disabled"
},
"hideUserChat": {
"label": "Hide user chat",
"enabled": "User chat hidden",
@@ -14,6 +9,26 @@
"label": "Search logs",
"placeholder": "Filter logs by keyword"
},
"fontSize": {
"label": "Log font size",
"decrease": "Decrease font size",
"increase": "Increase font size"
},
"filters": {
"title": "Filters",
"button": "Filters",
"reset": "Reset",
"level": "Log level",
"source": "Source",
"allSources": "All sources",
"core": "Core",
"plugins": "Plugins",
"plugin": "Plugin",
"allPlugins": "All plugins",
"platform": "Bot platform",
"allPlatforms": "All platforms",
"unscopedPlatform": "System / Unscoped"
},
"pipInstall": {
"button": "Install pip Package",
"dialogTitle": "Install Pip Package",
@@ -1,10 +1,5 @@
{
"title": "プラットフォームログ",
"autoScroll": {
"label": "自動スクロール",
"enabled": "自動スクロールを有効にしました",
"disabled": "自動スクロールを無効にしました"
},
"hideUserChat": {
"label": "ユーザーの会話を除外",
"enabled": "ユーザーの会話を除外しました",
@@ -14,6 +9,26 @@
"label": "ログを検索",
"placeholder": "キーワードでログを絞り込む"
},
"fontSize": {
"label": "ログの文字サイズ",
"decrease": "文字を小さくする",
"increase": "文字を大きくする"
},
"filters": {
"title": "フィルター",
"button": "フィルター",
"reset": "リセット",
"level": "ログレベル",
"source": "ソース",
"allSources": "すべてのソース",
"core": "Core",
"plugins": "プラグイン",
"plugin": "プラグイン",
"allPlugins": "すべてのプラグイン",
"platform": "ボットプラットフォーム",
"allPlatforms": "すべてのプラットフォーム",
"unscopedPlatform": "システム / 未指定"
},
"pipInstall": {
"button": "pip パッケージをインストール",
"dialogTitle": "pip パッケージをインストール",
@@ -1,10 +1,5 @@
{
"title": "Логи платформы",
"autoScroll": {
"label": "Автопрокрутка",
"enabled": "Автопрокрутка включена",
"disabled": "Автопрокрутка выключена"
},
"hideUserChat": {
"label": "Скрыть чаты пользователей",
"enabled": "Чаты пользователей скрыты",
@@ -14,6 +9,26 @@
"label": "Поиск по логам",
"placeholder": "Фильтровать логи по ключевому слову"
},
"fontSize": {
"label": "Размер шрифта логов",
"decrease": "Уменьшить шрифт",
"increase": "Увеличить шрифт"
},
"filters": {
"title": "Фильтры",
"button": "Фильтры",
"reset": "Сбросить",
"level": "Уровень логов",
"source": "Источник",
"allSources": "Все источники",
"core": "Core",
"plugins": "Плагины",
"plugin": "Плагин",
"allPlugins": "Все плагины",
"platform": "Платформа бота",
"allPlatforms": "Все платформы",
"unscopedPlatform": "Система / Без платформы"
},
"pipInstall": {
"button": "Установить pip-пакет",
"dialogTitle": "Установка Pip-пакета",
@@ -1,10 +1,5 @@
{
"title": "平台日志",
"autoScroll": {
"label": "自动滚动",
"enabled": "自动滚动已开启",
"disabled": "自动滚动已关闭"
},
"hideUserChat": {
"label": "排除用户对话",
"enabled": "已排除用户对话",
@@ -12,7 +7,27 @@
},
"search": {
"label": "搜索日志",
"placeholder": "按关键词过滤日志"
"placeholder": "搜索日志"
},
"fontSize": {
"label": "日志字号",
"decrease": "减小字号",
"increase": "增大字号"
},
"filters": {
"title": "筛选",
"button": "过滤器",
"reset": "重置",
"level": "日志级别",
"source": "来源",
"allSources": "全部来源",
"core": "系统",
"plugins": "插件",
"plugin": "插件",
"allPlugins": "全部插件",
"platform": "机器人平台",
"allPlatforms": "全部平台",
"unscopedPlatform": "系统 / 无平台"
},
"pipInstall": {
"button": "安装 pip 库",
@@ -910,7 +910,6 @@ getVersion();
checkUpdate();
initPasswordWarningFromStorage();
commonStore.createEventSource(); // log
commonStore.getStartTime();
onUnmounted(() => {
+1 -141
View File
@@ -1,15 +1,8 @@
import { defineStore } from "pinia";
import { logApi, pluginApi, statsApi } from "@/api/v1";
import { fetchWithAuth } from "@/api/http";
import { pluginApi, statsApi } from "@/api/v1";
export const useCommonStore = defineStore("common", {
state: () => ({
// @ts-ignore
eventSource: null,
log_cache: [],
sse_connected: false,
log_cache_max_len: 1000,
startTime: -1,
astrbotVersion: "",
dashboardVersion: "",
@@ -18,139 +11,6 @@ export const useCommonStore = defineStore("common", {
pluginMarketDataBySource: {},
}),
actions: {
async createEventSource() {
if (this.eventSource) {
return;
}
const controller = new AbortController();
const { signal } = controller;
// 注意:这里如果之前改过 Polyfill 的话,可能需要保持原样
// 如果是用 fetch 的话,这里是支持 Authorization Header 的
const headers = {
Authorization: "Bearer " + localStorage.getItem("token"),
};
fetchWithAuth(logApi.liveUrl(), {
method: "GET",
headers,
signal,
cache: "no-cache",
})
.then((response) => {
if (!response.ok) {
throw new Error(`SSE connection failed: ${response.status}`);
}
console.log("SSE stream opened");
this.sse_connected = true;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let bufferedText = "";
const processStream = ({ done, value }) => {
if (done) {
console.log("SSE stream closed");
setTimeout(() => {
this.eventSource = null;
this.createEventSource();
}, 2000);
return;
}
// Accumulate partial chunks; SSE data may split JSON across reads.
const text = decoder.decode(value, { stream: true });
bufferedText += text;
// Split completed events; keep the trailing partial in buffer.
const segments = bufferedText.split("\n\n");
bufferedText = segments.pop() || "";
segments.forEach((segment) => {
const line = segment.trim();
if (!line.startsWith("data: ")) {
return;
}
const logLine = line.replace("data: ", "").trim();
if (!logLine) {
return;
}
try {
const logObject = JSON.parse(logLine);
// 修复:兼容 HTTP 环境的 UUID 生成
if (!logObject.uuid) {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
logObject.uuid = crypto.randomUUID();
} else {
// 手动生成 UUID v4
logObject.uuid =
"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
function (c) {
var r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}
}
this.log_cache.push(logObject);
// Limit log cache size
if (this.log_cache.length > this.log_cache_max_len) {
this.log_cache.splice(
0,
this.log_cache.length - this.log_cache_max_len,
);
}
} catch (err) {
console.warn(
"Failed to parse SSE log line, skipping:",
err,
logLine,
);
}
});
return reader.read().then(processStream);
};
reader.read().then(processStream);
})
.catch((error) => {
console.error("SSE error:", error);
// Attempt to reconnect after a delay
this.log_cache.push({
type: "log",
level: "ERROR",
time: Date.now() / 1000,
data: "SSE Connection failed, retrying in 5 seconds...",
uuid: "error-" + Date.now(),
});
setTimeout(() => {
this.eventSource = null;
this.createEventSource();
}, 1000);
});
// Store controller to allow closing the connection
this.eventSource = controller;
},
closeEventSourcet() {
if (this.eventSource) {
this.eventSource.abort();
this.eventSource = null;
}
},
getLogCache() {
return this.log_cache;
},
async fetchStartTime() {
const res = await statsApi.startTime();
this.startTime = res.data.data.start_time;
+53 -47
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { reactive, ref, watch } from "vue";
import { PackagePlus } from "@lucide/vue";
import { Minus, PackagePlus, Plus } from "@lucide/vue";
import ConsoleDisplayer from "@/components/shared/ConsoleDisplayer.vue";
import { useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
@@ -11,8 +11,11 @@ const { tm } = useModuleI18n("features/console");
const toast = useToast();
const customizerStore = useCustomizerStore();
const autoScrollEnabled = ref(
localStorage.getItem("console_auto_scroll") !== "false",
const logFontSize = ref(
Math.min(
16,
Math.max(8, Number(localStorage.getItem("console_log_font_size")) || 12),
),
);
const hideUserChatEnabled = ref(
localStorage.getItem("console_hide_user_chat") === "true",
@@ -21,8 +24,8 @@ const pipDialog = ref(false);
const pipInstallPayload = reactive({ package: "", mirror: "" });
const loading = ref(false);
watch(autoScrollEnabled, (value) => {
localStorage.setItem("console_auto_scroll", String(value));
watch(logFontSize, (value) => {
localStorage.setItem("console_log_font_size", String(value));
});
watch(hideUserChatEnabled, (value) => {
@@ -56,29 +59,42 @@ async function pipInstall() {
class="console-display"
workspace-mode
show-search
:auto-scroll="autoScrollEnabled"
:hide-user-chat="hideUserChatEnabled"
:font-size="logFontSize"
v-model:hide-user-chat="hideUserChatEnabled"
>
<template #header-actions>
<div class="console-header-actions">
<v-switch
v-model="hideUserChatEnabled"
:label="tm('hideUserChat.label')"
:aria-label="tm('hideUserChat.label')"
hide-details
density="compact"
inset
color="primary"
/>
<v-switch
v-model="autoScrollEnabled"
:label="tm('autoScroll.label')"
:aria-label="tm('autoScroll.label')"
hide-details
density="compact"
inset
color="primary"
/>
<div
class="console-font-controls"
role="group"
:aria-label="tm('fontSize.label')"
>
<v-btn
icon
size="x-small"
variant="text"
:disabled="logFontSize <= 8"
:aria-label="tm('fontSize.decrease')"
:title="tm('fontSize.decrease')"
@click="logFontSize -= 2"
>
<Minus :size="15" aria-hidden="true" />
</v-btn>
<output :aria-label="tm('fontSize.label')"
>{{ logFontSize }}px</output
>
<v-btn
icon
size="x-small"
variant="text"
:disabled="logFontSize >= 16"
:aria-label="tm('fontSize.increase')"
:title="tm('fontSize.increase')"
@click="logFontSize += 2"
>
<Plus :size="15" aria-hidden="true" />
</v-btn>
</div>
<v-dialog v-model="pipDialog" width="440">
<template #activator="{ props }">
<v-btn
@@ -162,17 +178,23 @@ async function pipInstall() {
.console-header-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
flex: 0 0 auto;
flex-wrap: nowrap;
gap: 6px 14px;
}
.console-header-actions :deep(.v-switch .v-selection-control) {
min-height: 34px;
.console-font-controls {
align-items: center;
display: flex;
gap: 2px;
white-space: nowrap;
}
.console-header-actions :deep(.v-label) {
font-size: 0.75rem;
opacity: 0.72;
.console-font-controls output {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.72rem;
min-width: 32px;
text-align: center;
}
.pip-install-button :deep(.v-btn__content) {
@@ -191,21 +213,5 @@ async function pipInstall() {
height: calc(100dvh - 112px);
padding: 0 4px 6px;
}
.console-header-actions {
width: 100%;
}
}
@media (max-width: 520px) {
.console-header-actions {
display: grid;
gap: 4px 10px;
grid-template-columns: 1fr 1fr;
}
.pip-install-button {
grid-column: 1 / -1;
}
}
</style>
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import ts from "typescript";
import { parse } from "vue/compiler-sfc";
function setup(document = {}) {
const source = readFileSync(new URL("../src/components/shared/ConsoleDisplayer.vue", import.meta.url), "utf8");
const { descriptor } = parse(source);
const ast = ts.createSourceFile("console.js", descriptor.script.content, ts.ScriptTarget.Latest, true);
const definition = ast.statements.find(ts.isExportAssignment);
const context = vm.createContext({ document, translateConsole: (key) => key });
const component = vm.runInContext(`(${definition.expression.getText(ast)})`, context);
const state = Object.assign(component.data(), component.methods, {
hideUserChat: false, $emit() {},
});
return { component, state };
}
test("log filters intersect level, source, plugin, platform, category, and keyword", () => {
const { state } = setup();
const log = { level: "INFO", data: "\u001b[1;36mWeather updated\u001b[0m", source: "plugin", plugin_name: "weather", platform_id: "qq", category: "system" };
state.selectedSource = "plugin";
state.selectedPlugin = "weather";
state.selectedPlatform = "qq";
state.searchKeyword = "WEATHER";
state.hideUserChat = true;
assert.equal(state.isLogVisible(log), true);
for (const changed of [
{ source: "core" }, { plugin_name: "other" }, { platform_id: "telegram" },
{ category: "user_chat" }, { data: "Unrelated message" }, { level: "TRACE" },
]) assert.equal(state.isLogVisible({ ...log, ...changed }), false);
state.resetFilters();
state.searchKeyword = "";
state.hideUserChat = false;
state.selectedPlatform = "__unscoped__";
assert.equal(state.isLogVisible({ level: "INFO", data: "Legacy log" }), true);
assert.equal(state.isLogVisible(log), false);
});
test("plugin options prefer display names and keep stable IDs as values and subtitles", () => {
const { component, state } = setup();
state.localLogCache = [
{ plugin_name: "weather" }, { plugin_name: "weather" }, { plugin_name: "unknown" },
];
state.pluginDisplayNames = { weather: "Weather Forecast" };
const options = component.computed.pluginOptions.call(state);
assert.equal(options.length, 2);
const weather = options.find((item) => item.value === "weather");
assert.equal(weather.title, "Weather Forecast");
assert.equal(weather.subtitle, "weather");
assert.equal(options.find((item) => item.value === "unknown").title, "unknown");
});
test("new logs follow the bottom without interrupting reading older entries", () => {
const term = {
scrollHeight: 1000, scrollTop: 900, clientHeight: 100, entries: [],
appendChild(fragment) {
this.entries.push(...fragment.childNodes);
this.scrollHeight += fragment.childNodes.length * 100;
},
};
const { state } = setup({
getElementById: () => term,
createDocumentFragment: () => ({ childNodes: [], appendChild(node) { this.childNodes.push(node); } }),
});
state.buildLogElement = (log) => log;
state.processNewLogs([{ time: 1, level: "INFO", data: "First log" }]);
assert.equal(term.scrollTop, term.scrollHeight);
term.scrollTop = 200;
state.processNewLogs([{ time: 2, level: "INFO", data: "Second log" }]);
assert.equal(term.scrollTop, 200);
state.processNewLogs([{ time: 2, level: "INFO", data: "Second log" }]);
assert.equal(state.localLogCache.length, 2);
assert.equal(term.entries.length, 2);
});
+4
View File
@@ -166,6 +166,10 @@ Use the `Conversations` tab to find and manage saved conversation records:
The `Logs` tab shows AstrBot runtime logs in real time. You can filter by log level and install missing Pip packages from this page. To view DEBUG logs, first set `Console Log Level` to `DEBUG` under `System Settings → General → Logs`.
Open `Filters` next to the search box to filter by level, source (System/Plugin), individual plugin, and bot platform, or exclude user-chat logs. Combine these conditions with `Search logs`. Plugin menus prefer display names and show plugin IDs as subtitles; `System` replaces the former Core source label.
The toolbar contains search, fullscreen, font-size controls, and `Install Pip packages`. The default font size is 12px, adjustable from 8–16px in 2px steps. The former auto-scroll switch is replaced by bottom-following behavior: new logs follow when you are at the bottom, without interrupting reading older entries. Top-row filters move into the `Filters` sidebar; closing it preserves selections. Logs remain in memory, without new file-reading or database persistence.
### Trace
The `Trace` tab shows AstrBot execution traces in real time and is useful for debugging model-call paths and tool invocations. Use the switch at the top to enable or disable trace recording.
+4
View File
@@ -166,6 +166,10 @@ ChatUI 支持以下常用能力:
`日志` 页面显示 AstrBot 的实时运行日志,可以按日志级别筛选,也可以从页面中安装缺失的 Pip 库。需要查看 DEBUG 日志时,请先在 `系统设置 → 常规 → 日志` 中将 `控制台日志级别` 设为 `DEBUG`。
点击搜索框左侧的“过滤器”,在日志容器左侧按日志级别、来源(系统/插件)、具体插件、机器人平台筛选,也可排除用户对话日志;这些条件可与“搜索日志”叠加使用。插件优先显示 display name,菜单副标题显示插件 ID;“系统”对应原先的 Core 来源。
顶部工具栏保留搜索、全屏、字号调整与“安装 Pip 库”。字号默认 12px,可按 2px 步长在 8–16px 之间调整。原来的“自动滚动”开关已移除:滚动到底部后跟随新日志,上翻查看旧日志时不自动跳转。筛选项从顶部移至“过滤器”侧栏,折叠侧栏不会清除筛选条件。日志仍使用内存缓存,不新增文件读取或数据库持久化。
### 追踪
`追踪` 页面实时显示 AstrBot 的运行追踪记录,适合调试模型调用路径和工具调用过程。可以通过页面顶部的开关启用或禁用追踪记录。
+76 -1
View File
@@ -1,5 +1,6 @@
"""Tests for the plugin-aware logger exposed by ``astrbot.api``."""
import asyncio
import json
import logging
from pathlib import Path
@@ -8,11 +9,85 @@ from unittest.mock import MagicMock, patch
import pytest
import astrbot.api as api
from astrbot.core.log import LogBroker, LogManager, LogQueueHandler
from astrbot.core.log import LogBroker, LogManager, LogQueueHandler, log_context
from astrbot.core.star import Star
from astrbot.core.star.star import StarMetadata, star_map
def test_queue_log_contains_structured_source_and_platform_fields() -> None:
"""Ensure queue entries expose fields used by Dashboard log filters."""
broker = LogBroker()
plugin_logger = logging.Logger("astrbot.plugin.weather")
LogManager.set_queue_handler(plugin_logger, broker)
with log_context("qq-bot-1", "aiocqhttp"):
plugin_logger.info("Weather updated", extra={"category": "user_chat"})
entry = broker.log_cache[-1]
assert entry["source"] == "plugin"
assert entry["plugin_name"] == "weather"
assert entry["platform_id"] == "qq-bot-1"
assert entry["platform_type"] == "aiocqhttp"
assert entry["category"] == "user_chat"
@pytest.mark.asyncio
async def test_detached_task_does_not_keep_expired_log_context() -> None:
"""Ensure a detached task cannot retain an event's expired platform."""
broker = LogBroker()
core_logger = logging.Logger("astrbot.context_test")
LogManager.set_queue_handler(core_logger, broker)
release_task = asyncio.Event()
async def emit_after_context_exit() -> None:
"""Emit a log after the parent context has ended."""
await release_task.wait()
core_logger.info("Background task completed")
with log_context("telegram-bot-1", "telegram"):
task = asyncio.create_task(emit_after_context_exit())
release_task.set()
await task
entry = broker.log_cache[-1]
assert entry["source"] == "core"
assert entry["plugin_name"] is None
assert entry["platform_id"] is None
assert entry["platform_type"] is None
@pytest.mark.asyncio
async def test_concurrent_log_contexts_do_not_mix_platforms() -> None:
"""Ensure concurrent message tasks retain their own platform metadata."""
broker = LogBroker()
core_logger = logging.Logger("astrbot.concurrent_context_test")
LogManager.set_queue_handler(core_logger, broker)
async def emit_for_platform(platform_id: str, platform_type: str) -> None:
"""Emit one log inside a platform context.
Args:
platform_id: Unique platform instance ID.
platform_type: Platform adapter type.
"""
with log_context(platform_id, platform_type):
await asyncio.sleep(0)
core_logger.info(platform_id)
await asyncio.gather(
emit_for_platform("qq-bot-1", "aiocqhttp"),
emit_for_platform("telegram-bot-1", "telegram"),
)
qq_entry = next(entry for entry in broker.log_cache if "qq-bot-1" in entry["data"])
telegram_entry = next(
entry for entry in broker.log_cache if "telegram-bot-1" in entry["data"]
)
assert qq_entry["platform_type"] == "aiocqhttp"
assert telegram_entry["platform_type"] == "telegram"
def test_plugin_logger_cache_refreshes_after_plugin_rename(
monkeypatch: pytest.MonkeyPatch,
) -> None: