scriptbook 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scriptbook/__init__.py +5 -0
- scriptbook/cli.py +112 -0
- scriptbook/core/__init__.py +0 -0
- scriptbook/core/file_scanner.py +99 -0
- scriptbook/core/markdown_parser.py +160 -0
- scriptbook/core/plugin_manager.py +109 -0
- scriptbook/core/script_executor.py +116 -0
- scriptbook/main.py +70 -0
- scriptbook/models/__init__.py +0 -0
- scriptbook/models/schemas.py +58 -0
- scriptbook/plugins/dark-theme/manifest.json +8 -0
- scriptbook/plugins/dark-theme/style.css +248 -0
- scriptbook/plugins/default/manifest.json +8 -0
- scriptbook/plugins/default/style.css +202 -0
- scriptbook/routers/__init__.py +0 -0
- scriptbook/routers/markdown.py +214 -0
- scriptbook/routers/plugins.py +61 -0
- scriptbook/routers/scripts.py +160 -0
- scriptbook/static/css/main.css +353 -0
- scriptbook/static/index.html +77 -0
- scriptbook/static/js/app.js +352 -0
- scriptbook/static/js/plugin-loader.js +136 -0
- scriptbook/static/plugins/dark-theme/manifest.json +8 -0
- scriptbook/static/plugins/dark-theme/style.css +248 -0
- scriptbook/static/plugins/default/manifest.json +8 -0
- scriptbook/static/plugins/default/style.css +202 -0
- scriptbook-1.0.0.dist-info/METADATA +212 -0
- scriptbook-1.0.0.dist-info/RECORD +30 -0
- scriptbook-1.0.0.dist-info/WHEEL +4 -0
- scriptbook-1.0.0.dist-info/entry_points.txt +2 -0
scriptbook/__init__.py
ADDED
scriptbook/cli.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
命令行入口
|
|
3
|
+
"""
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
import uvicorn
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_app(content_dir: Path):
|
|
12
|
+
"""创建FastAPI应用实例"""
|
|
13
|
+
from scriptbook.main import create_app as _create_app
|
|
14
|
+
|
|
15
|
+
# 设置环境变量传递content目录
|
|
16
|
+
import os
|
|
17
|
+
os.environ['CONTENT_DIR'] = str(content_dir)
|
|
18
|
+
|
|
19
|
+
return _create_app()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
"""主函数"""
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
description='Scriptbook - 可执行脚本的 Markdown 服务器',
|
|
26
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
27
|
+
epilog="""
|
|
28
|
+
示例:
|
|
29
|
+
%(prog)s content/ # 使用content目录启动服务
|
|
30
|
+
%(prog)s /path/to/sop/documents # 使用指定SOP目录
|
|
31
|
+
%(prog)s content/ --port 9000 # 指定端口
|
|
32
|
+
%(prog)s content/ --host 0.0.0.0 # 指定主机(允许外部访问)
|
|
33
|
+
|
|
34
|
+
默认端口: 8000
|
|
35
|
+
默认主机: 127.0.0.1
|
|
36
|
+
|
|
37
|
+
功能特点:
|
|
38
|
+
- 在Markdown中嵌入可执行脚本
|
|
39
|
+
- 每个脚本块可独立执行
|
|
40
|
+
- 实时输出展示
|
|
41
|
+
- 类似Jupyter Notebook的交互体验
|
|
42
|
+
"""
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
'content_dir',
|
|
47
|
+
type=str,
|
|
48
|
+
help='Markdown文件目录路径'
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
'--port',
|
|
53
|
+
type=int,
|
|
54
|
+
default=8000,
|
|
55
|
+
help='服务端口 (默认: 8000)'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
'--host',
|
|
60
|
+
type=str,
|
|
61
|
+
default='127.0.0.1',
|
|
62
|
+
help='服务主机 (默认: 127.0.0.1)'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
'--version',
|
|
67
|
+
action='version',
|
|
68
|
+
version='%(prog)s 1.0.0'
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
args = parser.parse_args()
|
|
72
|
+
|
|
73
|
+
# 验证content目录
|
|
74
|
+
content_path = Path(args.content_dir)
|
|
75
|
+
if not content_path.exists():
|
|
76
|
+
print(f"错误: 目录 '{args.content_dir}' 不存在", file=sys.stderr)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
if not content_path.is_dir():
|
|
80
|
+
print(f"错误: '{args.content_dir}' 不是一个目录", file=sys.stderr)
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
# 创建应用
|
|
84
|
+
try:
|
|
85
|
+
app = create_app(content_path)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"错误: 创建应用失败: {e}", file=sys.stderr)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
# 启动服务
|
|
91
|
+
print(f"启动 Scriptbook - 可执行脚本的 Markdown 服务器")
|
|
92
|
+
print(f"文档目录: {content_path.absolute()}")
|
|
93
|
+
print(f"服务地址: http://{args.host}:{args.port}")
|
|
94
|
+
print(f"访问地址: http://localhost:{args.port}")
|
|
95
|
+
print(f"按 Ctrl+C 停止服务\n")
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
uvicorn.run(
|
|
99
|
+
app,
|
|
100
|
+
host=args.host,
|
|
101
|
+
port=args.port,
|
|
102
|
+
log_level="info"
|
|
103
|
+
)
|
|
104
|
+
except KeyboardInterrupt:
|
|
105
|
+
print("\n服务已停止")
|
|
106
|
+
except Exception as e:
|
|
107
|
+
print(f"错误: 启动服务失败: {e}", file=sys.stderr)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == '__main__':
|
|
112
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import glob
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import List, Dict, Any
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
class FileScanner:
|
|
8
|
+
"""文件扫描器,用于扫描markdown文件"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, content_dir: str = "content"):
|
|
11
|
+
self.content_dir = content_dir
|
|
12
|
+
self._cache = None
|
|
13
|
+
self._cache_time = 0
|
|
14
|
+
self._cache_ttl = 10 # 缓存时间(秒)
|
|
15
|
+
|
|
16
|
+
def scan_files(self, force_refresh: bool = False) -> List[Dict[str, Any]]:
|
|
17
|
+
"""
|
|
18
|
+
扫描content目录下的所有markdown文件
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
force_refresh: 是否强制刷新缓存
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
文件信息列表,每个元素包含name, size, modified
|
|
25
|
+
"""
|
|
26
|
+
# 检查缓存
|
|
27
|
+
current_time = time.time()
|
|
28
|
+
if (not force_refresh and self._cache is not None and
|
|
29
|
+
current_time - self._cache_time < self._cache_ttl):
|
|
30
|
+
return self._cache
|
|
31
|
+
|
|
32
|
+
files = []
|
|
33
|
+
try:
|
|
34
|
+
# 确保目录存在
|
|
35
|
+
if not os.path.exists(self.content_dir):
|
|
36
|
+
os.makedirs(self.content_dir, exist_ok=True)
|
|
37
|
+
return files
|
|
38
|
+
|
|
39
|
+
# 扫描.md文件
|
|
40
|
+
pattern = os.path.join(self.content_dir, "*.md")
|
|
41
|
+
for filepath in glob.glob(pattern):
|
|
42
|
+
try:
|
|
43
|
+
stat = os.stat(filepath)
|
|
44
|
+
filename = os.path.basename(filepath)
|
|
45
|
+
|
|
46
|
+
files.append({
|
|
47
|
+
"name": filename,
|
|
48
|
+
"size": stat.st_size,
|
|
49
|
+
"modified": datetime.fromtimestamp(stat.st_mtime)
|
|
50
|
+
})
|
|
51
|
+
except Exception as e:
|
|
52
|
+
print(f"读取文件信息失败 {filepath}: {e}")
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
# 按文件名排序
|
|
56
|
+
files.sort(key=lambda x: x["name"])
|
|
57
|
+
|
|
58
|
+
# 更新缓存和时间戳
|
|
59
|
+
self._cache = files
|
|
60
|
+
self._cache_time = current_time
|
|
61
|
+
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print(f"扫描文件失败: {e}")
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
return files
|
|
67
|
+
|
|
68
|
+
def get_file_content(self, filename: str) -> str:
|
|
69
|
+
"""
|
|
70
|
+
获取指定文件的内容
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
filename: 文件名
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
文件内容
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
FileNotFoundError: 文件不存在
|
|
80
|
+
ValueError: 文件名不合法
|
|
81
|
+
"""
|
|
82
|
+
# 安全检查
|
|
83
|
+
if not filename.endswith(".md"):
|
|
84
|
+
raise ValueError("文件必须是markdown文件 (.md)")
|
|
85
|
+
|
|
86
|
+
# 防止目录遍历攻击
|
|
87
|
+
if ".." in filename or "/" in filename or "\\" in filename:
|
|
88
|
+
raise ValueError("文件名不合法")
|
|
89
|
+
|
|
90
|
+
filepath = os.path.join(self.content_dir, filename)
|
|
91
|
+
if not os.path.exists(filepath):
|
|
92
|
+
raise FileNotFoundError(f"文件不存在: {filename}")
|
|
93
|
+
|
|
94
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
95
|
+
return f.read()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# 创建全局文件扫描器实例
|
|
99
|
+
file_scanner = FileScanner()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import json
|
|
3
|
+
from typing import List, Dict, Any, Tuple
|
|
4
|
+
from scriptbook.models.schemas import ScriptBlock
|
|
5
|
+
|
|
6
|
+
class MarkdownParser:
|
|
7
|
+
"""Markdown解析器,支持特殊脚本语法"""
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.script_pattern = re.compile(
|
|
11
|
+
r'```(bash|sh|shell)\s*(\{[^}]*\})?\s*\n(.*?)\n\s*```',
|
|
12
|
+
re.DOTALL
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def extract_scripts(self, markdown_text: str) -> Tuple[str, List[ScriptBlock]]:
|
|
16
|
+
"""
|
|
17
|
+
从markdown文本中提取脚本块
|
|
18
|
+
|
|
19
|
+
返回:
|
|
20
|
+
- 清理后的markdown文本(脚本块被替换为占位符)
|
|
21
|
+
- 脚本块列表
|
|
22
|
+
"""
|
|
23
|
+
scripts = []
|
|
24
|
+
cleaned_text = markdown_text
|
|
25
|
+
offset = 0
|
|
26
|
+
|
|
27
|
+
for match in self.script_pattern.finditer(markdown_text):
|
|
28
|
+
language = match.group(1)
|
|
29
|
+
metadata_str = match.group(2)
|
|
30
|
+
code = match.group(3).strip()
|
|
31
|
+
|
|
32
|
+
# 处理language为None的情况
|
|
33
|
+
if language is None:
|
|
34
|
+
language = "text"
|
|
35
|
+
|
|
36
|
+
script_id = f'script_{len(scripts)}'
|
|
37
|
+
title = f'{language}脚本' # 默认title
|
|
38
|
+
|
|
39
|
+
# 如果有metadata字符串,尝试解析
|
|
40
|
+
if metadata_str and metadata_str.strip():
|
|
41
|
+
try:
|
|
42
|
+
metadata = json.loads(metadata_str)
|
|
43
|
+
# 如果解析成功,使用metadata中的值
|
|
44
|
+
if 'id' in metadata:
|
|
45
|
+
script_id = metadata['id']
|
|
46
|
+
if 'title' in metadata:
|
|
47
|
+
title = metadata['title']
|
|
48
|
+
else:
|
|
49
|
+
# 有metadata但没有title,使用智能生成
|
|
50
|
+
title = self._generate_title(code, language)
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
# JSON解析失败,使用默认title
|
|
53
|
+
pass
|
|
54
|
+
else:
|
|
55
|
+
# 没有metadata,使用智能生成title
|
|
56
|
+
title = self._generate_title(code, language)
|
|
57
|
+
|
|
58
|
+
script_block = ScriptBlock(
|
|
59
|
+
id=script_id,
|
|
60
|
+
title=title,
|
|
61
|
+
language=language,
|
|
62
|
+
code=code,
|
|
63
|
+
line_start=match.start() - offset,
|
|
64
|
+
line_end=match.end() - offset
|
|
65
|
+
)
|
|
66
|
+
scripts.append(script_block)
|
|
67
|
+
|
|
68
|
+
# 用占位符替换脚本块
|
|
69
|
+
placeholder = f'\n\n[脚本块: {script_id} - {title}]\n\n'
|
|
70
|
+
cleaned_text = (
|
|
71
|
+
cleaned_text[:match.start() - offset] +
|
|
72
|
+
placeholder +
|
|
73
|
+
cleaned_text[match.end() - offset:]
|
|
74
|
+
)
|
|
75
|
+
offset += len(match.group(0)) - len(placeholder)
|
|
76
|
+
|
|
77
|
+
return cleaned_text, scripts
|
|
78
|
+
|
|
79
|
+
def _generate_title(self, code: str, language: str) -> str:
|
|
80
|
+
"""
|
|
81
|
+
从代码中智能生成title
|
|
82
|
+
"""
|
|
83
|
+
lines = code.strip().split('\n')
|
|
84
|
+
first_line = lines[0].strip() if lines else ''
|
|
85
|
+
|
|
86
|
+
# 尝试从echo命令提取引号内的内容
|
|
87
|
+
echo_match = re.search(r'echo\s+["\']([^"\']+)["\']', first_line, re.IGNORECASE)
|
|
88
|
+
if echo_match:
|
|
89
|
+
return echo_match.group(1)
|
|
90
|
+
|
|
91
|
+
# 尝试从printf命令提取引号内的内容
|
|
92
|
+
printf_match = re.search(r'printf\s+["\']([^"\']+)["\']', first_line, re.IGNORECASE)
|
|
93
|
+
if printf_match:
|
|
94
|
+
return printf_match.group(1)
|
|
95
|
+
|
|
96
|
+
# 如果是cat命令,尝试获取文件名
|
|
97
|
+
cat_match = re.search(r'cat\s+(\S+)', first_line)
|
|
98
|
+
if cat_match:
|
|
99
|
+
return f"读取文件: {cat_match.group(1)}"
|
|
100
|
+
|
|
101
|
+
# 如果是ls命令,生成描述
|
|
102
|
+
if re.match(r'^\s*ls\b', first_line, re.IGNORECASE):
|
|
103
|
+
if '-l' in first_line or '-la' in first_line:
|
|
104
|
+
return "列出文件详情"
|
|
105
|
+
return "列出文件"
|
|
106
|
+
|
|
107
|
+
# 如果是cd命令,生成描述
|
|
108
|
+
cd_match = re.search(r'cd\s+(\S+)', first_line)
|
|
109
|
+
if cd_match:
|
|
110
|
+
return f"切换目录: {cd_match.group(1)}"
|
|
111
|
+
|
|
112
|
+
# 如果是git命令,生成描述
|
|
113
|
+
git_match = re.search(r'git\s+(\w+)', first_line)
|
|
114
|
+
if git_match:
|
|
115
|
+
git_actions = {
|
|
116
|
+
'clone': '克隆仓库',
|
|
117
|
+
'pull': '更新代码',
|
|
118
|
+
'push': '推送代码',
|
|
119
|
+
'commit': '提交代码',
|
|
120
|
+
'status': '查看状态',
|
|
121
|
+
'checkout': '切换分支',
|
|
122
|
+
}
|
|
123
|
+
action = git_match.group(1)
|
|
124
|
+
return git_actions.get(action, f'Git操作: {action}')
|
|
125
|
+
|
|
126
|
+
# 如果第一行不超过30个字符,直接使用
|
|
127
|
+
if len(first_line) <= 30:
|
|
128
|
+
# 清理引号
|
|
129
|
+
first_line = first_line.strip('"').strip("'")
|
|
130
|
+
return first_line
|
|
131
|
+
|
|
132
|
+
# 否则取前几个词作为title
|
|
133
|
+
words = first_line.split()[:4]
|
|
134
|
+
return ' '.join(words) + ('...' if len(first_line.split()) > 4 else '')
|
|
135
|
+
|
|
136
|
+
def parse(self, markdown_text: str) -> Dict[str, Any]:
|
|
137
|
+
"""
|
|
138
|
+
解析markdown文本,返回解析结果
|
|
139
|
+
|
|
140
|
+
返回:
|
|
141
|
+
{
|
|
142
|
+
"html": "渲染后的HTML",
|
|
143
|
+
"scripts": [脚本块列表]
|
|
144
|
+
}
|
|
145
|
+
"""
|
|
146
|
+
# 提取脚本块
|
|
147
|
+
cleaned_text, scripts = self.extract_scripts(markdown_text)
|
|
148
|
+
|
|
149
|
+
# 这里应该使用markdown库渲染cleaned_text
|
|
150
|
+
# 暂时返回原始文本
|
|
151
|
+
html = cleaned_text
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"html": html,
|
|
155
|
+
"scripts": scripts
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# 创建全局解析器实例
|
|
160
|
+
parser = MarkdownParser()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import copy
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
from scriptbook.models.schemas import PluginInfo
|
|
6
|
+
|
|
7
|
+
class PluginManager:
|
|
8
|
+
"""插件管理器"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, plugins_dir: str = "app/plugins"):
|
|
11
|
+
self.plugins_dir = plugins_dir
|
|
12
|
+
self._plugins_cache = None
|
|
13
|
+
self._active_plugins = []
|
|
14
|
+
|
|
15
|
+
def scan_plugins(self, force_refresh: bool = False) -> List[PluginInfo]:
|
|
16
|
+
"""扫描插件目录,返回所有可用插件"""
|
|
17
|
+
# 如果有缓存且不强制刷新,返回缓存的副本
|
|
18
|
+
if not force_refresh and self._plugins_cache is not None:
|
|
19
|
+
return copy.deepcopy(self._plugins_cache)
|
|
20
|
+
|
|
21
|
+
plugins = []
|
|
22
|
+
|
|
23
|
+
if not os.path.exists(self.plugins_dir):
|
|
24
|
+
os.makedirs(self.plugins_dir, exist_ok=True)
|
|
25
|
+
return plugins
|
|
26
|
+
|
|
27
|
+
for plugin_name in os.listdir(self.plugins_dir):
|
|
28
|
+
plugin_path = os.path.join(self.plugins_dir, plugin_name)
|
|
29
|
+
|
|
30
|
+
# 必须是目录
|
|
31
|
+
if not os.path.isdir(plugin_path):
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
manifest_path = os.path.join(plugin_path, "manifest.json")
|
|
35
|
+
|
|
36
|
+
# 必须有manifest.json文件
|
|
37
|
+
if not os.path.exists(manifest_path):
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
42
|
+
manifest = json.load(f)
|
|
43
|
+
|
|
44
|
+
# 验证必要字段
|
|
45
|
+
if "name" not in manifest:
|
|
46
|
+
manifest["name"] = plugin_name
|
|
47
|
+
|
|
48
|
+
plugin_info = PluginInfo(
|
|
49
|
+
name=manifest.get("name", plugin_name),
|
|
50
|
+
version=manifest.get("version", "1.0.0"),
|
|
51
|
+
description=manifest.get("description", ""),
|
|
52
|
+
type=manifest.get("type", "theme"),
|
|
53
|
+
css=manifest.get("css"),
|
|
54
|
+
js=manifest.get("js")
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
plugins.append(plugin_info)
|
|
58
|
+
|
|
59
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
60
|
+
print(f"加载插件 {plugin_name} 失败: {e}")
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
# 更新缓存
|
|
64
|
+
self._plugins_cache = plugins
|
|
65
|
+
return copy.deepcopy(plugins)
|
|
66
|
+
|
|
67
|
+
def get_plugin(self, plugin_name: str) -> Optional[PluginInfo]:
|
|
68
|
+
"""获取指定插件信息"""
|
|
69
|
+
plugins = self.scan_plugins(force_refresh=True)
|
|
70
|
+
for plugin in plugins:
|
|
71
|
+
if plugin.name == plugin_name:
|
|
72
|
+
return plugin
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def activate_plugin(self, plugin_name: str) -> bool:
|
|
76
|
+
"""激活插件"""
|
|
77
|
+
plugin = self.get_plugin(plugin_name)
|
|
78
|
+
if not plugin:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
# 避免重复激活
|
|
82
|
+
if plugin_name not in self._active_plugins:
|
|
83
|
+
self._active_plugins.append(plugin_name)
|
|
84
|
+
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
def deactivate_plugin(self, plugin_name: str) -> bool:
|
|
88
|
+
"""停用插件"""
|
|
89
|
+
if plugin_name in self._active_plugins:
|
|
90
|
+
self._active_plugins.remove(plugin_name)
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
def get_active_plugins(self) -> List[str]:
|
|
95
|
+
"""获取当前激活的插件列表"""
|
|
96
|
+
return self._active_plugins.copy()
|
|
97
|
+
|
|
98
|
+
def get_active_plugins_info(self) -> List[PluginInfo]:
|
|
99
|
+
"""获取当前激活插件的详细信息"""
|
|
100
|
+
active_plugins = []
|
|
101
|
+
for plugin_name in self._active_plugins:
|
|
102
|
+
plugin = self.get_plugin(plugin_name)
|
|
103
|
+
if plugin:
|
|
104
|
+
active_plugins.append(plugin)
|
|
105
|
+
return active_plugins
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# 创建全局插件管理器实例
|
|
109
|
+
plugin_manager = PluginManager()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import subprocess
|
|
3
|
+
from typing import AsyncGenerator, Dict, Any
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
class ScriptExecutor:
|
|
7
|
+
"""脚本执行器,用于执行bash脚本"""
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self._processes: Dict[str, asyncio.subprocess.Process] = {}
|
|
11
|
+
|
|
12
|
+
async def execute(
|
|
13
|
+
self,
|
|
14
|
+
script_id: str,
|
|
15
|
+
code: str,
|
|
16
|
+
timeout: int = 30
|
|
17
|
+
) -> AsyncGenerator[Dict[str, Any], None]:
|
|
18
|
+
"""
|
|
19
|
+
异步执行bash脚本
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
script_id: 脚本ID
|
|
23
|
+
code: 脚本代码
|
|
24
|
+
timeout: 超时时间(秒)
|
|
25
|
+
|
|
26
|
+
Yields:
|
|
27
|
+
输出消息字典,包含type, content, timestamp
|
|
28
|
+
"""
|
|
29
|
+
process = None
|
|
30
|
+
terminated = False
|
|
31
|
+
try:
|
|
32
|
+
# 创建子进程
|
|
33
|
+
process = await asyncio.create_subprocess_shell(
|
|
34
|
+
code,
|
|
35
|
+
stdout=subprocess.PIPE,
|
|
36
|
+
stderr=subprocess.PIPE,
|
|
37
|
+
shell=True
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# 存储进程引用
|
|
41
|
+
self._processes[script_id] = process
|
|
42
|
+
|
|
43
|
+
# 读取输出的协程
|
|
44
|
+
async def read_output(stream, output_type):
|
|
45
|
+
while True:
|
|
46
|
+
line = await stream.readline()
|
|
47
|
+
if not line:
|
|
48
|
+
break
|
|
49
|
+
yield {
|
|
50
|
+
"type": output_type,
|
|
51
|
+
"content": line.decode("utf-8", errors="replace").rstrip(),
|
|
52
|
+
"timestamp": datetime.now().isoformat()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# 同时读取stdout和stderr
|
|
56
|
+
stdout_reader = read_output(process.stdout, "stdout")
|
|
57
|
+
stderr_reader = read_output(process.stderr, "stderr")
|
|
58
|
+
|
|
59
|
+
# 合并输出流
|
|
60
|
+
async for output in self._merge_outputs(stdout_reader, stderr_reader):
|
|
61
|
+
yield output
|
|
62
|
+
|
|
63
|
+
# 使用wait_for设置超时
|
|
64
|
+
try:
|
|
65
|
+
returncode = await asyncio.wait_for(process.wait(), timeout=timeout)
|
|
66
|
+
yield {
|
|
67
|
+
"type": "exit",
|
|
68
|
+
"content": f"进程退出,返回码: {returncode}",
|
|
69
|
+
"timestamp": datetime.now().isoformat()
|
|
70
|
+
}
|
|
71
|
+
except asyncio.TimeoutError:
|
|
72
|
+
# 超时,终止进程
|
|
73
|
+
terminated = True
|
|
74
|
+
process.terminate()
|
|
75
|
+
yield {
|
|
76
|
+
"type": "error",
|
|
77
|
+
"content": f"脚本执行超时 ({timeout}秒),进程已终止",
|
|
78
|
+
"timestamp": datetime.now().isoformat()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
except Exception as e:
|
|
82
|
+
yield {
|
|
83
|
+
"type": "error",
|
|
84
|
+
"content": f"执行错误: {str(e)}",
|
|
85
|
+
"timestamp": datetime.now().isoformat()
|
|
86
|
+
}
|
|
87
|
+
finally:
|
|
88
|
+
# 清理进程引用
|
|
89
|
+
if script_id in self._processes:
|
|
90
|
+
del self._processes[script_id]
|
|
91
|
+
# 只在未终止且进程仍在运行时才终止
|
|
92
|
+
if process and not terminated and process.returncode is None:
|
|
93
|
+
try:
|
|
94
|
+
process.terminate()
|
|
95
|
+
except:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
async def _merge_outputs(self, *generators):
|
|
99
|
+
"""合并多个异步生成器的输出"""
|
|
100
|
+
# 简化实现:按顺序读取
|
|
101
|
+
# 实际应该使用asyncio.Queue
|
|
102
|
+
for gen in generators:
|
|
103
|
+
async for item in gen:
|
|
104
|
+
yield item
|
|
105
|
+
|
|
106
|
+
def kill_process(self, script_id: str):
|
|
107
|
+
"""终止指定脚本的进程"""
|
|
108
|
+
if script_id in self._processes:
|
|
109
|
+
process = self._processes[script_id]
|
|
110
|
+
if process.returncode is None:
|
|
111
|
+
process.terminate()
|
|
112
|
+
del self._processes[script_id]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# 创建全局脚本执行器实例
|
|
116
|
+
executor = ScriptExecutor()
|
scriptbook/main.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI应用入口
|
|
3
|
+
"""
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.staticfiles import StaticFiles
|
|
6
|
+
from fastapi.responses import FileResponse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_app(content_dir: Path = None) -> FastAPI:
|
|
12
|
+
"""
|
|
13
|
+
创建FastAPI应用实例
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
content_dir: Markdown文件目录,如果为None则使用环境变量CONTENT_DIR或默认content目录
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
FastAPI应用实例
|
|
20
|
+
"""
|
|
21
|
+
# 确定content目录
|
|
22
|
+
if content_dir is None:
|
|
23
|
+
content_dir = os.environ.get('CONTENT_DIR', 'content')
|
|
24
|
+
content_dir = Path(content_dir)
|
|
25
|
+
|
|
26
|
+
# 创建FastAPI应用
|
|
27
|
+
app = FastAPI(
|
|
28
|
+
title="Scriptbook - 可执行脚本的 Markdown 服务器",
|
|
29
|
+
description="支持脚本执行的在线 Markdown 服务器,可用于SOP自动化和交互式文档",
|
|
30
|
+
version="1.0.0"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# 设置content目录到应用状态
|
|
34
|
+
app.state.content_dir = str(content_dir)
|
|
35
|
+
|
|
36
|
+
# 设置插件目录到应用状态
|
|
37
|
+
# 优先使用环境变量,否则使用相对于当前模块目录的路径
|
|
38
|
+
base_dir = Path(__file__).parent
|
|
39
|
+
plugins_dir = base_dir / "static" / "plugins"
|
|
40
|
+
app.state.plugins_dir = str(plugins_dir)
|
|
41
|
+
|
|
42
|
+
# 导入路由
|
|
43
|
+
from scriptbook.routers import markdown, scripts, plugins
|
|
44
|
+
|
|
45
|
+
# 包含路由
|
|
46
|
+
app.include_router(markdown.router, prefix="/api/markdown")
|
|
47
|
+
app.include_router(scripts.router, prefix="/api")
|
|
48
|
+
app.include_router(plugins.router, prefix="/api/plugins")
|
|
49
|
+
|
|
50
|
+
# 静态文件目录
|
|
51
|
+
static_dir = base_dir / "static"
|
|
52
|
+
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
53
|
+
|
|
54
|
+
# 根路由 - 返回主页面
|
|
55
|
+
@app.get("/")
|
|
56
|
+
async def read_root():
|
|
57
|
+
return FileResponse(str(static_dir / "index.html"))
|
|
58
|
+
|
|
59
|
+
# 健康检查端点
|
|
60
|
+
@app.get("/health")
|
|
61
|
+
async def health_check():
|
|
62
|
+
return {
|
|
63
|
+
"status": "healthy",
|
|
64
|
+
"service": "scriptbook",
|
|
65
|
+
"version": "1.0.0",
|
|
66
|
+
"description": "Scriptbook - 可执行脚本的 Markdown 服务器"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return app
|
|
70
|
+
|
|
File without changes
|