open-kimi-ppt-skill PPTD 中间层 + 本地编辑器
这个项目把非官方 Kimi Slides 能力包装成可安装的 Agent Skill:Agent 产出 PPTD 项目,人类可在本地浏览器编辑,再通过官方前端导出 PPTX 或图片总览。源码亮点在于安装事务、文件系统安全边界、iframe RPC 桥和导出后 ZIP/XML 校验。
它解决什么,凭什么值得拆
open-kimi-ppt-skill 的核心不是再写一个 PPTX 生成库,而是把 Agent 友好的 PPTD 中间格式、本地可视化编辑器和官方 Kimi 前端导出器串成一条交付链。入口 CLI 在 lib/editor-server.js:26 和 lib/editor-server.js:82 之间暴露本地静态服务,安装命令在 lib/editor-server.js:64 这类 HTTP 响应边界之外由 CLI 处理,从而把“装 skill”和“开编辑器”分成两个明确动作。
项目最值得关注的抽象是 PPTD。浏览器侧的 lib/editor-lib.js:67 负责从 manifest 解析页面列表,lib/editor-lib.js:111 又把可写路径限制为 .pptd 和 .page,避免编辑器把 Kimi iframe 的保存请求扩展成任意文件写入。这个约束让本地编辑器可以自动保存,但不会把 media 或系统路径暴露给远端编辑内核。
安装入口:先 staging,后 rename
▸CLI 把 install 和 serve 合并到一个二进制
参数解析在 lib/editor-server.js:17 一类 HTTP 层之外完成,真正的 CLI 逻辑来自快照中的 bin 文件;但可验证源快照里,服务入口仍由 lib/editor-server.js:82 返回 server 和 url,供 CLI 的 serve 命令使用。安装路径的事务性体现在 CLI 源码 L104-L135:先复制到临时 staging,再用 rename 切换,最后清理 staging。
26export function createEditorServer({ editorDirectory = defaultEditorDirectory } = {}) { 27 const root = resolve(editorDirectory); 28 const rootPrefix = `${root}${sep}`; 29 30 return createServer((request, response) => { 31 if (request.method !== "GET" && request.method !== "HEAD") { 32 respond(response, 405, "Method Not Allowed"); 33 return; 34 } 35 36 let pathname; 37 try { 38 pathname = decodeURIComponent(new URL(request.url ?? "/", "http://localhost").pathname); 39 } catch { 40 respond(response, 400, "Bad Request"); 41 return; 42 } 43 44 if (pathname.endsWith("/")) pathname += "index.html"; 45 const filePath = resolve(root, `.${pathname}`); 46 if (filePath !== root && !filePath.startsWith(rootPrefix)) { 47 respond(response, 403, "Forbidden"); 48 return; 49 }
▸HTTP 宿主只负责静态编辑器,不承担项目文件 API
本地服务器的安全性来自极小职责面:lib/editor-server.js:30 拒绝非 GET/HEAD,lib/editor-server.js:45 用 resolve 拼出文件路径,lib/editor-server.js:46 检查结果仍在 editor 根目录下。这样 serve 命令不会把用户 PPTD 项目作为 HTTP 文件树暴露出去,项目文件读取和写入都留在浏览器 File System Access API 侧。
51 let stats; 52 try { 53 stats = statSync(filePath); 54 } catch { 55 respond(response, 404, "Not Found"); 56 return; 57 } 58 59 if (!stats.isFile()) { 60 respond(response, 404, "Not Found"); 61 return; 62 } 63 64 response.writeHead(200, { 65 "Content-Type": contentTypes.get(extname(filePath).toLowerCase()) ?? "application/octet-stream", 66 "Content-Length": stats.size, 67 "Cache-Control": "no-store", 68 "X-Content-Type-Options": "nosniff", 69 }); 70 71 if (request.method === "HEAD") { 72 response.end(); 73 return; 74 } 75 76 const stream = createReadStream(filePath); 77 stream.on("error", () => response.destroy()); 78 stream.pipe(response); 79 });
编辑器宿主:把官方 iframe 变成本地工作台
▸功能开关通过 query 明确传给 Kimi 前端
编辑器不是把 Kimi 页面当黑盒 iframe 直接嵌进来。lib/editor-app.js:113 构造 sdkMode、functional、sdkSaveMode 和 sdkImageMode,明确启用 fullscreen、present、export,同时关闭 share、annotation、feedback 等不需要的功能。这让本地宿主对公开前端的能力面有一层声明式约束。
113function editorQuery() { 114 return new URLSearchParams({ 115 sdkMode: "ppt-editor", 116 pptPlatform: "neodeck-local", 117 functional: JSON.stringify({ 118 fullscreen: true, 119 present: true, 120 export: true, 121 close: false, 122 annotation: false, 123 feedback: false, 124 share: false, 125 versionHistory: false, 126 }), 127 sdkSaveMode: "external", 128 sdkImageMode: "external", 129 }); 130}
▸Demo deck 也是同一套 payload 结构
内置 demo 在 lib/editor-app.js:132 到 lib/editor-app.js:218 里直接构造 manifest、pages、animations,并走与真实文件夹相同的 setDeck 通道。这个设计使“示例载入”和“用户项目载入”共享远端 setPPTD 契约,减少两套逻辑不一致。
225async function setDeck(payload, sourceLabel) { 226 const remote = requireRemote(); 227 setLoading(true, "正在载入 PPTD", sourceLabel); 228 state.lastDeckPayload = payload; 229 state.manifestContent = payload.manifestContent; 230 state.manifestPath = payload.manifestPath; 231 state.manifestDirectory = dirname(payload.manifestPath); 232 state.deckTitle = payload.title; 233 elements.documentTitle.textContent = payload.title; 234 elements.documentPath.textContent = sourceLabel; 235 elements.reload.disabled = false; 236 237 await remote.setPPTD(payload.id, { 238 pptdContent: payload.manifestContent, 239 pages: payload.pages, 240 basePath: payload.basePath, 241 pptdPath: payload.manifestPath, 242 isCreate: true, 243 }); 244 await remote.setEditable(true); 245 const status = await remote.getSlideStatus(); 246 setLoading(false); 247 addActivity(`已载入「${payload.title}」,共 ${payload.pages.length} 页`, "success"); 248 document.documentElement.dataset.deckStatus = "ready"; 249 window.dispatchEvent(new CustomEvent("neodeck:ready", { detail: status }));
PPTD 解析:YAML 和 JSON 两种 manifest 都吃
▸路径归一化先把危险输入挡住
lib/editor-lib.js:1 是整个文件系统边界的第一道门。它拒绝空路径、NUL、绝对路径、Windows 盘符和 .. 段,再把可用路径压缩成相对路径。后续 lib/editor-lib.js:40 和 lib/editor-lib.js:111 都基于这个函数做组合,因此路径逃逸不会被遗漏在某个调用点。
1export function normalizeRelativePath(value) { 2 if (typeof value !== "string") { 3 throw new TypeError("文件路径必须是字符串"); 4 } 5 6 let path = value.trim().replaceAll("\\", "/"); 7 try { 8 path = decodeURIComponent(path); 9 } catch { 10 // Keep the original path when it contains a literal percent sign. 11 } 12 13 path = path.replace(/^file:\/\/+/, "").replace(/^\.\//, ""); 14 if (!path || path.includes("\0") || path.startsWith("/") || /^[A-Za-z]:\//.test(path)) { 15 throw new Error(`不允许的绝对路径:${value}`); 16 } 17 18 const parts = []; 19 for (const part of path.split("/")) { 20 if (!part || part === ".") continue; 21 if (part === "..") throw new Error(`不允许越过项目目录:${value}`); 22 parts.push(part); 23 } 24 25 if (parts.length === 0) throw new Error(`无效文件路径:${value}`); 26 return parts.join("/"); 27}
▸manifest 解析只抽取 pages 和 title
PPTD manifest 的读取非常克制。lib/editor-lib.js:67 支持 JSON pages,也支持 YAML pages 列表;lib/editor-lib.js:98 只额外抽 title。它没有尝试实现完整 YAML 解释器,而是抽取编辑器载入所需的最小字段。这个取舍降低了 parser 复杂度,但也意味着格式兼容范围要靠测试守住。
67export function extractPagePaths(manifestText) { 68 const trimmed = manifestText.trim(); 69 if (!trimmed) throw new Error("PPTD manifest 是空文件"); 70 71 if (trimmed.startsWith("{")) { 72 const parsed = JSON.parse(trimmed); 73 if (!Array.isArray(parsed.pages)) throw new Error("PPTD manifest 缺少 pages 数组"); 74 return parsed.pages.map((path) => normalizeRelativePath(String(path))); 75 } 76 77 const lines = manifestText.replaceAll("\r\n", "\n").split("\n"); 78 const pageHeader = lines.findIndex((line) => /^(\s*)pages\s*:\s*(?:#.*)?$/.test(line)); 79 if (pageHeader === -1) throw new Error("PPTD manifest 缺少 pages 列表"); 80 81 const headerIndent = lines[pageHeader].match(/^\s*/)[0].length; 82 const pages = []; 83 for (let index = pageHeader + 1; index < lines.length; index += 1) { 84 const line = lines[index]; 85 if (!line.trim() || line.trimStart().startsWith("#")) continue; 86 const indent = line.match(/^\s*/)[0].length; 87 if (indent <= headerIndent && !/^\s*-/.test(line)) break; 88 const match = line.match(/^\s*-\s*(.+?)\s*$/); 89 if (!match) break; 90 const path = unquoteYamlScalar(match[1]); 91 if (path) pages.push(normalizeRelativePath(path)); 92 } 93 94 if (pages.length === 0) throw new Error("PPTD manifest 的 pages 列表为空"); 95 return pages; 96}
保存事务:限制变更、备份、失败回滚
▸文件夹打开先拿 readwrite 授权
真实项目载入从 lib/editor-app.js:364 开始,先查询和请求 readwrite 权限,再索引目录并寻找 .pptd。只读 fallback 在 lib/editor-app.js:385 和 lib/editor-app.js:397 分开处理,用户可以预览但不能写回。这种分支避免在非 Chromium 或无授权场景下假装“保存成功”。
364async function openDirectoryHandle(directoryHandle, label = directoryHandle.name) { 365 setLoading(true, "正在扫描文件夹", label); 366 let permission = await directoryHandle.queryPermission?.({ mode: "readwrite" }); 367 if (permission !== "granted") permission = await directoryHandle.requestPermission?.({ mode: "readwrite" }); 368 if (permission !== "granted") throw new Error("没有获得文件夹读写权限"); 369 370 state.source = "directory"; 371 state.directoryHandle = directoryHandle; 372 state.readOnlyFallback = false; 373 state.fileIndex = await indexDirectory(directoryHandle); 374 state.imageCache.clear(); 375 const manifests = [...state.fileIndex.keys()].filter((path) => path.toLowerCase().endsWith(".pptd")); 376 if (manifests.length === 0) throw new Error("文件夹里没有找到 .pptd 清单文件"); 377 const manifestPath = await chooseManifest(manifests.sort()); 378 if (!manifestPath) { 379 setLoading(false); 380 return; 381 } 382 await loadManifest(manifestPath, `${label}/${manifestPath}`); 383}
▸保存队列把 iframe 回调串行化
保存链路是全项目最硬的工程点。lib/editor-app.js:556 先限制变更数量,lib/editor-app.js:561 把每个 change 规范化并通过 lib/editor-lib.js:111 校验可写路径,lib/editor-app.js:587 读取备份,写入失败时 lib/editor-app.js:595 回滚。外层 lib/editor-app.js:606 用 saveQueue 串行化远端 onSave 回调,避免多个保存请求交错覆盖。
556async function persistChanges(payload = {}) { 557 const changes = Array.isArray(payload.changes) ? payload.changes : []; 558 if (changes.length === 0) return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; 559 if (changes.length > MAX_CHANGE_COUNT) throw new Error(`一次保存不能超过 ${MAX_CHANGE_COUNT} 个文件`); 560 561 const normalized = changes.map((change) => { 562 const path = assertWritableChangePath(state.manifestDirectory, change.path); 563 const operation = change.operate === "delete" ? "delete" : "put"; 564 const content = operation === "put" ? String(change.content ?? "") : null; 565 if (content && new Blob([content]).size > MAX_TEXT_BYTES) throw new Error(`保存内容超过 20 MiB:${path}`); 566 return { path, operation, content }; 567 }); 568 569 if (state.source === "demo") { 570 for (const change of normalized) { 571 if (change.operation === "delete") state.memoryFiles.delete(change.path); 572 else state.memoryFiles.set(change.path, change.content); 573 } 574 setSaveState("刚刚保存到内存", "memory"); 575 addActivity(`已在内存保存 ${normalized.length} 项变更`, "success"); 576 return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; 577 } 578 579 if (state.readOnlyFallback || !state.directoryHandle) { 580 throw new Error("当前是只读兼容模式,请用支持文件夹授权的 Chromium 浏览器打开"); 581 } 582 583 const permission = await state.directoryHandle.queryPermission({ mode: "readwrite" }); 584 if (permission !== "granted") throw new Error("项目文件夹的写入权限已经失效"); 585 586 setSaveState("正在保存…", "saving"); 587 const backups = new Map(); 588 try { 589 for (const change of normalized) backups.set(change.path, await readBackup(change.path)); 590 for (const change of normalized) { 591 if (change.operation === "delete") await deleteFile(change.path); 592 else await writeFile(change.path, change.content); 593 } 594 } catch (error) { 595 await rollbackChanges(backups).catch((rollbackError) => { 596 addActivity(`回滚未完全成功:${rollbackError.message}`, "error"); 597 }); 598 throw error; 599 } 600 601 setSaveState("刚刚已保存", "saved"); 602 addActivity(`已保存 ${normalized.length} 个文件变更`, "success"); 603 return { fileContent: payload.fileContent, lastModifiedTime: Date.now() }; 604}
PPTX 导出:浏览器生成,Python 验证
▸payload 只暴露 manifest、pages 和 imageMap
导出脚本没有把本地目录裸传给浏览器。scripts/export_pptx.py:196 读取 manifest,scripts/export_pptx.py:204 定位项目根,scripts/export_pptx.py:206 按 pages 列表逐页读文件,最后 scripts/export_pptx.py:216 生成 id、manifestContent、pages 和 imageMap。这个 payload 是导出器的最小输入。
196def build_payload(manifest: Path) -> Dict[str, Any]: 197 manifest_text, manifest_data = read_yaml_mapping(manifest) 198 if manifest_data.get("version") != "v2": 199 raise ExportError("local PPTX export currently requires PPTD version: v2") 200 page_paths = manifest_data.get("pages") 201 if not isinstance(page_paths, list) or not page_paths: 202 raise ExportError("PPTD manifest must contain a non-empty pages list") 203 204 root = manifest.parent.resolve() 205 pages: List[Dict[str, str]] = [] 206 for entry in page_paths: 207 page_path = safe_project_path(root, entry) 208 if not page_path.is_file(): 209 raise ExportError(f"missing page file: {entry}") 210 page_text, page_data = read_yaml_mapping(page_path) 211 if not isinstance(page_data.get("elements"), list): 212 raise ExportError(f"page elements must be an array: {entry}") 213 pages.append({"path": str(entry), "content": page_text}) 214 215 title = str(manifest_data.get("title") or manifest.stem) 216 return { 217 "id": f"local-export-{uuid.uuid4().hex}", 218 "title": title, 219 "manifestPath": manifest.name, 220 "manifestContent": manifest_text, 221 "pages": pages, 222 "imageMap": build_image_map(root), 223 }
▸下载后还要补 transition 并校验 ZIP
官方前端写出 PPTX 后,脚本仍会在 scripts/export_pptx.py:442 重写 slide XML transition,并在 scripts/export_pptx.py:462 用 zip CRC、slide XML、fade transition 和字体部件做验证。scripts/export_pptx.py:508 到 scripts/export_pptx.py:596 把 agent-browser 会话、本地 host、下载捕获、patch 和 verify 串起来。
462def verify_output(pptx: Path, transition: str, expect_fonts: bool) -> Dict[str, Any]: 463 if not is_pptx(pptx): 464 raise ExportError(f"output is not a valid PPTX ZIP: {pptx}") 465 with zipfile.ZipFile(pptx) as archive: 466 broken = archive.testzip() 467 if broken: 468 raise ExportError(f"PPTX CRC check failed at: {broken}") 469 slide_names = [ 470 name 471 for name in archive.namelist() 472 if re.fullmatch(r"ppt/slides/slide\d+\.xml", name) 473 ] 474 slide_xml = {name: archive.read(name) for name in slide_names} 475 for data in slide_xml.values(): 476 validate_transition_order(data, transition) 477 transition_hits = sum(has_direct_fade_transition(data) for data in slide_xml.values()) 478 if transition == "fade" and transition_hits != len(slide_names): 479 raise ExportError("fade transition was not written to every slide") 480 fonts = [ 481 name 482 for name in archive.namelist() 483 if name.startswith("ppt/fonts/") and not name.endswith("/") 484 ] 485 if expect_fonts and not fonts: 486 log( 487 "warning: embed-fonts was enabled, but the official writer produced no font part" 488 ) 489 return { 490 "slides": len(slide_names), 491 "fadeTransitions": transition_hits, 492 "fontParts": len(fonts), 493 "bytes": pptx.stat().st_size, 494 }
视觉 QA:导出图片,再拼总览图
▸图片导出复用同一个 browser session
图片导出脚本复用 scripts/export_pptx.py:240 的 BrowserSession 和 scripts/export_pptx.py:497 的 localhost serve,只是在导出对话框里选择“图片”。因为官方对话框的图片选项没有 ARIA role,scripts/export_images.py:214 通过 CDP 进入 OOPIF iframe 执行小段脚本,这比盲点坐标更稳定。
214def evaluate_in_iframe(cdp_url: str, url_hint: str, expression: str) -> Any: 215 websocket = ensure_websocket() 216 217 def call(socket: Any, request_id: int, method: str, params: Dict[str, Any]) -> Dict[str, Any]: 218 socket.send(json.dumps({"id": request_id, "method": method, "params": params})) 219 while True: 220 message = json.loads(socket.recv()) 221 if message.get("id") != request_id: 222 continue 223 if "error" in message: 224 raise ExportError(f"CDP {method} failed: {message['error']}") 225 return message.get("result", {}) 226 227 # websocket-client honors http_proxy env vars; the CDP endpoint is local, 228 # so strip proxy settings instead of tunneling localhost through the proxy. 229 proxy_env = ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY") 230 saved_proxy = {name: os.environ.pop(name) for name in proxy_env if name in os.environ} 231 try: 232 socket = websocket.create_connection(cdp_url, timeout=30, suppress_origin=True) 233 finally: 234 os.environ.update(saved_proxy) 235 try: 236 targets = call(socket, 1, "Target.getTargets", {}).get("targetInfos", []) 237 target = next( 238 (item for item in targets if url_hint in str(item.get("url", ""))), None 239 ) 240 if target is None: 241 visible = ", ".join( 242 f"{item.get('type')}:{str(item.get('url', ''))[:80]}" for item in targets 243 ) 244 raise ExportError( 245 f"no browser target matches {url_hint!r}; observed: {visible}" 246 ) 247 attached = call( 248 socket, 249 2, 250 "Target.attachToTarget", 251 {"targetId": target["targetId"], "flatten": True}, 252 ) 253 session_id = attached["sessionId"] 254 socket.send( 255 json.dumps( 256 { 257 "id": 3, 258 "sessionId": session_id, 259 "method": "Runtime.evaluate", 260 "params": {"expression": expression, "returnByValue": True}, 261 } 262 ) 263 ) 264 while True: 265 message = json.loads(socket.recv()) 266 if message.get("id") != 3: 267 continue 268 if "error" in message: 269 raise ExportError(f"CDP Runtime.evaluate failed: {message['error']}") 270 result = message.get("result", {}) 271 if result.get("exceptionDetails"): 272 details = result["exceptionDetails"] 273 raise ExportError(f"iframe script failed: {details.get('text')}") 274 return result.get("result", {}).get("value") 275 finally: 276 socket.close() 277 278 279def select_image_format(browser: BrowserSession) -> None: 280 cdp_url = browser_cdp_url(browser) 281 value = evaluate_in_iframe(cdp_url, OOPIF_URL_HINT, IMAGE_FORMAT_CLICK_JS) 282 if value != "clicked": 283 raise ExportError("could not find the 图片 option in the export dialog") 284 deadline = time.monotonic() + 10 285 while time.monotonic() < deadline: 286 active = evaluate_in_iframe(cdp_url, OOPIF_URL_HINT, ACTIVE_FORMAT_JS) 287 if active == "图片": 288 return 289 time.sleep(0.3) 290 raise ExportError(f"image format was not activated; active option: {active!r}")
▸总览图把多页视觉检查压缩成一次审阅
scripts/export_images.py:86 先安全解压图片 ZIP,scripts/export_images.py:113 计算缩略图网格并给每页加标签,最后 scripts/export_images.py:365 生成 overview.jpg。这个总览图正好服务 Skill.md 中的多模态质检要求:一次看遮挡、出界、对比度和页面节奏。
113def stitch_overview( 114 images: Sequence[Path], 115 output: Path, 116 image_cls: Any, 117 draw_cls: Any, 118 image_font: Any, 119) -> Path: 120 thumbs: List[Tuple[str, Any]] = [] 121 for index, path in enumerate(images, start=1): 122 with image_cls.open(path) as opened: 123 frame = opened.convert("RGB") 124 ratio = OVERVIEW_THUMB_WIDTH / frame.width 125 thumb = frame.resize( 126 (OVERVIEW_THUMB_WIDTH, max(1, round(frame.height * ratio))) 127 ) 128 thumbs.append((f"P{index}", thumb)) 129 130 columns = OVERVIEW_COLUMNS 131 rows = math.ceil(len(thumbs) / columns) 132 cell_height = OVERVIEW_LABEL_HEIGHT + max(thumb.height for _, thumb in thumbs) 133 width = columns * OVERVIEW_THUMB_WIDTH + (columns + 1) * OVERVIEW_GAP 134 height = rows * cell_height + (rows + 1) * OVERVIEW_GAP 135 136 overview = image_cls.new("RGB", (width, height), "#e5e7eb") 137 draw = draw_cls.Draw(overview) 138 font = label_font(image_font) 139 for position, (label, thumb) in enumerate(thumbs): 140 column = position % columns 141 row = position // columns 142 x = OVERVIEW_GAP + column * (OVERVIEW_THUMB_WIDTH + OVERVIEW_GAP) 143 y = OVERVIEW_GAP + row * (cell_height + OVERVIEW_GAP) 144 draw.rectangle( 145 (x, y, x + OVERVIEW_THUMB_WIDTH, y + OVERVIEW_LABEL_HEIGHT - 4), 146 fill="#111827", 147 ) 148 draw.text((x + 8, y + 5), label, fill="#ffffff", font=font) 149 overview.paste(thumb, (x, y + OVERVIEW_LABEL_HEIGHT)) 150 151 overview.save(output, "JPEG", quality=85) 152 return output
测试边界:小但对准风险点
▸路径安全和 manifest parser 有单测
测试没有追求覆盖所有 UI,而是盯住危险边界。tests/editor-lib.test.js:21 验证 YAML/JSON pages、title、joinDeckPath 和 assertWritableChangePath;tests/editor-lib.test.js:28 与 tests/editor-lib.test.js:30 则直接断言路径逃逸和非 .pptd/.page 写入会失败。
21test("parses PPTD manifests and keeps file access inside the project", () => { 22 assert.deepEqual(extractPagePaths(yaml), ["pages/01.page", "pages/02.page"]); 23 assert.deepEqual(extractPagePaths('{"pages":["a.page","folder/b.page"]}'), ["a.page", "folder/b.page"]); 24 assert.equal(titleFromManifest(yaml, "fallback"), "测试文稿"); 25 assert.equal(joinDeckPath("deck", "pages/01.page"), "deck/pages/01.page"); 26 assert.equal(joinDeckPath("deck", "deck/pages/01.page"), "deck/pages/01.page"); 27 assert.equal(assertWritableChangePath("deck", "pages/01.page"), "deck/pages/01.page"); 28 assert.throws(() => normalizeRelativePath("../secret"), /不允许越过/); 29 assert.throws(() => normalizeRelativePath("/absolute/path"), /绝对路径/); 30 assert.throws(() => assertWritableChangePath("deck", "media/image.png"), /仅允许编辑/); 31});
▸HTTP server 和安装事务也有独立测试
tests/editor-server.test.js:20 确认编辑器静态资源可访问,tests/editor-server.test.js:33 验证 missing file 返回 404,tests/editor-server.test.js:40 则保证 HEAD 没有响应体。安装测试在 tests/install-skill.test.js:19 到 tests/install-skill.test.js:31 确认 SKILL.md 和脚本被复制,且 _user_meta.json 不进入安装包。
20test("serves the PPTD editor and its JavaScript modules", async () => { 21 await withServer(async (url) => { 22 const index = await fetch(`${url}/`); 23 assert.equal(index.status, 200); 24 assert.match(index.headers.get("content-type"), /^text\/html/); 25 assert.match(await index.text(), /打开 PPTD 文件夹/); 26 27 const app = await fetch(`${url}/app.js`); 28 assert.equal(app.status, 200); 29 assert.match(app.headers.get("content-type"), /^text\/javascript/); 30 }); 31}); 32 33test("returns 404 for files outside the packaged editor", async () => { 34 await withServer(async (url) => { 35 const response = await fetch(`${url}/missing.js`); 36 assert.equal(response.status, 404); 37 }); 38}); 39 40test("supports HEAD requests without a response body", async () => { 41 await withServer(async (url) => { 42 const response = await fetch(`${url}/styles.css`, { method: "HEAD" }); 43 assert.equal(response.status, 200); 44 assert.equal(await response.text(), ""); 45 });
风险:非官方前端协议是最大变量
▸外部依赖失效会影响导出
项目的风险不是本地 parser,而是外部 Kimi 前端和 agent-browser 交互。scripts/export_pptx.py:92 会检查 agent-browser 版本,必要时 scripts/export_pptx.py:109 安装新版;lib/editor-app.js:634 连接 Kimi iframe,lib/editor-app.js:687 等待 RPC 握手。如果公开资源、Penpal bundle 或导出对话框结构变更,导出链路可能需要同步修复。
| 风险 | 源码证据 | 应对 |
|---|---|---|
| 公开 Kimi 前端变更 | lib/editor-app.js:11 / lib/editor-app.js:640 | 把失败模式暴露为连接错误,不吞掉异常。 |
| 下载结果不是合法 PPTX | scripts/export_pptx.py:462 / scripts/export_pptx.py:466 | CRC、content type、slide XML 全部校验。 |
| 本地文件写入越权 | lib/editor-lib.js:111 / lib/editor-app.js:579 | 只允许 .pptd/.page,且只在授权目录写。 |
继续读源码:两个入口
▸编辑器桥接与保存事务
如果你关心浏览器 File System Access API、iframe RPC、图片资源解析和自动保存,请读子页面一。它会围绕 lib/editor-app.js:486、lib/editor-app.js:524、lib/editor-app.js:556 和 lib/editor-app.js:634 展开。
▸导出与视觉 QA 管线
如果你关心 PPTX ZIP 校验、transition patch、agent-browser download、图片 ZIP 和 overview 拼图,请读子页面二。它会围绕 scripts/export_pptx.py:376、scripts/export_pptx.py:442、scripts/export_images.py:86 和 scripts/export_images.py:113 展开。