diff --git a/AGENTS.md b/AGENTS.md index c776234..dfcf446 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,9 +4,14 @@ ```bash pip install -r requirements.txt -python -m oss.cli serve # start server on :8080 +pip install -e . # register nebula CLI +nebula serve # start server on :8080 # or: python main.py -# or: oss serve (after pip install -e .) + +## CLI modes (前后端分离) + +- `nebula serve` — 启动后端服务(HTTP API + WebUI) +- `nebula cli` — 启动 TUI 前端,连接现有后端(默认 localhost:8080) ``` ## Architecture (minimal core philosophy) @@ -22,8 +27,9 @@ python -m oss.cli serve # start server on :8080 | Action | Command | |--------|---------| -| Start server | `python -m oss.cli serve` | -| Show info | `python -m oss.cli info` | +| Start server | `nebula serve` | +| CLI / TUI mode | `nebula cli` (TBD) | +| Show info | `nebula info` | | Hidden achievements | Prefix with `!!` (e.g., `!!help`, `!!list`, `!!stats`, `!!debug`) | | Docker | `docker-compose up` (ports 8080-8082) | diff --git a/ai.md b/ai.md index 3411fc9..897e72a 100644 --- a/ai.md +++ b/ai.md @@ -1,5 +1,8 @@ # NebulaShell AI 开发文档 +> **架构决策**:`nebula cli` 采用前后端分离设计,TUI 前端直连后端 JSON API, +> 不使用 HTML→ANSI 转换引擎。详见下文 [TUI 架构决策](#tui-架构决策)。 + ## 项目介绍 NebulaShell 是一个企业级插件化运行时框架 (v1.2.0),核心理念是「一切皆为插件」。它提供了一个最小化的核心系统,仅负责加载 `plugin-loader` 插件,其余 26+ 个官方插件均由该加载器管理。 @@ -23,130 +26,77 @@ NebulaShell 是一个企业级插件化运行时框架 (v1.2.0),核心理念 --- -## TUI + WebUI 双启动架构 +## TUI 架构决策 -### 架构概述 +### 废弃方案:HTML→ANSI 动态转换层(v1.3) -系统现在默认同时启动 WebUI 和 TUI: -- **WebUI**:在浏览器中运行,提供完整的图形界面 -- **TUI**:在终端中运行,通过强大的转换层 (v1.3) 自动解析 WebUI 的 `/tui` 接口 +**已废弃。** 早期方案通过 `oss/tui/converter.py`(1430 行)在运行时将 WebUI 的 HTML 页面解析为终端元素,存在以下问题: -### TUI 转换层核心能力 (v1.3) +| 问题 | 说明 | +|------|------| +| **布局失真** | CSS Flex/Grid 布局模型无法映射到终端字符网格 | +| **交互断层** | JavaScript 事件系统只能在终端模拟,与真实浏览器行为不一致 | +| **维护成本高** | 1430 行转换引擎 + 每个 WebUI 页面需维护 TUI 兼容标记 | +| **渲染性能差** | 每次导航需对整个 HTML 进行 DOM 解析和布局计算 | +| **调试困难** | 终端渲染错误难以定位是 HTML 问题还是转换器 Bug | -TUI 转换层是一个强大的渲染引擎,能够自动访问 WebUI 开放的 `/tui` 接口,解析特殊的 `.html` 文件(入口为 `index.html`),并将其转换为终端界面。 - -#### 支持的组件类型 (64+) +### 当前方案:前后端分离,原生 ANSI 渲染 ``` -基础组件: text, heading, paragraph, span, divider, spacer -容器组件: container, box, panel, card, grid, flex, stack -表单组件: input, button, checkbox, radio, select, textarea, slider -数据组件: table, list, tree, progress, gauge, chart, stat -导航组件: navbar, sidebar, menu, breadcrumb, tabs, pagination -反馈组件: alert, toast, modal, spinner, tooltip, badge -布局组件: row, col, section, article, aside, header, footer -特殊组件: code, pre, blockquote, mark, kbd, time, avatar +nebula serve ─── JSON API ───→ nebula cli (TUI 前端) + (后端) (原生 ANSI 终端渲染) ``` -#### CSS 样式支持 +**后端职责**(`nebula serve`): +- 提供 RESTful JSON API(如 `/api/dashboard/stats`) +- WebSocket 实时推送 +- 不感知 TUI 存在 -转换层支持终端兼容的 CSS 样式: +**前端职责**(`nebula cli`): +- 通过 HTTP/WebSocket 消费后端 JSON 数据 +- 使用 ANSI 转义码直接在终端绘制界面 +- 不依赖任何 HTML/CSS 解析 -```css -/* 颜色系统 */ -color: #RGB, #RRGGBB, rgb(), rgba(), hsl(), 颜色名称 -background-color: 同上 -border-color: 同上 - -/* 字体排版 */ -font-size: small, medium, large, x-large, numeric(pt) -font-weight: normal, bold, bolder, lighter, numeric(100-900) -font-style: normal, italic, oblique -text-decoration: none, underline, overline, line-through -text-align: left, center, right, justify - -/* 边框样式 */ -border: width style color -border-style: none, solid, double, dashed, rounded, heavy, ascii -border-width: thin, medium, thick, numeric -border-radius: numeric (仅支持 rounded 样式) - -/* 布局与间距 */ -margin: numeric -padding: numeric -width: numeric, percentage, auto -height: numeric, percentage, auto -display: block, inline, flex, grid, none - -/* 特殊效果 */ -opacity: 0.0-1.0 (通过字符密度模拟) -white-space: normal, nowrap, pre -overflow: visible, hidden, scroll -``` - -#### JavaScript 交互支持 - -转换层模拟基础 JS 交互功能: - -```javascript -// 键盘事件 -document.addEventListener('keydown', (e) => { ... }) -document.addEventListener('keyup', (e) => { ... }) - -// 鼠标事件 (如果终端支持) -element.addEventListener('click', (e) => { ... }) -element.addEventListener('mouseover', (e) => { ... }) -element.addEventListener('mouseout', (e) => { ... }) - -// 焦点管理 -element.focus() -element.blur() - -// 类名切换 -element.classList.add('active') -element.classList.remove('active') -element.classList.toggle('active') -``` - -### 使用方式 - -#### 启动服务 - -```bash -# 方式 1: 直接启动 -python main.py - -# 方式 2: 模块方式 -python -m oss.cli serve - -# 方式 3: Docker -docker run -p 8080:8080 nebulashell:latest -``` - -启动后: -- WebUI 自动在默认浏览器打开 (通常是 http://localhost:8080) -- TUI 在终端中自动渲染,显示相同内容 - -#### TUI 交互 - -- **方向键**:导航焦点元素 -- **Enter/Space**:激活按钮/复选框 -- **Tab/Shift+Tab**:切换焦点 -- **q / Ctrl+C**:退出 TUI (WebUI 继续运行) -- **鼠标点击**:如果终端支持鼠标,可直接点击交互 - -#### 访问 /tui 接口 - -WebUI 需要开放 `/tui` 路径提供特殊 HTML: +#### 技术要点 ``` -GET /tui/index.html - TUI 主页面 -GET /tui/*.html - 其他 TUI 页面 -GET /tui/*.css - TUI 专用样式 -GET /tui/*.js - TUI 交互逻辑 +终端控制: + raw mode ─── tty.setraw(),单字节读取 + ONLCR ─── 重新开启 \n→\r\n 映射,避免阶梯乱码 + SGR 鼠标 ─── \x1b[?1000h\x1b[?1006h,解析 \x1b[ 1 and sys.argv[1].startswith("!!"): if _ACHIEVEMENTS_ENABLED: diff --git a/oss/config/__pycache__/__init__.cpython-313.pyc b/oss/config/__pycache__/__init__.cpython-313.pyc index 90f6dc8..f80c3f4 100644 Binary files a/oss/config/__pycache__/__init__.cpython-313.pyc and b/oss/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/oss/config/__pycache__/config.cpython-313.pyc b/oss/config/__pycache__/config.cpython-313.pyc index aa7175f..dc67f36 100644 Binary files a/oss/config/__pycache__/config.cpython-313.pyc and b/oss/config/__pycache__/config.cpython-313.pyc differ diff --git a/oss/logger/__pycache__/logger.cpython-313.pyc b/oss/logger/__pycache__/logger.cpython-313.pyc index f9982f6..5587c20 100644 Binary files a/oss/logger/__pycache__/logger.cpython-313.pyc and b/oss/logger/__pycache__/logger.cpython-313.pyc differ diff --git a/oss/plugin/__pycache__/capabilities.cpython-313.pyc b/oss/plugin/__pycache__/capabilities.cpython-313.pyc index c3efc93..902c417 100644 Binary files a/oss/plugin/__pycache__/capabilities.cpython-313.pyc and b/oss/plugin/__pycache__/capabilities.cpython-313.pyc differ diff --git a/oss/plugin/__pycache__/loader.cpython-313.pyc b/oss/plugin/__pycache__/loader.cpython-313.pyc index 87be3c2..933d7bc 100644 Binary files a/oss/plugin/__pycache__/loader.cpython-313.pyc and b/oss/plugin/__pycache__/loader.cpython-313.pyc differ diff --git a/oss/plugin/__pycache__/manager.cpython-313.pyc b/oss/plugin/__pycache__/manager.cpython-313.pyc index 31b8ea7..ab9b204 100644 Binary files a/oss/plugin/__pycache__/manager.cpython-313.pyc and b/oss/plugin/__pycache__/manager.cpython-313.pyc differ diff --git a/oss/plugin/__pycache__/types.cpython-313.pyc b/oss/plugin/__pycache__/types.cpython-313.pyc index 63fbf09..e596401 100644 Binary files a/oss/plugin/__pycache__/types.cpython-313.pyc and b/oss/plugin/__pycache__/types.cpython-313.pyc differ diff --git a/oss/shared/__pycache__/__init__.cpython-313.pyc b/oss/shared/__pycache__/__init__.cpython-313.pyc index 9455fb8..6d432d3 100644 Binary files a/oss/shared/__pycache__/__init__.cpython-313.pyc and b/oss/shared/__pycache__/__init__.cpython-313.pyc differ diff --git a/oss/shared/__pycache__/router.cpython-313.pyc b/oss/shared/__pycache__/router.cpython-313.pyc index e52de12..2da0115 100644 Binary files a/oss/shared/__pycache__/router.cpython-313.pyc and b/oss/shared/__pycache__/router.cpython-313.pyc differ diff --git a/oss/tui/client.py b/oss/tui/client.py new file mode 100644 index 0000000..994c9d6 --- /dev/null +++ b/oss/tui/client.py @@ -0,0 +1,491 @@ +"""TUI 客户端 - 前后端分离的 TUI 前端 + +通过 HTTP 连接后端 nebula serve,消费 JSON API, +直接使用 ANSI 转义码绘制专业终端界面。 +支持鼠标点击导航。 +""" +import sys +import json +import time +import tty +import termios +import signal +import socket +import urllib.request +import urllib.error +import shutil +import re +from typing import Optional + + +# ── ANSI 工具 ──────────────────────────────────────────── + +def fg(r, g, b): return f"\x1b[38;2;{r};{g};{b}m" +def bg(r, g, b): return f"\x1b[48;2;{r};{g};{b}m" +def bold(s): return f"\x1b[1m{s}\x1b[22m" +def dim(s): return f"\x1b[2m{s}\x1b[22m" +def rst(): return "\x1b[0m" + +C = { + "header_bg": (30, 30, 46), + "status_bg": (30, 30, 46), + "accent": (0, 255, 135), + "green": (0, 255, 135), + "yellow": (255, 220, 80), + "red": (255, 80, 80), + "cyan": (80, 200, 255), + "dim": (100, 100, 120), + "white": (220, 220, 240), + "bar_bg": (50, 50, 70), +} + +# ── 鼠标转义 ──────────────────────────────────────────── + +_MOUSE_ON = "\x1b[?1000h\x1b[?1002h\x1b[?1006h" +_MOUSE_OFF = "\x1b[?1006l\x1b[?1002l\x1b[?1000l" + +# ── HTTP 请求 ──────────────────────────────────────────── + +def http_get(url: str, timeout=5) -> Optional[str]: + try: + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read().decode("utf-8") + except Exception: + return None + + +def backend_alive(host="127.0.0.1", port=8080) -> bool: + try: + s = socket.create_connection((host, port), timeout=2) + s.close() + return True + except OSError: + return False + + +# ── 布局工具 ──────────────────────────────────────────── + +def term_size(): + return shutil.get_terminal_size((80, 24)) + + +def hbar(width: int, percent: float, color_fg=(0, 255, 135), color_bg=(50, 50, 70), char="█"): + filled = max(0, min(width, int(width * percent / 100))) + empty = width - filled + bar = fg(*color_fg) + char * filled + rst() + fg(*color_bg) + "░" * empty + rst() + return bar + + +# ── TUI 客户端 ────────────────────────────────────────── + +Page = dict # {"id": str, "label": str, "desc": str} + + +class TUIClient: + _resize_flag = False + + @classmethod + def _sigwinch(cls, sig, frame): + cls._resize_flag = True + + PAGES: list[Page] = [ + {"id": "welcome", "label": "首页", "desc": "系统概览"}, + {"id": "dashboard", "label": "仪表盘", "desc": "CPU · 内存 · 磁盘 · 网络"}, + {"id": "logs", "label": "日志", "desc": "实时日志输出"}, + {"id": "terminal", "label": "终端", "desc": "Shell"}, + {"id": "plugins", "label": "插件", "desc": "插件管理"}, + ] + + def __init__(self, host="127.0.0.1", port=8080): + self.host = host + self.port = port + self.base_url = f"http://{host}:{port}" + self.running = False + self.current_page = "welcome" + self.width = 80 + self.height = 24 + self._stats_cache = {} + self._stats_time = 0 + + # 鼠标点击区域: list of (y, page_id) + self._click_zones: list[tuple[int, str]] = [] + + def _fetch_stats(self) -> dict: + now = time.time() + if now - self._stats_time < 1 and self._stats_cache: + return self._stats_cache + raw = http_get(f"{self.base_url}/api/dashboard/stats") + if raw: + try: + self._stats_cache = json.loads(raw) + self._stats_time = now + except json.JSONDecodeError: + pass + return self._stats_cache + + # ── 鼠标事件 ────────────────────────────────────────── + + @staticmethod + def _parse_sgr_mouse(data: str): + """解析 SGR 鼠标事件 \x1b[ 1024**3: + return f"{b/(1024**3):.1f}G" + if b > 1024**2: + return f"{b/(1024**2):.1f}M" + if b > 1024: + return f"{b/1024:.1f}K" + return f"{b:.0f}B" + + # ── 主循环 ──────────────────────────────────────────── + + def _navigate(self, page_id: str): + self._stats_cache = {} + self._stats_time = 0 + self.current_page = page_id + self.width, self.height = term_size() + self._render_all() + + def run(self): + self.width, self.height = term_size() + self.running = True + self._render_all() + + signal.signal(signal.SIGWINCH, TUIClient._sigwinch) + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + # setraw 会关闭 ONLCR(\n→\r\n),重新开启避免阶梯乱码 + attrs = termios.tcgetattr(fd) + attrs[1] = attrs[1] | termios.ONLCR + termios.tcsetattr(fd, termios.TCSANOW, attrs) + sys.stdout.write(_MOUSE_ON) + sys.stdout.flush() + + buf = "" + while self.running: + # 终端 resize 检测 + if TUIClient._resize_flag: + TUIClient._resize_flag = False + self.width, self.height = term_size() + self._render_all() + + ch = sys.stdin.read(1) + buf += ch + + # 检测 SGR 鼠标事件结束符 M/m + if buf.startswith("\x1b[<") and ch in ("M", "m"): + ev = self._parse_sgr_mouse(buf) + buf = "" + if ev: + button, mx, my = ev + if button == 0 and ch == "M": # 左键按下 + for zy, page_id in self._click_zones: + if my == zy + 1: # 鼠标坐标 1-based + self._navigate(page_id) + break + continue + + # 非鼠标序列 → 重置缓冲区 + if not buf.startswith("\x1b"): + pass + elif buf.startswith("\x1b[<"): + continue # 等待更多字符 + elif len(buf) > 1: + buf = "" # 其他转义序列,丢弃 + + # 处理单字符输入 + if len(buf) == 1: + c = buf + buf = "" + if c in ("q", "Q", "\x03", "\x04"): + break + elif c == "1": + self._navigate("welcome") + elif c == "2": + self._navigate("dashboard") + elif c == "3": + self._navigate("logs") + elif c == "4": + self._navigate("terminal") + elif c == "5": + self._navigate("plugins") + elif c in ("r", "R"): + self._stats_cache = {} + self._stats_time = 0 + self._render_all() + except Exception: + pass + finally: + signal.signal(signal.SIGWINCH, signal.SIG_DFL) + sys.stdout.write(_MOUSE_OFF) + sys.stdout.flush() + termios.tcsetattr(fd, termios.TCSADRAIN, old) + print("\x1b[2J\x1b[H\x1b[0mTUI 已退出\n") diff --git a/oss/tui/converter.py b/oss/tui/converter.py index bf808c6..bf62fff 100644 --- a/oss/tui/converter.py +++ b/oss/tui/converter.py @@ -1344,6 +1344,14 @@ class TUIManager: else: self.show_error(f"Page not found: {path}") + def render_page(self, path: str = None) -> str: + """渲染指定页面,返回终端文本(不写入画布)""" + path = path or self.current_page + if not path or path not in self.pages: + return "" + html = self.pages[path] + return self.renderer.render_with_frame(html, title=f"NebulaShell - {path}") + def render_current(self): """渲染当前页面""" if not self.current_page or self.current_page not in self.pages: diff --git a/oss/tui/plugin.py b/oss/tui/plugin.py index 281197b..9c04535 100644 --- a/oss/tui/plugin.py +++ b/oss/tui/plugin.py @@ -620,6 +620,11 @@ export default TUI; }) ) + 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 停止中...") diff --git a/pyproject.toml b/pyproject.toml index 14af1d9..ce5bd2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ ] [project.scripts] +nebula = "oss.cli:main" oss = "oss.cli:main" [tool.setuptools.packages.find] diff --git a/store/@{Falck}/html-render/__pycache__/main.cpython-313.pyc b/store/@{Falck}/html-render/__pycache__/main.cpython-313.pyc index 108a6d0..45eea58 100644 Binary files a/store/@{Falck}/html-render/__pycache__/main.cpython-313.pyc and b/store/@{Falck}/html-render/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{Falck}/web-toolkit/__pycache__/main.cpython-313.pyc b/store/@{Falck}/web-toolkit/__pycache__/main.cpython-313.pyc index 5beadfb..bcf5ae1 100644 Binary files a/store/@{Falck}/web-toolkit/__pycache__/main.cpython-313.pyc and b/store/@{Falck}/web-toolkit/__pycache__/main.cpython-313.pyc differ diff --git a/store/@{Falck}/web-toolkit/__pycache__/router.cpython-313.pyc b/store/@{Falck}/web-toolkit/__pycache__/router.cpython-313.pyc index e30a84b..13b816b 100644 Binary files a/store/@{Falck}/web-toolkit/__pycache__/router.cpython-313.pyc and b/store/@{Falck}/web-toolkit/__pycache__/router.cpython-313.pyc differ diff --git a/store/@{Falck}/web-toolkit/__pycache__/static.cpython-313.pyc b/store/@{Falck}/web-toolkit/__pycache__/static.cpython-313.pyc index 340e3f5..46a6446 100644 Binary files a/store/@{Falck}/web-toolkit/__pycache__/static.cpython-313.pyc and b/store/@{Falck}/web-toolkit/__pycache__/static.cpython-313.pyc differ diff --git a/store/@{Falck}/web-toolkit/__pycache__/template.cpython-313.pyc b/store/@{Falck}/web-toolkit/__pycache__/template.cpython-313.pyc index 23ad4ed..b77293e 100644 Binary files a/store/@{Falck}/web-toolkit/__pycache__/template.cpython-313.pyc and b/store/@{Falck}/web-toolkit/__pycache__/template.cpython-313.pyc differ