修复了一些错误 更新了AI.md(给ai看的)

This commit is contained in:
Falck
2026-05-02 19:21:50 +08:00
parent 0783428f80
commit 70c531860b
240 changed files with 5626 additions and 10790 deletions

Binary file not shown.

View File

@@ -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,

View File

@@ -1,11 +0,0 @@
"""核心模块"""
from .context import Context
# 配置验证器(内部使用)
# 注意:该模块包含系统完整性检查功能
try:
from .achievements import get_validator, init_achievements
except ImportError:
pass
__all__ = ["Context"]

View File

@@ -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:

View File

@@ -1,8 +0,0 @@
"""Base plugin module for backward compatibility."""
from oss.plugin.types import Plugin
# Alias for backward compatibility
BasePlugin = Plugin
__all__ = ['BasePlugin']

View File

@@ -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():

View File

@@ -1,4 +0,0 @@
"""共享工具模块"""
from .router import BaseRoute, BaseRouter, match_path, extract_path_params
__all__ = ["BaseRoute", "BaseRouter", "match_path", "extract_path_params"]

View File

@@ -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)

View File

@@ -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
View 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
View 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
View 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
View 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
View 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'])

View File

@@ -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

View 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'])

View File

@@ -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',

View File

@@ -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

View File

@@ -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' 退出 TUIWebUI 仍在运行")
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 入口点
返回特殊标记的 HTMLTUI 转换层会识别并转换。
此 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