@capyclaw/ppt-workbench 0.0.0-stage → 2.4.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/LICENSE +21 -0
- package/README.md +126 -3
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/contract/ppt-contract.json +1529 -0
- package/cordis.patch.yml +25 -0
- package/core/audit.js +500 -0
- package/core/chart.js +199 -0
- package/core/color.js +80 -0
- package/core/deck-layout.js +195 -0
- package/core/formula.js +80 -0
- package/core/hfmath/hershey.js +1763 -0
- package/core/hfmath/index.js +1176 -0
- package/core/hfmath/stretch.js +41 -0
- package/core/hfmath/symbols.js +408 -0
- package/core/ops/browser.js +28 -0
- package/core/ops/chart.js +64 -0
- package/core/ops/deck.js +21 -0
- package/core/ops/dispatcher.js +2 -0
- package/core/ops/element.js +55 -0
- package/core/ops/history.js +11 -0
- package/core/ops/import.js +95 -0
- package/core/ops/index.js +39 -0
- package/core/ops/playback.js +9 -0
- package/core/ops/recolor.js +33 -0
- package/core/ops/slide.js +66 -0
- package/core/ops/table.js +85 -0
- package/core/ops/text.js +35 -0
- package/core/ops/view.js +13 -0
- package/core/ops.js +12 -0
- package/core/quick.js +52 -0
- package/core/shape-library.js +122 -0
- package/core/table.js +178 -0
- package/core/text.js +200 -0
- package/editor/app.js +5366 -0
- package/editor/heartbeat-worker.js +116 -0
- package/editor/index.html +89 -0
- package/editor/ppt-full-app.js +376 -0
- package/lib/host.mjs +98 -0
- package/package.json +51 -4
- package/server/audit.mjs +2 -0
- package/server/bridge-client.mjs +174 -0
- package/server/constants.mjs +20 -0
- package/server/contract-snapshots.mjs +62 -0
- package/server/contract.mjs +24 -0
- package/server/deck-layout.mjs +2 -0
- package/server/http-utils.mjs +2 -0
- package/server/infra/http.mjs +113 -0
- package/server/infra/runtime/context.mjs +128 -0
- package/server/infra/store/blob-store.mjs +109 -0
- package/server/infra/tuning.mjs +82 -0
- package/server/infra/util/fs-lock.mjs +132 -0
- package/server/infra/workspace/manager.mjs +657 -0
- package/server/mcp/dispatch.mjs +2 -0
- package/server/mcp/handler.mjs +2 -0
- package/server/mcp/instructions.mjs +40 -0
- package/server/mcp-http.mjs +3 -0
- package/server/ppt-shim.mjs +156 -0
- package/server/relay.mjs +245 -0
- package/server/routes/assets.mjs +2 -0
- package/server/routes/bridge.mjs +47 -0
- package/server/routes/editor.mjs +9 -0
- package/server/routes/import.mjs +258 -0
- package/server/routes/mcp.mjs +17 -0
- package/server/runtime/recovery.mjs +9 -0
- package/server/runtime/watch.mjs +17 -0
- package/server/shared/SHARED_MANIFEST.json +47 -0
- package/server/shared/asset-service.mjs +217 -0
- package/server/shared/editor-host.mjs +125 -0
- package/server/shared/kit/editor/lease.mjs +139 -0
- package/server/shared/kit/service/api.mjs +45 -0
- package/server/shared/kit/service/http.mjs +113 -0
- package/server/shared/kit/service/mcp-http.mjs +142 -0
- package/server/shared/kit/service/single-instance.mjs +72 -0
- package/server/shared/kit/tools/validate.mjs +115 -0
- package/server/shared/kit/tuning.mjs +82 -0
- package/server/shared/kit/util/fs-lock.mjs +132 -0
- package/server/shared/service-host.mjs +511 -0
- package/server/shared/state-service.mjs +249 -0
- package/server/shared/tool-host.mjs +341 -0
- package/server/shared/tuning.mjs +63 -0
- package/server/single-instance.mjs +2 -0
- package/server/store/backends/file-backend.mjs +13 -0
- package/server/store/checkpoints.mjs +28 -0
- package/server/store/doc.mjs +52 -0
- package/server/store/index.mjs +34 -0
- package/server/store/journal.mjs +9 -0
- package/server/store/lease.mjs +2 -0
- package/server/store/sanitize.mjs +32 -0
- package/server/store.mjs +2 -0
- package/server/tools/capabilities/apply.mjs +33 -0
- package/server/tools/capabilities/audit.mjs +24 -0
- package/server/tools/capabilities/chart.mjs +83 -0
- package/server/tools/capabilities/compose.mjs +97 -0
- package/server/tools/capabilities/contract.mjs +34 -0
- package/server/tools/capabilities/deck.mjs +27 -0
- package/server/tools/capabilities/element.mjs +67 -0
- package/server/tools/capabilities/export.mjs +95 -0
- package/server/tools/capabilities/formula.mjs +72 -0
- package/server/tools/capabilities/history.mjs +29 -0
- package/server/tools/capabilities/image.mjs +152 -0
- package/server/tools/capabilities/imagesearch.mjs +152 -0
- package/server/tools/capabilities/import.mjs +180 -0
- package/server/tools/capabilities/index.mjs +58 -0
- package/server/tools/capabilities/line.mjs +58 -0
- package/server/tools/capabilities/notes.mjs +33 -0
- package/server/tools/capabilities/present.mjs +34 -0
- package/server/tools/capabilities/recolor.mjs +60 -0
- package/server/tools/capabilities/screenshot.mjs +86 -0
- package/server/tools/capabilities/shape.mjs +64 -0
- package/server/tools/capabilities/slide.mjs +81 -0
- package/server/tools/capabilities/style.mjs +48 -0
- package/server/tools/capabilities/table.mjs +127 -0
- package/server/tools/capabilities/task.mjs +52 -0
- package/server/tools/capabilities/text.mjs +82 -0
- package/server/tools/domains/color.mjs +4 -0
- package/server/tools/domains/image.mjs +49 -0
- package/server/tools/domains/index.mjs +7 -0
- package/server/tools/domains/pptx-export.mjs +566 -0
- package/server/tools/domains/recolor.mjs +25 -0
- package/server/tools/domains/remote-image.mjs +291 -0
- package/server/tools/domains/slide.mjs +21 -0
- package/server/tools/domains/text-style.mjs +11 -0
- package/server/tools/domains/tooling.mjs +13 -0
- package/server/tools/index.mjs +14 -0
- package/server/tools/kernel/define.mjs +5 -0
- package/server/tools/kernel/ports.mjs +42 -0
- package/server/tools/kernel/registry.mjs +17 -0
- package/server/tools.mjs +18 -0
- package/server/tuning.mjs +86 -0
- package/server/validate.mjs +2 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程图片抓取:浏览器头 + 分级错误 + 主机熔断 + 候选预检。
|
|
3
|
+
*
|
|
4
|
+
* 2026-11-02 事故复盘(用户侧现象:一直报"远程图片下载/校验失败"):
|
|
5
|
+
* 1. 请求不带 User-Agent,图床把裸 Node 请求当爬虫直接 403;
|
|
6
|
+
* 2. 10s 硬超时覆盖了整个响应体,3.6MB 的原图在并发下必然中断;
|
|
7
|
+
* 3. 403 / 超时 / 超大 都吐同一句"请改用可访问的图片 URL",
|
|
8
|
+
* 模型读不出"这个域名没救",于是换一个 URL 再撞一次。
|
|
9
|
+
*
|
|
10
|
+
* 所以判定逻辑收在这里:每个失败都带 code 和 permanent 标记,调用方
|
|
11
|
+
* 据此告诉模型下一步该做什么;同一主机确认不可用后 10 分钟内短路,
|
|
12
|
+
* 不再重复付超时的代价。
|
|
13
|
+
*/
|
|
14
|
+
export const IMAGE_MAX_BYTES = 6 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
export const IMAGE_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
|
17
|
+
+ '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 25000
|
|
20
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 4500
|
|
21
|
+
const DEFAULT_RETRIES = 1
|
|
22
|
+
const HOST_BLOCK_TTL_MS = 10 * 60 * 1000
|
|
23
|
+
const RETRY_BACKOFF_MS = 400
|
|
24
|
+
/* 同一主机连续多少个"永久失败"才整体熔断。
|
|
25
|
+
* 单条 403 可能只是某张图防盗链,一刀切会误伤同一 CDN 上的好图;
|
|
26
|
+
* 连续两条都废,才说明这个域名对本机不是可用来源。 */
|
|
27
|
+
const HOST_BLOCK_THRESHOLD = 2
|
|
28
|
+
|
|
29
|
+
/** host -> { until, code, reason } */
|
|
30
|
+
const blockedHosts = new Map()
|
|
31
|
+
/** host -> 连续永久失败次数(成功一次清零) */
|
|
32
|
+
const hostFailures = new Map()
|
|
33
|
+
|
|
34
|
+
export class ImageFetchError extends Error {
|
|
35
|
+
constructor(code, message, options = {}) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = 'ImageFetchError'
|
|
38
|
+
this.code = code
|
|
39
|
+
this.host = options.host || ''
|
|
40
|
+
this.status = options.status || 0
|
|
41
|
+
this.permanent = options.permanent === true
|
|
42
|
+
this.retryable = options.retryable === true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hostOf(url) {
|
|
47
|
+
try { return new URL(String(url)).hostname.toLowerCase() } catch { return '' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function blockedHost(host) {
|
|
51
|
+
const entry = blockedHosts.get(host)
|
|
52
|
+
if (!entry) return null
|
|
53
|
+
if (entry.until <= Date.now()) { blockedHosts.delete(host); return null }
|
|
54
|
+
return entry
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function blockHost(host, code, reason) {
|
|
58
|
+
if (!host) return
|
|
59
|
+
blockedHosts.set(host, { until: Date.now() + HOST_BLOCK_TTL_MS, code, reason })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 记一次永久失败;达到阈值才真正熔断整个主机。 */
|
|
63
|
+
export function noteHostFailure(host, code, reason) {
|
|
64
|
+
if (!host) return false
|
|
65
|
+
const count = (hostFailures.get(host) || 0) + 1
|
|
66
|
+
hostFailures.set(host, count)
|
|
67
|
+
if (count < HOST_BLOCK_THRESHOLD) return false
|
|
68
|
+
blockHost(host, code, reason)
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function noteHostSuccess(host) {
|
|
73
|
+
if (host) hostFailures.delete(host)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 测试用:清空熔断表。 */
|
|
77
|
+
export function clearBlockedHosts() {
|
|
78
|
+
blockedHosts.clear()
|
|
79
|
+
hostFailures.clear()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
83
|
+
|
|
84
|
+
function isTimeout(error) {
|
|
85
|
+
const name = String(error?.name || '')
|
|
86
|
+
return name === 'TimeoutError' || name === 'AbortError'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function networkError(error, host, timeoutMs) {
|
|
90
|
+
if (isTimeout(error)) {
|
|
91
|
+
return new ImageFetchError('TIMEOUT', `下载超时(${Math.round(timeoutMs / 1000)}s 内没有收完)`, {
|
|
92
|
+
host, retryable: true,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
return new ImageFetchError('NETWORK', `网络错误:${error?.message || error}`, { host, retryable: true })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function httpError(status, host) {
|
|
99
|
+
if (status === 401 || status === 403 || status === 451) {
|
|
100
|
+
noteHostFailure(host, 'HTTP_' + status, '拒绝直连(防盗链/爬虫拦截)')
|
|
101
|
+
return new ImageFetchError('HTTP_' + status, `HTTP ${status}:该站拒绝程序直连(防盗链)`, {
|
|
102
|
+
host, status, permanent: true,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
if (status === 404 || status === 410) {
|
|
106
|
+
return new ImageFetchError('HTTP_' + status, `HTTP ${status}:链接已失效`, { host, status, permanent: true })
|
|
107
|
+
}
|
|
108
|
+
if (status === 408 || status === 425 || status === 429 || status >= 500) {
|
|
109
|
+
return new ImageFetchError('HTTP_' + status, `HTTP ${status}:对方暂时不可用`, { host, status, retryable: true })
|
|
110
|
+
}
|
|
111
|
+
return new ImageFetchError('HTTP_' + status, `HTTP ${status}`, { host, status, permanent: true })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const FETCH_HEADERS = {
|
|
115
|
+
'user-agent': IMAGE_UA,
|
|
116
|
+
accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8,*/*;q=0.5',
|
|
117
|
+
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
118
|
+
referer: 'https://www.bing.com/',
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function cancelBody(res) {
|
|
122
|
+
try { await res.body?.cancel() } catch { /* 已关闭 */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function fetchBinary(url, { timeoutMs, maxBytes }) {
|
|
126
|
+
const host = hostOf(url)
|
|
127
|
+
let res
|
|
128
|
+
try {
|
|
129
|
+
res = await fetch(url, {
|
|
130
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
131
|
+
redirect: 'follow',
|
|
132
|
+
headers: FETCH_HEADERS,
|
|
133
|
+
})
|
|
134
|
+
} catch (error) {
|
|
135
|
+
throw networkError(error, host, timeoutMs)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
await cancelBody(res)
|
|
140
|
+
throw httpError(res.status, host)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const type = String(res.headers.get('content-type') || '').toLowerCase()
|
|
144
|
+
if (type && !type.startsWith('image/') && !type.startsWith('application/octet-stream')) {
|
|
145
|
+
await cancelBody(res)
|
|
146
|
+
throw new ImageFetchError('NOT_IMAGE', `返回的不是图片(content-type=${type})`, {
|
|
147
|
+
host, status: res.status, permanent: true,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const declared = Number(res.headers.get('content-length') || 0)
|
|
152
|
+
if (declared > maxBytes) {
|
|
153
|
+
await cancelBody(res)
|
|
154
|
+
throw new ImageFetchError('TOO_LARGE', `图片 ${(declared / 1024 / 1024).toFixed(1)}MB 超过 ${Math.round(maxBytes / 1024 / 1024)}MB 上限`, {
|
|
155
|
+
host, status: res.status, permanent: true,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
160
|
+
let buf
|
|
161
|
+
try { buf = Buffer.from(await res.arrayBuffer()) } catch (error) {
|
|
162
|
+
throw networkError(error, host, timeoutMs)
|
|
163
|
+
}
|
|
164
|
+
if (buf.length > maxBytes) {
|
|
165
|
+
throw new ImageFetchError('TOO_LARGE', `图片 ${(buf.length / 1024 / 1024).toFixed(1)}MB 超过上限`, {
|
|
166
|
+
host, permanent: true,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
noteHostSuccess(host)
|
|
170
|
+
return { buf, contentType: type, host }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const reader = res.body.getReader()
|
|
174
|
+
const chunks = []
|
|
175
|
+
let size = 0
|
|
176
|
+
try {
|
|
177
|
+
while (true) {
|
|
178
|
+
const { done, value } = await reader.read()
|
|
179
|
+
if (done) break
|
|
180
|
+
size += value.byteLength
|
|
181
|
+
if (size > maxBytes) {
|
|
182
|
+
try { await reader.cancel() } catch { /* 忽略 */ }
|
|
183
|
+
throw new ImageFetchError('TOO_LARGE', `图片超过 ${Math.round(maxBytes / 1024 / 1024)}MB 上限`, {
|
|
184
|
+
host, permanent: true,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
chunks.push(Buffer.from(value))
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error instanceof ImageFetchError) throw error
|
|
191
|
+
throw networkError(error, host, timeoutMs)
|
|
192
|
+
}
|
|
193
|
+
noteHostSuccess(host)
|
|
194
|
+
return { buf: Buffer.concat(chunks), contentType: type, host }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 下载远程图片为 Buffer。
|
|
199
|
+
* @returns {Promise<{buf: Buffer, contentType: string, host: string}>}
|
|
200
|
+
*/
|
|
201
|
+
export async function downloadImage(url, options = {}) {
|
|
202
|
+
const host = hostOf(url)
|
|
203
|
+
const blocked = blockedHost(host)
|
|
204
|
+
if (blocked) {
|
|
205
|
+
throw new ImageFetchError('HOST_BLOCKED', `${host} 近期已确认不可用(${blocked.reason}),本次直接跳过`, {
|
|
206
|
+
host, permanent: true,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || DEFAULT_TIMEOUT_MS)
|
|
210
|
+
const maxBytes = Number(options.maxBytes) || IMAGE_MAX_BYTES
|
|
211
|
+
const retries = options.retries == null ? DEFAULT_RETRIES : Math.max(0, Number(options.retries) || 0)
|
|
212
|
+
|
|
213
|
+
let last = null
|
|
214
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
215
|
+
try {
|
|
216
|
+
return await fetchBinary(url, { timeoutMs, maxBytes })
|
|
217
|
+
} catch (error) {
|
|
218
|
+
last = error
|
|
219
|
+
const retryable = error instanceof ImageFetchError && error.retryable
|
|
220
|
+
if (!retryable || attempt === retries) break
|
|
221
|
+
await sleep(RETRY_BACKOFF_MS * (attempt + 1))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
throw last
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 预检候选图:只读响应头,判定能否被 ppt_image 下载。
|
|
229
|
+
* 用于 ppt_images 在把直链交给模型之前先过滤死链,避免"选一个、失败、
|
|
230
|
+
* 再选一个、再失败"的循环。拿到头就断开,不读正文。
|
|
231
|
+
* @returns {Promise<{ok: true, size: number, type: string} | {ok: false, code: string, reason: string, permanent: boolean}>}
|
|
232
|
+
*/
|
|
233
|
+
export async function probeImage(url, options = {}) {
|
|
234
|
+
const host = hostOf(url)
|
|
235
|
+
const blocked = blockedHost(host)
|
|
236
|
+
if (blocked) return { ok: false, code: 'HOST_BLOCKED', reason: blocked.reason, permanent: true }
|
|
237
|
+
|
|
238
|
+
const budget = Math.max(1000, Number(options.timeoutMs) || DEFAULT_PROBE_TIMEOUT_MS)
|
|
239
|
+
const remaining = options.deadline ? Number(options.deadline) - Date.now() : budget
|
|
240
|
+
if (remaining <= 0) return { ok: false, code: 'TIMEOUT', reason: '预检预算用尽', permanent: false }
|
|
241
|
+
const timeoutMs = Math.min(budget, remaining)
|
|
242
|
+
let res
|
|
243
|
+
try {
|
|
244
|
+
/* 不用 Range 探体积:部分图床对 bytes=0-0 回 416/400,
|
|
245
|
+
* 会把好图误判成死链。这里发普通 GET,拿到响应头就立刻 cancel,
|
|
246
|
+
* 正文不会真的下载完。 */
|
|
247
|
+
res = await fetch(url, {
|
|
248
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
249
|
+
redirect: 'follow',
|
|
250
|
+
headers: FETCH_HEADERS,
|
|
251
|
+
})
|
|
252
|
+
} catch (error) {
|
|
253
|
+
/* 预检超时不等于不可用:下载阶段给了更长预算,保留候选交给 ppt_image 判定。 */
|
|
254
|
+
return { ok: false, code: isTimeout(error) ? 'TIMEOUT' : 'NETWORK', reason: error?.message || String(error), permanent: false }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const type = String(res.headers.get('content-type') || '').toLowerCase()
|
|
258
|
+
if (!res.ok && res.status !== 206) {
|
|
259
|
+
await cancelBody(res)
|
|
260
|
+
const mapped = httpError(res.status, host)
|
|
261
|
+
return { ok: false, code: mapped.code, reason: mapped.message, permanent: mapped.permanent }
|
|
262
|
+
}
|
|
263
|
+
await cancelBody(res)
|
|
264
|
+
if (type && !type.startsWith('image/') && !type.startsWith('application/octet-stream')) {
|
|
265
|
+
return { ok: false, code: 'NOT_IMAGE', reason: `content-type=${type}`, permanent: true }
|
|
266
|
+
}
|
|
267
|
+
let size = Number(res.headers.get('content-length') || 0)
|
|
268
|
+
const range = String(res.headers.get('content-range') || '')
|
|
269
|
+
const total = Number(range.split('/').pop())
|
|
270
|
+
if (Number.isFinite(total) && total > 0) size = total
|
|
271
|
+
if (size > IMAGE_MAX_BYTES) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
code: 'TOO_LARGE',
|
|
275
|
+
reason: `${(size / 1024 / 1024).toFixed(1)}MB 超过上限`,
|
|
276
|
+
permanent: true,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { ok: true, size, type }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 把 ImageFetchError 翻译成模型能直接照着做的下一步。 */
|
|
283
|
+
export function describeImageFailure(error, url) {
|
|
284
|
+
const code = error instanceof ImageFetchError ? error.code : 'UNKNOWN'
|
|
285
|
+
const message = String(error?.message || error)
|
|
286
|
+
const permanent = error instanceof ImageFetchError ? error.permanent : false
|
|
287
|
+
const action = permanent
|
|
288
|
+
? '这是永久性失败,不要重试同一链接或同一域名:改用 ppt_images 换来源,或本地下载后用 path 传入'
|
|
289
|
+
: '可以重试一次;仍失败就换来源(ppt_images)或改用本地 path'
|
|
290
|
+
return `远程图片不可用[${code}]:${message}。${action}。url=${String(url).slice(0, 160)}`
|
|
291
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** 幻灯片工厂:加页工具用的最小页面。 */
|
|
2
|
+
import { escapeHtml } from '../../../core/text.js'
|
|
3
|
+
|
|
4
|
+
export function simpleSlide(title, bullets, background) {
|
|
5
|
+
const W = 1000, pad = 60, ts = Date.now()
|
|
6
|
+
const els = [{
|
|
7
|
+
type: 'text', id: `mcp_t_${ts}`,
|
|
8
|
+
left: pad, top: 50, width: W - pad * 2, height: 70,
|
|
9
|
+
content: `<p style="font-size: 34px;"><strong>${escapeHtml(title)}</strong></p>`,
|
|
10
|
+
defaultColor: '#333333', defaultFontName: '', fixedRatio: false, rotate: 0,
|
|
11
|
+
}]
|
|
12
|
+
;(bullets || []).slice(0, 8).forEach((b, i) => {
|
|
13
|
+
els.push({
|
|
14
|
+
type: 'text', id: `mcp_b_${ts}_${i}`,
|
|
15
|
+
left: pad, top: 150 + i * 46, width: W - pad * 2, height: 42,
|
|
16
|
+
content: `<p style="font-size: 20px; color: #555555;">• ${escapeHtml(String(b))}</p>`,
|
|
17
|
+
defaultColor: '#333333', defaultFontName: '', fixedRatio: false, rotate: 0,
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
return { id: `mcp_slide_${ts}`, background: { type: 'solid', color: background || '#ffffff' }, elements: els }
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** 文本样式参数归一化:ppt_text / ppt_fill / ppt_element 共用。 */
|
|
2
|
+
import { resolveColor } from './color.mjs'
|
|
3
|
+
|
|
4
|
+
export function normalizeTextStyle({ font_size, color, bold, align } = {}) {
|
|
5
|
+
const out = {}
|
|
6
|
+
if (font_size != null) out.fontSize = Math.min(200, Math.max(8, Number(font_size) | 0))
|
|
7
|
+
if (color) out.color = resolveColor(color)
|
|
8
|
+
if (bold != null) out.bold = !!bold
|
|
9
|
+
if (align) out.align = align
|
|
10
|
+
return out
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** 工具层通用小函数:参数钳制、等待、回执文案。 */
|
|
2
|
+
|
|
3
|
+
export const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n))
|
|
4
|
+
|
|
5
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
6
|
+
|
|
7
|
+
export const okText = (r, msg) => ({
|
|
8
|
+
text: r.ok ? `${msg}(seq ${r.seq},${r.applied} 条)` : `应用失败:${r.error}`,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export const normalizeSlides = (slides) => (Array.isArray(slides) && slides.length
|
|
12
|
+
? slides.map((n) => clamp(Number(n) | 0, 0, 999))
|
|
13
|
+
: null)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 工具面组合根:能力声明 → 注册表 → 对外导出的唯一入口。 */
|
|
2
|
+
import { CAPABILITIES } from './capabilities/index.mjs'
|
|
3
|
+
import { createRegistry } from './kernel/registry.mjs'
|
|
4
|
+
|
|
5
|
+
export { CAPABILITIES, createRegistry }
|
|
6
|
+
|
|
7
|
+
export const registry = createRegistry(CAPABILITIES)
|
|
8
|
+
export const {
|
|
9
|
+
TOOLS,
|
|
10
|
+
TOOL_IMPL,
|
|
11
|
+
TOOL_META,
|
|
12
|
+
FEATURES,
|
|
13
|
+
CAPABILITIES: CAPABILITY_LIST,
|
|
14
|
+
} = registry
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内核端口:能力模块唯一允许接触的 I/O 面。
|
|
3
|
+
* 每个端口对应一类职责,能力按 requires 声明,registry 负责注入。
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
BASE, get, getDeckOnly, getImportResult, getShot, getStatus, post, push, putAsset,
|
|
7
|
+
requireEditor, summarize, uploadImport,
|
|
8
|
+
} from '../../bridge-client.mjs'
|
|
9
|
+
|
|
10
|
+
export function createKernelPorts() {
|
|
11
|
+
return {
|
|
12
|
+
deck: {
|
|
13
|
+
read: getDeckOnly,
|
|
14
|
+
write: push,
|
|
15
|
+
summarize,
|
|
16
|
+
},
|
|
17
|
+
status: {
|
|
18
|
+
get: getStatus,
|
|
19
|
+
baseUrl: BASE,
|
|
20
|
+
},
|
|
21
|
+
editor: {
|
|
22
|
+
require: requireEditor,
|
|
23
|
+
getShot,
|
|
24
|
+
requestShot: () => post('/shot-request', {}),
|
|
25
|
+
},
|
|
26
|
+
assets: {
|
|
27
|
+
put: putAsset,
|
|
28
|
+
},
|
|
29
|
+
task: {
|
|
30
|
+
status: () => get('/task/status'),
|
|
31
|
+
list: () => get('/task/checkpoints'),
|
|
32
|
+
checkpoint: (name, meta) => post('/task/checkpoint', { name, meta: meta || {} }),
|
|
33
|
+
read: (name) => get('/task/checkpoint?name=' + encodeURIComponent(name)),
|
|
34
|
+
},
|
|
35
|
+
importer: {
|
|
36
|
+
upload: uploadImport,
|
|
37
|
+
result: getImportResult,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const KERNEL_PORT_NAMES = Object.freeze(['deck', 'status', 'editor', 'assets', 'task', 'importer', 'contract'])
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 能力注册表:通用实现来自 dsh-tool-host;本文件只注入 PPT 的内核端口与契约报告。
|
|
3
|
+
*/
|
|
4
|
+
import { createRegistry as createBaseRegistry } from '../../shared/tool-host.mjs'
|
|
5
|
+
import { createKernelPorts, KERNEL_PORT_NAMES } from './ports.mjs'
|
|
6
|
+
import { capabilityReport } from '../../contract.mjs'
|
|
7
|
+
|
|
8
|
+
export function createRegistry(capabilities, options = {}) {
|
|
9
|
+
const { toolHost = null, ...rest } = options
|
|
10
|
+
const factory = toolHost?.createRegistry || createBaseRegistry
|
|
11
|
+
return factory(capabilities, {
|
|
12
|
+
ports: createKernelPorts(),
|
|
13
|
+
reserved: KERNEL_PORT_NAMES,
|
|
14
|
+
report: ({ editorUrl = '', runtime = {}, ...args }) => capabilityReport(args, editorUrl, runtime),
|
|
15
|
+
...rest,
|
|
16
|
+
})
|
|
17
|
+
}
|
package/server/tools.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** 兼容入口:保持 relay / stdio / tests 的旧 import 路径不变。 */
|
|
2
|
+
export {
|
|
3
|
+
TOOLS,
|
|
4
|
+
TOOL_IMPL,
|
|
5
|
+
TOOL_META,
|
|
6
|
+
FEATURES,
|
|
7
|
+
CAPABILITY_LIST,
|
|
8
|
+
CAPABILITIES,
|
|
9
|
+
createRegistry,
|
|
10
|
+
registry,
|
|
11
|
+
} from './tools/index.mjs'
|
|
12
|
+
|
|
13
|
+
/* 旧 tools.mjs 直接 re-export 的工具辅助;能力模块内部不再走这里。 */
|
|
14
|
+
export {
|
|
15
|
+
expandRecolorOps,
|
|
16
|
+
resolveColor,
|
|
17
|
+
simpleSlide,
|
|
18
|
+
} from './tools/domains/index.mjs'
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 运行时调优参数(单一真源,构建时复制到 server/tuning.mjs)。
|
|
3
|
+
*
|
|
4
|
+
* 这些值不是拍脑袋的常量:由 bench/evolution 的 NSGA-II 进化搜索在
|
|
5
|
+
* 延迟 / 吞吐 / 空闲 CPU / 稳定性四个目标上跑出的 Pareto 解固化而来。
|
|
6
|
+
* 生产可以通过 PPT_TUNING_JSON 环境变量覆盖(见 applyTuningFromEnv)。
|
|
7
|
+
*
|
|
8
|
+
* 注意:本模块同时被 src/mcp 与构建后的 server/ 使用,禁止依赖插件内其它模块。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_TUNING = Object.freeze({
|
|
12
|
+
/* 编辑器长轮询:越小延迟越低,越大空闲 CPU 越低。
|
|
13
|
+
* 进化搜索的膝点解:75ms 只比 50ms 多 ~5ms 更新延迟,但轮询唤醒减少 35%。 */
|
|
14
|
+
watchPollMs: 75,
|
|
15
|
+
watchTimeoutSec: 20,
|
|
16
|
+
watchTimeoutMaxSec: 30,
|
|
17
|
+
|
|
18
|
+
/* MCP 客户端等待回执的节奏。 */
|
|
19
|
+
statusPollMs: 100,
|
|
20
|
+
pushBaseDeadlineMs: 12000,
|
|
21
|
+
pushPerOpMs: 50,
|
|
22
|
+
|
|
23
|
+
/* HTTP 宿主超时。 */
|
|
24
|
+
httpRequestTimeoutMs: 30000,
|
|
25
|
+
httpHeadersTimeoutMs: 10000,
|
|
26
|
+
httpKeepAliveTimeoutMs: 5000,
|
|
27
|
+
|
|
28
|
+
/* 状态持久化。 */
|
|
29
|
+
journalMaxBytes: 2 * 1024 * 1024,
|
|
30
|
+
stateCompact: true,
|
|
31
|
+
|
|
32
|
+
/* 请求体与 MCP 批量上限。 */
|
|
33
|
+
bodyLimitBytes: 10 * 1024 * 1024,
|
|
34
|
+
mcpBatchLimit: 100,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const NUMERIC_KEYS = new Set([
|
|
38
|
+
'watchPollMs', 'watchTimeoutSec', 'watchTimeoutMaxSec', 'statusPollMs',
|
|
39
|
+
'pushBaseDeadlineMs', 'pushPerOpMs', 'httpRequestTimeoutMs', 'httpHeadersTimeoutMs',
|
|
40
|
+
'httpKeepAliveTimeoutMs', 'journalMaxBytes', 'bodyLimitBytes', 'mcpBatchLimit',
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
let current = { ...DEFAULT_TUNING }
|
|
44
|
+
|
|
45
|
+
function sanitize(patch) {
|
|
46
|
+
const next = {}
|
|
47
|
+
for (const [key, raw] of Object.entries(patch || {})) {
|
|
48
|
+
if (!(key in DEFAULT_TUNING)) continue
|
|
49
|
+
if (key === 'stateCompact') {
|
|
50
|
+
if (typeof raw === 'boolean') next[key] = raw
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (!NUMERIC_KEYS.has(key)) continue
|
|
54
|
+
const value = Number(raw)
|
|
55
|
+
if (Number.isFinite(value) && value >= 0) next[key] = value
|
|
56
|
+
}
|
|
57
|
+
return next
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getTuning() {
|
|
61
|
+
return current
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 仅供测试/进化搜索使用:设置当前进程内的调优参数。 */
|
|
65
|
+
export function setTuning(patch) {
|
|
66
|
+
current = { ...current, ...sanitize(patch) }
|
|
67
|
+
return current
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resetTuning() {
|
|
71
|
+
current = { ...DEFAULT_TUNING }
|
|
72
|
+
return current
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 生产覆盖:PPT_TUNING_JSON='{"watchPollMs":100}'。非法值静默忽略。 */
|
|
76
|
+
export function applyTuningFromEnv(env = process.env) {
|
|
77
|
+
const raw = env && env.PPT_TUNING_JSON
|
|
78
|
+
if (!raw) return current
|
|
79
|
+
try {
|
|
80
|
+
return setTuning(JSON.parse(raw))
|
|
81
|
+
} catch {
|
|
82
|
+
return current
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
applyTuningFromEnv()
|