"""TUI 插件 - 终端用户界面,与 WebUI 双启动 强大的转换层架构: - 只访问 WebUI 开放的 /tui 接口 - 自动解析 .html 文件(入口是 index.html) - 支持终端兼容的 CSS(背景、字体排版样式) - 支持基础 JS 交互(鼠标位置、点击、按键) - 参考 opencode 风格的现代化终端体验 """ import os import sys import threading import time from pathlib import Path from oss.logger.logger import Log from oss.plugin.types import Plugin, Response, register_plugin_type from oss.config import get_config from oss.tui.converter import TUIManager, TUIRenderer, HTMLToTUIConverter class TUIPlugin(Plugin): """TUI 插件 - 提供终端界面,通过访问 WebUI 的 /tui 接口获取 HTML""" def __init__(self): self.webui = None self.http_api = None self.tui_manager = None self.running = False self.tui_thread = None self.server = None def meta(self): from oss.plugin.types import Metadata, PluginConfig, Manifest config = get_config() return Manifest( metadata=Metadata( name="tui", version="2.0.0", author="NebulaShell", description="终端用户界面 - 强大的 WebUI 转换层,与 WebUI 双启动" ), config=PluginConfig( enabled=True, args={ "width": config.get("TUI_WIDTH", 80), "height": config.get("TUI_HEIGHT", 24), "theme": "dark", "mouse_enabled": True, } ), dependencies=["http-api", "webui"] ) def set_webui(self, webui): """注入 webui 引用""" self.webui = webui def set_http_api(self, http_api): """注入 http_api 引用""" self.http_api = http_api def init(self, deps: dict = None): """初始化 TUI - 注册 /tui 接口供转换层访问""" Log.info("tui", "TUI 插件初始化中...") # 创建 TUI 管理器 config = get_config() width = config.get("TUI_WIDTH", 80) height = config.get("TUI_HEIGHT", 24) self.tui_manager = TUIManager.get_instance(width, height) # 注册 /tui 路由供 TUI 转换层访问 WebUI 页面 if self.http_api and self.http_api.router: # 核心接口:/tui/index.html - TUI 入口 self.http_api.router.get("/tui/index.html", self._handle_tui_index) # 核心接口:/tui/page - 获取任意页面的 TUI 版本 self.http_api.router.get("/tui/page", self._handle_tui_page) # 核心接口:/tui/css - 返回终端兼容的 CSS self.http_api.router.get("/tui/css", self._handle_tui_css) # 核心接口:/tui/js - 返回 TUI 交互配置(模拟 JS) self.http_api.router.get("/tui/js", self._handle_tui_js) # 核心接口:/tui/interact - 处理 TUI 交互事件 self.http_api.router.post("/tui/interact", self._handle_tui_interact) # 核心接口:/tui/pages - 列出所有可用页面 self.http_api.router.get("/tui/pages", self._handle_tui_pages) Log.ok("tui", "已注册 TUI API 路由 (/tui/*)") else: Log.warn("tui", "警告:未找到 http-api 依赖") # 从 WebUI 加载默认页面到 TUI 缓存 self._load_default_pages() Log.ok("tui", "TUI 插件初始化完成 - 强大的转换层已就绪") def _load_default_pages(self): """从 WebUI 加载默认页面到 TUI 缓存""" default_pages = ["/", "/dashboard", "/logs", "/terminal", "/plugins"] for path in default_pages: try: html = self._fetch_webui_page(path) if html: self.tui_manager.load_page(path, html) Log.info("tui", f"已加载页面:{path}") except Exception as e: Log.debug("tui", f"加载页面 {path} 失败:{e}") def _fetch_webui_page(self, path: str) -> str: """从 WebUI 获取页面 HTML - 转换层核心方法 此方法模拟访问 WebUI 页面并获取 HTML,然后由 TUI 转换层解析。 WebUI 开放的 /tui 接口会返回带有特殊标记的 HTML,不含用户可见内容, 但包含 data-tui-* 属性和 script[type='application/x-tui-*'] 配置。 """ if not self.webui or not hasattr(self.webui, 'server'): return "" try: from oss.plugin.types import Request request = Request(method="GET", path=path, headers={}, body="") # 查找匹配的路由 router = self.webui.server.router if hasattr(router, 'routes'): for route_path, handler in router.routes.items(): if route_path == path or (route_path.endswith('*') and path.startswith(route_path[:-1])): response = handler(request) if response and hasattr(response, 'body'): return response.body.decode('utf-8') if isinstance(response.body, bytes) else response.body except Exception as e: Log.debug("tui", f"获取 WebUI 页面失败:{e}") return "" def start(self): """启动 TUI(在后台线程运行)""" Log.info("tui", "TUI 启动中...") self.running = True # 在后台线程运行 TUI self.tui_thread = threading.Thread(target=self._tui_loop, daemon=True) self.tui_thread.start() Log.ok("tui", "TUI 已启动(后台模式)") Log.info("tui", "提示:按 'q' 退出 TUI,WebUI 仍在运行") def _tui_loop(self): """TUI 主循环""" try: # 显示欢迎界面 self._show_welcome() # 主事件循环 self._event_loop() except Exception as e: Log.error("tui", f"TUI 循环异常:{e}") finally: self.running = False def _show_welcome(self): """显示欢迎界面""" welcome_html = """
终端用户界面已启动
WebUI 同时运行在:http://localhost:8080
终端界面就绪
路径:
""" return Response( status=404, headers={"Content-Type": "text/html; charset=utf-8"}, body=error_html ) def _handle_tui_css(self, request): """处理 /tui/css 请求 - 返回终端兼容的 CSS 只返回终端支持的 CSS 属性: - 背景色(ANSI 颜色) - 文字颜色(ANSI 颜色) - 字体样式(bold, italic, underline) - 边框样式(单线、双线、圆角等) """ css = """/* TUI 兼容 CSS - 仅支持终端属性 */ /* 基础样式 */ .tui-page { background-color: #000000; color: #ffffff; } .tui-body { font-family: monospace; font-weight: normal; } /* 字体样式 - TUI 支持 */ .bold { font-weight: bold; } .italic { font-style: italic; } .underline { text-decoration: underline; } .dim { opacity: 0.7; } /* 布局 - TUI 简化处理 */ .tui-container { padding: 0; margin: 0; } [data-tui-layout="vertical"] { display: block; } [data-tui-layout="horizontal"] { display: inline-block; } /* 边框样式 */ [data-tui-border="single"] { border-style: single; } [data-tui-border="double"] { border-style: double; } [data-tui-border="rounded"] { border-style: rounded; } /* 交互元素标记 */ [data-tui-action] { cursor: pointer; } [data-tui-key]::before { content: "[" attr(data-tui-key) "] "; } /* 面板/卡片 */ [data-tui-type="panel"] { border-style: single; padding: 1; } /* 按钮 */ button, [data-tui-type="button"] { border-style: single; padding: 0 2; } /* 列表 */ ul, ol { list-style-position: inside; } /* 进度条 */ [data-tui-type="progress"] { filled-char: "█"; empty-char: "░"; } /* 加载动画 */ [data-tui-type="spinner"] { animation: spin 1s linear infinite; } """ return Response( status=200, headers={"Content-Type": "text/css"}, body=css ) def _handle_tui_js(self, request): """处理 /tui/js 请求 - 返回 TUI 交互配置(模拟 JS) TUI 不支持完整 JavaScript,只支持: - 获取鼠标位置 - 点击事件 - 按键事件 - 简单的 DOM 操作 """ js_config = """// TUI JS 模拟配置 // 仅支持基础交互功能 const TUI = { // 鼠标支持 mouse: { enabled: true, getPosition: () => ({ x: 0, y: 0 }), onClick: (handler) => {}, }, // 键盘支持 keyboard: { enabled: true, onKeyPress: (handler) => {}, bindings: {}, }, // DOM 操作(简化版) querySelector: (selector) => null, querySelectorAll: (selector) => [], // 事件系统 addEventListener: (event, handler) => {}, removeEventListener: (event, handler) => {}, }; // 导出配置 export default TUI; """ return Response( status=200, headers={"Content-Type": "application/javascript"}, body=js_config ) def _handle_tui_interact(self, request): """处理 TUI 交互请求 - 处理鼠标、键盘事件""" import json try: body = json.loads(request.body) action = body.get('action', '') target = body.get('target', '') key = body.get('key', '') mouse_x = body.get('mouse_x', 0) mouse_y = body.get('mouse_y', 0) # 处理导航 if action == 'navigate': html = self._fetch_webui_page(target) if html: self.tui_manager.load_page(target, html) return Response( status=200, headers={"Content-Type": "application/json"}, body=json.dumps({'success': True, 'page': target}) ) # 处理点击 elif action == 'click': return Response( status=200, headers={"Content-Type": "application/json"}, body=json.dumps({'success': True, 'target': target}) ) # 处理按键 elif action == 'keypress': return Response( status=200, headers={"Content-Type": "application/json"}, body=json.dumps({'success': True, 'key': key}) ) # 处理鼠标移动 elif action == 'mousemove': return Response( status=200, headers={"Content-Type": "application/json"}, body=json.dumps({'success': True, 'x': mouse_x, 'y': mouse_y}) ) # 处理刷新 elif action == 'refresh': self._load_default_pages() return Response( status=200, headers={"Content-Type": "application/json"}, body=json.dumps({'success': True}) ) # 处理退出 elif action == 'quit': self.running = False return Response( status=200, headers={"Content-Type": "application/json"}, body=json.dumps({'success': True, 'message': 'Quitting TUI'}) ) return Response( status=400, headers={"Content-Type": "application/json"}, body=json.dumps({'success': False, 'error': 'Unknown action'}) ) except Exception as e: return Response( status=500, headers={"Content-Type": "application/json"}, body=json.dumps({'success': False, 'error': str(e)}) ) def _handle_tui_pages(self, request): """处理 /tui/pages 请求 - 列出所有可用页面""" import json pages = [] if self.webui and hasattr(self.webui, 'server'): router = self.webui.server.router if hasattr(router, 'routes'): pages = list(router.routes.keys()) return Response( status=200, headers={"Content-Type": "application/json"}, body=json.dumps({ 'success': True, 'pages': pages, 'current': self.tui_manager.current_page if self.tui_manager else None }) ) def wait_for_exit(self): """前台阻塞等待 TUI 退出(用于 CLI 模式)""" if self.tui_thread and self.tui_thread.is_alive(): self.tui_thread.join() def stop(self): """停止 TUI""" Log.info("tui", "TUI 停止中...") self.running = False if self.tui_thread: self.tui_thread.join(timeout=2) Log.ok("tui", "TUI 已停止") register_plugin_type("TUIPlugin", TUIPlugin) def New(): return TUIPlugin()