修复了一些错误 更新了AI.md(给ai看的)
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -18,7 +18,7 @@ class Config:
|
||||
# 服务器配置
|
||||
"HTTP_API_PORT": 8080,
|
||||
"HTTP_TCP_PORT": 8082,
|
||||
"HOST": "0.0.0.0",
|
||||
"HOST": "127.0.0.1",
|
||||
|
||||
# 数据目录
|
||||
"DATA_DIR": "./data",
|
||||
@@ -39,6 +39,12 @@ class Config:
|
||||
# 安全配置
|
||||
"PERMISSION_CHECK": True,
|
||||
"ENFORCE_SIGNATURE": True,
|
||||
"CORS_ALLOWED_ORIGINS": ["http://localhost:3000", "http://127.0.0.1:3000"], # 允许的CORS来源
|
||||
"CSRF_ENABLED": True, # 启用CSRF防护
|
||||
"INPUT_VALIDATION_ENABLED": True, # 启用输入验证
|
||||
"RATE_LIMIT_ENABLED": True, # 启用限流
|
||||
"RATE_LIMIT_MAX_REQUESTS": 100, # 最大请求数
|
||||
"RATE_LIMIT_TIME_WINDOW": 60, # 时间窗口(秒)
|
||||
|
||||
# 性能配置
|
||||
"MAX_WORKERS": 4,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""核心模块"""
|
||||
from .context import Context
|
||||
|
||||
# 配置验证器(内部使用)
|
||||
# 注意:该模块包含系统完整性检查功能
|
||||
try:
|
||||
from .achievements import get_validator, init_achievements
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__all__ = ["Context"]
|
||||
|
||||
@@ -1,54 +1,17 @@
|
||||
"""Context class for plugin execution environment."""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class Context:
|
||||
"""Execution context for plugins.
|
||||
|
||||
Provides access to configuration, state, and utilities during plugin execution.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
"""Initialize the context.
|
||||
|
||||
Args:
|
||||
config: Optional configuration dictionary.
|
||||
"""
|
||||
self.config = config or {}
|
||||
self._state: Dict[str, Any] = {}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a configuration value.
|
||||
|
||||
Args:
|
||||
key: Configuration key.
|
||||
default: Default value if key not found.
|
||||
|
||||
Returns:
|
||||
The configuration value or default.
|
||||
"""
|
||||
return self.config.get(key, default)
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a state value.
|
||||
|
||||
Args:
|
||||
key: State key.
|
||||
value: State value.
|
||||
"""
|
||||
self._state[key] = value
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a state value.
|
||||
|
||||
Args:
|
||||
key: State key.
|
||||
default: Default value if key not found.
|
||||
|
||||
Returns:
|
||||
The state value or default.
|
||||
"""
|
||||
return self._state.get(key, default)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +0,0 @@
|
||||
"""Base plugin module for backward compatibility."""
|
||||
|
||||
from oss.plugin.types import Plugin
|
||||
|
||||
# Alias for backward compatibility
|
||||
BasePlugin = Plugin
|
||||
|
||||
__all__ = ['BasePlugin']
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
"""能力扫描器 - 自动扫描插件支持的能力"""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def scan_capabilities(plugin_dir: Path) -> Any:
|
||||
"""扫描插件目录,自动发现支持的能力"""
|
||||
capabilities: set[str] = set()
|
||||
main_file = plugin_dir / "main.py"
|
||||
|
||||
@@ -17,16 +9,10 @@ def scan_capabilities(plugin_dir: Path) -> Any:
|
||||
|
||||
tree = ast.parse(source)
|
||||
|
||||
# 扫描规则:
|
||||
# 1. 检查是否导出了特定的类或函数
|
||||
# 2. 检查是否有特定的装饰器或标记
|
||||
# 3. 检查 import 语句(表示依赖了某个能力)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
# 检查类定义
|
||||
if isinstance(node, ast.ClassDef):
|
||||
class_name = node.name
|
||||
# 如果类名包含特定后缀,认为是能力提供者
|
||||
if class_name.endswith("Provider"):
|
||||
cap_name = class_name.replace("Provider", "").lower()
|
||||
capabilities.add(cap_name)
|
||||
@@ -37,10 +23,8 @@ def scan_capabilities(plugin_dir: Path) -> Any:
|
||||
cap_name = class_name.replace("Support", "").lower()
|
||||
capabilities.add(cap_name)
|
||||
|
||||
# 检查函数定义
|
||||
elif isinstance(node, ast.FunctionDef):
|
||||
func_name = node.name
|
||||
# 检查是否有能力相关的装饰器
|
||||
for decorator in node.decorator_list:
|
||||
if isinstance(decorator, ast.Name):
|
||||
if decorator.id.startswith("provides_"):
|
||||
@@ -51,7 +35,6 @@ def scan_capabilities(plugin_dir: Path) -> Any:
|
||||
cap_name = decorator.attr.replace("provides_", "")
|
||||
capabilities.add(cap_name)
|
||||
|
||||
# 检查 import 语句(表示使用了某个能力)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if "circuit" in alias.name.lower() or "breaker" in alias.name.lower():
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
"""共享工具模块"""
|
||||
from .router import BaseRoute, BaseRouter, match_path, extract_path_params
|
||||
|
||||
__all__ = ["BaseRoute", "BaseRouter", "match_path", "extract_path_params"]
|
||||
|
||||
@@ -1,36 +1,14 @@
|
||||
"""共享路由工具函数"""
|
||||
from typing import Callable, Optional, Any
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class BaseRoute:
|
||||
"""路由定义基类"""
|
||||
__slots__ = ('method', 'path', 'handler', '_pattern_parts')
|
||||
|
||||
def __init__(self, method: str, path: str, handler: Callable):
|
||||
self.method = method
|
||||
self.path = path
|
||||
self.handler = handler
|
||||
# 预编译路径模式,避免重复解析
|
||||
self._pattern_parts = path.strip("/").split("/") if ":" in path else None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def match_path(pattern: str, path: str) -> bool:
|
||||
"""路径匹配
|
||||
|
||||
支持:
|
||||
- 精确匹配:/api/users == /api/users
|
||||
- 参数匹配:/api/users/:id 匹配 /api/users/123
|
||||
- 通配符匹配:/api/:path 匹配 /api/users/123/profile
|
||||
|
||||
Args:
|
||||
pattern: 路由模式 (如 /api/users/:id)
|
||||
path: 实际请求路径 (如 /api/users/123)
|
||||
|
||||
Returns:
|
||||
是否匹配成功
|
||||
"""
|
||||
if pattern == path:
|
||||
return True
|
||||
|
||||
@@ -40,12 +18,10 @@ def match_path(pattern: str, path: str) -> bool:
|
||||
|
||||
path_parts = path.strip("/").split("/")
|
||||
|
||||
# 检查是否是通配符模式(最后一个参数以 : 开头且是通配符名称)
|
||||
last_pattern = pattern_parts[-1]
|
||||
is_wildcard = _is_wildcard_param(last_pattern)
|
||||
|
||||
if is_wildcard and len(path_parts) >= len(pattern_parts):
|
||||
# 通配符模式:允许更多路径段
|
||||
for i, p in enumerate(pattern_parts[:-1]):
|
||||
if i >= len(path_parts):
|
||||
return False
|
||||
@@ -53,7 +29,6 @@ def match_path(pattern: str, path: str) -> bool:
|
||||
return False
|
||||
return True
|
||||
|
||||
# 普通参数匹配,段数必须相同
|
||||
if len(pattern_parts) != len(path_parts):
|
||||
return False
|
||||
|
||||
@@ -65,17 +40,6 @@ def match_path(pattern: str, path: str) -> bool:
|
||||
|
||||
|
||||
def _is_wildcard_param(param: str) -> bool:
|
||||
"""判断参数是否为通配符(如 :path, :wildcard 等)"""
|
||||
if not param.startswith(":"):
|
||||
return False
|
||||
name = param[1:].lower()
|
||||
# 常见的通配符参数名
|
||||
return name in ("path", "wildcard", "rest", "catch", "all")
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _get_pattern_parts(pattern: str) -> Optional[list]:
|
||||
"""获取并缓存路径模式的分割结果"""
|
||||
if ":" not in pattern:
|
||||
return None
|
||||
return pattern.strip("/").split("/")
|
||||
@@ -83,15 +47,6 @@ def _get_pattern_parts(pattern: str) -> Optional[list]:
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def extract_path_params(pattern: str, path: str) -> dict[str, str]:
|
||||
"""从路径中提取参数
|
||||
|
||||
Args:
|
||||
pattern: 路由模式 (如 /api/users/:id)
|
||||
path: 实际请求路径 (如 /api/users/123)
|
||||
|
||||
Returns:
|
||||
参数字典 (如 {"id": "123"})
|
||||
"""
|
||||
params = {}
|
||||
|
||||
pattern_parts = _get_pattern_parts(pattern)
|
||||
@@ -100,28 +55,21 @@ def extract_path_params(pattern: str, path: str) -> dict[str, str]:
|
||||
|
||||
path_parts = path.strip("/").split("/")
|
||||
|
||||
# 检查是否是通配符模式
|
||||
last_pattern = pattern_parts[-1]
|
||||
is_wildcard = _is_wildcard_param(last_pattern)
|
||||
use_wildcard = is_wildcard and len(path_parts) > len(pattern_parts)
|
||||
|
||||
# 确定要迭代的模式部分数量
|
||||
if use_wildcard:
|
||||
# 通配符模式:只处理前面的固定部分
|
||||
parts_to_process = pattern_parts[:-1]
|
||||
else:
|
||||
# 普通模式:处理所有部分
|
||||
parts_to_process = pattern_parts
|
||||
|
||||
for i, p in enumerate(parts_to_process):
|
||||
if i < len(path_parts) and p.startswith(":"):
|
||||
param_name = p[1:] # 去掉 :
|
||||
params[param_name] = path_parts[i]
|
||||
param_name = p[1:] params[param_name] = path_parts[i]
|
||||
|
||||
# 处理通配符
|
||||
if use_wildcard:
|
||||
param_name = last_pattern[1:]
|
||||
# 将剩余的路径段合并
|
||||
remaining = "/".join(path_parts[len(pattern_parts) - 1:])
|
||||
params[param_name] = remaining
|
||||
|
||||
@@ -129,36 +77,17 @@ def extract_path_params(pattern: str, path: str) -> dict[str, str]:
|
||||
|
||||
|
||||
class BaseRouter:
|
||||
"""路由器基类
|
||||
|
||||
提供通用的路由注册和匹配功能,子类只需实现 handle() 方法
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.routes: list[BaseRoute] = []
|
||||
|
||||
def add(self, method: str, path: str, handler: Callable):
|
||||
"""添加路由"""
|
||||
self.routes.append(BaseRoute(method, path, handler))
|
||||
|
||||
def get(self, path: str, handler: Callable):
|
||||
"""GET 路由"""
|
||||
self.add("GET", path, handler)
|
||||
|
||||
def post(self, path: str, handler: Callable):
|
||||
"""POST 路由"""
|
||||
self.add("POST", path, handler)
|
||||
|
||||
def put(self, path: str, handler: Callable):
|
||||
"""PUT 路由"""
|
||||
self.add("PUT", path, handler)
|
||||
|
||||
def delete(self, path: str, handler: Callable):
|
||||
"""DELETE 路由"""
|
||||
self.add("DELETE", path, handler)
|
||||
|
||||
def find_route(self, method: str, path: str) -> Optional[tuple[BaseRoute, dict[str, str]]]:
|
||||
"""查找匹配的路由和路径参数
|
||||
|
||||
Args:
|
||||
method: HTTP 方法
|
||||
@@ -166,7 +95,6 @@ class BaseRouter:
|
||||
|
||||
Returns:
|
||||
(路由,路径参数) 或 None
|
||||
"""
|
||||
for route in self.routes:
|
||||
if route.method == method and match_path(route.path, path):
|
||||
params = extract_path_params(route.path, path)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"""
|
||||
Node.js Runtime Adapter for NebulaShell
|
||||
=====================================
|
||||
This plugin acts as a pure service provider (Adapter). It does NOT contain its own business logic or pkg.
|
||||
@@ -9,7 +8,6 @@ Usage by other plugins:
|
||||
1. Get this adapter from the shared service registry.
|
||||
2. Call adapter.execute_in_context(plugin_root="./path/to/other-plugin", command="npm start")
|
||||
3. The adapter will automatically switch CWD to "./path/to/other-plugin/pkg" and run the command.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -19,10 +17,8 @@ import shutil
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
class NodeJSAdapter:
|
||||
"""
|
||||
Pure Node.js Runtime Adapter.
|
||||
Provides execution context switching for other plugins.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.name = "nodejs-adapter"
|
||||
@@ -33,21 +29,6 @@ class NodeJSAdapter:
|
||||
self._detect_runtime()
|
||||
|
||||
def _detect_runtime(self):
|
||||
"""Detect global Node.js and npm installation"""
|
||||
try:
|
||||
self.node_path = shutil.which('node')
|
||||
self.npm_path = shutil.which('npm')
|
||||
|
||||
if not self.node_path:
|
||||
print("[WARNING] Node.js not found in global PATH")
|
||||
if not self.npm_path:
|
||||
print("[WARNING] npm not found in global PATH")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to detect Node.js runtime: {type(e).__name__} - {e}")
|
||||
|
||||
def get_capabilities(self) -> Dict[str, Any]:
|
||||
"""Return available capabilities and runtime info"""
|
||||
versions = self.check_versions()
|
||||
return {
|
||||
'available': bool(self.node_path),
|
||||
@@ -57,23 +38,6 @@ class NodeJSAdapter:
|
||||
}
|
||||
|
||||
def check_versions(self) -> Dict[str, str]:
|
||||
"""Check Node.js and npm versions"""
|
||||
result = {}
|
||||
if self.node_path:
|
||||
try:
|
||||
result['node'] = subprocess.check_output([self.node_path, '--version'], stderr=subprocess.STDOUT).decode().strip()
|
||||
except Exception as e:
|
||||
result['node'] = f"Error: {type(e).__name__} - {e}"
|
||||
|
||||
if self.npm_path:
|
||||
try:
|
||||
result['npm'] = subprocess.check_output([self.npm_path, '--version'], stderr=subprocess.STDOUT).decode().strip()
|
||||
except Exception as e:
|
||||
result['npm'] = f"Error: {type(e).__name__} - {e}"
|
||||
return result
|
||||
|
||||
def execute_in_context(self, plugin_root: str, command_args: List[str], is_npm: bool = False) -> Dict[str, Any]:
|
||||
"""
|
||||
CORE METHOD: Execute a command within the context of another plugin.
|
||||
|
||||
Args:
|
||||
@@ -86,28 +50,22 @@ class NodeJSAdapter:
|
||||
2. Sets cwd to that pkg directory.
|
||||
3. Executes the command.
|
||||
4. Ensures dependencies install into that specific pkg folder.
|
||||
"""
|
||||
if not self.node_path:
|
||||
return {'success': False, 'error': 'Node.js runtime not found'}
|
||||
if is_npm and not self.npm_path:
|
||||
return {'success': False, 'error': 'npm not found'}
|
||||
|
||||
# Determine the working directory: plugin_root/pkg
|
||||
work_dir = os.path.join(plugin_root, 'pkg')
|
||||
|
||||
if not os.path.exists(work_dir):
|
||||
return {'success': False, 'error': f'Target pkg directory not found: {work_dir}'}
|
||||
|
||||
try:
|
||||
# Construct command
|
||||
executable = self.npm_path if is_npm else self.node_path
|
||||
cmd = [executable] + command_args
|
||||
|
||||
# Setup environment to ensure isolation
|
||||
env = os.environ.copy()
|
||||
# Force npm to install into the current working dir (the pkg folder)
|
||||
env['npm_config_prefix'] = work_dir
|
||||
# Ensure node can find modules in the pkg folder
|
||||
env['NODE_PATH'] = os.path.join(work_dir, 'node_modules')
|
||||
|
||||
print(f"[ADAPTER] Executing in context: {work_dir}")
|
||||
@@ -119,8 +77,7 @@ class NodeJSAdapter:
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 min timeout for installs
|
||||
)
|
||||
timeout=300 )
|
||||
|
||||
return {
|
||||
'success': result.returncode == 0,
|
||||
@@ -136,20 +93,16 @@ class NodeJSAdapter:
|
||||
return {'success': False, 'error': f'{type(e).__name__} - {e}'}
|
||||
|
||||
def install_dependencies(self, plugin_root: str, packages: List[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Helper: Install dependencies for a specific plugin.
|
||||
If packages is None, runs 'npm install' (installs from package.json).
|
||||
If packages is provided, runs 'npm install <pkg1> <pkg2>...'.
|
||||
"""
|
||||
args = ['install']
|
||||
if packages:
|
||||
args.extend(packages)
|
||||
return self.execute_in_context(plugin_root, args, is_npm=True)
|
||||
|
||||
def run_script(self, plugin_root: str, script_name: str, extra_args: List[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Helper: Run an npm script (e.g., 'start', 'build') for a specific plugin.
|
||||
"""
|
||||
args = ['run', script_name]
|
||||
if extra_args:
|
||||
args.append('--')
|
||||
@@ -157,25 +110,19 @@ class NodeJSAdapter:
|
||||
return self.execute_in_context(plugin_root, args, is_npm=True)
|
||||
|
||||
def run_file(self, plugin_root: str, file_path: str, args: List[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Helper: Run a specific JS file within a plugin's pkg directory.
|
||||
file_path should be relative to the pkg dir (e.g., 'index.js').
|
||||
"""
|
||||
cmd_args = [file_path]
|
||||
if args:
|
||||
cmd_args.extend(args)
|
||||
return self.execute_in_context(plugin_root, cmd_args, is_npm=False)
|
||||
|
||||
def init_project(self, plugin_root: str, name: str = "plugin-project") -> Dict[str, Any]:
|
||||
"""
|
||||
Helper: Initialize a package.json in the plugin's pkg directory.
|
||||
"""
|
||||
# First run npm init -y
|
||||
res = self.execute_in_context(plugin_root, ['init', '-y'], is_npm=True)
|
||||
if not res['success']:
|
||||
return res
|
||||
|
||||
# Then update the name to be more specific
|
||||
pkg_json_path = os.path.join(plugin_root, 'pkg', 'package.json')
|
||||
if os.path.exists(pkg_json_path):
|
||||
try:
|
||||
@@ -192,14 +139,11 @@ class NodeJSAdapter:
|
||||
return res
|
||||
|
||||
|
||||
# --- Plugin Lifecycle Hooks ---
|
||||
|
||||
def init(context):
|
||||
"""
|
||||
Initialize the adapter and register it as a shared service.
|
||||
This plugin does NOT start any server or run any code itself.
|
||||
It just registers the tool for others to use.
|
||||
"""
|
||||
adapter = NodeJSAdapter()
|
||||
versions = adapter.check_versions()
|
||||
|
||||
@@ -209,7 +153,6 @@ def init(context):
|
||||
if versions.get('npm'):
|
||||
print(f"[INFO] Package Manager: npm {versions['npm']}")
|
||||
|
||||
# Register in shared services so other plugins can retrieve it
|
||||
if 'services' not in context:
|
||||
context['services'] = {}
|
||||
context['services']['nodejs-adapter'] = adapter
|
||||
@@ -222,16 +165,6 @@ def init(context):
|
||||
}
|
||||
|
||||
def start(context):
|
||||
"""No-op: This is a stateless service provider."""
|
||||
return {'status': 'active'}
|
||||
|
||||
def stop(context):
|
||||
"""No-op: Nothing to clean up."""
|
||||
return {'status': 'inactive'}
|
||||
|
||||
def get_info(context):
|
||||
"""Return adapter capabilities."""
|
||||
adapter = context.get('services', {}).get('nodejs-adapter')
|
||||
if adapter:
|
||||
return adapter.get_capabilities()
|
||||
return {'error': 'Adapter service not found'}
|
||||
|
||||
165
oss/tests/conftest.py
Normal file
165
oss/tests/conftest.py
Normal file
@@ -0,0 +1,165 @@
|
||||
Pytest configuration and shared fixtures
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def temp_data_dir():
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
store_dir = Path(temp_dir) / "store"
|
||||
store_dir.mkdir()
|
||||
|
||||
(store_dir / "@{NebulaShell}").mkdir()
|
||||
(store_dir / "@{Falck}").mkdir()
|
||||
|
||||
yield str(store_dir)
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(temp_data_dir, temp_store_dir):
|
||||
from oss.config.config import _global_config
|
||||
original_config = _global_config
|
||||
_global_config = None
|
||||
|
||||
yield
|
||||
|
||||
_global_config = original_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_plugin_dir(temp_store_dir):
|
||||
from oss.plugin.types import Plugin
|
||||
|
||||
class TestPlugin(Plugin):
|
||||
def __init__(self):
|
||||
self.name = "test-plugin"
|
||||
self.version = "1.0.0"
|
||||
|
||||
def init(self):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def New():
|
||||
return TestPlugin()
|
||||
{
|
||||
"metadata": {
|
||||
"name": "test-plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test plugin"
|
||||
},
|
||||
"config": {
|
||||
"args": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"permissions": []
|
||||
}
|
||||
plugin_dir = Path(sample_plugin_dir)
|
||||
|
||||
pl_dir = plugin_dir / "PL"
|
||||
pl_dir.mkdir()
|
||||
|
||||
pl_main = pl_dir / "main.py"
|
||||
with open(pl_main, 'w') as f:
|
||||
f.write(
|
||||
import sys
|
||||
import types
|
||||
from typing import Any, Optional, Dict
|
||||
|
||||
from oss.plugin.types import Plugin, register_plugin_type
|
||||
|
||||
class Log:
|
||||
@classmethod
|
||||
def info(cls, tag: str, msg: str): print(f"[{tag}] {msg}")
|
||||
@classmethod
|
||||
def warn(cls, tag: str, msg: str): print(f"[{tag}] ⚠ {msg}")
|
||||
@classmethod
|
||||
def error(cls, tag: str, msg: str): print(f"[{tag}] ✗ {msg}")
|
||||
@classmethod
|
||||
def ok(cls, tag: str, msg: str): print(f"[{tag}] {msg}")
|
||||
|
||||
class PluginInfo:
|
||||
def __init__(self):
|
||||
self.name: str = ""
|
||||
self.version: str = ""
|
||||
self.author: str = ""
|
||||
self.description: str = ""
|
||||
self.readme: str = ""
|
||||
self.config: dict[str, Any] = {}
|
||||
self.extensions: dict[str, Any] = {}
|
||||
self.lifecycle: Any = None
|
||||
self.capabilities: set[str] = set()
|
||||
self.dependencies: list[str] = []
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self):
|
||||
self.plugins: dict = {}
|
||||
self.lifecycle_plugin = None
|
||||
self._dependency_plugin = None
|
||||
self._signature_verifier = None
|
||||
|
||||
def load_all(self, store_dir: str = "store"):
|
||||
pass
|
||||
|
||||
def init_and_start_all(self):
|
||||
pass
|
||||
|
||||
def stop_all(self):
|
||||
pass
|
||||
|
||||
class PluginLoaderPlugin(Plugin):
|
||||
def __init__(self):
|
||||
self.manager = PluginManager()
|
||||
self._loaded = False
|
||||
self._started = False
|
||||
|
||||
def init(self, deps: dict = None):
|
||||
if self._loaded: return
|
||||
self._loaded = True
|
||||
Log.info("plugin-loader", "开始加载插件...")
|
||||
self.manager.load_all()
|
||||
|
||||
def start(self):
|
||||
if self._started: return
|
||||
self._started = True
|
||||
Log.info("plugin-loader", "启动插件...")
|
||||
self.manager.init_and_start_all()
|
||||
|
||||
def stop(self):
|
||||
Log.info("plugin-loader", "停止插件...")
|
||||
self.manager.stop_all()
|
||||
|
||||
register_plugin_type("PluginManager", PluginManager)
|
||||
register_plugin_type("PluginInfo", PluginInfo)
|
||||
|
||||
def New():
|
||||
return PluginLoaderPlugin()
|
||||
pass
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
for item in items:
|
||||
if "plugin_loader" in item.nodeid or "plugin_dir" in item.nodeid:
|
||||
item.add_marker(pytest.mark.plugin)
|
||||
|
||||
if "integration" in item.nodeid:
|
||||
item.add_marker(pytest.mark.integration)
|
||||
|
||||
if "slow" in item.nodeid:
|
||||
item.add_marker(pytest.mark.slow)
|
||||
141
oss/tests/test_config.py
Normal file
141
oss/tests/test_config.py
Normal file
@@ -0,0 +1,141 @@
|
||||
Tests for Configuration Management
|
||||
|
||||
import os
|
||||
import json
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from oss.config import Config, get_config, init_config
|
||||
|
||||
|
||||
class TestConfig:
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
config_file = os.path.join(temp_dir, "config.json")
|
||||
|
||||
config_data = {
|
||||
"HTTP_API_PORT": 9000,
|
||||
"HTTP_TCP_PORT": 9002,
|
||||
"HOST": "127.0.0.1",
|
||||
"DATA_DIR": "./test_data",
|
||||
"STORE_DIR": "./test_store",
|
||||
"LOG_LEVEL": "DEBUG",
|
||||
"PERMISSION_CHECK": False,
|
||||
"MAX_WORKERS": 8,
|
||||
"API_KEY": "test-key",
|
||||
"CORS_ALLOWED_ORIGINS": ["http://localhost:8080"]
|
||||
}
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config_data, f)
|
||||
|
||||
yield config_file
|
||||
|
||||
os.remove(config_file)
|
||||
os.rmdir(temp_dir)
|
||||
|
||||
def test_config_initialization_defaults(self):
|
||||
config = Config(temp_config_file)
|
||||
|
||||
assert config.get("HTTP_API_PORT") == 9000
|
||||
assert config.get("HTTP_TCP_PORT") == 9002
|
||||
assert config.get("HOST") == "127.0.0.1"
|
||||
assert config.get("DATA_DIR") == "./test_data"
|
||||
assert config.get("STORE_DIR") == "./test_store"
|
||||
assert config.get("LOG_LEVEL") == "DEBUG"
|
||||
assert config.get("PERMISSION_CHECK") is False
|
||||
assert config.get("MAX_WORKERS") == 8
|
||||
assert config.get("API_KEY") == "test-key"
|
||||
assert config.get("CORS_ALLOWED_ORIGINS") == ["http://localhost:8080"]
|
||||
|
||||
def test_config_load_from_nonexistent_file(self):
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
config_file = os.path.join(temp_dir, "invalid_config.json")
|
||||
|
||||
with open(config_file, 'w') as f:
|
||||
f.write("{ invalid json")
|
||||
|
||||
config = Config(config_file)
|
||||
|
||||
assert config.get("HTTP_API_PORT") == 8080
|
||||
|
||||
os.remove(config_file)
|
||||
os.rmdir(temp_dir)
|
||||
|
||||
def test_config_load_from_env(self):
|
||||
os.environ["HTTP_API_PORT"] = "7000"
|
||||
os.environ["HOST"] = "192.168.1.1"
|
||||
|
||||
try:
|
||||
config = Config(temp_config_file)
|
||||
|
||||
assert config.get("HTTP_TCP_PORT") == 9002
|
||||
assert config.get("DATA_DIR") == "./test_data"
|
||||
|
||||
assert config.get("HTTP_API_PORT") == 7000
|
||||
assert config.get("HOST") == "192.168.1.1"
|
||||
finally:
|
||||
for key in ["HTTP_API_PORT", "HOST"]:
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
def test_config_env_type_conversion(self):
|
||||
os.environ["HTTP_API_PORT"] = "not_a_number"
|
||||
os.environ["PERMISSION_CHECK"] = "not_a_boolean"
|
||||
|
||||
try:
|
||||
config = Config()
|
||||
|
||||
assert config.get("HTTP_API_PORT") == 8080
|
||||
assert config.get("PERMISSION_CHECK") is True
|
||||
finally:
|
||||
for key in ["HTTP_API_PORT", "PERMISSION_CHECK"]:
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
def test_config_get_with_default(self):
|
||||
config = Config()
|
||||
|
||||
config.set("HTTP_API_PORT", 9000)
|
||||
assert config.get("HTTP_API_PORT") == 9000
|
||||
|
||||
config.set("NONEXISTENT_KEY", "value")
|
||||
assert config.get("NONEXISTENT_KEY") is None
|
||||
|
||||
def test_config_all(self):
|
||||
config = Config()
|
||||
|
||||
assert isinstance(config.http_api_port, int)
|
||||
assert isinstance(config.http_tcp_port, int)
|
||||
assert isinstance(config.host, str)
|
||||
assert isinstance(config.data_dir, Path)
|
||||
assert isinstance(config.store_dir, Path)
|
||||
assert isinstance(config.log_level, str)
|
||||
assert isinstance(config.permission_check, bool)
|
||||
|
||||
assert config.http_api_port == 8080
|
||||
assert config.http_tcp_port == 8082
|
||||
assert config.host == "0.0.0.0"
|
||||
assert config.data_dir == Path("./data")
|
||||
assert config.store_dir == Path("./store")
|
||||
assert config.log_level == "INFO"
|
||||
assert config.permission_check is True
|
||||
|
||||
|
||||
class TestGlobalConfig:
|
||||
config1 = get_config()
|
||||
config2 = get_config()
|
||||
|
||||
assert config1 is config2
|
||||
|
||||
def test_init_config(self):
|
||||
config = init_config(temp_config_file)
|
||||
|
||||
assert isinstance(config, Config)
|
||||
assert config.get("HTTP_API_PORT") == 9000
|
||||
|
||||
assert config is get_config()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
32
oss/tests/test_fixes.py
Normal file
32
oss/tests/test_fixes.py
Normal file
@@ -0,0 +1,32 @@
|
||||
Simple test to verify our fixes
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from oss.config import Config
|
||||
from oss.logger.logger import Logger
|
||||
|
||||
|
||||
def test_cors_fix():
|
||||
config = Config()
|
||||
|
||||
assert config.get("LOG_FILE") == ""
|
||||
assert config.get("LOG_MAX_SIZE") == 10485760 assert config.get("LOG_BACKUP_COUNT") == 5
|
||||
|
||||
os.environ["LOG_FILE"] = "/tmp/test.log"
|
||||
os.environ["LOG_MAX_SIZE"] = "20971520" os.environ["LOG_BACKUP_COUNT"] = "10"
|
||||
|
||||
config = Config()
|
||||
|
||||
assert config.get("LOG_FILE") == "/tmp/test.log"
|
||||
assert config.get("LOG_MAX_SIZE") == 20971520
|
||||
assert config.get("LOG_BACKUP_COUNT") == 10
|
||||
|
||||
for key in ["LOG_FILE", "LOG_MAX_SIZE", "LOG_BACKUP_COUNT"]:
|
||||
if key in os.environ:
|
||||
del os.environ[key]
|
||||
|
||||
|
||||
def test_logger_functionality():
|
||||
137
oss/tests/test_http_api.py
Normal file
137
oss/tests/test_http_api.py
Normal file
@@ -0,0 +1,137 @@
|
||||
Tests for HTTP API
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from oss.config import get_config
|
||||
from oss.logger.logger import Log
|
||||
from store.@{NebulaShell}.http-api.server import HttpServer, Request, Response
|
||||
from store.@{NebulaShell}.http-api.middleware import MiddlewareChain, CorsMiddleware, AuthMiddleware, LoggerMiddleware
|
||||
|
||||
|
||||
class TestRequest:
|
||||
req = Request("GET", "/test", {"Content-Type": "application/json"}, '{"test": true}')
|
||||
|
||||
assert req.method == "GET"
|
||||
assert req.path == "/test"
|
||||
assert req.headers == {"Content-Type": "application/json"}
|
||||
assert req.body == '{"test": true}'
|
||||
assert req.path_params == {}
|
||||
|
||||
|
||||
class TestResponse:
|
||||
resp = Response()
|
||||
|
||||
assert resp.status == 200
|
||||
assert resp.headers == {}
|
||||
assert resp.body == ""
|
||||
|
||||
def test_response_initialization_with_params(self):
|
||||
|
||||
@pytest.fixture
|
||||
def mock_router(self):
|
||||
return MiddlewareChain()
|
||||
|
||||
def test_http_server_initialization(self, mock_router, middleware_chain):
|
||||
server = HttpServer(mock_router, middleware_chain, host="127.0.0.1", port=9000)
|
||||
|
||||
assert server.host == "127.0.0.1"
|
||||
assert server.port == 9000
|
||||
|
||||
@patch('store.@{NebulaShell}.http-api.server.HTTPServer')
|
||||
def test_http_server_start(self, mock_http_server, mock_router, middleware_chain):
|
||||
server = HttpServer(mock_router, middleware_chain)
|
||||
|
||||
mock_server_instance = Mock()
|
||||
server._server = mock_server_instance
|
||||
|
||||
server.stop()
|
||||
|
||||
mock_server_instance.shutdown.assert_called_once()
|
||||
|
||||
|
||||
class TestMiddleware:
|
||||
from store.@{NebulaShell}.http-api.middleware import Middleware
|
||||
|
||||
class TestMiddleware(Middleware):
|
||||
def process(self, ctx, next_fn):
|
||||
return next_fn()
|
||||
|
||||
middleware = TestMiddleware()
|
||||
ctx = {}
|
||||
next_fn = Mock(return_value=None)
|
||||
|
||||
result = middleware.process(ctx, next_fn)
|
||||
|
||||
next_fn.assert_called_once()
|
||||
assert result is None
|
||||
|
||||
def test_cors_middleware_process(self):
|
||||
middleware = AuthMiddleware()
|
||||
ctx = {"request": Request("GET", "/api/test", {}, "")}
|
||||
next_fn = Mock(return_value=None)
|
||||
|
||||
with patch('store.@{NebulaShell}.http-api.middleware.get_config') as mock_get_config:
|
||||
mock_get_config.return_value.get.return_value = ""
|
||||
|
||||
result = middleware.process(ctx, next_fn)
|
||||
|
||||
next_fn.assert_called_once()
|
||||
assert result is None
|
||||
|
||||
def test_auth_middleware_process_public_path(self):
|
||||
middleware = AuthMiddleware()
|
||||
ctx = {"request": Request("GET", "/api/test", {"Authorization": "Bearer test-key"}, "")}
|
||||
next_fn = Mock(return_value=None)
|
||||
|
||||
with patch('store.@{NebulaShell}.http-api.middleware.get_config') as mock_get_config:
|
||||
mock_get_config.return_value.get.return_value = "test-key"
|
||||
|
||||
result = middleware.process(ctx, next_fn)
|
||||
|
||||
next_fn.assert_called_once()
|
||||
assert result is None
|
||||
|
||||
def test_auth_middleware_process_with_invalid_token(self):
|
||||
middleware = LoggerMiddleware()
|
||||
ctx = {"request": Request("GET", "/api/test", {}, "")}
|
||||
next_fn = Mock(return_value=None)
|
||||
|
||||
with patch.object(Log, 'info') as mock_log:
|
||||
result = middleware.process(ctx, next_fn)
|
||||
|
||||
next_fn.assert_called_once()
|
||||
mock_log.assert_called_once_with("http-api", "GET /api/test")
|
||||
assert result is None
|
||||
|
||||
def test_logger_middleware_process_silent_path(self):
|
||||
|
||||
def test_middleware_chain_initialization(self):
|
||||
chain = MiddlewareChain()
|
||||
initial_count = len(chain.middlewares)
|
||||
|
||||
mock_middleware = Mock()
|
||||
chain.add(mock_middleware)
|
||||
|
||||
assert len(chain.middlewares) == initial_count + 1
|
||||
assert chain.middlewares[-1] is mock_middleware
|
||||
|
||||
def test_middleware_chain_run(self):
|
||||
chain = MiddlewareChain()
|
||||
ctx = {}
|
||||
|
||||
response = Response(status=401, body='{"error": "Unauthorized"}')
|
||||
chain.middlewares[0].process = Mock(return_value=response)
|
||||
|
||||
result = chain.run(ctx)
|
||||
|
||||
chain.middlewares[0].process.assert_called_once()
|
||||
for middleware in chain.middlewares[1:]:
|
||||
middleware.process.assert_not_called()
|
||||
|
||||
assert result is response
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
104
oss/tests/test_logger.py
Normal file
104
oss/tests/test_logger.py
Normal file
@@ -0,0 +1,104 @@
|
||||
Tests for Logger
|
||||
|
||||
import logging
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import patch, Mock
|
||||
from io import StringIO
|
||||
|
||||
from oss.logger.logger import Logger
|
||||
|
||||
|
||||
class TestLogger:
|
||||
return Logger("test")
|
||||
|
||||
def test_logger_initialization(self):
|
||||
logger = Logger("test")
|
||||
|
||||
with patch.object(logger.logger, 'info') as mock_info:
|
||||
logger.info("Test message")
|
||||
|
||||
mock_info.assert_called_once_with("Test message")
|
||||
|
||||
def test_logger_warn(self):
|
||||
logger = Logger("test")
|
||||
|
||||
with patch.object(logger.logger, 'error') as mock_error:
|
||||
logger.error("Test error")
|
||||
|
||||
mock_error.assert_called_once_with("Test error")
|
||||
|
||||
def test_logger_debug(self):
|
||||
logger = Logger("test")
|
||||
|
||||
with patch.object(logger.logger, 'info') as mock_info:
|
||||
logger.info("Test message", "TAG")
|
||||
|
||||
mock_info.assert_called_once_with("[TAG] Test message")
|
||||
|
||||
def test_logger_warn_with_tag(self):
|
||||
logger = Logger("test")
|
||||
|
||||
with patch.object(logger.logger, 'error') as mock_error:
|
||||
logger.error("Test error", "TAG")
|
||||
|
||||
mock_error.assert_called_once_with("[TAG] Test error")
|
||||
|
||||
def test_logger_debug_with_tag(self):
|
||||
logger = Logger("test")
|
||||
|
||||
format_str = logger._get_log_format()
|
||||
|
||||
assert "%(asctime)s" in format_str
|
||||
assert "%(name)s" in format_str
|
||||
assert "%(levelname)s" in format_str
|
||||
assert "%(message)s" in format_str
|
||||
|
||||
def test_get_log_format_json(self):
|
||||
os.environ["LOG_FORMAT"] = "json"
|
||||
|
||||
try:
|
||||
logger = Logger("test")
|
||||
format_str = logger._get_log_format()
|
||||
|
||||
assert "%(asctime)s" in format_str
|
||||
assert "%(name)s" in format_str
|
||||
assert "%(levelname)s" in format_str
|
||||
assert "%(message)s" in format_str
|
||||
finally:
|
||||
if "LOG_FORMAT" in os.environ:
|
||||
del os.environ["LOG_FORMAT"]
|
||||
|
||||
def test_logger_json_format(self):
|
||||
|
||||
def test_logger_output(self):
|
||||
log_capture = StringIO()
|
||||
|
||||
logger = logging.getLogger("test_json")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
handler = logging.StreamHandler(log_capture)
|
||||
formatter = logging.Formatter(
|
||||
'{"time": "%(asctime)s", "name": "%(name)s", "level": "%(levelname)s", "message": "%(message)s"}'
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger.info("Test JSON message")
|
||||
|
||||
log_output = log_capture.getvalue().strip()
|
||||
assert log_output.startswith("{")
|
||||
assert log_output.endswith("}")
|
||||
assert "test_json" in log_output
|
||||
assert "INFO" in log_output
|
||||
assert "Test JSON message" in log_output
|
||||
|
||||
try:
|
||||
import json
|
||||
json.loads(log_output)
|
||||
except json.JSONDecodeError:
|
||||
pytest.fail("Log output is not valid JSON")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -1,6 +1,4 @@
|
||||
"""
|
||||
Tests for Node.js Adapter Plugin
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -9,11 +7,9 @@ import tempfile
|
||||
import shutil
|
||||
import pytest
|
||||
|
||||
# Add the plugin directory to path
|
||||
PLUGIN_DIR = os.path.join(os.path.dirname(__file__), '..', 'store', '@{NebulaShell}', 'nodejs-adapter')
|
||||
sys.path.insert(0, PLUGIN_DIR)
|
||||
|
||||
# Import after path update
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("nodejs_adapter_main", os.path.join(PLUGIN_DIR, "main.py"))
|
||||
main_module = importlib.util.module_from_spec(spec)
|
||||
@@ -22,76 +18,23 @@ NodeJSAdapter = main_module.NodeJSAdapter
|
||||
|
||||
|
||||
class TestNodeJSAdapter:
|
||||
"""Test suite for NodeJSAdapter class"""
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(self):
|
||||
"""Create a fresh adapter instance"""
|
||||
return NodeJSAdapter()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_plugin_dir(self):
|
||||
"""Create a temporary plugin directory structure"""
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
pkg_dir = os.path.join(temp_dir, 'pkg')
|
||||
os.makedirs(pkg_dir)
|
||||
|
||||
# Create a minimal package.json
|
||||
package_json = {
|
||||
"name": "test-plugin",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "echo 'test passed'"
|
||||
}
|
||||
}
|
||||
with open(os.path.join(pkg_dir, 'package.json'), 'w') as f:
|
||||
json.dump(package_json, f)
|
||||
|
||||
yield temp_dir
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
def test_adapter_initialization(self, adapter):
|
||||
"""Test that adapter initializes correctly"""
|
||||
assert adapter.name == "nodejs-adapter"
|
||||
assert adapter.version == "1.0.0"
|
||||
assert "Node.js" in adapter.description
|
||||
|
||||
def test_get_capabilities(self, adapter):
|
||||
"""Test capabilities reporting"""
|
||||
caps = adapter.get_capabilities()
|
||||
|
||||
assert 'available' in caps
|
||||
assert 'npm_available' in caps
|
||||
assert 'versions' in caps
|
||||
assert 'features' in caps
|
||||
assert isinstance(caps['features'], list)
|
||||
|
||||
def test_check_versions(self, adapter):
|
||||
"""Test version checking"""
|
||||
versions = adapter.check_versions()
|
||||
|
||||
# Should return dict with node and/or npm keys
|
||||
assert isinstance(versions, dict)
|
||||
# At least one should be present if runtime exists
|
||||
if adapter.node_path:
|
||||
assert 'node' in versions
|
||||
assert not versions['node'].startswith('Error')
|
||||
|
||||
def test_execute_in_context_missing_dir(self, adapter):
|
||||
"""Test execution with non-existent directory"""
|
||||
if not adapter.node_path:
|
||||
pytest.skip("Node.js not available")
|
||||
|
||||
result = adapter.execute_in_context('/nonexistent/path', ['--version'])
|
||||
|
||||
assert result['success'] is False
|
||||
assert 'error' in result
|
||||
assert 'not found' in result['error'].lower()
|
||||
|
||||
def test_execute_in_context_node_version(self, adapter, temp_plugin_dir):
|
||||
"""Test executing node --version in context"""
|
||||
if not adapter.node_path:
|
||||
pytest.skip("Node.js not available")
|
||||
|
||||
@@ -100,24 +43,9 @@ class TestNodeJSAdapter:
|
||||
assert result['success'] is True
|
||||
assert 'cwd' in result
|
||||
assert result['cwd'].endswith('pkg')
|
||||
# Version should start with v
|
||||
assert result['stdout'].strip().startswith('v')
|
||||
|
||||
def test_execute_in_context_npm_version(self, adapter, temp_plugin_dir):
|
||||
"""Test executing npm --version in context"""
|
||||
if not adapter.npm_path:
|
||||
pytest.skip("npm not available")
|
||||
|
||||
result = adapter.execute_in_context(temp_plugin_dir, ['--version'], is_npm=True)
|
||||
|
||||
assert result['success'] is True
|
||||
assert 'cwd' in result
|
||||
assert result['cwd'].endswith('pkg')
|
||||
# Version should be numeric (possibly with dots)
|
||||
assert len(result['stdout'].strip()) > 0
|
||||
|
||||
def test_install_dependencies_empty(self, adapter, temp_plugin_dir):
|
||||
"""Test installing dependencies (empty, just reads package.json)"""
|
||||
if not adapter.npm_path:
|
||||
pytest.skip("npm not available")
|
||||
|
||||
@@ -128,21 +56,9 @@ class TestNodeJSAdapter:
|
||||
assert result['cwd'].endswith('pkg')
|
||||
|
||||
def test_run_script_test(self, adapter, temp_plugin_dir):
|
||||
"""Test running a custom npm script"""
|
||||
if not adapter.npm_path:
|
||||
pytest.skip("npm not available")
|
||||
|
||||
result = adapter.run_script(temp_plugin_dir, 'test')
|
||||
|
||||
assert result['success'] is True
|
||||
assert 'test passed' in result['stdout']
|
||||
|
||||
def test_run_file(self, adapter, temp_plugin_dir):
|
||||
"""Test running a JavaScript file"""
|
||||
if not adapter.node_path:
|
||||
pytest.skip("Node.js not available")
|
||||
|
||||
# Create a simple JS file
|
||||
js_file = os.path.join(temp_plugin_dir, 'pkg', 'hello.js')
|
||||
with open(js_file, 'w') as f:
|
||||
f.write("console.log('Hello from Node.js');")
|
||||
@@ -153,50 +69,8 @@ class TestNodeJSAdapter:
|
||||
assert 'Hello from Node.js' in result['stdout']
|
||||
|
||||
def test_init_project(self, adapter, temp_plugin_dir):
|
||||
"""Test initializing a new project"""
|
||||
if not adapter.npm_path:
|
||||
pytest.skip("npm not available")
|
||||
|
||||
# Create empty pkg dir for this test
|
||||
pkg_dir = os.path.join(temp_plugin_dir, 'pkg2')
|
||||
os.makedirs(pkg_dir)
|
||||
|
||||
# Create a minimal package.json first (npm init -y creates one)
|
||||
package_json = {"name": "temp", "version": "1.0.0"}
|
||||
with open(os.path.join(pkg_dir, 'package.json'), 'w') as f:
|
||||
json.dump(package_json, f)
|
||||
|
||||
# Manually test the logic since execute_in_context targets ./pkg by default
|
||||
pkg_json_path = os.path.join(pkg_dir, 'package.json')
|
||||
|
||||
# Simulate what init_project does
|
||||
data = {"name": "custom-test-project", "version": "1.0.0", "private": True}
|
||||
with open(pkg_json_path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Verify
|
||||
with open(pkg_json_path, 'r') as f:
|
||||
pkg_data = json.load(f)
|
||||
assert pkg_data['name'] == 'custom-test-project'
|
||||
assert pkg_data['private'] is True
|
||||
|
||||
|
||||
class TestPluginLifecycle:
|
||||
"""Test plugin lifecycle hooks"""
|
||||
|
||||
def test_init_hook(self):
|
||||
"""Test init hook registers service"""
|
||||
init = main_module.init
|
||||
|
||||
context = {}
|
||||
result = init(context)
|
||||
|
||||
assert result['status'] == 'ready'
|
||||
assert 'nodejs-adapter' in context['services']
|
||||
assert 'runtime_available' in result
|
||||
|
||||
def test_start_hook(self):
|
||||
"""Test start hook"""
|
||||
start = main_module.start
|
||||
|
||||
context = {}
|
||||
@@ -205,16 +79,6 @@ class TestPluginLifecycle:
|
||||
assert result['status'] == 'active'
|
||||
|
||||
def test_stop_hook(self):
|
||||
"""Test stop hook"""
|
||||
stop = main_module.stop
|
||||
|
||||
context = {}
|
||||
result = stop(context)
|
||||
|
||||
assert result['status'] == 'inactive'
|
||||
|
||||
def test_get_info_hook(self):
|
||||
"""Test get_info hook"""
|
||||
init = main_module.init
|
||||
get_info = main_module.get_info
|
||||
|
||||
|
||||
73
oss/tests/test_plugin_manager.py
Normal file
73
oss/tests/test_plugin_manager.py
Normal file
@@ -0,0 +1,73 @@
|
||||
Tests for Plugin Manager
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from oss.plugin.manager import PluginManager
|
||||
from oss.plugin.loader import PluginLoader
|
||||
|
||||
|
||||
class TestPluginManager:
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
store_dir = Path(temp_dir) / "store"
|
||||
store_dir.mkdir()
|
||||
|
||||
plugin_loader_dir = store_dir / "@{NebulaShell}" / "plugin-loader"
|
||||
plugin_loader_dir.mkdir(parents=True)
|
||||
|
||||
main_py = plugin_loader_dir / "main.py"
|
||||
with open(main_py, 'w') as f:
|
||||
f.write(
|
||||
from oss.plugin.types import Plugin
|
||||
|
||||
class TestPlugin(Plugin):
|
||||
def __init__(self):
|
||||
self.name = "test-plugin"
|
||||
|
||||
def init(self):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def New():
|
||||
return TestPlugin()
|
||||
loader = PluginLoader()
|
||||
assert loader.loaded == {}
|
||||
assert loader._config is not None
|
||||
|
||||
def test_load_plugin_with_main_py(self, temp_plugin_dir):
|
||||
loader = PluginLoader()
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
plugin_dir = Path(temp_dir) / "empty-plugin"
|
||||
plugin_dir.mkdir()
|
||||
|
||||
result = loader._load_plugin("empty-plugin", plugin_dir)
|
||||
|
||||
assert result is None
|
||||
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
def test_load_plugin_without_new_function(self):
|
||||
loader = PluginLoader()
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
plugin_dir = Path(temp_dir) / "syntax-error-plugin"
|
||||
plugin_dir.mkdir()
|
||||
|
||||
main_py = plugin_dir / "main.py"
|
||||
with open(main_py, 'w') as f:
|
||||
f.write("def broken_function(\n
|
||||
result = loader._load_plugin("syntax-error-plugin", plugin_dir)
|
||||
|
||||
assert result is None
|
||||
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -1,39 +1,21 @@
|
||||
"""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,
|
||||
@@ -46,28 +28,22 @@ from .converter import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 管理器
|
||||
'TUIManager',
|
||||
'TUIRenderer',
|
||||
'HTMLToTUIConverter',
|
||||
|
||||
# 输入处理
|
||||
'TUIInputHandler',
|
||||
'TUIEventManager',
|
||||
|
||||
# 画布
|
||||
'TUICanvas',
|
||||
|
||||
# 样式系统
|
||||
'ANSIStyle',
|
||||
'BorderStyle',
|
||||
'TUIColor',
|
||||
'TUIStyle',
|
||||
|
||||
# 元素类型
|
||||
'TUIElementType',
|
||||
|
||||
# 基础元素
|
||||
'TUIElement',
|
||||
'TUIButton',
|
||||
'TUILabel',
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
"""TUI 客户端 - 前后端分离的 TUI 前端
|
||||
|
||||
通过 HTTP 连接后端 nebula serve,消费 JSON API,
|
||||
直接使用 ANSI 转义码绘制专业终端界面。
|
||||
支持鼠标点击导航。
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
@@ -18,7 +12,6 @@ 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"
|
||||
@@ -39,12 +32,10 @@ C = {
|
||||
"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:
|
||||
@@ -64,7 +55,6 @@ def backend_alive(host="127.0.0.1", port=8080) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ── 布局工具 ────────────────────────────────────────────
|
||||
|
||||
def term_size():
|
||||
return shutil.get_terminal_size((80, 24))
|
||||
@@ -77,10 +67,8 @@ def hbar(width: int, percent: float, color_fg=(0, 255, 135), color_bg=(50, 50, 7
|
||||
return bar
|
||||
|
||||
|
||||
# ── TUI 客户端 ──────────────────────────────────────────
|
||||
|
||||
Page = dict # {"id": str, "label": str, "desc": str}
|
||||
|
||||
Page = dict
|
||||
|
||||
class TUIClient:
|
||||
_resize_flag = False
|
||||
@@ -108,7 +96,6 @@ class TUIClient:
|
||||
self._stats_cache = {}
|
||||
self._stats_time = 0
|
||||
|
||||
# 鼠标点击区域: list of (y, page_id)
|
||||
self._click_zones: list[tuple[int, str]] = []
|
||||
|
||||
def _fetch_stats(self) -> dict:
|
||||
@@ -124,368 +111,6 @@ class TUIClient:
|
||||
pass
|
||||
return self._stats_cache
|
||||
|
||||
# ── 鼠标事件 ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _parse_sgr_mouse(data: str):
|
||||
"""解析 SGR 鼠标事件 \x1b[<button;x;y;M/m → (button, x, y)"""
|
||||
m = re.match(r"^\x1b\[<(\d+);(\d+);(\d+)([Mm])$", data)
|
||||
if m:
|
||||
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
return None
|
||||
|
||||
# ── 屏幕渲染 ──────────────────────────────────────────
|
||||
|
||||
def _draw_header(self):
|
||||
w = self.width
|
||||
alive = backend_alive(self.host, self.port)
|
||||
status_icon = "●" if alive else "○"
|
||||
status_color = C["green"] if alive else C["red"]
|
||||
|
||||
print(bg(*C["header_bg"]), end="")
|
||||
print(" " * w, end="")
|
||||
print(f"\r{bg(*C['header_bg'])} ", end="")
|
||||
|
||||
title = " NebulaShell TUI "
|
||||
print(fg(*C["accent"]) + bold(title) + rst(), end="")
|
||||
print(bg(*C["header_bg"]), end="")
|
||||
|
||||
right = f" {fg(*status_color)}{status_icon}{rst()}{bg(*C['header_bg'])} {fg(*C['dim'])}{self.host}:{self.port}{rst()}"
|
||||
print(f"\x1b[{w - len(right) + 1}G{right}", end="")
|
||||
print(rst())
|
||||
|
||||
def _draw_status_bar(self):
|
||||
w = self.width
|
||||
print(bg(*C["status_bg"]), end="")
|
||||
print(" " * w, end="")
|
||||
print(f"\r{bg(*C['status_bg'])} ", end="")
|
||||
|
||||
nav_hint = f"{fg(*C['dim'])}数字/点击导航 q 退出 r 刷新{rst()}"
|
||||
page_name = self.current_page.upper()
|
||||
page_info = f"{fg(*C['cyan'])}{page_name}{rst()}"
|
||||
print(f"\r{bg(*C['status_bg'])} {page_info}", end="")
|
||||
print(f"\x1b[{w - len(nav_hint) + 1}G{nav_hint}", end="")
|
||||
print(rst())
|
||||
|
||||
def _clear(self):
|
||||
print("\x1b[2J\x1b[H", end="")
|
||||
|
||||
def _render_all(self):
|
||||
self._click_zones.clear()
|
||||
self._clear()
|
||||
self._draw_header()
|
||||
print()
|
||||
content_top = 2 # header(1) + blank(1) = 2
|
||||
|
||||
alive = backend_alive(self.host, self.port)
|
||||
if self.current_page == "welcome":
|
||||
self._render_welcome(alive, content_top)
|
||||
elif self.current_page == "dashboard":
|
||||
self._render_dashboard()
|
||||
elif self.current_page == "logs":
|
||||
self._render_logs()
|
||||
elif self.current_page == "terminal":
|
||||
self._render_terminal()
|
||||
elif self.current_page == "plugins":
|
||||
self._render_plugins()
|
||||
else:
|
||||
self._render_home()
|
||||
|
||||
used = self._content_lines + 4
|
||||
for _ in range(self.height - used):
|
||||
print()
|
||||
self._draw_status_bar()
|
||||
sys.stdout.flush()
|
||||
|
||||
# ── 页面内容 ──────────────────────────────────────────
|
||||
|
||||
def _render_welcome(self, alive: bool, top: int):
|
||||
w = self.width
|
||||
self._content_lines = 0
|
||||
|
||||
logo = [
|
||||
"███╗ ██╗███████╗██████╗ ██╗ ██╗██╗ █████╗ ",
|
||||
"████╗ ██║██╔════╝██╔══██╗██║ ██║██║ ██╔══██╗",
|
||||
"██╔██╗ ██║█████╗ ██████╔╝██║ ██║██║ ███████║",
|
||||
"██║╚██╗██║██╔══╝ ██╔══██╗██║ ██║██║ ██╔══██║",
|
||||
"██║ ╚████║███████╗██████╔╝╚██████╔╝███████╗██║ ██║",
|
||||
"╚═╝ ╚═══╝╚══════╝╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝",
|
||||
]
|
||||
for line in logo:
|
||||
print(" " + fg(*C["accent"]) + line + rst())
|
||||
self._content_lines += 1
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
print(f" {fg(*C['dim'])}一切皆为插件的开发者工具运行时框架{rst()}")
|
||||
self._content_lines += 1
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
if alive:
|
||||
print(f" {fg(*C['green'])}● 后端已连接{rst()} {fg(*C['dim'])}{self.base_url}{rst()}")
|
||||
else:
|
||||
print(f" {fg(*C['red'])}○ 后端未连接{rst()} {fg(*C['dim'])}{self.base_url}{rst()}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}请先启动后端: nebula serve{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
print(f" {fg(*C['dim'])}─ 点击或按键导航 ─{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
for i, pg in enumerate(self.PAGES):
|
||||
line_y = top + self._content_lines
|
||||
self._click_zones.append((line_y, pg["id"]))
|
||||
key = str(i + 1) if i < 9 else "0"
|
||||
print(f" [{fg(*C['accent'])}{key}{rst()}] {bold(pg['label'])} {fg(*C['dim'])}{pg['desc']}{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_home(self):
|
||||
w = self.width
|
||||
self._content_lines = 0
|
||||
stats = self._fetch_stats()
|
||||
if not stats:
|
||||
print(f" {fg(*C['dim'])}无法获取系统信息{rst()}")
|
||||
self._content_lines += 1
|
||||
return
|
||||
|
||||
uptime = stats.get("uptime", "N/A")
|
||||
processes = stats.get("processes", 0)
|
||||
cpu = stats.get("cpu", {})
|
||||
mem = stats.get("ram", {})
|
||||
disk = stats.get("disk", {})
|
||||
|
||||
print(f" {bold('系统概览')}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}运行时间: {uptime} 进程数: {processes}{rst()}")
|
||||
self._content_lines += 1
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
bar_w = w - 30
|
||||
|
||||
cpu_p = cpu.get("percent", 0)
|
||||
print(f" CPU {fg(*C['dim'])}{str(cpu_p).rjust(5)}%{rst()} {hbar(bar_w, cpu_p, C['green'] if cpu_p < 50 else C['yellow'] if cpu_p < 80 else C['red'])}")
|
||||
self._content_lines += 1
|
||||
|
||||
ram_p = mem.get("percent", 0)
|
||||
ram_u = mem.get("used", 0)
|
||||
ram_t = mem.get("total", 0)
|
||||
print(f" 内存 {fg(*C['dim'])}{str(ram_p).rjust(5)}%{rst()} {hbar(bar_w, ram_p, C['green'] if ram_p < 50 else C['yellow'] if ram_p < 80 else C['red'])} {fg(*C['dim'])}{ram_u}G / {ram_t}G{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
disk_p = disk.get("percent", 0)
|
||||
disk_u = disk.get("used", 0)
|
||||
disk_t = disk.get("total", 0)
|
||||
print(f" 磁盘 {fg(*C['dim'])}{str(disk_p).rjust(5)}%{rst()} {hbar(bar_w, disk_p, C['green'] if disk_p < 50 else C['yellow'] if disk_p < 80 else C['red'])} {fg(*C['dim'])}{disk_u}G / {disk_t}G{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
net = stats.get("network", {})
|
||||
recv = net.get("recv_rate", 0)
|
||||
sent = net.get("sent_rate", 0)
|
||||
latency = stats.get("latency", 0)
|
||||
print(f" 网络 {fg(*C['dim'])}▼ {self._fmt_bytes(recv)}/s ▲ {self._fmt_bytes(sent)}/s 延迟: {latency}ms{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
load = stats.get("load", {})
|
||||
l1 = load.get("load1", 0)
|
||||
l5 = load.get("load5", 0)
|
||||
l15 = load.get("load15", 0)
|
||||
print(f" 负载 {fg(*C['dim'])}1m: {l1} 5m: {l5} 15m: {l15}{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_dashboard(self):
|
||||
w = self.width
|
||||
self._content_lines = 0
|
||||
stats = self._fetch_stats()
|
||||
if not stats:
|
||||
print(f" {fg(*C['dim'])}无法获取仪表盘数据{rst()}")
|
||||
self._content_lines += 1
|
||||
return
|
||||
|
||||
print(f" {bold('系统仪表盘')} 实时监控")
|
||||
self._content_lines += 1
|
||||
|
||||
cpu = stats.get("cpu", {})
|
||||
mem = stats.get("ram", {})
|
||||
disk = stats.get("disk", {})
|
||||
net = stats.get("network", {})
|
||||
disk_io = stats.get("disk_io", {})
|
||||
load = stats.get("load", {})
|
||||
latency = stats.get("latency", 0)
|
||||
processes = stats.get("processes", 0)
|
||||
uptime = stats.get("uptime", "N/A")
|
||||
|
||||
bar_w = w - 36
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
cpu_p = cpu.get("percent", 0)
|
||||
cpu_cores = cpu.get("cores", 0)
|
||||
print(f" {fg(*C['cyan'])}CPU {rst()}{hbar(bar_w, cpu_p, C['green'] if cpu_p < 50 else C['yellow'] if cpu_p < 80 else C['red'])} {fg(*C['white'])}{cpu_p}%{rst()} {fg(*C['dim'])}({cpu_cores} 核){rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
ram_p = mem.get("percent", 0)
|
||||
ram_u = mem.get("used", 0)
|
||||
ram_t = mem.get("total", 0)
|
||||
print(f" {fg(*C['cyan'])}内存 {rst()}{hbar(bar_w, ram_p, C['green'] if ram_p < 50 else C['yellow'] if ram_p < 80 else C['red'])} {fg(*C['white'])}{ram_p}%{rst()} {fg(*C['dim'])}{ram_u}G / {ram_t}G{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
disk_p = disk.get("percent", 0)
|
||||
disk_u = disk.get("used", 0)
|
||||
disk_t = disk.get("total", 0)
|
||||
print(f" {fg(*C['cyan'])}磁盘 {rst()}{hbar(bar_w, disk_p, C['green'] if disk_p < 50 else C['yellow'] if disk_p < 80 else C['red'])} {fg(*C['white'])}{disk_p}%{rst()} {fg(*C['dim'])}{disk_u}G / {disk_t}G{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
recv = net.get("recv_rate", 0)
|
||||
sent = net.get("sent_rate", 0)
|
||||
tr = net.get("total_recv", 0)
|
||||
ts = net.get("total_sent", 0)
|
||||
print(f" {fg(*C['cyan'])}网络 {rst()}▼ {fg(*C['green'])}{self._fmt_bytes(recv)}/s{rst()} ▲ {fg(*C['yellow'])}{self._fmt_bytes(sent)}/s{rst()} {fg(*C['dim'])}总量: {self._fmt_bytes(tr)} / {self._fmt_bytes(ts)}{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
disk_r = disk_io.get("read_rate", 0)
|
||||
disk_w = disk_io.get("write_rate", 0)
|
||||
print(f" {fg(*C['cyan'])}磁盘IO {rst()}▼ {fg(*C['green'])}{self._fmt_bytes(disk_r)}/s{rst()} ▲ {fg(*C['yellow'])}{self._fmt_bytes(disk_w)}/s{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
print()
|
||||
self._content_lines += 1
|
||||
|
||||
l1 = load.get("load1", 0)
|
||||
l5 = load.get("load5", 0)
|
||||
l15 = load.get("load15", 0)
|
||||
print(f" {fg(*C['cyan'])}负载 {rst()}1m: {fg(*C['white'])}{l1}{rst()} 5m: {fg(*C['white'])}{l5}{rst()} 15m: {fg(*C['white'])}{l15}{rst()} 进程: {fg(*C['white'])}{processes}{rst()} 延迟: {fg(*C['white'])}{latency}ms{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
print(f" {fg(*C['cyan'])}运行 {rst()}{fg(*C['dim'])}{uptime}{rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_logs(self):
|
||||
self._content_lines = 0
|
||||
print(f" {bold('系统日志')}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}实时日志输出(待实现){rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_terminal(self):
|
||||
self._content_lines = 0
|
||||
print(f" {bold('终端')}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}Shell 终端(待实现){rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
def _render_plugins(self):
|
||||
self._content_lines = 0
|
||||
print(f" {bold('插件管理')}")
|
||||
self._content_lines += 1
|
||||
print(f" {fg(*C['dim'])}插件列表(待实现){rst()}")
|
||||
self._content_lines += 1
|
||||
|
||||
# ── 工具 ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _fmt_bytes(b):
|
||||
if b > 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")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,3 @@
|
||||
"""TUI 插件 - 终端用户界面,与 WebUI 双启动
|
||||
|
||||
强大的转换层架构:
|
||||
- 只访问 WebUI 开放的 /tui 接口
|
||||
- 自动解析 .html 文件(入口是 index.html)
|
||||
- 支持终端兼容的 CSS(背景、字体排版样式)
|
||||
- 支持基础 JS 交互(鼠标位置、点击、按键)
|
||||
- 参考 opencode 风格的现代化终端体验
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
@@ -20,100 +11,37 @@ 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 ""
|
||||
|
||||
@@ -121,7 +49,6 @@ class TUIPlugin(Plugin):
|
||||
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():
|
||||
@@ -135,24 +62,9 @@ class TUIPlugin(Plugin):
|
||||
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:
|
||||
@@ -161,8 +73,6 @@ class TUIPlugin(Plugin):
|
||||
self.running = False
|
||||
|
||||
def _show_welcome(self):
|
||||
"""显示欢迎界面"""
|
||||
welcome_html = """
|
||||
<!DOCTYPE html>
|
||||
<html class="tui-page">
|
||||
<head>
|
||||
@@ -207,26 +117,10 @@ class TUIPlugin(Plugin):
|
||||
</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
|
||||
@@ -240,10 +134,8 @@ class TUIPlugin(Plugin):
|
||||
while self.running:
|
||||
char = sys.stdin.read(1)
|
||||
|
||||
if char == '\x03': # Ctrl+C
|
||||
break
|
||||
elif char == '\x04': # Ctrl+D
|
||||
break
|
||||
if char == '\x03': break
|
||||
elif char == '\x04': break
|
||||
elif char == 'q':
|
||||
Log.info("tui", "用户退出 TUI")
|
||||
break
|
||||
@@ -261,7 +153,6 @@ class TUIPlugin(Plugin):
|
||||
self._load_default_pages()
|
||||
self._render_current()
|
||||
elif char == '\n' or char == '\r':
|
||||
# Enter 刷新当前页
|
||||
self._render_current()
|
||||
|
||||
except Exception as e:
|
||||
@@ -269,65 +160,9 @@ class TUIPlugin(Plugin):
|
||||
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>"""
|
||||
html =
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
@@ -335,22 +170,15 @@ class TUIPlugin(Plugin):
|
||||
)
|
||||
|
||||
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"')
|
||||
@@ -363,16 +191,7 @@ class TUIPlugin(Plugin):
|
||||
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>"""
|
||||
error_html =
|
||||
return Response(
|
||||
status=404,
|
||||
headers={"Content-Type": "text/html; charset=utf-8"},
|
||||
@@ -380,113 +199,7 @@ class TUIPlugin(Plugin):
|
||||
)
|
||||
|
||||
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 模拟配置
|
||||
css = // TUI JS 模拟配置
|
||||
// 仅支持基础交互功能
|
||||
|
||||
const TUI = {
|
||||
@@ -515,7 +228,6 @@ const TUI = {
|
||||
|
||||
// 导出配置
|
||||
export default TUI;
|
||||
"""
|
||||
return Response(
|
||||
status=200,
|
||||
headers={"Content-Type": "application/javascript"},
|
||||
@@ -523,85 +235,6 @@ export default TUI;
|
||||
)
|
||||
|
||||
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 = []
|
||||
@@ -621,12 +254,6 @@ 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 停止中...")
|
||||
self.running = False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user