mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 06:58:51 +08:00
feat: improve log filtering and console workspace
This commit is contained in:
@@ -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
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -166,6 +166,10 @@ ChatUI 支持以下常用能力:
|
||||
|
||||
`日志` 页面显示 AstrBot 的实时运行日志,可以按日志级别筛选,也可以从页面中安装缺失的 Pip 库。需要查看 DEBUG 日志时,请先在 `系统设置 → 常规 → 日志` 中将 `控制台日志级别` 设为 `DEBUG`。
|
||||
|
||||
点击搜索框左侧的“过滤器”,在日志容器左侧按日志级别、来源(系统/插件)、具体插件、机器人平台筛选,也可排除用户对话日志;这些条件可与“搜索日志”叠加使用。插件优先显示 display name,菜单副标题显示插件 ID;“系统”对应原先的 Core 来源。
|
||||
|
||||
顶部工具栏保留搜索、全屏、字号调整与“安装 Pip 库”。字号默认 12px,可按 2px 步长在 8–16px 之间调整。原来的“自动滚动”开关已移除:滚动到底部后跟随新日志,上翻查看旧日志时不自动跳转。筛选项从顶部移至“过滤器”侧栏,折叠侧栏不会清除筛选条件。日志仍使用内存缓存,不新增文件读取或数据库持久化。
|
||||
|
||||
### 追踪
|
||||
|
||||
`追踪` 页面实时显示 AstrBot 的运行追踪记录,适合调试模型调用路径和工具调用过程。可以通过页面顶部的开关启用或禁用追踪记录。
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user