201 lines
6.3 KiB
Python
201 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
xiaozhi-esp32-server 实时日志监控器
|
||
|
||
用法:
|
||
python xiaozhi_log_monitor.py # 全部日志
|
||
python xiaozhi_log_monitor.py --filter llm # 只看 LLM
|
||
python xiaozhi_log_monitor.py --filter mcp # 只看 MCP
|
||
python xiaozhi_log_monitor.py --filter device # 只看设备连接
|
||
python xiaozhi_log_monitor.py --filter error # 只看错误
|
||
|
||
按 Ctrl+C 退出
|
||
"""
|
||
|
||
import subprocess
|
||
import re
|
||
import sys
|
||
import argparse
|
||
import time
|
||
from datetime import datetime
|
||
|
||
# ANSI 颜色码
|
||
COLORS = {
|
||
"reset": "\033[0m",
|
||
"bold": "\033[1m",
|
||
"red": "\033[91m",
|
||
"green": "\033[92m",
|
||
"yellow": "\033[93m",
|
||
"blue": "\033[94m",
|
||
"magenta": "\033[95m",
|
||
"cyan": "\033[96m",
|
||
"gray": "\033[90m",
|
||
"white": "\033[97m",
|
||
}
|
||
|
||
# 日志类型配色
|
||
LOG_COLORS = {
|
||
"ERROR": COLORS["red"] + COLORS["bold"],
|
||
"error": COLORS["red"],
|
||
"WARN": COLORS["yellow"] + COLORS["bold"],
|
||
"warn": COLORS["yellow"],
|
||
"LLM": COLORS["cyan"] + COLORS["bold"],
|
||
"llm": COLORS["cyan"],
|
||
"MCP": COLORS["magenta"] + COLORS["bold"],
|
||
"mcp": COLORS["magenta"],
|
||
"device": COLORS["green"],
|
||
"DEVICE": COLORS["green"] + COLORS["bold"],
|
||
"TTS": COLORS["blue"] + COLORS["bold"],
|
||
"tts": COLORS["blue"],
|
||
"STT": COLORS["blue"],
|
||
"ASR": COLORS["blue"],
|
||
"VAD": COLORS["gray"],
|
||
"websocket": COLORS["yellow"],
|
||
"MCP接入点": COLORS["magenta"],
|
||
"连接成功": COLORS["green"],
|
||
"启动": COLORS["cyan"],
|
||
"初始化": COLORS["cyan"],
|
||
"function_call": COLORS["yellow"],
|
||
}
|
||
|
||
# 过滤器模式
|
||
FILTER_PATTERNS = {
|
||
"llm": re.compile(r"(LLM|llm|大模型|MiniMax|abab|chatglm|openai_api|function_call|psycho_screen)", re.IGNORECASE),
|
||
"mcp": re.compile(r"(MCP|mcp|接入点|tool|call|psycho_screen)", re.IGNORECASE),
|
||
"device": re.compile(r"(device|设备|连接|断开|认证|websocket)", re.IGNORECASE),
|
||
"error": re.compile(r"(ERROR|error|错误|失败|异常|401|400|500|exception)", re.IGNORECASE),
|
||
"tts": re.compile(r"(TTS|tts|语音|音频|EdgeTTS|生成)", re.IGNORECASE),
|
||
"stt": re.compile(r"(STT|ASR|stt|识别|转文字)", re.IGNORECASE),
|
||
"all": re.compile(r".*"),
|
||
}
|
||
|
||
# 时间戳提取(格式: 20260406 22:50:12)
|
||
TIMESTAMP_RE = re.compile(r"(\d{8}\s+\d{2}:\d{2}:\d{2})")
|
||
|
||
|
||
def colorize(line: str) -> str:
|
||
"""为日志行上色"""
|
||
for keyword, color in LOG_COLORS.items():
|
||
if keyword in line:
|
||
return color + line + COLORS["reset"]
|
||
return line
|
||
|
||
|
||
def format_line(line: str) -> str:
|
||
"""格式化单行日志"""
|
||
line = line.rstrip("\n\r")
|
||
if not line:
|
||
return ""
|
||
|
||
# 提取时间戳
|
||
ts_match = TIMESTAMP_RE.search(line)
|
||
ts = ts_match.group(1) if ts_match else ""
|
||
|
||
# 上色
|
||
colored = colorize(line)
|
||
|
||
# 高亮时间戳
|
||
if ts:
|
||
return f"{COLORS['gray']}{ts}{COLORS['reset']} {colored[len(ts):]}"
|
||
return colored
|
||
|
||
|
||
def matches_filter(line: str, filter_name: str) -> bool:
|
||
"""检查日志行是否匹配过滤器"""
|
||
pattern = FILTER_PATTERNS.get(filter_name, FILTER_PATTERNS["all"])
|
||
return bool(pattern.search(line))
|
||
|
||
|
||
def monitor(filter_name: str = "all", container: str = "xiaozhi-esp32-server"):
|
||
"""主监控循环"""
|
||
print(f"\n{COLORS['cyan']}{COLORS['bold']}=== xiaozhi-esp32-server 实时日志监控 ==={COLORS['reset']}")
|
||
print(f"过滤器: {filter_name} | 容器: {container}")
|
||
print(f"按 {COLORS['yellow']}Ctrl+C{COLORS['reset']} 退出\n")
|
||
|
||
try:
|
||
proc = subprocess.Popen(
|
||
["docker", "logs", "-f", "--since", "0s", container],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=False,
|
||
bufsize=1,
|
||
)
|
||
except Exception as e:
|
||
print(f"{COLORS['red']}启动日志失败: {e}{COLORS['reset']}")
|
||
sys.exit(1)
|
||
|
||
line_count = 0
|
||
shown_lines = []
|
||
|
||
try:
|
||
for raw_line in iter(proc.stdout.readline, b""):
|
||
try:
|
||
line = raw_line.decode("utf-8", errors="replace")
|
||
except Exception:
|
||
line = raw_line.decode("latin-1", errors="replace")
|
||
|
||
if not matches_filter(line, filter_name):
|
||
continue
|
||
|
||
line_count += 1
|
||
shown_lines.append(line)
|
||
|
||
# 只保留最后 5000 行
|
||
if len(shown_lines) > 5000:
|
||
shown_lines.pop(0)
|
||
|
||
print(format_line(line))
|
||
sys.stdout.flush()
|
||
|
||
except KeyboardInterrupt:
|
||
print(f"\n\n{COLORS['gray']}已停止。共显示了 {line_count} 条日志。{COLORS['reset']}")
|
||
finally:
|
||
proc.terminate()
|
||
proc.wait(timeout=5)
|
||
|
||
|
||
def replay_recent(filter_name: str = "all", container: str = "xiaozhi-esp32-server", lines: int = 200):
|
||
"""回放最近 N 条日志"""
|
||
print(f"\n{COLORS['cyan']}=== 回放最近 {lines} 条日志 ==={COLORS['reset']}\n")
|
||
try:
|
||
proc = subprocess.run(
|
||
["docker", "logs", "--tail", str(lines), container],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=False,
|
||
timeout=10,
|
||
)
|
||
for raw_line in iter(proc.stdout.readline, b""):
|
||
line = raw_line.decode("utf-8", errors="replace")
|
||
if matches_filter(line, filter_name):
|
||
print(format_line(line))
|
||
except Exception as e:
|
||
print(f"{COLORS['red']}回放失败: {e}{COLORS['reset']}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
parser = argparse.ArgumentParser(description="xiaozhi-esp32-server 实时日志监控器")
|
||
parser.add_argument(
|
||
"--filter", "-f",
|
||
choices=["all", "llm", "mcp", "device", "error", "tts", "stt"],
|
||
default="all",
|
||
help="日志过滤器 (default: all)"
|
||
)
|
||
parser.add_argument(
|
||
"--container", "-c",
|
||
default="xiaozhi-esp32-server",
|
||
help="Docker 容器名 (default: xiaozhi-esp32-server)"
|
||
)
|
||
parser.add_argument(
|
||
"--replay", "-r",
|
||
type=int,
|
||
metavar="N",
|
||
help="回放最近 N 条日志后退出(不持续监控)"
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
if args.replay:
|
||
replay_recent(filter_name=args.filter, container=args.container, lines=args.replay)
|
||
else:
|
||
monitor(filter_name=args.filter, container=args.container)
|