myagent-ai 1.17.3 → 1.18.0
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/core/deps_checker.py +9 -3
- package/main.py +13 -0
- package/package.json +1 -1
- package/skills/__init__.py +0 -0
- package/skills/base.py +0 -0
- package/skills/browser_skill.py +0 -0
- package/skills/chromedev_mcp.py +0 -0
- package/skills/docx_skill.py +199 -0
- package/skills/file_skill.py +0 -0
- package/skills/gui_skill.py +0 -0
- package/skills/pdf_skill.py +356 -0
- package/skills/ppt_skill.py +311 -0
- package/skills/registry.py +0 -0
- package/skills/search_skill.py +0 -0
- package/skills/system_skill.py +0 -0
- package/skills/xlsx_skill.py +303 -0
package/core/deps_checker.py
CHANGED
|
@@ -68,14 +68,20 @@ DEPENDENCIES: List[DepInfo] = [
|
|
|
68
68
|
# ── PDF 处理 ──
|
|
69
69
|
DepInfo("PyPDF2", "PyPDF2", "3.0.0", "pdf", "all",
|
|
70
70
|
note="纯 Python PDF 文本提取(pdftotext 失败时的备用方案)"),
|
|
71
|
+
DepInfo("pypdf", "pypdf", "4.0.0", "pdf", "all",
|
|
72
|
+
note="PDF 文本提取 (PyPDF2 新版, 优先使用)"),
|
|
73
|
+
DepInfo("reportlab", "reportlab", "4.0.0", "pdf", "all",
|
|
74
|
+
note="PDF 文档生成引擎"),
|
|
75
|
+
DepInfo("PIL", "Pillow", "10.0.0", "pdf", "all",
|
|
76
|
+
note="图片处理 (PDF 插图, 报告图表)"),
|
|
71
77
|
|
|
72
78
|
# ── 文档处理 (Excel/Word/PPT) ──
|
|
73
79
|
DepInfo("openpyxl", "openpyxl", "3.1.0", "doc", "all",
|
|
74
|
-
note="Excel (.xlsx)
|
|
80
|
+
note="Excel (.xlsx) 文件读取和生成"),
|
|
75
81
|
DepInfo("docx", "python-docx", "1.1.0", "doc", "all",
|
|
76
|
-
note="Word (.docx)
|
|
82
|
+
note="Word (.docx) 文件读取和生成"),
|
|
77
83
|
DepInfo("pptx", "python-pptx", "0.6.21", "doc", "all",
|
|
78
|
-
note="PowerPoint (.pptx)
|
|
84
|
+
note="PowerPoint (.pptx) 文件读取和生成"),
|
|
79
85
|
DepInfo("xlrd", "xlrd", "2.0.0", "doc", "all",
|
|
80
86
|
note="旧版 Excel (.xls) 文件读取"),
|
|
81
87
|
|
package/main.py
CHANGED
|
@@ -390,6 +390,19 @@ class MyAgentApp:
|
|
|
390
390
|
]:
|
|
391
391
|
self.skill_registry.register(skill_cls())
|
|
392
392
|
|
|
393
|
+
# ── 文档生成技能 (v1.17.3, 融合 MiniMax Skills 设计理念) ──
|
|
394
|
+
from skills.pdf_skill import PDFCreateSkill, PDFReadSkill
|
|
395
|
+
from skills.docx_skill import DOCXCreateSkill, DOCXReadSkill
|
|
396
|
+
from skills.xlsx_skill import XLSXCreateSkill, XLSXReadSkill, XLSXEditSkill
|
|
397
|
+
from skills.ppt_skill import PPTCreateSkill, PPTReadSkill
|
|
398
|
+
for skill_cls in [
|
|
399
|
+
PDFCreateSkill, PDFReadSkill,
|
|
400
|
+
DOCXCreateSkill, DOCXReadSkill,
|
|
401
|
+
XLSXCreateSkill, XLSXReadSkill, XLSXEditSkill,
|
|
402
|
+
PPTCreateSkill, PPTReadSkill,
|
|
403
|
+
]:
|
|
404
|
+
self.skill_registry.register(skill_cls())
|
|
405
|
+
|
|
393
406
|
async def process_message(
|
|
394
407
|
self,
|
|
395
408
|
user_message: str,
|
package/package.json
CHANGED
package/skills/__init__.py
CHANGED
|
File without changes
|
package/skills/base.py
CHANGED
|
File without changes
|
package/skills/browser_skill.py
CHANGED
|
File without changes
|
package/skills/chromedev_mcp.py
CHANGED
|
File without changes
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
skills/docx_skill.py - DOCX Word 文档生成/编辑技能
|
|
3
|
+
===============================================
|
|
4
|
+
基于 python-docx 生成和编辑 Word 文档,融入 MiniMax DOCX Skill 设计理念。
|
|
5
|
+
支持标题、段落、列表、表格、图片、页眉页脚、样式等。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from skills.base import Skill, SkillParameter, SkillResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DOCXCreateSkill(Skill):
|
|
18
|
+
"""生成 Word 文档"""
|
|
19
|
+
name = "docx_create"
|
|
20
|
+
description = (
|
|
21
|
+
"生成 Word (DOCX) 文档。支持多级标题、段落、列表、表格、图片等。"
|
|
22
|
+
"content 为 JSON 数组,每项: {type, text/items/headers/rows/path...}。"
|
|
23
|
+
"type 可选: h1/h2/h3/body/bullet/numbered/table/image/pagebreak/spacer。"
|
|
24
|
+
)
|
|
25
|
+
category = "doc"
|
|
26
|
+
dangerous = True
|
|
27
|
+
parameters = [
|
|
28
|
+
SkillParameter("content", "string",
|
|
29
|
+
"内容块 JSON 数组。示例: [{\"type\":\"h1\",\"text\":\"报告\"},{\"type\":\"body\",\"text\":\"正文\"}]",
|
|
30
|
+
required=True),
|
|
31
|
+
SkillParameter("output_path", "string", "输出 DOCX 文件路径", required=True),
|
|
32
|
+
SkillParameter("title", "string", "文档标题", required=False, default=""),
|
|
33
|
+
SkillParameter("author", "string", "作者", required=False, default=""),
|
|
34
|
+
SkillParameter("font", "string", "中文字体", required=False, default="SimHei"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
async def execute(self, content: str = "", output_path: str = "",
|
|
38
|
+
title: str = "", author: str = "",
|
|
39
|
+
font: str = "SimHei", **kwargs) -> SkillResult:
|
|
40
|
+
try:
|
|
41
|
+
from docx import Document
|
|
42
|
+
from docx.shared import Inches, Cm, Pt, Emu, RGBColor
|
|
43
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
44
|
+
from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
45
|
+
except ImportError:
|
|
46
|
+
return SkillResult(success=False, error="python-docx 未安装: pip install python-docx")
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
blocks = json.loads(content) if isinstance(content, str) else content
|
|
50
|
+
if not isinstance(blocks, list):
|
|
51
|
+
blocks = [{"type": "body", "text": str(blocks)}]
|
|
52
|
+
except json.JSONDecodeError as e:
|
|
53
|
+
return SkillResult(success=False, error=f"content JSON 解析失败: {e}")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
out = Path(output_path).expanduser().resolve()
|
|
57
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
doc = Document()
|
|
60
|
+
if title:
|
|
61
|
+
doc.core_properties.title = title
|
|
62
|
+
if author:
|
|
63
|
+
doc.core_properties.author = author
|
|
64
|
+
|
|
65
|
+
style = doc.styles["Normal"]
|
|
66
|
+
rpr = style.font
|
|
67
|
+
rpr.name = font
|
|
68
|
+
rpr.size = Pt(11)
|
|
69
|
+
|
|
70
|
+
for level, size in [("Heading 1", 22), ("Heading 2", 16), ("Heading 3", 13)]:
|
|
71
|
+
if level in doc.styles:
|
|
72
|
+
hs = doc.styles[level]
|
|
73
|
+
hs.font.name = font
|
|
74
|
+
hs.font.size = Pt(size)
|
|
75
|
+
hs.font.color.rgb = RGBColor(0x1a, 0x36, 0x5d)
|
|
76
|
+
|
|
77
|
+
for block in blocks:
|
|
78
|
+
if not isinstance(block, dict):
|
|
79
|
+
continue
|
|
80
|
+
bt = block.get("type", "body")
|
|
81
|
+
|
|
82
|
+
if bt in ("h1", "h2", "h3"):
|
|
83
|
+
heading_map = {"h1": 0, "h2": 1, "h3": 2}
|
|
84
|
+
doc.add_heading(block.get("text", ""), level=heading_map[bt] + 1)
|
|
85
|
+
elif bt == "body":
|
|
86
|
+
p = doc.add_paragraph(block.get("text", ""))
|
|
87
|
+
p.paragraph_format.first_line_indent = Cm(0.74)
|
|
88
|
+
p.paragraph_format.line_spacing = 1.5
|
|
89
|
+
elif bt == "bullet":
|
|
90
|
+
for item in block.get("items", []):
|
|
91
|
+
doc.add_paragraph(item, style="List Bullet")
|
|
92
|
+
elif bt == "numbered":
|
|
93
|
+
for item in block.get("items", []):
|
|
94
|
+
doc.add_paragraph(item, style="List Number")
|
|
95
|
+
elif bt == "table":
|
|
96
|
+
headers = block.get("headers", [])
|
|
97
|
+
rows = block.get("rows", [])
|
|
98
|
+
if headers:
|
|
99
|
+
table = doc.add_table(rows=1 + len(rows), cols=len(headers))
|
|
100
|
+
table.style = "Light Grid Accent 1"
|
|
101
|
+
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
102
|
+
for j, h in enumerate(headers):
|
|
103
|
+
cell = table.rows[0].cells[j]
|
|
104
|
+
cell.text = str(h)
|
|
105
|
+
for run in cell.paragraphs[0].runs:
|
|
106
|
+
run.bold = True
|
|
107
|
+
for i, row in enumerate(rows):
|
|
108
|
+
for j, val in enumerate(row):
|
|
109
|
+
if j < len(table.columns):
|
|
110
|
+
table.rows[i + 1].cells[j].text = str(val)
|
|
111
|
+
elif bt == "image":
|
|
112
|
+
img_path = block.get("path", "")
|
|
113
|
+
img_w = block.get("width", 0)
|
|
114
|
+
if img_path and os.path.isfile(img_path):
|
|
115
|
+
try:
|
|
116
|
+
if img_w > 0:
|
|
117
|
+
doc.add_picture(img_path, width=Emu(int(img_w)))
|
|
118
|
+
else:
|
|
119
|
+
doc.add_picture(img_path, width=Inches(5.5))
|
|
120
|
+
except Exception as e:
|
|
121
|
+
doc.add_paragraph(f"[图片加载失败: {e}]")
|
|
122
|
+
elif bt == "pagebreak":
|
|
123
|
+
doc.add_page_break()
|
|
124
|
+
elif bt == "spacer":
|
|
125
|
+
doc.add_paragraph("")
|
|
126
|
+
|
|
127
|
+
doc.save(str(out))
|
|
128
|
+
return SkillResult(
|
|
129
|
+
success=True,
|
|
130
|
+
message=f"Word 文档已生成: {out}",
|
|
131
|
+
files=[str(out)],
|
|
132
|
+
data={"path": str(out), "blocks": len(blocks)},
|
|
133
|
+
)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
return SkillResult(success=False, error=f"DOCX 生成失败: {e}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class DOCXReadSkill(Skill):
|
|
139
|
+
"""读取 Word 文档内容"""
|
|
140
|
+
name = "docx_read"
|
|
141
|
+
description = "读取 Word (DOCX) 文件,提取文本内容。支持提取表格数据。"
|
|
142
|
+
category = "doc"
|
|
143
|
+
parameters = [
|
|
144
|
+
SkillParameter("path", "string", "DOCX 文件路径", required=True),
|
|
145
|
+
SkillParameter("max_chars", "integer", "最大字符数", required=False, default=50000),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
async def execute(self, path: str = "", max_chars: int = 50000, **kwargs) -> SkillResult:
|
|
149
|
+
try:
|
|
150
|
+
from docx import Document
|
|
151
|
+
except ImportError:
|
|
152
|
+
return SkillResult(success=False, error="python-docx 未安装: pip install python-docx")
|
|
153
|
+
|
|
154
|
+
fp = Path(path).expanduser().resolve()
|
|
155
|
+
if not fp.exists():
|
|
156
|
+
return SkillResult(success=False, error=f"文件不存在: {path}")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
doc = Document(str(fp))
|
|
160
|
+
parts = []
|
|
161
|
+
for para in doc.paragraphs:
|
|
162
|
+
style_name = para.style.name if para.style else ""
|
|
163
|
+
prefix = ""
|
|
164
|
+
if "Heading" in style_name:
|
|
165
|
+
level = style_name.replace("Heading", "").strip()
|
|
166
|
+
prefix = f"[{'#' * int(level) if level.isdigit() else '#'}] "
|
|
167
|
+
elif "Bullet" in style_name:
|
|
168
|
+
prefix = " * "
|
|
169
|
+
elif "Number" in style_name or "List" in style_name:
|
|
170
|
+
prefix = " - "
|
|
171
|
+
text = para.text.strip()
|
|
172
|
+
if text:
|
|
173
|
+
parts.append(f"{prefix}{text}")
|
|
174
|
+
|
|
175
|
+
for i, table in enumerate(doc.tables):
|
|
176
|
+
rows = []
|
|
177
|
+
for row in table.rows:
|
|
178
|
+
cells = [cell.text.strip() for cell in row.cells]
|
|
179
|
+
rows.append(" | ".join(cells))
|
|
180
|
+
if rows:
|
|
181
|
+
parts.append(f"\n[表格 {i+1}]")
|
|
182
|
+
parts.append(rows[0])
|
|
183
|
+
if len(rows) > 1:
|
|
184
|
+
parts.append("-" * len(rows[0]))
|
|
185
|
+
parts.extend(rows[1:])
|
|
186
|
+
|
|
187
|
+
full = "\n".join(parts)
|
|
188
|
+
if len(full) > max_chars:
|
|
189
|
+
full = full[:max_chars] + f"\n\n... (截断,共 {len(full)} 字符)"
|
|
190
|
+
|
|
191
|
+
return SkillResult(
|
|
192
|
+
success=True,
|
|
193
|
+
message=f"已读取 Word 文档: {fp.name}",
|
|
194
|
+
data={"path": str(fp), "paragraphs": len(doc.paragraphs),
|
|
195
|
+
"tables": len(doc.tables)},
|
|
196
|
+
output=full,
|
|
197
|
+
)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
return SkillResult(success=False, error=f"DOCX 读取失败: {e}")
|
package/skills/file_skill.py
CHANGED
|
File without changes
|
package/skills/gui_skill.py
CHANGED
|
File without changes
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
skills/pdf_skill.py - PDF 文档生成技能
|
|
3
|
+
========================================
|
|
4
|
+
基于 ReportLab 生成专业 PDF 文档,融入 MiniMax PDF Skill 设计理念。
|
|
5
|
+
支持封面、多级标题、段落、列表、表格、图片、分页等。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import textwrap
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from skills.base import Skill, SkillParameter, SkillResult
|
|
16
|
+
|
|
17
|
+
# ── 调色板 (源自 MiniMax PDF Skill design system) ──
|
|
18
|
+
PALETTES = {
|
|
19
|
+
"report": {"primary": "#1a365d", "secondary": "#2b6cb0", "accent": "#e53e3e",
|
|
20
|
+
"bg": "#ffffff", "text": "#1a202c", "muted": "#718096"},
|
|
21
|
+
"proposal": {"primary": "#22543d", "secondary": "#38a169", "accent": "#d69e2e",
|
|
22
|
+
"bg": "#ffffff", "text": "#1a202c", "muted": "#718096"},
|
|
23
|
+
"academic": {"primary": "#553c9a", "secondary": "#805ad5", "accent": "#e53e3e",
|
|
24
|
+
"bg": "#ffffff", "text": "#1a202c", "muted": "#718096"},
|
|
25
|
+
"minimal": {"primary": "#2d3748", "secondary": "#4a5568", "accent": "#3182ce",
|
|
26
|
+
"bg": "#ffffff", "text": "#1a202c", "muted": "#718096"},
|
|
27
|
+
"dark": {"primary": "#e2e8f0", "secondary": "#a0aec0", "accent": "#63b3ed",
|
|
28
|
+
"bg": "#1a202c", "text": "#e2e8f0", "muted": "#a0aec0"},
|
|
29
|
+
"warm": {"primary": "#744210", "secondary": "#c05621", "accent": "#d69e2e",
|
|
30
|
+
"bg": "#fffff0", "text": "#1a202c", "muted": "#718096"},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _hex_to_rgb(h: str) -> tuple:
|
|
35
|
+
h = h.lstrip("#")
|
|
36
|
+
return tuple(int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PDFCreateSkill(Skill):
|
|
40
|
+
"""生成 PDF 文档 — 支持封面、标题、段落、列表、表格、图片
|
|
41
|
+
|
|
42
|
+
content 为 JSON 数组,每个元素代表一个内容块:
|
|
43
|
+
- {"type": "h1", "text": "标题"} — 一级标题
|
|
44
|
+
- {"type": "h2", "text": "标题"} — 二级标题
|
|
45
|
+
- {"type": "h3", "text": "标题"} — 三级标题
|
|
46
|
+
- {"type": "body", "text": "正文"} — 正文段落
|
|
47
|
+
- {"type": "bullet", "items": ["a","b"]} — 无序列表
|
|
48
|
+
- {"type": "numbered", "items": ["a","b"]} — 有序列表
|
|
49
|
+
- {"type": "table", "headers": ["A","B"], "rows": [["1","2"]]} — 表格
|
|
50
|
+
- {"type": "image", "path": "/path/to/img.png", "width": 400} — 图片
|
|
51
|
+
- {"type": "divider"} — 分割线
|
|
52
|
+
- {"type": "pagebreak"} — 分页
|
|
53
|
+
- {"type": "spacer", "height": 20} — 空白
|
|
54
|
+
- {"type": "callout", "text": "提示", "style": "info"} — 提示框
|
|
55
|
+
"""
|
|
56
|
+
name = "pdf_create"
|
|
57
|
+
description = (
|
|
58
|
+
"生成 PDF 文档。支持封面、多级标题、段落、列表、表格、图片、提示框等。"
|
|
59
|
+
"content 为 JSON 数组,每项: {type, text/items/headers/rows/path...}。"
|
|
60
|
+
"type 可选: h1/h2/h3/body/bullet/numbered/table/image/divider/pagebreak/spacer/callout。"
|
|
61
|
+
"palette 可选: report/proposal/academic/minimal/dark/warm。"
|
|
62
|
+
)
|
|
63
|
+
category = "pdf"
|
|
64
|
+
dangerous = True
|
|
65
|
+
parameters = [
|
|
66
|
+
SkillParameter("content", "string",
|
|
67
|
+
"PDF 内容块 JSON 数组,每项含 type 字段。示例: [{\"type\":\"h1\",\"text\":\"报告标题\"},{\"type\":\"body\",\"text\":\"正文内容\"}]",
|
|
68
|
+
required=True),
|
|
69
|
+
SkillParameter("output_path", "string",
|
|
70
|
+
"输出 PDF 文件路径 (如 /tmp/report.pdf)", required=True),
|
|
71
|
+
SkillParameter("title", "string", "文档标题 (用于封面)", required=False, default=""),
|
|
72
|
+
SkillParameter("subtitle", "string", "副标题 (用于封面)", required=False, default=""),
|
|
73
|
+
SkillParameter("author", "string", "作者", required=False, default=""),
|
|
74
|
+
SkillParameter("palette", "string", "配色方案", required=False,
|
|
75
|
+
default="report", enum=["report","proposal","academic","minimal","dark","warm"]),
|
|
76
|
+
SkillParameter("cover", "boolean", "是否生成封面", required=False, default=True),
|
|
77
|
+
SkillParameter("page_size", "string", "页面尺寸", required=False,
|
|
78
|
+
default="A4", enum=["A4","Letter"]),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
async def execute(self, content: str = "", output_path: str = "",
|
|
82
|
+
title: str = "", subtitle: str = "", author: str = "",
|
|
83
|
+
palette: str = "report", cover: bool = True,
|
|
84
|
+
page_size: str = "A4", **kwargs) -> SkillResult:
|
|
85
|
+
try:
|
|
86
|
+
from reportlab.lib.pagesizes import A4, letter
|
|
87
|
+
from reportlab.lib.units import mm, cm
|
|
88
|
+
from reportlab.lib.colors import HexColor
|
|
89
|
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
90
|
+
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
|
|
91
|
+
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer,
|
|
92
|
+
Table, TableStyle, Image as RLImage, PageBreak, HRFlowable, KeepTogether)
|
|
93
|
+
from reportlab.pdfbase import pdfmetrics
|
|
94
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
95
|
+
except ImportError:
|
|
96
|
+
return SkillResult(success=False, error="ReportLab 未安装,请运行: pip install reportlab")
|
|
97
|
+
|
|
98
|
+
# 解析 content
|
|
99
|
+
try:
|
|
100
|
+
blocks = json.loads(content) if isinstance(content, str) else content
|
|
101
|
+
if not isinstance(blocks, list):
|
|
102
|
+
blocks = [{"type": "body", "text": str(blocks)}]
|
|
103
|
+
except json.JSONDecodeError as e:
|
|
104
|
+
return SkillResult(success=False, error=f"content JSON 解析失败: {e}")
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
# 输出路径
|
|
108
|
+
out = Path(output_path).expanduser().resolve()
|
|
109
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
|
|
111
|
+
# 配色
|
|
112
|
+
pal = PALETTES.get(palette, PALETTES["report"])
|
|
113
|
+
|
|
114
|
+
# 页面尺寸
|
|
115
|
+
ps = A4 if page_size.upper() == "A4" else letter
|
|
116
|
+
pw, ph = ps
|
|
117
|
+
|
|
118
|
+
# 注册中文字体
|
|
119
|
+
_zh_fonts = {}
|
|
120
|
+
for _fpath, _fname in [
|
|
121
|
+
("/usr/share/fonts/truetype/chinese/SimHei.ttf", "SimHei"),
|
|
122
|
+
("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", "WQYZenHei"),
|
|
123
|
+
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "DejaVuSans"),
|
|
124
|
+
]:
|
|
125
|
+
if os.path.isfile(_fpath):
|
|
126
|
+
try:
|
|
127
|
+
pdfmetrics.registerFont(TTFont(_fname, _fpath))
|
|
128
|
+
_zh_fonts[_fname] = _fpath
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
font_name = "SimHei" if "SimHei" in _zh_fonts else (
|
|
133
|
+
"WQYZenHei" if "WQYZenHei" in _zh_fonts else "Helvetica")
|
|
134
|
+
|
|
135
|
+
# 样式
|
|
136
|
+
styles = getSampleStyleSheet()
|
|
137
|
+
primary_rgb = _hex_to_rgb(pal["primary"])
|
|
138
|
+
secondary_rgb = _hex_to_rgb(pal["secondary"])
|
|
139
|
+
accent_rgb = _hex_to_rgb(pal["accent"])
|
|
140
|
+
text_rgb = _hex_to_rgb(pal["text"])
|
|
141
|
+
muted_rgb = _hex_to_rgb(pal["muted"])
|
|
142
|
+
|
|
143
|
+
styles.add(ParagraphStyle(
|
|
144
|
+
"zh_h1", fontName=font_name, fontSize=22, leading=28,
|
|
145
|
+
textColor=HexColor(pal["primary"]), spaceAfter=12, spaceBefore=20))
|
|
146
|
+
styles.add(ParagraphStyle(
|
|
147
|
+
"zh_h2", fontName=font_name, fontSize=16, leading=22,
|
|
148
|
+
textColor=HexColor(pal["primary"]), spaceAfter=8, spaceBefore=16))
|
|
149
|
+
styles.add(ParagraphStyle(
|
|
150
|
+
"zh_h3", fontName=font_name, fontSize=13, leading=18,
|
|
151
|
+
textColor=HexColor(pal["secondary"]), spaceAfter=6, spaceBefore=12))
|
|
152
|
+
styles.add(ParagraphStyle(
|
|
153
|
+
"zh_body", fontName=font_name, fontSize=10.5, leading=17,
|
|
154
|
+
textColor=HexColor(pal["text"]), alignment=TA_JUSTIFY,
|
|
155
|
+
spaceAfter=6, firstLineIndent=21))
|
|
156
|
+
styles.add(ParagraphStyle(
|
|
157
|
+
"zh_bullet", fontName=font_name, fontSize=10.5, leading=17,
|
|
158
|
+
textColor=HexColor(pal["text"]), leftIndent=20, bulletIndent=8,
|
|
159
|
+
spaceAfter=3))
|
|
160
|
+
styles.add(ParagraphStyle(
|
|
161
|
+
"zh_numbered", fontName=font_name, fontSize=10.5, leading=17,
|
|
162
|
+
textColor=HexColor(pal["text"]), leftIndent=20, bulletIndent=8,
|
|
163
|
+
spaceAfter=3))
|
|
164
|
+
styles.add(ParagraphStyle(
|
|
165
|
+
"zh_table_header", fontName=font_name, fontSize=10,
|
|
166
|
+
textColor=HexColor("#ffffff"), alignment=TA_CENTER))
|
|
167
|
+
styles.add(ParagraphStyle(
|
|
168
|
+
"zh_table_cell", fontName=font_name, fontSize=9.5,
|
|
169
|
+
textColor=HexColor(pal["text"]), alignment=TA_LEFT))
|
|
170
|
+
styles.add(ParagraphStyle(
|
|
171
|
+
"zh_callout", fontName=font_name, fontSize=10, leading=16,
|
|
172
|
+
textColor=HexColor(pal["text"]), leftIndent=12, rightIndent=12,
|
|
173
|
+
spaceBefore=8, spaceAfter=8, backColor=HexColor("#ebf8ff"),
|
|
174
|
+
borderColor=HexColor(pal["secondary"]), borderWidth=1,
|
|
175
|
+
borderPadding=8))
|
|
176
|
+
|
|
177
|
+
# 构建文档元素
|
|
178
|
+
story = []
|
|
179
|
+
|
|
180
|
+
# 封面
|
|
181
|
+
if cover and title:
|
|
182
|
+
story.append(Spacer(1, ph * 0.3))
|
|
183
|
+
_accent = HexColor(pal["accent"])
|
|
184
|
+
story.append(HRFlowable(width="40%", thickness=3, color=_accent,
|
|
185
|
+
spaceAfter=20, spaceBefore=0, hAlign="CENTER"))
|
|
186
|
+
_t = ParagraphStyle("cover_title", fontName=font_name, fontSize=28,
|
|
187
|
+
leading=36, textColor=HexColor(pal["primary"]),
|
|
188
|
+
alignment=TA_CENTER, spaceAfter=12)
|
|
189
|
+
story.append(Paragraph(title, _t))
|
|
190
|
+
if subtitle:
|
|
191
|
+
_s = ParagraphStyle("cover_sub", fontName=font_name, fontSize=14,
|
|
192
|
+
leading=20, textColor=HexColor(pal["secondary"]),
|
|
193
|
+
alignment=TA_CENTER, spaceAfter=8)
|
|
194
|
+
story.append(Paragraph(subtitle, _s))
|
|
195
|
+
if author:
|
|
196
|
+
_a = ParagraphStyle("cover_author", fontName=font_name, fontSize=11,
|
|
197
|
+
leading=16, textColor=HexColor(pal["muted"]),
|
|
198
|
+
alignment=TA_CENTER, spaceAfter=6)
|
|
199
|
+
story.append(Paragraph(author, _a))
|
|
200
|
+
story.append(HRFlowable(width="40%", thickness=3, color=_accent,
|
|
201
|
+
spaceAfter=0, spaceBefore=20, hAlign="CENTER"))
|
|
202
|
+
story.append(PageBreak())
|
|
203
|
+
|
|
204
|
+
# 内容块
|
|
205
|
+
for block in blocks:
|
|
206
|
+
if not isinstance(block, dict):
|
|
207
|
+
continue
|
|
208
|
+
bt = block.get("type", "body")
|
|
209
|
+
|
|
210
|
+
if bt == "h1":
|
|
211
|
+
story.append(Paragraph(str(block.get("text", "")), styles["zh_h1"]))
|
|
212
|
+
elif bt == "h2":
|
|
213
|
+
story.append(Paragraph(str(block.get("text", "")), styles["zh_h2"]))
|
|
214
|
+
elif bt == "h3":
|
|
215
|
+
story.append(Paragraph(str(block.get("text", "")), styles["zh_h3"]))
|
|
216
|
+
elif bt == "body":
|
|
217
|
+
story.append(Paragraph(str(block.get("text", "")), styles["zh_body"]))
|
|
218
|
+
elif bt == "bullet":
|
|
219
|
+
items = block.get("items", [])
|
|
220
|
+
for item in items:
|
|
221
|
+
story.append(Paragraph(f"\u2022 {item}", styles["zh_bullet"]))
|
|
222
|
+
elif bt == "numbered":
|
|
223
|
+
items = block.get("items", [])
|
|
224
|
+
for i, item in enumerate(items, 1):
|
|
225
|
+
story.append(Paragraph(f"{i}. {item}", styles["zh_numbered"]))
|
|
226
|
+
elif bt == "table":
|
|
227
|
+
headers = block.get("headers", [])
|
|
228
|
+
rows = block.get("rows", [])
|
|
229
|
+
if headers:
|
|
230
|
+
data = []
|
|
231
|
+
hdr_row = [Paragraph(str(h), styles["zh_table_header"]) for h in headers]
|
|
232
|
+
data.append(hdr_row)
|
|
233
|
+
for row in rows:
|
|
234
|
+
data.append([Paragraph(str(c), styles["zh_table_cell"]) for c in row])
|
|
235
|
+
col_w = (pw - 80) / max(len(headers), 1)
|
|
236
|
+
t = Table(data, colWidths=[col_w] * len(headers))
|
|
237
|
+
t.setStyle(TableStyle([
|
|
238
|
+
("BACKGROUND", (0, 0), (-1, 0), HexColor(pal["primary"])),
|
|
239
|
+
("TEXTCOLOR", (0, 0), (-1, 0), HexColor("#ffffff")),
|
|
240
|
+
("ALIGN", (0, 0), (-1, 0), "CENTER"),
|
|
241
|
+
("FONTNAME", (0, 0), (-1, 0), font_name),
|
|
242
|
+
("FONTSIZE", (0, 0), (-1, 0), 10),
|
|
243
|
+
("BOTTOMPADDING", (0, 0), (-1, 0), 8),
|
|
244
|
+
("TOPPADDING", (0, 0), (-1, 0), 8),
|
|
245
|
+
("BACKGROUND", (0, 1), (-1, -1), HexColor("#f7fafc")),
|
|
246
|
+
("TEXTCOLOR", (0, 1), (-1, -1), HexColor(pal["text"])),
|
|
247
|
+
("FONTNAME", (0, 1), (-1, -1), font_name),
|
|
248
|
+
("FONTSIZE", (0, 1), (-1, -1), 9.5),
|
|
249
|
+
("ALIGN", (0, 1), (-1, -1), "LEFT"),
|
|
250
|
+
("BOTTOMPADDING", (0, 1), (-1, -1), 6),
|
|
251
|
+
("TOPPADDING", (0, 1), (-1, -1), 6),
|
|
252
|
+
("GRID", (0, 0), (-1, -1), 0.5, HexColor("#cbd5e0")),
|
|
253
|
+
("ROWBACKGROUNDS", (0, 1), (-1, -1),
|
|
254
|
+
[HexColor("#ffffff"), HexColor("#f7fafc")]),
|
|
255
|
+
]))
|
|
256
|
+
story.append(t)
|
|
257
|
+
elif bt == "image":
|
|
258
|
+
img_path = block.get("path", "")
|
|
259
|
+
img_w = block.get("width", pw - 80)
|
|
260
|
+
if img_path and os.path.isfile(img_path):
|
|
261
|
+
try:
|
|
262
|
+
img = RLImage(img_path, width=img_w, height=img_w * 0.75)
|
|
263
|
+
img.hAlign = "CENTER"
|
|
264
|
+
story.append(img)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
story.append(Paragraph(f"[图片加载失败: {e}]", styles["zh_body"]))
|
|
267
|
+
elif bt == "divider":
|
|
268
|
+
story.append(HRFlowable(width="100%", thickness=0.5,
|
|
269
|
+
color=HexColor("#cbd5e0"), spaceAfter=8, spaceBefore=8))
|
|
270
|
+
elif bt == "pagebreak":
|
|
271
|
+
story.append(PageBreak())
|
|
272
|
+
elif bt == "spacer":
|
|
273
|
+
story.append(Spacer(1, block.get("height", 20)))
|
|
274
|
+
elif bt == "callout":
|
|
275
|
+
style_map = {"info": "#ebf8ff", "warning": "#fffbeb", "error": "#fff5f5",
|
|
276
|
+
"success": "#f0fff4"}
|
|
277
|
+
bg = style_map.get(block.get("style", "info"), "#ebf8ff")
|
|
278
|
+
cs = ParagraphStyle("callout_dyn", fontName=font_name, fontSize=10,
|
|
279
|
+
leading=16, textColor=HexColor(pal["text"]),
|
|
280
|
+
leftIndent=12, rightIndent=12,
|
|
281
|
+
spaceBefore=8, spaceAfter=8,
|
|
282
|
+
backColor=HexColor(bg), borderWidth=1,
|
|
283
|
+
borderColor=HexColor(pal["secondary"]),
|
|
284
|
+
borderPadding=8)
|
|
285
|
+
story.append(Paragraph(str(block.get("text", "")), cs))
|
|
286
|
+
|
|
287
|
+
# 生成 PDF
|
|
288
|
+
doc = SimpleDocTemplate(str(out), pagesize=ps,
|
|
289
|
+
leftMargin=30, rightMargin=30,
|
|
290
|
+
topMargin=30, bottomMargin=30)
|
|
291
|
+
doc.build(story)
|
|
292
|
+
|
|
293
|
+
return SkillResult(
|
|
294
|
+
success=True,
|
|
295
|
+
message=f"PDF 文档已生成: {out}",
|
|
296
|
+
files=[str(out)],
|
|
297
|
+
data={"path": str(out), "pages": "?", "blocks": len(blocks)},
|
|
298
|
+
)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
return SkillResult(success=False, error=f"PDF 生成失败: {e}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class PDFReadSkill(Skill):
|
|
304
|
+
"""读取 PDF 文件内容,提取文本"""
|
|
305
|
+
name = "pdf_read"
|
|
306
|
+
description = "读取 PDF 文件,提取文本内容。支持提取指定页码范围。"
|
|
307
|
+
category = "pdf"
|
|
308
|
+
parameters = [
|
|
309
|
+
SkillParameter("path", "string", "PDF 文件路径", required=True),
|
|
310
|
+
SkillParameter("page_start", "integer", "起始页 (从1开始)", required=False, default=1),
|
|
311
|
+
SkillParameter("page_end", "integer", "结束页 (0=全部)", required=False, default=0),
|
|
312
|
+
SkillParameter("max_chars", "integer", "最大字符数", required=False, default=50000),
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
async def execute(self, path: str = "", page_start: int = 1, page_end: int = 0,
|
|
316
|
+
max_chars: int = 50000, **kwargs) -> SkillResult:
|
|
317
|
+
try:
|
|
318
|
+
import importlib
|
|
319
|
+
for mod_name in ["pypdf", "PyPDF2"]:
|
|
320
|
+
try:
|
|
321
|
+
mod = importlib.import_module(mod_name)
|
|
322
|
+
break
|
|
323
|
+
except ImportError:
|
|
324
|
+
continue
|
|
325
|
+
else:
|
|
326
|
+
return SkillResult(success=False, error="需要 pypdf 或 PyPDF2: pip install pypdf")
|
|
327
|
+
|
|
328
|
+
fp = Path(path).expanduser().resolve()
|
|
329
|
+
if not fp.exists():
|
|
330
|
+
return SkillResult(success=False, error=f"文件不存在: {path}")
|
|
331
|
+
|
|
332
|
+
reader = mod.PdfReader(str(fp))
|
|
333
|
+
total = len(reader.pages)
|
|
334
|
+
ps = page_start - 1
|
|
335
|
+
pe = page_end if page_end > 0 else total
|
|
336
|
+
ps = max(0, min(ps, total - 1))
|
|
337
|
+
pe = max(ps, min(pe, total))
|
|
338
|
+
|
|
339
|
+
texts = []
|
|
340
|
+
for i in range(ps, pe):
|
|
341
|
+
page = reader.pages[i]
|
|
342
|
+
t = page.extract_text() or ""
|
|
343
|
+
texts.append(f"--- 第 {i+1} 页 ---\n{t}")
|
|
344
|
+
|
|
345
|
+
full = "\n\n".join(texts)
|
|
346
|
+
if len(full) > max_chars:
|
|
347
|
+
full = full[:max_chars] + f"\n\n... (截断,共 {len(full)} 字符)"
|
|
348
|
+
|
|
349
|
+
return SkillResult(
|
|
350
|
+
success=True,
|
|
351
|
+
message=f"已读取 PDF: {fp.name} (第{ps+1}-{pe}/{total}页)",
|
|
352
|
+
data={"path": str(fp), "total_pages": total, "pages_read": pe - ps},
|
|
353
|
+
output=full,
|
|
354
|
+
)
|
|
355
|
+
except Exception as e:
|
|
356
|
+
return SkillResult(success=False, error=f"PDF 读取失败: {e}")
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
skills/ppt_skill.py - PPT PowerPoint 演示文稿技能
|
|
3
|
+
===============================================
|
|
4
|
+
基于 python-pptx 生成 PowerPoint 演示文稿,融入 MiniMax PPT Skill 设计理念。
|
|
5
|
+
支持多种幻灯片类型、配色方案、图表等。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from skills.base import Skill, SkillParameter, SkillResult
|
|
15
|
+
|
|
16
|
+
# 配色方案 (源自 MiniMax PPT design-system)
|
|
17
|
+
THEMES = {
|
|
18
|
+
"professional": {"primary": "1A365D", "secondary": "2B6CB0", "accent": "E53E3E",
|
|
19
|
+
"light": "EBF8FF", "bg": "FFFFFF", "text": "1A202C", "muted": "718096"},
|
|
20
|
+
"modern": {"primary": "553C9A", "secondary": "805AD5", "accent": "ED8936",
|
|
21
|
+
"light": "FAF5FF", "bg": "FFFFFF", "text": "1A202C", "muted": "718096"},
|
|
22
|
+
"nature": {"primary": "22543D", "secondary": "38A169", "accent": "D69E2E",
|
|
23
|
+
"light": "F0FFF4", "bg": "FFFFFF", "text": "1A202C", "muted": "718096"},
|
|
24
|
+
"warm": {"primary": "744210", "secondary": "C05621", "accent": "DD6B20",
|
|
25
|
+
"light": "FFFFF0", "bg": "FFFFFF", "text": "1A202C", "muted": "718096"},
|
|
26
|
+
"dark": {"primary": "E2E8F0", "secondary": "A0AEC0", "accent": "63B3ED",
|
|
27
|
+
"light": "2D3748", "bg": "1A202C", "text": "E2E8F0", "muted": "A0AEC0"},
|
|
28
|
+
"minimal": {"primary": "2D3748", "secondary": "4A5568", "accent": "3182CE",
|
|
29
|
+
"light": "F7FAFC", "bg": "FFFFFF", "text": "1A202C", "muted": "718096"},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _hex_to_rgb(hex_str: str):
|
|
34
|
+
from pptx.dml.color import RGBColor
|
|
35
|
+
h = hex_str.lstrip("#")
|
|
36
|
+
return RGBColor(*[int(h[i:i+2], 16) for i in (0, 2, 4)])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PPTCreateSkill(Skill):
|
|
40
|
+
"""生成 PowerPoint 演示文稿
|
|
41
|
+
|
|
42
|
+
slides 为 JSON 数组,每个元素代表一张幻灯片:
|
|
43
|
+
- {"type": "cover", "title": "...", "subtitle": "...", "author": "..."}
|
|
44
|
+
- {"type": "toc", "title": "...", "items": ["章节1", "章节2"]}
|
|
45
|
+
- {"type": "section", "title": "章节标题"}
|
|
46
|
+
- {"type": "content", "title": "...", "body": ["要点1", "要点2"]}
|
|
47
|
+
- {"type": "content", "title": "...", "table": {"headers": [], "rows": []}}
|
|
48
|
+
- {"type": "content", "title": "...", "image": {"path": "...", "caption": "..."}}
|
|
49
|
+
- {"type": "summary", "title": "...", "points": ["要点1", "要点2"]}
|
|
50
|
+
"""
|
|
51
|
+
name = "ppt_create"
|
|
52
|
+
description = (
|
|
53
|
+
"生成 PowerPoint (PPTX) 演示文稿。支持封面、目录、章节、内容、表格、"
|
|
54
|
+
"图片、总结等幻灯片类型。"
|
|
55
|
+
"slides 为 JSON 数组,每项: {type, title, body/items/points...}。"
|
|
56
|
+
"theme 可选: professional/modern/nature/warm/dark/minimal。"
|
|
57
|
+
)
|
|
58
|
+
category = "doc"
|
|
59
|
+
dangerous = True
|
|
60
|
+
parameters = [
|
|
61
|
+
SkillParameter("slides", "string",
|
|
62
|
+
"幻灯片 JSON 数组。type: cover/toc/section/content/summary", required=True),
|
|
63
|
+
SkillParameter("output_path", "string", "输出 PPTX 文件路径", required=True),
|
|
64
|
+
SkillParameter("theme", "string", "配色主题", required=False,
|
|
65
|
+
default="professional",
|
|
66
|
+
enum=["professional","modern","nature","warm","dark","minimal"]),
|
|
67
|
+
SkillParameter("font", "string", "中文字体", required=False, default="SimHei"),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
async def execute(self, slides: str = "", output_path: str = "",
|
|
71
|
+
theme: str = "professional", font: str = "SimHei",
|
|
72
|
+
**kwargs) -> SkillResult:
|
|
73
|
+
try:
|
|
74
|
+
from pptx import Presentation
|
|
75
|
+
from pptx.util import Inches, Pt, Emu
|
|
76
|
+
from pptx.dml.color import RGBColor
|
|
77
|
+
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
|
78
|
+
from pptx.enum.shapes import MSO_SHAPE
|
|
79
|
+
except ImportError:
|
|
80
|
+
return SkillResult(success=False, error="python-pptx 未安装: pip install python-pptx")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
slide_data = json.loads(slides) if isinstance(slides, str) else slides
|
|
84
|
+
if not isinstance(slide_data, list):
|
|
85
|
+
slide_data = [{"type": "content", "title": str(slide_data)}]
|
|
86
|
+
except json.JSONDecodeError as e:
|
|
87
|
+
return SkillResult(success=False, error=f"slides JSON 解析失败: {e}")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
out = Path(output_path).expanduser().resolve()
|
|
91
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
t = THEMES.get(theme, THEMES["professional"])
|
|
94
|
+
pr = _hex_to_rgb(t["primary"])
|
|
95
|
+
sc = _hex_to_rgb(t["secondary"])
|
|
96
|
+
ac = _hex_to_rgb(t["accent"])
|
|
97
|
+
tc = _hex_to_rgb(t["text"])
|
|
98
|
+
mc = _hex_to_rgb(t["muted"])
|
|
99
|
+
lc = _hex_to_rgb(t["light"])
|
|
100
|
+
|
|
101
|
+
prs = Presentation()
|
|
102
|
+
prs.slide_width = Inches(13.333)
|
|
103
|
+
prs.slide_height = Inches(7.5)
|
|
104
|
+
BLANK = 6
|
|
105
|
+
|
|
106
|
+
def add_text_box(slide, left, top, width, height, text, font_size=18,
|
|
107
|
+
bold=False, color=tc, align=PP_ALIGN.LEFT, font_name=font):
|
|
108
|
+
txBox = slide.shapes.add_textbox(Inches(left), Inches(top),
|
|
109
|
+
Inches(width), Inches(height))
|
|
110
|
+
tf = txBox.text_frame
|
|
111
|
+
tf.word_wrap = True
|
|
112
|
+
p = tf.paragraphs[0]
|
|
113
|
+
p.text = text
|
|
114
|
+
p.font.size = Pt(font_size)
|
|
115
|
+
p.font.bold = bold
|
|
116
|
+
p.font.color.rgb = color
|
|
117
|
+
p.font.name = font_name
|
|
118
|
+
p.alignment = align
|
|
119
|
+
return tf
|
|
120
|
+
|
|
121
|
+
def add_shape_bg(slide, color_hex):
|
|
122
|
+
shape = slide.shapes.add_shape(
|
|
123
|
+
MSO_SHAPE.RECTANGLE, Inches(0), Inches(0),
|
|
124
|
+
prs.slide_width, prs.slide_height)
|
|
125
|
+
shape.fill.solid()
|
|
126
|
+
shape.fill.fore_color.rgb = _hex_to_rgb(color_hex)
|
|
127
|
+
shape.line.fill.background()
|
|
128
|
+
|
|
129
|
+
def add_accent_line(slide, left, top, width, color_hex):
|
|
130
|
+
shape = slide.shapes.add_shape(
|
|
131
|
+
MSO_SHAPE.RECTANGLE, Inches(left), Inches(top),
|
|
132
|
+
Inches(width), Inches(0.06))
|
|
133
|
+
shape.fill.solid()
|
|
134
|
+
shape.fill.fore_color.rgb = _hex_to_rgb(color_hex)
|
|
135
|
+
shape.line.fill.background()
|
|
136
|
+
|
|
137
|
+
def add_page_number(slide, num, total):
|
|
138
|
+
add_text_box(slide, 12.2, 7.0, 1, 0.4, f"{num}/{total}",
|
|
139
|
+
font_size=10, color=mc, align=PP_ALIGN.RIGHT)
|
|
140
|
+
|
|
141
|
+
total_slides = len(slide_data)
|
|
142
|
+
|
|
143
|
+
for idx, s in enumerate(slide_data):
|
|
144
|
+
if not isinstance(s, dict):
|
|
145
|
+
continue
|
|
146
|
+
st = s.get("type", "content")
|
|
147
|
+
slide = prs.slides.add_slide(prs.slide_layouts[BLANK])
|
|
148
|
+
|
|
149
|
+
if st != "cover":
|
|
150
|
+
add_page_number(slide, idx + 1, total_slides)
|
|
151
|
+
|
|
152
|
+
if st == "cover":
|
|
153
|
+
add_shape_bg(slide, t["primary"])
|
|
154
|
+
add_accent_line(slide, 2, 3.0, 2, t["accent"])
|
|
155
|
+
add_text_box(slide, 2, 3.3, 9, 1.5, s.get("title", ""),
|
|
156
|
+
font_size=40, bold=True, color=RGBColor(255, 255, 255))
|
|
157
|
+
if s.get("subtitle"):
|
|
158
|
+
add_text_box(slide, 2, 5.0, 9, 0.8, s["subtitle"],
|
|
159
|
+
font_size=18, color=RGBColor(200, 200, 200))
|
|
160
|
+
if s.get("author"):
|
|
161
|
+
add_text_box(slide, 2, 6.0, 9, 0.6, s["author"],
|
|
162
|
+
font_size=14, color=mc)
|
|
163
|
+
|
|
164
|
+
elif st == "toc":
|
|
165
|
+
add_text_box(slide, 0.8, 0.5, 8, 0.8, s.get("title", "目录"),
|
|
166
|
+
font_size=28, bold=True, color=pr)
|
|
167
|
+
add_accent_line(slide, 0.8, 1.4, 1.5, t["accent"])
|
|
168
|
+
for i, item in enumerate(s.get("items", [])):
|
|
169
|
+
add_text_box(slide, 1.2, 2.0 + i * 0.7, 8, 0.6,
|
|
170
|
+
f"{i+1}. {item}", font_size=18, color=tc)
|
|
171
|
+
|
|
172
|
+
elif st == "section":
|
|
173
|
+
add_shape_bg(slide, t["primary"])
|
|
174
|
+
add_text_box(slide, 1, 2.5, 11, 2, s.get("title", ""),
|
|
175
|
+
font_size=36, bold=True, color=RGBColor(255, 255, 255),
|
|
176
|
+
align=PP_ALIGN.CENTER)
|
|
177
|
+
|
|
178
|
+
elif st == "content":
|
|
179
|
+
# 标题栏
|
|
180
|
+
title_shape = slide.shapes.add_shape(
|
|
181
|
+
MSO_SHAPE.RECTANGLE, Inches(0), Inches(0),
|
|
182
|
+
prs.slide_width, Inches(1.2))
|
|
183
|
+
title_shape.fill.solid()
|
|
184
|
+
title_shape.fill.fore_color.rgb = pr
|
|
185
|
+
title_shape.line.fill.background()
|
|
186
|
+
add_text_box(slide, 0.8, 0.2, 11, 0.8, s.get("title", ""),
|
|
187
|
+
font_size=24, bold=True, color=RGBColor(255, 255, 255))
|
|
188
|
+
|
|
189
|
+
body = s.get("body", [])
|
|
190
|
+
if body and isinstance(body, list):
|
|
191
|
+
for i, item in enumerate(body):
|
|
192
|
+
y = 1.6 + i * 0.7
|
|
193
|
+
if y > 6.8:
|
|
194
|
+
break
|
|
195
|
+
add_text_box(slide, 1.0, y, 11, 0.6,
|
|
196
|
+
f"\u25cf {item}", font_size=16, color=tc)
|
|
197
|
+
|
|
198
|
+
table_data = s.get("table", None)
|
|
199
|
+
if table_data and isinstance(table_data, dict):
|
|
200
|
+
headers = table_data.get("headers", [])
|
|
201
|
+
rows = table_data.get("rows", [])
|
|
202
|
+
if headers:
|
|
203
|
+
n_rows = len(rows) + 1
|
|
204
|
+
n_cols = len(headers)
|
|
205
|
+
tbl = slide.shapes.add_table(
|
|
206
|
+
n_rows, n_cols, Inches(0.8), Inches(1.6),
|
|
207
|
+
Inches(11.7), Inches(min(n_rows * 0.5 + 0.5, 5.0))).table
|
|
208
|
+
for j, h in enumerate(headers):
|
|
209
|
+
cell = tbl.cell(0, j)
|
|
210
|
+
cell.text = str(h)
|
|
211
|
+
for p in cell.text_frame.paragraphs:
|
|
212
|
+
p.font.size = Pt(12)
|
|
213
|
+
p.font.bold = True
|
|
214
|
+
p.font.color.rgb = RGBColor(255, 255, 255)
|
|
215
|
+
p.font.name = font
|
|
216
|
+
cell.fill.solid()
|
|
217
|
+
cell.fill.fore_color.rgb = pr
|
|
218
|
+
for i, row in enumerate(rows):
|
|
219
|
+
for j, val in enumerate(row):
|
|
220
|
+
if j < n_cols:
|
|
221
|
+
cell = tbl.cell(i + 1, j)
|
|
222
|
+
cell.text = str(val)
|
|
223
|
+
for p in cell.text_frame.paragraphs:
|
|
224
|
+
p.font.size = Pt(11)
|
|
225
|
+
p.font.color.rgb = tc
|
|
226
|
+
p.font.name = font
|
|
227
|
+
if i % 2 == 0:
|
|
228
|
+
cell.fill.solid()
|
|
229
|
+
cell.fill.fore_color.rgb = lc
|
|
230
|
+
|
|
231
|
+
image_data = s.get("image", None)
|
|
232
|
+
if image_data and isinstance(image_data, dict):
|
|
233
|
+
img_path = image_data.get("path", "")
|
|
234
|
+
caption = image_data.get("caption", "")
|
|
235
|
+
if img_path and os.path.isfile(img_path):
|
|
236
|
+
try:
|
|
237
|
+
slide.shapes.add_picture(img_path, Inches(2), Inches(1.8),
|
|
238
|
+
width=Inches(9))
|
|
239
|
+
except Exception:
|
|
240
|
+
add_text_box(slide, 2, 3, 9, 1, "[图片加载失败]",
|
|
241
|
+
font_size=14, color=mc, align=PP_ALIGN.CENTER)
|
|
242
|
+
if caption:
|
|
243
|
+
add_text_box(slide, 2, 6.2, 9, 0.6, caption,
|
|
244
|
+
font_size=12, color=mc, align=PP_ALIGN.CENTER)
|
|
245
|
+
|
|
246
|
+
elif st == "summary":
|
|
247
|
+
add_text_box(slide, 0.8, 0.5, 11, 0.8, s.get("title", "总结"),
|
|
248
|
+
font_size=28, bold=True, color=pr)
|
|
249
|
+
add_accent_line(slide, 0.8, 1.4, 1.5, t["accent"])
|
|
250
|
+
for i, pt in enumerate(s.get("points", [])):
|
|
251
|
+
y = 2.0 + i * 0.8
|
|
252
|
+
if y > 6.5:
|
|
253
|
+
break
|
|
254
|
+
add_text_box(slide, 1.2, y, 10, 0.6,
|
|
255
|
+
f"\u2713 {pt}", font_size=17, color=tc)
|
|
256
|
+
|
|
257
|
+
prs.save(str(out))
|
|
258
|
+
return SkillResult(
|
|
259
|
+
success=True,
|
|
260
|
+
message=f"PPT 已生成: {out} ({total_slides} 张幻灯片)",
|
|
261
|
+
files=[str(out)],
|
|
262
|
+
data={"path": str(out), "slides": total_slides, "theme": theme},
|
|
263
|
+
)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
return SkillResult(success=False, error=f"PPT 生成失败: {e}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class PPTReadSkill(Skill):
|
|
269
|
+
"""读取 PowerPoint 文件内容"""
|
|
270
|
+
name = "ppt_read"
|
|
271
|
+
description = "读取 PowerPoint (PPTX) 文件,提取文本内容。"
|
|
272
|
+
category = "doc"
|
|
273
|
+
parameters = [
|
|
274
|
+
SkillParameter("path", "string", "PPTX 文件路径", required=True),
|
|
275
|
+
SkillParameter("max_chars", "integer", "最大字符数", required=False, default=30000),
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
async def execute(self, path: str = "", max_chars: int = 30000, **kwargs) -> SkillResult:
|
|
279
|
+
try:
|
|
280
|
+
from pptx import Presentation
|
|
281
|
+
except ImportError:
|
|
282
|
+
return SkillResult(success=False, error="python-pptx 未安装: pip install python-pptx")
|
|
283
|
+
|
|
284
|
+
fp = Path(path).expanduser().resolve()
|
|
285
|
+
if not fp.exists():
|
|
286
|
+
return SkillResult(success=False, error=f"文件不存在: {path}")
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
prs = Presentation(str(fp))
|
|
290
|
+
parts = []
|
|
291
|
+
for i, slide in enumerate(prs.slides):
|
|
292
|
+
slide_texts = []
|
|
293
|
+
for shape in slide.shapes:
|
|
294
|
+
if hasattr(shape, "text") and shape.text.strip():
|
|
295
|
+
slide_texts.append(shape.text.strip())
|
|
296
|
+
if slide_texts:
|
|
297
|
+
parts.append(f"--- 幻灯片 {i+1} ---")
|
|
298
|
+
parts.extend(slide_texts)
|
|
299
|
+
|
|
300
|
+
full = "\n".join(parts)
|
|
301
|
+
if len(full) > max_chars:
|
|
302
|
+
full = full[:max_chars] + "\n..."
|
|
303
|
+
|
|
304
|
+
return SkillResult(
|
|
305
|
+
success=True,
|
|
306
|
+
message=f"已读取 PPT: {fp.name} ({len(prs.slides)} 张幻灯片)",
|
|
307
|
+
data={"path": str(fp), "slides": len(prs.slides)},
|
|
308
|
+
output=full,
|
|
309
|
+
)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
return SkillResult(success=False, error=f"PPT 读取失败: {e}")
|
package/skills/registry.py
CHANGED
|
File without changes
|
package/skills/search_skill.py
CHANGED
|
File without changes
|
package/skills/system_skill.py
CHANGED
|
File without changes
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
skills/xlsx_skill.py - XLSX Excel 电子表格技能
|
|
3
|
+
=============================================
|
|
4
|
+
基于 openpyxl 创建和编辑 Excel 文件,融入 MiniMax XLSX Skill 设计理念。
|
|
5
|
+
支持创建/编辑/格式化工作表、图表、公式等。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from skills.base import Skill, SkillParameter, SkillResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class XLSXCreateSkill(Skill):
|
|
18
|
+
"""生成 Excel 电子表格
|
|
19
|
+
|
|
20
|
+
sheets 为 JSON 对象,key 为工作表名,value 为:
|
|
21
|
+
{
|
|
22
|
+
"headers": ["列1", "列2", ...],
|
|
23
|
+
"rows": [["值1", "值2"], ...],
|
|
24
|
+
"col_widths": [15, 20, ...],
|
|
25
|
+
"freeze_panes": "A2",
|
|
26
|
+
"auto_filter": true,
|
|
27
|
+
"formulas": {"C2": "=A2+B2"},
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
name = "xlsx_create"
|
|
31
|
+
description = (
|
|
32
|
+
"生成 Excel (XLSX) 电子表格。支持多工作表、表头、数据行、公式、"
|
|
33
|
+
"冻结窗格、自动筛选、列宽设置。"
|
|
34
|
+
"sheets 为 JSON 对象,key 为工作表名。"
|
|
35
|
+
)
|
|
36
|
+
category = "doc"
|
|
37
|
+
dangerous = True
|
|
38
|
+
parameters = [
|
|
39
|
+
SkillParameter("sheets", "string",
|
|
40
|
+
"工作表数据 JSON 对象。key=工作表名, value={headers, rows, col_widths?, formulas?}。"
|
|
41
|
+
"示例: {\"Sheet1\":{\"headers\":[\"Name\",\"Score\"],\"rows\":[[\"Alice\",95]]}}",
|
|
42
|
+
required=True),
|
|
43
|
+
SkillParameter("output_path", "string", "输出 XLSX 文件路径", required=True),
|
|
44
|
+
SkillParameter("title", "string", "文档标题", required=False, default=""),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
async def execute(self, sheets: str = "", output_path: str = "",
|
|
48
|
+
title: str = "", **kwargs) -> SkillResult:
|
|
49
|
+
try:
|
|
50
|
+
import openpyxl
|
|
51
|
+
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
52
|
+
from openpyxl.utils import get_column_letter
|
|
53
|
+
except ImportError:
|
|
54
|
+
return SkillResult(success=False, error="openpyxl 未安装: pip install openpyxl")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
sheets_data = json.loads(sheets) if isinstance(sheets, str) else sheets
|
|
58
|
+
if not isinstance(sheets_data, dict):
|
|
59
|
+
return SkillResult(success=False, error="sheets 必须是 JSON 对象 (key=工作表名)")
|
|
60
|
+
except json.JSONDecodeError as e:
|
|
61
|
+
return SkillResult(success=False, error=f"sheets JSON 解析失败: {e}")
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
out = Path(output_path).expanduser().resolve()
|
|
65
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
wb = openpyxl.Workbook()
|
|
68
|
+
wb.remove(wb.active)
|
|
69
|
+
|
|
70
|
+
header_font = Font(name="SimHei", size=11, bold=True, color="FFFFFF")
|
|
71
|
+
header_fill = PatternFill(start_color="1A365D", end_color="1A365D", fill_type="solid")
|
|
72
|
+
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
73
|
+
cell_font = Font(name="SimHei", size=10)
|
|
74
|
+
cell_align = Alignment(vertical="center", wrap_text=True)
|
|
75
|
+
formula_font = Font(name="Consolas", size=10, color="006400")
|
|
76
|
+
thin_border = Border(
|
|
77
|
+
left=Side(style="thin", color="D0D5DD"),
|
|
78
|
+
right=Side(style="thin", color="D0D5DD"),
|
|
79
|
+
top=Side(style="thin", color="D0D5DD"),
|
|
80
|
+
bottom=Side(style="thin", color="D0D5DD"),
|
|
81
|
+
)
|
|
82
|
+
alt_fill = PatternFill(start_color="F7FAFC", end_color="F7FAFC", fill_type="solid")
|
|
83
|
+
|
|
84
|
+
total_rows = 0
|
|
85
|
+
for sheet_name, sheet_data in sheets_data.items():
|
|
86
|
+
if not isinstance(sheet_data, dict):
|
|
87
|
+
continue
|
|
88
|
+
ws = wb.create_sheet(title=sheet_name[:31])
|
|
89
|
+
headers = sheet_data.get("headers", [])
|
|
90
|
+
rows = sheet_data.get("rows", [])
|
|
91
|
+
col_widths = sheet_data.get("col_widths", [])
|
|
92
|
+
formulas = sheet_data.get("formulas", {})
|
|
93
|
+
freeze_panes = sheet_data.get("freeze_panes", "")
|
|
94
|
+
auto_filter = sheet_data.get("auto_filter", False)
|
|
95
|
+
|
|
96
|
+
for col_idx, header in enumerate(headers, 1):
|
|
97
|
+
cell = ws.cell(row=1, column=col_idx, value=header)
|
|
98
|
+
cell.font = header_font
|
|
99
|
+
cell.fill = header_fill
|
|
100
|
+
cell.alignment = header_align
|
|
101
|
+
cell.border = thin_border
|
|
102
|
+
|
|
103
|
+
for row_idx, row_data in enumerate(rows, 2):
|
|
104
|
+
for col_idx, val in enumerate(row_data, 1):
|
|
105
|
+
cell = ws.cell(row=row_idx, column=col_idx, value=val)
|
|
106
|
+
cell.font = cell_font
|
|
107
|
+
cell.alignment = cell_align
|
|
108
|
+
cell.border = thin_border
|
|
109
|
+
if row_idx % 2 == 0:
|
|
110
|
+
cell.fill = alt_fill
|
|
111
|
+
total_rows += 1
|
|
112
|
+
|
|
113
|
+
for cell_ref, formula in formulas.items():
|
|
114
|
+
cell = ws[cell_ref]
|
|
115
|
+
cell.value = formula
|
|
116
|
+
cell.font = formula_font
|
|
117
|
+
cell.border = thin_border
|
|
118
|
+
|
|
119
|
+
if not col_widths:
|
|
120
|
+
for col_idx in range(1, len(headers) + 1):
|
|
121
|
+
max_len = len(str(headers[col_idx - 1])) if col_idx <= len(headers) else 0
|
|
122
|
+
for row_data in rows[:50]:
|
|
123
|
+
if col_idx <= len(row_data):
|
|
124
|
+
max_len = max(max_len, len(str(row_data[col_idx - 1])))
|
|
125
|
+
ws.column_dimensions[get_column_letter(col_idx)].width = min(max(max_len + 4, 10), 50)
|
|
126
|
+
else:
|
|
127
|
+
for i, w in enumerate(col_widths, 1):
|
|
128
|
+
ws.column_dimensions[get_column_letter(i)].width = w
|
|
129
|
+
|
|
130
|
+
if freeze_panes:
|
|
131
|
+
ws.freeze_panes = freeze_panes
|
|
132
|
+
if auto_filter and headers:
|
|
133
|
+
ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}{len(rows) + 1}"
|
|
134
|
+
|
|
135
|
+
if title:
|
|
136
|
+
wb.properties.title = title
|
|
137
|
+
|
|
138
|
+
wb.save(str(out))
|
|
139
|
+
sheet_names = list(sheets_data.keys())
|
|
140
|
+
return SkillResult(
|
|
141
|
+
success=True,
|
|
142
|
+
message=f"Excel 文件已生成: {out} ({len(sheet_names)} 个工作表, {total_rows} 行数据)",
|
|
143
|
+
files=[str(out)],
|
|
144
|
+
data={"path": str(out), "sheets": sheet_names, "total_rows": total_rows},
|
|
145
|
+
)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
return SkillResult(success=False, error=f"XLSX 生成失败: {e}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class XLSXReadSkill(Skill):
|
|
151
|
+
"""读取 Excel 文件内容"""
|
|
152
|
+
name = "xlsx_read"
|
|
153
|
+
description = "读取 Excel (XLSX/XLS/CSV) 文件,提取数据。支持指定工作表和行数限制。"
|
|
154
|
+
category = "doc"
|
|
155
|
+
parameters = [
|
|
156
|
+
SkillParameter("path", "string", "Excel 文件路径", required=True),
|
|
157
|
+
SkillParameter("sheet", "string", "工作表名 (空=第一个)", required=False, default=""),
|
|
158
|
+
SkillParameter("max_rows", "integer", "最大读取行数", required=False, default=200),
|
|
159
|
+
SkillParameter("max_chars", "integer", "最大字符数", required=False, default=50000),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
async def execute(self, path: str = "", sheet: str = "", max_rows: int = 200,
|
|
163
|
+
max_chars: int = 50000, **kwargs) -> SkillResult:
|
|
164
|
+
try:
|
|
165
|
+
import openpyxl
|
|
166
|
+
except ImportError:
|
|
167
|
+
return SkillResult(success=False, error="openpyxl 未安装: pip install openpyxl")
|
|
168
|
+
|
|
169
|
+
fp = Path(path).expanduser().resolve()
|
|
170
|
+
if not fp.exists():
|
|
171
|
+
return SkillResult(success=False, error=f"文件不存在: {path}")
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
wb = openpyxl.load_workbook(str(fp), read_only=True, data_only=True)
|
|
175
|
+
if not wb.worksheets:
|
|
176
|
+
wb.close()
|
|
177
|
+
import csv
|
|
178
|
+
text = fp.read_text(encoding="utf-8-sig", errors="replace")
|
|
179
|
+
reader = csv.reader(text.splitlines())
|
|
180
|
+
rows = list(reader)
|
|
181
|
+
if len(rows) > max_rows:
|
|
182
|
+
rows = rows[:max_rows]
|
|
183
|
+
full = "\n".join("\t".join(row) for row in rows)
|
|
184
|
+
if len(full) > max_chars:
|
|
185
|
+
full = full[:max_chars] + "\n..."
|
|
186
|
+
return SkillResult(success=True, message=f"已读取 CSV: {fp.name} ({len(rows)} 行)",
|
|
187
|
+
output=full, data={"rows": len(rows)})
|
|
188
|
+
|
|
189
|
+
sheet_names = wb.sheetnames
|
|
190
|
+
ws_name = sheet if sheet and sheet in sheet_names else sheet_names[0]
|
|
191
|
+
ws = wb[ws_name]
|
|
192
|
+
|
|
193
|
+
parts = []
|
|
194
|
+
row_count = 0
|
|
195
|
+
for row in ws.iter_rows(values_only=True):
|
|
196
|
+
if row_count >= max_rows:
|
|
197
|
+
parts.append(f"\n... (仅显示前 {max_rows} 行)")
|
|
198
|
+
break
|
|
199
|
+
cells = [str(c) if c is not None else "" for c in row]
|
|
200
|
+
parts.append("\t".join(cells))
|
|
201
|
+
row_count += 1
|
|
202
|
+
|
|
203
|
+
wb.close()
|
|
204
|
+
full = "\n".join(parts)
|
|
205
|
+
if len(full) > max_chars:
|
|
206
|
+
full = full[:max_chars] + "\n..."
|
|
207
|
+
|
|
208
|
+
return SkillResult(
|
|
209
|
+
success=True,
|
|
210
|
+
message=f"已读取 Excel: {fp.name} (工作表: {ws_name}, {row_count} 行)",
|
|
211
|
+
data={"path": str(fp), "sheets": sheet_names, "active_sheet": ws_name,
|
|
212
|
+
"rows": row_count},
|
|
213
|
+
output=full,
|
|
214
|
+
)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
return SkillResult(success=False, error=f"XLSX 读取失败: {e}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class XLSXEditSkill(Skill):
|
|
220
|
+
"""编辑 Excel 文件"""
|
|
221
|
+
name = "xlsx_edit"
|
|
222
|
+
description = "编辑已有 Excel 文件。支持: append_row, add_column, update_cell。"
|
|
223
|
+
category = "doc"
|
|
224
|
+
dangerous = True
|
|
225
|
+
parameters = [
|
|
226
|
+
SkillParameter("path", "string", "Excel 文件路径", required=True),
|
|
227
|
+
SkillParameter("action", "string", "操作类型", required=True,
|
|
228
|
+
enum=["append_row", "add_column", "update_cell"]),
|
|
229
|
+
SkillParameter("sheet", "string", "工作表名 (空=第一个)", required=False, default=""),
|
|
230
|
+
SkillParameter("data", "string",
|
|
231
|
+
"操作数据 JSON。"
|
|
232
|
+
"append_row: [[\"值1\",\"值2\"]]"
|
|
233
|
+
"add_column: {\"header\":\"列名\", \"values\":[1,2,3]}"
|
|
234
|
+
"update_cell: {\"cell\":\"A1\", \"value\":\"新值\"}",
|
|
235
|
+
required=True),
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
async def execute(self, path: str = "", action: str = "", sheet: str = "",
|
|
239
|
+
data: str = "", **kwargs) -> SkillResult:
|
|
240
|
+
try:
|
|
241
|
+
import openpyxl
|
|
242
|
+
from openpyxl.styles import Font, PatternFill
|
|
243
|
+
from openpyxl.utils import get_column_letter
|
|
244
|
+
except ImportError:
|
|
245
|
+
return SkillResult(success=False, error="openpyxl 未安装")
|
|
246
|
+
|
|
247
|
+
fp = Path(path).expanduser().resolve()
|
|
248
|
+
if not fp.exists():
|
|
249
|
+
return SkillResult(success=False, error=f"文件不存在: {path}")
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
data_parsed = json.loads(data) if isinstance(data, str) else data
|
|
253
|
+
except json.JSONDecodeError as e:
|
|
254
|
+
return SkillResult(success=False, error=f"data JSON 解析失败: {e}")
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
wb = openpyxl.load_workbook(str(fp))
|
|
258
|
+
ws_name = sheet if sheet and sheet in wb.sheetnames else wb.sheetnames[0]
|
|
259
|
+
ws = wb[ws_name]
|
|
260
|
+
|
|
261
|
+
if action == "append_row":
|
|
262
|
+
last_row = ws.max_row
|
|
263
|
+
rows_to_add = data_parsed if isinstance(data_parsed[0], list) else [data_parsed]
|
|
264
|
+
for row_data in rows_to_add:
|
|
265
|
+
last_row += 1
|
|
266
|
+
for col_idx, val in enumerate(row_data, 1):
|
|
267
|
+
ws.cell(row=last_row, column=col_idx, value=val)
|
|
268
|
+
msg = f"追加 {len(rows_to_add)} 行到工作表 '{ws_name}'"
|
|
269
|
+
|
|
270
|
+
elif action == "add_column":
|
|
271
|
+
header = data_parsed.get("header", "New Column")
|
|
272
|
+
values = data_parsed.get("values", [])
|
|
273
|
+
formula = data_parsed.get("formula", "")
|
|
274
|
+
new_col = ws.max_column + 1
|
|
275
|
+
ws.cell(row=1, column=new_col, value=header)
|
|
276
|
+
ws.cell(row=1, column=new_col).font = Font(bold=True, color="FFFFFF")
|
|
277
|
+
ws.cell(row=1, column=new_col).fill = PatternFill(start_color="1A365D", fill_type="solid")
|
|
278
|
+
if formula:
|
|
279
|
+
for i in range(2, ws.max_row + 1):
|
|
280
|
+
cell = ws.cell(row=i, column=new_col)
|
|
281
|
+
cell.value = formula.replace("{ROW}", str(i))
|
|
282
|
+
cell.font = Font(name="Consolas", color="006400")
|
|
283
|
+
elif values:
|
|
284
|
+
for i, val in enumerate(values):
|
|
285
|
+
ws.cell(row=i + 2, column=new_col, value=val)
|
|
286
|
+
msg = f"添加列 '{header}' 到工作表 '{ws_name}'"
|
|
287
|
+
|
|
288
|
+
elif action == "update_cell":
|
|
289
|
+
cell_ref = data_parsed.get("cell", "A1")
|
|
290
|
+
value = data_parsed.get("value", "")
|
|
291
|
+
ws[cell_ref] = value
|
|
292
|
+
msg = f"更新单元格 {cell_ref} = {value}"
|
|
293
|
+
else:
|
|
294
|
+
return SkillResult(success=False, error=f"未知操作: {action}")
|
|
295
|
+
|
|
296
|
+
wb.save(str(fp))
|
|
297
|
+
return SkillResult(
|
|
298
|
+
success=True, message=f"编辑成功: {msg}",
|
|
299
|
+
files=[str(fp)],
|
|
300
|
+
data={"path": str(fp), "action": action, "sheet": ws_name},
|
|
301
|
+
)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
return SkillResult(success=False, error=f"XLSX 编辑失败: {e}")
|