@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
package/lib/host.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 【已退役 2026-11-01 服务解耦标准】本文件不再作为 cordis 入口使用。
|
|
3
|
+
*
|
|
4
|
+
* 旧设计:本模块被 insert 进 dsh 进程,在 apply 里进程内拉起桥 relay——
|
|
5
|
+
* 违反「服务彼此独立、跨端口拉起交给中间件」原则,已废弃。
|
|
6
|
+
*
|
|
7
|
+
* 新形态:relay 是独立端口服务(server/relay.mjs 即 standalone 入口,
|
|
8
|
+
* dsh-service-host 骨架:/healthz 带 pid、单实例锁、优雅关闭),由
|
|
9
|
+
* dsh-service-broker 按清单以独立进程拉起(服务规格见包根
|
|
10
|
+
* cordis.patch.yml 注释)。本文件仅为过渡期兼容保留,勿再 insert。
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* dsh-ppt-workbench 宿主插件(cordis 入口,loader 经包名加载本模块)。
|
|
14
|
+
*
|
|
15
|
+
* apply 里进程内拉起桥 relay(桥端点 + /mcp + 编辑器静态页),返回
|
|
16
|
+
* disposer——插件停用时关闭 relay(官方 remote-access 等同款生命周期)。
|
|
17
|
+
* 零 @deepseek-ai 依赖:只用 node 内置模块,最小化跨版本耦合。
|
|
18
|
+
*/
|
|
19
|
+
import os from 'node:os'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { runtimeContextFromConfig, createRuntimeStoreClient } from '../server/infra/runtime/context.mjs'
|
|
23
|
+
import { WorkspaceBackend } from '../server/infra/workspace/manager.mjs'
|
|
24
|
+
import { createWorkspaceAssetStore } from '../server/shared/asset-service.mjs'
|
|
25
|
+
import { assertServiceApi } from '../server/shared/kit/service/api.mjs'
|
|
26
|
+
|
|
27
|
+
export const name = 'ppt-workbench-host'
|
|
28
|
+
|
|
29
|
+
function safeGet(ctx, serviceName) {
|
|
30
|
+
try { return ctx?.get ? ctx.get(serviceName) : null } catch { return null }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function requireApi(service, name) {
|
|
34
|
+
if (service) assertServiceApi(service, { name, major: 1 })
|
|
35
|
+
return service
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function apply(ctx, config) {
|
|
39
|
+
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
40
|
+
const { startRelay, DEFAULT_PORT } = await import('../server/relay.mjs')
|
|
41
|
+
const runtime = runtimeContextFromConfig(config || {})
|
|
42
|
+
const workId = (config && config.workId) || runtime?.workId || 'main'
|
|
43
|
+
/* 如果同进程挂载了 workspace-service,优先使用它提供的 scoped client;
|
|
44
|
+
* 没有时保留 runtime context fallback,保证独立运行。 */
|
|
45
|
+
const workspaceService = safeGet(ctx, 'workspace')
|
|
46
|
+
const storeClient = workspaceService?.getWork
|
|
47
|
+
? workspaceService.getWork(workId)
|
|
48
|
+
: (runtime ? createRuntimeStoreClient(runtime, { workId }) : null)
|
|
49
|
+
const journalPath = runtime ? path.join(runtime.paths.logs, 'ppt-journal.jsonl') : null
|
|
50
|
+
const backend = storeClient ? new WorkspaceBackend({ client: storeClient, workId, journalPath }) : null
|
|
51
|
+
const hostDir = (config && config.hostDir)
|
|
52
|
+
|| runtime?.paths?.contentState
|
|
53
|
+
|| process.env.PPT_BRIDGE_HOME
|
|
54
|
+
|| path.join(os.homedir(), '.dsh-ppt-bridge')
|
|
55
|
+
/* 公共插件 API 契约:存在则校验 major,避免静默不兼容。 */
|
|
56
|
+
const serviceHost = requireApi(safeGet(ctx, 'serviceHost'), 'serviceHost')
|
|
57
|
+
const toolHost = requireApi(safeGet(ctx, 'toolHost'), 'toolHost')
|
|
58
|
+
const editorHost = requireApi(safeGet(ctx, 'editorHost'), 'editorHost')
|
|
59
|
+
/* 图片等二进制资源只进对象仓库;deck 里只保存 /assets/<sha256> 引用。 */
|
|
60
|
+
const assetService = requireApi(safeGet(ctx, 'assets'), 'assets')
|
|
61
|
+
const assetStore = storeClient
|
|
62
|
+
? (assetService?.createStore
|
|
63
|
+
? assetService.createStore({ storeClient, workId })
|
|
64
|
+
: createWorkspaceAssetStore({ storeClient, workId }))
|
|
65
|
+
: (assetService?.createFileStore
|
|
66
|
+
? assetService.createFileStore({ dir: path.join(hostDir, 'assets') })
|
|
67
|
+
: null)
|
|
68
|
+
const port = (config && Number(config.port))
|
|
69
|
+
|| runtime?.ports?.content
|
|
70
|
+
|| DEFAULT_PORT
|
|
71
|
+
const relay = await startRelay({
|
|
72
|
+
packageDir,
|
|
73
|
+
port,
|
|
74
|
+
hostDir,
|
|
75
|
+
strictPort: config?.strictPort != null ? !!config.strictPort : !!runtime,
|
|
76
|
+
allowReuse: config?.allowReuse != null ? !!config.allowReuse : !runtime,
|
|
77
|
+
lockFile: (config && config.lockFile) || path.join(hostDir, '.relay-instance.lock'),
|
|
78
|
+
backend,
|
|
79
|
+
assetStore,
|
|
80
|
+
assetService,
|
|
81
|
+
workId,
|
|
82
|
+
capabilities: config?.capabilities || null,
|
|
83
|
+
serviceHost,
|
|
84
|
+
toolHost,
|
|
85
|
+
editorHost,
|
|
86
|
+
})
|
|
87
|
+
const line = relay.reused
|
|
88
|
+
? `[ppt-workbench] 复用已运行的桥: ${relay.url}`
|
|
89
|
+
: `[ppt-workbench] 桥已就绪: ${relay.url}`
|
|
90
|
+
console.log(line)
|
|
91
|
+
console.log(`[ppt-workbench] 编辑器: ${relay.editorUrl} (浏览器打开即用)`)
|
|
92
|
+
if (runtime) console.log(`[ppt-workbench] runtime=${runtime.runtimeId} work=${workId} namespace=${runtime.store?.namespace || ''}`)
|
|
93
|
+
if (relay.reused) console.log('[ppt-workbench] 注意:实际端口可能不是配置端口,/mcp 以当前 runtime 的 profile 注入为准')
|
|
94
|
+
return async () => {
|
|
95
|
+
await relay.stop()
|
|
96
|
+
console.log('[ppt-workbench] 桥已停止')
|
|
97
|
+
}
|
|
98
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@capyclaw/ppt-workbench",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@capyclaw/ppt-workbench",
|
|
3
|
+
"version": "2.4.1",
|
|
4
|
+
"description": "PPT 工作台运行时:27 个 ppt_* MCP 工具(导入/导出 PPTX + 图表/表格/公式/形状/线条/备注 + 契约探测)+ 本机实时 PPTist 编辑器。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/host.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/host.mjs",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"dsh": {
|
|
12
|
+
"bundle": {
|
|
13
|
+
"patch": "./cordis.patch.yml"
|
|
14
|
+
},
|
|
15
|
+
"capabilities": {
|
|
16
|
+
"source": "server/tools/capabilities",
|
|
17
|
+
"manifest": "capabilities.json"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"meta": {
|
|
21
|
+
"title": "PPT 工作台运行时",
|
|
22
|
+
"description": "AI 生成/编辑 PPT:27 个 ppt_* 工具 + 浏览器实时编辑器(http://127.0.0.1:39311/)"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"lib/",
|
|
26
|
+
"server/",
|
|
27
|
+
"core/",
|
|
28
|
+
"!server/.relay-instance.lock",
|
|
29
|
+
"editor/",
|
|
30
|
+
"contract/",
|
|
31
|
+
"cordis.patch.yml",
|
|
32
|
+
"README.md",
|
|
33
|
+
"THIRD_PARTY_NOTICES.md"
|
|
34
|
+
],
|
|
35
|
+
"license": "AGPL-3.0-or-later",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"pptxgenjs": "^3.12.0",
|
|
38
|
+
"@capyclaw/document-workbench": "^0.1.0"
|
|
39
|
+
},
|
|
40
|
+
"author": {
|
|
41
|
+
"name": "CapyClaw",
|
|
42
|
+
"url": "https://github.com/capyclaw",
|
|
43
|
+
"email": "support@capyclaw.dev"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "https://github.com/capyclaw/dsh-plugins"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/capyclaw/dsh-plugins/issues"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/capyclaw/dsh-plugins"
|
|
53
|
+
}
|
package/server/audit.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/** 桥 HTTP 客户端(MCP 侧):keep-alive 连接复用 + 指令推送/回执 + deck 摘要。 */
|
|
2
|
+
import nodeHttp from 'node:http'
|
|
3
|
+
import { textHolder, htmlOf } from '../core/text.js'
|
|
4
|
+
import { getTuning } from './tuning.mjs'
|
|
5
|
+
|
|
6
|
+
export const BASE = (process.env.PPT_BRIDGE_URL || 'http://127.0.0.1:3311/api/desktop/works/ppt-bridge').replace(/\/+$/, '')
|
|
7
|
+
|
|
8
|
+
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
9
|
+
|
|
10
|
+
/* keep-alive 连接池:同 host 复用 TCP,省去每次 handshake;
|
|
11
|
+
* server 关闭时 agent 自动重连,不会积压僵尸连接。 */
|
|
12
|
+
const keepAliveAgent = new nodeHttp.Agent({ keepAlive: true, maxSockets: 8 })
|
|
13
|
+
|
|
14
|
+
export const getDeck = () => http('GET', BASE)
|
|
15
|
+
export const get = (path) => http('GET', BASE + path)
|
|
16
|
+
export const post = (path, body, options) => http('POST', BASE + path, body, options)
|
|
17
|
+
|
|
18
|
+
/** 轻量读取(2026-10-22):轮询只取状态,不拉 deck / 截图 base64。
|
|
19
|
+
* 旧实现每次 push 回执轮询都拉完整文档(含上一张截图 2-4MB),
|
|
20
|
+
* 一次工具调用最多轮询 120 次 ≈ 数百 MB 本地传输 + JSON 解析。 */
|
|
21
|
+
export const getStatus = () => http('GET', BASE + '/status', undefined, { timeoutMs: 5000 })
|
|
22
|
+
export const getDeckOnly = async () => {
|
|
23
|
+
let d = await http('GET', BASE + '/deck', undefined, { timeoutMs: 15000 })
|
|
24
|
+
/* 新编辑器先回执后镜像:等镜像追上本次指令(旧编辑器 state_seq=0,直接返回) */
|
|
25
|
+
const deadline = Date.now() + 2000
|
|
26
|
+
while (d.state_seq > 0 && d.state_seq < (d.applied_seq || 0) && Date.now() < deadline) {
|
|
27
|
+
await sleep(getTuning().statusPollMs || 100)
|
|
28
|
+
d = await http('GET', BASE + '/deck', undefined, { timeoutMs: 15000 })
|
|
29
|
+
}
|
|
30
|
+
return d
|
|
31
|
+
}
|
|
32
|
+
export const getShot = () => http('GET', BASE + '/shot', undefined, { timeoutMs: 30000 })
|
|
33
|
+
|
|
34
|
+
/** 把图片写入 relay 的对象仓库,返回 /assets/<sha256> 引用。 */
|
|
35
|
+
export const putAsset = (bytes, contentType = 'application/octet-stream') =>
|
|
36
|
+
post('/assets', { data: Buffer.from(bytes).toString('base64'), contentType }, { timeoutMs: 30000 })
|
|
37
|
+
|
|
38
|
+
/** 上传待导入的 PPTX(原始字节,不走 base64),返回临时下载地址。 */
|
|
39
|
+
export const uploadImport = (bytes, name = 'import.pptx', sourcePath = '') =>
|
|
40
|
+
http('POST', BASE + '/import-file', undefined, {
|
|
41
|
+
body: Buffer.from(bytes),
|
|
42
|
+
headers: {
|
|
43
|
+
'content-type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
44
|
+
'x-file-name': encodeURIComponent(String(name || 'import.pptx')),
|
|
45
|
+
...(sourcePath ? { 'x-source-path': encodeURIComponent(String(sourcePath)) } : {}),
|
|
46
|
+
},
|
|
47
|
+
timeoutMs: 120000,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** 读取编辑器回传的导入结果;done=false 表示仍在处理。 */
|
|
51
|
+
export const getImportResult = (req) => get('/import-result?req=' + encodeURIComponent(String(req || '')))
|
|
52
|
+
|
|
53
|
+
/** 本机回环请求:每条独立连接(agent:false)。
|
|
54
|
+
* 2026-10-21 实测坑:fetch 连接池会在网关重启后复用僵尸 keep-alive
|
|
55
|
+
* 连接,服务端把它当坏请求回 501。
|
|
56
|
+
* 2026-10-31:补 connect/response 超时与响应体上限;桥挂起时不再永久卡住工具。 */
|
|
57
|
+
function http(method, url, body, options = {}) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const u = new URL(url)
|
|
60
|
+
const raw = Buffer.isBuffer(options.body) ? options.body : null
|
|
61
|
+
const payload = raw || (body ? Buffer.from(JSON.stringify(body)) : null)
|
|
62
|
+
const headers = { ...(options.headers || {}) }
|
|
63
|
+
if (payload) {
|
|
64
|
+
if (!raw && !headers['content-type']) headers['content-type'] = 'application/json'
|
|
65
|
+
headers['content-length'] = payload.length
|
|
66
|
+
}
|
|
67
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || Number(getTuning().httpRequestTimeoutMs) || 15000)
|
|
68
|
+
let settled = false
|
|
69
|
+
const finish = (fn, value) => {
|
|
70
|
+
if (settled) return
|
|
71
|
+
settled = true
|
|
72
|
+
fn(value)
|
|
73
|
+
}
|
|
74
|
+
const req = nodeHttp.request({
|
|
75
|
+
hostname: u.hostname, port: u.port || 80,
|
|
76
|
+
path: u.pathname + u.search, method, headers, agent: keepAliveAgent,
|
|
77
|
+
}, (res) => {
|
|
78
|
+
let data = ''
|
|
79
|
+
let size = 0
|
|
80
|
+
res.setEncoding('utf8')
|
|
81
|
+
res.on('data', (chunk) => {
|
|
82
|
+
size += Buffer.byteLength(chunk)
|
|
83
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
84
|
+
req.destroy(new Error(`bridge 响应超过 ${Math.round(MAX_RESPONSE_BYTES / 1024 / 1024)}MB 上限`))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
data += chunk
|
|
88
|
+
})
|
|
89
|
+
res.on('error', (error) => finish(reject, error))
|
|
90
|
+
res.on('end', () => {
|
|
91
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
92
|
+
return finish(reject, new Error(`bridge ${method} ${url} -> HTTP ${res.statusCode} ${data.slice(0, 160)}`))
|
|
93
|
+
}
|
|
94
|
+
try { finish(resolve, JSON.parse(data)) }
|
|
95
|
+
catch { finish(reject, new Error('bridge 返回非 JSON: ' + data.slice(0, 160))) }
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
req.setTimeout(timeoutMs, () => {
|
|
99
|
+
req.destroy(new Error(`bridge ${method} ${url} 超时(${timeoutMs}ms)`))
|
|
100
|
+
})
|
|
101
|
+
req.on('error', (error) => finish(reject, error))
|
|
102
|
+
if (payload) req.write(payload)
|
|
103
|
+
req.end()
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
|
108
|
+
export const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n))
|
|
109
|
+
|
|
110
|
+
/** 编辑器连接必须活跃(lease 有效)才允许推送。
|
|
111
|
+
* 2026-10-22:此前无窗口/租约过期时仍盲等 12~30s,实测造成 20 次
|
|
112
|
+
* 25~40s 的工具超时。现在最多等 graceMs 让新窗口完成 claim,随后立即失败。
|
|
113
|
+
* 2026-10-30:错误信息只说"下一步做什么",不暴露内部租约概念。 */
|
|
114
|
+
export async function requireEditor(graceMs = 4000) {
|
|
115
|
+
const deadline = Date.now() + graceMs
|
|
116
|
+
let st = await getStatus()
|
|
117
|
+
while (true) {
|
|
118
|
+
if (st.owner && Number(st.owner.age || 0) <= 10) return st
|
|
119
|
+
if (Date.now() >= deadline) {
|
|
120
|
+
throw new Error('PPT 编辑器窗口尚未连接:请打开右侧「浏览器」面板访问 http://127.0.0.1:39311 ,页面加载完成后会自动连接,无需其他操作')
|
|
121
|
+
}
|
|
122
|
+
await sleep(getTuning().statusPollMs || 200)
|
|
123
|
+
st = await getStatus()
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function push(cmds, title) {
|
|
128
|
+
if (!Array.isArray(cmds)) throw new Error('没有可执行的指令')
|
|
129
|
+
if (!cmds.length && !title) throw new Error('没有可执行的指令')
|
|
130
|
+
/* 解耦批:写操作在服务端执行,不依赖编辑器页面在线 */
|
|
131
|
+
const doc = await post('', { cmds, title: title || '' }, { timeoutMs: 30000 })
|
|
132
|
+
const seq = doc.seq
|
|
133
|
+
const tuning = getTuning()
|
|
134
|
+
/* 批次越大,服务端执行时间越长;固定 12s 会把合法的 100 条批次误判为超时。 */
|
|
135
|
+
const baseDeadline = Number(tuning.pushBaseDeadlineMs) || 12000
|
|
136
|
+
const perOp = Number(tuning.pushPerOpMs) || 0
|
|
137
|
+
const deadline = Date.now() + Math.min(baseDeadline + cmds.length * perOp, 110000)
|
|
138
|
+
while (Date.now() < deadline) {
|
|
139
|
+
const cur = await getStatus()
|
|
140
|
+
if (cur.applied_seq >= seq) {
|
|
141
|
+
return { seq, applied: cur.applied, error: cur.error || '', ok: !cur.error }
|
|
142
|
+
}
|
|
143
|
+
await sleep(tuning.statusPollMs || 100)
|
|
144
|
+
}
|
|
145
|
+
return { seq, applied: 0, error: '等待编辑器应用超时(右侧面板是否已打开?)', ok: false }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const okText = (r, msg) => ({
|
|
149
|
+
text: r.ok ? `${msg}(seq ${r.seq},${r.applied} 条)` : `应用失败:${r.error}`,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/** 紧凑 deck 摘要;detailed 时附元素几何/颜色。 */
|
|
153
|
+
export function summarize(deck, detailed) {
|
|
154
|
+
if (!deck || !Array.isArray(deck.slides)) return '(桥上没有 deck:右侧编辑器未就绪)'
|
|
155
|
+
const lines = [`标题:${deck.title || '未命名'}`, `共 ${deck.slides.length} 页`]
|
|
156
|
+
deck.slides.forEach((s, i) => {
|
|
157
|
+
const bg = s.background ? (s.background.type === 'solid' ? s.background.color : s.background.type) : '默认'
|
|
158
|
+
lines.push(`[${i}] id=${s.id || '-'} 背景=${bg}`)
|
|
159
|
+
;(s.elements || []).forEach(e => {
|
|
160
|
+
const holder = textHolder(e)
|
|
161
|
+
if (holder) {
|
|
162
|
+
const t = String(htmlOf(e) || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
|
163
|
+
const geom = detailed ? ` (${Math.round(e.left)},${Math.round(e.top)} ${Math.round(e.width)}x${Math.round(e.height)}${e.color ? ' ' + e.color : ''})` : ''
|
|
164
|
+
const label = e.type === 'text' ? '文本' : '形状文本'
|
|
165
|
+
const fill = detailed && e.type === 'shape' ? ` fill=${e.fill || ''}` : ''
|
|
166
|
+
lines.push(` - ${label}#${e.id}${geom}${fill}:${t.slice(0, 80)}`)
|
|
167
|
+
} else {
|
|
168
|
+
const geom = detailed ? ` (${Math.round(e.left)},${Math.round(e.top)} ${Math.round(e.width)}x${Math.round(e.height)}) fill=${e.fill || ''}` : ''
|
|
169
|
+
lines.push(` - ${e.type}#${e.id}${geom}`)
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
return lines.join('\n')
|
|
174
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** 桥的稳定常量(relay/routes/tests 共用,避免循环 import)。 */
|
|
2
|
+
import { VIEW_OP_NAMES } from '../core/ops.js'
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PORT = 39311
|
|
5
|
+
export const BASE_PATH = '/api/desktop/works/ppt-bridge'
|
|
6
|
+
|
|
7
|
+
export const EDITOR_FILES = {
|
|
8
|
+
'/': ['index.html', 'text/html; charset=utf-8'],
|
|
9
|
+
'/index.html': ['index.html', 'text/html; charset=utf-8'],
|
|
10
|
+
/* “打开方式”启动器专用入口:版本化 URL 绕过浏览器对旧 app.js 的强缓存。
|
|
11
|
+
* 升级编辑器构建时同时改 app-v* 文件名与 index.html 里的 script src。 */
|
|
12
|
+
'/open.html': ['index.html', 'text/html; charset=utf-8'],
|
|
13
|
+
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
|
14
|
+
'/app-v20261103.js': ['app.js', 'text/javascript; charset=utf-8'],
|
|
15
|
+
'/heartbeat-worker.js': ['heartbeat-worker.js', 'text/javascript; charset=utf-8'],
|
|
16
|
+
'/ppt-full-app.js': ['ppt-full-app.js', 'text/javascript; charset=utf-8'],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 页面级命令:不改 deck,只交给编辑器执行。由 op registry 的 view 元数据派生。 */
|
|
20
|
+
export const VIEW_OPS = new Set(VIEW_OP_NAMES)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 契约快照校验:workbench 与同目录 skill 包的 contract/ppt-contract.json
|
|
3
|
+
* major 不一致时,MCP 工具调用被拒绝(ppt_capabilities 除外,便于诊断)。
|
|
4
|
+
*
|
|
5
|
+
* 开发态(plugin/)和发布态(components/)里 skill 包都是 workbench 的兄弟目录,
|
|
6
|
+
* 因此无需配置文件;只装 workbench 时 checked=0,不拦截。
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
const SKILL_PREFIX = 'dsh-ppt-skill-'
|
|
12
|
+
|
|
13
|
+
function majorOf(version) {
|
|
14
|
+
return String(version || '').split('.')[0]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function inspectContractSnapshots(packageDir, contractVersion, { prefix = SKILL_PREFIX } = {}) {
|
|
18
|
+
const expectedMajor = majorOf(contractVersion)
|
|
19
|
+
const report = { expected: contractVersion || '', checked: 0, ok: true, mismatched: [] }
|
|
20
|
+
const parent = path.dirname(packageDir)
|
|
21
|
+
|
|
22
|
+
let entries
|
|
23
|
+
try {
|
|
24
|
+
entries = fs.readdirSync(parent, { withFileTypes: true })
|
|
25
|
+
} catch {
|
|
26
|
+
return report
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (!entry.name.startsWith(prefix)) continue
|
|
31
|
+
const dir = path.join(parent, entry.name)
|
|
32
|
+
try {
|
|
33
|
+
/* junction/软链接也要跟随;Dirent.isDirectory() 对链接为 false。 */
|
|
34
|
+
if (!fs.statSync(dir).isDirectory()) continue
|
|
35
|
+
} catch {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
report.checked++
|
|
40
|
+
const file = path.join(dir, 'contract', 'ppt-contract.json')
|
|
41
|
+
let snapshot = null
|
|
42
|
+
try {
|
|
43
|
+
snapshot = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
44
|
+
} catch {
|
|
45
|
+
report.mismatched.push({ package: entry.name, expected: contractVersion, actual: null })
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
const actualMajor = majorOf(snapshot.contractVersion)
|
|
49
|
+
if (actualMajor !== expectedMajor) {
|
|
50
|
+
report.mismatched.push({ package: entry.name, expected: contractVersion, actual: snapshot.contractVersion || null })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
report.ok = report.mismatched.length === 0
|
|
55
|
+
return report
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function describeMismatch(report) {
|
|
59
|
+
return report.mismatched
|
|
60
|
+
.map((item) => `${item.package}(snapshot=${item.actual || 'missing'}, runtime=${item.expected})`)
|
|
61
|
+
.join(';')
|
|
62
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP 工具契约的唯一真源(版本 + 报告格式)。
|
|
3
|
+
*
|
|
4
|
+
* 工具/能力清单由 capabilities → registry 派生;报告结构来自 dsh-tool-host。
|
|
5
|
+
* 新增能力只改 capabilities/index.mjs。
|
|
6
|
+
*/
|
|
7
|
+
import { createCapabilityReport } from './shared/tool-host.mjs'
|
|
8
|
+
|
|
9
|
+
/** 工具契约版本:工具名、参数、返回结构发生不兼容变化时升 major。 */
|
|
10
|
+
export const CONTRACT_VERSION = '2.4.0'
|
|
11
|
+
|
|
12
|
+
/** 工作台包版本。 */
|
|
13
|
+
export const PACKAGE_VERSION = '2.4.0'
|
|
14
|
+
|
|
15
|
+
/** 生成模型可读的能力报告。 */
|
|
16
|
+
export function capabilityReport(args, editorUrl = '', runtime = {}) {
|
|
17
|
+
return createCapabilityReport({
|
|
18
|
+
...args,
|
|
19
|
+
contractVersion: CONTRACT_VERSION,
|
|
20
|
+
packageVersion: PACKAGE_VERSION,
|
|
21
|
+
editorUrl,
|
|
22
|
+
runtime,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** 本机插件 HTTP 传输:安全响应头、body 上限、查询解析、loopback 监听。 */
|
|
2
|
+
import http from 'node:http'
|
|
3
|
+
import { tuning } from './tuning.mjs'
|
|
4
|
+
|
|
5
|
+
export const SECURITY_HEADERS = {
|
|
6
|
+
'x-content-type-options': 'nosniff',
|
|
7
|
+
'referrer-policy': 'no-referrer',
|
|
8
|
+
'cache-control': 'no-store, no-cache, must-revalidate',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 客户端已断开时放弃写入:长请求(截图/长轮询)在 handler 完成前客户端超时
|
|
12
|
+
* 断连是常态,写已销毁的 socket 会触发无人监听的 'error' 事件打崩进程。 */
|
|
13
|
+
function resDead(res) {
|
|
14
|
+
return res.destroyed || res.writableEnded
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function send(req, res, code, obj, extraHeaders = {}) {
|
|
18
|
+
if (resDead(res)) return
|
|
19
|
+
const body = obj === undefined ? '' : JSON.stringify(obj)
|
|
20
|
+
try {
|
|
21
|
+
res.writeHead(code, {
|
|
22
|
+
'content-type': 'application/json',
|
|
23
|
+
'content-length': Buffer.byteLength(body),
|
|
24
|
+
...SECURITY_HEADERS,
|
|
25
|
+
...extraHeaders,
|
|
26
|
+
})
|
|
27
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
28
|
+
} catch { /* socket 已死:响应无人接收,静默放弃 */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sendText(req, res, code, text, contentType = 'text/plain; charset=utf-8', extraHeaders = {}) {
|
|
32
|
+
if (resDead(res)) return
|
|
33
|
+
const body = text === undefined || text === null ? '' : String(text)
|
|
34
|
+
try {
|
|
35
|
+
res.writeHead(code, {
|
|
36
|
+
'content-type': contentType,
|
|
37
|
+
'content-length': Buffer.byteLength(body),
|
|
38
|
+
...SECURITY_HEADERS,
|
|
39
|
+
...extraHeaders,
|
|
40
|
+
})
|
|
41
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
42
|
+
} catch { /* socket 已死:响应无人接收,静默放弃 */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function bodyError(code, message) {
|
|
46
|
+
const error = new Error(message)
|
|
47
|
+
error.code = code
|
|
48
|
+
return error
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readBody(req, limit = 10 * 1024 * 1024) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
let size = 0
|
|
54
|
+
const chunks = []
|
|
55
|
+
let settled = false
|
|
56
|
+
const fail = (error) => {
|
|
57
|
+
if (settled) return
|
|
58
|
+
settled = true
|
|
59
|
+
reject(error)
|
|
60
|
+
}
|
|
61
|
+
req.on('data', (chunk) => {
|
|
62
|
+
if (settled) return
|
|
63
|
+
size += chunk.length
|
|
64
|
+
if (size > limit) {
|
|
65
|
+
/* 先让路由回 413;立即 destroy 会让客户端只看到 connection reset。 */
|
|
66
|
+
fail(bodyError('BODY_TOO_LARGE', 'body too large'))
|
|
67
|
+
req.resume()
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
chunks.push(chunk)
|
|
71
|
+
})
|
|
72
|
+
req.on('end', () => {
|
|
73
|
+
if (settled) return
|
|
74
|
+
settled = true
|
|
75
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
76
|
+
if (!raw) return resolve({})
|
|
77
|
+
try { resolve(JSON.parse(raw)) } catch { reject(bodyError('BAD_JSON', 'bad json')) }
|
|
78
|
+
})
|
|
79
|
+
req.on('error', (error) => fail(error))
|
|
80
|
+
/* 客户端中途断连且未触发 error/aborted 时(如 curl -m 超时),close 兜底,
|
|
81
|
+
否则 promise 永远 pending,handler 泄漏。 */
|
|
82
|
+
req.on('close', () => {
|
|
83
|
+
if (!req.readableEnded) fail(bodyError('CLIENT_ABORTED', 'client aborted'))
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function intQ(url, name) {
|
|
89
|
+
const value = parseInt(url.searchParams.get(name) || '0', 10)
|
|
90
|
+
return Number.isFinite(value) && value >= 0 ? value : 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function listen(port, opts = {}) {
|
|
94
|
+
const defaults = tuning()
|
|
95
|
+
const {
|
|
96
|
+
host = '127.0.0.1',
|
|
97
|
+
requestTimeoutMs = defaults.requestTimeoutMs,
|
|
98
|
+
headersTimeoutMs = defaults.headersTimeoutMs,
|
|
99
|
+
keepAliveTimeoutMs = defaults.keepAliveTimeoutMs,
|
|
100
|
+
handler,
|
|
101
|
+
} = opts
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const server = handler ? http.createServer(handler) : http.createServer()
|
|
104
|
+
server.requestTimeout = requestTimeoutMs
|
|
105
|
+
server.headersTimeout = headersTimeoutMs
|
|
106
|
+
server.keepAliveTimeout = keepAliveTimeoutMs
|
|
107
|
+
server.on('clientError', (error, socket) => {
|
|
108
|
+
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\n\r\n')
|
|
109
|
+
})
|
|
110
|
+
server.on('error', reject)
|
|
111
|
+
server.listen(port, host, () => resolve(server))
|
|
112
|
+
})
|
|
113
|
+
}
|