Initial commit: SenSu Alpha 0.2.0

- 13-service async plugin framework
- Textual TUI with CLI fallback
- Plugin hot-reload + permission system
- Web management panel (aiohttp)
- Bridge-based inter-module communication
- 10 regression tests

Fixes applied:
- PBKDF2-SHA256 auth (was plain SHA256)
- Auth bypass removed (was allow-all on fail)
- Bare excepts replaced with logged errors
- CatFramework/DreamSu -> SenSu naming unified
- ServiceManager: health checks + startup_order
- Env var credentials (SENSU_ADMIN_PASSWORD etc)
This commit is contained in:
2026-06-10 12:27:14 +08:00
commit e6875f0b4b
78 changed files with 14843 additions and 0 deletions
+806
View File
@@ -0,0 +1,806 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import logging
import sys
import io
import time
from textual.app import App
from textual.containers import Container, ScrollableContainer
from textual.widgets import Static, Input, Header, Footer
from textual.reactive import reactive
from typing import List, Dict
import asyncio
from datetime import datetime
logger = logging.getLogger(__name__)
class SystemExitGraceful(Exception):
"""优雅的系统退出异常"""
pass
class LogDisplay(Static):
"""日志显示组件 - 直接捕获所有日志输出"""
def __init__(self):
super().__init__("日志显示区域 - 等待日志输入...")
self.log_lines: List[str] = []
self.max_lines = 500
self.auto_scroll_enabled = True # 启用自动滚动
# 保存原始的logging处理器和格式器
self.original_handlers = []
self.original_formatters = {}
logger.debug("LogDisplay初始化完成")
def start_capture(self):
"""开始捕获所有日志输出"""
try:
# 获取根日志记录器
root_logger = logging.getLogger()
# 保存原始处理器和它们的格式器
self.original_handlers = root_logger.handlers.copy()
for handler in self.original_handlers:
self.original_formatters[handler] = handler.formatter
# 清除所有现有处理器
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# 添加我们的自定义处理器
custom_handler = self.TUILogHandler(self)
custom_handler.setLevel(logging.DEBUG) # 捕获所有级别的日志
# 强制使用包含彩色级别的格式器
formatter = self.ColoredFormatter(
'%(asctime)s %(levelname_color)s %(name)s: %(message)s',
datefmt='%H:%M:%S'
)
custom_handler.setFormatter(formatter)
root_logger.addHandler(custom_handler)
# 同时重定向stdout和stderr作为备份
self.original_stdout = sys.stdout
self.original_stderr = sys.stderr
sys.stdout = self.TUIOutput(self)
sys.stderr = self.TUIOutput(self, is_error=True)
print("✅ TUI日志捕获已启动 - 捕获所有日志输出")
except Exception as e:
logger.error(f"启动日志捕获失败: {e}")
def stop_capture(self):
"""停止捕获输出"""
try:
# 恢复logging处理器
root_logger = logging.getLogger()
# 移除我们的处理器
for handler in root_logger.handlers[:]:
if hasattr(handler, 'log_display'):
root_logger.removeHandler(handler)
# 恢复原始处理器和格式器
for handler in self.original_handlers:
# 恢复格式器
if handler in self.original_formatters:
handler.setFormatter(self.original_formatters[handler])
root_logger.addHandler(handler)
# 恢复stdout和stderr
sys.stdout = self.original_stdout
sys.stderr = self.original_stderr
print("🛑 TUI日志捕获已停止")
except Exception as e:
logger.error(f"停止日志捕获失败: {e}")
def add_log_line(self, line: str):
"""添加日志行到TUI显示"""
try:
# 添加到缓冲区
self.log_lines.append(line)
if len(self.log_lines) > self.max_lines:
self.log_lines.pop(0)
# 更新显示
display_content = "\n".join(self.log_lines)
self.update(display_content)
# 自动滚动到底部
if self.auto_scroll_enabled:
self.scroll_to_bottom()
except Exception as e:
# 如果TUI更新失败,回退到原始输出
if hasattr(self, 'original_stdout'):
self.original_stdout.write(f"TUI日志显示错误: {e}\n")
def scroll_to_bottom(self):
"""滚动到底部"""
try:
# 获取父容器(ScrollableContainer
parent = self.parent
if parent and hasattr(parent, 'scroll_end'):
parent.scroll_end()
except Exception as e:
# 忽略滚动错误,不影响主要功能
pass
def toggle_auto_scroll(self, enabled: bool = None):
"""切换自动滚动状态"""
if enabled is None:
self.auto_scroll_enabled = not self.auto_scroll_enabled
else:
self.auto_scroll_enabled = enabled
logger.debug(f"日志自动滚动: {'启用' if self.auto_scroll_enabled else '禁用'}")
return self.auto_scroll_enabled
class ColoredFormatter(logging.Formatter):
"""带颜色的日志格式器 - 增强版"""
# ANSI颜色代码
COLORS = {
'DEBUG': '\033[36m', # 青色 - DEBUG信息
'INFO': '\033[32m', # 绿色 - 正常信息
'WARNING': '\033[33m', # 黄色 - 警告信息
'ERROR': '\033[31m', # 红色 - 错误信息
'CRITICAL': '\033[35m', # 紫色 - 严重错误
'RESET': '\033[0m' # 重置颜色
}
# 级别显示宽度
LEVEL_WIDTH = 8
def format(self, record):
"""格式化日志记录,为级别添加颜色"""
try:
# 为级别添加颜色和固定宽度
levelname = record.levelname
if levelname in self.COLORS:
# 添加颜色并保持固定宽度
colored_level = f"{self.COLORS[levelname]}[{levelname:<{self.LEVEL_WIDTH}}]{self.COLORS['RESET']}"
record.levelname_color = colored_level
else:
record.levelname_color = f"[{levelname:<{self.LEVEL_WIDTH}}]"
# 调用父类格式化方法
formatted_message = super().format(record)
return formatted_message
except Exception:
# 如果格式化失败,返回简单格式
return f"{record.asctime} [{record.levelname}] {record.name}: {record.getMessage()}"
class TUILogHandler(logging.Handler):
"""自定义logging处理器,同时输出到终端和TUI"""
def __init__(self, log_display):
super().__init__()
self.log_display = log_display
def emit(self, record):
"""处理日志记录"""
try:
# 格式化日志记录(使用我们的格式器)
formatted_message = self.format(record)
# 输出到原始终端(通过原始处理器,但使用我们的格式器)
for original_handler in self.log_display.original_handlers:
if original_handler.level <= record.levelno:
# 临时使用我们的格式器来确保级别显示一致
original_handler.setFormatter(self.formatter)
original_handler.emit(record)
# 恢复原始格式器
original_formatter = self.log_display.original_formatters.get(original_handler)
if original_formatter:
original_handler.setFormatter(original_formatter)
# 添加到TUI显示
self.log_display.add_log_line(formatted_message)
except Exception as e:
# 如果处理失败,使用简单格式
try:
simple_message = f"{datetime.now().strftime('%H:%M:%S')} [{record.levelname:8}] {record.name}: {record.getMessage()}"
self.log_display.add_log_line(simple_message)
except:
pass
class TUIOutput(io.TextIOBase):
"""自定义输出流,捕获print等输出"""
def __init__(self, log_display, is_error=False):
self.log_display = log_display
self.is_error = is_error
self.original_stream = sys.stderr if is_error else sys.stdout
# 颜色定义
self.COLORS = {
'INFO': '\033[32m', # 绿色
'ERROR': '\033[31m', # 红色
'RESET': '\033[0m' # 重置颜色
}
def write(self, text):
"""写入文本"""
try:
# 写入到原始终端
self.original_stream.write(text)
self.original_stream.flush()
# 如果文本不是空的,添加到TUI
if text.strip():
# 添加简单的时间戳和级别
timestamp = datetime.now().strftime('%H:%M:%S')
level = "ERROR" if self.is_error else "INFO"
# 添加颜色
if level in self.COLORS:
colored_level = f"{self.COLORS[level]}[{level}]{self.COLORS['RESET']}"
else:
colored_level = f"[{level}]"
# 分割多行文本
lines = text.split('\n')
for line in lines:
if line.strip(): # 忽略空行
log_line = f"{timestamp} {colored_level} {line.strip()}"
self.log_display.add_log_line(log_line)
return len(text)
except Exception:
# 如果TUI处理失败,只输出到终端
self.original_stream.write(text)
self.original_stream.flush()
return len(text)
def flush(self):
"""刷新缓冲区"""
self.original_stream.flush()
def close(self):
"""关闭流"""
pass
class MessageDisplay(Static):
"""消息显示组件 - 增强版"""
def __init__(self):
super().__init__("消息区域")
self.current_messages: List[Dict] = []
self.max_messages = 200 # 更大的消息容量
self.auto_scroll_enabled = True # 启用自动滚动
# ANSI颜色代码
self.COLORS = {
'INFO': '\033[37m', # 黑底白字
'DEBUG': '\033[36m', # 青色 - DEBUG信息
'SUCCESS': '\033[32m', # 绿色 - 正常信息
'WARNING': '\033[33m', # 黄色 - 警告信息
'ERROR': '\033[31m', # 红色 - 错误信息
'COMMAND': '\033[40;37m', # 灰底白字 - 命令信息
'RESET': '\033[0m' # 重置颜色
}
self.message_types = {
'info': {'icon': f"{self.COLORS['INFO']}[INFO ]{self.COLORS['RESET']}", 'color': 'white'},
'success': {'icon': f"{self.COLORS['SUCCESS']}[SUCCESS]{self.COLORS['RESET']}", 'color': 'green'},
'error': {'icon': f"{self.COLORS['ERROR']}[ERROR ]{self.COLORS['RESET']}", 'color': 'red'},
'warning': {'icon': f"{self.COLORS['WARNING']}[WARNING]{self.COLORS['RESET']}", 'color': 'yellow'},
'debug': {'icon': f"{self.COLORS['DEBUG']}[DEBUG ]{self.COLORS['RESET']}", 'color': 'cyan'},
'command': {'icon': f"{self.COLORS['COMMAND']}[COMMAND]{self.COLORS['RESET']}", 'color': 'meow'}
}
logger.debug("MessageDisplay初始化完成")
def add_message(self, message: str, msg_type: str = "info", persistent: bool = False):
"""添加消息 - 支持多行消息"""
try:
# 分割多行消息为单独的消息
lines = message.strip().split('\n')
for line in lines:
if line.strip(): # 忽略空行
message_data = {
"text": line.strip(),
"type": msg_type,
"persistent": persistent,
"timestamp": asyncio.get_event_loop().time(),
"display_time": datetime.now().strftime('%H:%M:%S')
}
self.current_messages.append(message_data)
# 智能消息管理
self._manage_messages()
self._update_display()
except Exception as e:
logger.error(f"添加消息时出错: {str(e)}")
def reset_display(self):
"""重置显示状态"""
try:
# 清空所有消息
self.current_messages.clear()
# 更新显示
self.update("消息区域已重置")
# 强制刷新
self.refresh()
except Exception as e:
logger.error(f"重置消息显示时出错: {str(e)}")
def _manage_messages(self):
"""智能管理消息数量"""
try:
# 计算非持久化消息的数量
non_persistent_messages = [msg for msg in self.current_messages if not msg['persistent']]
if len(non_persistent_messages) > self.max_messages:
# 移除最旧的非持久化消息
for i, msg in enumerate(self.current_messages):
if not msg['persistent']:
self.current_messages.pop(i)
break
except Exception as e:
logger.error(f"管理消息时出错: {str(e)}")
def _update_display(self):
"""更新显示 - 带时间戳的格式化消息"""
try:
if not self.current_messages:
display_text = "📭 暂无消息"
else:
display_text = []
for msg in self.current_messages:
# 获取消息类型配置
msg_config = self.message_types.get(msg['type'], self.message_types['info'])
icon = msg_config['icon']
# 构建显示行
persistent_mark = "🔒 " if msg['persistent'] else ""
time_stamp = f"[{msg['display_time']}] " if len(self.current_messages) > 1 else ""
display_line = f"{time_stamp}{persistent_mark}{icon} {msg['text']}"
display_text.append(display_line)
display_text = "\n".join(display_text)
self.update(display_text)
# 自动滚动到底部
if self.auto_scroll_enabled:
self.scroll_to_bottom()
except Exception as e:
logger.error(f"更新消息显示时出错: {str(e)}")
def scroll_to_bottom(self):
"""滚动到底部"""
try:
# 获取父容器(ScrollableContainer
parent = self.parent
if parent and hasattr(parent, 'scroll_end'):
parent.scroll_end()
except Exception as e:
# 忽略滚动错误,不影响主要功能
pass
def toggle_auto_scroll(self, enabled: bool = None):
"""切换自动滚动状态"""
if enabled is None:
self.auto_scroll_enabled = not self.auto_scroll_enabled
else:
self.auto_scroll_enabled = enabled
logger.debug(f"消息自动滚动: {'启用' if self.auto_scroll_enabled else '禁用'}")
return self.auto_scroll_enabled
class TUIFramework(App):
"""TUI框架应用"""
def __init__(self, config, log_service, command_service):
super().__init__()
self.config = config
self.log_service = log_service
self.command_service = command_service
self.log_display = LogDisplay()
self.message_display = MessageDisplay()
self.command_input = None
self.CSS = self._generate_css()
def _generate_css(self):
"""根据配置动态生成CSS - 增强版"""
try:
tui_config = self.config.get('tui', {})
layout_config = tui_config.get('layout', {})
styles_config = tui_config.get('styles', {})
# 获取布局配置,使用默认值
grid_rows = layout_config.get('grid_rows', '7fr 2fr 1fr')
# 获取样式配置,使用默认值
log_area_style = styles_config.get('log_area', 'border: solid green; overflow-y: auto;')
message_area_style = styles_config.get('message_area', 'border: solid yellow; overflow-y: auto;')
input_area_style = styles_config.get('input_area', 'border: solid red;')
css = f"""
Screen {{
layout: grid;
grid-size: 1 3;
grid-rows: {grid_rows};
}}
#log-area {{
{log_area_style}
overflow-y: auto;
scrollbar-size: 1 1;
}}
#message-area {{
{message_area_style}
overflow-y: auto;
scrollbar-size: 1 1;
}}
#input-area {{
{input_area_style}
}}
/* 自定义滚动条样式 */
ScrollableContainer {{
scrollbar-color: #666 #222;
scrollbar-color-hover: #888 #333;
overflow-y: auto;
}}
/* 确保内容正确换行 */
Static {{
width: 100%;
content-align: left middle;
overflow-y: auto;
}}
"""
logger.debug(f"生成的TUI CSS:\n{css}")
return css
except Exception as e:
logger.error(f"生成TUI CSS时出错: {str(e)}", exc_info=True)
# 返回默认CSS作为回退
return """
Screen {
layout: grid;
grid-size: 1 3;
grid-rows: 7fr 2fr 1fr;
}
#log-area {
border: solid green;
overflow-y: auto;
scrollbar-size: 1 1;
}
#message-area {
border: solid yellow;
overflow-y: auto;
scrollbar-size: 1 1;
}
#input-area {
border: solid red;
}
/* 自定义滚动条样式 */
ScrollableContainer {
scrollbar-color: #666 #222;
scrollbar-color-hover: #888 #333;
overflow-y: auto;
}
/* 确保内容正确换行 */
Static {
width: 100%;
content-align: left middle;
overflow-y: auto;
}
"""
def compose(self):
"""组合界面"""
yield Header()
yield ScrollableContainer(
self.log_display,
id="log-area"
)
yield ScrollableContainer(
self.message_display,
id="message-area"
)
self.command_input = Input(placeholder="输入指令...", id="command-input")
yield Container(
self.command_input,
id="input-area"
)
yield Footer()
async def on_mount(self):
"""挂载完成事件"""
try:
# 开始捕获所有输出
self.log_display.start_capture()
# 设置输入框焦点
if self.command_input:
self.command_input.focus()
# 显示欢迎消息
self.show_message("🐱 SenSu TUI 已就绪!输入 'help' 查看命令\n", "info")
print("✅ TUI已启动,开始捕获所有输出")
except Exception as e:
print(f"❌ TUI挂载时出错: {str(e)}")
async def on_input_submitted(self, event):
"""输入提交事件"""
try:
if hasattr(event, 'input') and event.input.id == "command-input":
command = event.value
event.input.value = "" # 清空输入框
if command.strip():
print(f"执行命令: {command}")
# 在消息区域显示正在处理
self.show_message(f"执行命令: {command}", "command")
# 发送到指令服务处理
result = await self.command_service.process_command(command, "tui")
# 显示命令结果
if result:
self.show_message(f"结果: {result}", "success")
else:
self.show_message("命令执行完成", "success")
except Exception as e:
print(f"❌ 指令处理错误: {str(e)}")
self.show_message(f"指令处理错误: {str(e)}", "error")
def show_message(self, message: str, msg_type: str = "info", persistent: bool = False):
"""显示消息"""
try:
self.message_display.add_message(message, msg_type, persistent)
except Exception as e:
print(f"❌ 显示TUI消息时出错: {str(e)}")
def clear_messages(self, clear_persistent: bool = False):
"""清空消息区域"""
try:
self.message_display.clear_messages(clear_persistent)
except Exception as e:
print(f"❌ 清空消息时出错: {str(e)}")
async def action_quit(self):
"""重写退出动作 - 最佳方案:优雅关闭"""
try:
logger.info("🐱 TUI接收到退出信号,开始关闭流程")
# 显示关闭消息
self.show_message("🐱 正在关闭框架...", "info", persistent=True)
# 停止捕获输出
self.log_display.stop_capture()
# 使用异步任务来优雅关闭,避免阻塞
asyncio.create_task(self._async_graceful_shutdown())
except Exception as e:
logger.error(f"TUI退出处理时出错: {str(e)}")
# 紧急退出
import os
os._exit(0)
async def _async_graceful_shutdown(self):
"""异步优雅关闭"""
try:
# 给一点时间显示消息
self.show_message("🐱 3...", "info", persistent=True)
await asyncio.sleep(1)
self.show_message("🐱 2..", "info", persistent=True)
await asyncio.sleep(1)
self.show_message("🐱 1.", "info", persistent=True)
await asyncio.sleep(1)
logger.info("🐱 执行异步关闭")
logger.debug("使用事件循环停止")
# 获取当前事件循环
loop = asyncio.get_event_loop()
# 停止所有运行中的任务(除了当前任务)
tasks = [t for t in asyncio.all_tasks(loop) if t is not asyncio.current_task()]
if tasks:
logger.debug(f"取消 {len(tasks)} 个运行中的任务")
for task in tasks:
task.cancel()
# 等待任务取消完成
await asyncio.gather(*tasks, return_exceptions=True)
# 停止事件循环
loop.stop()
logger.info("🐱 事件循环已停止,框架关闭完成")
except Exception as e:
logger.error(f"异步关闭失败: {str(e)}")
# 最后的手段
import os
os._exit(0)
def shutdown(self):
"""关闭TUI"""
try:
# 停止捕获输出
self.log_display.stop_capture()
self.action_quit()
self.exit()
print("🛑 TUI已关闭")
except Exception as e:
print(f"❌ 关闭TUI时出错: {str(e)}")
class TuiService:
"""TUI服务"""
def __init__(self, config: Dict, log_service, command_service):
self.config = config
self.log_service = log_service
self.command_service = command_service
self.tui_app = None
self._message_queue = asyncio.Queue()
self._message_processor_task = None
async def start(self):
"""启动TUI"""
try:
if not self.config.get('tui', {}).get('enabled', True):
print("TUI已禁用")
return
print("启动TUI服务")
self.tui_app = TUIFramework(self.config, self.log_service, self.command_service)
# 设置动态标题
self._setup_title()
# 启动消息处理任务
self._message_processor_task = asyncio.create_task(self._process_message_queue())
# 在后台运行TUI
asyncio.create_task(self._run_tui())
except Exception as e:
print(f"启动TUI服务时出错: {str(e)}")
raise
def toggle_auto_scroll(self, target: str = "all", enabled: bool = None):
"""切换自动滚动状态"""
try:
if not self.tui_app:
return "❌ TUI未启动"
result = []
if target in ["all", "log"]:
log_state = self.tui_app.log_display.toggle_auto_scroll(enabled)
result.append(f"📜 日志自动滚动: {'✅ 启用' if log_state else '❌ 禁用'}")
if target in ["all", "message"]:
msg_state = self.tui_app.message_display.toggle_auto_scroll(enabled)
result.append(f"💬 消息自动滚动: {'✅ 启用' if msg_state else '❌ 禁用'}")
return "\n".join(result)
except Exception as e:
return f"❌ 切换自动滚动失败: {str(e)}"
def scroll_to_bottom(self, target: str = "all"):
"""手动滚动到底部"""
try:
if not self.tui_app:
return "❌ TUI未启动"
result = []
if target in ["all", "log"]:
self.tui_app.log_display.scroll_to_bottom()
result.append("📜 日志区域已滚动到底部")
if target in ["all", "message"]:
self.tui_app.message_display.scroll_to_bottom()
result.append("💬 消息区域已滚动到底部")
return "\n".join(result)
except Exception as e:
return f"❌ 滚动到底部失败: {str(e)}"
async def _process_message_queue(self):
"""处理消息队列,避免消息过多导致界面卡顿"""
try:
while True:
# 从队列中获取消息
message_data = await self._message_queue.get()
if message_data is None: # 停止信号
break
message, msg_type, persistent = message_data
# 显示消息
if self.tui_app:
self.tui_app.show_message(message, msg_type, persistent)
# 小延迟避免消息过快
await asyncio.sleep(0.05)
except asyncio.CancelledError:
logger.debug("消息处理任务被取消")
except Exception as e:
logger.error(f"消息处理任务出错: {str(e)}")
def _setup_title(self):
"""设置TUI标题"""
try:
framework_config = self.config.get('framework', {})
name = framework_config.get('name', 'SenSu')
version = framework_config.get('version', 'Unknown')
debug_mode = framework_config.get('debug', False)
# 构建标题
title_parts = [f"🐱 {name} Ver.{version}"]
if debug_mode:
title_parts.append("[DEBUG]")
self.tui_app.title = " ".join(title_parts)
self.tui_app.sub_title = "Based DreamSu Framework"
logger.debug(f"设置TUI标题: {self.tui_app.title}")
logger.debug(f"设置TUI副标题: {self.tui_app.sub_title}")
except Exception as e:
logger.error(f"设置TUI标题时出错: {e}")
self.tui_app.title = "🐱 SenSu - Based DreamSu Framework" # 默认标题
async def _run_tui(self):
"""运行TUI"""
try:
await self.tui_app.run_async()
except Exception as e:
print(f"运行TUI时出错: {str(e)}")
def show_message(self, message: str, msg_type: str = "info", persistent: bool = False):
"""显示消息"""
try:
if self.tui_app:
# 将消息放入队列,由后台任务处理
self._message_queue.put_nowait((message, msg_type, persistent))
except Exception as e:
print(f"通过TUI服务显示消息时出错: {str(e)}")
def shutdown(self):
"""关闭TUI服务"""
try:
if self.tui_app:
self.tui_app.shutdown()
print("TUI服务已关闭")
except Exception as e:
print(f"关闭TUI服务时出错: {str(e)}")