@capyclaw/ppt-workbench 0.0.0-stage → 2.4.1

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.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -3
  3. package/THIRD_PARTY_NOTICES.md +21 -0
  4. package/contract/ppt-contract.json +1529 -0
  5. package/cordis.patch.yml +25 -0
  6. package/core/audit.js +500 -0
  7. package/core/chart.js +199 -0
  8. package/core/color.js +80 -0
  9. package/core/deck-layout.js +195 -0
  10. package/core/formula.js +80 -0
  11. package/core/hfmath/hershey.js +1763 -0
  12. package/core/hfmath/index.js +1176 -0
  13. package/core/hfmath/stretch.js +41 -0
  14. package/core/hfmath/symbols.js +408 -0
  15. package/core/ops/browser.js +28 -0
  16. package/core/ops/chart.js +64 -0
  17. package/core/ops/deck.js +21 -0
  18. package/core/ops/dispatcher.js +2 -0
  19. package/core/ops/element.js +55 -0
  20. package/core/ops/history.js +11 -0
  21. package/core/ops/import.js +95 -0
  22. package/core/ops/index.js +39 -0
  23. package/core/ops/playback.js +9 -0
  24. package/core/ops/recolor.js +33 -0
  25. package/core/ops/slide.js +66 -0
  26. package/core/ops/table.js +85 -0
  27. package/core/ops/text.js +35 -0
  28. package/core/ops/view.js +13 -0
  29. package/core/ops.js +12 -0
  30. package/core/quick.js +52 -0
  31. package/core/shape-library.js +122 -0
  32. package/core/table.js +178 -0
  33. package/core/text.js +200 -0
  34. package/editor/app.js +5366 -0
  35. package/editor/heartbeat-worker.js +116 -0
  36. package/editor/index.html +89 -0
  37. package/editor/ppt-full-app.js +376 -0
  38. package/lib/host.mjs +98 -0
  39. package/package.json +53 -6
  40. package/server/audit.mjs +2 -0
  41. package/server/bridge-client.mjs +174 -0
  42. package/server/constants.mjs +20 -0
  43. package/server/contract-snapshots.mjs +62 -0
  44. package/server/contract.mjs +24 -0
  45. package/server/deck-layout.mjs +2 -0
  46. package/server/http-utils.mjs +2 -0
  47. package/server/infra/http.mjs +113 -0
  48. package/server/infra/runtime/context.mjs +128 -0
  49. package/server/infra/store/blob-store.mjs +109 -0
  50. package/server/infra/tuning.mjs +82 -0
  51. package/server/infra/util/fs-lock.mjs +132 -0
  52. package/server/infra/workspace/manager.mjs +657 -0
  53. package/server/mcp/dispatch.mjs +2 -0
  54. package/server/mcp/handler.mjs +2 -0
  55. package/server/mcp/instructions.mjs +40 -0
  56. package/server/mcp-http.mjs +3 -0
  57. package/server/ppt-shim.mjs +156 -0
  58. package/server/relay.mjs +245 -0
  59. package/server/routes/assets.mjs +2 -0
  60. package/server/routes/bridge.mjs +47 -0
  61. package/server/routes/editor.mjs +9 -0
  62. package/server/routes/import.mjs +258 -0
  63. package/server/routes/mcp.mjs +17 -0
  64. package/server/runtime/recovery.mjs +9 -0
  65. package/server/runtime/watch.mjs +17 -0
  66. package/server/shared/SHARED_MANIFEST.json +47 -0
  67. package/server/shared/asset-service.mjs +217 -0
  68. package/server/shared/editor-host.mjs +125 -0
  69. package/server/shared/kit/editor/lease.mjs +139 -0
  70. package/server/shared/kit/service/api.mjs +45 -0
  71. package/server/shared/kit/service/http.mjs +113 -0
  72. package/server/shared/kit/service/mcp-http.mjs +142 -0
  73. package/server/shared/kit/service/single-instance.mjs +72 -0
  74. package/server/shared/kit/tools/validate.mjs +115 -0
  75. package/server/shared/kit/tuning.mjs +82 -0
  76. package/server/shared/kit/util/fs-lock.mjs +132 -0
  77. package/server/shared/service-host.mjs +511 -0
  78. package/server/shared/state-service.mjs +249 -0
  79. package/server/shared/tool-host.mjs +341 -0
  80. package/server/shared/tuning.mjs +63 -0
  81. package/server/single-instance.mjs +2 -0
  82. package/server/store/backends/file-backend.mjs +13 -0
  83. package/server/store/checkpoints.mjs +28 -0
  84. package/server/store/doc.mjs +52 -0
  85. package/server/store/index.mjs +34 -0
  86. package/server/store/journal.mjs +9 -0
  87. package/server/store/lease.mjs +2 -0
  88. package/server/store/sanitize.mjs +32 -0
  89. package/server/store.mjs +2 -0
  90. package/server/tools/capabilities/apply.mjs +33 -0
  91. package/server/tools/capabilities/audit.mjs +24 -0
  92. package/server/tools/capabilities/chart.mjs +83 -0
  93. package/server/tools/capabilities/compose.mjs +97 -0
  94. package/server/tools/capabilities/contract.mjs +34 -0
  95. package/server/tools/capabilities/deck.mjs +27 -0
  96. package/server/tools/capabilities/element.mjs +67 -0
  97. package/server/tools/capabilities/export.mjs +95 -0
  98. package/server/tools/capabilities/formula.mjs +72 -0
  99. package/server/tools/capabilities/history.mjs +29 -0
  100. package/server/tools/capabilities/image.mjs +152 -0
  101. package/server/tools/capabilities/imagesearch.mjs +152 -0
  102. package/server/tools/capabilities/import.mjs +180 -0
  103. package/server/tools/capabilities/index.mjs +58 -0
  104. package/server/tools/capabilities/line.mjs +58 -0
  105. package/server/tools/capabilities/notes.mjs +33 -0
  106. package/server/tools/capabilities/present.mjs +34 -0
  107. package/server/tools/capabilities/recolor.mjs +60 -0
  108. package/server/tools/capabilities/screenshot.mjs +86 -0
  109. package/server/tools/capabilities/shape.mjs +64 -0
  110. package/server/tools/capabilities/slide.mjs +81 -0
  111. package/server/tools/capabilities/style.mjs +48 -0
  112. package/server/tools/capabilities/table.mjs +127 -0
  113. package/server/tools/capabilities/task.mjs +52 -0
  114. package/server/tools/capabilities/text.mjs +82 -0
  115. package/server/tools/domains/color.mjs +4 -0
  116. package/server/tools/domains/image.mjs +49 -0
  117. package/server/tools/domains/index.mjs +7 -0
  118. package/server/tools/domains/pptx-export.mjs +566 -0
  119. package/server/tools/domains/recolor.mjs +25 -0
  120. package/server/tools/domains/remote-image.mjs +291 -0
  121. package/server/tools/domains/slide.mjs +21 -0
  122. package/server/tools/domains/text-style.mjs +11 -0
  123. package/server/tools/domains/tooling.mjs +13 -0
  124. package/server/tools/index.mjs +14 -0
  125. package/server/tools/kernel/define.mjs +5 -0
  126. package/server/tools/kernel/ports.mjs +42 -0
  127. package/server/tools/kernel/registry.mjs +17 -0
  128. package/server/tools.mjs +18 -0
  129. package/server/tuning.mjs +86 -0
  130. package/server/validate.mjs +2 -0
