@ww_nero/mini-cli 1.0.56
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.
- package/bin/mini.js +3 -0
- package/package.json +38 -0
- package/src/chat.js +1008 -0
- package/src/config.js +371 -0
- package/src/index.js +38 -0
- package/src/llm.js +147 -0
- package/src/prompt/tool.js +18 -0
- package/src/request.js +328 -0
- package/src/tools/bash.js +241 -0
- package/src/tools/convert.js +297 -0
- package/src/tools/index.js +66 -0
- package/src/tools/mcp.js +478 -0
- package/src/tools/python/html_to_png.py +100 -0
- package/src/tools/python/html_to_pptx.py +163 -0
- package/src/tools/python/pdf_to_png.py +58 -0
- package/src/tools/python/pptx_to_pdf.py +107 -0
- package/src/tools/read.js +44 -0
- package/src/tools/replace.js +135 -0
- package/src/tools/todos.js +90 -0
- package/src/tools/write.js +52 -0
- package/src/utils/cliOptions.js +8 -0
- package/src/utils/commands.js +89 -0
- package/src/utils/git.js +89 -0
- package/src/utils/helpers.js +93 -0
- package/src/utils/history.js +181 -0
- package/src/utils/model.js +127 -0
- package/src/utils/output.js +76 -0
- package/src/utils/renderer.js +92 -0
- package/src/utils/settings.js +90 -0
- package/src/utils/think.js +211 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Convert multiple HTML files into a single PPTX via Playwright screenshots."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable, List
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from playwright.sync_api import sync_playwright
|
|
12
|
+
from pptx import Presentation
|
|
13
|
+
from pptx.util import Inches
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SLIDE_SIZE_43 = (Inches(10), Inches(7.5)) # 4:3
|
|
17
|
+
SLIDE_SIZE_169 = (Inches(13.3333), Inches(7.5)) # 16:9
|
|
18
|
+
MIN_WIDTH = 320
|
|
19
|
+
MIN_HEIGHT = 240
|
|
20
|
+
DEVICE_SCALE_FACTOR = 4
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _find_target_element(page):
|
|
24
|
+
body = page.query_selector('body')
|
|
25
|
+
if body is None:
|
|
26
|
+
raise ValueError('未找到 body 元素')
|
|
27
|
+
|
|
28
|
+
top_level_elements = body.query_selector_all(':scope > *')
|
|
29
|
+
if not top_level_elements:
|
|
30
|
+
raise ValueError('body 中未找到可截图元素')
|
|
31
|
+
|
|
32
|
+
# 单个最外层节点时直接使用该节点,否则使用整个 body
|
|
33
|
+
return top_level_elements[0] if len(top_level_elements) == 1 else body
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _measure_element(element):
|
|
37
|
+
size = element.evaluate(
|
|
38
|
+
"""
|
|
39
|
+
(el) => {
|
|
40
|
+
const rect = el.getBoundingClientRect();
|
|
41
|
+
const width = Math.max(rect.width || 0, el.scrollWidth || 0, el.offsetWidth || 0);
|
|
42
|
+
const height = Math.max(rect.height || 0, el.scrollHeight || 0, el.offsetHeight || 0);
|
|
43
|
+
return { width, height };
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
)
|
|
47
|
+
width = max(1, int(size.get('width') or 0))
|
|
48
|
+
height = max(1, int(size.get('height') or 0))
|
|
49
|
+
return width, height
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _choose_slide_size(aspect_ratios: Iterable[float]):
|
|
53
|
+
ratios = [ratio for ratio in aspect_ratios if ratio > 0]
|
|
54
|
+
if not ratios:
|
|
55
|
+
return SLIDE_SIZE_43
|
|
56
|
+
average_ratio = sum(ratios) / len(ratios)
|
|
57
|
+
diff_43 = abs(average_ratio - (4 / 3))
|
|
58
|
+
diff_169 = abs(average_ratio - (16 / 9))
|
|
59
|
+
return SLIDE_SIZE_43 if diff_43 <= diff_169 else SLIDE_SIZE_169
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def capture_html_to_image(page, html_path: Path, target_path: Path, wait_ms: int) -> float:
|
|
63
|
+
file_url = html_path.as_uri()
|
|
64
|
+
page.goto(file_url)
|
|
65
|
+
page.wait_for_timeout(wait_ms)
|
|
66
|
+
target = _find_target_element(page)
|
|
67
|
+
element_width, element_height = _measure_element(target)
|
|
68
|
+
viewport_width = max(MIN_WIDTH, element_width)
|
|
69
|
+
viewport_height = max(MIN_HEIGHT, element_height)
|
|
70
|
+
page.set_viewport_size({'width': viewport_width, 'height': viewport_height})
|
|
71
|
+
page.wait_for_timeout(200)
|
|
72
|
+
target.screenshot(path=str(target_path))
|
|
73
|
+
return element_width / element_height if element_height else 1.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_presentation(images: Iterable[Path], output_file: Path, slide_size) -> Path:
|
|
77
|
+
slide_width, slide_height = slide_size
|
|
78
|
+
presentation = Presentation()
|
|
79
|
+
presentation.slide_width = slide_width
|
|
80
|
+
presentation.slide_height = slide_height
|
|
81
|
+
|
|
82
|
+
# Remove default slide if present
|
|
83
|
+
while len(presentation.slides) > 0:
|
|
84
|
+
slide_id = presentation.slides._sldIdLst[0].rId
|
|
85
|
+
presentation.part.drop_rel(slide_id)
|
|
86
|
+
del presentation.slides._sldIdLst[0]
|
|
87
|
+
|
|
88
|
+
layout = presentation.slide_layouts[6]
|
|
89
|
+
for image_path in images:
|
|
90
|
+
with Image.open(image_path) as image:
|
|
91
|
+
image_ratio = image.width / image.height if image.height else 1.0
|
|
92
|
+
slide_ratio = slide_width / slide_height if slide_height else image_ratio
|
|
93
|
+
if slide_ratio >= image_ratio:
|
|
94
|
+
target_height = slide_height
|
|
95
|
+
target_width = int(target_height * image_ratio)
|
|
96
|
+
else:
|
|
97
|
+
target_width = slide_width
|
|
98
|
+
target_height = int(target_width / image_ratio)
|
|
99
|
+
left = max(0, (slide_width - target_width) // 2)
|
|
100
|
+
top = max(0, (slide_height - target_height) // 2)
|
|
101
|
+
slide = presentation.slides.add_slide(layout)
|
|
102
|
+
slide.shapes.add_picture(str(image_path), left, top, width=target_width, height=target_height)
|
|
103
|
+
|
|
104
|
+
presentation.save(str(output_file))
|
|
105
|
+
return output_file
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def convert_html_list_to_pptx(html_files: Iterable[str | Path], output_file: str | Path, wait_ms: int = 1500) -> Path:
|
|
109
|
+
html_paths = [Path(item).expanduser().resolve() for item in html_files]
|
|
110
|
+
if not html_paths:
|
|
111
|
+
raise ValueError('请提供至少一个 HTML 文件')
|
|
112
|
+
for path_item in html_paths:
|
|
113
|
+
if not path_item.exists():
|
|
114
|
+
raise FileNotFoundError(f'HTML 文件不存在: {path_item}')
|
|
115
|
+
|
|
116
|
+
output_path = Path(output_file).expanduser().resolve()
|
|
117
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
screenshots: List[Path] = []
|
|
120
|
+
aspect_ratios: List[float] = []
|
|
121
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
122
|
+
temp_root = Path(temp_dir)
|
|
123
|
+
with sync_playwright() as playwright:
|
|
124
|
+
browser = playwright.chromium.launch(headless=True)
|
|
125
|
+
context = browser.new_context(
|
|
126
|
+
viewport={'width': MIN_WIDTH, 'height': MIN_HEIGHT},
|
|
127
|
+
device_scale_factor=DEVICE_SCALE_FACTOR
|
|
128
|
+
)
|
|
129
|
+
page = context.new_page()
|
|
130
|
+
try:
|
|
131
|
+
for index, html_path in enumerate(html_paths, start=1):
|
|
132
|
+
target_file = temp_root / f'slide_{index:04d}.png'
|
|
133
|
+
ratio = capture_html_to_image(page, html_path, target_file, wait_ms)
|
|
134
|
+
screenshots.append(target_file)
|
|
135
|
+
aspect_ratios.append(ratio)
|
|
136
|
+
finally:
|
|
137
|
+
context.close()
|
|
138
|
+
browser.close()
|
|
139
|
+
slide_size = _choose_slide_size(aspect_ratios)
|
|
140
|
+
build_presentation(screenshots, output_path, slide_size)
|
|
141
|
+
return output_path
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_args() -> argparse.Namespace:
|
|
145
|
+
parser = argparse.ArgumentParser(description='将多个 HTML 文件合并到单个 PPTX 中。')
|
|
146
|
+
parser.add_argument('--inputs', nargs='+', required=True, help='HTML 文件路径列表')
|
|
147
|
+
parser.add_argument('--output', required=True, help='输出 PPTX 文件路径')
|
|
148
|
+
parser.add_argument('--wait', type=int, default=1500, help='每个页面加载等待时间,单位毫秒,默认 1500')
|
|
149
|
+
return parser.parse_args()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main() -> None:
|
|
153
|
+
args = _parse_args()
|
|
154
|
+
try:
|
|
155
|
+
result = convert_html_list_to_pptx(args.inputs, args.output, wait_ms=args.wait)
|
|
156
|
+
print(f'已生成 PPTX: {result}')
|
|
157
|
+
except Exception as error:
|
|
158
|
+
print(f'转换失败: {error}', file=sys.stderr)
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == '__main__':
|
|
163
|
+
main()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Convert PDF pages to PNG files."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
from pdf2image import convert_from_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def convert_pdf_to_png(input_file: str | Path, output_dir: str | Path, dpi: int = 300, fmt: str = 'png') -> List[Path]:
|
|
13
|
+
"""Convert a PDF into PNG images and return saved file paths."""
|
|
14
|
+
input_path = Path(input_file).expanduser().resolve()
|
|
15
|
+
if not input_path.exists():
|
|
16
|
+
raise FileNotFoundError(f'PDF文件不存在: {input_path}')
|
|
17
|
+
|
|
18
|
+
output_path = Path(output_dir).expanduser().resolve()
|
|
19
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
format_lower = fmt.lower() or 'png'
|
|
22
|
+
images = convert_from_path(str(input_path), dpi=dpi, fmt=format_lower)
|
|
23
|
+
|
|
24
|
+
saved_files: List[Path] = []
|
|
25
|
+
for index, image in enumerate(images, start=1):
|
|
26
|
+
file_name = f'page_{index:04d}.{format_lower}'
|
|
27
|
+
target = output_path / file_name
|
|
28
|
+
image.save(str(target), format_lower.upper())
|
|
29
|
+
saved_files.append(target)
|
|
30
|
+
|
|
31
|
+
return saved_files
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_args() -> argparse.Namespace:
|
|
35
|
+
parser = argparse.ArgumentParser(description='将 PDF 转换为 PNG 图片。')
|
|
36
|
+
parser.add_argument('--input', required=True, help='待转换的 PDF 文件路径')
|
|
37
|
+
parser.add_argument('--output-dir', required=True, help='保存图片的目录')
|
|
38
|
+
parser.add_argument('--dpi', type=int, default=300, help='输出图片的 DPI,默认 300')
|
|
39
|
+
parser.add_argument('--format', default='png', help='输出图片格式,默认 PNG')
|
|
40
|
+
return parser.parse_args()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> None:
|
|
44
|
+
args = _parse_args()
|
|
45
|
+
try:
|
|
46
|
+
results = convert_pdf_to_png(args.input, args.output_dir, dpi=args.dpi, fmt=args.format)
|
|
47
|
+
if not results:
|
|
48
|
+
print('未生成任何图片')
|
|
49
|
+
return
|
|
50
|
+
print(f'成功生成 {len(results)} 张图片:')
|
|
51
|
+
for path in results:
|
|
52
|
+
print(path)
|
|
53
|
+
except Exception as error:
|
|
54
|
+
print(f'转换失败: {error}', file=sys.stderr)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
if __name__ == '__main__':
|
|
58
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""使用 LibreOffice 无头模式将 PPT/PPTX 直接转换为 PDF。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _detect_libreoffice() -> str:
|
|
15
|
+
"""寻找 LibreOffice 可执行文件路径。"""
|
|
16
|
+
system = platform.system()
|
|
17
|
+
candidates: list[str] = []
|
|
18
|
+
|
|
19
|
+
if system == 'Darwin':
|
|
20
|
+
candidates.append('/Applications/LibreOffice.app/Contents/MacOS/soffice')
|
|
21
|
+
elif system == 'Windows':
|
|
22
|
+
program_dirs = [
|
|
23
|
+
os.environ.get('PROGRAMFILES', ''),
|
|
24
|
+
os.environ.get('PROGRAMFILES(X86)', '')
|
|
25
|
+
]
|
|
26
|
+
for base in program_dirs:
|
|
27
|
+
if base:
|
|
28
|
+
candidates.append(str(Path(base) / 'LibreOffice' / 'program' / 'soffice.exe'))
|
|
29
|
+
|
|
30
|
+
# 通用命令放在末尾,优先尝试绝对路径
|
|
31
|
+
candidates.extend(['soffice', 'libreoffice'])
|
|
32
|
+
|
|
33
|
+
for candidate in candidates:
|
|
34
|
+
candidate_path = Path(candidate)
|
|
35
|
+
if candidate_path.exists():
|
|
36
|
+
return str(candidate_path)
|
|
37
|
+
|
|
38
|
+
resolved = shutil.which(candidate)
|
|
39
|
+
if resolved:
|
|
40
|
+
return resolved
|
|
41
|
+
|
|
42
|
+
return ''
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pptx_to_pdf(input_file: str | Path, output_file: str | Path) -> Path:
|
|
46
|
+
pptx_path = Path(input_file).expanduser().resolve()
|
|
47
|
+
if not pptx_path.exists():
|
|
48
|
+
raise FileNotFoundError(f'PPT/PPTX 文件不存在: {pptx_path}')
|
|
49
|
+
if pptx_path.is_dir():
|
|
50
|
+
raise ValueError(f'输入路径不是文件: {pptx_path}')
|
|
51
|
+
|
|
52
|
+
output_path = Path(output_file).expanduser().resolve()
|
|
53
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
libreoffice_cmd = _detect_libreoffice()
|
|
56
|
+
if not libreoffice_cmd:
|
|
57
|
+
raise RuntimeError('未找到 LibreOffice,请先安装(例如 apt install libreoffice 或从官网安装包)。')
|
|
58
|
+
|
|
59
|
+
with tempfile.TemporaryDirectory(prefix='pptx_to_pdf_') as temp_dir:
|
|
60
|
+
cmd = [
|
|
61
|
+
libreoffice_cmd,
|
|
62
|
+
'--headless',
|
|
63
|
+
'--invisible',
|
|
64
|
+
'--convert-to', 'pdf',
|
|
65
|
+
'--outdir', temp_dir,
|
|
66
|
+
str(pptx_path)
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
70
|
+
if result.returncode != 0:
|
|
71
|
+
message = result.stderr.strip() or result.stdout.strip() or 'LibreOffice 返回非零退出码'
|
|
72
|
+
raise RuntimeError(f'LibreOffice 转换失败: {message}')
|
|
73
|
+
|
|
74
|
+
generated = Path(temp_dir) / f'{pptx_path.stem}.pdf'
|
|
75
|
+
if not generated.exists():
|
|
76
|
+
pdf_candidates = list(Path(temp_dir).glob('*.pdf'))
|
|
77
|
+
if len(pdf_candidates) == 1:
|
|
78
|
+
generated = pdf_candidates[0]
|
|
79
|
+
else:
|
|
80
|
+
raise RuntimeError('未找到 LibreOffice 生成的 PDF 文件')
|
|
81
|
+
|
|
82
|
+
if output_path.exists():
|
|
83
|
+
output_path.unlink()
|
|
84
|
+
|
|
85
|
+
shutil.move(str(generated), str(output_path))
|
|
86
|
+
return output_path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_args() -> argparse.Namespace:
|
|
90
|
+
parser = argparse.ArgumentParser(description='将 PPT/PPTX 转换为 PDF(调用 LibreOffice 无头模式)。')
|
|
91
|
+
parser.add_argument('--input', required=True, help='PPT/PPTX 文件路径')
|
|
92
|
+
parser.add_argument('--output', required=True, help='输出 PDF 文件路径')
|
|
93
|
+
return parser.parse_args()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main() -> None:
|
|
97
|
+
args = _parse_args()
|
|
98
|
+
try:
|
|
99
|
+
result = pptx_to_pdf(args.input, args.output)
|
|
100
|
+
print(f'已生成 PDF: {result}')
|
|
101
|
+
except Exception as error:
|
|
102
|
+
print(f'转换失败: {error}', file=sys.stderr)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == '__main__':
|
|
107
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const { resolveWorkspacePath, readTextFile } = require('../utils/helpers');
|
|
2
|
+
|
|
3
|
+
const readFile = async ({ filePath } = {}, context = {}) => {
|
|
4
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
5
|
+
return 'filePath 参数不能为空';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let absolutePath;
|
|
9
|
+
try {
|
|
10
|
+
absolutePath = resolveWorkspacePath(context.workspaceRoot, filePath);
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return `无效路径: ${error.message}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
return await readTextFile(absolutePath);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return `读取失败 ${filePath}: ${error.message}`;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const schema = {
|
|
23
|
+
type: 'function',
|
|
24
|
+
function: {
|
|
25
|
+
name: 'read_file',
|
|
26
|
+
description: '读取指定相对路径的文本类文件完整内容,不支持二进制文件',
|
|
27
|
+
parameters: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
filePath: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: '文本文件的相对路径,例如 src/App.js'
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
required: ['filePath']
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
name: 'read_file',
|
|
42
|
+
schema,
|
|
43
|
+
handler: readFile
|
|
44
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
const { diffLines } = require('diff');
|
|
2
|
+
const {
|
|
3
|
+
resolveWorkspacePath,
|
|
4
|
+
readTextFile,
|
|
5
|
+
writeTextFile,
|
|
6
|
+
normalizeLineBreaks,
|
|
7
|
+
isCodeIdentical,
|
|
8
|
+
processContent
|
|
9
|
+
} = require('../utils/helpers');
|
|
10
|
+
|
|
11
|
+
const countLines = (text = '') => {
|
|
12
|
+
if (!text) return 0;
|
|
13
|
+
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
14
|
+
const lines = normalized.split('\n');
|
|
15
|
+
if (lines[lines.length - 1] === '') {
|
|
16
|
+
lines.pop();
|
|
17
|
+
}
|
|
18
|
+
return lines.length;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const computeDiffStats = (before = '', after = '') => {
|
|
22
|
+
const parts = diffLines(before, after);
|
|
23
|
+
return parts.reduce((acc, part) => {
|
|
24
|
+
if (part.added) {
|
|
25
|
+
acc.added += countLines(part.value);
|
|
26
|
+
} else if (part.removed) {
|
|
27
|
+
acc.removed += countLines(part.value);
|
|
28
|
+
}
|
|
29
|
+
return acc;
|
|
30
|
+
}, { added: 0, removed: 0 });
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const replaceInFile = async ({ filePath, search, replace } = {}, context = {}) => {
|
|
34
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
35
|
+
return 'filePath 参数不能为空';
|
|
36
|
+
}
|
|
37
|
+
if (typeof search !== 'string' || search.trim() === '') {
|
|
38
|
+
return 'search 参数不能为空';
|
|
39
|
+
}
|
|
40
|
+
if (typeof replace !== 'string') {
|
|
41
|
+
return 'replace 参数必须是字符串,可为空字符串表示删除';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let absolutePath;
|
|
45
|
+
try {
|
|
46
|
+
absolutePath = resolveWorkspacePath(context.workspaceRoot, filePath);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return `无效路径: ${error.message}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let originCode;
|
|
52
|
+
try {
|
|
53
|
+
originCode = await readTextFile(absolutePath);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return `读取文件失败 ${filePath}: ${error.message}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const normalizedOrigin = normalizeLineBreaks(originCode);
|
|
59
|
+
const normalizedSearch = normalizeLineBreaks(search);
|
|
60
|
+
let normalizedReplace = normalizeLineBreaks(replace);
|
|
61
|
+
|
|
62
|
+
if (!normalizedSearch) {
|
|
63
|
+
return 'search 参数规范化后为空';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (normalizedReplace) {
|
|
67
|
+
normalizedReplace = normalizedReplace.replace(/\n+$/, '');
|
|
68
|
+
if (/\n$/.test(replace)) {
|
|
69
|
+
normalizedReplace += '\n';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let searchPattern = normalizedSearch;
|
|
74
|
+
if (!normalizedReplace || normalizedReplace.trim() === '') {
|
|
75
|
+
if (normalizedOrigin.includes(normalizedSearch + '\n')) {
|
|
76
|
+
searchPattern = normalizedSearch + '\n';
|
|
77
|
+
} else if (normalizedOrigin.includes('\n' + normalizedSearch)) {
|
|
78
|
+
searchPattern = '\n' + normalizedSearch;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!normalizedOrigin.includes(searchPattern)) {
|
|
83
|
+
return `在文件 ${filePath} 中未找到要搜索的内容`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const updated = normalizedOrigin.replaceAll(searchPattern, normalizedReplace);
|
|
87
|
+
if (isCodeIdentical(originCode, updated)) {
|
|
88
|
+
return `修改前后内容相同: ${filePath}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const processed = processContent(updated);
|
|
92
|
+
try {
|
|
93
|
+
await writeTextFile(absolutePath, processed);
|
|
94
|
+
const diffStats = computeDiffStats(normalizedOrigin, normalizeLineBreaks(processed));
|
|
95
|
+
return {
|
|
96
|
+
success: true,
|
|
97
|
+
message: `搜索替换成功: ${filePath}`,
|
|
98
|
+
diffStats
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return `搜索替换失败 ${filePath}: ${error.message}`;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const schema = {
|
|
106
|
+
type: 'function',
|
|
107
|
+
function: {
|
|
108
|
+
name: 'search_and_replace',
|
|
109
|
+
description: '在文件中搜索并替换指定的代码片段(简单字符串匹配,非正则)',
|
|
110
|
+
parameters: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
filePath: {
|
|
114
|
+
type: 'string',
|
|
115
|
+
description: '文件的相对路径'
|
|
116
|
+
},
|
|
117
|
+
search: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
description: '原始代码片段(完整拷贝,勿省略)'
|
|
120
|
+
},
|
|
121
|
+
replace: {
|
|
122
|
+
type: 'string',
|
|
123
|
+
description: '替换后的代码片段,可为空字符串表示删除'
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
required: ['filePath', 'search', 'replace']
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
name: 'search_and_replace',
|
|
133
|
+
schema,
|
|
134
|
+
handler: replaceInFile
|
|
135
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
const STATUS_EMOJI = {
|
|
4
|
+
pending: '⬜',
|
|
5
|
+
in_progress: '🕒',
|
|
6
|
+
completed: '✅'
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const formatTodosList = (todos = []) => {
|
|
10
|
+
if (!Array.isArray(todos) || todos.length === 0) {
|
|
11
|
+
return '暂无待办事项';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const lines = todos.map(item => {
|
|
15
|
+
const emoji = STATUS_EMOJI[item.status] || STATUS_EMOJI.pending;
|
|
16
|
+
const content = String(item.content || '').replace(/\s+/g, ' ').trim();
|
|
17
|
+
return `${emoji} ${content}`;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return `${lines.join('\n')}`;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const validateTodos = (todos) => {
|
|
24
|
+
if (!Array.isArray(todos)) {
|
|
25
|
+
return 'todos 必须是数组';
|
|
26
|
+
}
|
|
27
|
+
for (const item of todos) {
|
|
28
|
+
if (!item || typeof item !== 'object') {
|
|
29
|
+
return 'todos 中的每一项必须是对象';
|
|
30
|
+
}
|
|
31
|
+
if (!item.id || !item.content || !item.status) {
|
|
32
|
+
return '每个待办事项必须包含 id/content/status';
|
|
33
|
+
}
|
|
34
|
+
if (!['pending', 'in_progress', 'completed'].includes(item.status)) {
|
|
35
|
+
return 'status 仅支持 pending/in_progress/completed';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
let lastTodos = [];
|
|
42
|
+
|
|
43
|
+
const writeTodos = async ({ todos } = {}) => {
|
|
44
|
+
const error = validateTodos(todos);
|
|
45
|
+
if (error) {
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
lastTodos = todos;
|
|
49
|
+
const formatted = formatTodosList(todos);
|
|
50
|
+
return formatted;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const schema = {
|
|
54
|
+
type: 'function',
|
|
55
|
+
function: {
|
|
56
|
+
name: 'write_todos',
|
|
57
|
+
description: '更新任务列表(创建、修改、完成)',
|
|
58
|
+
parameters: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
todos: {
|
|
62
|
+
type: 'array',
|
|
63
|
+
items: {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
id: { type: 'string' },
|
|
67
|
+
content: { type: 'string' },
|
|
68
|
+
status: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
enum: ['pending', 'in_progress', 'completed']
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
required: ['id', 'content', 'status']
|
|
74
|
+
},
|
|
75
|
+
description: '完整的待办事项数组'
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
required: ['todos']
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const getLastTodos = () => lastTodos;
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
name: 'write_todos',
|
|
87
|
+
schema,
|
|
88
|
+
handler: writeTodos,
|
|
89
|
+
getLastTodos
|
|
90
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const { resolveWorkspacePath, writeTextFile } = require('../utils/helpers');
|
|
2
|
+
|
|
3
|
+
const writeFile = async ({ filePath, content } = {}, context = {}) => {
|
|
4
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
5
|
+
return 'filePath 参数不能为空';
|
|
6
|
+
}
|
|
7
|
+
if (typeof content !== 'string') {
|
|
8
|
+
return 'content 必须是字符串';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let absolutePath;
|
|
12
|
+
try {
|
|
13
|
+
absolutePath = resolveWorkspacePath(context.workspaceRoot, filePath);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
return `无效路径: ${error.message}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
await writeTextFile(absolutePath, content);
|
|
20
|
+
return `写入成功: ${filePath}`;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return `写入失败 ${filePath}: ${error.message}`;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const schema = {
|
|
27
|
+
type: 'function',
|
|
28
|
+
function: {
|
|
29
|
+
name: 'write_file',
|
|
30
|
+
description: '向文件写入完整内容(不存在则创建)',
|
|
31
|
+
parameters: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
filePath: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: '文件的相对路径'
|
|
37
|
+
},
|
|
38
|
+
content: {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description: '写入的完整内容'
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
required: ['filePath', 'content']
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
name: 'write_file',
|
|
50
|
+
schema,
|
|
51
|
+
handler: writeFile
|
|
52
|
+
};
|