html-to-aippt-composition-skill 1.0.3
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/README.md +0 -0
- package/bin/agentpower-skill.mjs +166 -0
- package/html-to-aippt-composition/SKILL.md +155 -0
- package/html-to-aippt-composition/reference/composition-contract.md +40 -0
- package/html-to-aippt-composition/scripts/ensure-html-to-composition.mjs +189 -0
- package/html-to-aippt-composition/scripts/publish-template.mjs +391 -0
- package/package.json +22 -0
package/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const skillName = "html-to-aippt-composition";
|
|
10
|
+
const bundledSkillDir = path.join(packageRoot, skillName);
|
|
11
|
+
const requiredFiles = [
|
|
12
|
+
"SKILL.md",
|
|
13
|
+
"reference/composition-contract.md",
|
|
14
|
+
"scripts/ensure-html-to-composition.mjs",
|
|
15
|
+
"scripts/publish-template.mjs",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const help = `
|
|
19
|
+
Usage:
|
|
20
|
+
npx @agentpower/html-to-aippt-composition-skill install [--force]
|
|
21
|
+
npx @agentpower/html-to-aippt-composition-skill doctor
|
|
22
|
+
npx @agentpower/html-to-aippt-composition-skill uninstall
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
--codex-home <path> Install under this Codex home instead of CODEX_HOME or ~/.codex.
|
|
26
|
+
--force Replace an existing installed skill.
|
|
27
|
+
--help Show this help.
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
main().catch((error) => {
|
|
31
|
+
console.error(error.message);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function main() {
|
|
36
|
+
const { command, options } = parseArgs(process.argv.slice(2));
|
|
37
|
+
|
|
38
|
+
if (options.help || command === "help") {
|
|
39
|
+
process.stdout.write(help.trimStart());
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (command === "install") {
|
|
44
|
+
install(options);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (command === "doctor") {
|
|
49
|
+
doctor(options);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (command === "uninstall") {
|
|
54
|
+
uninstall(options);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(`Unknown command: ${command || "(none)"}\n\n${help.trimStart()}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function install(options) {
|
|
62
|
+
assertBundledSkill();
|
|
63
|
+
|
|
64
|
+
const installPath = installedSkillPath(options);
|
|
65
|
+
if (existsSync(installPath)) {
|
|
66
|
+
if (!options.force) {
|
|
67
|
+
throw new Error(`${skillName} is already installed at ${installPath}. Re-run with --force to replace it.`);
|
|
68
|
+
}
|
|
69
|
+
rmSync(installPath, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
mkdirSync(path.dirname(installPath), { recursive: true });
|
|
73
|
+
cpSync(bundledSkillDir, installPath, {
|
|
74
|
+
recursive: true,
|
|
75
|
+
filter: (source) => path.basename(source) !== ".env",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
console.log(`Installed ${skillName} to ${installPath}`);
|
|
79
|
+
console.log("Restart Codex to pick up the skill.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function doctor(options) {
|
|
83
|
+
const installPath = installedSkillPath(options);
|
|
84
|
+
if (!existsSync(installPath)) {
|
|
85
|
+
throw new Error(`${skillName} is not installed at ${installPath}. Run install first.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const missing = missingRequiredFiles(installPath);
|
|
89
|
+
if (missing.length > 0) {
|
|
90
|
+
throw new Error(`${skillName} is installed but missing: ${missing.join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`${skillName} is ready at ${installPath}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function uninstall(options) {
|
|
97
|
+
const installPath = installedSkillPath(options);
|
|
98
|
+
if (!existsSync(installPath)) {
|
|
99
|
+
throw new Error(`${skillName} is not installed at ${installPath}.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
rmSync(installPath, { recursive: true, force: true });
|
|
103
|
+
console.log(`Uninstalled ${skillName} from ${installPath}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseArgs(argv) {
|
|
107
|
+
const options = {
|
|
108
|
+
codexHome: process.env.CODEX_HOME || path.join(os.homedir(), ".codex"),
|
|
109
|
+
force: false,
|
|
110
|
+
help: false,
|
|
111
|
+
};
|
|
112
|
+
let command = null;
|
|
113
|
+
|
|
114
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
115
|
+
const token = argv[index];
|
|
116
|
+
if (token === "--help" || token === "-h") {
|
|
117
|
+
options.help = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (token === "--force") {
|
|
121
|
+
options.force = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (token === "--codex-home") {
|
|
125
|
+
const value = argv[index + 1];
|
|
126
|
+
if (!value || value.startsWith("--")) {
|
|
127
|
+
throw new Error("Missing value for --codex-home");
|
|
128
|
+
}
|
|
129
|
+
options.codexHome = path.resolve(value);
|
|
130
|
+
index += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (token.startsWith("--codex-home=")) {
|
|
134
|
+
options.codexHome = path.resolve(token.slice("--codex-home=".length));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (token.startsWith("--")) {
|
|
138
|
+
throw new Error(`Unknown option: ${token}`);
|
|
139
|
+
}
|
|
140
|
+
if (command) {
|
|
141
|
+
throw new Error(`Unexpected argument: ${token}`);
|
|
142
|
+
}
|
|
143
|
+
command = token;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { command: command || "help", options };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function installedSkillPath(options) {
|
|
150
|
+
return path.join(options.codexHome, "skills", skillName);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function assertBundledSkill() {
|
|
154
|
+
if (!existsSync(bundledSkillDir)) {
|
|
155
|
+
throw new Error(`Bundled skill directory not found: ${bundledSkillDir}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const missing = missingRequiredFiles(bundledSkillDir);
|
|
159
|
+
if (missing.length > 0) {
|
|
160
|
+
throw new Error(`Bundled skill is incomplete: ${missing.join(", ")}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function missingRequiredFiles(root) {
|
|
165
|
+
return requiredFiles.filter((relativePath) => !existsSync(path.join(root, relativePath)));
|
|
166
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: html-to-aippt-composition
|
|
3
|
+
description: 当用户需要将 HTML slide、自定义页面布局转换为 AIPPT SlideComposition JSON,或提到发布、上传、审核通过、上架、提交模板等发布类意图时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# HTML 转 AIPPT Composition
|
|
7
|
+
|
|
8
|
+
当用户提供 HTML slide、由截图还原的 HTML、自定义页面布局,并希望生成可用于 AIPPT 在线预览和 PPTX 导出的 `SlideCompositionDocument` JSON 时,使用本 Skill。
|
|
9
|
+
|
|
10
|
+
## 工作流程
|
|
11
|
+
|
|
12
|
+
1. 如果用户表达发布、上传、上架、提交审核、审核通过、发布模板等发布类意图,必须先暂停发布/上传动作,并按顺序收集发布元数据:
|
|
13
|
+
- 先只提示用户输入:`模版标题`。
|
|
14
|
+
- 用户输入标题后,再提示用户输入:`模版描述`。
|
|
15
|
+
- 不要在同一次回复里同时询问标题和描述。
|
|
16
|
+
- 用户输入模版描述后,如果 SDK 输出 JSON 尚未生成,先完成本工作流程的生成和验证步骤;随后执行“发布/上传后台保存流程”。
|
|
17
|
+
- 如果用户已在当前对话明确提供其中一项,只追问缺失项。
|
|
18
|
+
2. 确认 SDK CLI 可用且保持最新。每次使用转换能力前先执行:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
node html-to-aippt-composition/scripts/ensure-html-to-composition.mjs
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
这个脚本是幂等的:如果已经安装且是 npm 最新版本,会直接复用本机 `aippt-html-import`;如果未安装,会安装 `html-to-composition@latest`;如果 npm 上有新版本,会升级到 latest。联调脚本行为时可先执行 `node html-to-aippt-composition/scripts/ensure-html-to-composition.mjs --dry-run`。
|
|
25
|
+
|
|
26
|
+
3. 确认用户提供的 HTML 文件路径,以及是否有图片替换 URL。
|
|
27
|
+
4. 调用 SDK CLI:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
aippt-html-import --html <html-file> --out <composition-json> --pretty
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
如果用户提供了替换图片,按图片出现顺序重复传入 `--image <url>`。
|
|
34
|
+
|
|
35
|
+
批量转换时优先使用 SDK 批量能力,不要用外层脚本手写循环:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
aippt-html-import --html-dir <slides-dir> --out <deck-composition-json> --slide-id-prefix <deck-id> --pretty
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
也可以重复传入 `--html`:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
aippt-html-import --html slide_01.html --html slide_02.html --out deck-composition.json --pretty
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
批量图片替换按“slide 排序顺序 + 每页图片出现顺序”依次消费 `--image`。如果映射复杂,先整理成明确的顺序清单再执行。
|
|
48
|
+
|
|
49
|
+
5. 阅读 CLI 输出摘要、`report` 和 `warnings`,重点检查:
|
|
50
|
+
- `textCount`、`imageCount`、`shapeCount` 是否明显少于源 HTML。
|
|
51
|
+
- 是否存在 `hybrid` 或 `raster` 导出策略。
|
|
52
|
+
- 是否有未使用的图片替换。
|
|
53
|
+
- 批量模式下 `slides.length` 是否等于 HTML 文件数,slide 顺序是否符合文件名排序。
|
|
54
|
+
6. 校验输出文档包含 `version: "aippt-composition/v1"`,并且至少包含一页 slide。
|
|
55
|
+
7. 必须做视觉保真验证:
|
|
56
|
+
- 用浏览器截取源 HTML 作为基准图。
|
|
57
|
+
- 将生成的 JSON 放入 AIPPT composition 渲染器,再截取渲染图。
|
|
58
|
+
- 导出 PPTX,并检查 PPTX 画面是否与浏览器渲染一致。
|
|
59
|
+
- 批量模式下至少抽查首尾页和所有 warning 页;交付前优先逐页验证。
|
|
60
|
+
8. 根据验证结果选择交付等级:
|
|
61
|
+
- `native`:文本、普通图片、基础形状都可编辑,视觉差异小。
|
|
62
|
+
- `hybrid`:文本尽量可编辑,复杂 SVG path、异形裁切、遮罩、滤镜等局部锁定或标记不可编辑。
|
|
63
|
+
- `raster`:差异过大时用整页截图兜底,明确说明可编辑性降低。
|
|
64
|
+
9. 如果视觉结果不完整,优先修正 SDK 的 transform、SVG path、clipPath、z-index、字体或图片替换逻辑;不要只因为 JSON schema 通过就交付。
|
|
65
|
+
|
|
66
|
+
## 发布/上传后台保存流程
|
|
67
|
+
|
|
68
|
+
当用户已经输入 `模版标题` 和 `模版描述` 后,按《PPT 模板生产后台保存对接文档》的“2. 推荐流程”执行。
|
|
69
|
+
|
|
70
|
+
这里的 `template.json` 固定指 SDK 生成的 `SlideCompositionDocument` JSON,也就是本 Skill 通过 `aippt-html-import` 产出的 `slide-composition.json` 或 `deck-composition.json`。不要另造一份模板 JSON,不要上传源 HTML、截图、PPTX 或 render JSON。
|
|
71
|
+
|
|
72
|
+
执行顺序:
|
|
73
|
+
|
|
74
|
+
1. 确认 SDK 已生成并验证 `template.json`:
|
|
75
|
+
- 单页任务使用 SDK 输出的 `slide-composition.json` 作为 `template.json`。
|
|
76
|
+
- 批量任务使用 SDK 输出的 `deck-composition.json` 作为 `template.json`。
|
|
77
|
+
- 上传前必须已完成 JSON 结构校验和视觉保真验证。
|
|
78
|
+
2. 优先调用本 Skill 自带脚本执行后台保存链路:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
node html-to-aippt-composition/scripts/publish-template.mjs \
|
|
82
|
+
--phone <admin-phone> \
|
|
83
|
+
--template-json <slide-composition.json-or-deck-composition.json> \
|
|
84
|
+
--template-code <template-code> \
|
|
85
|
+
--template-version <template-version> \
|
|
86
|
+
--title <模版标题> \
|
|
87
|
+
--description <模版描述>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
首次执行或联调前可以先 dry-run:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
node html-to-aippt-composition/scripts/publish-template.mjs \
|
|
94
|
+
--phone <admin-phone> \
|
|
95
|
+
--template-json <slide-composition.json-or-deck-composition.json> \
|
|
96
|
+
--template-code <template-code> \
|
|
97
|
+
--template-version <template-version> \
|
|
98
|
+
--title <模版标题> \
|
|
99
|
+
--description <模版描述> \
|
|
100
|
+
--dry-run
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
脚本默认使用服务端真实 `api-base`:`https://prism-stone-pre.byering.com`。脚本会依次执行登录、`templateCode` 存在性校验、OSS 预签名、OSS 直传、后台保存,并在成功后输出 `templateJsonObjectKey` 和保存结果。可把 `AIPPT_API_BASE`、`AIPPT_ADMIN_PHONE`、`AIPPT_SMS_CODE`、`AIPPT_TEMPLATE_CODE`、`AIPPT_TEMPLATE_VERSION` 写到 `html-to-aippt-composition/.env`;不要把 `.env` 提交到仓库。
|
|
104
|
+
|
|
105
|
+
3. 如果脚本不可用,才按下面接口顺序手动执行:
|
|
106
|
+
4. Web 登录,拿到 `access_token` Cookie:
|
|
107
|
+
- 登录接口为 `POST /api/web/auth/login`。
|
|
108
|
+
- 请求必须带 `credentials: "include"`。
|
|
109
|
+
- 验证码默认传 `123456`。
|
|
110
|
+
- 不要手写 Cookie,不要把 token 写入 URL、OSS object key、日志或模板 JSON。
|
|
111
|
+
5. 调模板名称校验接口,按 `templateCode` 判断模板是否已存在:
|
|
112
|
+
- 接口为 `POST /admin/deliverable-presentation-templates/exists`。
|
|
113
|
+
- 校验字段是 `templateCode`,不是 `模版标题` / `title`。
|
|
114
|
+
- 如果 `exists=true`,停止上传和保存,提示用户更换 `templateCode`。
|
|
115
|
+
6. 调通用 OSS 预签名接口,为 `template.json` 申请固定 object key 上传凭证:
|
|
116
|
+
- 接口为 `GET /bean/storage/oss/presign`。
|
|
117
|
+
- `fileName` 固定传 `template.json`。
|
|
118
|
+
- `prefix` 推荐 `deliverables/templates/presentation/<templateCode>/<templateVersion>`。
|
|
119
|
+
- `contentType` 传 `application/json`。
|
|
120
|
+
- `publicRead` 固定传 `true`。
|
|
121
|
+
7. 前端把 SDK 生成的 `template.json` 直传 OSS:
|
|
122
|
+
- 使用预签名响应中的 `host`、`objectKey`、`accessId`、`policy`、`signature`、`contentType`、`acl` 组装表单。
|
|
123
|
+
- 表单中的 `key` 必须等于 `presign.objectKey`。
|
|
124
|
+
- `file` 字段放在最后,文件名使用 `template.json`。
|
|
125
|
+
- 直传 OSS 时不要携带业务 Cookie 或 Authorization。
|
|
126
|
+
8. 调 PPT 模板保存接口,只保存模板元数据和 `templateJsonObjectKey`:
|
|
127
|
+
- 接口为 `POST /admin/deliverable-presentation-templates/save`。
|
|
128
|
+
- 请求体包含 `templateCode`、`templateVersion`、`title`、`description`、`templateJsonObjectKey`、`remark`。
|
|
129
|
+
- `title` 使用用户输入的 `模版标题`。
|
|
130
|
+
- `description` 使用用户输入的 `模版描述`。
|
|
131
|
+
- `templateJsonObjectKey` 必须使用 `presign.objectKey`,不能传 URL、publicUrl 或签名 URL。
|
|
132
|
+
- 新建模板默认 `status=0`,启用由运营后台处理,不属于本 Skill 上传流程。
|
|
133
|
+
|
|
134
|
+
如果执行推荐流程所需的 `apiBase`、管理员手机号、`templateCode` 或 `templateVersion` 缺失,先逐项向用户补问缺失项;不要跳过名称校验、OSS 上传或保存步骤。
|
|
135
|
+
|
|
136
|
+
## 质量规则
|
|
137
|
+
|
|
138
|
+
- 将 `SlideCompositionDocument` 视为最终交付协议。
|
|
139
|
+
- 尽可能把文本保留为 native text,方便后续编辑。
|
|
140
|
+
- 普通图片应保留为 native image element。
|
|
141
|
+
- SVG `path`、CSS transform、clipPath、z-index、字体加载和图片裁切是高风险项,必须通过截图或 PPTX 检查验证。
|
|
142
|
+
- 复杂或暂不支持的视觉效果,需要用 `exportPolicy.mode: "hybrid"` / `raster` 标记,或输出 warning。
|
|
143
|
+
- 不要声称任意 HTML 都能完全可编辑,除非浏览器渲染和 PPTX 导出检查已经证明这一点。
|
|
144
|
+
- 当用户要求模板不固定时,不要写模板特判;优先提升 SDK 的通用抽取、保真校验和降级策略。
|
|
145
|
+
|
|
146
|
+
## 交付物
|
|
147
|
+
|
|
148
|
+
- `slide-composition.json`
|
|
149
|
+
- 批量任务交付 `deck-composition.json`,包含多页 `slides`
|
|
150
|
+
- 源 HTML 基准截图
|
|
151
|
+
- AIPPT composition 渲染截图
|
|
152
|
+
- PPTX 导出文件或导出验证结果
|
|
153
|
+
- 文本、图片、形状数量摘要
|
|
154
|
+
- 影响保真度或可编辑性的 warnings
|
|
155
|
+
- `native` / `hybrid` / `raster` 交付等级说明
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Composition 协议
|
|
2
|
+
|
|
3
|
+
SDK 输出的顶层结构为:
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{
|
|
7
|
+
"version": "aippt-composition/v1",
|
|
8
|
+
"slides": []
|
|
9
|
+
}
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
每一页 slide 应为 1280 x 720,并包含可被 AIPPT composition 渲染器消费的 layers:
|
|
13
|
+
|
|
14
|
+
- `background`
|
|
15
|
+
- `media`
|
|
16
|
+
- `structure`
|
|
17
|
+
- `content`
|
|
18
|
+
- `ornament`
|
|
19
|
+
|
|
20
|
+
首版 HTML 导入优先生成 `text`、`image` 和 `shape` 元素。复杂效果应通过 warning 或 `exportPolicy.mode: "hybrid"` 明确标记。
|
|
21
|
+
|
|
22
|
+
## 批量导入
|
|
23
|
+
|
|
24
|
+
批量导入应输出一个 `SlideCompositionDocument`,其中:
|
|
25
|
+
|
|
26
|
+
- `slides.length` 等于输入 HTML 文件数。
|
|
27
|
+
- `--html-dir` 按文件名排序,`--html` 重复传入时按参数顺序。
|
|
28
|
+
- 每页 `slide.id` 必须稳定且唯一。
|
|
29
|
+
- `assets[].id` 必须跨页唯一。
|
|
30
|
+
- 顶层 `warnings` 和 `report` 汇总所有页面。
|
|
31
|
+
- 图片替换按页面顺序、页内图片出现顺序消费。
|
|
32
|
+
|
|
33
|
+
## 保真要求
|
|
34
|
+
|
|
35
|
+
模板不固定时,导入器不能依赖模板特判。必须优先使用通用规则处理:
|
|
36
|
+
|
|
37
|
+
- 坐标、字号、clipPath 都要统一考虑 CSS transform。
|
|
38
|
+
- SVG `path` 不能静默丢弃;可编辑近似形状用 native shape,无法编辑的复杂形状用 `hybrid` 或 `raster` 标记。
|
|
39
|
+
- layer 的 `zIndex` 应接近源 HTML/PPT 的绘制顺序,避免图片盖住前景指标或标签。
|
|
40
|
+
- 生成后必须用源 HTML 截图、composition 渲染截图和 PPTX 导出结果做保真验证。
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const PACKAGE_NAME = "html-to-composition";
|
|
8
|
+
const CLI_NAME = "aippt-html-import";
|
|
9
|
+
|
|
10
|
+
const HELP = `
|
|
11
|
+
Usage:
|
|
12
|
+
node html-to-aippt-composition/scripts/ensure-html-to-composition.mjs
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--dry-run Check installed/latest versions without installing.
|
|
16
|
+
--help Show this help.
|
|
17
|
+
|
|
18
|
+
Behavior:
|
|
19
|
+
- If ${PACKAGE_NAME} is not installed globally, install ${PACKAGE_NAME}@latest.
|
|
20
|
+
- If a newer npm version exists, upgrade the global package to latest.
|
|
21
|
+
- If the installed version is already latest, do nothing.
|
|
22
|
+
- If npm latest cannot be queried but the CLI is installed, keep using the installed CLI.
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
main().catch((error) => {
|
|
26
|
+
console.error(`ERROR: ${error.message}`);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
const args = parseArgs(process.argv.slice(2));
|
|
32
|
+
if (args.help) {
|
|
33
|
+
process.stdout.write(HELP.trimStart());
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const installed = await getInstalledVersion();
|
|
38
|
+
const latest = await getLatestVersion();
|
|
39
|
+
|
|
40
|
+
if (!latest && !installed.version) {
|
|
41
|
+
throw new Error(`Cannot find installed ${PACKAGE_NAME}, and npm latest lookup failed`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const plan = decidePlan(installed.version, latest);
|
|
45
|
+
if (args.dryRun) {
|
|
46
|
+
console.log(JSON.stringify({
|
|
47
|
+
dryRun: true,
|
|
48
|
+
package: PACKAGE_NAME,
|
|
49
|
+
cli: CLI_NAME,
|
|
50
|
+
installedVersion: installed.version,
|
|
51
|
+
latestVersion: latest,
|
|
52
|
+
action: plan.action,
|
|
53
|
+
reason: plan.reason,
|
|
54
|
+
}, null, 2));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (plan.action === "use-installed") {
|
|
59
|
+
console.log(`${PACKAGE_NAME} ${installed.version} is ready (${plan.reason}).`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`${plan.reason}; running npm install -g ${PACKAGE_NAME}@latest`);
|
|
64
|
+
await installLatest();
|
|
65
|
+
const afterInstall = await getInstalledVersion();
|
|
66
|
+
if (!afterInstall.version) {
|
|
67
|
+
throw new Error(`${PACKAGE_NAME} installation finished, but global package was not detected`);
|
|
68
|
+
}
|
|
69
|
+
console.log(`${PACKAGE_NAME} ${afterInstall.version} is ready.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseArgs(argv) {
|
|
73
|
+
const args = {};
|
|
74
|
+
for (const arg of argv) {
|
|
75
|
+
if (arg === "--help") {
|
|
76
|
+
args.help = true;
|
|
77
|
+
} else if (arg === "--dry-run") {
|
|
78
|
+
args.dryRun = true;
|
|
79
|
+
} else {
|
|
80
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return args;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function getInstalledVersion() {
|
|
87
|
+
const npmLs = await run("npm", ["ls", "-g", PACKAGE_NAME, "--json", "--depth=0"], { allowFailure: true });
|
|
88
|
+
if (npmLs.ok) {
|
|
89
|
+
const version = parseInstalledVersion(npmLs.stdout);
|
|
90
|
+
if (version) {
|
|
91
|
+
return { version, source: "npm-ls" };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const which = await run(process.platform === "win32" ? "where" : "which", [CLI_NAME], { allowFailure: true });
|
|
96
|
+
if (!which.ok) {
|
|
97
|
+
return { version: null, source: "missing" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { version: null, source: "cli-present-version-unknown" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseInstalledVersion(stdout) {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(stdout);
|
|
106
|
+
return parsed.dependencies?.[PACKAGE_NAME]?.version || null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function getLatestVersion() {
|
|
113
|
+
const result = await run("npm", ["view", PACKAGE_NAME, "version"], { allowFailure: true });
|
|
114
|
+
if (!result.ok) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return result.stdout.trim() || null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function decidePlan(installedVersion, latestVersion) {
|
|
121
|
+
if (!latestVersion) {
|
|
122
|
+
return {
|
|
123
|
+
action: "use-installed",
|
|
124
|
+
reason: "npm latest lookup failed; using installed CLI",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!installedVersion) {
|
|
129
|
+
return {
|
|
130
|
+
action: "install",
|
|
131
|
+
reason: `${PACKAGE_NAME} is not installed globally`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (compareSemver(installedVersion, latestVersion) < 0) {
|
|
136
|
+
return {
|
|
137
|
+
action: "upgrade",
|
|
138
|
+
reason: `${PACKAGE_NAME} ${installedVersion} is older than latest ${latestVersion}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
action: "use-installed",
|
|
144
|
+
reason: `${PACKAGE_NAME} ${installedVersion} is already latest ${latestVersion}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function installLatest() {
|
|
149
|
+
await run("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], { stdio: "inherit" });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function compareSemver(left, right) {
|
|
153
|
+
const a = semverParts(left);
|
|
154
|
+
const b = semverParts(right);
|
|
155
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
156
|
+
const diff = (a[i] || 0) - (b[i] || 0);
|
|
157
|
+
if (diff !== 0) {
|
|
158
|
+
return diff;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function semverParts(version) {
|
|
165
|
+
return version
|
|
166
|
+
.split(/[.-]/)
|
|
167
|
+
.slice(0, 3)
|
|
168
|
+
.map((part) => Number.parseInt(part, 10))
|
|
169
|
+
.map((part) => Number.isFinite(part) ? part : 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function run(command, args, options = {}) {
|
|
173
|
+
try {
|
|
174
|
+
const result = await execFileAsync(command, args, {
|
|
175
|
+
encoding: "utf8",
|
|
176
|
+
stdio: options.stdio,
|
|
177
|
+
});
|
|
178
|
+
return { ok: true, stdout: result.stdout || "", stderr: result.stderr || "" };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (options.allowFailure) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
stdout: error.stdout || "",
|
|
184
|
+
stderr: error.stderr || error.message,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const skillDir = path.resolve(scriptDir, '..');
|
|
10
|
+
const DEFAULT_API_BASE = 'https://prism-stone-pre.byering.com';
|
|
11
|
+
|
|
12
|
+
const HELP = `
|
|
13
|
+
Usage:
|
|
14
|
+
node html-to-aippt-composition/scripts/publish-template.mjs \\
|
|
15
|
+
--phone 13800138001 \\
|
|
16
|
+
--template-json ./slide-composition.json \\
|
|
17
|
+
--template-code business_summary \\
|
|
18
|
+
--template-version 1.0.0 \\
|
|
19
|
+
--title "经营分析汇报" \\
|
|
20
|
+
--description "用于 PPT/PDF 演示生成"
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--api-base Java API base URL. Defaults to ${DEFAULT_API_BASE}.
|
|
24
|
+
--phone Admin login phone.
|
|
25
|
+
--sms-code SMS code. Defaults to 123456.
|
|
26
|
+
--template-json SDK generated template JSON path.
|
|
27
|
+
--template-code Stable unique template code.
|
|
28
|
+
--template-version Template version. Defaults to 1.0.0.
|
|
29
|
+
--title Template display title.
|
|
30
|
+
--description Template description.
|
|
31
|
+
--remark Save remark. Defaults to "前端 skill 生成并上传".
|
|
32
|
+
--env Env file path. Defaults to html-to-aippt-composition/.env.
|
|
33
|
+
--dry-run Validate inputs and print the request plan without calling APIs.
|
|
34
|
+
--help Show this help.
|
|
35
|
+
|
|
36
|
+
Env fallback:
|
|
37
|
+
AIPPT_API_BASE, AIPPT_ADMIN_PHONE, AIPPT_SMS_CODE,
|
|
38
|
+
AIPPT_TEMPLATE_CODE, AIPPT_TEMPLATE_VERSION
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
main().catch((error) => {
|
|
42
|
+
console.error(`ERROR: ${error.message}`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function main() {
|
|
47
|
+
const args = parseArgs(process.argv.slice(2));
|
|
48
|
+
if (args.help) {
|
|
49
|
+
process.stdout.write(HELP.trimStart());
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const envPath = path.resolve(args.env || path.join(skillDir, '.env'));
|
|
54
|
+
const fileEnv = await readEnvFile(envPath);
|
|
55
|
+
const options = normalizeOptions(args, fileEnv);
|
|
56
|
+
const templateJson = await readAndValidateTemplateJson(options.templateJsonPath);
|
|
57
|
+
|
|
58
|
+
const plan = {
|
|
59
|
+
apiBase: options.apiBase,
|
|
60
|
+
templateJson: options.templateJsonPath,
|
|
61
|
+
templateCode: options.templateCode,
|
|
62
|
+
templateVersion: options.templateVersion,
|
|
63
|
+
title: options.title,
|
|
64
|
+
description: options.description,
|
|
65
|
+
prefix: templatePrefix(options.templateCode, options.templateVersion),
|
|
66
|
+
endpoints: [
|
|
67
|
+
'POST /api/web/auth/login',
|
|
68
|
+
'POST /admin/deliverable-presentation-templates/exists',
|
|
69
|
+
'GET /bean/storage/oss/presign',
|
|
70
|
+
'POST <oss-host>',
|
|
71
|
+
'POST /admin/deliverable-presentation-templates/save',
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (options.dryRun) {
|
|
76
|
+
console.log(JSON.stringify({ dryRun: true, plan }, null, 2));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cookieJar = new CookieJar();
|
|
81
|
+
await login(options, cookieJar);
|
|
82
|
+
await assertTemplateCodeAvailable(options, cookieJar);
|
|
83
|
+
const presign = await requestPresign(options, cookieJar);
|
|
84
|
+
await uploadTemplateJson(templateJson, presign);
|
|
85
|
+
const saved = await saveTemplate(options, presign.objectKey, cookieJar);
|
|
86
|
+
|
|
87
|
+
console.log(
|
|
88
|
+
JSON.stringify(
|
|
89
|
+
{
|
|
90
|
+
ok: true,
|
|
91
|
+
templateCode: options.templateCode,
|
|
92
|
+
templateVersion: options.templateVersion,
|
|
93
|
+
templateJsonObjectKey: presign.objectKey,
|
|
94
|
+
saved: saved.entity ?? saved,
|
|
95
|
+
},
|
|
96
|
+
null,
|
|
97
|
+
2,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseArgs(argv) {
|
|
103
|
+
const args = {};
|
|
104
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
105
|
+
const token = argv[i];
|
|
106
|
+
if (!token.startsWith('--')) {
|
|
107
|
+
throw new Error(`Unexpected argument: ${token}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const [rawKey, inlineValue] = token.slice(2).split(/=(.*)/s, 2);
|
|
111
|
+
const key = toCamelCase(rawKey);
|
|
112
|
+
if (key === 'help' || key === 'dryRun') {
|
|
113
|
+
args[key] = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const value = inlineValue !== undefined ? inlineValue : argv[i + 1];
|
|
118
|
+
if (!value || value.startsWith('--')) {
|
|
119
|
+
throw new Error(`Missing value for --${rawKey}`);
|
|
120
|
+
}
|
|
121
|
+
args[key] = value;
|
|
122
|
+
if (inlineValue === undefined) {
|
|
123
|
+
i += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return args;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function toCamelCase(value) {
|
|
130
|
+
return value.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function readEnvFile(envPath) {
|
|
134
|
+
if (!existsSync(envPath)) {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const content = await readFile(envPath, 'utf8');
|
|
139
|
+
const env = {};
|
|
140
|
+
for (const line of content.split(/\r?\n/)) {
|
|
141
|
+
const trimmed = line.trim();
|
|
142
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const index = trimmed.indexOf('=');
|
|
146
|
+
if (index === -1) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const key = trimmed.slice(0, index).trim();
|
|
150
|
+
const value = trimmed
|
|
151
|
+
.slice(index + 1)
|
|
152
|
+
.trim()
|
|
153
|
+
.replace(/^['"]|['"]$/g, '');
|
|
154
|
+
env[key] = value;
|
|
155
|
+
}
|
|
156
|
+
return env;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeOptions(args, fileEnv) {
|
|
160
|
+
const options = {
|
|
161
|
+
apiBase: args.apiBase || fileEnv.AIPPT_API_BASE || DEFAULT_API_BASE,
|
|
162
|
+
phone: args.phone || fileEnv.AIPPT_ADMIN_PHONE,
|
|
163
|
+
smsCode: args.smsCode || fileEnv.AIPPT_SMS_CODE || '123456',
|
|
164
|
+
templateJsonPath: args.templateJson,
|
|
165
|
+
templateCode: args.templateCode || fileEnv.AIPPT_TEMPLATE_CODE,
|
|
166
|
+
templateVersion: args.templateVersion || fileEnv.AIPPT_TEMPLATE_VERSION || '1.0.0',
|
|
167
|
+
title: args.title,
|
|
168
|
+
description: args.description,
|
|
169
|
+
remark: args.remark || '前端 skill 生成并上传',
|
|
170
|
+
dryRun: Boolean(args.dryRun),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const missing = [];
|
|
174
|
+
for (const [key, value] of Object.entries(options)) {
|
|
175
|
+
if (key === 'remark' || key === 'dryRun') {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!value) {
|
|
179
|
+
missing.push(key);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (missing.length > 0) {
|
|
183
|
+
throw new Error(`Missing required option(s): ${missing.join(', ')}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
options.apiBase = options.apiBase.replace(/\/+$/, '');
|
|
187
|
+
options.templateJsonPath = path.resolve(options.templateJsonPath);
|
|
188
|
+
validateTemplateCode(options.templateCode);
|
|
189
|
+
validateTemplateVersion(options.templateVersion);
|
|
190
|
+
return options;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateTemplateCode(templateCode) {
|
|
194
|
+
if (!/^[a-z0-9_-]+$/.test(templateCode)) {
|
|
195
|
+
throw new Error('templateCode must use lowercase letters, numbers, underscores, or hyphens');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validateTemplateVersion(templateVersion) {
|
|
200
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(templateVersion)) {
|
|
201
|
+
throw new Error('templateVersion must look like semantic version, for example 1.0.0');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function readAndValidateTemplateJson(templateJsonPath) {
|
|
206
|
+
let parsed;
|
|
207
|
+
try {
|
|
208
|
+
parsed = JSON.parse(await readFile(templateJsonPath, 'utf8'));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new Error(`Cannot read or parse template JSON: ${error.message}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (parsed.version !== 'aippt-composition/v1') {
|
|
214
|
+
throw new Error('template.json must contain version: "aippt-composition/v1"');
|
|
215
|
+
}
|
|
216
|
+
if (!Array.isArray(parsed.slides) || parsed.slides.length === 0) {
|
|
217
|
+
throw new Error('template.json must contain a non-empty slides array');
|
|
218
|
+
}
|
|
219
|
+
return parsed;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function login(options, cookieJar) {
|
|
223
|
+
const payload = await fetchJson(
|
|
224
|
+
apiUrl(options.apiBase, '/api/web/auth/login'),
|
|
225
|
+
{
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: { 'Content-Type': 'application/json' },
|
|
228
|
+
body: JSON.stringify({ phone: options.phone, smsCode: options.smsCode }),
|
|
229
|
+
},
|
|
230
|
+
cookieJar,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
if (!payload.entity?.userInfo && !payload.entity?.accessToken) {
|
|
234
|
+
throw new Error('Login response did not include expected entity data');
|
|
235
|
+
}
|
|
236
|
+
if (!cookieJar.hasCookies()) {
|
|
237
|
+
throw new Error('Login did not return cookies');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function assertTemplateCodeAvailable(options, cookieJar) {
|
|
242
|
+
const payload = await fetchJson(
|
|
243
|
+
apiUrl(options.apiBase, '/admin/deliverable-presentation-templates/exists'),
|
|
244
|
+
{
|
|
245
|
+
method: 'POST',
|
|
246
|
+
headers: { 'Content-Type': 'application/json' },
|
|
247
|
+
body: JSON.stringify({ templateCode: options.templateCode }),
|
|
248
|
+
},
|
|
249
|
+
cookieJar,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (typeof payload.entity?.exists !== 'boolean') {
|
|
253
|
+
throw new Error('Exists response did not include entity.exists');
|
|
254
|
+
}
|
|
255
|
+
if (payload.entity?.exists) {
|
|
256
|
+
throw new Error(`templateCode already exists: ${options.templateCode}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function requestPresign(options, cookieJar) {
|
|
261
|
+
const url = apiUrl(options.apiBase, '/bean/storage/oss/presign');
|
|
262
|
+
url.searchParams.set('fileName', 'template.json');
|
|
263
|
+
url.searchParams.set('prefix', templatePrefix(options.templateCode, options.templateVersion));
|
|
264
|
+
url.searchParams.set('contentType', 'application/json');
|
|
265
|
+
url.searchParams.set('publicRead', 'true');
|
|
266
|
+
|
|
267
|
+
const payload = await fetchJson(url, { method: 'GET' }, cookieJar);
|
|
268
|
+
const presign = payload.entity;
|
|
269
|
+
const required = ['accessId', 'policy', 'signature', 'host', 'objectKey'];
|
|
270
|
+
const missing = required.filter((key) => !presign?.[key]);
|
|
271
|
+
if (missing.length > 0) {
|
|
272
|
+
throw new Error(`Presign response missing field(s): ${missing.join(', ')}`);
|
|
273
|
+
}
|
|
274
|
+
if (presign.objectKey !== `${templatePrefix(options.templateCode, options.templateVersion)}/template.json`) {
|
|
275
|
+
throw new Error(`Unexpected presign objectKey: ${presign.objectKey}`);
|
|
276
|
+
}
|
|
277
|
+
return presign;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function uploadTemplateJson(templateJson, presign) {
|
|
281
|
+
const contentType = presign.contentType || 'application/json';
|
|
282
|
+
const formData = new FormData();
|
|
283
|
+
formData.append('key', presign.objectKey);
|
|
284
|
+
formData.append('OSSAccessKeyId', presign.accessId);
|
|
285
|
+
formData.append('policy', presign.policy);
|
|
286
|
+
formData.append('Signature', presign.signature);
|
|
287
|
+
if (presign.contentType) {
|
|
288
|
+
formData.append('Content-Type', presign.contentType);
|
|
289
|
+
}
|
|
290
|
+
formData.append('x-oss-object-acl', presign.acl || 'public-read');
|
|
291
|
+
formData.append('file', new Blob([JSON.stringify(templateJson)], { type: contentType }), 'template.json');
|
|
292
|
+
|
|
293
|
+
const response = await fetch(presign.host, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
body: formData,
|
|
296
|
+
});
|
|
297
|
+
if (!response.ok) {
|
|
298
|
+
throw new Error(`OSS upload failed: HTTP ${response.status} ${await response.text()}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function saveTemplate(options, templateJsonObjectKey, cookieJar) {
|
|
303
|
+
return fetchJson(
|
|
304
|
+
apiUrl(options.apiBase, '/admin/deliverable-presentation-templates/save'),
|
|
305
|
+
{
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: { 'Content-Type': 'application/json' },
|
|
308
|
+
body: JSON.stringify({
|
|
309
|
+
templateCode: options.templateCode,
|
|
310
|
+
templateVersion: options.templateVersion,
|
|
311
|
+
title: options.title,
|
|
312
|
+
description: options.description,
|
|
313
|
+
templateJsonObjectKey,
|
|
314
|
+
remark: options.remark,
|
|
315
|
+
}),
|
|
316
|
+
},
|
|
317
|
+
cookieJar,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function templatePrefix(templateCode, templateVersion) {
|
|
322
|
+
return `deliverables/templates/presentation/${templateCode}/${templateVersion}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function apiUrl(apiBase, pathname) {
|
|
326
|
+
const base = new URL(apiBase.endsWith('/') ? apiBase : `${apiBase}/`);
|
|
327
|
+
const basePath = base.pathname.replace(/\/$/, '');
|
|
328
|
+
base.pathname = `${basePath}${pathname}`;
|
|
329
|
+
return base;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function fetchJson(url, init, cookieJar) {
|
|
333
|
+
const headers = new Headers(init.headers || {});
|
|
334
|
+
if (cookieJar?.hasCookies()) {
|
|
335
|
+
headers.set('Cookie', cookieJar.header());
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const response = await fetch(url, {
|
|
339
|
+
...init,
|
|
340
|
+
credentials: 'include',
|
|
341
|
+
headers,
|
|
342
|
+
});
|
|
343
|
+
cookieJar?.storeFrom(response.headers);
|
|
344
|
+
|
|
345
|
+
const text = await response.text();
|
|
346
|
+
let payload = {};
|
|
347
|
+
if (text) {
|
|
348
|
+
try {
|
|
349
|
+
payload = JSON.parse(text);
|
|
350
|
+
} catch {
|
|
351
|
+
throw new Error(`Expected JSON from ${url.pathname}, got: ${text.slice(0, 200)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!response.ok) {
|
|
356
|
+
throw new Error(`HTTP ${response.status} from ${url.pathname}: ${JSON.stringify(payload)}`);
|
|
357
|
+
}
|
|
358
|
+
return payload;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
class CookieJar {
|
|
362
|
+
#cookies = new Map();
|
|
363
|
+
|
|
364
|
+
storeFrom(headers) {
|
|
365
|
+
const rawCookies = typeof headers.getSetCookie === 'function' ? headers.getSetCookie() : splitSetCookieHeader(headers.get('set-cookie'));
|
|
366
|
+
|
|
367
|
+
for (const cookie of rawCookies) {
|
|
368
|
+
const pair = cookie.split(';', 1)[0];
|
|
369
|
+
const index = pair.indexOf('=');
|
|
370
|
+
if (index === -1) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
this.#cookies.set(pair.slice(0, index), pair.slice(index + 1));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
hasCookies() {
|
|
378
|
+
return this.#cookies.size > 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
header() {
|
|
382
|
+
return [...this.#cookies.entries()].map(([key, value]) => `${key}=${value}`).join('; ');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function splitSetCookieHeader(header) {
|
|
387
|
+
if (!header) {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
return header.split(/,(?=\s*[^;=]+=[^;]+)/g).map((value) => value.trim());
|
|
391
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "html-to-aippt-composition-skill",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"html-to-aippt-composition-skill": "./bin/agentpower-skill.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"html-to-aippt-composition/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test test/*.test.mjs",
|
|
16
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"license": "UNLICENSED"
|
|
22
|
+
}
|