@@ -0,0 +1,72 @@
1
+ /** 公式能力:TeX 数学公式 → PPTist 原生 latex 元素(hfmath stroke 渲染,与编辑器描边渲染同款)。 */
2
+ import { renderFormula } from '../../../core/formula.js'
3
+ import { clamp, okText } from '../domains/tooling.mjs'
4
+ import { defineCapability, defineTool } from '../kernel/define.mjs'
5
+
6
+ export const formulaCapability = defineCapability({
7
+ id: 'formula',
8
+ title: '数学公式',
9
+ tags: ['write', 'content'],
10
+ requires: ['deck'],
11
+ features: ['deck.formula'],
12
+ tools: [
13
+ defineTool({
14
+ name: 'ppt_formula',
15
+ description:
16
+ '插入或更新数学公式(TeX 语法,PPTist 原生 latex 元素,手写风描边渲染)。' +
17
+ '支持分式 \\frac{}{}、上下标 ^ _、根号 \\sqrt、求和积分 \\sum \\int、希腊字母、矩阵等;' +
18
+ '渲染由内置 hfmath 引擎完成。带 element_id 时为更新(重新渲染并替换)。',
19
+ inputSchema: {
20
+ type: 'object',
21
+ properties: {
22
+ slide: { type: 'integer', description: '0 起页号,默认 0' },
23
+ latex: { type: 'string', description: 'TeX 公式,如 E=mc^2 / \\frac{a}{b} / \\int_0^1 x^2 dx' },
24
+ element_id: { type: 'string', description: '更新模式:要更新的公式元素 id' },
25
+ color: { type: 'string', description: '笔画颜色,默认 #333333' },
26
+ stroke_width: { type: 'number', description: '笔画宽度,默认 2' },
27
+ left: { type: 'number' },
28
+ top: { type: 'number' },
29
+ width: { type: 'number', description: '默认按渲染原始尺寸' },
30
+ height: { type: 'number', description: '默认按渲染原始尺寸(fixedRatio 锁比例)' },
31
+ },
32
+ required: ['slide', 'latex'],
33
+ additionalProperties: false,
34
+ },
35
+ kind: 'write',
36
+ since: '2.2.0',
37
+ handler: async (args, ctx) => {
38
+ const i = clamp(Number(args.slide) | 0, 0, 999)
39
+ const p = args || {}
40
+ if (p.element_id) {
41
+ /* 更新:重新渲染新公式,替换 path/viewBox/latex(几何/颜色按参数顺带改)。 */
42
+ const rendered = renderFormula(p.latex)
43
+ const props = {
44
+ latex: p.latex,
45
+ path: rendered.path,
46
+ viewBox: rendered.viewBox,
47
+ }
48
+ if (p.width != null) props.width = p.width
49
+ if (p.height != null) props.height = p.height
50
+ if (p.color) props.color = p.color
51
+ if (p.stroke_width != null) props.strokeWidth = p.stroke_width
52
+ for (const k of ['left', 'top']) {
53
+ if (p[k] != null) props[k] = p[k]
54
+ }
55
+ const r = await ctx.deck.write([{ op: 'set-element', slide: i, elementId: String(p.element_id), props }])
56
+ return okText(r, `已更新公式 ${p.element_id}`)
57
+ }
58
+ const element = {
59
+ type: 'latex',
60
+ latex: p.latex,
61
+ ...(p.color ? { color: p.color } : {}),
62
+ ...(p.stroke_width != null ? { strokeWidth: p.stroke_width } : {}),
63
+ }
64
+ for (const k of ['left', 'top', 'width', 'height']) {
65
+ if (p[k] != null) element[k] = p[k]
66
+ }
67
+ const r = await ctx.deck.write([{ op: 'add-element', slide: i, element }])
68
+ return okText(r, `已在第 ${i} 页插入公式`)
69
+ },
70
+ }),
71
+ ],
72
+ })
@@ -0,0 +1,29 @@
1
+ /** 撤销/重做能力。 */
2
+ import { okText } from '../domains/tooling.mjs'
3
+ import { defineCapability, defineTool } from '../kernel/define.mjs'
4
+
5
+ export const historyCapability = defineCapability({
6
+ id: 'history',
7
+ title: '撤销重做',
8
+ tags: ['control'],
9
+ requires: ['deck'],
10
+ features: ['deck.undo'],
11
+ tools: [
12
+ defineTool({
13
+ name: 'ppt_history',
14
+ description: '撤销/重做。',
15
+ inputSchema: {
16
+ type: 'object',
17
+ properties: { action: { type: 'string', enum: ['undo', 'redo'] } },
18
+ required: ['action'],
19
+ additionalProperties: false,
20
+ },
21
+ kind: 'control',
22
+ since: '1.0.0',
23
+ handler: async ({ action }, ctx) => {
24
+ const r = await ctx.deck.write([{ op: String(action) }])
25
+ return okText(r, action === 'undo' ? '已撤销' : '已重做')
26
+ },
27
+ }),
28
+ ],
29
+ })
@@ -0,0 +1,152 @@
1
+ /** 图片能力:图片先进入对象仓库,deck 只保存 /assets/<sha256> 引用。 */
2
+ import fs from 'node:fs'
3
+ import { imageMime, imageSize, resolveAttachmentFile } from '../domains/image.mjs'
4
+ import { IMAGE_MAX_BYTES, describeImageFailure, downloadImage } from '../domains/remote-image.mjs'
5
+ import { clamp, okText } from '../domains/tooling.mjs'
6
+ import { defineCapability, defineTool } from '../kernel/define.mjs'
7
+
8
+ const MAX_MB = Math.round(IMAGE_MAX_BYTES / 1024 / 1024)
9
+ const TOO_LARGE_HINT = `图片过大(>${MAX_MB}MB),请先压缩后再插入`
10
+
11
+ function readLocalImage(file) {
12
+ const stat = fs.statSync(file)
13
+ if (!stat.isFile()) throw new Error('图片路径不是文件:' + file)
14
+ if (stat.size > IMAGE_MAX_BYTES) throw new Error(TOO_LARGE_HINT)
15
+ return fs.readFileSync(file)
16
+ }
17
+
18
+ async function insertImage(ctx, spec) {
19
+ const { slide, attachment_id, attachmentId, path: filePath, src, left, top, width, height } = spec || {}
20
+ const i = clamp(Number(slide == null ? 0 : slide) | 0, 0, 999)
21
+ const aid = attachment_id || attachmentId
22
+ let buf = null
23
+ let assetUrl = ''
24
+ let dataUrl = ''
25
+ let natW = 0, natH = 0
26
+ if (aid) {
27
+ const p = resolveAttachmentFile(aid)
28
+ buf = readLocalImage(p)
29
+ } else if (filePath) {
30
+ if (!fs.existsSync(String(filePath))) throw new Error('图片文件不存在:' + filePath)
31
+ buf = readLocalImage(String(filePath))
32
+ } else if (src) {
33
+ const s = String(src)
34
+ if (/^https?:/i.test(s)) {
35
+ if (/\/assets\/[a-f0-9]{64}$/i.test(s)) {
36
+ assetUrl = s
37
+ } else {
38
+ /* 远程图不能直接把 URL 丢给编辑器:先下载并验证是图片,再进入对象仓库。 */
39
+ try {
40
+ const fetched = await downloadImage(s)
41
+ buf = fetched.buf
42
+ } catch (error) {
43
+ throw new Error(describeImageFailure(error, s))
44
+ }
45
+ }
46
+ } else if (/^data:image\//i.test(s)) {
47
+ const comma = s.indexOf(',')
48
+ if (comma < 0) throw new Error('data URL 不合法')
49
+ /* 先按 base64 长度粗判,避免解码超大字符串造成瞬时内存峰值。 */
50
+ if (s.length > Math.ceil(IMAGE_MAX_BYTES * 4 / 3) + 128) throw new Error(TOO_LARGE_HINT)
51
+ buf = Buffer.from(s.slice(comma + 1), 'base64')
52
+ if (buf.length > IMAGE_MAX_BYTES) throw new Error(TOO_LARGE_HINT)
53
+ } else if (/^\/assets\/[a-f0-9]{64}$/i.test(s)) {
54
+ assetUrl = s
55
+ } else if (fs.existsSync(s)) {
56
+ buf = readLocalImage(s)
57
+ } else {
58
+ throw new Error('src 既不是 http(s)/data URL,也不是存在的文件:' + s.slice(0, 120))
59
+ }
60
+ } else {
61
+ throw new Error('ppt_image 需要 attachment_id / path / src 之一')
62
+ }
63
+ if (buf) {
64
+ if (buf.length > IMAGE_MAX_BYTES) throw new Error(TOO_LARGE_HINT)
65
+ const mime = imageMime(buf)
66
+ if (!mime) throw new Error('不支持的图片格式(仅 PNG/JPEG/GIF/WebP)')
67
+ if (process.env.PPT_ASSET_STORE === '1') {
68
+ const asset = await ctx.assets.put(buf, mime)
69
+ assetUrl = asset.url
70
+ } else {
71
+ dataUrl = `data:${mime};base64,${buf.toString('base64')}`
72
+ }
73
+ const sz = imageSize(buf)
74
+ if (sz) { natW = sz[0]; natH = sz[1] }
75
+ }
76
+ if (!assetUrl && !dataUrl) throw new Error('图片未能写入对象仓库')
77
+ const SW = 1000, SH = 562.5
78
+ let w = width != null ? Number(width) : null
79
+ let h = height != null ? Number(height) : null
80
+ if ((w == null || h == null) && natW && natH) {
81
+ if (w == null && h == null) {
82
+ const scale = Math.min(SW / natW, SH / natH)
83
+ w = natW * scale; h = natH * scale
84
+ } else if (w == null) {
85
+ w = h * natW / natH
86
+ } else {
87
+ h = w * natH / natW
88
+ }
89
+ }
90
+ if (w == null) w = SW
91
+ if (h == null) h = SH
92
+ const l = left != null ? Number(left) : Math.round((SW - w) / 2)
93
+ const t = top != null ? Number(top) : Math.round((SH - h) / 2)
94
+ const element = {
95
+ type: 'image', src: assetUrl || dataUrl,
96
+ left: l, top: t, width: Math.round(w), height: Math.round(h),
97
+ }
98
+ const beforeDoc = await ctx.deck.read()
99
+ const beforeSlide = ((beforeDoc.deck && beforeDoc.deck.slides) || [])[i] || {}
100
+ const beforeIds = new Set((beforeSlide.elements || []).filter(e => e.type === 'image').map(e => e.id))
101
+ const r = await ctx.deck.write([{ op: 'add-element', slide: i, element }])
102
+ if (!r.ok) return okText(r, '图片插入失败')
103
+ const afterDoc = await ctx.deck.read()
104
+ const afterSlide = ((afterDoc.deck && afterDoc.deck.slides) || [])[i] || {}
105
+ const newImg = (afterSlide.elements || []).find(e => e.type === 'image' && !beforeIds.has(e.id))
106
+ if (!newImg) {
107
+ throw new Error('图片未真正插入:deck 中目标页没有新增 image 元素。最常见原因是编辑器还是旧 bundle(不支持 image),请按 F5 刷新桌面后重试;不要重复插入。')
108
+ }
109
+ const imgSrc = String(newImg.src || '')
110
+ if (!/^\/assets\/[a-f0-9]{64}$/i.test(imgSrc) && !/^data:image\//i.test(imgSrc) && !/^https?:/i.test(imgSrc)) {
111
+ throw new Error('图片元素已创建但 src 无效,请重试或刷新编辑器。')
112
+ }
113
+ const nat = natW && natH ? `${natW}×${natH}` : '图片'
114
+ if (assetUrl) {
115
+ const assetId = assetUrl.split('/').pop()
116
+ return { text: `已存入对象仓库并插入:第 ${i} 页新增图片 id=${newImg.id}(${nat} → ${Math.round(newImg.width)}×${Math.round(newImg.height)},asset=${assetId})` }
117
+ }
118
+ return { text: `已插入并校验:第 ${i} 页新增图片 id=${newImg.id}(${nat} → ${Math.round(newImg.width)}×${Math.round(newImg.height)})` }
119
+ }
120
+
121
+ export const imageCapability = defineCapability({
122
+ id: 'image',
123
+ title: '图片',
124
+ tags: ['write', 'media'],
125
+ requires: ['deck', 'assets'],
126
+ provides: { media: { insertImage } },
127
+ features: ['deck.image'],
128
+ tools: [
129
+ defineTool({
130
+ name: 'ppt_image',
131
+ description: '插入图片(一次完成,图片先写入对象仓库,deck 只存 /assets/<sha256> 引用):attachment_id 取聊天图片附件的 sha256 id;也支持 path/src。src 走远程下载时带浏览器头、25s 超时、自动重试一次,上限 6MB。失败会返回 [错误码]:HOST_BLOCKED / HTTP_403 / TIMEOUT / TOO_LARGE 等——永久性失败不要重试同一链接或域名,改换来源或先用本地 path 下载。不要用 shell 找图,不要截图验证。',
132
+ inputSchema: {
133
+ type: 'object',
134
+ properties: {
135
+ slide: { type: 'integer', description: '0 起页号,缺省 0(封面)' },
136
+ attachment_id: { type: 'string', description: '聊天图片附件的 attachmentId(sha256:…)' },
137
+ attachmentId: { type: 'string', description: 'attachment_id 的兼容别名' },
138
+ path: { type: 'string', description: '本机图片路径(兼容旧用法)' },
139
+ src: { type: 'string', description: 'http(s) 或 data URL' },
140
+ left: { type: 'number' },
141
+ top: { type: 'number' },
142
+ width: { type: 'number' },
143
+ height: { type: 'number' },
144
+ },
145
+ additionalProperties: false,
146
+ },
147
+ kind: 'write',
148
+ since: '1.0.0',
149
+ handler: (args, ctx) => insertImage(ctx, args),
150
+ }),
151
+ ],
152
+ })
@@ -0,0 +1,152 @@
1
+ /** 图片检索能力:搜索 + 预检,返回确认可下载的候选直链;下载/入库/插图由 ppt_image 负责。 */
2
+ import { IMAGE_UA, probeImage } from '../domains/remote-image.mjs'
3
+ import { defineCapability, defineTool } from '../kernel/define.mjs'
4
+
5
+ const BING = 'https://cn.bing.com/images/async'
6
+ const UA = IMAGE_UA
7
+ const MIN_WIDTH = 480
8
+ const CACHE_TTL_MS = 10 * 60 * 1000
9
+ /* 全局预算:一个 read 工具不该拖到十几秒。
10
+ * 2026-11-02 实测 Bing 图片搜索常态 0.2~0.5s,偶发 7s+ 抖动,
11
+ * 所以搜索单独给 8s(沿用原值,不因预检而收紧),预检在剩余预算里跑,
12
+ * 整轮 12s 封顶。预算耗尽就返回已拿到的结果,不再补测。 */
13
+ const TOTAL_BUDGET_MS = 12000
14
+ const SEARCH_TIMEOUT_MS = 8000
15
+ const PROBE_CONCURRENCY = 9
16
+ const PROBE_TIMEOUT_MS = 3000
17
+ const PROBE_MAX_RATIO = 2
18
+ const cache = new Map()
19
+
20
+ const decode = (value) => String(value || '')
21
+ .replace(/&quot;/g, '"')
22
+ .replace(/&amp;/g, '&')
23
+ .replace(/&#39;/g, "'")
24
+ .replace(/&lt;/g, '<')
25
+ .replace(/&gt;/g, '>')
26
+
27
+ /** 从 Bing 图片搜索 HTML 中提取 murl/purl/t 宽高。 */
28
+ export function parseBingImages(html, limit = 12) {
29
+ const out = []
30
+ const seen = new Set()
31
+ for (const match of String(html || '').matchAll(/m="(\{&quot;[\s\S]*?)"/g)) {
32
+ let meta
33
+ try { meta = JSON.parse(decode(match[1])) } catch { continue }
34
+ const url = String(meta.murl || '')
35
+ if (!/^https?:\/\//i.test(url) || seen.has(url)) continue
36
+ seen.add(url)
37
+ out.push({
38
+ title: String(meta.t || '').replace(/\s+/g, ' ').trim().slice(0, 80),
39
+ page: String(meta.purl || ''),
40
+ url,
41
+ width: Number(meta.w) || 0,
42
+ height: Number(meta.h) || 0,
43
+ })
44
+ if (out.length >= limit) break
45
+ }
46
+ return out
47
+ }
48
+
49
+ async function searchBing(query, count, deadline) {
50
+ const url = `${BING}?q=${encodeURIComponent(query)}&first=1&count=${Math.max(count * 3, 24)}&mkt=zh-CN&qft=+filterui:photo-photo`
51
+ const budget = Math.min(SEARCH_TIMEOUT_MS, Math.max(1000, deadline - Date.now()))
52
+ const res = await fetch(url, {
53
+ signal: AbortSignal.timeout(budget),
54
+ headers: { 'user-agent': UA, 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' },
55
+ })
56
+ if (!res.ok) throw new Error(`图片搜索 HTTP ${res.status}`)
57
+ return parseBingImages(await res.text(), count)
58
+ }
59
+
60
+ /**
61
+ * 并发预检,凑够 want 条就停。
62
+ * 只淘汰"确定的坏链"(403/404/不是图片/超大);预检超时或网络抖动归入
63
+ * unverified——下载阶段预算长得多,不能因为探测慢就丢掉可能的好图。
64
+ * 返回顺序:验证通过的在前,未验证的兜底在后。
65
+ */
66
+ async function filterDownloadable(items, want, deadline) {
67
+ const verified = []
68
+ const unverified = []
69
+ const rejected = []
70
+ const pool = items.slice(0, Math.min(items.length, want * PROBE_MAX_RATIO))
71
+ let cursor = 0
72
+ const worker = async () => {
73
+ while (cursor < pool.length && verified.length < want && Date.now() < deadline) {
74
+ const item = pool[cursor++]
75
+ const probe = await probeImage(item.url, { timeoutMs: PROBE_TIMEOUT_MS, deadline })
76
+ if (probe.ok) verified.push(item)
77
+ else if (probe.permanent) rejected.push({ url: item.url, code: probe.code, permanent: true })
78
+ else unverified.push(item)
79
+ }
80
+ }
81
+ const lanes = Math.min(PROBE_CONCURRENCY, pool.length)
82
+ await Promise.all(Array.from({ length: lanes }, worker))
83
+ return {
84
+ candidates: [...verified, ...unverified].slice(0, want),
85
+ verifiedCount: Math.min(verified.length, want),
86
+ verified: new Set(verified),
87
+ rejected,
88
+ }
89
+ }
90
+
91
+ export const imagesearchCapability = defineCapability({
92
+ id: 'imagesearch',
93
+ title: '图片检索',
94
+ tags: ['read'],
95
+ features: ['media.search'],
96
+ tools: [
97
+ defineTool({
98
+ name: 'ppt_images',
99
+ description: '图片搜索:返回已预检可下载的候选直链(不下载正文,整轮 12s 内)。选中后直接用 ppt_image { src: url, slide } 插入;返回的候选基本不会 403/超限,若仍失败说明网络波动,换下一个候选即可。',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ query: { type: 'string', maxLength: 120, description: '中文或英文图片搜索词' },
104
+ count: { type: 'integer', minimum: 1, maximum: 12, description: '需要的候选数量,缺省 6' },
105
+ min_width: { type: 'integer', description: '最小宽度像素,缺省 480' },
106
+ },
107
+ required: ['query'],
108
+ additionalProperties: false,
109
+ },
110
+ kind: 'read',
111
+ since: '2.3.0',
112
+ handler: async ({ query, count, min_width }) => {
113
+ const want = Math.min(12, Math.max(1, Number(count) || 6))
114
+ const floor = Math.max(0, Number(min_width) || MIN_WIDTH)
115
+ const cacheKey = `${query}|${want}|${floor}`
116
+ const hit = cache.get(cacheKey)
117
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS) return { text: hit.text }
118
+
119
+ const deadline = Date.now() + TOTAL_BUDGET_MS
120
+ const found = (await searchBing(query, want * 3, deadline)).filter((item) => !item.width || item.width >= floor)
121
+ if (!found.length) throw new Error(`没有找到图片(${query})`)
122
+ const { candidates, verifiedCount, verified, rejected } = await filterDownloadable(found, want, deadline)
123
+ if (!candidates.length) {
124
+ const codes = [...new Set(rejected.map((r) => r.code))].slice(0, 3).join('/') || '未知'
125
+ throw new Error(`搜到 ${found.length} 条但全部不可下载(${codes}):请换搜索词,或改用本地图片路径`)
126
+ }
127
+ const results = candidates.map((item) => {
128
+ const entry = {
129
+ title: item.title,
130
+ page: item.page,
131
+ url: item.url,
132
+ width: item.width,
133
+ height: item.height,
134
+ }
135
+ if (!verified.has(item)) entry.verified = false
136
+ return entry
137
+ })
138
+ const text = JSON.stringify({
139
+ query,
140
+ count: results.length,
141
+ verified: verifiedCount,
142
+ images: results,
143
+ note: verifiedCount === results.length
144
+ ? '候选均已预检可下载;直接用 ppt_image { src: url, slide } 插入,不要换 URL 试探'
145
+ : '前 ' + verifiedCount + ' 条已预检可下载,优先用它们;verified:false 的仅预检超时,下载可能较慢。直接用 ppt_image { src: url, slide } 插入。',
146
+ }, null, 2)
147
+ cache.set(cacheKey, { at: Date.now(), text })
148
+ return { text }
149
+ },
150
+ }),
151
+ ],
152
+ })
@@ -0,0 +1,180 @@
1
+ /** PPTX 导入:把本机文件交给编辑器内置的 PPTist 导入器,返回导入摘要。 */
2
+ import crypto from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { defineCapability, defineTool } from '../kernel/define.mjs'
6
+
7
+ const MAX_BYTES = 128 * 1024 * 1024
8
+ const DEFAULT_WAIT_MS = 180_000
9
+ const MAX_WAIT_MS = 300_000
10
+ const RESULT_URL = '/api/desktop/works/ppt-bridge/import-result'
11
+ const PPTX_EXT = /\.pptx$/i
12
+
13
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
14
+
15
+ function readBytes({ filePath, data }) {
16
+ if (filePath && data) throw new Error('path 与 data 只能二选一')
17
+ if (!filePath && !data) throw new Error('请提供 path(本机 .pptx 路径)或 data(base64)')
18
+
19
+ if (filePath) {
20
+ const abs = path.resolve(String(filePath))
21
+ let stat
22
+ try {
23
+ stat = fs.statSync(abs)
24
+ } catch {
25
+ throw new Error(`找不到文件:${abs}`)
26
+ }
27
+ if (!stat.isFile()) throw new Error(`不是文件:${abs}`)
28
+ if (!PPTX_EXT.test(abs)) throw new Error('只支持 .pptx(OOXML)文件;.ppt 请先用 PowerPoint 另存为 .pptx')
29
+ if (stat.size > MAX_BYTES) throw new Error(`文件过大:${Math.round(stat.size / 1024 / 1024)}MB(上限 ${MAX_BYTES / 1024 / 1024}MB)`)
30
+ return { bytes: fs.readFileSync(abs), name: path.basename(abs), path: abs }
31
+ }
32
+
33
+ const bytes = Buffer.from(String(data), 'base64')
34
+ if (!bytes.length) throw new Error('data 不是有效的 base64 内容')
35
+ if (bytes.length > MAX_BYTES) throw new Error(`文件过大:${Math.round(bytes.length / 1024 / 1024)}MB(上限 ${MAX_BYTES / 1024 / 1024}MB)`)
36
+ return { bytes, name: 'import.pptx', path: '' }
37
+ }
38
+
39
+ function assertPptx(bytes) {
40
+ if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4b) {
41
+ throw new Error('不是有效的 PPTX 文件(缺少 ZIP/OOXML 头)')
42
+ }
43
+ }
44
+
45
+ function compactDeck(deck) {
46
+ const slides = Array.isArray(deck?.slides) ? deck.slides : []
47
+ return {
48
+ title: deck?.title || '',
49
+ slides: slides.length,
50
+ elements: slides.reduce((sum, slide) => sum + (Array.isArray(slide.elements) ? slide.elements.length : 0), 0),
51
+ perSlide: slides.map((slide, index) => ({
52
+ index,
53
+ elements: Array.isArray(slide.elements) ? slide.elements.length : 0,
54
+ types: Array.from(new Set((slide.elements || []).map((el) => el.type).filter(Boolean))),
55
+ })),
56
+ }
57
+ }
58
+
59
+ async function waitForMirror(ctx, summary, timeoutMs = 8000) {
60
+ const wantSlides = Number(summary?.import?.final ?? summary?.slideCount ?? 0)
61
+ const wantTitle = String(summary?.title || '')
62
+ const deadline = Date.now() + timeoutMs
63
+ let doc = await ctx.deck.read()
64
+ while (Date.now() < deadline) {
65
+ const deck = doc?.deck || {}
66
+ const slides = Array.isArray(deck.slides) ? deck.slides.length : 0
67
+ const titleOk = !wantTitle || String(deck.title || '') === wantTitle
68
+ if ((!wantSlides || slides === wantSlides) && titleOk) return { doc, synced: true }
69
+ await sleep(150)
70
+ doc = await ctx.deck.read()
71
+ }
72
+ return { doc, synced: false }
73
+ }
74
+
75
+ export const importCapability = defineCapability({
76
+ id: 'import',
77
+ title: '导入 PPTX',
78
+ tags: ['write', 'file'],
79
+ requires: ['deck', 'editor', 'importer'],
80
+ features: ['deck.import'],
81
+ tools: [
82
+ defineTool({
83
+ name: 'ppt_import_pptx',
84
+ description: '打开本机 .pptx:上传给右侧 PPTist 编辑器,用其原生导入器转换成可编辑 deck。导入后返回标题/页数/每页元素摘要;动画、母版、SmartArt、复杂图表等可能丢失。',
85
+ inputSchema: {
86
+ type: 'object',
87
+ properties: {
88
+ path: { type: 'string', description: '本机 .pptx 文件路径(绝对路径,或相对 MCP 进程 cwd)' },
89
+ data: { type: 'string', description: '可选:PPTX 的 base64 内容(无法访问 path 时使用)' },
90
+ name: { type: 'string', maxLength: 200, description: '显示文件名;缺省取 path 的 basename' },
91
+ mode: {
92
+ type: 'string',
93
+ enum: ['replace', 'append'],
94
+ description: 'replace(默认)用导入内容替换当前 deck;append 追加到现有页之后',
95
+ },
96
+ wait_ms: {
97
+ type: 'integer',
98
+ minimum: 1000,
99
+ maximum: MAX_WAIT_MS,
100
+ description: '等待编辑器完成导入的毫秒数(默认 180000)',
101
+ },
102
+ },
103
+ additionalProperties: false,
104
+ },
105
+ kind: 'write',
106
+ since: '2.4.0',
107
+ handler: async ({ path: filePath, data, name, mode, wait_ms }, ctx) => {
108
+ const input = readBytes({ filePath, data })
109
+ assertPptx(input.bytes)
110
+ const displayName = String(name || input.name || 'import.pptx')
111
+ const importMode = mode === 'append' ? 'append' : 'replace'
112
+ const waitMs = Math.max(1000, Math.min(Number(wait_ms) || DEFAULT_WAIT_MS, MAX_WAIT_MS))
113
+
114
+ /* 导入必须在编辑器页面里执行(复用 PPTist 的 DOM/字体/图片上下文)。 */
115
+ await ctx.editor.require(4000)
116
+
117
+ const uploaded = await ctx.importer.upload(input.bytes, displayName, input.path || '')
118
+ if (!uploaded || !uploaded.url) throw new Error('上传 PPTX 失败:桥没有返回临时文件地址')
119
+
120
+ const req = `imp_${Date.now().toString(36)}_${crypto.randomBytes(4).toString('hex')}`
121
+ const pushed = await ctx.deck.write([
122
+ /* 先压服务端历史快照:导入是编辑器级操作,不走 op 内核;
123
+ * 没有这一步 ppt_history undo 回不到导入前。 */
124
+ { op: 'snapshot' },
125
+ {
126
+ op: 'import-pptx',
127
+ req,
128
+ id: uploaded.id,
129
+ url: uploaded.url,
130
+ name: displayName,
131
+ mode: importMode,
132
+ resultUrl: RESULT_URL,
133
+ },
134
+ ])
135
+ if (!pushed.ok) throw new Error('导入指令投递失败:' + (pushed.error || '编辑器未接受指令'))
136
+
137
+ const deadline = Date.now() + waitMs
138
+ let result = null
139
+ while (Date.now() < deadline) {
140
+ try {
141
+ const current = await ctx.importer.result(req)
142
+ if (current && current.done) { result = current; break }
143
+ } catch { /* relay 瞬断:继续轮询直到超时 */ }
144
+ await sleep(200)
145
+ }
146
+ if (!result) {
147
+ throw new Error(
148
+ `等待编辑器导入超时(${Math.round(waitMs / 1000)}s):请确认右侧编辑器页面已打开并刷新到最新版本`,
149
+ )
150
+ }
151
+ if (!result.ok) throw new Error('PPTX 导入失败:' + (result.error || '编辑器未返回错误详情'))
152
+
153
+ const { doc, synced } = await waitForMirror(ctx, result.summary)
154
+ const deck = compactDeck(doc?.deck)
155
+ const summary = result.summary && typeof result.summary === 'object' ? result.summary : {}
156
+ const warnings = []
157
+ if (!synced) warnings.push('deck 镜像尚未追上导入结果;稍后调用 ppt_get_deck 复查')
158
+ if (summary.mounted === false) warnings.push('编辑器返回 mounted=false,导入结果可能不完整')
159
+ const expectedFinal = Number(summary.import?.final ?? summary.slideCount ?? 0)
160
+ if (expectedFinal && deck.perSlide.length !== expectedFinal) {
161
+ warnings.push('导入摘要与 deck 页数不一致;请用 ppt_get_deck 复查')
162
+ }
163
+
164
+ return {
165
+ text: JSON.stringify({
166
+ ok: true,
167
+ source: { name: displayName, path: input.path || '', bytes: input.bytes.length, mode: importMode },
168
+ title: deck.title || summary.title || '',
169
+ slideCount: deck.slides,
170
+ elementCount: deck.elements,
171
+ deck: { title: deck.title, slides: deck.slides, elements: deck.elements, perSlide: deck.perSlide },
172
+ imported: summary,
173
+ warnings,
174
+ note: 'PPTX 已转换为 PPTist 编辑模型;动画/母版/SmartArt/复杂图表等可能丢失。建议随后调用 ppt_get_deck 或 ppt_audit 检查。',
175
+ }, null, 2),
176
+ }
177
+ },
178
+ }),
179
+ ],
180
+ })
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 能力清单:唯一扩展点。
3
+ *
4
+ * 新增能力:新建 capabilities/<id>.mjs,导出 defineCapability(...),
5
+ * 然后在这里加一行 import 和数组项即可。contract / instructions / 测试
6
+ * 全部由 registry 自动派生。
7
+ */
8
+ import { contractCapability } from './contract.mjs'
9
+ import { deckCapability } from './deck.mjs'
10
+ import { auditCapability } from './audit.mjs'
11
+ import { taskCapability } from './task.mjs'
12
+ import { textCapability } from './text.mjs'
13
+ import { styleCapability } from './style.mjs'
14
+ import { elementCapability } from './element.mjs'
15
+ import { slideCapability } from './slide.mjs'
16
+ import { applyCapability } from './apply.mjs'
17
+ import { composeCapability } from './compose.mjs'
18
+ import { recolorCapability } from './recolor.mjs'
19
+ import { imageCapability } from './image.mjs'
20
+ import { imagesearchCapability } from './imagesearch.mjs'
21
+ import { screenshotCapability } from './screenshot.mjs'
22
+ import { presentCapability } from './present.mjs'
23
+ import { historyCapability } from './history.mjs'
24
+ import { chartCapability } from './chart.mjs'
25
+ import { tableCapability } from './table.mjs'
26
+ import { notesCapability } from './notes.mjs'
27
+ import { shapeCapability } from './shape.mjs'
28
+ import { lineCapability } from './line.mjs'
29
+ import { formulaCapability } from './formula.mjs'
30
+ import { importCapability } from './import.mjs'
31
+ import { exportCapability } from './export.mjs'
32
+
33
+ export const CAPABILITIES = [
34
+ contractCapability,
35
+ deckCapability,
36
+ auditCapability,
37
+ taskCapability,
38
+ textCapability,
39
+ styleCapability,
40
+ elementCapability,
41
+ slideCapability,
42
+ applyCapability,
43
+ composeCapability,
44
+ recolorCapability,
45
+ imageCapability,
46
+ imagesearchCapability,
47
+ screenshotCapability,
48
+ presentCapability,
49
+ historyCapability,
50
+ chartCapability,
51
+ tableCapability,
52
+ notesCapability,
53
+ shapeCapability,
54
+ lineCapability,
55
+ formulaCapability,
56
+ importCapability,
57
+ exportCapability,
58
+ ]