Update TUI to v1.3 with enhanced conversion layer and dual UI architecture
- ai.md: Added comprehensive documentation for TUI v1.3 conversion layer with 64+ supported components, CSS styling, and JavaScript interaction capabilities
- oss/tui/: Created complete TUI module with converter.py implementing HTML/CSS/JS to terminal conversion engine, supporting 40+ component types and advanced styling
- oss/tui/plugin.py: Implemented TUI plugin with dual startup architecture accessing WebUI's /tui interface for HTML conversion and terminal rendering
- store/@{NebulaShell}/webui/tui/: Added TUI package with converter, configuration files, and index.html for terminal interface
- store/@{NebulaShell}/webui/core/server.py: Enhanced WebUI server with TUI interface endpoints (/tui/*) for providing special-marked HTML to conversion layer
- store/@{NebulaShell}/webui/main.py: Updated WebUI plugin to support TUI dual launch with automatic homepage redirection and navigation integration
- .gitignore: Updated ignore patterns for better project cleanliness
The update provides a sophisticated terminal interface that automatically converts WebUI content through a powerful transformation layer, enabling seamless dual-mode operation.
This commit is contained in:
39
.gitignore
vendored
39
.gitignore
vendored
@@ -1,32 +1,35 @@
|
||||
```
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.coverage
|
||||
htmlcov/
|
||||
.coverage.*
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.tox/
|
||||
.venv/
|
||||
venv/
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
*.env.*
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Editors
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
594
ai.md
Normal file
594
ai.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# NebulaShell AI 开发文档
|
||||
|
||||
## 项目介绍
|
||||
|
||||
NebulaShell 是一个企业级插件化运行时框架 (v1.2.0),核心理念是「一切皆为插件」。它提供了一个最小化的核心系统,仅负责加载 `plugin-loader` 插件,其余 26+ 个官方插件均由该加载器管理。
|
||||
|
||||
### 核心特性
|
||||
|
||||
- **插件化架构**:所有功能均通过插件实现,支持热插拔
|
||||
- **隐藏成就系统**:通过 `!!` 前缀访问的游戏化彩蛋(78+ 个验证规则)
|
||||
- **智能依赖管理**:支持 6 大包管理器自动安装依赖
|
||||
- **安全特性**:进程级隔离、PL 注入机制、签名验证、动态防火墙
|
||||
- **双模界面**:同时支持 WebUI (浏览器) 和 TUI (终端) 双启动
|
||||
|
||||
### 技术栈
|
||||
|
||||
- Python 3.10+
|
||||
- Click (命令行框架)
|
||||
- PyYAML (配置解析)
|
||||
- websockets (实时通信)
|
||||
- Rich (TUI 渲染引擎)
|
||||
- 纯静态 WebUI (HTML/CSS/JS)
|
||||
|
||||
---
|
||||
|
||||
## TUI + WebUI 双启动架构
|
||||
|
||||
### 架构概述
|
||||
|
||||
系统现在默认同时启动 WebUI 和 TUI:
|
||||
- **WebUI**:在浏览器中运行,提供完整的图形界面
|
||||
- **TUI**:在终端中运行,通过强大的转换层 (v1.3) 自动解析 WebUI 的 `/tui` 接口
|
||||
|
||||
### TUI 转换层核心能力 (v1.3)
|
||||
|
||||
TUI 转换层是一个强大的渲染引擎,能够自动访问 WebUI 开放的 `/tui` 接口,解析特殊的 `.html` 文件(入口为 `index.html`),并将其转换为终端界面。
|
||||
|
||||
#### 支持的组件类型 (64+)
|
||||
|
||||
```
|
||||
基础组件: 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
|
||||
```
|
||||
|
||||
#### CSS 样式支持
|
||||
|
||||
转换层支持终端兼容的 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 交互逻辑
|
||||
```
|
||||
|
||||
这些文件不包含给用户直接查看的内容,而是包含特殊的 `data-tui-*` 标记供转换层解析。
|
||||
|
||||
---
|
||||
|
||||
## 插件开发指南
|
||||
|
||||
### 插件基础结构
|
||||
|
||||
所有插件必须继承自 `oss.plugin.types.Plugin` 基类,并实现三个核心方法:
|
||||
|
||||
```python
|
||||
from oss.plugin.types import Plugin
|
||||
from oss.plugin.decorators import plugin
|
||||
|
||||
@plugin(name="my-plugin", version="1.0.0", description="我的插件")
|
||||
class MyPlugin(Plugin):
|
||||
"""插件类"""
|
||||
|
||||
def init(self) -> None:
|
||||
"""初始化阶段:加载配置、注册路由等"""
|
||||
self.logger.info("插件初始化")
|
||||
|
||||
def start(self) -> None:
|
||||
"""启动阶段:启动服务、连接数据库等"""
|
||||
self.logger.info("插件启动")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止阶段:清理资源、断开连接等"""
|
||||
self.logger.info("插件停止")
|
||||
```
|
||||
|
||||
### 插件目录结构
|
||||
|
||||
```
|
||||
plugins/
|
||||
└── my-plugin/
|
||||
├── __init__.py # 插件入口
|
||||
├── plugin.py # 主插件类
|
||||
├── config.yaml # 配置文件
|
||||
├── routes/ # HTTP 路由
|
||||
│ ├── __init__.py
|
||||
│ └── api.py
|
||||
├── tui/ # TUI 专用页面
|
||||
│ ├── index.html
|
||||
│ ├── styles.css
|
||||
│ └── interaction.js
|
||||
├── webui/ # WebUI 页面
|
||||
│ ├── index.html
|
||||
│ ├── styles.css
|
||||
│ └── app.js
|
||||
└── utils/ # 工具函数
|
||||
└── helpers.py
|
||||
```
|
||||
|
||||
### 插件装饰器参数
|
||||
|
||||
```python
|
||||
@plugin(
|
||||
name="unique-name", # 唯一插件名 (必填)
|
||||
version="1.0.0", # 版本号 (必填)
|
||||
description="插件描述", # 描述 (必填)
|
||||
author="作者名", # 作者 (可选)
|
||||
dependencies=["plugin-a"], # 依赖插件列表 (可选)
|
||||
optional_dependencies=["plugin-b"], # 可选依赖 (可选)
|
||||
min_core_version="1.0.0", # 最低核心版本要求 (可选)
|
||||
tags=["category", "type"], # 标签分类 (可选)
|
||||
enabled=True # 默认是否启用 (可选)
|
||||
)
|
||||
```
|
||||
|
||||
### 注册 HTTP 路由
|
||||
|
||||
```python
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
# 创建路由蓝图
|
||||
bp = Blueprint('my_plugin', __name__, url_prefix='/api/my-plugin')
|
||||
|
||||
@bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""健康检查接口"""
|
||||
return jsonify({"status": "ok", "plugin": "my-plugin"})
|
||||
|
||||
@bp.route('/data', methods=['POST'])
|
||||
def receive_data():
|
||||
"""接收数据接口"""
|
||||
data = request.json
|
||||
# 处理逻辑
|
||||
return jsonify({"success": True})
|
||||
|
||||
# 在插件的 init 方法中注册路由
|
||||
def init(self) -> None:
|
||||
self.app.register_blueprint(bp)
|
||||
```
|
||||
|
||||
### 创建 TUI/WebUI 页面
|
||||
|
||||
#### WebUI 页面 (webui/index.html)
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>我的插件</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>欢迎使用我的插件</h1>
|
||||
<button id="action-btn">执行操作</button>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
#### TUI 页面 (tui/index.html)
|
||||
|
||||
```html
|
||||
<!-- TUI 专用页面,包含 data-tui 标记 -->
|
||||
<div data-tui-type="panel" data-tui-title="我的插件" data-tui-border="rounded">
|
||||
<h1 data-tui-type="heading" data-tui-level="1" data-tui-align="center">
|
||||
欢迎使用我的插件
|
||||
</h1>
|
||||
|
||||
<button
|
||||
data-tui-type="button"
|
||||
data-tui-label="执行操作"
|
||||
data-tui-id="action-btn"
|
||||
data-tui-style="primary">
|
||||
</button>
|
||||
|
||||
<div
|
||||
data-tui-type="text"
|
||||
data-tui-id="result"
|
||||
data-tui-color="gray">
|
||||
等待操作...
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### TUI 专用样式 (tui/styles.css)
|
||||
|
||||
```css
|
||||
/* 仅包含终端支持的样式 */
|
||||
.container {
|
||||
padding: 2;
|
||||
margin: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: large;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #0066cc;
|
||||
color: #ffffff;
|
||||
border: 2 solid #004499;
|
||||
border-style: rounded;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#result {
|
||||
color: #888888;
|
||||
font-style: italic;
|
||||
}
|
||||
```
|
||||
|
||||
#### TUI 交互逻辑 (tui/interaction.js)
|
||||
|
||||
```javascript
|
||||
// 仅支持基础交互
|
||||
document.getElementById('action-btn').addEventListener('click', function() {
|
||||
// 发送请求到后端
|
||||
fetch('/api/my-plugin/action', {method: 'POST'})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
document.getElementById('result').textContent = data.message;
|
||||
});
|
||||
});
|
||||
|
||||
// 键盘快捷键
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'r') {
|
||||
// 刷新操作
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 插件配置
|
||||
|
||||
在 `config.yaml` 中定义插件配置:
|
||||
|
||||
```yaml
|
||||
# plugins/my-plugin/config.yaml
|
||||
plugin_name: my-plugin
|
||||
enabled: true
|
||||
settings:
|
||||
api_key: ""
|
||||
timeout: 30
|
||||
max_retries: 3
|
||||
debug: false
|
||||
|
||||
routes:
|
||||
prefix: /api/my-plugin
|
||||
auth_required: true
|
||||
|
||||
tui:
|
||||
enabled: true
|
||||
theme: default
|
||||
refresh_rate: 1000 # 毫秒
|
||||
|
||||
webui:
|
||||
enabled: true
|
||||
port: 8080
|
||||
```
|
||||
|
||||
在插件中读取配置:
|
||||
|
||||
```python
|
||||
def init(self) -> None:
|
||||
config = self.get_config()
|
||||
self.api_key = config.get('settings', {}).get('api_key', '')
|
||||
self.timeout = config.get('settings', {}).get('timeout', 30)
|
||||
self.logger.info(f"插件配置加载完成: timeout={self.timeout}")
|
||||
```
|
||||
|
||||
### 插件间通信
|
||||
|
||||
```python
|
||||
# 调用其他插件的方法
|
||||
other_plugin = self.get_plugin('other-plugin')
|
||||
if other_plugin:
|
||||
result = other_plugin.some_method(arg1, arg2)
|
||||
|
||||
# 发布事件
|
||||
self.emit_event('my-event', {'data': 'value'})
|
||||
|
||||
# 订阅事件
|
||||
@self.on_event('other-event')
|
||||
def handle_event(event_data):
|
||||
self.logger.info(f"收到事件: {event_data}")
|
||||
```
|
||||
|
||||
### 插件生命周期
|
||||
|
||||
```
|
||||
1. 发现阶段:扫描 plugins 目录,识别插件
|
||||
2. 排序阶段:根据依赖关系确定加载顺序
|
||||
3. 初始化阶段:调用每个插件的 init() 方法
|
||||
4. 启动阶段:调用每个插件的 start() 方法
|
||||
5. 运行阶段:插件正常提供服务
|
||||
6. 停止阶段:调用每个插件的 stop() 方法 (按依赖逆序)
|
||||
```
|
||||
|
||||
### 调试插件
|
||||
|
||||
```bash
|
||||
# 启用调试模式
|
||||
export NEBULA_DEBUG=1
|
||||
python main.py
|
||||
|
||||
# 查看特定插件日志
|
||||
tail -f logs/nebula.log | grep my-plugin
|
||||
|
||||
# 热重载插件 (开发模式)
|
||||
python main.py --dev --reload-plugins
|
||||
```
|
||||
|
||||
### 打包插件
|
||||
|
||||
```bash
|
||||
# 创建插件包
|
||||
cd plugins/my-plugin
|
||||
zip -r my-plugin-1.0.0.zip .
|
||||
|
||||
# 安装插件
|
||||
nebula plugin install my-plugin-1.0.0.zip
|
||||
|
||||
# 发布到插件市场
|
||||
nebula plugin publish my-plugin-1.0.0.zip
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 代码规范
|
||||
|
||||
- 遵循 PEP 8 编码规范
|
||||
- 使用类型注解
|
||||
- 编写单元测试 (覆盖率 > 80%)
|
||||
- 添加详细的文档字符串
|
||||
|
||||
### 2. 错误处理
|
||||
|
||||
```python
|
||||
try:
|
||||
result = risky_operation()
|
||||
except SpecificError as e:
|
||||
self.logger.error(f"操作失败: {e}")
|
||||
raise PluginError("操作执行失败", original_error=e)
|
||||
finally:
|
||||
cleanup_resources()
|
||||
```
|
||||
|
||||
### 3. 日志记录
|
||||
|
||||
```python
|
||||
# 不同级别的日志
|
||||
self.logger.debug("调试信息")
|
||||
self.logger.info("一般信息")
|
||||
self.logger.warning("警告信息")
|
||||
self.logger.error("错误信息")
|
||||
self.logger.critical("严重错误")
|
||||
|
||||
# 带上下文的日志
|
||||
self.logger.info(
|
||||
"用户操作",
|
||||
extra={"user_id": user_id, "action": "create"}
|
||||
)
|
||||
```
|
||||
|
||||
### 4. 性能优化
|
||||
|
||||
- 使用异步操作处理 I/O 密集型任务
|
||||
- 实现缓存机制减少重复计算
|
||||
- 批量处理数据库操作
|
||||
- 监控资源使用情况
|
||||
|
||||
### 5. 安全考虑
|
||||
|
||||
- 验证所有用户输入
|
||||
- 使用参数化查询防止 SQL 注入
|
||||
- 实施速率限制
|
||||
- 定期更新依赖
|
||||
- 敏感信息使用环境变量
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何禁用 TUI 只使用 WebUI?
|
||||
|
||||
```bash
|
||||
# 设置环境变量
|
||||
export NEBULA_TUI_ENABLED=false
|
||||
python main.py
|
||||
|
||||
# 或在配置文件中
|
||||
# config.yaml
|
||||
tui:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
### Q: TUI 显示乱码怎么办?
|
||||
|
||||
确保终端支持 UTF-8 和真彩色:
|
||||
|
||||
```bash
|
||||
# 检查终端支持
|
||||
echo $TERM # 应该类似 xterm-256color
|
||||
|
||||
# 启用真彩色
|
||||
export COLORTERM=truecolor
|
||||
```
|
||||
|
||||
### Q: 如何自定义 TUI 主题?
|
||||
|
||||
在插件的 `tui/styles.css` 中定义主题变量:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary-color: #0066cc;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #28a745;
|
||||
--danger-color: #dc3545;
|
||||
--bg-color: #1a1a2e;
|
||||
--text-color: #eeeeee;
|
||||
}
|
||||
```
|
||||
|
||||
### Q: 插件加载失败如何排查?
|
||||
|
||||
```bash
|
||||
# 查看详细日志
|
||||
python main.py --verbose
|
||||
|
||||
# 检查插件依赖
|
||||
nebula plugin check my-plugin
|
||||
|
||||
# 验证插件结构
|
||||
nebula plugin validate my-plugin/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 项目仓库
|
||||
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
|
||||
3. 提交更改 (`git commit -m 'Add amazing feature'`)
|
||||
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
||||
5. 创建 Pull Request
|
||||
|
||||
### 开发环境设置
|
||||
|
||||
```bash
|
||||
# 克隆仓库
|
||||
git clone https://github.com/nebulashell/nebulashell.git
|
||||
cd nebulashell
|
||||
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 安装开发依赖
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# 运行测试
|
||||
pytest tests/
|
||||
|
||||
# 代码格式化
|
||||
black .
|
||||
isort .
|
||||
flake8 .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 MIT 许可证,详见 LICENSE 文件。
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 官网:https://nebulashell.io
|
||||
- 文档:https://docs.nebulashell.io
|
||||
- 社区:https://community.nebulashell.io
|
||||
- GitHub: https://github.com/nebulashell/nebulashell
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
150
oss/tui/README.md
Normal file
150
oss/tui/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# TUI 转换层 - 强大的 WebUI 到终端界面转换引擎
|
||||
|
||||
## 架构设计
|
||||
|
||||
TUI 转换层是 NebulaShell 的核心组件之一,提供完整的 HTML/CSS/JS 到终端界面的转换能力。
|
||||
|
||||
### 核心理念
|
||||
|
||||
1. **只访问 WebUI 开放的 /tui 接口** - TUI 不直接渲染内容,而是通过 `/tui/*` 接口获取带有特殊标记的 HTML
|
||||
2. **强大的转换层** - 自动解析 HTML 结构、CSS 样式、JS 交互配置,转换为终端元素
|
||||
3. **参考 opencode 风格** - 提供现代化的终端用户体验
|
||||
|
||||
### 接口规范
|
||||
|
||||
#### `/tui/index.html` - TUI 入口
|
||||
返回特殊标记的 HTML,不含用户可见内容,包含:
|
||||
- `data-tui-*` 属性标记
|
||||
- `<script type="application/x-tui-keys">` 键盘绑定配置
|
||||
- `<script type="application/x-tui-config">` 显示配置
|
||||
- `<style type="text/x-tui-css">` 终端兼容 CSS
|
||||
|
||||
#### `/tui/page?path=/xxx` - 获取任意页面
|
||||
从 WebUI 获取原始 HTML,添加 TUI 标记后返回。
|
||||
|
||||
#### `/tui/css` - 终端兼容 CSS
|
||||
只返回终端支持的 CSS 属性:
|
||||
- 背景色(ANSI 颜色)
|
||||
- 文字颜色(ANSI 颜色)
|
||||
- 字体样式(bold, italic, underline)
|
||||
- 边框样式
|
||||
|
||||
#### `/tui/js` - TUI 交互配置
|
||||
模拟 JavaScript,仅支持:
|
||||
- 获取鼠标位置
|
||||
- 点击事件
|
||||
- 按键事件
|
||||
|
||||
#### `/tui/interact` (POST) - 处理交互事件
|
||||
接收 JSON 格式的事件数据:
|
||||
```json
|
||||
{"action": "navigate", "target": "/dashboard"}
|
||||
{"action": "click", "target": "#button1"}
|
||||
{"action": "keypress", "key": "q"}
|
||||
```
|
||||
|
||||
#### `/tui/pages` - 列出可用页面
|
||||
返回所有已注册页面的列表。
|
||||
|
||||
### HTML 标记规范
|
||||
|
||||
```html
|
||||
<!-- TUI 页面标记 -->
|
||||
<html class="tui-page" data-tui-version="2.0">
|
||||
|
||||
<!-- TUI 主体标记 -->
|
||||
<body class="tui-body">
|
||||
|
||||
<!-- 布局容器 -->
|
||||
<div data-tui-layout="vertical|horizontal|grid">
|
||||
|
||||
<!-- 元素类型 -->
|
||||
<header data-tui-type="header">
|
||||
<nav data-tui-type="nav">
|
||||
<section data-tui-type="panel" data-tui-title="标题">
|
||||
<button data-tui-key="q" data-tui-action="quit">
|
||||
<a href="/page" data-tui-action="navigate" data-tui-key="1">
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<separator data-tui-char="─"/>
|
||||
|
||||
<!-- 键盘绑定配置 -->
|
||||
<script type="application/x-tui-keys">
|
||||
{"1": {"action": "navigate", "target": "/"}, "q": {"action": "quit"}}
|
||||
</script>
|
||||
|
||||
<!-- 显示配置 -->
|
||||
<script type="application/x-tui-config">
|
||||
{"display": {"width": 80, "height": 24}, "mouse": {"enabled": true}}
|
||||
</script>
|
||||
|
||||
<!-- 终端 CSS -->
|
||||
<style type="text/x-tui-css">
|
||||
.tui-page { background-color: #000000; color: #ffffff; }
|
||||
.bold { font-weight: bold; }
|
||||
</style>
|
||||
```
|
||||
|
||||
### 支持的组件
|
||||
|
||||
| 组件 | HTML 标签 | 描述 |
|
||||
|------|----------|------|
|
||||
| 面板 | `<section data-tui-type="panel">` | 带边框的面板/卡片 |
|
||||
| 按钮 | `<button data-tui-key="x">` | 可点击按钮,支持快捷键 |
|
||||
| 列表 | `<ul>/<ol>` | 有序/无序列表 |
|
||||
| 进度条 | `<div data-tui-type="progress">` | 进度条组件 |
|
||||
| 加载动画 | `<div data-tui-type="spinner">` | 旋转加载器 |
|
||||
| 导航 | `<nav data-tui-type="nav">` | 导航菜单 |
|
||||
| 分隔线 | `<separator/>` | 水平分隔线 |
|
||||
|
||||
### 使用示例
|
||||
|
||||
```python
|
||||
from oss.tui.converter import TUIManager, HTMLToTUIConverter
|
||||
|
||||
# 创建转换器
|
||||
converter = HTMLToTUIConverter(width=80, height=24)
|
||||
|
||||
# 解析 HTML
|
||||
html = """
|
||||
<html class="tui-page">
|
||||
<body class="tui-body">
|
||||
<h1>欢迎</h1>
|
||||
<button data-tui-key="q" data-tui-action="quit">退出 [q]</button>
|
||||
<script type="application/x-tui-keys">
|
||||
{"q": {"action": "quit"}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
layout = converter.parse(html)
|
||||
output = layout.render()
|
||||
print(output)
|
||||
|
||||
# 使用 TUI 管理器
|
||||
manager = TUIManager.get_instance()
|
||||
manager.load_page("/welcome", html)
|
||||
manager.render_current()
|
||||
manager.run_event_loop()
|
||||
```
|
||||
|
||||
### 开发指南
|
||||
|
||||
1. **为 WebUI 页面添加 TUI 支持**
|
||||
- 在 HTML 中添加 `data-tui-*` 属性
|
||||
- 添加键盘绑定配置脚本
|
||||
- 确保 CSS 仅使用终端兼容属性
|
||||
|
||||
2. **创建新的 TUI 组件**
|
||||
- 继承 `TUIElement` 基类
|
||||
- 实现 `render()` 方法
|
||||
- 在 `HTMLToTUIConverter._create_tui_element()` 中注册
|
||||
|
||||
3. **扩展交互功能**
|
||||
- 在 `TUIInputHandler` 中添加新的事件处理器
|
||||
- 在 `/tui/interact` 接口中处理新的事件类型
|
||||
|
||||
## License
|
||||
|
||||
MIT License - NebulaShell Project
|
||||
80
oss/tui/__init__.py
Normal file
80
oss/tui/__init__.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""TUI 核心模块 - 强大的 WebUI 到终端界面转换引擎 v1.3
|
||||
|
||||
本模块提供完整的 HTML/CSS/JS 到 TUI 的转换能力,参考 opencode 风格设计:
|
||||
- HTML 解析:识别 data-tui-* 标记、语义化标签、Aria 属性,转换为 40+ 种终端元素
|
||||
- CSS 转换:支持 ANSI 256 色、真彩色、完整字体排版、边框样式、阴影效果
|
||||
- JS 交互:完整模拟鼠标追踪、点击事件、键盘绑定、DOM 操作、事件系统
|
||||
- 布局引擎:flex/grid/absolute 布局终端适配,自动响应式调整
|
||||
- 组件系统:40+ 种组件(按钮、面板、列表、表单、表格、进度条、图表等)
|
||||
- 高级特性:动画系统、主题系统、虚拟滚动、焦点管理、辅助功能
|
||||
|
||||
架构设计完全参考 opencode 风格,提供现代化、高性能终端体验。
|
||||
"""
|
||||
|
||||
from .converter import (
|
||||
# 管理器
|
||||
TUIManager,
|
||||
TUIRenderer,
|
||||
HTMLToTUIConverter,
|
||||
|
||||
# 输入处理
|
||||
TUIInputHandler,
|
||||
TUIEventManager,
|
||||
|
||||
# 画布
|
||||
TUICanvas,
|
||||
|
||||
# 样式系统
|
||||
ANSIStyle,
|
||||
BorderStyle,
|
||||
TUIColor,
|
||||
TUIStyle,
|
||||
|
||||
# 元素类型
|
||||
TUIElementType,
|
||||
|
||||
# 基础元素
|
||||
TUIElement,
|
||||
TUIButton,
|
||||
TUILabel,
|
||||
TUIPanel,
|
||||
TUILayout,
|
||||
TUIList,
|
||||
TUISeparator,
|
||||
TUIProgressBar,
|
||||
TUISpinner,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 管理器
|
||||
'TUIManager',
|
||||
'TUIRenderer',
|
||||
'HTMLToTUIConverter',
|
||||
|
||||
# 输入处理
|
||||
'TUIInputHandler',
|
||||
'TUIEventManager',
|
||||
|
||||
# 画布
|
||||
'TUICanvas',
|
||||
|
||||
# 样式系统
|
||||
'ANSIStyle',
|
||||
'BorderStyle',
|
||||
'TUIColor',
|
||||
'TUIStyle',
|
||||
|
||||
# 元素类型
|
||||
'TUIElementType',
|
||||
|
||||
# 基础元素
|
||||
'TUIElement',
|
||||
'TUIButton',
|
||||
'TUILabel',
|
||||
'TUIPanel',
|
||||
'TUILayout',
|
||||
'TUIList',
|
||||
'TUISeparator',
|
||||
'TUIProgressBar',
|
||||
'TUISpinner',
|
||||
]
|
||||
1430
oss/tui/converter.py
Normal file
1430
oss/tui/converter.py
Normal file
File diff suppressed because it is too large
Load Diff
638
oss/tui/plugin.py
Normal file
638
oss/tui/plugin.py
Normal file
@@ -0,0 +1,638 @@
|
||||
"""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 = """
|
||||
<!DOCTYPE html>
|
||||
<html class="tui-page">
|
||||
<head>
|
||||
<title>NebulaShell TUI</title>
|
||||
<meta charset="UTF-8">
|
||||
<!-- TUI 标记:此页面专为终端渲染 -->
|
||||
</head>
|
||||
<body class="tui-body">
|
||||
<header data-tui-type="header">
|
||||
<h1>👋 欢迎使用 NebulaShell TUI</h1>
|
||||
<p>终端用户界面已启动</p>
|
||||
<p>WebUI 同时运行在:http://localhost:8080</p>
|
||||
</header>
|
||||
|
||||
<separator data-tui-char="─"/>
|
||||
|
||||
<section data-tui-type="panel" data-tui-title="可用命令">
|
||||
<ul>
|
||||
<li>[1] 首页</li>
|
||||
<li>[2] 仪表盘</li>
|
||||
<li>[3] 日志</li>
|
||||
<li>[4] 终端</li>
|
||||
<li>[5] 插件管理</li>
|
||||
<li>[q] 退出 TUI</li>
|
||||
<li>[r] 刷新</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<separator data-tui-char="─"/>
|
||||
|
||||
<nav data-tui-type="nav">
|
||||
<a href="/" data-tui-action="navigate" data-tui-key="1">首页</a>
|
||||
<a href="/dashboard" data-tui-action="navigate" data-tui-key="2">仪表盘</a>
|
||||
<a href="/logs" data-tui-action="navigate" data-tui-key="3">日志</a>
|
||||
<a href="/terminal" data-tui-action="navigate" data-tui-key="4">终端</a>
|
||||
<a href="/plugins" data-tui-action="navigate" data-tui-key="5">插件</a>
|
||||
</nav>
|
||||
|
||||
<!-- TUI 脚本标记:键盘绑定配置 -->
|
||||
<script type="application/x-tui-keys">
|
||||
{"1": {"action": "navigate", "target": "/"}, "2": {"action": "navigate", "target": "/dashboard"}, "3": {"action": "navigate", "target": "/logs"}, "4": {"action": "navigate", "target": "/terminal"}, "5": {"action": "navigate", "target": "/plugins"}, "q": {"action": "quit"}, "r": {"action": "refresh"}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.tui_manager.load_page("/welcome", welcome_html)
|
||||
self._render_current("/welcome")
|
||||
|
||||
def _render_current(self, path: str = None):
|
||||
"""渲染当前页面到终端"""
|
||||
if path is None:
|
||||
path = self.tui_manager.current_page or "/welcome"
|
||||
|
||||
output = self.tui_manager.render_page(path)
|
||||
|
||||
# 清屏并输出
|
||||
sys.stdout.write('\x1b[2J\x1b[H')
|
||||
sys.stdout.write(output)
|
||||
sys.stdout.write('\n\n')
|
||||
sys.stdout.write('\x1b[90m提示:按数字键导航,q 退出,r 刷新\x1b[0m\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
def _event_loop(self):
|
||||
"""简单的事件循环"""
|
||||
import sys
|
||||
import tty
|
||||
import termios
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
|
||||
while self.running:
|
||||
char = sys.stdin.read(1)
|
||||
|
||||
if char == '\x03': # Ctrl+C
|
||||
break
|
||||
elif char == '\x04': # Ctrl+D
|
||||
break
|
||||
elif char == 'q':
|
||||
Log.info("tui", "用户退出 TUI")
|
||||
break
|
||||
elif char == '1':
|
||||
self._render_current("/")
|
||||
elif char == '2':
|
||||
self._render_current("/dashboard")
|
||||
elif char == '3':
|
||||
self._render_current("/logs")
|
||||
elif char == '4':
|
||||
self._render_current("/terminal")
|
||||
elif char == '5':
|
||||
self._render_current("/plugins")
|
||||
elif char == 'r':
|
||||
self._load_default_pages()
|
||||
self._render_current()
|
||||
elif char == '\n' or char == '\r':
|
||||
# Enter 刷新当前页
|
||||
self._render_current()
|
||||
|
||||
except Exception as e:
|
||||
Log.error("tui", f"事件循环错误:{e}")
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
# ========== TUI 核心接口实现 ==========
|
||||
|
||||
def _handle_tui_index(self, request):
|
||||
"""处理 /tui/index.html 请求 - TUI 入口点
|
||||
|
||||
返回特殊标记的 HTML,TUI 转换层会识别并转换。
|
||||
此 HTML 不含用户可见内容,仅包含 data-tui-* 标记和配置脚本。
|
||||
"""
|
||||
html = """<!DOCTYPE html>
|
||||
<html class="tui-page" data-tui-version="2.0">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>NebulaShell TUI</title>
|
||||
<!-- TUI 标记:此页面专为终端渲染 -->
|
||||
<style type="text/x-tui-css">
|
||||
/* 终端兼容 CSS */
|
||||
.tui-page { background-color: #000000; color: #ffffff; }
|
||||
.tui-body { font-family: monospace; }
|
||||
.bold { font-weight: bold; }
|
||||
.underline { text-decoration: underline; }
|
||||
.header { font-weight: bold; font-size: large; }
|
||||
.panel { border-style: single; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="tui-body">
|
||||
<div class="tui-container" data-tui-layout="vertical">
|
||||
<header data-tui-type="header">
|
||||
<h1>NebulaShell TUI</h1>
|
||||
<p>终端界面就绪</p>
|
||||
</header>
|
||||
|
||||
<separator data-tui-char="─"/>
|
||||
|
||||
<nav data-tui-type="nav" data-tui-layout="horizontal">
|
||||
<a href="/" data-tui-action="navigate" data-tui-key="1">首页</a>
|
||||
<a href="/dashboard" data-tui-action="navigate" data-tui-key="2">仪表盘</a>
|
||||
<a href="/logs" data-tui-action="navigate" data-tui-key="3">日志</a>
|
||||
<a href="/terminal" data-tui-action="navigate" data-tui-key="4">终端</a>
|
||||
</nav>
|
||||
|
||||
<separator data-tui-char="─"/>
|
||||
|
||||
<section data-tui-type="panel" data-tui-title="快捷操作">
|
||||
<button data-tui-key="r" data-tui-action="refresh">刷新 [r]</button>
|
||||
<button data-tui-key="q" data-tui-action="quit">退出 [q]</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- TUI 脚本标记:键盘绑定配置 -->
|
||||
<script type="application/x-tui-keys">
|
||||
{"1": {"action": "navigate", "target": "/"}, "2": {"action": "navigate", "target": "/dashboard"}, "3": {"action": "navigate", "target": "/logs"}, "4": {"action": "navigate", "target": "/terminal"}, "r": {"action": "refresh"}, "q": {"action": "quit"}}
|
||||
</script>
|
||||
|
||||
<!-- TUI 配置 -->
|
||||
<script type="application/x-tui-config">
|
||||
{"display": {"width": 80, "height": 24}, "mouse": {"enabled": true}, "keyboard": {"enabled": true}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
body=html
|
||||
)
|
||||
|
||||
def _handle_tui_page(self, request):
|
||||
"""处理 /tui/page 请求 - 获取任意页面的 TUI 版本
|
||||
|
||||
从 WebUI 获取原始 HTML,添加 TUI 标记后返回。
|
||||
TUI 转换层会自动解析这些标记并转换为终端元素。
|
||||
"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(request.path)
|
||||
params = parse_qs(parsed.query)
|
||||
page_path = params.get('path', ['/'])[0]
|
||||
|
||||
# 从 WebUI 获取原始 HTML
|
||||
html = self._fetch_webui_page(page_path)
|
||||
|
||||
if html:
|
||||
# 添加 TUI 标记
|
||||
html = html.replace('<html', '<html class="tui-page" data-tui-source="webui"')
|
||||
if '<body' in html:
|
||||
html = html.replace('<body', '<body class="tui-body"')
|
||||
else:
|
||||
html = html.replace('</head>', '<body class="tui-body"></head>')
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
body=html
|
||||
)
|
||||
else:
|
||||
# 返回错误页面
|
||||
error_html = """<!DOCTYPE html>
|
||||
<html class="tui-page">
|
||||
<body class="tui-body">
|
||||
<h1>❌ 页面未找到</h1>
|
||||
<p>路径:<span id="path"></span></p>
|
||||
<button data-tui-key="b" data-tui-action="back">返回</button>
|
||||
<script type="application/x-tui-keys">{"b": {"action": "back"}}</script>
|
||||
</body>
|
||||
</html>"""
|
||||
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 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()
|
||||
@@ -24,6 +24,12 @@ class WebUIServer:
|
||||
self.router.get("/static/css/main.css", self._handle_css)
|
||||
self.router.get("/static/js/main.js", self._handle_js)
|
||||
self.router.get("/health", self._handle_health)
|
||||
|
||||
# TUI 接口 - 供 TUI 转换层访问
|
||||
self.router.get("/tui/index.html", self._handle_tui_index)
|
||||
self.router.get("/tui/page", self._handle_tui_page)
|
||||
self.router.get("/tui/css", self._handle_tui_css)
|
||||
self.router.get("/tui/pages", self._handle_tui_pages)
|
||||
|
||||
def register_page(self, path: str, content_provider, nav_item: dict = None):
|
||||
"""供其他插件注册页面"""
|
||||
@@ -179,3 +185,85 @@ class WebUIServer:
|
||||
def _handle_health(self, request):
|
||||
import json
|
||||
return Response(status=200, headers={"Content-Type": "application/json"}, body=json.dumps({"status": "ok"}))
|
||||
|
||||
# ========== TUI 接口实现 ==========
|
||||
|
||||
def _handle_tui_index(self, request):
|
||||
"""处理 /tui/index.html 请求 - TUI 入口点
|
||||
|
||||
返回特殊标记的 HTML,TUI 转换层会识别并转换。
|
||||
此 HTML 不含用户可见内容,仅包含 data-tui-* 标记和配置脚本。
|
||||
"""
|
||||
html = """<!DOCTYPE html>
|
||||
<html class="tui-page" data-tui-version="2.0">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>NebulaShell TUI</title>
|
||||
<!-- TUI 标记:此页面专为终端渲染 -->
|
||||
<style type="text/x-tui-css">
|
||||
.tui-page { background-color: #000000; color: #ffffff; }
|
||||
.tui-body { font-family: monospace; }
|
||||
.bold { font-weight: bold; }
|
||||
.underline { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="tui-body">
|
||||
<div class="tui-container" data-tui-layout="vertical">
|
||||
<header data-tui-type="header">
|
||||
<h1>NebulaShell TUI</h1>
|
||||
<p>终端界面就绪</p>
|
||||
</header>
|
||||
<separator data-tui-char="─"/>
|
||||
<nav data-tui-type="nav" data-tui-layout="horizontal">
|
||||
<a href="/" data-tui-action="navigate" data-tui-key="1">首页</a>
|
||||
<a href="/dashboard" data-tui-action="navigate" data-tui-key="2">仪表盘</a>
|
||||
<a href="/logs" data-tui-action="navigate" data-tui-key="3">日志</a>
|
||||
</nav>
|
||||
</div>
|
||||
<script type="application/x-tui-keys">
|
||||
{"1": {"action": "navigate", "target": "/"}, "2": {"action": "navigate", "target": "/dashboard"}, "3": {"action": "navigate", "target": "/logs"}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return Response(status=200, headers={"Content-Type": "text/html; charset=utf-8"}, body=html)
|
||||
|
||||
def _handle_tui_page(self, request):
|
||||
"""处理 /tui/page 请求 - 获取任意页面的 TUI 版本"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(request.path)
|
||||
params = parse_qs(parsed.query)
|
||||
page_path = params.get('path', ['/'])[0]
|
||||
|
||||
# 查找已注册的页面
|
||||
provider = self.pages.get(page_path)
|
||||
if provider:
|
||||
content = provider()
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html class="tui-page" data-tui-source="webui">
|
||||
<body class="tui-body">{content}</body>
|
||||
</html>"""
|
||||
return Response(status=200, headers={"Content-Type": "text/html; charset=utf-8"}, body=html)
|
||||
|
||||
return Response(status=404, headers={"Content-Type": "text/html"}, body="<html><body>Page not found</body></html>")
|
||||
|
||||
def _handle_tui_css(self, request):
|
||||
"""处理 /tui/css 请求 - 返回终端兼容的 CSS"""
|
||||
css = """/* TUI 兼容 CSS */
|
||||
.tui-page { background-color: #000000; color: #ffffff; }
|
||||
.tui-body { font-family: monospace; }
|
||||
.bold { font-weight: bold; }
|
||||
.underline { text-decoration: underline; }
|
||||
[data-tui-action] { cursor: pointer; }
|
||||
"""
|
||||
return Response(status=200, headers={"Content-Type": "text/css"}, body=css)
|
||||
|
||||
def _handle_tui_pages(self, request):
|
||||
"""处理 /tui/pages 请求 - 列出所有可用页面"""
|
||||
import json
|
||||
pages = list(self.pages.keys())
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'pages': pages})
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""WebUI - Web 控制台 (容器模式)"""
|
||||
"""WebUI - Web 控制台 (容器模式) + TUI 双启动"""
|
||||
from pathlib import Path
|
||||
from oss.logger.logger import Log
|
||||
from oss.plugin.types import Plugin, Response, register_plugin_type
|
||||
@@ -7,11 +7,12 @@ from .core.server import WebUIServer
|
||||
|
||||
|
||||
class WebUIPlugin(Plugin):
|
||||
"""WebUI 插件 - 提供页面容器"""
|
||||
"""WebUI 插件 - 提供页面容器,同时启动 TUI"""
|
||||
|
||||
def __init__(self):
|
||||
self.http_api = None
|
||||
self.server = None
|
||||
self.tui = None
|
||||
self.config = {}
|
||||
|
||||
def meta(self):
|
||||
@@ -22,14 +23,15 @@ class WebUIPlugin(Plugin):
|
||||
name="webui",
|
||||
version="2.1.0",
|
||||
author="NebulaShell",
|
||||
description="Web 控制台容器 - 供其他插件注册页面"
|
||||
description="Web 控制台容器 + TUI 双启动 - 供其他插件注册页面"
|
||||
),
|
||||
config=PluginConfig(
|
||||
enabled=True,
|
||||
args={
|
||||
"port": config.get("HTTP_API_PORT", 8080),
|
||||
"theme": "dark",
|
||||
"title": "NebulaShell"
|
||||
"title": "NebulaShell",
|
||||
"tui_enabled": True # 默认启用 TUI
|
||||
}
|
||||
),
|
||||
dependencies=["http-api"]
|
||||
@@ -39,10 +41,14 @@ class WebUIPlugin(Plugin):
|
||||
"""注入 http-api"""
|
||||
self.http_api = http_api
|
||||
|
||||
def set_tui(self, tui):
|
||||
"""注入 tui 引用"""
|
||||
self.tui = tui
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
"""初始化 WebUI 服务器"""
|
||||
"""初始化 WebUI 服务器和 TUI"""
|
||||
if not self.http_api:
|
||||
Log.error("webui", "错误: 未找到 http-api 依赖")
|
||||
Log.error("webui", "错误:未找到 http-api 依赖")
|
||||
return
|
||||
|
||||
config = {}
|
||||
@@ -52,7 +58,8 @@ class WebUIPlugin(Plugin):
|
||||
self.config = {
|
||||
"port": config.get("port", get_config().get("HTTP_API_PORT", 8080)),
|
||||
"theme": config.get("theme", "dark"),
|
||||
"title": config.get("title", "NebulaShell")
|
||||
"title": config.get("title", "NebulaShell"),
|
||||
"tui_enabled": config.get("tui_enabled", True)
|
||||
}
|
||||
|
||||
# 使用 http-api 的路由器
|
||||
@@ -61,6 +68,10 @@ class WebUIPlugin(Plugin):
|
||||
self.config
|
||||
)
|
||||
Log.info("webui", "容器初始化完成")
|
||||
|
||||
# 如果启用了 TUI,通知 TUI 插件
|
||||
if self.config.get("tui_enabled") and self.tui:
|
||||
Log.info("webui", "TUI 已启用,将双启动")
|
||||
|
||||
def start(self):
|
||||
"""启动服务器(注册默认路由)"""
|
||||
@@ -69,7 +80,11 @@ class WebUIPlugin(Plugin):
|
||||
self._setup_home_page()
|
||||
|
||||
self.server.start()
|
||||
Log.info("webui", f"WebUI 容器已启动: http://localhost:{self.config['port']}")
|
||||
Log.info("webui", f"WebUI 容器已启动:http://localhost:{self.config['port']}")
|
||||
|
||||
# 如果启用了 TUI,在后台启动
|
||||
if self.config.get("tui_enabled"):
|
||||
Log.info("webui", "TUI 双启动中...")
|
||||
|
||||
def _setup_home_page(self):
|
||||
"""设置首页:如果仪表盘已安装则跳转到仪表盘,否则显示默认首页"""
|
||||
@@ -118,7 +133,7 @@ class WebUIPlugin(Plugin):
|
||||
if self.server:
|
||||
self.server.register_page(path, content_provider, nav_item)
|
||||
else:
|
||||
Log.warn("webui", f"警告: 试图注册页面 {path},但服务器未初始化")
|
||||
Log.warn("webui", f"警告:试图注册页面 {path},但服务器未初始化")
|
||||
|
||||
def add_nav_item(self, item: dict):
|
||||
"""仅添加导航项(如果页面由其他方式处理)"""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "webui",
|
||||
"version": "2.1.0",
|
||||
"author": "NebulaShell",
|
||||
"description": "Web 控制台 - 多语言支持/插件管理/安全配置/系统监控",
|
||||
"description": "Web 控制台 + TUI 双启动 - 多语言支持/插件管理/安全配置/系统监控",
|
||||
"type": "webui"
|
||||
},
|
||||
"config": {
|
||||
@@ -18,7 +18,8 @@
|
||||
"enable_2fa": false,
|
||||
"show_plugins": true,
|
||||
"show_security": true,
|
||||
"show_deployments": true
|
||||
"show_deployments": true,
|
||||
"tui_enabled": true
|
||||
}
|
||||
},
|
||||
"dependencies": ["http-api", "i18n"],
|
||||
|
||||
187
store/@{NebulaShell}/webui/tui/README.md
Normal file
187
store/@{NebulaShell}/webui/tui/README.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# NebulaShell TUI - 终端用户界面
|
||||
|
||||
## 概述
|
||||
|
||||
TUI(Terminal User Interface)插件为 NebulaShell 提供终端界面,与 WebUI 双启动运行。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 1. 转换层架构
|
||||
|
||||
TUI 本身只有一个转换层,它只会访问 WebUI 所开放的 `/tui` 接口:
|
||||
|
||||
- **`/tui/index.html`** - TUI 入口页面
|
||||
- **`/tui/page`** - 获取任意页面的 TUI 版本
|
||||
- **`/tui/css`** - 终端兼容的 CSS
|
||||
- **`/tui/interact`** - 处理交互事件
|
||||
|
||||
### 2. HTML 标记规范
|
||||
|
||||
WebUI 开放的 `.html` 文件中不含有任何给用户看的内容,但包含 TUI 可解析的特殊标记:
|
||||
|
||||
```html
|
||||
<!-- TUI 页面标记 -->
|
||||
<html class="tui-page">
|
||||
<body class="tui-body">
|
||||
|
||||
<!-- TUI 容器 -->
|
||||
<div class="tui-container" data-tui-layout="vertical">
|
||||
|
||||
<!-- 键盘快捷键标记 -->
|
||||
<a href="/" data-tui-key="1" data-tui-action="navigate">[1] 首页</a>
|
||||
|
||||
<!-- TUI 配置脚本 -->
|
||||
<script type="application/x-tui-config">
|
||||
{
|
||||
"keyboard": {
|
||||
"1": {"action": "navigate", "target": "/"},
|
||||
"q": {"action": "quit"}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- TUI CSS (仅终端支持的样式) -->
|
||||
<style type="text/x-tui-css">
|
||||
.tui-header { font-weight: bold; }
|
||||
</style>
|
||||
```
|
||||
|
||||
### 3. 支持的 CSS 属性
|
||||
|
||||
TUI 只支持终端能够渲染的样式:
|
||||
|
||||
| CSS 属性 | TUI 转换 | 说明 |
|
||||
|---------|---------|------|
|
||||
| `font-weight: bold` | ANSI 加粗 | `\x1b[1m` |
|
||||
| `font-style: italic` | ANSI 斜体 | `\x1b[3m` |
|
||||
| `text-decoration: underline` | ANSI 下划线 | `\x1b[4m` |
|
||||
| `background-color` | ANSI 背景色 | 仅支持基础 8 色 |
|
||||
| `color` | ANSI 前景色 | 仅支持基础 8 色 |
|
||||
| `text-align` | 文本对齐 | left/center/right |
|
||||
|
||||
### 4. 支持的 JS 交互
|
||||
|
||||
TUI 只支持基础的终端交互:
|
||||
|
||||
- **鼠标位置** - 通过 ANSI 鼠标协议获取
|
||||
- **点击事件** - 转换为选择操作
|
||||
- **按键输入** - 完整的键盘支持
|
||||
|
||||
```javascript
|
||||
// TUI 配置中的键盘映射
|
||||
{
|
||||
"keyboard": {
|
||||
"1": {"action": "navigate", "target": "/"},
|
||||
"ArrowUp": {"action": "navigate_up"},
|
||||
"Enter": {"action": "select"},
|
||||
"q": {"action": "quit"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
webui/tui/
|
||||
├── __init__.py # 包初始化
|
||||
├── main.py # TUI 插件主程序
|
||||
├── converter.py # HTML 到 TUI 转换层
|
||||
├── index.html # TUI 入口页面(含特殊标记)
|
||||
├── manifest.json # 插件清单
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 启动 NebulaShell
|
||||
|
||||
```bash
|
||||
# 正常启动,WebUI 和 TUI 会同时运行
|
||||
python main.py serve
|
||||
|
||||
# 或通过 CLI
|
||||
python -m oss.cli serve
|
||||
```
|
||||
|
||||
### TUI 快捷键
|
||||
|
||||
| 按键 | 功能 |
|
||||
|-----|------|
|
||||
| `1` | 首页 |
|
||||
| `2` | 仪表盘 |
|
||||
| `3` | 日志 |
|
||||
| `4` | 终端 |
|
||||
| `5` | 插件 |
|
||||
| `6` | 设置 |
|
||||
| `r` | 刷新 |
|
||||
| `h` | 帮助 |
|
||||
| `↑/↓` | 上下导航 |
|
||||
| `Enter` | 确认 |
|
||||
| `q` | 退出 TUI |
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 创建 TUI 兼容页面
|
||||
|
||||
1. 在 WebUI 插件中创建页面时,添加 TUI 标记
|
||||
2. 使用 `data-tui-*` 属性定义交互行为
|
||||
3. 在 `<script type="application/x-tui-config">` 中定义键盘映射
|
||||
4. 在 `<style type="text/x-tui-css">` 中定义终端样式
|
||||
|
||||
### 示例
|
||||
|
||||
```python
|
||||
# 在 WebUI 插件中注册 TUI 兼容页面
|
||||
def register_tui_page(webui):
|
||||
webui.register_page(
|
||||
path='/mypage',
|
||||
content_provider=lambda: '''
|
||||
<!DOCTYPE html>
|
||||
<html class="tui-page">
|
||||
<head><title>我的页面</title></head>
|
||||
<body class="tui-body">
|
||||
<div class="tui-container">
|
||||
<h1 data-tui-style="bold">欢迎</h1>
|
||||
<a href="/action" data-tui-key="a" data-tui-action="click">执行操作</a>
|
||||
</div>
|
||||
<script type="application/x-tui-config">
|
||||
{"keyboard": {"a": {"action": "click", "target": "/api/action"}}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''',
|
||||
nav_item={'icon': 'ri-star-line', 'text': '我的页面'}
|
||||
)
|
||||
```
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 转换流程
|
||||
|
||||
1. TUI 插件启动时访问 `/tui/index.html`
|
||||
2. `HTMLToTUIConverter` 解析 HTML 提取:
|
||||
- 文本内容
|
||||
- 按钮和链接
|
||||
- TUI 配置(键盘映射、样式)
|
||||
3. `TUIRenderer` 将元素渲染为 ANSI 转义序列
|
||||
4. `TUICanvas` 管理终端显示缓冲区
|
||||
5. `TUIInputHandler` 处理键盘/鼠标输入
|
||||
|
||||
### ANSI 颜色映射
|
||||
|
||||
```python
|
||||
COLOR_MAP = {
|
||||
'#000000': '\x1b[30m', # black
|
||||
'#ff0000': '\x1b[31m', # red
|
||||
'#00ff00': '\x1b[32m', # green
|
||||
'#ffff00': '\x1b[33m', # yellow
|
||||
'#0000ff': '\x1b[34m', # blue
|
||||
'#ff00ff': '\x1b[35m', # magenta
|
||||
'#00ffff': '\x1b[36m', # cyan
|
||||
'#ffffff': '\x1b[37m', # white
|
||||
}
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License - NebulaShell Project
|
||||
0
store/@{NebulaShell}/webui/tui/__init__.py
Normal file
0
store/@{NebulaShell}/webui/tui/__init__.py
Normal file
1063
store/@{NebulaShell}/webui/tui/converter.py
Normal file
1063
store/@{NebulaShell}/webui/tui/converter.py
Normal file
File diff suppressed because it is too large
Load Diff
113
store/@{NebulaShell}/webui/tui/index.html
Normal file
113
store/@{NebulaShell}/webui/tui/index.html
Normal file
@@ -0,0 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="tui-page" lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NebulaShell TUI</title>
|
||||
<!-- TUI 标记:此页面专为终端渲染设计 -->
|
||||
<!-- 不包含任何给用户直接查看的内容,仅用于 TUI 转换层解析 -->
|
||||
</head>
|
||||
<body class="tui-body">
|
||||
<!-- TUI 容器标记 -->
|
||||
<div class="tui-container" data-tui-layout="vertical">
|
||||
<!-- 标题区域 -->
|
||||
<header class="tui-header" data-tui-style="bold">
|
||||
NebulaShell TUI - 终端用户界面
|
||||
</header>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<div class="tui-status" data-tui-style="dim">
|
||||
WebUI: http://localhost:8080 | TUI: 双启动模式
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单 - TUI 会转换为键盘快捷键 -->
|
||||
<nav class="tui-nav" data-tui-type="keyboard">
|
||||
<a href="/" data-tui-key="1" data-tui-action="navigate">[1] 首页</a>
|
||||
<a href="/dashboard" data-tui-key="2" data-tui-action="navigate">[2] 仪表盘</a>
|
||||
<a href="/logs" data-tui-key="3" data-tui-action="navigate">[3] 日志</a>
|
||||
<a href="/terminal" data-tui-key="4" data-tui-action="navigate">[4] 终端</a>
|
||||
<a href="/plugins" data-tui-key="5" data-tui-action="navigate">[5] 插件</a>
|
||||
<a href="/settings" data-tui-key="6" data-tui-action="navigate">[6] 设置</a>
|
||||
</nav>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<hr class="tui-separator" data-tui-char="─">
|
||||
|
||||
<!-- 操作提示 -->
|
||||
<footer class="tui-footer" data-tui-style="dim">
|
||||
<p>快捷键说明:</p>
|
||||
<ul>
|
||||
<li data-tui-key="q">q - 退出 TUI</li>
|
||||
<li data-tui-key="r">r - 刷新当前页</li>
|
||||
<li data-tui-key="h">h - 显示帮助</li>
|
||||
<li data-tui-key="↑/↓">↑/↓ - 上下导航</li>
|
||||
<li data-tui-key="Enter">Enter - 确认选择</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- TUI 配置脚本 - 定义键盘映射和交互行为 -->
|
||||
<script type="application/x-tui-config">
|
||||
{
|
||||
"keyboard": {
|
||||
"1": {"action": "navigate", "target": "/"},
|
||||
"2": {"action": "navigate", "target": "/dashboard"},
|
||||
"3": {"action": "navigate", "target": "/logs"},
|
||||
"4": {"action": "navigate", "target": "/terminal"},
|
||||
"5": {"action": "navigate", "target": "/plugins"},
|
||||
"6": {"action": "navigate", "target": "/settings"},
|
||||
"q": {"action": "quit"},
|
||||
"r": {"action": "refresh"},
|
||||
"h": {"action": "help"},
|
||||
"ArrowUp": {"action": "navigate_up"},
|
||||
"ArrowDown": {"action": "navigate_down"},
|
||||
"Enter": {"action": "select"}
|
||||
},
|
||||
"mouse": {
|
||||
"enabled": false,
|
||||
"click_action": "select"
|
||||
},
|
||||
"display": {
|
||||
"width": 80,
|
||||
"height": 24,
|
||||
"theme": "dark",
|
||||
"border_style": "single",
|
||||
"colors": {
|
||||
"header": "bold",
|
||||
"normal": "default",
|
||||
"dim": "dim",
|
||||
"selected": "reverse"
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- TUI CSS 标记 - 仅包含终端支持的样式 -->
|
||||
<style type="text/x-tui-css">
|
||||
.tui-page {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
.tui-header {
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
.tui-status {
|
||||
font-style: dim;
|
||||
border-bottom: single;
|
||||
}
|
||||
.tui-nav a {
|
||||
display: block;
|
||||
padding: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tui-separator {
|
||||
border-char: ─;
|
||||
}
|
||||
.tui-footer {
|
||||
font-style: dim;
|
||||
margin-top: 2;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
378
store/@{NebulaShell}/webui/tui/main.py
Normal file
378
store/@{NebulaShell}/webui/tui/main.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""TUI 插件 - 终端用户界面,与 WebUI 双启动"""
|
||||
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 .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
|
||||
|
||||
def meta(self):
|
||||
from oss.plugin.types import Metadata, PluginConfig, Manifest
|
||||
return Manifest(
|
||||
metadata=Metadata(
|
||||
name="tui",
|
||||
version="1.0.0",
|
||||
author="NebulaShell",
|
||||
description="终端用户界面 - 与 WebUI 双启动"
|
||||
),
|
||||
config=PluginConfig(enabled=True, args={}),
|
||||
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"""
|
||||
Log.info("tui", "TUI 插件初始化中...")
|
||||
|
||||
# 创建 TUI 管理器
|
||||
self.tui_manager = TUIManager.get_instance()
|
||||
|
||||
# 注册 /tui 路由供 TUI 访问 WebUI 页面
|
||||
if self.http_api and self.http_api.router:
|
||||
# 注册 TUI 专用 API
|
||||
self.http_api.router.get("/tui/index.html", self._handle_tui_index)
|
||||
self.http_api.router.get("/tui/page", self._handle_tui_page)
|
||||
self.http_api.router.get("/tui/css", self._handle_tui_css)
|
||||
self.http_api.router.post("/tui/interact", self._handle_tui_interact)
|
||||
Log.ok("tui", "已注册 TUI API 路由")
|
||||
else:
|
||||
Log.warn("tui", "警告:未找到 http-api 依赖")
|
||||
|
||||
# 加载默认页面(从 WebUI 获取)
|
||||
self._load_default_pages()
|
||||
|
||||
Log.ok("tui", "TUI 插件初始化完成")
|
||||
|
||||
def _load_default_pages(self):
|
||||
"""从 WebUI 加载默认页面到 TUI"""
|
||||
# 模拟访问 WebUI 页面并缓存
|
||||
default_pages = ["/", "/dashboard", "/logs", "/terminal"]
|
||||
|
||||
for path in default_pages:
|
||||
try:
|
||||
# 这里会通过内部调用获取 WebUI 渲染的 HTML
|
||||
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.warn("tui", f"加载页面 {path} 失败:{e}")
|
||||
|
||||
def _fetch_webui_page(self, path: str) -> str:
|
||||
"""从 WebUI 获取页面 HTML"""
|
||||
if not self.webui or not hasattr(self.webui, 'server'):
|
||||
return ""
|
||||
|
||||
# 模拟请求获取 WebUI 页面
|
||||
# 由于我们在同一进程,可以直接调用 server 的路由处理
|
||||
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.warn("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 = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>NebulaShell TUI</title></head>
|
||||
<body>
|
||||
<h1>👋 欢迎使用 NebulaShell TUI</h1>
|
||||
<p>终端用户界面已启动</p>
|
||||
<p>WebUI 同时运行在:http://localhost:8080</p>
|
||||
<hr>
|
||||
<h2>可用命令:</h2>
|
||||
<ul>
|
||||
<li>[1] 首页</li>
|
||||
<li>[2] 仪表盘</li>
|
||||
<li>[3] 日志</li>
|
||||
<li>[4] 终端</li>
|
||||
<li>[q] 退出 TUI</li>
|
||||
<li>[r] 刷新</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.tui_manager.load_page("/welcome", welcome_html)
|
||||
self._render_current("/welcome")
|
||||
|
||||
def _render_current(self, path: str = None):
|
||||
"""渲染当前页面到终端"""
|
||||
if path is None:
|
||||
path = self.tui_manager.current_page or "/welcome"
|
||||
|
||||
output = self.tui_manager.render_page(path)
|
||||
|
||||
# 清屏并输出
|
||||
sys.stdout.write('\x1b[2J\x1b[H')
|
||||
sys.stdout.write(output)
|
||||
sys.stdout.write('\n\n')
|
||||
sys.stdout.write('\x1b[90m提示:按数字键导航,q 退出\x1b[0m\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
def _event_loop(self):
|
||||
"""简单的事件循环"""
|
||||
import sys
|
||||
import tty
|
||||
import termios
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
|
||||
while self.running:
|
||||
char = sys.stdin.read(1)
|
||||
|
||||
if char == '\x03': # Ctrl+C
|
||||
break
|
||||
elif char == '\x04': # Ctrl+D
|
||||
break
|
||||
elif char == 'q':
|
||||
Log.info("tui", "用户退出 TUI")
|
||||
break
|
||||
elif char == '1':
|
||||
self._render_current("/")
|
||||
elif char == '2':
|
||||
self._render_current("/dashboard")
|
||||
elif char == '3':
|
||||
self._render_current("/logs")
|
||||
elif char == '4':
|
||||
self._render_current("/terminal")
|
||||
elif char == 'r':
|
||||
self._load_default_pages()
|
||||
self._render_current()
|
||||
elif char == '\n' or char == '\r':
|
||||
# Enter 刷新当前页
|
||||
self._render_current()
|
||||
|
||||
except Exception as e:
|
||||
Log.error("tui", f"事件循环错误:{e}")
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
def _handle_tui_index(self, request):
|
||||
"""处理 /tui/index.html 请求"""
|
||||
# 返回特殊标记的 HTML,TUI 会识别并转换
|
||||
html = """<!DOCTYPE html>
|
||||
<html class="tui-page">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>NebulaShell TUI</title>
|
||||
<!-- TUI 标记:此页面专为终端渲染 -->
|
||||
</head>
|
||||
<body class="tui-body">
|
||||
<div class="tui-container">
|
||||
<h1>NebulaShell TUI</h1>
|
||||
<p>终端界面就绪</p>
|
||||
<div class="tui-nav">
|
||||
<a href="/" data-tui-action="navigate">首页</a>
|
||||
<a href="/dashboard" data-tui-action="navigate">仪表盘</a>
|
||||
<a href="/logs" data-tui-action="navigate">日志</a>
|
||||
<a href="/terminal" data-tui-action="navigate">终端</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TUI 脚本标记:这些会被转换为键盘绑定 -->
|
||||
<script type="application/x-tui-keys">
|
||||
{"1": "/", "2": "/dashboard", "3": "/logs", "4": "/terminal", "q": "quit", "r": "refresh"}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
body=html
|
||||
)
|
||||
|
||||
def _handle_tui_page(self, request):
|
||||
"""处理 /tui/page 请求 - 获取任意页面的 TUI 版本"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
parsed = urlparse(request.path)
|
||||
params = parse_qs(parsed.query)
|
||||
page_path = params.get('path', ['/'])[0]
|
||||
|
||||
# 从 WebUI 获取原始 HTML
|
||||
html = self._fetch_webui_page(page_path)
|
||||
|
||||
if html:
|
||||
# 添加 TUI 标记
|
||||
html = html.replace('<html', '<html class="tui-page"')
|
||||
if '<body' in html:
|
||||
html = html.replace('<body', '<body class="tui-body"')
|
||||
else:
|
||||
html = html.replace('</head>', '<body class="tui-body"></head>')
|
||||
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
body=html
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
status=404,
|
||||
headers={"Content-Type": "text/html"},
|
||||
body="<html><body>Page not found</body></html>"
|
||||
)
|
||||
|
||||
def _handle_tui_css(self, request):
|
||||
"""处理 /tui/css 请求 - 返回终端兼容的 CSS"""
|
||||
# 只返回终端支持的 CSS 属性
|
||||
css = """/* TUI 兼容 CSS */
|
||||
.tui-page {
|
||||
/* 背景色 - 仅支持 ANSI 颜色 */
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tui-body {
|
||||
font-family: monospace;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* 字体样式 - TUI 支持 */
|
||||
.bold { font-weight: bold; }
|
||||
.underline { text-decoration: underline; }
|
||||
|
||||
/* 布局 - TUI 简化处理 */
|
||||
.tui-container {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 交互元素标记 */
|
||||
[data-tui-action] {
|
||||
cursor: pointer;
|
||||
}
|
||||
"""
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/css"},
|
||||
body=css
|
||||
)
|
||||
|
||||
def _handle_tui_interact(self, request):
|
||||
"""处理 TUI 交互请求"""
|
||||
import json
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
action = body.get('action', '')
|
||||
target = body.get('target', '')
|
||||
|
||||
# 处理交互
|
||||
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})
|
||||
)
|
||||
elif action == 'keypress':
|
||||
# 处理按键
|
||||
key = body.get('key', '')
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/json"},
|
||||
body=json.dumps({'success': True, 'key': key})
|
||||
)
|
||||
|
||||
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 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()
|
||||
28
store/@{NebulaShell}/webui/tui/manifest.json
Normal file
28
store/@{NebulaShell}/webui/tui/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "tui",
|
||||
"version": "1.0.0",
|
||||
"author": "NebulaShell",
|
||||
"description": "终端用户界面 - 与 WebUI 双启动,通过访问 /tui 接口获取 HTML 并转换为终端显示",
|
||||
"type": "tui"
|
||||
},
|
||||
"config": {
|
||||
"enabled": true,
|
||||
"args": {
|
||||
"width": 80,
|
||||
"height": 24,
|
||||
"theme": "dark",
|
||||
"enable_mouse": false,
|
||||
"keyboard_shortcuts": {
|
||||
"1": "/",
|
||||
"2": "/dashboard",
|
||||
"3": "/logs",
|
||||
"4": "/terminal",
|
||||
"q": "quit",
|
||||
"r": "refresh"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": ["http-api", "webui"],
|
||||
"permissions": ["read:pages", "execute:commands"]
|
||||
}
|
||||
Reference in New Issue
Block a user