@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.
- 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 +53 -6
- 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,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PPTX 导入中转路由。
|
|
3
|
+
*
|
|
4
|
+
* 浏览器端导入需要 File 对象,而 MCP 工具在 Node 进程里拿到的是路径/字节。
|
|
5
|
+
* 这里负责:
|
|
6
|
+
* POST <base>/import-file 原始字节上传 → 临时文件 + 同源下载地址
|
|
7
|
+
* GET <base>/import-file/:id 编辑器页面取回原始字节
|
|
8
|
+
* POST <base>/import-result 编辑器回传导入结果
|
|
9
|
+
* GET <base>/import-result?req= MCP 工具轮询结果
|
|
10
|
+
*
|
|
11
|
+
* 文件与结果都是短生命周期:结果回传后删除临时文件,结果按 TTL 清理。
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'node:crypto'
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import { readBody, send } from '../infra/http.mjs'
|
|
17
|
+
import { renderDeckToPptx } from '../tools/domains/pptx-export.mjs'
|
|
18
|
+
|
|
19
|
+
const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
20
|
+
const MAX_UPLOAD_BYTES = 128 * 1024 * 1024
|
|
21
|
+
const MAX_RESULT_BYTES = 2 * 1024 * 1024
|
|
22
|
+
const RESULT_TTL_MS = 10 * 60 * 1000
|
|
23
|
+
const FILE_TTL_MS = 60 * 60 * 1000
|
|
24
|
+
const ID_RE = /^[a-f0-9]{16,64}$/
|
|
25
|
+
|
|
26
|
+
function readRawBody(req, limit) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let size = 0
|
|
29
|
+
let settled = false
|
|
30
|
+
const chunks = []
|
|
31
|
+
const fail = (error) => {
|
|
32
|
+
if (settled) return
|
|
33
|
+
settled = true
|
|
34
|
+
reject(error)
|
|
35
|
+
}
|
|
36
|
+
req.on('data', (chunk) => {
|
|
37
|
+
if (settled) return
|
|
38
|
+
size += chunk.length
|
|
39
|
+
if (size > limit) {
|
|
40
|
+
const error = new Error('pptx 文件超过大小上限')
|
|
41
|
+
error.code = 'BODY_TOO_LARGE'
|
|
42
|
+
fail(error)
|
|
43
|
+
req.resume()
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
chunks.push(chunk)
|
|
47
|
+
})
|
|
48
|
+
req.on('end', () => {
|
|
49
|
+
if (settled) return
|
|
50
|
+
settled = true
|
|
51
|
+
resolve(Buffer.concat(chunks))
|
|
52
|
+
})
|
|
53
|
+
req.on('error', fail)
|
|
54
|
+
req.on('close', () => {
|
|
55
|
+
if (!req.readableEnded) fail(new Error('客户端在上传完成前断开'))
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeName(value) {
|
|
61
|
+
let name = String(value || 'import.pptx')
|
|
62
|
+
try { name = decodeURIComponent(name) } catch { /* 保留原值 */ }
|
|
63
|
+
name = path.basename(name).replace(/[\\/:*?"<>|]/g, '_').slice(0, 120)
|
|
64
|
+
return name.toLowerCase().endsWith('.pptx') ? name : `${name || 'import'}.pptx`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function jsonResult(body) {
|
|
68
|
+
return {
|
|
69
|
+
done: true,
|
|
70
|
+
ok: !!body.ok,
|
|
71
|
+
error: body.error ? String(body.error).slice(0, 2000) : '',
|
|
72
|
+
summary: body.summary && typeof body.summary === 'object' ? body.summary : null,
|
|
73
|
+
ts: Date.now(),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createImportRoutes({ basePath, importDir, store }) {
|
|
78
|
+
const prefix = String(basePath || '').replace(/\/+$/, '')
|
|
79
|
+
if (!importDir) throw new Error('createImportRoutes 需要 importDir')
|
|
80
|
+
fs.mkdirSync(importDir, { recursive: true })
|
|
81
|
+
const sourceRecord = path.join(importDir, 'last-open.json')
|
|
82
|
+
|
|
83
|
+
/* 启动时清掉上一个进程遗留的临时文件(失败不影响服务启动)。 */
|
|
84
|
+
try {
|
|
85
|
+
const now = Date.now()
|
|
86
|
+
for (const entry of fs.readdirSync(importDir)) {
|
|
87
|
+
if (!entry.endsWith('.pptx')) continue
|
|
88
|
+
const file = path.join(importDir, entry)
|
|
89
|
+
try {
|
|
90
|
+
if (now - fs.statSync(file).mtimeMs > FILE_TTL_MS) fs.rmSync(file, { force: true })
|
|
91
|
+
} catch { /* 单个文件清理失败忽略 */ }
|
|
92
|
+
}
|
|
93
|
+
} catch { /* 目录不可读忽略 */ }
|
|
94
|
+
|
|
95
|
+
const results = new Map()
|
|
96
|
+
let saving = false
|
|
97
|
+
const timer = setInterval(() => {
|
|
98
|
+
const now = Date.now()
|
|
99
|
+
for (const [key, value] of results) {
|
|
100
|
+
if (now - value.ts > RESULT_TTL_MS) results.delete(key)
|
|
101
|
+
}
|
|
102
|
+
}, 60_000)
|
|
103
|
+
timer.unref?.()
|
|
104
|
+
|
|
105
|
+
return async function importRoutes(req, res, u) {
|
|
106
|
+
const pathname = u.pathname
|
|
107
|
+
if (prefix && pathname !== prefix && !pathname.startsWith(prefix + '/')) return false
|
|
108
|
+
const sub = prefix ? pathname.slice(prefix.length) : pathname
|
|
109
|
+
|
|
110
|
+
/* 原始字节上传 */
|
|
111
|
+
if (sub === '/import-file' && req.method === 'POST') {
|
|
112
|
+
let body
|
|
113
|
+
try {
|
|
114
|
+
body = await readRawBody(req, MAX_UPLOAD_BYTES)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const status = error?.code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
117
|
+
send(req, res, status, { error: String(error.message || error) })
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
if (!body.length) {
|
|
121
|
+
send(req, res, 400, { error: '上传内容为空' })
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
/* ZIP 魔数:pptx 必须是 OOXML 包,提前拦住误传的图片/JSON。 */
|
|
125
|
+
if (body[0] !== 0x50 || body[1] !== 0x4b) {
|
|
126
|
+
send(req, res, 400, { error: '不是有效的 PPTX 文件(缺少 ZIP/OOXML 头)' })
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
const id = crypto.randomBytes(12).toString('hex')
|
|
130
|
+
const name = safeName(req.headers['x-file-name'])
|
|
131
|
+
const file = path.join(importDir, `${id}.pptx`)
|
|
132
|
+
try {
|
|
133
|
+
await fs.promises.writeFile(file, body)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
send(req, res, 500, { error: '写入临时文件失败:' + String(error.message || error) })
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
/* 记录源文件路径:编辑器“保存回原文件”按钮据此写回。 */
|
|
139
|
+
const sourceHeader = req.headers['x-source-path']
|
|
140
|
+
if (sourceHeader) {
|
|
141
|
+
try {
|
|
142
|
+
const sourcePath = decodeURIComponent(String(sourceHeader))
|
|
143
|
+
if (sourcePath) {
|
|
144
|
+
fs.writeFileSync(sourceRecord, JSON.stringify({ path: sourcePath, name, ts: Date.now() }))
|
|
145
|
+
}
|
|
146
|
+
} catch { /* 源路径只影响保存按钮,不阻塞导入 */ }
|
|
147
|
+
}
|
|
148
|
+
send(req, res, 200, {
|
|
149
|
+
ok: true,
|
|
150
|
+
id,
|
|
151
|
+
name,
|
|
152
|
+
bytes: body.length,
|
|
153
|
+
url: `${prefix}/import-file/${id}`,
|
|
154
|
+
})
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* 编辑器取回原始字节 */
|
|
159
|
+
if (sub.startsWith('/import-file/') && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
160
|
+
const id = sub.slice('/import-file/'.length)
|
|
161
|
+
if (!ID_RE.test(id)) {
|
|
162
|
+
send(req, res, 404, { error: '导入文件不存在' })
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
const file = path.join(importDir, `${id}.pptx`)
|
|
166
|
+
let data
|
|
167
|
+
try {
|
|
168
|
+
data = fs.readFileSync(file)
|
|
169
|
+
} catch {
|
|
170
|
+
send(req, res, 404, { error: '导入文件不存在或已清理' })
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
res.writeHead(200, {
|
|
174
|
+
'content-type': PPTX_MIME,
|
|
175
|
+
'content-length': data.length,
|
|
176
|
+
'cache-control': 'no-store',
|
|
177
|
+
'x-content-type-options': 'nosniff',
|
|
178
|
+
'referrer-policy': 'no-referrer',
|
|
179
|
+
})
|
|
180
|
+
res.end(req.method === 'HEAD' ? undefined : data)
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/* 编辑器回传导入结果 */
|
|
185
|
+
if (sub === '/import-result' && req.method === 'POST') {
|
|
186
|
+
let body
|
|
187
|
+
try {
|
|
188
|
+
body = await readBody(req, MAX_RESULT_BYTES)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
send(req, res, error?.code === 'BODY_TOO_LARGE' ? 413 : 400, { error: String(error.message || error) })
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
if (!body || typeof body.req !== 'string' || !body.req) {
|
|
194
|
+
send(req, res, 400, { error: '缺少 import req' })
|
|
195
|
+
return true
|
|
196
|
+
}
|
|
197
|
+
results.set(body.req, jsonResult(body))
|
|
198
|
+
if (body.id && ID_RE.test(String(body.id))) {
|
|
199
|
+
try { fs.rmSync(path.join(importDir, `${body.id}.pptx`), { force: true }) } catch { /* 忽略 */ }
|
|
200
|
+
}
|
|
201
|
+
send(req, res, 200, { ok: true })
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* MCP 工具轮询结果 */
|
|
206
|
+
if (sub === '/import-result' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
207
|
+
const reqId = u.searchParams.get('req') || ''
|
|
208
|
+
const result = reqId ? results.get(reqId) : null
|
|
209
|
+
send(req, res, 200, result || { done: false })
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* 编辑器“保存回原文件”:把当前 deck 导出到导入时记录的源路径,先备份。 */
|
|
214
|
+
if (sub === '/save-pptx' && req.method === 'POST') {
|
|
215
|
+
if (!store) {
|
|
216
|
+
send(req, res, 503, { error: 'store 未就绪' })
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
if (saving) {
|
|
220
|
+
send(req, res, 409, { error: '已有保存任务在进行中' })
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
let record = null
|
|
224
|
+
try { record = JSON.parse(fs.readFileSync(sourceRecord, 'utf8')) } catch { /* 没有记录 */ }
|
|
225
|
+
const target = String(record?.path || '')
|
|
226
|
+
if (!target || !/\.pptx$/i.test(target)) {
|
|
227
|
+
send(req, res, 409, { error: '当前 deck 不是从本机 .pptx 打开的;请让 agent 用 ppt_export_pptx 另存' })
|
|
228
|
+
return true
|
|
229
|
+
}
|
|
230
|
+
const deck = store.get().deck
|
|
231
|
+
if (!deck || !Array.isArray(deck.slides) || !deck.slides.length) {
|
|
232
|
+
send(req, res, 409, { error: '当前 deck 没有幻灯片' })
|
|
233
|
+
return true
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
saving = true
|
|
237
|
+
try {
|
|
238
|
+
const host = req.headers.host || `127.0.0.1:39311`
|
|
239
|
+
const { buffer, stats } = await renderDeckToPptx(deck, {
|
|
240
|
+
assetBase: `http://${host}${prefix}`,
|
|
241
|
+
includeNotes: true,
|
|
242
|
+
})
|
|
243
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '')
|
|
244
|
+
const backup = target.replace(/\.pptx$/i, `.bak-${stamp}.pptx`)
|
|
245
|
+
if (fs.existsSync(target)) fs.copyFileSync(target, backup)
|
|
246
|
+
fs.writeFileSync(target, buffer)
|
|
247
|
+
send(req, res, 200, { ok: true, path: target, backup, bytes: buffer.length, stats })
|
|
248
|
+
} catch (error) {
|
|
249
|
+
send(req, res, 500, { error: '保存回原文件失败(可能被 PowerPoint 占用):' + String(error.message || error) })
|
|
250
|
+
} finally {
|
|
251
|
+
saving = false
|
|
252
|
+
}
|
|
253
|
+
return true
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** MCP 端点路由:协议细节由 server/mcp/handler 处理。 */
|
|
2
|
+
import { send } from '../http-utils.mjs'
|
|
3
|
+
|
|
4
|
+
export function createMcpRoutes({ mcpHandler, getMcpHandler } = {}) {
|
|
5
|
+
return [
|
|
6
|
+
async (req, res, u) => {
|
|
7
|
+
if (u.pathname !== '/mcp') return false
|
|
8
|
+
const handler = typeof getMcpHandler === 'function' ? getMcpHandler() : mcpHandler
|
|
9
|
+
if (!handler) {
|
|
10
|
+
send(req, res, 503, { ok: false, error: 'MCP tools not ready' })
|
|
11
|
+
return true
|
|
12
|
+
}
|
|
13
|
+
await handler(req, res)
|
|
14
|
+
return true
|
|
15
|
+
},
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** 崩溃恢复:通用 replay 由 DocumentSession.recover 提供,本文件只注入 PPT 的 VIEW_OPS。 */
|
|
2
|
+
import { VIEW_OPS } from '../constants.mjs'
|
|
3
|
+
|
|
4
|
+
export async function replayPending({ store, jsonP, applyOp }) {
|
|
5
|
+
return store.recover({
|
|
6
|
+
apply: (cmd) => applyOp(jsonP, cmd),
|
|
7
|
+
isViewOp: (cmd) => VIEW_OPS.has(cmd.op),
|
|
8
|
+
})
|
|
9
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** 兼容层:长轮询的通用实现位于 @capyclaw/document-workbench/session。 */
|
|
2
|
+
import { watchSession } from '@capyclaw/document-workbench/session'
|
|
3
|
+
import { getTuning } from '../tuning.mjs'
|
|
4
|
+
|
|
5
|
+
export function watchDoc(store, seq0, shot0, ui0, timeoutS, aborted) {
|
|
6
|
+
const tuning = getTuning()
|
|
7
|
+
return watchSession(store, {
|
|
8
|
+
seq: seq0,
|
|
9
|
+
shot: shot0,
|
|
10
|
+
ui: ui0,
|
|
11
|
+
timeoutS,
|
|
12
|
+
isAborted: aborted,
|
|
13
|
+
pollMs: tuning.watchPollMs,
|
|
14
|
+
timeoutSec: tuning.watchTimeoutSec,
|
|
15
|
+
maxTimeoutSec: tuning.watchTimeoutMaxSec,
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.1.0",
|
|
3
|
+
"packages": {
|
|
4
|
+
"service-host": {
|
|
5
|
+
"package": "dsh-service-host",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"apiVersion": "1.0.0",
|
|
8
|
+
"sha256": "232b9140d1ba60435999df02a743d944b2b9c81bc5d16dafd3ebc7d24437d7bf"
|
|
9
|
+
},
|
|
10
|
+
"tool-host": {
|
|
11
|
+
"package": "dsh-tool-host",
|
|
12
|
+
"version": "0.1.0",
|
|
13
|
+
"apiVersion": "1.0.0",
|
|
14
|
+
"sha256": "4c8b34977fba79facb498807b844d65993b56267db945e8d157c11e93dd78a48"
|
|
15
|
+
},
|
|
16
|
+
"state-service": {
|
|
17
|
+
"package": "dsh-state-service",
|
|
18
|
+
"version": "0.1.0",
|
|
19
|
+
"apiVersion": "1.0.0",
|
|
20
|
+
"sha256": "725607c3ede320e4fb8cedc3068bfe1bdb0eb68ce65f8d071e95259195161bc4"
|
|
21
|
+
},
|
|
22
|
+
"asset-service": {
|
|
23
|
+
"package": "dsh-asset-service",
|
|
24
|
+
"version": "0.1.0",
|
|
25
|
+
"apiVersion": "1.0.0",
|
|
26
|
+
"sha256": "2bb1dd432188e518adedd0faf8caa1f611aa7c3830fc030cf4733541762a8bf5"
|
|
27
|
+
},
|
|
28
|
+
"editor-host": {
|
|
29
|
+
"package": "dsh-editor-host",
|
|
30
|
+
"version": "0.1.0",
|
|
31
|
+
"apiVersion": "1.0.0",
|
|
32
|
+
"sha256": "a2cdda07cdb61ee258dd20fffd489d93fbf88e891cd2c5f5d9f434544ca8cfea"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"kit": {
|
|
36
|
+
"tuning.mjs": "c7f3feaf2da1e8ab9e2e47ca1f12a6796cc01e0be46c431031fcedfa2336e870",
|
|
37
|
+
"service/api.mjs": "94fa4ced06ec0abe56071abe7cde0316765508995376b323b2734214e2520762",
|
|
38
|
+
"service/http.mjs": "a80346c6fabf893cecff89507a3575b6f342a4db77a375013f1f03f1f1439af3",
|
|
39
|
+
"service/mcp-http.mjs": "a45fa4ab71e5ee385203fe33893c76e1bc34807d9b1f251bb8df4bd6ee86f444",
|
|
40
|
+
"tools/validate.mjs": "ed7bc55d4fcbab406f883d54f587e44cf24f06be6e75456bdcbdd1b2cf9739be",
|
|
41
|
+
"editor/lease.mjs": "5c4e9cb37079b6fd8541deb208a780e144f394befafdb027fb589dc715ef3374",
|
|
42
|
+
"util/fs-lock.mjs": "0eb754f067466ccf90505242e80e4a4de248db5aaa7040e825efa66da386105a"
|
|
43
|
+
},
|
|
44
|
+
"pkgRoot": {
|
|
45
|
+
"dsh-service-host/tuning.mjs": "51d4145d8a94b0adea989924fd65042fc372e343a47fdceeac97d418e9766347"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-asset-service —— 通用对象资产仓库。
|
|
3
|
+
*
|
|
4
|
+
* 功能插件不再各自实现 `/assets/<sha256>`、base64 上传、content-type 与缓存头。
|
|
5
|
+
* 资产按 sha256 去重,url 为 `/assets/<id>`。
|
|
6
|
+
*
|
|
7
|
+
* 两种后端:
|
|
8
|
+
* - workspace:storeClient(ctx.workspace 的 scoped client),资产归当前 work;
|
|
9
|
+
* - file:本地目录,适合无 workspace 的独立运行/测试。
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { send, readBody } from './kit/service/http.mjs'
|
|
15
|
+
import { createServiceApi } from './kit/service/api.mjs'
|
|
16
|
+
|
|
17
|
+
function sha256(buffer) {
|
|
18
|
+
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ASSET_ID_RE = /^[a-f0-9]{64}$/
|
|
22
|
+
|
|
23
|
+
function validId(id) {
|
|
24
|
+
return ASSET_ID_RE.test(String(id || ''))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeBuffer(value) {
|
|
28
|
+
if (Buffer.isBuffer(value)) return value
|
|
29
|
+
if (value instanceof Uint8Array) return Buffer.from(value)
|
|
30
|
+
throw new Error('asset: 数据必须是 Buffer/Uint8Array')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 基于 ctx.workspace 的 scoped WorkspaceClient。 */
|
|
34
|
+
export function createWorkspaceAssetStore({ storeClient, workId = 'main' } = {}) {
|
|
35
|
+
if (!storeClient || typeof storeClient.put !== 'function') throw new Error('createWorkspaceAssetStore 需要 storeClient')
|
|
36
|
+
return {
|
|
37
|
+
kind: 'workspace',
|
|
38
|
+
async put(value, contentType = 'application/octet-stream') {
|
|
39
|
+
const buffer = normalizeBuffer(value)
|
|
40
|
+
const id = sha256(buffer)
|
|
41
|
+
const key = `assets/${id}`
|
|
42
|
+
storeClient.put(workId, key, buffer, { contentType })
|
|
43
|
+
return { id, key, url: `/assets/${id}`, size: buffer.length, contentType }
|
|
44
|
+
},
|
|
45
|
+
get(id) {
|
|
46
|
+
if (!validId(id)) return null
|
|
47
|
+
const key = `assets/${id}`
|
|
48
|
+
try {
|
|
49
|
+
const data = storeClient.get(workId, key)
|
|
50
|
+
if (!data) return null
|
|
51
|
+
const meta = storeClient.list(workId).find((item) => item.key === key)
|
|
52
|
+
return { data, contentType: meta?.contentType || 'application/octet-stream' }
|
|
53
|
+
} catch {
|
|
54
|
+
/* 缺失/权限错误按 404 处理,不能把 500 泄漏给资产路由。 */
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
has(id) {
|
|
59
|
+
if (!validId(id)) return false
|
|
60
|
+
try { return !!storeClient.get(workId, `assets/${id}`) } catch { return false }
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 本地目录后端:<dir>/<sha256> + <dir>/<sha256>.meta.json。 */
|
|
66
|
+
export function createFileAssetStore({ dir } = {}) {
|
|
67
|
+
if (!dir) throw new Error('createFileAssetStore 需要 dir')
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
69
|
+
return {
|
|
70
|
+
kind: 'file',
|
|
71
|
+
async put(value, contentType = 'application/octet-stream') {
|
|
72
|
+
const buffer = normalizeBuffer(value)
|
|
73
|
+
const id = sha256(buffer)
|
|
74
|
+
const file = path.join(dir, id)
|
|
75
|
+
const metaFile = file + '.meta.json'
|
|
76
|
+
const existingOk = (() => {
|
|
77
|
+
try { return fs.statSync(file).size === buffer.length } catch { return false }
|
|
78
|
+
})()
|
|
79
|
+
if (!existingOk) {
|
|
80
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
81
|
+
fs.writeFileSync(tmp, buffer)
|
|
82
|
+
fs.renameSync(tmp, file)
|
|
83
|
+
}
|
|
84
|
+
const metaTmp = `${metaFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
85
|
+
fs.writeFileSync(metaTmp, JSON.stringify({ contentType, size: buffer.length }))
|
|
86
|
+
fs.renameSync(metaTmp, metaFile)
|
|
87
|
+
return { id, key: id, url: `/assets/${id}`, size: buffer.length, contentType }
|
|
88
|
+
},
|
|
89
|
+
get(id) {
|
|
90
|
+
if (!validId(id)) return null
|
|
91
|
+
const file = path.join(dir, path.basename(String(id)))
|
|
92
|
+
try {
|
|
93
|
+
const data = fs.readFileSync(file)
|
|
94
|
+
/* 内容寻址仓库必须复核 hash:截断/篡改的对象不能静默返回。 */
|
|
95
|
+
if (sha256(data) !== id) {
|
|
96
|
+
console.error(`[asset-service] 对象校验失败,拒绝返回: ${id}`)
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
let contentType = 'application/octet-stream'
|
|
100
|
+
try { contentType = JSON.parse(fs.readFileSync(file + '.meta.json', 'utf8')).contentType || contentType } catch { /* 默认 */ }
|
|
101
|
+
return { data, contentType }
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
has(id) {
|
|
107
|
+
if (!validId(id)) return false
|
|
108
|
+
try { return fs.existsSync(path.join(dir, path.basename(String(id)))) } catch { return false }
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 防止 text/html / svg 在 loopback 同源执行脚本;图片仍按原 MIME 内联。 */
|
|
114
|
+
function safeContentType(type) {
|
|
115
|
+
const base = String(type || '').split(';')[0].trim().toLowerCase() || 'application/octet-stream'
|
|
116
|
+
if (base === 'text/html' || base === 'application/xhtml+xml' || base === 'image/svg+xml') {
|
|
117
|
+
return 'text/plain; charset=utf-8'
|
|
118
|
+
}
|
|
119
|
+
return base
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* service-host 兼容的资产路由。
|
|
124
|
+
* GET/HEAD `/assets/<id>`(同时兼容 `<basePath>/assets/<id>`);
|
|
125
|
+
* POST `/assets` 或 `<basePath>/assets`(JSON: { data: base64, contentType })。
|
|
126
|
+
*/
|
|
127
|
+
export function createAssetRoutes({ assetStore, basePath = '', maxUploadBytes = 10 * 1024 * 1024 } = {}) {
|
|
128
|
+
const prefix = basePath === '/' ? '' : String(basePath || '').replace(/\/+$/, '')
|
|
129
|
+
const getPrefixes = ['/assets/', prefix ? `${prefix}/assets/` : null].filter(Boolean)
|
|
130
|
+
const postPaths = new Set(['/assets', prefix ? `${prefix}/assets` : null].filter(Boolean))
|
|
131
|
+
return async function assetRoutes(req, res, u) {
|
|
132
|
+
const p = u.pathname
|
|
133
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
134
|
+
const getPrefix = getPrefixes.find((candidate) => p.startsWith(candidate))
|
|
135
|
+
if (getPrefix) {
|
|
136
|
+
if (!assetStore) { send(req, res, 404, { error: 'asset store unavailable' }); return true }
|
|
137
|
+
const id = decodeURIComponent(p.slice(getPrefix.length))
|
|
138
|
+
if (!validId(id)) { send(req, res, 404, { error: 'asset not found' }); return true }
|
|
139
|
+
const asset = assetStore.get(id)
|
|
140
|
+
if (!asset) { send(req, res, 404, { error: 'asset not found' }); return true }
|
|
141
|
+
res.writeHead(200, {
|
|
142
|
+
'content-type': safeContentType(asset.contentType),
|
|
143
|
+
'content-length': asset.data.length,
|
|
144
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
145
|
+
'x-content-type-options': 'nosniff',
|
|
146
|
+
'referrer-policy': 'no-referrer',
|
|
147
|
+
})
|
|
148
|
+
res.end(req.method === 'HEAD' ? undefined : asset.data)
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (postPaths.has(p) && req.method === 'POST') {
|
|
153
|
+
if (!assetStore) { send(req, res, 503, { error: 'asset store unavailable' }); return true }
|
|
154
|
+
let body
|
|
155
|
+
try { body = await readBody(req, maxUploadBytes) } catch (error) {
|
|
156
|
+
const status = error?.code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
157
|
+
send(req, res, status, { error: String(error.message || error) })
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
if (!body || typeof body.data !== 'string' || !body.data) {
|
|
161
|
+
send(req, res, 400, { error: '缺少图片数据' })
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
const buffer = Buffer.from(body.data, 'base64')
|
|
165
|
+
if (!buffer.length) { send(req, res, 400, { error: '图片数据为空' }); return true }
|
|
166
|
+
const asset = await assetStore.put(buffer, body.contentType || 'application/octet-stream')
|
|
167
|
+
send(req, res, 200, { ok: true, ...asset })
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createAssetHost(config = {}) {
|
|
175
|
+
const defaultStore = config.storeClient
|
|
176
|
+
? createWorkspaceAssetStore({ storeClient: config.storeClient, workId: config.workId })
|
|
177
|
+
: config.dir
|
|
178
|
+
? createFileAssetStore({ dir: config.dir })
|
|
179
|
+
: null
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
api: createServiceApi('assets', '1.0.0', ['workspace-store', 'file-store', 'routes', 'sha256', 'mount']),
|
|
183
|
+
version: '0.1.0',
|
|
184
|
+
createStore: createWorkspaceAssetStore,
|
|
185
|
+
createFileStore: createFileAssetStore,
|
|
186
|
+
routes: createAssetRoutes,
|
|
187
|
+
store: defaultStore,
|
|
188
|
+
/**
|
|
189
|
+
* 每个 service-host app 只挂一次资产路由,避免多个插件重复争抢 /assets。
|
|
190
|
+
* @param {object} hostApp createServiceApp() 返回值
|
|
191
|
+
* @param {{ store?: object, basePath?: string }} options
|
|
192
|
+
*/
|
|
193
|
+
mount(hostApp, { store = defaultStore, basePath = '' } = {}) {
|
|
194
|
+
if (!hostApp || typeof hostApp.use !== 'function') throw new Error('assets.mount 需要 service-host app')
|
|
195
|
+
if (!store) throw new Error('assets.mount: 没有可用 store')
|
|
196
|
+
if (hostApp.__dshAssetsMounted) return () => {}
|
|
197
|
+
hostApp.__dshAssetsMounted = true
|
|
198
|
+
const unuse = hostApp.use(createAssetRoutes({ assetStore: store, basePath }))
|
|
199
|
+
let disposed = false
|
|
200
|
+
return () => {
|
|
201
|
+
if (disposed) return
|
|
202
|
+
disposed = true
|
|
203
|
+
/* 真正卸载路由;否则热重载后旧 store 仍会抢答 /assets。 */
|
|
204
|
+
if (typeof unuse === 'function') unuse()
|
|
205
|
+
hostApp.__dshAssetsMounted = false
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
async put(value, contentType) {
|
|
209
|
+
if (!defaultStore) throw new Error('asset-service: 未配置 storeClient/dir')
|
|
210
|
+
return defaultStore.put(value, contentType)
|
|
211
|
+
},
|
|
212
|
+
get(id) {
|
|
213
|
+
if (!defaultStore) throw new Error('asset-service: 未配置 storeClient/dir')
|
|
214
|
+
return defaultStore.get(id)
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|